Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
672c0c1e9f | ||
|
|
436b513431 | ||
|
|
29b0d7741d | ||
|
|
19c3dcf917 | ||
|
|
e5a81185bb | ||
|
|
3a5e2057e0 | ||
|
|
af05248af5 |
16 changed files with 711 additions and 309 deletions
2
Makefile
2
Makefile
|
|
@ -11,7 +11,7 @@ EveCanvas_FILES = \
|
||||||
Sources/EVEViewController.mm \
|
Sources/EVEViewController.mm \
|
||||||
Sources/EVEGLView.mm \
|
Sources/EVEGLView.mm \
|
||||||
Sources/EVEFrameStreamClient.m \
|
Sources/EVEFrameStreamClient.m \
|
||||||
Sources/EVEDashboardClient.m \
|
Sources/EVEHermodrClient.m \
|
||||||
Sources/EVESensorUplinkClient.m \
|
Sources/EVESensorUplinkClient.m \
|
||||||
Sources/EVEH264StreamDecoder.m
|
Sources/EVEH264StreamDecoder.m
|
||||||
|
|
||||||
|
|
|
||||||
57
README.md
57
README.md
|
|
@ -102,8 +102,10 @@ schema or composition builders.
|
||||||
|
|
||||||
## Current Mechanism
|
## Current Mechanism
|
||||||
|
|
||||||
The current checked-in client is the iOS Theos app, still named `EveCanvas` at
|
The current checked-in iOS runtime is the Theos app still named `EveCanvas` at
|
||||||
the bundle level until the wider Eve runtime split exists.
|
the bundle level. Its primary input is Gjallar's aggregate surface through the
|
||||||
|
temporary Starfire Hermodr HTTP boundary; direct typed CultMesh transport can
|
||||||
|
replace that edge later without moving layout authority into the client.
|
||||||
|
|
||||||
The Android proof under `android/` is a small native Kotlin client built with
|
The Android proof under `android/` is a small native Kotlin client built with
|
||||||
the installed Android SDK and CultLib's `cultmesh-kotlin` package, without
|
the installed Android SDK and CultLib's `cultmesh-kotlin` package, without
|
||||||
|
|
@ -115,9 +117,12 @@ payloads as `mimir.eve_media_observation.v1`.
|
||||||
|
|
||||||
- `EVEAppDelegate` creates one fullscreen `UIWindow`.
|
- `EVEAppDelegate` creates one fullscreen `UIWindow`.
|
||||||
- `EVEViewController` installs:
|
- `EVEViewController` installs:
|
||||||
|
- `EVEHermodrClient`, the configured live `gjallar.overview` surface client.
|
||||||
|
- native UIKit lowering for generic Eve trees and Gjallar's
|
||||||
|
`weighted-bisect` aggregate.
|
||||||
- `EVEGLView`, a `CAEAGLLayer` backed OpenGL ES render target.
|
- `EVEGLView`, a `CAEAGLLayer` backed OpenGL ES render target.
|
||||||
- a full-screen `UIImageView` that displays CEF relay frames.
|
- an optional full-screen `UIImageView` and `EVEFrameStreamClient` for legacy
|
||||||
- `EVEFrameStreamClient`, a native WebSocket client for frame/input transport.
|
configured frame streams.
|
||||||
- a native UIKit `UILabel` overlay for crisp Retina status text.
|
- a native UIKit `UILabel` overlay for crisp Retina status text.
|
||||||
- `CADisplayLink` for frame ticking.
|
- `CADisplayLink` for frame ticking.
|
||||||
- `CMMotionManager` for accelerometer and gyro telemetry.
|
- `CMMotionManager` for accelerometer and gyro telemetry.
|
||||||
|
|
@ -126,8 +131,10 @@ payloads as `mimir.eve_media_observation.v1`.
|
||||||
- `EVESensorUplinkClient` opens separate WebSocket uplinks for camera and
|
- `EVESensorUplinkClient` opens separate WebSocket uplinks for camera and
|
||||||
microphone observations so sensor traffic does not block display/control
|
microphone observations so sensor traffic does not block display/control
|
||||||
traffic.
|
traffic.
|
||||||
- `EVEDashboardClient` opens the native Mimir dashboard socket and receives
|
- `EVEHermodrClient` polls Odin-owned Hermodr for Gjallar's canonical
|
||||||
scene/control state snapshots.
|
`gamecult.eve.surface_state.v1` aggregate and hands the decoded surface to
|
||||||
|
UIKit. Hermodr is a transport/lowering boundary; it does not own layout or
|
||||||
|
provider membership.
|
||||||
- `EVEViewController` captures camera frames with AVFoundation and microphone
|
- `EVEViewController` captures camera frames with AVFoundation and microphone
|
||||||
blocks with AVAudioEngine, then sends binary CultMesh `eve-camera` and
|
blocks with AVAudioEngine, then sends binary CultMesh `eve-camera` and
|
||||||
`eve-mic` media observations to Mimir. It also renders the dashboard natively with UIKit: a scene graph,
|
`eve-mic` media observations to Mimir. It also renders the dashboard natively with UIKit: a scene graph,
|
||||||
|
|
@ -150,8 +157,8 @@ payloads as `mimir.eve_media_observation.v1`.
|
||||||
- CultNet carries typed surface state, commands, and timestamped sensor packets.
|
- CultNet carries typed surface state, commands, and timestamped sensor packets.
|
||||||
Edge JSON is tolerated only as an interoperability envelope while the typed
|
Edge JSON is tolerated only as an interoperability envelope while the typed
|
||||||
CultMesh document shape is being proven.
|
CultMesh document shape is being proven.
|
||||||
- UIKit is the current iOS streamed-frame owner; OpenGL ES is fallback/local
|
- UIKit is the current iOS `gjallar.overview` lowering owner. Streamed frames
|
||||||
render.
|
are an optional non-canonical input; OpenGL ES remains fallback/local render.
|
||||||
- The status bar stays hidden.
|
- The status bar stays hidden.
|
||||||
- Eve clients own local sensor reads and timestamps. Mimir owns synchronization
|
- Eve clients own local sensor reads and timestamps. Mimir owns synchronization
|
||||||
and final interpretation after those samples arrive.
|
and final interpretation after those samples arrive.
|
||||||
|
|
@ -236,20 +243,19 @@ Periwinkle sends camera, microphone, motion, and touch observations only when
|
||||||
launched with an explicit `org.gamecult.eve.SENSOR_URI`; the apps no longer
|
launched with an explicit `org.gamecult.eve.SENSOR_URI`; the apps no longer
|
||||||
guess receiver URLs.
|
guess receiver URLs.
|
||||||
|
|
||||||
## Native Mimir Dashboard
|
## Native Gjallar Runtime
|
||||||
|
|
||||||
Start the dashboard authority on Starfire:
|
EveCanvas is the native iOS lowering of Gjallar's aggregate Eve surface. Gjallar
|
||||||
|
owns visible surface membership, order, and `weighted-bisect` layout metadata;
|
||||||
|
EveCanvas owns UIKit views, touch handling, reconnect behavior, and visible
|
||||||
|
transport failure. Odin-owned Hermodr exposes the typed CultMesh record at the
|
||||||
|
iOS HTTP boundary.
|
||||||
|
|
||||||
```powershell
|
Configure one or more Hermodr base URLs through the `EVE_HERMODR_URLS` array in
|
||||||
dotnet run --project E:\Projects\Mimir\src\Mimir.EveDashboard\Mimir.EveDashboard.csproj -- --port 8795
|
`org.gamecult.evecanvas` preferences. EveCanvas reads
|
||||||
```
|
`gjallar.overview` from `/hermodr/surface/gjallar.overview` and does not guess a
|
||||||
|
LAN address. It sends supported interactions back through
|
||||||
EveCanvas connects to the Mimir dashboard WebSocket lowering for native retained
|
`/hermodr/commands/eve`:
|
||||||
`dashboard-state` snapshots. That lowering renders the daemon-owned CultMesh
|
|
||||||
dashboard state; it is not provider discovery authority. Live provider discovery
|
|
||||||
belongs to Odin/CultMesh advertisements. Native iOS dashboard lowerings must be
|
|
||||||
configured through `EVE_DASHBOARD_URLS`; missing discovery stays visible instead
|
|
||||||
of falling back to LAN constants. EveCanvas sends compact commands back:
|
|
||||||
|
|
||||||
- `select`
|
- `select`
|
||||||
- `move`
|
- `move`
|
||||||
|
|
@ -347,14 +353,13 @@ without losing the basic app deployment path.
|
||||||
|
|
||||||
## Next Cut
|
## Next Cut
|
||||||
|
|
||||||
- Connect the existing browser reference to a clean-consumer, networked
|
- Admit live provider-owned Eve surfaces into Odin/CultMesh so Gjallar's
|
||||||
CultMesh sample and use it as the visual/behavior oracle for native clients.
|
aggregate has real children to tile; advertisements without readable surface
|
||||||
- Split the shared CultNet/CultMesh surface contract from the iOS app code and
|
state remain catalog entries, not invented UI.
|
||||||
keep `gamecult.eve.surface.v1` as the renderer-facing contract.
|
- Add native command receipts for Gjallar tiles and prove touch round trips
|
||||||
|
against provider-owned command surfaces.
|
||||||
- Replace JSON/base64 sensor packets with binary framing once camera and mic
|
- Replace JSON/base64 sensor packets with binary framing once camera and mic
|
||||||
timing are proven through Mimir.
|
timing are proven through Mimir.
|
||||||
- Replace the dashboard fixture state with live `MimirPresentationControlState`
|
|
||||||
and `MimirSceneEditorState` snapshots.
|
|
||||||
- Expand the Android Kotlin client from dashboard node rendering into the full
|
- Expand the Android Kotlin client from dashboard node rendering into the full
|
||||||
retained `surface.root` renderer, and add the browser client against the same
|
retained `surface.root` renderer, and add the browser client against the same
|
||||||
provider/sensor API.
|
provider/sensor API.
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
|
|
||||||
@protocol EVEDashboardClientDelegate;
|
|
||||||
|
|
||||||
@interface EVEDashboardClient : NSObject
|
|
||||||
|
|
||||||
- (instancetype)initWithURLs:(NSArray<NSURL *> *)urls delegate:(id<EVEDashboardClientDelegate>)delegate;
|
|
||||||
- (void)connect;
|
|
||||||
- (void)disconnect;
|
|
||||||
- (void)sendCommand:(NSDictionary *)command;
|
|
||||||
|
|
||||||
@property(nonatomic, copy, readonly) NSString *status;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
@protocol EVEDashboardClientDelegate <NSObject>
|
|
||||||
|
|
||||||
- (void)dashboardClient:(EVEDashboardClient *)client didReceiveState:(NSDictionary *)state;
|
|
||||||
- (void)dashboardClient:(EVEDashboardClient *)client didChangeStatus:(NSString *)status;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
#import "EVEDashboardClient.h"
|
|
||||||
|
|
||||||
@interface EVEDashboardClient ()
|
|
||||||
|
|
||||||
@property(nonatomic, copy) NSArray<NSURL *> *urls;
|
|
||||||
@property(nonatomic, assign) NSUInteger urlIndex;
|
|
||||||
@property(nonatomic, weak) id<EVEDashboardClientDelegate> delegate;
|
|
||||||
@property(nonatomic, strong) NSURLSession *session;
|
|
||||||
@property(nonatomic, strong) NSURLSessionWebSocketTask *task;
|
|
||||||
@property(nonatomic, assign) BOOL shouldReconnect;
|
|
||||||
@property(nonatomic, copy, readwrite) NSString *status;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation EVEDashboardClient
|
|
||||||
|
|
||||||
- (instancetype)initWithURLs:(NSArray<NSURL *> *)urls delegate:(id<EVEDashboardClientDelegate>)delegate {
|
|
||||||
self = [super init];
|
|
||||||
if (!self) {
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.urls = urls.count > 0 ? urls : @[];
|
|
||||||
self.delegate = delegate;
|
|
||||||
self.session = [NSURLSession sessionWithConfiguration:NSURLSessionConfiguration.defaultSessionConfiguration];
|
|
||||||
self.status = @"dashboard idle";
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)connect {
|
|
||||||
self.shouldReconnect = YES;
|
|
||||||
[self.task cancelWithCloseCode:NSURLSessionWebSocketCloseCodeNormalClosure reason:nil];
|
|
||||||
NSURL *url = self.urls.count > 0 ? self.urls[self.urlIndex % self.urls.count] : nil;
|
|
||||||
if (!url) {
|
|
||||||
[self publishStatus:@"dashboard URL missing"];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.task = [self.session webSocketTaskWithURL:url];
|
|
||||||
[self.task resume];
|
|
||||||
[self publishStatus:[NSString stringWithFormat:@"dashboard connecting %@", url.host ?: @""]];
|
|
||||||
[self receiveNextMessage];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)disconnect {
|
|
||||||
self.shouldReconnect = NO;
|
|
||||||
[self.task cancelWithCloseCode:NSURLSessionWebSocketCloseCodeNormalClosure reason:nil];
|
|
||||||
self.task = nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)sendCommand:(NSDictionary *)command {
|
|
||||||
if (!self.task) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSData *json = [NSJSONSerialization dataWithJSONObject:command options:0 error:nil];
|
|
||||||
if (!json) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSString *text = [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding];
|
|
||||||
NSURLSessionWebSocketMessage *message = [[NSURLSessionWebSocketMessage alloc] initWithString:text];
|
|
||||||
[self.task sendMessage:message completionHandler:^(NSError *error) {
|
|
||||||
if (error) {
|
|
||||||
[self publishStatus:@"dashboard command failed"];
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)receiveNextMessage {
|
|
||||||
__weak typeof(self) weakSelf = self;
|
|
||||||
[self.task receiveMessageWithCompletionHandler:^(NSURLSessionWebSocketMessage *message, NSError *error) {
|
|
||||||
__strong typeof(weakSelf) self = weakSelf;
|
|
||||||
if (!self) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
[self publishStatus:@"dashboard disconnected"];
|
|
||||||
[self reconnectSoon];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type == NSURLSessionWebSocketMessageTypeString) {
|
|
||||||
NSData *data = [message.string dataUsingEncoding:NSUTF8StringEncoding];
|
|
||||||
NSDictionary *state = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
|
|
||||||
if ([state isKindOfClass:NSDictionary.class]) {
|
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
|
||||||
[self.delegate dashboardClient:self didReceiveState:state];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[self receiveNextMessage];
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)reconnectSoon {
|
|
||||||
if (!self.shouldReconnect) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self.urls.count > 1) {
|
|
||||||
self.urlIndex = (self.urlIndex + 1) % self.urls.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
|
||||||
if (self.shouldReconnect) {
|
|
||||||
[self connect];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)publishStatus:(NSString *)status {
|
|
||||||
self.status = status ?: @"dashboard";
|
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
|
||||||
[self.delegate dashboardClient:self didChangeStatus:self.status];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)dealloc {
|
|
||||||
[self disconnect];
|
|
||||||
[self.session invalidateAndCancel];
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
28
Sources/EVEHermodrClient.h
Normal file
28
Sources/EVEHermodrClient.h
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@class EVEHermodrClient;
|
||||||
|
|
||||||
|
@protocol EVEHermodrClientDelegate <NSObject>
|
||||||
|
|
||||||
|
- (void)hermodrClient:(EVEHermodrClient *)client didReceiveSurface:(NSDictionary *)surface;
|
||||||
|
- (void)hermodrClient:(EVEHermodrClient *)client didChangeStatus:(NSString *)status;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface EVEHermodrClient : NSObject
|
||||||
|
|
||||||
|
@property(nonatomic, copy, readonly) NSString *status;
|
||||||
|
|
||||||
|
- (instancetype)initWithBaseURLs:(NSArray<NSURL *> *)baseURLs
|
||||||
|
providerId:(NSString *)providerId
|
||||||
|
surfaceId:(NSString *)surfaceId
|
||||||
|
delegate:(id<EVEHermodrClientDelegate>)delegate;
|
||||||
|
- (void)connect;
|
||||||
|
- (void)disconnect;
|
||||||
|
- (void)sendCommand:(NSDictionary *)command;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
192
Sources/EVEHermodrClient.m
Normal file
192
Sources/EVEHermodrClient.m
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
#import "EVEHermodrClient.h"
|
||||||
|
|
||||||
|
static NSTimeInterval const EVESurfaceRefreshInterval = 1.0;
|
||||||
|
|
||||||
|
@interface EVEHermodrClient ()
|
||||||
|
|
||||||
|
@property(nonatomic, copy) NSArray<NSURL *> *baseURLs;
|
||||||
|
@property(nonatomic, assign) NSUInteger urlIndex;
|
||||||
|
@property(nonatomic, copy) NSString *providerId;
|
||||||
|
@property(nonatomic, copy) NSString *surfaceId;
|
||||||
|
@property(nonatomic, weak) id<EVEHermodrClientDelegate> delegate;
|
||||||
|
@property(nonatomic, strong) NSURLSession *session;
|
||||||
|
@property(nonatomic, strong) NSURLSessionDataTask *surfaceTask;
|
||||||
|
@property(nonatomic, assign) BOOL shouldRefresh;
|
||||||
|
@property(nonatomic, assign) NSUInteger generation;
|
||||||
|
@property(nonatomic, copy) NSData *lastSurfaceData;
|
||||||
|
@property(nonatomic, copy, readwrite) NSString *status;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation EVEHermodrClient
|
||||||
|
|
||||||
|
- (instancetype)initWithBaseURLs:(NSArray<NSURL *> *)baseURLs
|
||||||
|
providerId:(NSString *)providerId
|
||||||
|
surfaceId:(NSString *)surfaceId
|
||||||
|
delegate:(id<EVEHermodrClientDelegate>)delegate {
|
||||||
|
self = [super init];
|
||||||
|
if (!self) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSURLSessionConfiguration *configuration = NSURLSessionConfiguration.ephemeralSessionConfiguration;
|
||||||
|
configuration.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
||||||
|
configuration.timeoutIntervalForRequest = 8.0;
|
||||||
|
configuration.timeoutIntervalForResource = 12.0;
|
||||||
|
self.session = [NSURLSession sessionWithConfiguration:configuration];
|
||||||
|
self.baseURLs = baseURLs.count > 0 ? baseURLs : @[];
|
||||||
|
self.providerId = providerId.length > 0 ? providerId : @"gjallar.overview";
|
||||||
|
self.surfaceId = surfaceId.length > 0 ? surfaceId : self.providerId;
|
||||||
|
self.delegate = delegate;
|
||||||
|
self.status = @"Hermodr idle";
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)connect {
|
||||||
|
self.shouldRefresh = YES;
|
||||||
|
self.generation += 1;
|
||||||
|
[self.surfaceTask cancel];
|
||||||
|
self.surfaceTask = nil;
|
||||||
|
self.lastSurfaceData = nil;
|
||||||
|
if (self.baseURLs.count == 0) {
|
||||||
|
[self publishStatus:@"Hermodr URL missing"];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[self requestSurfaceForGeneration:self.generation];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)disconnect {
|
||||||
|
self.shouldRefresh = NO;
|
||||||
|
self.generation += 1;
|
||||||
|
[self.surfaceTask cancel];
|
||||||
|
self.surfaceTask = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)requestSurfaceForGeneration:(NSUInteger)generation {
|
||||||
|
if (!self.shouldRefresh || generation != self.generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSURL *baseURL = self.baseURLs[self.urlIndex % self.baseURLs.count];
|
||||||
|
NSURL *surfaceURL = [self surfaceURLForBaseURL:baseURL];
|
||||||
|
if (!surfaceURL) {
|
||||||
|
[self publishStatus:@"Hermodr URL invalid"];
|
||||||
|
[self scheduleRefreshForGeneration:generation];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:surfaceURL];
|
||||||
|
request.HTTPMethod = @"GET";
|
||||||
|
[request setValue:@"application/json" forHTTPHeaderField:@"Accept"];
|
||||||
|
[self publishStatus:[NSString stringWithFormat:@"opening %@", self.providerId]];
|
||||||
|
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
self.surfaceTask = [self.session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||||
|
__strong typeof(weakSelf) self = weakSelf;
|
||||||
|
if (!self || generation != self.generation || !self.shouldRefresh) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSHTTPURLResponse *http = [response isKindOfClass:NSHTTPURLResponse.class] ? (NSHTTPURLResponse *)response : nil;
|
||||||
|
NSError *parseError = nil;
|
||||||
|
NSDictionary *state = data.length > 0 ? [NSJSONSerialization JSONObjectWithData:data options:0 error:&parseError] : nil;
|
||||||
|
NSDictionary *surface = [state isKindOfClass:NSDictionary.class] && [state[@"surface"] isKindOfClass:NSDictionary.class] ? state[@"surface"] : nil;
|
||||||
|
NSDictionary *root = [surface[@"root"] isKindOfClass:NSDictionary.class] ? surface[@"root"] : nil;
|
||||||
|
BOOL accepted = !error && http.statusCode >= 200 && http.statusCode < 300 && root != nil;
|
||||||
|
|
||||||
|
if (accepted) {
|
||||||
|
BOOL changed = ![self.lastSurfaceData isEqualToData:data];
|
||||||
|
self.lastSurfaceData = data;
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
[self publishStatus:[NSString stringWithFormat:@"%@ connected", self.providerId]];
|
||||||
|
if (changed) {
|
||||||
|
[self.delegate hermodrClient:self didReceiveSurface:state];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (self.baseURLs.count > 1) {
|
||||||
|
self.urlIndex = (self.urlIndex + 1) % self.baseURLs.count;
|
||||||
|
}
|
||||||
|
NSString *detail = error.localizedDescription;
|
||||||
|
if (detail.length == 0 && [state[@"error"] isKindOfClass:NSString.class]) {
|
||||||
|
detail = state[@"error"];
|
||||||
|
}
|
||||||
|
if (detail.length == 0 && http) {
|
||||||
|
detail = [NSString stringWithFormat:@"HTTP %ld", (long)http.statusCode];
|
||||||
|
}
|
||||||
|
if (detail.length == 0 && parseError) {
|
||||||
|
detail = parseError.localizedDescription;
|
||||||
|
}
|
||||||
|
[self publishStatus:[NSString stringWithFormat:@"Hermodr unavailable: %@", detail ?: @"invalid surface"]];
|
||||||
|
}
|
||||||
|
[self scheduleRefreshForGeneration:generation];
|
||||||
|
}];
|
||||||
|
[self.surfaceTask resume];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)scheduleRefreshForGeneration:(NSUInteger)generation {
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(EVESurfaceRefreshInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||||
|
[self requestSurfaceForGeneration:generation];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSURL *)surfaceURLForBaseURL:(NSURL *)baseURL {
|
||||||
|
NSURLComponents *components = [NSURLComponents componentsWithURL:baseURL resolvingAgainstBaseURL:NO];
|
||||||
|
if (!components.scheme.length || !components.host.length) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
NSString *basePath = [components.path stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"/"]];
|
||||||
|
NSString *provider = [self.providerId stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLPathAllowedCharacterSet];
|
||||||
|
components.path = [NSString stringWithFormat:@"/%@%@hermodr/surface/%@",
|
||||||
|
basePath,
|
||||||
|
basePath.length > 0 ? @"/" : @"",
|
||||||
|
provider ?: @""];
|
||||||
|
components.queryItems = @[[NSURLQueryItem queryItemWithName:@"surfaceId" value:self.surfaceId]];
|
||||||
|
return components.URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSURL *)commandURLForBaseURL:(NSURL *)baseURL {
|
||||||
|
NSURLComponents *components = [NSURLComponents componentsWithURL:baseURL resolvingAgainstBaseURL:NO];
|
||||||
|
if (!components.scheme.length || !components.host.length) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
NSString *basePath = [components.path stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"/"]];
|
||||||
|
components.path = [NSString stringWithFormat:@"/%@%@hermodr/commands/eve",
|
||||||
|
basePath,
|
||||||
|
basePath.length > 0 ? @"/" : @""];
|
||||||
|
components.query = nil;
|
||||||
|
return components.URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)sendCommand:(NSDictionary *)command {
|
||||||
|
if (self.baseURLs.count == 0 || ![NSJSONSerialization isValidJSONObject:command]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSURL *url = [self commandURLForBaseURL:self.baseURLs[self.urlIndex % self.baseURLs.count]];
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSMutableDictionary *body = [command mutableCopy];
|
||||||
|
if (![body[@"providerId"] isKindOfClass:NSString.class]) {
|
||||||
|
body[@"providerId"] = self.providerId;
|
||||||
|
}
|
||||||
|
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
|
||||||
|
request.HTTPMethod = @"POST";
|
||||||
|
request.HTTPBody = [NSJSONSerialization dataWithJSONObject:body options:0 error:nil];
|
||||||
|
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
||||||
|
[[self.session dataTaskWithRequest:request] resume];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)publishStatus:(NSString *)status {
|
||||||
|
self.status = status ?: @"Hermodr";
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
[self.delegate hermodrClient:self didChangeStatus:self.status];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)dealloc {
|
||||||
|
[self disconnect];
|
||||||
|
[self.session invalidateAndCancel];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
#import <QuartzCore/QuartzCore.h>
|
#import <QuartzCore/QuartzCore.h>
|
||||||
|
|
||||||
#import "EVEFrameStreamClient.h"
|
#import "EVEFrameStreamClient.h"
|
||||||
#import "EVEDashboardClient.h"
|
#import "EVEHermodrClient.h"
|
||||||
#import "EVEGLView.h"
|
#import "EVEGLView.h"
|
||||||
#import "EVEH264StreamDecoder.h"
|
#import "EVEH264StreamDecoder.h"
|
||||||
#import "EVESensorUplinkClient.h"
|
#import "EVESensorUplinkClient.h"
|
||||||
|
|
@ -170,7 +170,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
@interface EVEViewController () <EVEFrameStreamClientDelegate, EVEDashboardClientDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, UIGestureRecognizerDelegate>
|
@interface EVEViewController () <EVEFrameStreamClientDelegate, EVEHermodrClientDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, UIGestureRecognizerDelegate>
|
||||||
|
|
||||||
@property(nonatomic, strong) EVEGLView *glView;
|
@property(nonatomic, strong) EVEGLView *glView;
|
||||||
@property(nonatomic, strong) UIImageView *streamImageView;
|
@property(nonatomic, strong) UIImageView *streamImageView;
|
||||||
|
|
@ -189,7 +189,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
@property(nonatomic, strong) CADisplayLink *displayLink;
|
@property(nonatomic, strong) CADisplayLink *displayLink;
|
||||||
@property(nonatomic, strong) CMMotionManager *motionManager;
|
@property(nonatomic, strong) CMMotionManager *motionManager;
|
||||||
@property(nonatomic, strong) EVEFrameStreamClient *streamClient;
|
@property(nonatomic, strong) EVEFrameStreamClient *streamClient;
|
||||||
@property(nonatomic, strong) EVEDashboardClient *dashboardClient;
|
@property(nonatomic, strong) EVEHermodrClient *hermodrClient;
|
||||||
@property(nonatomic, strong) EVESensorUplinkClient *cameraUplink;
|
@property(nonatomic, strong) EVESensorUplinkClient *cameraUplink;
|
||||||
@property(nonatomic, strong) EVESensorUplinkClient *micUplink;
|
@property(nonatomic, strong) EVESensorUplinkClient *micUplink;
|
||||||
@property(nonatomic, strong) AVCaptureSession *captureSession;
|
@property(nonatomic, strong) AVCaptureSession *captureSession;
|
||||||
|
|
@ -291,9 +291,12 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
self.streamClient = [[EVEFrameStreamClient alloc] initWithURLs:streamURLs delegate:self];
|
self.streamClient = [[EVEFrameStreamClient alloc] initWithURLs:streamURLs delegate:self];
|
||||||
[self.streamClient connect];
|
[self.streamClient connect];
|
||||||
|
|
||||||
NSArray<NSURL *> *dashboardURLs = EVEConfiguredURLs(@"EVE_DASHBOARD_URLS");
|
NSArray<NSURL *> *hermodrURLs = EVEConfiguredURLs(@"EVE_HERMODR_URLS");
|
||||||
self.dashboardClient = [[EVEDashboardClient alloc] initWithURLs:dashboardURLs delegate:self];
|
self.hermodrClient = [[EVEHermodrClient alloc] initWithBaseURLs:hermodrURLs
|
||||||
[self.dashboardClient connect];
|
providerId:@"gjallar.overview"
|
||||||
|
surfaceId:@"gjallar.overview"
|
||||||
|
delegate:self];
|
||||||
|
[self.hermodrClient connect];
|
||||||
|
|
||||||
NSArray<NSURL *> *cameraURLs = EVEConfiguredURLs(@"EVE_CAMERA_URLS");
|
NSArray<NSURL *> *cameraURLs = EVEConfiguredURLs(@"EVE_CAMERA_URLS");
|
||||||
NSArray<NSURL *> *micURLs = EVEConfiguredURLs(@"EVE_MIC_URLS");
|
NSArray<NSURL *> *micURLs = EVEConfiguredURLs(@"EVE_MIC_URLS");
|
||||||
|
|
@ -310,7 +313,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
- (void)dealloc {
|
- (void)dealloc {
|
||||||
[self.displayLink invalidate];
|
[self.displayLink invalidate];
|
||||||
[self.streamClient disconnect];
|
[self.streamClient disconnect];
|
||||||
[self.dashboardClient disconnect];
|
[self.hermodrClient disconnect];
|
||||||
[self.cameraUplink disconnect];
|
[self.cameraUplink disconnect];
|
||||||
[self.micUplink disconnect];
|
[self.micUplink disconnect];
|
||||||
[self.captureSession stopRunning];
|
[self.captureSession stopRunning];
|
||||||
|
|
@ -791,7 +794,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
return CGPointMake(x, y);
|
return CGPointMake(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)dashboardClient:(EVEDashboardClient *)client didReceiveState:(NSDictionary *)state {
|
- (void)hermodrClient:(EVEHermodrClient *)client didReceiveSurface:(NSDictionary *)state {
|
||||||
(void)client;
|
(void)client;
|
||||||
NSString *title = [state[@"title"] isKindOfClass:NSString.class] ? state[@"title"] : @"dashboard";
|
NSString *title = [state[@"title"] isKindOfClass:NSString.class] ? state[@"title"] : @"dashboard";
|
||||||
self.dashboardStatus = [NSString stringWithFormat:@"%@ v%@", title, state[@"version"] ?: @"?"];
|
self.dashboardStatus = [NSString stringWithFormat:@"%@ v%@", title, state[@"version"] ?: @"?"];
|
||||||
|
|
@ -803,10 +806,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
self.selectedNodeId = selected;
|
self.selectedNodeId = selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
NSArray *nodes = state[@"nodes"];
|
NSArray *nodes = [state[@"nodes"] isKindOfClass:NSArray.class] ? state[@"nodes"] : @[];
|
||||||
if (![nodes isKindOfClass:NSArray.class]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
[self.dashboardNodes removeAllObjects];
|
[self.dashboardNodes removeAllObjects];
|
||||||
NSMutableSet<NSString *> *liveNodeIds = [NSMutableSet set];
|
NSMutableSet<NSString *> *liveNodeIds = [NSMutableSet set];
|
||||||
|
|
@ -876,19 +876,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
- (BOOL)shouldRenderFullscreenSurfaceForState:(NSDictionary *)state {
|
- (BOOL)shouldRenderFullscreenSurfaceForState:(NSDictionary *)state {
|
||||||
NSDictionary *surface = [state[@"surface"] isKindOfClass:NSDictionary.class] ? state[@"surface"] : nil;
|
NSDictionary *surface = [state[@"surface"] isKindOfClass:NSDictionary.class] ? state[@"surface"] : nil;
|
||||||
NSDictionary *root = [surface[@"root"] isKindOfClass:NSDictionary.class] ? surface[@"root"] : nil;
|
NSDictionary *root = [surface[@"root"] isKindOfClass:NSDictionary.class] ? surface[@"root"] : nil;
|
||||||
if (!root) {
|
return root != nil;
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSString *providerId = [state[@"providerId"] isKindOfClass:NSString.class] ? state[@"providerId"] : @"";
|
|
||||||
if ([providerId isEqualToString:@"odin.allseer"]) {
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSDictionary *props = [root[@"props"] isKindOfClass:NSDictionary.class] ? root[@"props"] : nil;
|
|
||||||
NSDictionary *layout = [props[@"layout"] isKindOfClass:NSDictionary.class] ? props[@"layout"] : nil;
|
|
||||||
NSString *viewportMode = [layout[@"viewportMode"] isKindOfClass:NSString.class] ? layout[@"viewportMode"] : @"";
|
|
||||||
return [viewportMode isEqualToString:@"fullscreen"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)renderFullscreenSurfaceState:(NSDictionary *)state title:(NSString *)title version:(id)version {
|
- (void)renderFullscreenSurfaceState:(NSDictionary *)state title:(NSString *)title version:(id)version {
|
||||||
|
|
@ -896,8 +884,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
[view removeFromSuperview];
|
[view removeFromSuperview];
|
||||||
}
|
}
|
||||||
|
|
||||||
NSString *providerId = [state[@"providerId"] isKindOfClass:NSString.class] ? state[@"providerId"] : @"";
|
if ([self renderAggregateSurfaceWallForState:state]) {
|
||||||
if ([providerId isEqualToString:@"odin.allseer"] && [self renderOdinInterfaceWallForState:state]) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -932,7 +919,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
]];
|
]];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (BOOL)renderOdinInterfaceWallForState:(NSDictionary *)state {
|
- (BOOL)renderAggregateSurfaceWallForState:(NSDictionary *)state {
|
||||||
[self.surfaceDashboardView layoutIfNeeded];
|
[self.surfaceDashboardView layoutIfNeeded];
|
||||||
CGRect bounds = self.surfaceDashboardView.bounds;
|
CGRect bounds = self.surfaceDashboardView.bounds;
|
||||||
if (bounds.size.width < 20.0 || bounds.size.height < 20.0) {
|
if (bounds.size.width < 20.0 || bounds.size.height < 20.0) {
|
||||||
|
|
@ -941,6 +928,11 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
|
|
||||||
NSDictionary *surface = [state[@"surface"] isKindOfClass:NSDictionary.class] ? state[@"surface"] : nil;
|
NSDictionary *surface = [state[@"surface"] isKindOfClass:NSDictionary.class] ? state[@"surface"] : nil;
|
||||||
NSDictionary *root = [surface[@"root"] isKindOfClass:NSDictionary.class] ? surface[@"root"] : nil;
|
NSDictionary *root = [surface[@"root"] isKindOfClass:NSDictionary.class] ? surface[@"root"] : nil;
|
||||||
|
NSDictionary *rootLayout = [self surfaceLayoutForElement:root];
|
||||||
|
NSString *mode = [rootLayout[@"mode"] isKindOfClass:NSString.class] ? rootLayout[@"mode"] : @"";
|
||||||
|
if (![mode isEqualToString:@"weighted-bisect"]) {
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
NSArray *children = [root[@"children"] isKindOfClass:NSArray.class] ? root[@"children"] : @[];
|
NSArray *children = [root[@"children"] isKindOfClass:NSArray.class] ? root[@"children"] : @[];
|
||||||
NSMutableArray<NSDictionary *> *interfaces = [NSMutableArray array];
|
NSMutableArray<NSDictionary *> *interfaces = [NSMutableArray array];
|
||||||
for (NSDictionary *child in children) {
|
for (NSDictionary *child in children) {
|
||||||
|
|
@ -948,10 +940,10 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
NSString *kind = [child[@"kind"] isKindOfClass:NSString.class] ? child[@"kind"] : @"";
|
NSString *kind = [child[@"kind"] isKindOfClass:NSString.class] ? child[@"kind"] : @"";
|
||||||
NSDictionary *props = [child[@"props"] isKindOfClass:NSDictionary.class] ? child[@"props"] : @{};
|
NSDictionary *layout = [self surfaceLayoutForElement:child];
|
||||||
NSDictionary *layout = [props[@"layout"] isKindOfClass:NSDictionary.class] ? props[@"layout"] : @{};
|
id visible = layout[@"visible"];
|
||||||
NSNumber *visible = [layout[@"visible"] isKindOfClass:NSNumber.class] ? layout[@"visible"] : @YES;
|
BOOL isVisible = !visible || ![visible respondsToSelector:@selector(boolValue)] || [visible boolValue];
|
||||||
if ([kind isEqualToString:@"interface"] && visible.boolValue) {
|
if ([kind isEqualToString:@"interface"] && isVisible) {
|
||||||
[interfaces addObject:child];
|
[interfaces addObject:child];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -965,9 +957,20 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
wall.backgroundColor = [self fensalirBackdropColor];
|
wall.backgroundColor = [self fensalirBackdropColor];
|
||||||
[self.surfaceDashboardView addSubview:wall];
|
[self.surfaceDashboardView addSubview:wall];
|
||||||
|
|
||||||
CGFloat gap = 8.0;
|
if (interfaces.count == 0) {
|
||||||
|
NSDictionary *props = [root[@"props"] isKindOfClass:NSDictionary.class] ? root[@"props"] : @{};
|
||||||
|
NSString *summary = [props[@"summary"] isKindOfClass:NSString.class] ? props[@"summary"] : @"No visible Eve surfaces";
|
||||||
|
UILabel *empty = [self surfaceLabelWithText:summary size:14.0 weight:UIFontWeightMedium color:[self fensalirQuietTextColor]];
|
||||||
|
empty.frame = CGRectInset(wall.bounds, 24.0, 24.0);
|
||||||
|
empty.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||||
|
empty.textAlignment = NSTextAlignmentCenter;
|
||||||
|
[wall addSubview:empty];
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
CGFloat gap = [rootLayout[@"gap"] respondsToSelector:@selector(doubleValue)] ? [rootLayout[@"gap"] doubleValue] : 8.0;
|
||||||
CGRect packBounds = CGRectInset(bounds, gap, gap);
|
CGRect packBounds = CGRectInset(bounds, gap, gap);
|
||||||
NSArray<NSDictionary *> *placements = [self odinPackedPlacementsForInterfaces:interfaces bounds:packBounds gap:gap];
|
NSArray<NSDictionary *> *placements = [self aggregatePlacementsForInterfaces:interfaces bounds:packBounds gap:gap];
|
||||||
for (NSDictionary *placement in placements) {
|
for (NSDictionary *placement in placements) {
|
||||||
NSDictionary *interface = [placement[@"interface"] isKindOfClass:NSDictionary.class] ? placement[@"interface"] : nil;
|
NSDictionary *interface = [placement[@"interface"] isKindOfClass:NSDictionary.class] ? placement[@"interface"] : nil;
|
||||||
NSValue *frameValue = [placement[@"frame"] isKindOfClass:NSValue.class] ? placement[@"frame"] : nil;
|
NSValue *frameValue = [placement[@"frame"] isKindOfClass:NSValue.class] ? placement[@"frame"] : nil;
|
||||||
|
|
@ -975,17 +978,18 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
if (!interface || !frameValue) {
|
if (!interface || !frameValue) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
[wall addSubview:[self odinInterfaceTile:interface frame:frameValue.CGRectValue weight:weight.doubleValue]];
|
[wall addSubview:[self aggregateInterfaceTile:interface frame:frameValue.CGRectValue weight:weight.doubleValue]];
|
||||||
}
|
}
|
||||||
|
|
||||||
return YES;
|
return YES;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSArray<NSDictionary *> *)odinPackedPlacementsForInterfaces:(NSArray<NSDictionary *> *)interfaces bounds:(CGRect)bounds gap:(CGFloat)gap {
|
- (NSArray<NSDictionary *> *)aggregatePlacementsForInterfaces:(NSArray<NSDictionary *> *)interfaces bounds:(CGRect)bounds gap:(CGFloat)gap {
|
||||||
NSMutableArray<NSDictionary *> *items = [NSMutableArray arrayWithCapacity:interfaces.count];
|
NSMutableArray<NSDictionary *> *items = [NSMutableArray arrayWithCapacity:interfaces.count];
|
||||||
for (NSDictionary *interface in interfaces) {
|
for (NSDictionary *interface in interfaces) {
|
||||||
double weight = [self surfaceWeightForElement:interface];
|
NSDictionary *layout = [self surfaceLayoutForElement:interface];
|
||||||
[items addObject:@{@"interface": interface, @"weight": @(MAX(0.65, weight))}];
|
double weight = [layout[@"weight"] respondsToSelector:@selector(doubleValue)] ? [layout[@"weight"] doubleValue] : 1.0;
|
||||||
|
[items addObject:@{@"interface": interface, @"weight": @(MAX(0.1, weight))}];
|
||||||
}
|
}
|
||||||
[items sortUsingComparator:^NSComparisonResult(NSDictionary *left, NSDictionary *right) {
|
[items sortUsingComparator:^NSComparisonResult(NSDictionary *left, NSDictionary *right) {
|
||||||
double lw = [left[@"weight"] doubleValue];
|
double lw = [left[@"weight"] doubleValue];
|
||||||
|
|
@ -1000,11 +1004,11 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
}];
|
}];
|
||||||
|
|
||||||
NSMutableArray<NSDictionary *> *placements = [NSMutableArray array];
|
NSMutableArray<NSDictionary *> *placements = [NSMutableArray array];
|
||||||
[self packOdinItems:items bounds:bounds gap:gap into:placements];
|
[self packAggregateItems:items bounds:bounds gap:gap into:placements];
|
||||||
return placements;
|
return placements;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)packOdinItems:(NSArray<NSDictionary *> *)items bounds:(CGRect)bounds gap:(CGFloat)gap into:(NSMutableArray<NSDictionary *> *)placements {
|
- (void)packAggregateItems:(NSArray<NSDictionary *> *)items bounds:(CGRect)bounds gap:(CGFloat)gap into:(NSMutableArray<NSDictionary *> *)placements {
|
||||||
if (items.count == 0 || bounds.size.width <= 1.0 || bounds.size.height <= 1.0) {
|
if (items.count == 0 || bounds.size.width <= 1.0 || bounds.size.height <= 1.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1040,15 +1044,15 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
firstWidth = MAX(96.0, MIN(bounds.size.width - gap - 96.0, firstWidth));
|
firstWidth = MAX(96.0, MIN(bounds.size.width - gap - 96.0, firstWidth));
|
||||||
CGRect left = CGRectMake(bounds.origin.x, bounds.origin.y, firstWidth, bounds.size.height);
|
CGRect left = CGRectMake(bounds.origin.x, bounds.origin.y, firstWidth, bounds.size.height);
|
||||||
CGRect right = CGRectMake(CGRectGetMaxX(left) + gap, bounds.origin.y, bounds.size.width - firstWidth - gap, bounds.size.height);
|
CGRect right = CGRectMake(CGRectGetMaxX(left) + gap, bounds.origin.y, bounds.size.width - firstWidth - gap, bounds.size.height);
|
||||||
[self packOdinItems:first bounds:left gap:gap into:placements];
|
[self packAggregateItems:first bounds:left gap:gap into:placements];
|
||||||
[self packOdinItems:second bounds:right gap:gap into:placements];
|
[self packAggregateItems:second bounds:right gap:gap into:placements];
|
||||||
} else {
|
} else {
|
||||||
CGFloat firstHeight = floor((bounds.size.height - gap) * (CGFloat)ratio);
|
CGFloat firstHeight = floor((bounds.size.height - gap) * (CGFloat)ratio);
|
||||||
firstHeight = MAX(72.0, MIN(bounds.size.height - gap - 72.0, firstHeight));
|
firstHeight = MAX(72.0, MIN(bounds.size.height - gap - 72.0, firstHeight));
|
||||||
CGRect top = CGRectMake(bounds.origin.x, bounds.origin.y, bounds.size.width, firstHeight);
|
CGRect top = CGRectMake(bounds.origin.x, bounds.origin.y, bounds.size.width, firstHeight);
|
||||||
CGRect bottom = CGRectMake(bounds.origin.x, CGRectGetMaxY(top) + gap, bounds.size.width, bounds.size.height - firstHeight - gap);
|
CGRect bottom = CGRectMake(bounds.origin.x, CGRectGetMaxY(top) + gap, bounds.size.width, bounds.size.height - firstHeight - gap);
|
||||||
[self packOdinItems:first bounds:top gap:gap into:placements];
|
[self packAggregateItems:first bounds:top gap:gap into:placements];
|
||||||
[self packOdinItems:second bounds:bottom gap:gap into:placements];
|
[self packAggregateItems:second bounds:bottom gap:gap into:placements];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1060,31 +1064,19 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
return MAX(0.001, total);
|
return MAX(0.001, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
- (double)surfaceWeightForElement:(NSDictionary *)element {
|
- (NSDictionary *)surfaceLayoutForElement:(NSDictionary *)element {
|
||||||
NSString *kind = [element[@"kind"] isKindOfClass:NSString.class] ? element[@"kind"] : @"";
|
if (![element isKindOfClass:NSDictionary.class]) {
|
||||||
|
return @{};
|
||||||
|
}
|
||||||
|
NSDictionary *layout = [element[@"layout"] isKindOfClass:NSDictionary.class] ? element[@"layout"] : nil;
|
||||||
|
if (layout) {
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
NSDictionary *props = [element[@"props"] isKindOfClass:NSDictionary.class] ? element[@"props"] : @{};
|
NSDictionary *props = [element[@"props"] isKindOfClass:NSDictionary.class] ? element[@"props"] : @{};
|
||||||
NSArray *children = [element[@"children"] isKindOfClass:NSArray.class] ? element[@"children"] : @[];
|
return [props[@"layout"] isKindOfClass:NSDictionary.class] ? props[@"layout"] : @{};
|
||||||
NSString *text = [self surfaceTextForElement:element props:props fallback:@""];
|
|
||||||
double weight = [kind isEqualToString:@"interface"] ? 0.9 : 0.35;
|
|
||||||
if ([kind isEqualToString:@"text"] || [kind isEqualToString:@"metric"]) {
|
|
||||||
weight += MAX(0.22, (double)text.length / 95.0);
|
|
||||||
} else if ([kind isEqualToString:@"rail"]) {
|
|
||||||
weight += 0.55 + MIN(3.0, (double)children.count * 0.18);
|
|
||||||
} else if ([kind isEqualToString:@"card"] || [kind isEqualToString:@"pane"]) {
|
|
||||||
weight += 0.85;
|
|
||||||
} else if ([kind isEqualToString:@"dashboard"] || [kind isEqualToString:@"cockpit"]) {
|
|
||||||
weight += 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (NSDictionary *child in children) {
|
|
||||||
if ([child isKindOfClass:NSDictionary.class]) {
|
|
||||||
weight += [self surfaceWeightForElement:child];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return MAX(0.25, weight);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (UIView *)odinInterfaceTile:(NSDictionary *)interface frame:(CGRect)frame weight:(double)weight {
|
- (UIView *)aggregateInterfaceTile:(NSDictionary *)interface frame:(CGRect)frame weight:(double)weight {
|
||||||
NSDictionary *props = [interface[@"props"] isKindOfClass:NSDictionary.class] ? interface[@"props"] : @{};
|
NSDictionary *props = [interface[@"props"] isKindOfClass:NSDictionary.class] ? interface[@"props"] : @{};
|
||||||
UIView *tile = [[UIView alloc] initWithFrame:frame];
|
UIView *tile = [[UIView alloc] initWithFrame:frame];
|
||||||
tile.backgroundColor = [self fensalirPanelColor];
|
tile.backgroundColor = [self fensalirPanelColor];
|
||||||
|
|
@ -1865,7 +1857,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)dashboardClient:(EVEDashboardClient *)client didChangeStatus:(NSString *)status {
|
- (void)hermodrClient:(EVEHermodrClient *)client didChangeStatus:(NSString *)status {
|
||||||
(void)client;
|
(void)client;
|
||||||
self.dashboardStatus = status;
|
self.dashboardStatus = status;
|
||||||
self.dashboardStatusLabel.text = status;
|
self.dashboardStatusLabel.text = status;
|
||||||
|
|
@ -2022,7 +2014,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
}
|
}
|
||||||
|
|
||||||
CGFloat scale = MAX(0.25, MIN(3.0, self.activeGestureStartScale * recognizer.scale));
|
CGFloat scale = MAX(0.25, MIN(3.0, self.activeGestureStartScale * recognizer.scale));
|
||||||
[self.dashboardClient sendCommand:@{@"type": @"scale", @"nodeId": nodeId, @"scale": @(scale)}];
|
[self.hermodrClient sendCommand:@{@"type": @"scale", @"nodeId": nodeId, @"scale": @(scale)}];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)nodeRotated:(UIRotationGestureRecognizer *)recognizer {
|
- (void)nodeRotated:(UIRotationGestureRecognizer *)recognizer {
|
||||||
|
|
@ -2037,7 +2029,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
}
|
}
|
||||||
|
|
||||||
CGFloat rotation = self.activeGestureStartRotation + recognizer.rotation;
|
CGFloat rotation = self.activeGestureStartRotation + recognizer.rotation;
|
||||||
[self.dashboardClient sendCommand:@{@"type": @"rotate", @"nodeId": nodeId, @"rotation": @(rotation)}];
|
[self.hermodrClient sendCommand:@{@"type": @"rotate", @"nodeId": nodeId, @"rotation": @(rotation)}];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)sendMoveForNodeId:(NSString *)nodeId center:(CGPoint)center {
|
- (void)sendMoveForNodeId:(NSString *)nodeId center:(CGPoint)center {
|
||||||
|
|
@ -2045,7 +2037,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
CGFloat canvasHeight = MAX(1.0, self.sceneCanvasView.bounds.size.height);
|
CGFloat canvasHeight = MAX(1.0, self.sceneCanvasView.bounds.size.height);
|
||||||
CGFloat x = MIN(1.0, MAX(-1.0, (center.x / canvasWidth) * 2.0 - 1.0));
|
CGFloat x = MIN(1.0, MAX(-1.0, (center.x / canvasWidth) * 2.0 - 1.0));
|
||||||
CGFloat y = MIN(1.0, MAX(-1.0, (center.y / canvasHeight) * 2.0 - 1.0));
|
CGFloat y = MIN(1.0, MAX(-1.0, (center.y / canvasHeight) * 2.0 - 1.0));
|
||||||
[self.dashboardClient sendCommand:@{@"type": @"move", @"nodeId": nodeId, @"x": @(x), @"y": @(y)}];
|
[self.hermodrClient sendCommand:@{@"type": @"move", @"nodeId": nodeId, @"x": @(x), @"y": @(y)}];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)hierarchyNodePressed:(UIButton *)button {
|
- (void)hierarchyNodePressed:(UIButton *)button {
|
||||||
|
|
@ -2063,11 +2055,11 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
NSString *command = [node[@"command"] isKindOfClass:NSString.class] ? node[@"command"] : nil;
|
NSString *command = [node[@"command"] isKindOfClass:NSString.class] ? node[@"command"] : nil;
|
||||||
NSString *providerId = [node[@"providerId"] isKindOfClass:NSString.class] ? node[@"providerId"] : nil;
|
NSString *providerId = [node[@"providerId"] isKindOfClass:NSString.class] ? node[@"providerId"] : nil;
|
||||||
if ([command isEqualToString:@"open-provider"] && providerId) {
|
if ([command isEqualToString:@"open-provider"] && providerId) {
|
||||||
[self.dashboardClient sendCommand:@{@"type": @"open-provider", @"nodeId": nodeId, @"providerId": providerId}];
|
[self.hermodrClient sendCommand:@{@"type": @"open-provider", @"nodeId": nodeId, @"providerId": providerId}];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
[self.dashboardClient sendCommand:@{@"type": @"select", @"nodeId": nodeId}];
|
[self.hermodrClient sendCommand:@{@"type": @"select", @"nodeId": nodeId}];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)toolbarButtonPressed:(UIButton *)button {
|
- (void)toolbarButtonPressed:(UIButton *)button {
|
||||||
|
|
@ -2077,7 +2069,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
[self.dashboardClient sendCommand:@{@"type": action, @"nodeId": nodeId}];
|
[self.hermodrClient sendCommand:@{@"type": action, @"nodeId": nodeId}];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
|
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
|
||||||
|
|
|
||||||
2
control
2
control
|
|
@ -2,7 +2,7 @@ Package: org.gamecult.evecanvas
|
||||||
Name: EveCanvas
|
Name: EveCanvas
|
||||||
Version: 0.1.0
|
Version: 0.1.0
|
||||||
Architecture: iphoneos-arm
|
Architecture: iphoneos-arm
|
||||||
Description: Fullscreen OpenGL ES 3.0 render shell for EVE.
|
Description: Native iOS Eve runtime for Gjallar aggregate surfaces.
|
||||||
Maintainer: GameCult <meta@gamecult.org>
|
Maintainer: GameCult <meta@gamecult.org>
|
||||||
Author: GameCult
|
Author: GameCult
|
||||||
Section: Applications
|
Section: Applications
|
||||||
|
|
|
||||||
|
|
@ -132,11 +132,11 @@ Fensalir Direct2D:
|
||||||
|
|
||||||
## Current Proof
|
## Current Proof
|
||||||
|
|
||||||
The current native app consumes Mimir's dashboard WebSocket lowering for retained
|
The current native iOS app consumes Gjallar's aggregate Eve surface through
|
||||||
dashboard state. That lowering is shaped like the future API: retained dashboard
|
Odin-owned Hermodr. Gjallar owns visible provider membership and weighted-bisect
|
||||||
state, provider switching, commands, and typed node metadata. It is a renderer
|
layout intent. Hermodr only lowers the typed CultMesh record at the HTTP edge;
|
||||||
path over daemon-owned state, not provider discovery authority. Live provider
|
EveCanvas lowers the returned `surface.root` into UIKit and posts commands back
|
||||||
discovery belongs to Odin/CultMesh advertisements.
|
through the same bridge. Provider discovery remains Odin/CultMesh authority.
|
||||||
|
|
||||||
The Android proof under `android/` builds directly against the installed Android
|
The Android proof under `android/` builds directly against the installed Android
|
||||||
SDK and runs on Periwinkle. It is deliberately small: display Eve's role, render
|
SDK and runs on Periwinkle. It is deliberately small: display Eve's role, render
|
||||||
|
|
@ -156,12 +156,12 @@ pressure across the bench.
|
||||||
|
|
||||||
## Next Cut
|
## Next Cut
|
||||||
|
|
||||||
1. Extract the shared surface schema from the iOS app and Mimir broker into a
|
1. Admit readable provider-owned surface-state records into Odin so Gjallar can
|
||||||
versioned CultMesh contract.
|
compose live children instead of catalog-only advertisements.
|
||||||
2. Build the browser Eve runtime as the canonical renderer and comparison
|
2. Add a replay harness that feeds recorded surface/sensor documents to browser
|
||||||
target.
|
|
||||||
3. Add a replay harness that feeds recorded surface/sensor documents to browser
|
|
||||||
and native clients.
|
and native clients.
|
||||||
|
3. Prove native command receipts through Hermodr and provider-owned command
|
||||||
|
routes.
|
||||||
4. Replace JSON/base64 sensor payloads with binary packets while preserving the
|
4. Replace JSON/base64 sensor payloads with binary packets while preserving the
|
||||||
typed envelope.
|
typed envelope.
|
||||||
5. Add Android client scaffolding against the same provider and sensor API.
|
5. Add Android client scaffolding against the same provider and sensor API.
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ proves the same contract at the CultMesh typed document sync layer.
|
||||||
| Windows / Flutter | Active owner runtime | `EveFlutter` consumes supplied `gamecult.eve.surface.v1` documents and emits phone/tablet/desktop golden PNGs through its owner capture runner | Needs native window capture and tighter text/style normalization |
|
| Windows / Flutter | Active owner runtime | `EveFlutter` consumes supplied `gamecult.eve.surface.v1` documents and emits phone/tablet/desktop golden PNGs through its owner capture runner | Needs native window capture and tighter text/style normalization |
|
||||||
| Linux / Flutter | Active shared graphical smoke target | Nightwing runs the Flutter parity goldens, including embedded surface slots, over SSH and returns phone/tablet/desktop PNGs | Needs native window capture and runner health checks |
|
| Linux / Flutter | Active shared graphical smoke target | Nightwing runs the Flutter parity goldens, including embedded surface slots, over SSH and returns phone/tablet/desktop PNGs | Needs native window capture and runner health checks |
|
||||||
| Android / Flutter | Active shared graphical smoke target | The same Flutter parity app is built as an APK, installed on Periwinkle, and captured at phone/tablet/desktop plus native physical-panel orientations | Needs live provider picker and command transport beyond fixture assets |
|
| Android / Flutter | Active shared graphical smoke target | The same Flutter parity app is built as an APK, installed on Periwinkle, and captured at phone/tablet/desktop plus native physical-panel orientations | Needs live provider picker and command transport beyond fixture assets |
|
||||||
| iOS / UIKit | Active native screenshot target | SSH capture stages selected fixture JSON, EveCanvas renders it, and emits a PNG from `/var/mobile/Library/EveCanvas/latest-screenshot.png`; `embedded-surface` is part of the required fixture matrix | Not a plugin projection acceptance target until EveIOS owns adapters; still needs provider picker and command transport |
|
| iOS / UIKit | Active native Gjallar runtime and screenshot target | EveCanvas reads `gjallar.overview` through Hermodr, lowers its weighted-bisect aggregate with UIKit, and emits a device PNG from `/var/mobile/Library/EveCanvas/latest-screenshot.png`; fixture capture remains available as an offline parity oracle | Live aggregate is only as populated as Odin's readable provider surface-state records; command receipts and plugin projection acceptance remain |
|
||||||
| Android / Kotlin device edge | Active lightweight renderer | Native APK consumes CultMesh dashboard/sensor documents and preserves `surface.slot` / `embeddedDocuments` identity in the dashboard renderer | Not a plugin projection acceptance target; Android Flutter owns full graphical/plugin parity unless EveAndroid grows adapters |
|
| Android / Kotlin device edge | Active lightweight renderer | Native APK consumes CultMesh dashboard/sensor documents and preserves `surface.slot` / `embeddedDocuments` identity in the dashboard renderer | Not a plugin projection acceptance target; Android Flutter owns full graphical/plugin parity unless EveAndroid grows adapters |
|
||||||
| Rust / CultMesh | Document-sync contract runtime | CultLib replicates the `gamecult.eve.surface.v1` embedded slot contract through typed document sync | Not a pixel renderer |
|
| Rust / CultMesh | Document-sync contract runtime | CultLib replicates the `gamecult.eve.surface.v1` embedded slot contract through typed document sync | Not a pixel renderer |
|
||||||
| Fensalir Direct2D | External adapter spike, not active parity runtime | Fensalir has `src/Aquarium.EveSurface/EveSurfaceRuntime.cs`, reads an Eve broker, lowers into `AquariumUiDocument` / `DirectWriteOverlay`, and now has a runtime-pack `gamecult.eve.surface.v1` fixture in Eve | Needs runtime-owned command smoke, DirectWrite/Direct2D frame capture, and a clear reason to stay separate from Flutter for desktop UI |
|
| Fensalir Direct2D | External adapter spike, not active parity runtime | Fensalir has `src/Aquarium.EveSurface/EveSurfaceRuntime.cs`, reads an Eve broker, lowers into `AquariumUiDocument` / `DirectWriteOverlay`, and now has a runtime-pack `gamecult.eve.surface.v1` fixture in Eve | Needs runtime-owned command smoke, DirectWrite/Direct2D frame capture, and a clear reason to stay separate from Flutter for desktop UI |
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ This package owns the shared browser renderer contract:
|
||||||
- `EveBrowserProviderHost` owns advertisement-backed surface selection and uses one provider-neutral embedded-document resolver; provider schema dispatch stays behind the host transport boundary.
|
- `EveBrowserProviderHost` owns advertisement-backed surface selection and uses one provider-neutral embedded-document resolver; provider schema dispatch stays behind the host transport boundary.
|
||||||
- Editable string, textarea, number, select, and choice controls use named Eve state bindings. The renderer preserves edited drafts by provider/surface/binding identity; untouched controls contribute their authored value when an operation captures those bindings atomically under `payload.bindings`.
|
- Editable string, textarea, number, select, and choice controls use named Eve state bindings. The renderer preserves edited drafts by provider/surface/binding identity; untouched controls contribute their authored value when an operation captures those bindings atomically under `payload.bindings`.
|
||||||
- `gamecult.eve.command_result.v1` carries the persisted receipt, an optional one-time Eve projection, a binding-clear directive, and at most one plugin-scoped payload. Only the matching plugin adapter may consume that payload. The renderer-owned receipt region survives the authoritative surface refresh that follows the command.
|
- `gamecult.eve.command_result.v1` carries the persisted receipt, an optional one-time Eve projection, a binding-clear directive, and at most one plugin-scoped payload. Only the matching plugin adapter may consume that payload. The renderer-owned receipt region survives the authoritative surface refresh that follows the command.
|
||||||
|
- `resource.download` lowers a provider-issued resource grant to an accessible download link. The reference browser accepts only same-origin HTTP(S) or root-relative URLs and sanitizes the suggested filename; the provider remains responsible for authorization, expiry, single-use semantics, and resource bytes.
|
||||||
|
|
||||||
Host applications own transport and command delivery. Providers own command acceptance and receipts through their advertisements. For example, Eir resolves surfaces through Odin/CultMesh and posts commands to `/eir/command`; Aetheria resolves surfaces through Electron IPC. Neither host should duplicate DOM lowering or import provider internals to find a command route.
|
Host applications own transport and command delivery. Providers own command acceptance and receipts through their advertisements. For example, Eir resolves surfaces through Odin/CultMesh and posts commands to `/eir/command`; Aetheria resolves surfaces through Electron IPC. Neither host should duplicate DOM lowering or import provider internals to find a command route.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@ export declare class EveBrowserProviderHost {
|
||||||
private submit;
|
private submit;
|
||||||
private consumeCommandResult;
|
private consumeCommandResult;
|
||||||
private presentCommandStatus;
|
private presentCommandStatus;
|
||||||
|
private loweringOptions;
|
||||||
}
|
}
|
||||||
export interface EveEmbeddedDocumentRequest {
|
export interface EveEmbeddedDocumentRequest {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
|
|
|
||||||
88
packages/eve-browser-lowering/dist/index.js
vendored
88
packages/eve-browser-lowering/dist/index.js
vendored
|
|
@ -16,7 +16,7 @@ export class EveBrowserDraftStore {
|
||||||
this.values.set(this.key(providerId, surfaceId, bindingName), value);
|
this.values.set(this.key(providerId, surfaceId, bindingName), value);
|
||||||
}
|
}
|
||||||
clear(providerId, surfaceId, bindingNames) {
|
clear(providerId, surfaceId, bindingNames) {
|
||||||
if (bindingNames?.length) {
|
if (bindingNames !== undefined) {
|
||||||
for (const name of bindingNames)
|
for (const name of bindingNames)
|
||||||
this.values.delete(this.key(providerId, surfaceId, name));
|
this.values.delete(this.key(providerId, surfaceId, name));
|
||||||
return;
|
return;
|
||||||
|
|
@ -111,19 +111,7 @@ export class EveBrowserProviderHost {
|
||||||
if (version === this.lastSurfaceVersion)
|
if (version === this.lastSurfaceVersion)
|
||||||
return;
|
return;
|
||||||
this.lastSurfaceVersion = version;
|
this.lastSurfaceVersion = version;
|
||||||
renderEveSurface(surface, this.host, {
|
renderEveSurface(surface, this.host, this.loweringOptions());
|
||||||
activeSurfaceId: this.selected.surfaceId,
|
|
||||||
assetUrlResolver: this.transport.resolveAssetUrl,
|
|
||||||
body: this.options.body,
|
|
||||||
clientId: this.options.clientId || "eve-browser",
|
|
||||||
commandSink: intent => this.submit(intent),
|
|
||||||
documentResolver: this.transport.resolveDocument,
|
|
||||||
provider: this.provider,
|
|
||||||
pluginAdapters: this.pluginAdapters,
|
|
||||||
source: this.options.source,
|
|
||||||
statusElement: this.options.statusElement,
|
|
||||||
draftStore: this.draftStore,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
if (this.options.statusElement) {
|
if (this.options.statusElement) {
|
||||||
|
|
@ -132,12 +120,13 @@ export class EveBrowserProviderHost {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async submit(intent) {
|
async submit(intent) {
|
||||||
|
this.presentCommandStatus("Working…", "status");
|
||||||
try {
|
try {
|
||||||
const result = parseEveCommandResult(await this.transport.submitCommand(intent));
|
const result = parseEveCommandResult(await this.transport.submitCommand(intent));
|
||||||
await this.consumeCommandResult(intent, result);
|
await this.consumeCommandResult(intent, result);
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error");
|
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "alert");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.lastSurfaceVersion = "";
|
this.lastSurfaceVersion = "";
|
||||||
|
|
@ -158,7 +147,7 @@ export class EveBrowserProviderHost {
|
||||||
surface: this.selected,
|
surface: this.selected,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.presentCommandStatus(result.receipt.message || result.receipt.state, accepted ? "status" : "error", result.transientProjection);
|
this.presentCommandStatus(result.receipt.message || result.receipt.state, accepted ? "status" : "alert", result.transientProjection);
|
||||||
}
|
}
|
||||||
presentCommandStatus(message, role, transient) {
|
presentCommandStatus(message, role, transient) {
|
||||||
let region = this.host.querySelector(":scope > .eve-command-result-region");
|
let region = this.host.querySelector(":scope > .eve-command-result-region");
|
||||||
|
|
@ -171,15 +160,24 @@ export class EveBrowserProviderHost {
|
||||||
region.replaceChildren(el("p", "eve-command-result-message", message));
|
region.replaceChildren(el("p", "eve-command-result-message", message));
|
||||||
const projection = transient;
|
const projection = transient;
|
||||||
if (projection?.surface?.root) {
|
if (projection?.surface?.root) {
|
||||||
region.append(renderEveComponent(projection.surface.root, {
|
region.append(renderEveComponent(projection.surface.root, scopeEveBrowserOptions(projection, this.loweringOptions())));
|
||||||
activeSurfaceId: projection.surface.id || this.selected?.surfaceId,
|
|
||||||
clientId: this.options.clientId,
|
|
||||||
draftStore: this.draftStore,
|
|
||||||
pluginAdapters: this.pluginAdapters,
|
|
||||||
provider: this.provider,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
loweringOptions() {
|
||||||
|
return {
|
||||||
|
activeSurfaceId: this.selected?.surfaceId,
|
||||||
|
assetUrlResolver: this.transport.resolveAssetUrl,
|
||||||
|
body: this.options.body,
|
||||||
|
clientId: this.options.clientId || "eve-browser",
|
||||||
|
commandSink: intent => this.submit(intent),
|
||||||
|
documentResolver: this.transport.resolveDocument,
|
||||||
|
provider: this.provider,
|
||||||
|
pluginAdapters: this.pluginAdapters,
|
||||||
|
source: this.options.source,
|
||||||
|
statusElement: this.options.statusElement,
|
||||||
|
draftStore: this.draftStore,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function requireCommandCapability(surface) {
|
function requireCommandCapability(surface) {
|
||||||
if (!surface.worldInteraction?.commandBoundary || !surface.worldInteraction.receiptSchema) {
|
if (!surface.worldInteraction?.commandBoundary || !surface.worldInteraction.receiptSchema) {
|
||||||
|
|
@ -633,6 +631,23 @@ function renderEveComponentProjection(node, options) {
|
||||||
wireCommand(button, node, resolveComponentCommandId(props, node), props, options);
|
wireCommand(button, node, resolveComponentCommandId(props, node), props, options);
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
if (kind === "resource.download") {
|
||||||
|
const link = el("a", "cultui-button eve-resource-download", stringProp(props.label, "Download"));
|
||||||
|
assignId(link, node);
|
||||||
|
applyGeneratedLayout(link, layout, style);
|
||||||
|
const uri = firstString(props.uri, props.href, props.assetUri, "");
|
||||||
|
const href = resolveSafeDownloadUrl(uri, options);
|
||||||
|
if (!href) {
|
||||||
|
link.removeAttribute("href");
|
||||||
|
link.setAttribute("aria-disabled", "true");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
link.href = href;
|
||||||
|
link.download = safeDownloadFilename(stringProp(props.filename, "download"));
|
||||||
|
}
|
||||||
|
link.setAttribute("aria-label", stringProp(props.ariaLabel, stringProp(props.label, "Download")));
|
||||||
|
return link;
|
||||||
|
}
|
||||||
if (kind === "control.popup") {
|
if (kind === "control.popup") {
|
||||||
const button = el("button", "cultui-button eve-control-popup", stringProp(props.label, "Open"));
|
const button = el("button", "cultui-button eve-control-popup", stringProp(props.label, "Open"));
|
||||||
assignId(button, node);
|
assignId(button, node);
|
||||||
|
|
@ -1874,6 +1889,9 @@ function renderEditableControl(node, kind, props, layout, style, options) {
|
||||||
else if (props.rows !== undefined) {
|
else if (props.rows !== undefined) {
|
||||||
control.rows = positiveInt(props.rows, 3);
|
control.rows = positiveInt(props.rows, 3);
|
||||||
}
|
}
|
||||||
|
const maxLength = positiveInt(props.maxLength, 0);
|
||||||
|
if (maxLength > 0)
|
||||||
|
control.maxLength = maxLength;
|
||||||
control.value = String(context.value ?? "");
|
control.value = String(context.value ?? "");
|
||||||
control.disabled = context.disabled;
|
control.disabled = context.disabled;
|
||||||
control.name = context.bindingName;
|
control.name = context.bindingName;
|
||||||
|
|
@ -2127,6 +2145,30 @@ function resolveAssetUrl(uri, options) {
|
||||||
}
|
}
|
||||||
return uri;
|
return uri;
|
||||||
}
|
}
|
||||||
|
function resolveSafeDownloadUrl(uri, options) {
|
||||||
|
const resolved = resolveAssetUrl(uri, options).trim();
|
||||||
|
if (!resolved || resolved.startsWith("//"))
|
||||||
|
return "";
|
||||||
|
if (resolved.startsWith("/"))
|
||||||
|
return resolved;
|
||||||
|
try {
|
||||||
|
const base = new URL(document.baseURI);
|
||||||
|
const candidate = new URL(resolved, base);
|
||||||
|
if (!/^https?:$/.test(candidate.protocol) || candidate.origin !== base.origin)
|
||||||
|
return "";
|
||||||
|
return candidate.href;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function safeDownloadFilename(value) {
|
||||||
|
const filename = value
|
||||||
|
.replace(/[\\/:*?"<>|\u0000-\u001f\u007f]/g, "_")
|
||||||
|
.replace(/^\.+/, "")
|
||||||
|
.trim();
|
||||||
|
return filename || "download";
|
||||||
|
}
|
||||||
function loadFontStylesheet(href) {
|
function loadFontStylesheet(href) {
|
||||||
const normalized = typeof href === "string" ? href.trim() : "";
|
const normalized = typeof href === "string" ? href.trim() : "";
|
||||||
if (!normalized)
|
if (!normalized)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ Derived state:
|
||||||
- local drafts and the latest command-result region are renderer-owned
|
- local drafts and the latest command-result region are renderer-owned
|
||||||
transients; refresh may replace the surface projection but may not erase a
|
transients; refresh may replace the surface projection but may not erase a
|
||||||
receipt before the player can perceive it;
|
receipt before the player can perceive it;
|
||||||
|
- a provider-owned transient projection inherits the selected logical
|
||||||
|
surface's advertised command boundary while retaining its own command
|
||||||
|
descriptors and component context;
|
||||||
- loaded font stylesheets are a document resource cache, not active-surface
|
- loaded font stylesheets are a document resource cache, not active-surface
|
||||||
state.
|
state.
|
||||||
|
|
||||||
|
|
@ -42,6 +45,8 @@ Shared paths:
|
||||||
|
|
||||||
- initial render, embedded render, plugin render, binding updates, command
|
- initial render, embedded render, plugin render, binding updates, command
|
||||||
creation, and asset resolution all receive the same host-scoped context;
|
creation, and asset resolution all receive the same host-scoped context;
|
||||||
|
- retained and command-result projections use the same host transport options;
|
||||||
|
a transient presentation surface ID never becomes command-routing authority;
|
||||||
- initial binding hydration and live binding watches use the same component
|
- initial binding hydration and live binding watches use the same component
|
||||||
projection replacement primitive.
|
projection replacement primitive.
|
||||||
- operation capture resolves edited drafts first and otherwise uses the
|
- operation capture resolves edited drafts first and otherwise uses the
|
||||||
|
|
@ -63,4 +68,6 @@ Verification:
|
||||||
- untouched select and numeric defaults are present in captured operations;
|
- untouched select and numeric defaults are present in captured operations;
|
||||||
- accepted and denied command results remain visible after authoritative
|
- accepted and denied command results remain visible after authoritative
|
||||||
refresh;
|
refresh;
|
||||||
|
- controls inside a transient command-result projection invoke its descriptor
|
||||||
|
through the selected provider surface and canonical command sink;
|
||||||
- disposing or rerendering one host cannot unsubscribe the other.
|
- disposing or rerendering one host cannot unsubscribe the other.
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ export class EveBrowserDraftStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(providerId: string, surfaceId: string, bindingNames?: readonly string[]): void {
|
clear(providerId: string, surfaceId: string, bindingNames?: readonly string[]): void {
|
||||||
if (bindingNames?.length) {
|
if (bindingNames !== undefined) {
|
||||||
for (const name of bindingNames) this.values.delete(this.key(providerId, surfaceId, name));
|
for (const name of bindingNames) this.values.delete(this.key(providerId, surfaceId, name));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -269,19 +269,7 @@ export class EveBrowserProviderHost {
|
||||||
const version = `${surface.providerId || ""}:${surface.surface?.id || ""}:${surface.version ?? ""}`;
|
const version = `${surface.providerId || ""}:${surface.surface?.id || ""}:${surface.version ?? ""}`;
|
||||||
if (version === this.lastSurfaceVersion) return;
|
if (version === this.lastSurfaceVersion) return;
|
||||||
this.lastSurfaceVersion = version;
|
this.lastSurfaceVersion = version;
|
||||||
renderEveSurface(surface, this.host, {
|
renderEveSurface(surface, this.host, this.loweringOptions());
|
||||||
activeSurfaceId: this.selected.surfaceId,
|
|
||||||
assetUrlResolver: this.transport.resolveAssetUrl,
|
|
||||||
body: this.options.body,
|
|
||||||
clientId: this.options.clientId || "eve-browser",
|
|
||||||
commandSink: intent => this.submit(intent),
|
|
||||||
documentResolver: this.transport.resolveDocument,
|
|
||||||
provider: this.provider,
|
|
||||||
pluginAdapters: this.pluginAdapters,
|
|
||||||
source: this.options.source,
|
|
||||||
statusElement: this.options.statusElement,
|
|
||||||
draftStore: this.draftStore,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.options.statusElement) {
|
if (this.options.statusElement) {
|
||||||
this.options.statusElement.textContent = error instanceof Error ? error.message : "Eve surface unavailable.";
|
this.options.statusElement.textContent = error instanceof Error ? error.message : "Eve surface unavailable.";
|
||||||
|
|
@ -290,11 +278,12 @@ export class EveBrowserProviderHost {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async submit(intent: EveCommandIntent): Promise<void> {
|
private async submit(intent: EveCommandIntent): Promise<void> {
|
||||||
|
this.presentCommandStatus("Working…", "status");
|
||||||
try {
|
try {
|
||||||
const result = parseEveCommandResult(await this.transport.submitCommand(intent));
|
const result = parseEveCommandResult(await this.transport.submitCommand(intent));
|
||||||
await this.consumeCommandResult(intent, result);
|
await this.consumeCommandResult(intent, result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error");
|
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "alert");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.lastSurfaceVersion = "";
|
this.lastSurfaceVersion = "";
|
||||||
|
|
@ -318,12 +307,12 @@ export class EveBrowserProviderHost {
|
||||||
}
|
}
|
||||||
this.presentCommandStatus(
|
this.presentCommandStatus(
|
||||||
result.receipt.message || result.receipt.state,
|
result.receipt.message || result.receipt.state,
|
||||||
accepted ? "status" : "error",
|
accepted ? "status" : "alert",
|
||||||
result.transientProjection,
|
result.transientProjection,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private presentCommandStatus(message: string, role: "status" | "error", transient?: unknown): void {
|
private presentCommandStatus(message: string, role: "status" | "alert", transient?: unknown): void {
|
||||||
let region = this.host.querySelector<HTMLElement>(":scope > .eve-command-result-region");
|
let region = this.host.querySelector<HTMLElement>(":scope > .eve-command-result-region");
|
||||||
if (!region) {
|
if (!region) {
|
||||||
region = el("section", "eve-command-result-region");
|
region = el("section", "eve-command-result-region");
|
||||||
|
|
@ -334,15 +323,28 @@ export class EveBrowserProviderHost {
|
||||||
region.replaceChildren(el("p", "eve-command-result-message", message));
|
region.replaceChildren(el("p", "eve-command-result-message", message));
|
||||||
const projection = transient as EveSurfaceDocument | undefined;
|
const projection = transient as EveSurfaceDocument | undefined;
|
||||||
if (projection?.surface?.root) {
|
if (projection?.surface?.root) {
|
||||||
region.append(renderEveComponent(projection.surface.root, {
|
region.append(renderEveComponent(
|
||||||
activeSurfaceId: projection.surface.id || this.selected?.surfaceId,
|
projection.surface.root,
|
||||||
clientId: this.options.clientId,
|
scopeEveBrowserOptions(projection, this.loweringOptions()),
|
||||||
draftStore: this.draftStore,
|
));
|
||||||
pluginAdapters: this.pluginAdapters,
|
|
||||||
provider: this.provider,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loweringOptions(): EveBrowserLoweringOptions {
|
||||||
|
return {
|
||||||
|
activeSurfaceId: this.selected?.surfaceId,
|
||||||
|
assetUrlResolver: this.transport.resolveAssetUrl,
|
||||||
|
body: this.options.body,
|
||||||
|
clientId: this.options.clientId || "eve-browser",
|
||||||
|
commandSink: intent => this.submit(intent),
|
||||||
|
documentResolver: this.transport.resolveDocument,
|
||||||
|
provider: this.provider,
|
||||||
|
pluginAdapters: this.pluginAdapters,
|
||||||
|
source: this.options.source,
|
||||||
|
statusElement: this.options.statusElement,
|
||||||
|
draftStore: this.draftStore,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireCommandCapability(surface: EveProviderSurfaceAdvertisement): void {
|
function requireCommandCapability(surface: EveProviderSurfaceAdvertisement): void {
|
||||||
|
|
@ -848,6 +850,27 @@ function renderEveComponentProjection(
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kind === "resource.download") {
|
||||||
|
const link = el(
|
||||||
|
"a",
|
||||||
|
"cultui-button eve-resource-download",
|
||||||
|
stringProp(props.label, "Download"),
|
||||||
|
) as HTMLAnchorElement;
|
||||||
|
assignId(link, node);
|
||||||
|
applyGeneratedLayout(link, layout, style);
|
||||||
|
const uri = firstString(props.uri, props.href, props.assetUri, "");
|
||||||
|
const href = resolveSafeDownloadUrl(uri, options);
|
||||||
|
if (!href) {
|
||||||
|
link.removeAttribute("href");
|
||||||
|
link.setAttribute("aria-disabled", "true");
|
||||||
|
} else {
|
||||||
|
link.href = href;
|
||||||
|
link.download = safeDownloadFilename(stringProp(props.filename, "download"));
|
||||||
|
}
|
||||||
|
link.setAttribute("aria-label", stringProp(props.ariaLabel, stringProp(props.label, "Download")));
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
if (kind === "control.popup") {
|
if (kind === "control.popup") {
|
||||||
const button = el("button", "cultui-button eve-control-popup", stringProp(props.label, "Open")) as HTMLButtonElement;
|
const button = el("button", "cultui-button eve-control-popup", stringProp(props.label, "Open")) as HTMLButtonElement;
|
||||||
assignId(button, node);
|
assignId(button, node);
|
||||||
|
|
@ -2236,6 +2259,8 @@ function renderEditableControl(
|
||||||
} else if (props.rows !== undefined) {
|
} else if (props.rows !== undefined) {
|
||||||
control.rows = positiveInt(props.rows, 3);
|
control.rows = positiveInt(props.rows, 3);
|
||||||
}
|
}
|
||||||
|
const maxLength = positiveInt(props.maxLength, 0);
|
||||||
|
if (maxLength > 0) control.maxLength = maxLength;
|
||||||
control.value = String(context.value ?? "");
|
control.value = String(context.value ?? "");
|
||||||
control.disabled = context.disabled;
|
control.disabled = context.disabled;
|
||||||
control.name = context.bindingName;
|
control.name = context.bindingName;
|
||||||
|
|
@ -2510,6 +2535,28 @@ function resolveAssetUrl(uri: string, options: EveBrowserLoweringOptions): strin
|
||||||
return uri;
|
return uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSafeDownloadUrl(uri: string, options: EveBrowserLoweringOptions): string {
|
||||||
|
const resolved = resolveAssetUrl(uri, options).trim();
|
||||||
|
if (!resolved || resolved.startsWith("//")) return "";
|
||||||
|
if (resolved.startsWith("/")) return resolved;
|
||||||
|
try {
|
||||||
|
const base = new URL(document.baseURI);
|
||||||
|
const candidate = new URL(resolved, base);
|
||||||
|
if (!/^https?:$/.test(candidate.protocol) || candidate.origin !== base.origin) return "";
|
||||||
|
return candidate.href;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDownloadFilename(value: string): string {
|
||||||
|
const filename = value
|
||||||
|
.replace(/[\\/:*?"<>|\u0000-\u001f\u007f]/g, "_")
|
||||||
|
.replace(/^\.+/, "")
|
||||||
|
.trim();
|
||||||
|
return filename || "download";
|
||||||
|
}
|
||||||
|
|
||||||
function loadFontStylesheet(href: unknown): void {
|
function loadFontStylesheet(href: unknown): void {
|
||||||
const normalized = typeof href === "string" ? href.trim() : "";
|
const normalized = typeof href === "string" ? href.trim() : "";
|
||||||
if (!normalized) return;
|
if (!normalized) return;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import { EveBrowserDraftStore, renderEveSurface } from "../dist/index.js";
|
import { EveBrowserDraftStore, EveBrowserProviderHost, renderEveComponent, renderEveSurface } from "../dist/index.js";
|
||||||
|
|
||||||
function installDom() {
|
function installDom() {
|
||||||
const dom = new JSDOM("<!doctype html><html><head></head><body><div id='a'></div><div id='b'></div></body></html>", {
|
const dom = new JSDOM("<!doctype html><html><head></head><body><div id='a'></div><div id='b'></div></body></html>", {
|
||||||
|
|
@ -31,6 +31,40 @@ function installDom() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("resource downloads lower to same-origin one-time links without scriptable URLs", () => {
|
||||||
|
const restoreDom = installDom();
|
||||||
|
try {
|
||||||
|
const link = renderEveComponent({
|
||||||
|
id: "campaign.export",
|
||||||
|
kind: "resource.download",
|
||||||
|
props: {
|
||||||
|
label: "Download campaign",
|
||||||
|
uri: "/ghostlight/api/eve/resources/opaque-grant",
|
||||||
|
filename: "campaign:bad?.cc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(link.tagName, "A");
|
||||||
|
assert.equal(link.getAttribute("href"), "/ghostlight/api/eve/resources/opaque-grant");
|
||||||
|
assert.equal(link.getAttribute("download"), "campaign_bad_.cc");
|
||||||
|
assert.equal(link.getAttribute("aria-disabled"), null);
|
||||||
|
|
||||||
|
const rejected = renderEveComponent({
|
||||||
|
kind: "resource.download",
|
||||||
|
props: { label: "Bad", uri: "javascript:alert(1)" },
|
||||||
|
});
|
||||||
|
assert.equal(rejected.getAttribute("href"), null);
|
||||||
|
assert.equal(rejected.getAttribute("aria-disabled"), "true");
|
||||||
|
|
||||||
|
const crossOrigin = renderEveComponent({
|
||||||
|
kind: "resource.download",
|
||||||
|
props: { label: "Bad", uri: "https://elsewhere.test/private.cc" },
|
||||||
|
});
|
||||||
|
assert.equal(crossOrigin.getAttribute("href"), null);
|
||||||
|
} finally {
|
||||||
|
restoreDom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function testInputAdapter(onRender = () => {}) {
|
function testInputAdapter(onRender = () => {}) {
|
||||||
return {
|
return {
|
||||||
pluginId: "test.input",
|
pluginId: "test.input",
|
||||||
|
|
@ -225,7 +259,7 @@ test("editable bindings preserve local drafts across authoritative refresh and o
|
||||||
{
|
{
|
||||||
id: "composer",
|
id: "composer",
|
||||||
kind: "control.input.textarea",
|
kind: "control.input.textarea",
|
||||||
props: { label: "Message", value },
|
props: { label: "Message", value, maxLength: 2000 },
|
||||||
stateBindings: [{
|
stateBindings: [{
|
||||||
targetProp: "value",
|
targetProp: "value",
|
||||||
pointerId: "ghostlight.local.composer.message",
|
pointerId: "ghostlight.local.composer.message",
|
||||||
|
|
@ -245,6 +279,7 @@ test("editable bindings preserve local drafts across authoritative refresh and o
|
||||||
const options = { provider, draftStore: drafts, commandSink: intent => commands.push(intent) };
|
const options = { provider, draftStore: drafts, commandSink: intent => commands.push(intent) };
|
||||||
renderEveSurface(surface(""), host, options);
|
renderEveSurface(surface(""), host, options);
|
||||||
const composer = host.querySelector("textarea");
|
const composer = host.querySelector("textarea");
|
||||||
|
assert.equal(composer.maxLength, 2000);
|
||||||
composer.value = "The world should remember this.";
|
composer.value = "The world should remember this.";
|
||||||
composer.dispatchEvent(new window.Event("input", { bubbles: true }));
|
composer.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||||
|
|
||||||
|
|
@ -265,6 +300,20 @@ test("editable bindings preserve local drafts across authoritative refresh and o
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("an explicit empty draft directive clears no unrelated bindings", () => {
|
||||||
|
const drafts = new EveBrowserDraftStore();
|
||||||
|
drafts.set("ghostlight", "ghostlight.play", "channel_id", "private:host");
|
||||||
|
drafts.set("ghostlight", "ghostlight.play", "message", "still drafting");
|
||||||
|
|
||||||
|
drafts.clear("ghostlight", "ghostlight.play", []);
|
||||||
|
assert.equal(drafts.get("ghostlight", "ghostlight.play", "channel_id"), "private:host");
|
||||||
|
assert.equal(drafts.get("ghostlight", "ghostlight.play", "message"), "still drafting");
|
||||||
|
|
||||||
|
drafts.clear("ghostlight", "ghostlight.play");
|
||||||
|
assert.equal(drafts.has("ghostlight", "ghostlight.play", "channel_id"), false);
|
||||||
|
assert.equal(drafts.has("ghostlight", "ghostlight.play", "message"), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("operations capture untouched authored choice defaults", () => {
|
test("operations capture untouched authored choice defaults", () => {
|
||||||
const restoreDom = installDom();
|
const restoreDom = installDom();
|
||||||
try {
|
try {
|
||||||
|
|
@ -394,7 +443,7 @@ test("component captures extend rather than disappear behind operation descripto
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("authoritative refresh preserves the renderer-owned command result", () => {
|
test("authoritative refresh preserves the renderer-owned accessible command result", () => {
|
||||||
const restoreDom = installDom();
|
const restoreDom = installDom();
|
||||||
try {
|
try {
|
||||||
const host = document.querySelector("#a");
|
const host = document.querySelector("#a");
|
||||||
|
|
@ -406,7 +455,7 @@ test("authoritative refresh preserves the renderer-owned command result", () =>
|
||||||
renderEveSurface(surface(7), host);
|
renderEveSurface(surface(7), host);
|
||||||
const result = document.createElement("section");
|
const result = document.createElement("section");
|
||||||
result.className = "eve-command-result-region";
|
result.className = "eve-command-result-region";
|
||||||
result.setAttribute("role", "error");
|
result.setAttribute("role", "alert");
|
||||||
result.textContent = "The command was denied.";
|
result.textContent = "The command was denied.";
|
||||||
host.append(result);
|
host.append(result);
|
||||||
|
|
||||||
|
|
@ -418,3 +467,188 @@ test("authoritative refresh preserves the renderer-owned command result", () =>
|
||||||
restoreDom();
|
restoreDom();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("transient projection controls use the selected provider command boundary", async () => {
|
||||||
|
const restoreDom = installDom();
|
||||||
|
try {
|
||||||
|
const host = document.querySelector("#a");
|
||||||
|
const commands = [];
|
||||||
|
const descriptor = (command, payloadSchema) => ({
|
||||||
|
schema: "gamecult.eve.command.v1",
|
||||||
|
command,
|
||||||
|
payloadSchema,
|
||||||
|
});
|
||||||
|
const surfaceDocument = {
|
||||||
|
type: "surface-state",
|
||||||
|
schema: "gamecult.eve.surface.v1",
|
||||||
|
providerId: "ghostlight",
|
||||||
|
providerKind: "narrative.simulation",
|
||||||
|
title: "Ghostlight",
|
||||||
|
version: 7,
|
||||||
|
updatedAtUtc: "2026-08-22T00:00:00Z",
|
||||||
|
surface: {
|
||||||
|
id: "ghostlight.play",
|
||||||
|
styles: {},
|
||||||
|
root: {
|
||||||
|
id: "root",
|
||||||
|
kind: "control.button",
|
||||||
|
props: { label: "Assess", command: "world.assess" },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commands: [descriptor("world.assess", "ghostlight.player_action_assess.v1")],
|
||||||
|
};
|
||||||
|
const transientProjection = {
|
||||||
|
type: "surface-state",
|
||||||
|
schema: "gamecult.eve.surface.v1",
|
||||||
|
providerId: "ghostlight",
|
||||||
|
providerKind: "narrative.simulation.command-result",
|
||||||
|
title: "Assessment",
|
||||||
|
version: 7,
|
||||||
|
updatedAtUtc: "2026-08-22T00:00:01Z",
|
||||||
|
surface: {
|
||||||
|
id: "ghostlight.command-result",
|
||||||
|
styles: {},
|
||||||
|
root: {
|
||||||
|
id: "roll",
|
||||||
|
kind: "control.button",
|
||||||
|
props: {
|
||||||
|
label: "Roll the d20",
|
||||||
|
command: "world.attempt",
|
||||||
|
action: { assessment_digest: "sha256:assessment" },
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commands: [descriptor("world.attempt", "ghostlight.player_action_attempt.v1")],
|
||||||
|
};
|
||||||
|
const receipt = command => ({
|
||||||
|
schema: "gamecult.eve.command_receipt.v1",
|
||||||
|
receiptId: `receipt:${command}`,
|
||||||
|
commandId: `command:${command}`,
|
||||||
|
command,
|
||||||
|
state: "accepted",
|
||||||
|
ownerRepo: "GameCult/Ghostlight",
|
||||||
|
authority: "WorldKernel",
|
||||||
|
providerId: "ghostlight",
|
||||||
|
surfaceId: "ghostlight.play",
|
||||||
|
message: "accepted",
|
||||||
|
diagnostics: [],
|
||||||
|
issuedAtUtc: "2026-08-22T00:00:02Z",
|
||||||
|
sourceVersion: 7,
|
||||||
|
});
|
||||||
|
const transport = {
|
||||||
|
providerAdvertisement: async () => ({
|
||||||
|
providerId: "ghostlight",
|
||||||
|
surfaces: [{
|
||||||
|
surfaceId: "ghostlight.play",
|
||||||
|
worldInteraction: {
|
||||||
|
commandBoundary: "ghostlight.eve.commands",
|
||||||
|
receiptSchema: "gamecult.eve.command_result.v1",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
surface: async () => surfaceDocument,
|
||||||
|
submitCommand: async intent => {
|
||||||
|
commands.push(intent);
|
||||||
|
return {
|
||||||
|
schema: "gamecult.eve.command_result.v1",
|
||||||
|
receipt: receipt(intent.operation.operationId),
|
||||||
|
...(intent.operation.operationId === "world.assess" ? { transientProjection } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const providerHost = new EveBrowserProviderHost(host, transport, { pollMs: 0 });
|
||||||
|
await providerHost.start();
|
||||||
|
|
||||||
|
host.querySelector("button").click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
const roll = host.querySelector(".eve-command-result-region button");
|
||||||
|
assert.equal(roll.textContent, "Roll the d20");
|
||||||
|
roll.click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.deepEqual(commands.map(command => command.operation.operationId), ["world.assess", "world.attempt"]);
|
||||||
|
assert.equal(commands[1].surfaceId, "ghostlight.play");
|
||||||
|
assert.equal(commands[1].operation.schemaId, "ghostlight.player_action_attempt.v1");
|
||||||
|
assert.equal(commands[1].commandBoundary, "ghostlight.eve.commands");
|
||||||
|
assert.equal(commands[1].payload.assessment_digest, "sha256:assessment");
|
||||||
|
providerHost.stop();
|
||||||
|
} finally {
|
||||||
|
restoreDom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provider host replaces stale receipts with immediate renderer-local progress", async () => {
|
||||||
|
const restoreDom = installDom();
|
||||||
|
try {
|
||||||
|
const host = document.querySelector("#a");
|
||||||
|
let finish;
|
||||||
|
const commandResult = new Promise(resolve => { finish = resolve; });
|
||||||
|
const transport = {
|
||||||
|
providerAdvertisement: async () => ({
|
||||||
|
providerId: "ghostlight",
|
||||||
|
surfaces: [{
|
||||||
|
surfaceId: "ghostlight.play",
|
||||||
|
worldInteraction: {
|
||||||
|
commandBoundary: "ghostlight.eve.commands",
|
||||||
|
receiptSchema: "gamecult.eve.command_result.v1",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
surface: async () => ({
|
||||||
|
type: "surface-state",
|
||||||
|
schema: "gamecult.eve.surface.v1",
|
||||||
|
providerId: "ghostlight",
|
||||||
|
title: "Ghostlight",
|
||||||
|
version: 7,
|
||||||
|
surface: {
|
||||||
|
id: "ghostlight.play",
|
||||||
|
root: {
|
||||||
|
id: "assess",
|
||||||
|
kind: "control.button",
|
||||||
|
props: { label: "Assess", command: "world.assess" },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commands: [{
|
||||||
|
schema: "gamecult.eve.command.v1",
|
||||||
|
command: "world.assess",
|
||||||
|
payloadSchema: "ghostlight.player_action_assess.v1",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
submitCommand: async () => commandResult,
|
||||||
|
};
|
||||||
|
const providerHost = new EveBrowserProviderHost(host, transport, { pollMs: 0 });
|
||||||
|
await providerHost.start();
|
||||||
|
|
||||||
|
host.querySelector("button").click();
|
||||||
|
assert.equal(host.querySelector(".eve-command-result-message").textContent, "Working…");
|
||||||
|
assert.equal(host.querySelector(".eve-command-result-region").getAttribute("role"), "status");
|
||||||
|
|
||||||
|
finish({
|
||||||
|
schema: "gamecult.eve.command_result.v1",
|
||||||
|
receipt: {
|
||||||
|
schema: "gamecult.eve.command_receipt.v1",
|
||||||
|
receiptId: "receipt:assess",
|
||||||
|
commandId: "command:assess",
|
||||||
|
command: "world.assess",
|
||||||
|
state: "denied",
|
||||||
|
ownerRepo: "GameCult/Ghostlight",
|
||||||
|
authority: "WorldKernel",
|
||||||
|
providerId: "ghostlight",
|
||||||
|
surfaceId: "ghostlight.play",
|
||||||
|
message: "No world state changed.",
|
||||||
|
diagnostics: [],
|
||||||
|
issuedAtUtc: "2026-08-23T00:00:00Z",
|
||||||
|
sourceVersion: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
assert.equal(host.querySelector(".eve-command-result-message").textContent, "No world state changed.");
|
||||||
|
assert.equal(host.querySelector(".eve-command-result-region").getAttribute("role"), "alert");
|
||||||
|
providerHost.stop();
|
||||||
|
} finally {
|
||||||
|
restoreDom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue