Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
672c0c1e9f Lower authenticated resource downloads 2026-08-25 18:00:03 +02:00
Meta Vens
436b513431 Document EveCanvas as native Gjallar lowering 2026-08-25 04:29:07 +02:00
Meta Vens
29b0d7741d Make EveCanvas the native Gjallar runtime 2026-08-25 03:57:20 +02:00
Meta Vens
19c3dcf917 Show immediate Eve command progress 2026-08-23 11:08:53 +02:00
Meta Vens
e5a81185bb Route transient Eve controls through provider host 2026-08-23 01:26:45 +02:00
Meta Vens
3a5e2057e0 Expose editable limits and accessible command errors 2026-08-22 21:14:08 +02:00
Meta Vens
af05248af5 Preserve drafts on empty clear directives 2026-08-22 19:12:34 +02:00
16 changed files with 711 additions and 309 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View 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
View 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

View file

@ -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 {

View file

@ -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

View file

@ -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.

View file

@ -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 |

View file

@ -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.

View file

@ -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;

View file

@ -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)

View file

@ -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.

View file

@ -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;

View file

@ -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();
}
});