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/EVEGLView.mm \
Sources/EVEFrameStreamClient.m \
Sources/EVEDashboardClient.m \
Sources/EVEHermodrClient.m \
Sources/EVESensorUplinkClient.m \
Sources/EVEH264StreamDecoder.m

View file

@ -102,8 +102,10 @@ schema or composition builders.
## Current Mechanism
The current checked-in client is the iOS Theos app, still named `EveCanvas` at
the bundle level until the wider Eve runtime split exists.
The current checked-in iOS runtime is the Theos app still named `EveCanvas` at
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 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`.
- `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.
- a full-screen `UIImageView` that displays CEF relay frames.
- `EVEFrameStreamClient`, a native WebSocket client for frame/input transport.
- an optional full-screen `UIImageView` and `EVEFrameStreamClient` for legacy
configured frame streams.
- a native UIKit `UILabel` overlay for crisp Retina status text.
- `CADisplayLink` for frame ticking.
- `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
microphone observations so sensor traffic does not block display/control
traffic.
- `EVEDashboardClient` opens the native Mimir dashboard socket and receives
scene/control state snapshots.
- `EVEHermodrClient` polls Odin-owned Hermodr for Gjallar's canonical
`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
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,
@ -150,8 +157,8 @@ payloads as `mimir.eve_media_observation.v1`.
- CultNet carries typed surface state, commands, and timestamped sensor packets.
Edge JSON is tolerated only as an interoperability envelope while the typed
CultMesh document shape is being proven.
- UIKit is the current iOS streamed-frame owner; OpenGL ES is fallback/local
render.
- UIKit is the current iOS `gjallar.overview` lowering owner. Streamed frames
are an optional non-canonical input; OpenGL ES remains fallback/local render.
- The status bar stays hidden.
- Eve clients own local sensor reads and timestamps. Mimir owns synchronization
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
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
dotnet run --project E:\Projects\Mimir\src\Mimir.EveDashboard\Mimir.EveDashboard.csproj -- --port 8795
```
EveCanvas connects to the Mimir dashboard WebSocket lowering for native retained
`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:
Configure one or more Hermodr base URLs through the `EVE_HERMODR_URLS` array in
`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
`/hermodr/commands/eve`:
- `select`
- `move`
@ -347,14 +353,13 @@ without losing the basic app deployment path.
## Next Cut
- Connect the existing browser reference to a clean-consumer, networked
CultMesh sample and use it as the visual/behavior oracle for native clients.
- Split the shared CultNet/CultMesh surface contract from the iOS app code and
keep `gamecult.eve.surface.v1` as the renderer-facing contract.
- Admit live provider-owned Eve surfaces into Odin/CultMesh so Gjallar's
aggregate has real children to tile; advertisements without readable surface
state remain catalog entries, not invented UI.
- 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
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
retained `surface.root` renderer, and add the browser client against the same
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 "EVEFrameStreamClient.h"
#import "EVEDashboardClient.h"
#import "EVEHermodrClient.h"
#import "EVEGLView.h"
#import "EVEH264StreamDecoder.h"
#import "EVESensorUplinkClient.h"
@ -170,7 +170,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
return urls;
}
@interface EVEViewController () <EVEFrameStreamClientDelegate, EVEDashboardClientDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, UIGestureRecognizerDelegate>
@interface EVEViewController () <EVEFrameStreamClientDelegate, EVEHermodrClientDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, UIGestureRecognizerDelegate>
@property(nonatomic, strong) EVEGLView *glView;
@property(nonatomic, strong) UIImageView *streamImageView;
@ -189,7 +189,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
@property(nonatomic, strong) CADisplayLink *displayLink;
@property(nonatomic, strong) CMMotionManager *motionManager;
@property(nonatomic, strong) EVEFrameStreamClient *streamClient;
@property(nonatomic, strong) EVEDashboardClient *dashboardClient;
@property(nonatomic, strong) EVEHermodrClient *hermodrClient;
@property(nonatomic, strong) EVESensorUplinkClient *cameraUplink;
@property(nonatomic, strong) EVESensorUplinkClient *micUplink;
@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 connect];
NSArray<NSURL *> *dashboardURLs = EVEConfiguredURLs(@"EVE_DASHBOARD_URLS");
self.dashboardClient = [[EVEDashboardClient alloc] initWithURLs:dashboardURLs delegate:self];
[self.dashboardClient connect];
NSArray<NSURL *> *hermodrURLs = EVEConfiguredURLs(@"EVE_HERMODR_URLS");
self.hermodrClient = [[EVEHermodrClient alloc] initWithBaseURLs:hermodrURLs
providerId:@"gjallar.overview"
surfaceId:@"gjallar.overview"
delegate:self];
[self.hermodrClient connect];
NSArray<NSURL *> *cameraURLs = EVEConfiguredURLs(@"EVE_CAMERA_URLS");
NSArray<NSURL *> *micURLs = EVEConfiguredURLs(@"EVE_MIC_URLS");
@ -310,7 +313,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
- (void)dealloc {
[self.displayLink invalidate];
[self.streamClient disconnect];
[self.dashboardClient disconnect];
[self.hermodrClient disconnect];
[self.cameraUplink disconnect];
[self.micUplink disconnect];
[self.captureSession stopRunning];
@ -791,7 +794,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
return CGPointMake(x, y);
}
- (void)dashboardClient:(EVEDashboardClient *)client didReceiveState:(NSDictionary *)state {
- (void)hermodrClient:(EVEHermodrClient *)client didReceiveSurface:(NSDictionary *)state {
(void)client;
NSString *title = [state[@"title"] isKindOfClass:NSString.class] ? state[@"title"] : @"dashboard";
self.dashboardStatus = [NSString stringWithFormat:@"%@ v%@", title, state[@"version"] ?: @"?"];
@ -803,10 +806,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
self.selectedNodeId = selected;
}
NSArray *nodes = state[@"nodes"];
if (![nodes isKindOfClass:NSArray.class]) {
return;
}
NSArray *nodes = [state[@"nodes"] isKindOfClass:NSArray.class] ? state[@"nodes"] : @[];
[self.dashboardNodes removeAllObjects];
NSMutableSet<NSString *> *liveNodeIds = [NSMutableSet set];
@ -876,19 +876,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
- (BOOL)shouldRenderFullscreenSurfaceForState:(NSDictionary *)state {
NSDictionary *surface = [state[@"surface"] isKindOfClass:NSDictionary.class] ? state[@"surface"] : nil;
NSDictionary *root = [surface[@"root"] isKindOfClass:NSDictionary.class] ? surface[@"root"] : nil;
if (!root) {
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"];
return root != nil;
}
- (void)renderFullscreenSurfaceState:(NSDictionary *)state title:(NSString *)title version:(id)version {
@ -896,8 +884,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
[view removeFromSuperview];
}
NSString *providerId = [state[@"providerId"] isKindOfClass:NSString.class] ? state[@"providerId"] : @"";
if ([providerId isEqualToString:@"odin.allseer"] && [self renderOdinInterfaceWallForState:state]) {
if ([self renderAggregateSurfaceWallForState:state]) {
return;
}
@ -932,7 +919,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
]];
}
- (BOOL)renderOdinInterfaceWallForState:(NSDictionary *)state {
- (BOOL)renderAggregateSurfaceWallForState:(NSDictionary *)state {
[self.surfaceDashboardView layoutIfNeeded];
CGRect bounds = self.surfaceDashboardView.bounds;
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 *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"] : @[];
NSMutableArray<NSDictionary *> *interfaces = [NSMutableArray array];
for (NSDictionary *child in children) {
@ -948,10 +940,10 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
continue;
}
NSString *kind = [child[@"kind"] isKindOfClass:NSString.class] ? child[@"kind"] : @"";
NSDictionary *props = [child[@"props"] isKindOfClass:NSDictionary.class] ? child[@"props"] : @{};
NSDictionary *layout = [props[@"layout"] isKindOfClass:NSDictionary.class] ? props[@"layout"] : @{};
NSNumber *visible = [layout[@"visible"] isKindOfClass:NSNumber.class] ? layout[@"visible"] : @YES;
if ([kind isEqualToString:@"interface"] && visible.boolValue) {
NSDictionary *layout = [self surfaceLayoutForElement:child];
id visible = layout[@"visible"];
BOOL isVisible = !visible || ![visible respondsToSelector:@selector(boolValue)] || [visible boolValue];
if ([kind isEqualToString:@"interface"] && isVisible) {
[interfaces addObject:child];
}
}
@ -965,9 +957,20 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
wall.backgroundColor = [self fensalirBackdropColor];
[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);
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) {
NSDictionary *interface = [placement[@"interface"] isKindOfClass:NSDictionary.class] ? placement[@"interface"] : nil;
NSValue *frameValue = [placement[@"frame"] isKindOfClass:NSValue.class] ? placement[@"frame"] : nil;
@ -975,17 +978,18 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
if (!interface || !frameValue) {
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;
}
- (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];
for (NSDictionary *interface in interfaces) {
double weight = [self surfaceWeightForElement:interface];
[items addObject:@{@"interface": interface, @"weight": @(MAX(0.65, weight))}];
NSDictionary *layout = [self surfaceLayoutForElement:interface];
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) {
double lw = [left[@"weight"] doubleValue];
@ -1000,11 +1004,11 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
}];
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;
}
- (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) {
return;
}
@ -1040,15 +1044,15 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
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 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 packOdinItems:second bounds:right gap:gap into:placements];
[self packAggregateItems:first bounds:left gap:gap into:placements];
[self packAggregateItems:second bounds:right gap:gap into:placements];
} else {
CGFloat firstHeight = floor((bounds.size.height - gap) * (CGFloat)ratio);
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 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 packOdinItems:second bounds:bottom gap:gap into:placements];
[self packAggregateItems:first bounds:top 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);
}
- (double)surfaceWeightForElement:(NSDictionary *)element {
NSString *kind = [element[@"kind"] isKindOfClass:NSString.class] ? element[@"kind"] : @"";
- (NSDictionary *)surfaceLayoutForElement:(NSDictionary *)element {
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"] : @{};
NSArray *children = [element[@"children"] isKindOfClass:NSArray.class] ? element[@"children"] : @[];
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);
return [props[@"layout"] isKindOfClass:NSDictionary.class] ? props[@"layout"] : @{};
}
- (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"] : @{};
UIView *tile = [[UIView alloc] initWithFrame:frame];
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;
self.dashboardStatus = 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));
[self.dashboardClient sendCommand:@{@"type": @"scale", @"nodeId": nodeId, @"scale": @(scale)}];
[self.hermodrClient sendCommand:@{@"type": @"scale", @"nodeId": nodeId, @"scale": @(scale)}];
}
- (void)nodeRotated:(UIRotationGestureRecognizer *)recognizer {
@ -2037,7 +2029,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
}
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 {
@ -2045,7 +2037,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
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 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 {
@ -2063,11 +2055,11 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
NSString *command = [node[@"command"] isKindOfClass:NSString.class] ? node[@"command"] : nil;
NSString *providerId = [node[@"providerId"] isKindOfClass:NSString.class] ? node[@"providerId"] : nil;
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;
}
[self.dashboardClient sendCommand:@{@"type": @"select", @"nodeId": nodeId}];
[self.hermodrClient sendCommand:@{@"type": @"select", @"nodeId": nodeId}];
}
- (void)toolbarButtonPressed:(UIButton *)button {
@ -2077,7 +2069,7 @@ static NSArray<NSURL *> *EVEConfiguredURLs(NSString *key) {
return;
}
[self.dashboardClient sendCommand:@{@"type": action, @"nodeId": nodeId}];
[self.hermodrClient sendCommand:@{@"type": action, @"nodeId": nodeId}];
}
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {

View file

@ -2,7 +2,7 @@ Package: org.gamecult.evecanvas
Name: EveCanvas
Version: 0.1.0
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>
Author: GameCult
Section: Applications

View file

@ -132,11 +132,11 @@ Fensalir Direct2D:
## Current Proof
The current native app consumes Mimir's dashboard WebSocket lowering for retained
dashboard state. That lowering is shaped like the future API: retained dashboard
state, provider switching, commands, and typed node metadata. It is a renderer
path over daemon-owned state, not provider discovery authority. Live provider
discovery belongs to Odin/CultMesh advertisements.
The current native iOS app consumes Gjallar's aggregate Eve surface through
Odin-owned Hermodr. Gjallar owns visible provider membership and weighted-bisect
layout intent. Hermodr only lowers the typed CultMesh record at the HTTP edge;
EveCanvas lowers the returned `surface.root` into UIKit and posts commands back
through the same bridge. Provider discovery remains Odin/CultMesh authority.
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
@ -156,12 +156,12 @@ pressure across the bench.
## Next Cut
1. Extract the shared surface schema from the iOS app and Mimir broker into a
versioned CultMesh contract.
2. Build the browser Eve runtime as the canonical renderer and comparison
target.
3. Add a replay harness that feeds recorded surface/sensor documents to browser
1. Admit readable provider-owned surface-state records into Odin so Gjallar can
compose live children instead of catalog-only advertisements.
2. Add a replay harness that feeds recorded surface/sensor documents to browser
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
typed envelope.
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 |
| 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 |
| 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 |
| 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 |

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

View file

@ -158,6 +158,7 @@ export declare class EveBrowserProviderHost {
private submit;
private consumeCommandResult;
private presentCommandStatus;
private loweringOptions;
}
export interface EveEmbeddedDocumentRequest {
documentId: string;

View file

@ -16,7 +16,7 @@ export class EveBrowserDraftStore {
this.values.set(this.key(providerId, surfaceId, bindingName), value);
}
clear(providerId, surfaceId, bindingNames) {
if (bindingNames?.length) {
if (bindingNames !== undefined) {
for (const name of bindingNames)
this.values.delete(this.key(providerId, surfaceId, name));
return;
@ -111,19 +111,7 @@ export class EveBrowserProviderHost {
if (version === this.lastSurfaceVersion)
return;
this.lastSurfaceVersion = version;
renderEveSurface(surface, this.host, {
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,
});
renderEveSurface(surface, this.host, this.loweringOptions());
}
catch (error) {
if (this.options.statusElement) {
@ -132,12 +120,13 @@ export class EveBrowserProviderHost {
}
}
async submit(intent) {
this.presentCommandStatus("Working…", "status");
try {
const result = parseEveCommandResult(await this.transport.submitCommand(intent));
await this.consumeCommandResult(intent, result);
}
catch (error) {
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error");
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "alert");
return;
}
this.lastSurfaceVersion = "";
@ -158,7 +147,7 @@ export class EveBrowserProviderHost {
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) {
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));
const projection = transient;
if (projection?.surface?.root) {
region.append(renderEveComponent(projection.surface.root, {
activeSurfaceId: projection.surface.id || this.selected?.surfaceId,
clientId: this.options.clientId,
draftStore: this.draftStore,
pluginAdapters: this.pluginAdapters,
provider: this.provider,
}));
region.append(renderEveComponent(projection.surface.root, scopeEveBrowserOptions(projection, this.loweringOptions())));
}
}
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) {
if (!surface.worldInteraction?.commandBoundary || !surface.worldInteraction.receiptSchema) {
@ -633,6 +631,23 @@ function renderEveComponentProjection(node, options) {
wireCommand(button, node, resolveComponentCommandId(props, node), props, options);
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") {
const button = el("button", "cultui-button eve-control-popup", stringProp(props.label, "Open"));
assignId(button, node);
@ -1874,6 +1889,9 @@ function renderEditableControl(node, kind, props, layout, style, options) {
else if (props.rows !== undefined) {
control.rows = positiveInt(props.rows, 3);
}
const maxLength = positiveInt(props.maxLength, 0);
if (maxLength > 0)
control.maxLength = maxLength;
control.value = String(context.value ?? "");
control.disabled = context.disabled;
control.name = context.bindingName;
@ -2127,6 +2145,30 @@ function resolveAssetUrl(uri, options) {
}
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) {
const normalized = typeof href === "string" ? href.trim() : "";
if (!normalized)

View file

@ -28,6 +28,9 @@ Derived state:
- local drafts and the latest command-result region are renderer-owned
transients; refresh may replace the surface projection but may not erase a
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
state.
@ -42,6 +45,8 @@ Shared paths:
- initial render, embedded render, plugin render, binding updates, command
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
projection replacement primitive.
- 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;
- accepted and denied command results remain visible after authoritative
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.

View file

@ -167,7 +167,7 @@ export class EveBrowserDraftStore {
}
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));
return;
}
@ -269,19 +269,7 @@ export class EveBrowserProviderHost {
const version = `${surface.providerId || ""}:${surface.surface?.id || ""}:${surface.version ?? ""}`;
if (version === this.lastSurfaceVersion) return;
this.lastSurfaceVersion = version;
renderEveSurface(surface, this.host, {
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,
});
renderEveSurface(surface, this.host, this.loweringOptions());
} catch (error) {
if (this.options.statusElement) {
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> {
this.presentCommandStatus("Working…", "status");
try {
const result = parseEveCommandResult(await this.transport.submitCommand(intent));
await this.consumeCommandResult(intent, result);
} catch (error) {
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error");
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "alert");
return;
}
this.lastSurfaceVersion = "";
@ -318,12 +307,12 @@ export class EveBrowserProviderHost {
}
this.presentCommandStatus(
result.receipt.message || result.receipt.state,
accepted ? "status" : "error",
accepted ? "status" : "alert",
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");
if (!region) {
region = el("section", "eve-command-result-region");
@ -334,15 +323,28 @@ export class EveBrowserProviderHost {
region.replaceChildren(el("p", "eve-command-result-message", message));
const projection = transient as EveSurfaceDocument | undefined;
if (projection?.surface?.root) {
region.append(renderEveComponent(projection.surface.root, {
activeSurfaceId: projection.surface.id || this.selected?.surfaceId,
clientId: this.options.clientId,
draftStore: this.draftStore,
pluginAdapters: this.pluginAdapters,
provider: this.provider,
}));
region.append(renderEveComponent(
projection.surface.root,
scopeEveBrowserOptions(projection, this.loweringOptions()),
));
}
}
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 {
@ -848,6 +850,27 @@ function renderEveComponentProjection(
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") {
const button = el("button", "cultui-button eve-control-popup", stringProp(props.label, "Open")) as HTMLButtonElement;
assignId(button, node);
@ -2236,6 +2259,8 @@ function renderEditableControl(
} else if (props.rows !== undefined) {
control.rows = positiveInt(props.rows, 3);
}
const maxLength = positiveInt(props.maxLength, 0);
if (maxLength > 0) control.maxLength = maxLength;
control.value = String(context.value ?? "");
control.disabled = context.disabled;
control.name = context.bindingName;
@ -2510,6 +2535,28 @@ function resolveAssetUrl(uri: string, options: EveBrowserLoweringOptions): strin
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 {
const normalized = typeof href === "string" ? href.trim() : "";
if (!normalized) return;

View file

@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import { EveBrowserDraftStore, renderEveSurface } from "../dist/index.js";
import { EveBrowserDraftStore, EveBrowserProviderHost, renderEveComponent, renderEveSurface } from "../dist/index.js";
function installDom() {
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 = () => {}) {
return {
pluginId: "test.input",
@ -225,7 +259,7 @@ test("editable bindings preserve local drafts across authoritative refresh and o
{
id: "composer",
kind: "control.input.textarea",
props: { label: "Message", value },
props: { label: "Message", value, maxLength: 2000 },
stateBindings: [{
targetProp: "value",
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) };
renderEveSurface(surface(""), host, options);
const composer = host.querySelector("textarea");
assert.equal(composer.maxLength, 2000);
composer.value = "The world should remember this.";
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", () => {
const restoreDom = installDom();
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();
try {
const host = document.querySelector("#a");
@ -406,7 +455,7 @@ test("authoritative refresh preserves the renderer-owned command result", () =>
renderEveSurface(surface(7), host);
const result = document.createElement("section");
result.className = "eve-command-result-region";
result.setAttribute("role", "error");
result.setAttribute("role", "alert");
result.textContent = "The command was denied.";
host.append(result);
@ -418,3 +467,188 @@ test("authoritative refresh preserves the renderer-owned command result", () =>
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();
}
});