Make EveCanvas the native Gjallar runtime
This commit is contained in:
parent
19c3dcf917
commit
29b0d7741d
10 changed files with 317 additions and 252 deletions
2
Makefile
2
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
|
||||
|
||||
|
|
|
|||
40
README.md
40
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.
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
#import <Foundation/Foundation.h>
|
||||
|
||||
@protocol EVEDashboardClientDelegate;
|
||||
|
||||
@interface EVEDashboardClient : NSObject
|
||||
|
||||
- (instancetype)initWithURLs:(NSArray<NSURL *> *)urls delegate:(id<EVEDashboardClientDelegate>)delegate;
|
||||
- (void)connect;
|
||||
- (void)disconnect;
|
||||
- (void)sendCommand:(NSDictionary *)command;
|
||||
|
||||
@property(nonatomic, copy, readonly) NSString *status;
|
||||
|
||||
@end
|
||||
|
||||
@protocol EVEDashboardClientDelegate <NSObject>
|
||||
|
||||
- (void)dashboardClient:(EVEDashboardClient *)client didReceiveState:(NSDictionary *)state;
|
||||
- (void)dashboardClient:(EVEDashboardClient *)client didChangeStatus:(NSString *)status;
|
||||
|
||||
@end
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
#import "EVEDashboardClient.h"
|
||||
|
||||
@interface EVEDashboardClient ()
|
||||
|
||||
@property(nonatomic, copy) NSArray<NSURL *> *urls;
|
||||
@property(nonatomic, assign) NSUInteger urlIndex;
|
||||
@property(nonatomic, weak) id<EVEDashboardClientDelegate> delegate;
|
||||
@property(nonatomic, strong) NSURLSession *session;
|
||||
@property(nonatomic, strong) NSURLSessionWebSocketTask *task;
|
||||
@property(nonatomic, assign) BOOL shouldReconnect;
|
||||
@property(nonatomic, copy, readwrite) NSString *status;
|
||||
|
||||
@end
|
||||
|
||||
@implementation EVEDashboardClient
|
||||
|
||||
- (instancetype)initWithURLs:(NSArray<NSURL *> *)urls delegate:(id<EVEDashboardClientDelegate>)delegate {
|
||||
self = [super init];
|
||||
if (!self) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
self.urls = urls.count > 0 ? urls : @[];
|
||||
self.delegate = delegate;
|
||||
self.session = [NSURLSession sessionWithConfiguration:NSURLSessionConfiguration.defaultSessionConfiguration];
|
||||
self.status = @"dashboard idle";
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)connect {
|
||||
self.shouldReconnect = YES;
|
||||
[self.task cancelWithCloseCode:NSURLSessionWebSocketCloseCodeNormalClosure reason:nil];
|
||||
NSURL *url = self.urls.count > 0 ? self.urls[self.urlIndex % self.urls.count] : nil;
|
||||
if (!url) {
|
||||
[self publishStatus:@"dashboard URL missing"];
|
||||
return;
|
||||
}
|
||||
|
||||
self.task = [self.session webSocketTaskWithURL:url];
|
||||
[self.task resume];
|
||||
[self publishStatus:[NSString stringWithFormat:@"dashboard connecting %@", url.host ?: @""]];
|
||||
[self receiveNextMessage];
|
||||
}
|
||||
|
||||
- (void)disconnect {
|
||||
self.shouldReconnect = NO;
|
||||
[self.task cancelWithCloseCode:NSURLSessionWebSocketCloseCodeNormalClosure reason:nil];
|
||||
self.task = nil;
|
||||
}
|
||||
|
||||
- (void)sendCommand:(NSDictionary *)command {
|
||||
if (!self.task) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSData *json = [NSJSONSerialization dataWithJSONObject:command options:0 error:nil];
|
||||
if (!json) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *text = [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding];
|
||||
NSURLSessionWebSocketMessage *message = [[NSURLSessionWebSocketMessage alloc] initWithString:text];
|
||||
[self.task sendMessage:message completionHandler:^(NSError *error) {
|
||||
if (error) {
|
||||
[self publishStatus:@"dashboard command failed"];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)receiveNextMessage {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.task receiveMessageWithCompletionHandler:^(NSURLSessionWebSocketMessage *message, NSError *error) {
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
[self publishStatus:@"dashboard disconnected"];
|
||||
[self reconnectSoon];
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type == NSURLSessionWebSocketMessageTypeString) {
|
||||
NSData *data = [message.string dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSDictionary *state = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
|
||||
if ([state isKindOfClass:NSDictionary.class]) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.delegate dashboardClient:self didReceiveState:state];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[self receiveNextMessage];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)reconnectSoon {
|
||||
if (!self.shouldReconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.urls.count > 1) {
|
||||
self.urlIndex = (self.urlIndex + 1) % self.urls.count;
|
||||
}
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
if (self.shouldReconnect) {
|
||||
[self connect];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)publishStatus:(NSString *)status {
|
||||
self.status = status ?: @"dashboard";
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.delegate dashboardClient:self didChangeStatus:self.status];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self disconnect];
|
||||
[self.session invalidateAndCancel];
|
||||
}
|
||||
|
||||
@end
|
||||
28
Sources/EVEHermodrClient.h
Normal file
28
Sources/EVEHermodrClient.h
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class EVEHermodrClient;
|
||||
|
||||
@protocol EVEHermodrClientDelegate <NSObject>
|
||||
|
||||
- (void)hermodrClient:(EVEHermodrClient *)client didReceiveSurface:(NSDictionary *)surface;
|
||||
- (void)hermodrClient:(EVEHermodrClient *)client didChangeStatus:(NSString *)status;
|
||||
|
||||
@end
|
||||
|
||||
@interface EVEHermodrClient : NSObject
|
||||
|
||||
@property(nonatomic, copy, readonly) NSString *status;
|
||||
|
||||
- (instancetype)initWithBaseURLs:(NSArray<NSURL *> *)baseURLs
|
||||
providerId:(NSString *)providerId
|
||||
surfaceId:(NSString *)surfaceId
|
||||
delegate:(id<EVEHermodrClientDelegate>)delegate;
|
||||
- (void)connect;
|
||||
- (void)disconnect;
|
||||
- (void)sendCommand:(NSDictionary *)command;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
192
Sources/EVEHermodrClient.m
Normal file
192
Sources/EVEHermodrClient.m
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
#import "EVEHermodrClient.h"
|
||||
|
||||
static NSTimeInterval const EVESurfaceRefreshInterval = 1.0;
|
||||
|
||||
@interface EVEHermodrClient ()
|
||||
|
||||
@property(nonatomic, copy) NSArray<NSURL *> *baseURLs;
|
||||
@property(nonatomic, assign) NSUInteger urlIndex;
|
||||
@property(nonatomic, copy) NSString *providerId;
|
||||
@property(nonatomic, copy) NSString *surfaceId;
|
||||
@property(nonatomic, weak) id<EVEHermodrClientDelegate> delegate;
|
||||
@property(nonatomic, strong) NSURLSession *session;
|
||||
@property(nonatomic, strong) NSURLSessionDataTask *surfaceTask;
|
||||
@property(nonatomic, assign) BOOL shouldRefresh;
|
||||
@property(nonatomic, assign) NSUInteger generation;
|
||||
@property(nonatomic, copy) NSData *lastSurfaceData;
|
||||
@property(nonatomic, copy, readwrite) NSString *status;
|
||||
|
||||
@end
|
||||
|
||||
@implementation EVEHermodrClient
|
||||
|
||||
- (instancetype)initWithBaseURLs:(NSArray<NSURL *> *)baseURLs
|
||||
providerId:(NSString *)providerId
|
||||
surfaceId:(NSString *)surfaceId
|
||||
delegate:(id<EVEHermodrClientDelegate>)delegate {
|
||||
self = [super init];
|
||||
if (!self) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSURLSessionConfiguration *configuration = NSURLSessionConfiguration.ephemeralSessionConfiguration;
|
||||
configuration.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
||||
configuration.timeoutIntervalForRequest = 8.0;
|
||||
configuration.timeoutIntervalForResource = 12.0;
|
||||
self.session = [NSURLSession sessionWithConfiguration:configuration];
|
||||
self.baseURLs = baseURLs.count > 0 ? baseURLs : @[];
|
||||
self.providerId = providerId.length > 0 ? providerId : @"gjallar.overview";
|
||||
self.surfaceId = surfaceId.length > 0 ? surfaceId : self.providerId;
|
||||
self.delegate = delegate;
|
||||
self.status = @"Hermodr idle";
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)connect {
|
||||
self.shouldRefresh = YES;
|
||||
self.generation += 1;
|
||||
[self.surfaceTask cancel];
|
||||
self.surfaceTask = nil;
|
||||
self.lastSurfaceData = nil;
|
||||
if (self.baseURLs.count == 0) {
|
||||
[self publishStatus:@"Hermodr URL missing"];
|
||||
return;
|
||||
}
|
||||
[self requestSurfaceForGeneration:self.generation];
|
||||
}
|
||||
|
||||
- (void)disconnect {
|
||||
self.shouldRefresh = NO;
|
||||
self.generation += 1;
|
||||
[self.surfaceTask cancel];
|
||||
self.surfaceTask = nil;
|
||||
}
|
||||
|
||||
- (void)requestSurfaceForGeneration:(NSUInteger)generation {
|
||||
if (!self.shouldRefresh || generation != self.generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSURL *baseURL = self.baseURLs[self.urlIndex % self.baseURLs.count];
|
||||
NSURL *surfaceURL = [self surfaceURLForBaseURL:baseURL];
|
||||
if (!surfaceURL) {
|
||||
[self publishStatus:@"Hermodr URL invalid"];
|
||||
[self scheduleRefreshForGeneration:generation];
|
||||
return;
|
||||
}
|
||||
|
||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:surfaceURL];
|
||||
request.HTTPMethod = @"GET";
|
||||
[request setValue:@"application/json" forHTTPHeaderField:@"Accept"];
|
||||
[self publishStatus:[NSString stringWithFormat:@"opening %@", self.providerId]];
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
self.surfaceTask = [self.session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self || generation != self.generation || !self.shouldRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSHTTPURLResponse *http = [response isKindOfClass:NSHTTPURLResponse.class] ? (NSHTTPURLResponse *)response : nil;
|
||||
NSError *parseError = nil;
|
||||
NSDictionary *state = data.length > 0 ? [NSJSONSerialization JSONObjectWithData:data options:0 error:&parseError] : nil;
|
||||
NSDictionary *surface = [state isKindOfClass:NSDictionary.class] && [state[@"surface"] isKindOfClass:NSDictionary.class] ? state[@"surface"] : nil;
|
||||
NSDictionary *root = [surface[@"root"] isKindOfClass:NSDictionary.class] ? surface[@"root"] : nil;
|
||||
BOOL accepted = !error && http.statusCode >= 200 && http.statusCode < 300 && root != nil;
|
||||
|
||||
if (accepted) {
|
||||
BOOL changed = ![self.lastSurfaceData isEqualToData:data];
|
||||
self.lastSurfaceData = data;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self publishStatus:[NSString stringWithFormat:@"%@ connected", self.providerId]];
|
||||
if (changed) {
|
||||
[self.delegate hermodrClient:self didReceiveSurface:state];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (self.baseURLs.count > 1) {
|
||||
self.urlIndex = (self.urlIndex + 1) % self.baseURLs.count;
|
||||
}
|
||||
NSString *detail = error.localizedDescription;
|
||||
if (detail.length == 0 && [state[@"error"] isKindOfClass:NSString.class]) {
|
||||
detail = state[@"error"];
|
||||
}
|
||||
if (detail.length == 0 && http) {
|
||||
detail = [NSString stringWithFormat:@"HTTP %ld", (long)http.statusCode];
|
||||
}
|
||||
if (detail.length == 0 && parseError) {
|
||||
detail = parseError.localizedDescription;
|
||||
}
|
||||
[self publishStatus:[NSString stringWithFormat:@"Hermodr unavailable: %@", detail ?: @"invalid surface"]];
|
||||
}
|
||||
[self scheduleRefreshForGeneration:generation];
|
||||
}];
|
||||
[self.surfaceTask resume];
|
||||
}
|
||||
|
||||
- (void)scheduleRefreshForGeneration:(NSUInteger)generation {
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(EVESurfaceRefreshInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
[self requestSurfaceForGeneration:generation];
|
||||
});
|
||||
}
|
||||
|
||||
- (NSURL *)surfaceURLForBaseURL:(NSURL *)baseURL {
|
||||
NSURLComponents *components = [NSURLComponents componentsWithURL:baseURL resolvingAgainstBaseURL:NO];
|
||||
if (!components.scheme.length || !components.host.length) {
|
||||
return nil;
|
||||
}
|
||||
NSString *basePath = [components.path stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"/"]];
|
||||
NSString *provider = [self.providerId stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLPathAllowedCharacterSet];
|
||||
components.path = [NSString stringWithFormat:@"/%@%@hermodr/surface/%@",
|
||||
basePath,
|
||||
basePath.length > 0 ? @"/" : @"",
|
||||
provider ?: @""];
|
||||
components.queryItems = @[[NSURLQueryItem queryItemWithName:@"surfaceId" value:self.surfaceId]];
|
||||
return components.URL;
|
||||
}
|
||||
|
||||
- (NSURL *)commandURLForBaseURL:(NSURL *)baseURL {
|
||||
NSURLComponents *components = [NSURLComponents componentsWithURL:baseURL resolvingAgainstBaseURL:NO];
|
||||
if (!components.scheme.length || !components.host.length) {
|
||||
return nil;
|
||||
}
|
||||
NSString *basePath = [components.path stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"/"]];
|
||||
components.path = [NSString stringWithFormat:@"/%@%@hermodr/commands/eve",
|
||||
basePath,
|
||||
basePath.length > 0 ? @"/" : @""];
|
||||
components.query = nil;
|
||||
return components.URL;
|
||||
}
|
||||
|
||||
- (void)sendCommand:(NSDictionary *)command {
|
||||
if (self.baseURLs.count == 0 || ![NSJSONSerialization isValidJSONObject:command]) {
|
||||
return;
|
||||
}
|
||||
NSURL *url = [self commandURLForBaseURL:self.baseURLs[self.urlIndex % self.baseURLs.count]];
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
NSMutableDictionary *body = [command mutableCopy];
|
||||
if (![body[@"providerId"] isKindOfClass:NSString.class]) {
|
||||
body[@"providerId"] = self.providerId;
|
||||
}
|
||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
|
||||
request.HTTPMethod = @"POST";
|
||||
request.HTTPBody = [NSJSONSerialization dataWithJSONObject:body options:0 error:nil];
|
||||
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
||||
[[self.session dataTaskWithRequest:request] resume];
|
||||
}
|
||||
|
||||
- (void)publishStatus:(NSString *)status {
|
||||
self.status = status ?: @"Hermodr";
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.delegate hermodrClient:self didChangeStatus:self.status];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self disconnect];
|
||||
[self.session invalidateAndCancel];
|
||||
}
|
||||
|
||||
@end
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
#import <QuartzCore/QuartzCore.h>
|
||||
|
||||
#import "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 {
|
||||
|
|
|
|||
2
control
2
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 <meta@gamecult.org>
|
||||
Author: GameCult
|
||||
Section: Applications
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in a new issue