Make EveCanvas the native Gjallar runtime

This commit is contained in:
Meta Vens 2026-08-25 03:56:53 +02:00
commit 29b0d7741d
10 changed files with 317 additions and 252 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

@ -126,8 +126,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,
@ -236,20 +238,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 +348,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 |