diff --git a/Makefile b/Makefile index 2b5076d..6c14435 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index de190cc..b18866f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/EVEDashboardClient.h b/Sources/EVEDashboardClient.h deleted file mode 100644 index 5823b59..0000000 --- a/Sources/EVEDashboardClient.h +++ /dev/null @@ -1,21 +0,0 @@ -#import - -@protocol EVEDashboardClientDelegate; - -@interface EVEDashboardClient : NSObject - -- (instancetype)initWithURLs:(NSArray *)urls delegate:(id)delegate; -- (void)connect; -- (void)disconnect; -- (void)sendCommand:(NSDictionary *)command; - -@property(nonatomic, copy, readonly) NSString *status; - -@end - -@protocol EVEDashboardClientDelegate - -- (void)dashboardClient:(EVEDashboardClient *)client didReceiveState:(NSDictionary *)state; -- (void)dashboardClient:(EVEDashboardClient *)client didChangeStatus:(NSString *)status; - -@end diff --git a/Sources/EVEDashboardClient.m b/Sources/EVEDashboardClient.m deleted file mode 100644 index 078e882..0000000 --- a/Sources/EVEDashboardClient.m +++ /dev/null @@ -1,126 +0,0 @@ -#import "EVEDashboardClient.h" - -@interface EVEDashboardClient () - -@property(nonatomic, copy) NSArray *urls; -@property(nonatomic, assign) NSUInteger urlIndex; -@property(nonatomic, weak) id 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 *)urls delegate:(id)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 diff --git a/Sources/EVEHermodrClient.h b/Sources/EVEHermodrClient.h new file mode 100644 index 0000000..8fa7fd8 --- /dev/null +++ b/Sources/EVEHermodrClient.h @@ -0,0 +1,28 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@class EVEHermodrClient; + +@protocol EVEHermodrClientDelegate + +- (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 *)baseURLs + providerId:(NSString *)providerId + surfaceId:(NSString *)surfaceId + delegate:(id)delegate; +- (void)connect; +- (void)disconnect; +- (void)sendCommand:(NSDictionary *)command; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/EVEHermodrClient.m b/Sources/EVEHermodrClient.m new file mode 100644 index 0000000..181e284 --- /dev/null +++ b/Sources/EVEHermodrClient.m @@ -0,0 +1,192 @@ +#import "EVEHermodrClient.h" + +static NSTimeInterval const EVESurfaceRefreshInterval = 1.0; + +@interface EVEHermodrClient () + +@property(nonatomic, copy) NSArray *baseURLs; +@property(nonatomic, assign) NSUInteger urlIndex; +@property(nonatomic, copy) NSString *providerId; +@property(nonatomic, copy) NSString *surfaceId; +@property(nonatomic, weak) id 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 *)baseURLs + providerId:(NSString *)providerId + surfaceId:(NSString *)surfaceId + delegate:(id)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 diff --git a/Sources/EVEViewController.mm b/Sources/EVEViewController.mm index 802f554..36e258f 100644 --- a/Sources/EVEViewController.mm +++ b/Sources/EVEViewController.mm @@ -4,7 +4,7 @@ #import #import "EVEFrameStreamClient.h" -#import "EVEDashboardClient.h" +#import "EVEHermodrClient.h" #import "EVEGLView.h" #import "EVEH264StreamDecoder.h" #import "EVESensorUplinkClient.h" @@ -170,7 +170,7 @@ static NSArray *EVEConfiguredURLs(NSString *key) { return urls; } -@interface EVEViewController () +@interface EVEViewController () @property(nonatomic, strong) EVEGLView *glView; @property(nonatomic, strong) UIImageView *streamImageView; @@ -189,7 +189,7 @@ static NSArray *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 *EVEConfiguredURLs(NSString *key) { self.streamClient = [[EVEFrameStreamClient alloc] initWithURLs:streamURLs delegate:self]; [self.streamClient connect]; - NSArray *dashboardURLs = EVEConfiguredURLs(@"EVE_DASHBOARD_URLS"); - self.dashboardClient = [[EVEDashboardClient alloc] initWithURLs:dashboardURLs delegate:self]; - [self.dashboardClient connect]; + NSArray *hermodrURLs = EVEConfiguredURLs(@"EVE_HERMODR_URLS"); + self.hermodrClient = [[EVEHermodrClient alloc] initWithBaseURLs:hermodrURLs + providerId:@"gjallar.overview" + surfaceId:@"gjallar.overview" + delegate:self]; + [self.hermodrClient connect]; NSArray *cameraURLs = EVEConfiguredURLs(@"EVE_CAMERA_URLS"); NSArray *micURLs = EVEConfiguredURLs(@"EVE_MIC_URLS"); @@ -310,7 +313,7 @@ static NSArray *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 *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 *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 *liveNodeIds = [NSMutableSet set]; @@ -876,19 +876,7 @@ static NSArray *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 *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 *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 *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 *interfaces = [NSMutableArray array]; for (NSDictionary *child in children) { @@ -948,10 +940,10 @@ static NSArray *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 *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 *placements = [self odinPackedPlacementsForInterfaces:interfaces bounds:packBounds gap:gap]; + NSArray *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 *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 *)odinPackedPlacementsForInterfaces:(NSArray *)interfaces bounds:(CGRect)bounds gap:(CGFloat)gap { +- (NSArray *)aggregatePlacementsForInterfaces:(NSArray *)interfaces bounds:(CGRect)bounds gap:(CGFloat)gap { NSMutableArray *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 *EVEConfiguredURLs(NSString *key) { }]; NSMutableArray *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 *)items bounds:(CGRect)bounds gap:(CGFloat)gap into:(NSMutableArray *)placements { +- (void)packAggregateItems:(NSArray *)items bounds:(CGRect)bounds gap:(CGFloat)gap into:(NSMutableArray *)placements { if (items.count == 0 || bounds.size.width <= 1.0 || bounds.size.height <= 1.0) { return; } @@ -1040,15 +1044,15 @@ static NSArray *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 *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 *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 *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 *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 *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 *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 *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 { diff --git a/control b/control index bb557e4..6dbb985 100644 --- a/control +++ b/control @@ -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 Author: GameCult Section: Applications diff --git a/docs/cultmesh-streaming-ui-framework.md b/docs/cultmesh-streaming-ui-framework.md index 239bd4a..538bc11 100644 --- a/docs/cultmesh-streaming-ui-framework.md +++ b/docs/cultmesh-streaming-ui-framework.md @@ -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. diff --git a/docs/renderer-parity.md b/docs/renderer-parity.md index ae51508..bb5acb2 100644 --- a/docs/renderer-parity.md +++ b/docs/renderer-parity.md @@ -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 |