Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
672c0c1e9f | ||
|
|
436b513431 | ||
|
|
29b0d7741d | ||
|
|
19c3dcf917 | ||
|
|
e5a81185bb | ||
|
|
3a5e2057e0 | ||
|
|
af05248af5 |
16 changed files with 711 additions and 309 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
|
||||
|
||||
|
|
|
|||
57
README.md
57
README.md
|
|
@ -102,8 +102,10 @@ schema or composition builders.
|
|||
|
||||
## Current Mechanism
|
||||
|
||||
The current checked-in client is the iOS Theos app, still named `EveCanvas` at
|
||||
the bundle level until the wider Eve runtime split exists.
|
||||
The current checked-in iOS runtime is the Theos app still named `EveCanvas` at
|
||||
the bundle level. Its primary input is Gjallar's aggregate surface through the
|
||||
temporary Starfire Hermodr HTTP boundary; direct typed CultMesh transport can
|
||||
replace that edge later without moving layout authority into the client.
|
||||
|
||||
The Android proof under `android/` is a small native Kotlin client built with
|
||||
the installed Android SDK and CultLib's `cultmesh-kotlin` package, without
|
||||
|
|
@ -115,9 +117,12 @@ payloads as `mimir.eve_media_observation.v1`.
|
|||
|
||||
- `EVEAppDelegate` creates one fullscreen `UIWindow`.
|
||||
- `EVEViewController` installs:
|
||||
- `EVEHermodrClient`, the configured live `gjallar.overview` surface client.
|
||||
- native UIKit lowering for generic Eve trees and Gjallar's
|
||||
`weighted-bisect` aggregate.
|
||||
- `EVEGLView`, a `CAEAGLLayer` backed OpenGL ES render target.
|
||||
- a full-screen `UIImageView` that displays CEF relay frames.
|
||||
- `EVEFrameStreamClient`, a native WebSocket client for frame/input transport.
|
||||
- an optional full-screen `UIImageView` and `EVEFrameStreamClient` for legacy
|
||||
configured frame streams.
|
||||
- a native UIKit `UILabel` overlay for crisp Retina status text.
|
||||
- `CADisplayLink` for frame ticking.
|
||||
- `CMMotionManager` for accelerometer and gyro telemetry.
|
||||
|
|
@ -126,8 +131,10 @@ payloads as `mimir.eve_media_observation.v1`.
|
|||
- `EVESensorUplinkClient` opens separate WebSocket uplinks for camera and
|
||||
microphone observations so sensor traffic does not block display/control
|
||||
traffic.
|
||||
- `EVEDashboardClient` opens the native Mimir dashboard socket and receives
|
||||
scene/control state snapshots.
|
||||
- `EVEHermodrClient` polls Odin-owned Hermodr for Gjallar's canonical
|
||||
`gamecult.eve.surface_state.v1` aggregate and hands the decoded surface to
|
||||
UIKit. Hermodr is a transport/lowering boundary; it does not own layout or
|
||||
provider membership.
|
||||
- `EVEViewController` captures camera frames with AVFoundation and microphone
|
||||
blocks with AVAudioEngine, then sends binary CultMesh `eve-camera` and
|
||||
`eve-mic` media observations to Mimir. It also renders the dashboard natively with UIKit: a scene graph,
|
||||
|
|
@ -150,8 +157,8 @@ payloads as `mimir.eve_media_observation.v1`.
|
|||
- CultNet carries typed surface state, commands, and timestamped sensor packets.
|
||||
Edge JSON is tolerated only as an interoperability envelope while the typed
|
||||
CultMesh document shape is being proven.
|
||||
- UIKit is the current iOS streamed-frame owner; OpenGL ES is fallback/local
|
||||
render.
|
||||
- UIKit is the current iOS `gjallar.overview` lowering owner. Streamed frames
|
||||
are an optional non-canonical input; OpenGL ES remains fallback/local render.
|
||||
- The status bar stays hidden.
|
||||
- Eve clients own local sensor reads and timestamps. Mimir owns synchronization
|
||||
and final interpretation after those samples arrive.
|
||||
|
|
@ -236,20 +243,19 @@ Periwinkle sends camera, microphone, motion, and touch observations only when
|
|||
launched with an explicit `org.gamecult.eve.SENSOR_URI`; the apps no longer
|
||||
guess receiver URLs.
|
||||
|
||||
## Native Mimir Dashboard
|
||||
## Native Gjallar Runtime
|
||||
|
||||
Start the dashboard authority on Starfire:
|
||||
EveCanvas is the native iOS lowering of Gjallar's aggregate Eve surface. Gjallar
|
||||
owns visible surface membership, order, and `weighted-bisect` layout metadata;
|
||||
EveCanvas owns UIKit views, touch handling, reconnect behavior, and visible
|
||||
transport failure. Odin-owned Hermodr exposes the typed CultMesh record at the
|
||||
iOS HTTP boundary.
|
||||
|
||||
```powershell
|
||||
dotnet run --project E:\Projects\Mimir\src\Mimir.EveDashboard\Mimir.EveDashboard.csproj -- --port 8795
|
||||
```
|
||||
|
||||
EveCanvas connects to the Mimir dashboard WebSocket lowering for native retained
|
||||
`dashboard-state` snapshots. That lowering renders the daemon-owned CultMesh
|
||||
dashboard state; it is not provider discovery authority. Live provider discovery
|
||||
belongs to Odin/CultMesh advertisements. Native iOS dashboard lowerings must be
|
||||
configured through `EVE_DASHBOARD_URLS`; missing discovery stays visible instead
|
||||
of falling back to LAN constants. EveCanvas sends compact commands back:
|
||||
Configure one or more Hermodr base URLs through the `EVE_HERMODR_URLS` array in
|
||||
`org.gamecult.evecanvas` preferences. EveCanvas reads
|
||||
`gjallar.overview` from `/hermodr/surface/gjallar.overview` and does not guess a
|
||||
LAN address. It sends supported interactions back through
|
||||
`/hermodr/commands/eve`:
|
||||
|
||||
- `select`
|
||||
- `move`
|
||||
|
|
@ -347,14 +353,13 @@ without losing the basic app deployment path.
|
|||
|
||||
## Next Cut
|
||||
|
||||
- Connect the existing browser reference to a clean-consumer, networked
|
||||
CultMesh sample and use it as the visual/behavior oracle for native clients.
|
||||
- Split the shared CultNet/CultMesh surface contract from the iOS app code and
|
||||
keep `gamecult.eve.surface.v1` as the renderer-facing contract.
|
||||
- Admit live provider-owned Eve surfaces into Odin/CultMesh so Gjallar's
|
||||
aggregate has real children to tile; advertisements without readable surface
|
||||
state remain catalog entries, not invented UI.
|
||||
- Add native command receipts for Gjallar tiles and prove touch round trips
|
||||
against provider-owned command surfaces.
|
||||
- Replace JSON/base64 sensor packets with binary framing once camera and mic
|
||||
timing are proven through Mimir.
|
||||
- Replace the dashboard fixture state with live `MimirPresentationControlState`
|
||||
and `MimirSceneEditorState` snapshots.
|
||||
- Expand the Android Kotlin client from dashboard node rendering into the full
|
||||
retained `surface.root` renderer, and add the browser client against the same
|
||||
provider/sensor API.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ This package owns the shared browser renderer contract:
|
|||
- `EveBrowserProviderHost` owns advertisement-backed surface selection and uses one provider-neutral embedded-document resolver; provider schema dispatch stays behind the host transport boundary.
|
||||
- Editable string, textarea, number, select, and choice controls use named Eve state bindings. The renderer preserves edited drafts by provider/surface/binding identity; untouched controls contribute their authored value when an operation captures those bindings atomically under `payload.bindings`.
|
||||
- `gamecult.eve.command_result.v1` carries the persisted receipt, an optional one-time Eve projection, a binding-clear directive, and at most one plugin-scoped payload. Only the matching plugin adapter may consume that payload. The renderer-owned receipt region survives the authoritative surface refresh that follows the command.
|
||||
- `resource.download` lowers a provider-issued resource grant to an accessible download link. The reference browser accepts only same-origin HTTP(S) or root-relative URLs and sanitizes the suggested filename; the provider remains responsible for authorization, expiry, single-use semantics, and resource bytes.
|
||||
|
||||
Host applications own transport and command delivery. Providers own command acceptance and receipts through their advertisements. For example, Eir resolves surfaces through Odin/CultMesh and posts commands to `/eir/command`; Aetheria resolves surfaces through Electron IPC. Neither host should duplicate DOM lowering or import provider internals to find a command route.
|
||||
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ export declare class EveBrowserProviderHost {
|
|||
private submit;
|
||||
private consumeCommandResult;
|
||||
private presentCommandStatus;
|
||||
private loweringOptions;
|
||||
}
|
||||
export interface EveEmbeddedDocumentRequest {
|
||||
documentId: string;
|
||||
|
|
|
|||
88
packages/eve-browser-lowering/dist/index.js
vendored
88
packages/eve-browser-lowering/dist/index.js
vendored
|
|
@ -16,7 +16,7 @@ export class EveBrowserDraftStore {
|
|||
this.values.set(this.key(providerId, surfaceId, bindingName), value);
|
||||
}
|
||||
clear(providerId, surfaceId, bindingNames) {
|
||||
if (bindingNames?.length) {
|
||||
if (bindingNames !== undefined) {
|
||||
for (const name of bindingNames)
|
||||
this.values.delete(this.key(providerId, surfaceId, name));
|
||||
return;
|
||||
|
|
@ -111,19 +111,7 @@ export class EveBrowserProviderHost {
|
|||
if (version === this.lastSurfaceVersion)
|
||||
return;
|
||||
this.lastSurfaceVersion = version;
|
||||
renderEveSurface(surface, this.host, {
|
||||
activeSurfaceId: this.selected.surfaceId,
|
||||
assetUrlResolver: this.transport.resolveAssetUrl,
|
||||
body: this.options.body,
|
||||
clientId: this.options.clientId || "eve-browser",
|
||||
commandSink: intent => this.submit(intent),
|
||||
documentResolver: this.transport.resolveDocument,
|
||||
provider: this.provider,
|
||||
pluginAdapters: this.pluginAdapters,
|
||||
source: this.options.source,
|
||||
statusElement: this.options.statusElement,
|
||||
draftStore: this.draftStore,
|
||||
});
|
||||
renderEveSurface(surface, this.host, this.loweringOptions());
|
||||
}
|
||||
catch (error) {
|
||||
if (this.options.statusElement) {
|
||||
|
|
@ -132,12 +120,13 @@ export class EveBrowserProviderHost {
|
|||
}
|
||||
}
|
||||
async submit(intent) {
|
||||
this.presentCommandStatus("Working…", "status");
|
||||
try {
|
||||
const result = parseEveCommandResult(await this.transport.submitCommand(intent));
|
||||
await this.consumeCommandResult(intent, result);
|
||||
}
|
||||
catch (error) {
|
||||
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error");
|
||||
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "alert");
|
||||
return;
|
||||
}
|
||||
this.lastSurfaceVersion = "";
|
||||
|
|
@ -158,7 +147,7 @@ export class EveBrowserProviderHost {
|
|||
surface: this.selected,
|
||||
});
|
||||
}
|
||||
this.presentCommandStatus(result.receipt.message || result.receipt.state, accepted ? "status" : "error", result.transientProjection);
|
||||
this.presentCommandStatus(result.receipt.message || result.receipt.state, accepted ? "status" : "alert", result.transientProjection);
|
||||
}
|
||||
presentCommandStatus(message, role, transient) {
|
||||
let region = this.host.querySelector(":scope > .eve-command-result-region");
|
||||
|
|
@ -171,15 +160,24 @@ export class EveBrowserProviderHost {
|
|||
region.replaceChildren(el("p", "eve-command-result-message", message));
|
||||
const projection = transient;
|
||||
if (projection?.surface?.root) {
|
||||
region.append(renderEveComponent(projection.surface.root, {
|
||||
activeSurfaceId: projection.surface.id || this.selected?.surfaceId,
|
||||
clientId: this.options.clientId,
|
||||
draftStore: this.draftStore,
|
||||
pluginAdapters: this.pluginAdapters,
|
||||
provider: this.provider,
|
||||
}));
|
||||
region.append(renderEveComponent(projection.surface.root, scopeEveBrowserOptions(projection, this.loweringOptions())));
|
||||
}
|
||||
}
|
||||
loweringOptions() {
|
||||
return {
|
||||
activeSurfaceId: this.selected?.surfaceId,
|
||||
assetUrlResolver: this.transport.resolveAssetUrl,
|
||||
body: this.options.body,
|
||||
clientId: this.options.clientId || "eve-browser",
|
||||
commandSink: intent => this.submit(intent),
|
||||
documentResolver: this.transport.resolveDocument,
|
||||
provider: this.provider,
|
||||
pluginAdapters: this.pluginAdapters,
|
||||
source: this.options.source,
|
||||
statusElement: this.options.statusElement,
|
||||
draftStore: this.draftStore,
|
||||
};
|
||||
}
|
||||
}
|
||||
function requireCommandCapability(surface) {
|
||||
if (!surface.worldInteraction?.commandBoundary || !surface.worldInteraction.receiptSchema) {
|
||||
|
|
@ -633,6 +631,23 @@ function renderEveComponentProjection(node, options) {
|
|||
wireCommand(button, node, resolveComponentCommandId(props, node), props, options);
|
||||
return button;
|
||||
}
|
||||
if (kind === "resource.download") {
|
||||
const link = el("a", "cultui-button eve-resource-download", stringProp(props.label, "Download"));
|
||||
assignId(link, node);
|
||||
applyGeneratedLayout(link, layout, style);
|
||||
const uri = firstString(props.uri, props.href, props.assetUri, "");
|
||||
const href = resolveSafeDownloadUrl(uri, options);
|
||||
if (!href) {
|
||||
link.removeAttribute("href");
|
||||
link.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
else {
|
||||
link.href = href;
|
||||
link.download = safeDownloadFilename(stringProp(props.filename, "download"));
|
||||
}
|
||||
link.setAttribute("aria-label", stringProp(props.ariaLabel, stringProp(props.label, "Download")));
|
||||
return link;
|
||||
}
|
||||
if (kind === "control.popup") {
|
||||
const button = el("button", "cultui-button eve-control-popup", stringProp(props.label, "Open"));
|
||||
assignId(button, node);
|
||||
|
|
@ -1874,6 +1889,9 @@ function renderEditableControl(node, kind, props, layout, style, options) {
|
|||
else if (props.rows !== undefined) {
|
||||
control.rows = positiveInt(props.rows, 3);
|
||||
}
|
||||
const maxLength = positiveInt(props.maxLength, 0);
|
||||
if (maxLength > 0)
|
||||
control.maxLength = maxLength;
|
||||
control.value = String(context.value ?? "");
|
||||
control.disabled = context.disabled;
|
||||
control.name = context.bindingName;
|
||||
|
|
@ -2127,6 +2145,30 @@ function resolveAssetUrl(uri, options) {
|
|||
}
|
||||
return uri;
|
||||
}
|
||||
function resolveSafeDownloadUrl(uri, options) {
|
||||
const resolved = resolveAssetUrl(uri, options).trim();
|
||||
if (!resolved || resolved.startsWith("//"))
|
||||
return "";
|
||||
if (resolved.startsWith("/"))
|
||||
return resolved;
|
||||
try {
|
||||
const base = new URL(document.baseURI);
|
||||
const candidate = new URL(resolved, base);
|
||||
if (!/^https?:$/.test(candidate.protocol) || candidate.origin !== base.origin)
|
||||
return "";
|
||||
return candidate.href;
|
||||
}
|
||||
catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
function safeDownloadFilename(value) {
|
||||
const filename = value
|
||||
.replace(/[\\/:*?"<>|\u0000-\u001f\u007f]/g, "_")
|
||||
.replace(/^\.+/, "")
|
||||
.trim();
|
||||
return filename || "download";
|
||||
}
|
||||
function loadFontStylesheet(href) {
|
||||
const normalized = typeof href === "string" ? href.trim() : "";
|
||||
if (!normalized)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ Derived state:
|
|||
- local drafts and the latest command-result region are renderer-owned
|
||||
transients; refresh may replace the surface projection but may not erase a
|
||||
receipt before the player can perceive it;
|
||||
- a provider-owned transient projection inherits the selected logical
|
||||
surface's advertised command boundary while retaining its own command
|
||||
descriptors and component context;
|
||||
- loaded font stylesheets are a document resource cache, not active-surface
|
||||
state.
|
||||
|
||||
|
|
@ -42,6 +45,8 @@ Shared paths:
|
|||
|
||||
- initial render, embedded render, plugin render, binding updates, command
|
||||
creation, and asset resolution all receive the same host-scoped context;
|
||||
- retained and command-result projections use the same host transport options;
|
||||
a transient presentation surface ID never becomes command-routing authority;
|
||||
- initial binding hydration and live binding watches use the same component
|
||||
projection replacement primitive.
|
||||
- operation capture resolves edited drafts first and otherwise uses the
|
||||
|
|
@ -63,4 +68,6 @@ Verification:
|
|||
- untouched select and numeric defaults are present in captured operations;
|
||||
- accepted and denied command results remain visible after authoritative
|
||||
refresh;
|
||||
- controls inside a transient command-result projection invoke its descriptor
|
||||
through the selected provider surface and canonical command sink;
|
||||
- disposing or rerendering one host cannot unsubscribe the other.
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ export class EveBrowserDraftStore {
|
|||
}
|
||||
|
||||
clear(providerId: string, surfaceId: string, bindingNames?: readonly string[]): void {
|
||||
if (bindingNames?.length) {
|
||||
if (bindingNames !== undefined) {
|
||||
for (const name of bindingNames) this.values.delete(this.key(providerId, surfaceId, name));
|
||||
return;
|
||||
}
|
||||
|
|
@ -269,19 +269,7 @@ export class EveBrowserProviderHost {
|
|||
const version = `${surface.providerId || ""}:${surface.surface?.id || ""}:${surface.version ?? ""}`;
|
||||
if (version === this.lastSurfaceVersion) return;
|
||||
this.lastSurfaceVersion = version;
|
||||
renderEveSurface(surface, this.host, {
|
||||
activeSurfaceId: this.selected.surfaceId,
|
||||
assetUrlResolver: this.transport.resolveAssetUrl,
|
||||
body: this.options.body,
|
||||
clientId: this.options.clientId || "eve-browser",
|
||||
commandSink: intent => this.submit(intent),
|
||||
documentResolver: this.transport.resolveDocument,
|
||||
provider: this.provider,
|
||||
pluginAdapters: this.pluginAdapters,
|
||||
source: this.options.source,
|
||||
statusElement: this.options.statusElement,
|
||||
draftStore: this.draftStore,
|
||||
});
|
||||
renderEveSurface(surface, this.host, this.loweringOptions());
|
||||
} catch (error) {
|
||||
if (this.options.statusElement) {
|
||||
this.options.statusElement.textContent = error instanceof Error ? error.message : "Eve surface unavailable.";
|
||||
|
|
@ -290,11 +278,12 @@ export class EveBrowserProviderHost {
|
|||
}
|
||||
|
||||
private async submit(intent: EveCommandIntent): Promise<void> {
|
||||
this.presentCommandStatus("Working…", "status");
|
||||
try {
|
||||
const result = parseEveCommandResult(await this.transport.submitCommand(intent));
|
||||
await this.consumeCommandResult(intent, result);
|
||||
} catch (error) {
|
||||
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error");
|
||||
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "alert");
|
||||
return;
|
||||
}
|
||||
this.lastSurfaceVersion = "";
|
||||
|
|
@ -318,12 +307,12 @@ export class EveBrowserProviderHost {
|
|||
}
|
||||
this.presentCommandStatus(
|
||||
result.receipt.message || result.receipt.state,
|
||||
accepted ? "status" : "error",
|
||||
accepted ? "status" : "alert",
|
||||
result.transientProjection,
|
||||
);
|
||||
}
|
||||
|
||||
private presentCommandStatus(message: string, role: "status" | "error", transient?: unknown): void {
|
||||
private presentCommandStatus(message: string, role: "status" | "alert", transient?: unknown): void {
|
||||
let region = this.host.querySelector<HTMLElement>(":scope > .eve-command-result-region");
|
||||
if (!region) {
|
||||
region = el("section", "eve-command-result-region");
|
||||
|
|
@ -334,15 +323,28 @@ export class EveBrowserProviderHost {
|
|||
region.replaceChildren(el("p", "eve-command-result-message", message));
|
||||
const projection = transient as EveSurfaceDocument | undefined;
|
||||
if (projection?.surface?.root) {
|
||||
region.append(renderEveComponent(projection.surface.root, {
|
||||
activeSurfaceId: projection.surface.id || this.selected?.surfaceId,
|
||||
clientId: this.options.clientId,
|
||||
draftStore: this.draftStore,
|
||||
pluginAdapters: this.pluginAdapters,
|
||||
provider: this.provider,
|
||||
}));
|
||||
region.append(renderEveComponent(
|
||||
projection.surface.root,
|
||||
scopeEveBrowserOptions(projection, this.loweringOptions()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private loweringOptions(): EveBrowserLoweringOptions {
|
||||
return {
|
||||
activeSurfaceId: this.selected?.surfaceId,
|
||||
assetUrlResolver: this.transport.resolveAssetUrl,
|
||||
body: this.options.body,
|
||||
clientId: this.options.clientId || "eve-browser",
|
||||
commandSink: intent => this.submit(intent),
|
||||
documentResolver: this.transport.resolveDocument,
|
||||
provider: this.provider,
|
||||
pluginAdapters: this.pluginAdapters,
|
||||
source: this.options.source,
|
||||
statusElement: this.options.statusElement,
|
||||
draftStore: this.draftStore,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function requireCommandCapability(surface: EveProviderSurfaceAdvertisement): void {
|
||||
|
|
@ -848,6 +850,27 @@ function renderEveComponentProjection(
|
|||
return button;
|
||||
}
|
||||
|
||||
if (kind === "resource.download") {
|
||||
const link = el(
|
||||
"a",
|
||||
"cultui-button eve-resource-download",
|
||||
stringProp(props.label, "Download"),
|
||||
) as HTMLAnchorElement;
|
||||
assignId(link, node);
|
||||
applyGeneratedLayout(link, layout, style);
|
||||
const uri = firstString(props.uri, props.href, props.assetUri, "");
|
||||
const href = resolveSafeDownloadUrl(uri, options);
|
||||
if (!href) {
|
||||
link.removeAttribute("href");
|
||||
link.setAttribute("aria-disabled", "true");
|
||||
} else {
|
||||
link.href = href;
|
||||
link.download = safeDownloadFilename(stringProp(props.filename, "download"));
|
||||
}
|
||||
link.setAttribute("aria-label", stringProp(props.ariaLabel, stringProp(props.label, "Download")));
|
||||
return link;
|
||||
}
|
||||
|
||||
if (kind === "control.popup") {
|
||||
const button = el("button", "cultui-button eve-control-popup", stringProp(props.label, "Open")) as HTMLButtonElement;
|
||||
assignId(button, node);
|
||||
|
|
@ -2236,6 +2259,8 @@ function renderEditableControl(
|
|||
} else if (props.rows !== undefined) {
|
||||
control.rows = positiveInt(props.rows, 3);
|
||||
}
|
||||
const maxLength = positiveInt(props.maxLength, 0);
|
||||
if (maxLength > 0) control.maxLength = maxLength;
|
||||
control.value = String(context.value ?? "");
|
||||
control.disabled = context.disabled;
|
||||
control.name = context.bindingName;
|
||||
|
|
@ -2510,6 +2535,28 @@ function resolveAssetUrl(uri: string, options: EveBrowserLoweringOptions): strin
|
|||
return uri;
|
||||
}
|
||||
|
||||
function resolveSafeDownloadUrl(uri: string, options: EveBrowserLoweringOptions): string {
|
||||
const resolved = resolveAssetUrl(uri, options).trim();
|
||||
if (!resolved || resolved.startsWith("//")) return "";
|
||||
if (resolved.startsWith("/")) return resolved;
|
||||
try {
|
||||
const base = new URL(document.baseURI);
|
||||
const candidate = new URL(resolved, base);
|
||||
if (!/^https?:$/.test(candidate.protocol) || candidate.origin !== base.origin) return "";
|
||||
return candidate.href;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function safeDownloadFilename(value: string): string {
|
||||
const filename = value
|
||||
.replace(/[\\/:*?"<>|\u0000-\u001f\u007f]/g, "_")
|
||||
.replace(/^\.+/, "")
|
||||
.trim();
|
||||
return filename || "download";
|
||||
}
|
||||
|
||||
function loadFontStylesheet(href: unknown): void {
|
||||
const normalized = typeof href === "string" ? href.trim() : "";
|
||||
if (!normalized) return;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { EveBrowserDraftStore, renderEveSurface } from "../dist/index.js";
|
||||
import { EveBrowserDraftStore, EveBrowserProviderHost, renderEveComponent, renderEveSurface } from "../dist/index.js";
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><head></head><body><div id='a'></div><div id='b'></div></body></html>", {
|
||||
|
|
@ -31,6 +31,40 @@ function installDom() {
|
|||
};
|
||||
}
|
||||
|
||||
test("resource downloads lower to same-origin one-time links without scriptable URLs", () => {
|
||||
const restoreDom = installDom();
|
||||
try {
|
||||
const link = renderEveComponent({
|
||||
id: "campaign.export",
|
||||
kind: "resource.download",
|
||||
props: {
|
||||
label: "Download campaign",
|
||||
uri: "/ghostlight/api/eve/resources/opaque-grant",
|
||||
filename: "campaign:bad?.cc",
|
||||
},
|
||||
});
|
||||
assert.equal(link.tagName, "A");
|
||||
assert.equal(link.getAttribute("href"), "/ghostlight/api/eve/resources/opaque-grant");
|
||||
assert.equal(link.getAttribute("download"), "campaign_bad_.cc");
|
||||
assert.equal(link.getAttribute("aria-disabled"), null);
|
||||
|
||||
const rejected = renderEveComponent({
|
||||
kind: "resource.download",
|
||||
props: { label: "Bad", uri: "javascript:alert(1)" },
|
||||
});
|
||||
assert.equal(rejected.getAttribute("href"), null);
|
||||
assert.equal(rejected.getAttribute("aria-disabled"), "true");
|
||||
|
||||
const crossOrigin = renderEveComponent({
|
||||
kind: "resource.download",
|
||||
props: { label: "Bad", uri: "https://elsewhere.test/private.cc" },
|
||||
});
|
||||
assert.equal(crossOrigin.getAttribute("href"), null);
|
||||
} finally {
|
||||
restoreDom();
|
||||
}
|
||||
});
|
||||
|
||||
function testInputAdapter(onRender = () => {}) {
|
||||
return {
|
||||
pluginId: "test.input",
|
||||
|
|
@ -225,7 +259,7 @@ test("editable bindings preserve local drafts across authoritative refresh and o
|
|||
{
|
||||
id: "composer",
|
||||
kind: "control.input.textarea",
|
||||
props: { label: "Message", value },
|
||||
props: { label: "Message", value, maxLength: 2000 },
|
||||
stateBindings: [{
|
||||
targetProp: "value",
|
||||
pointerId: "ghostlight.local.composer.message",
|
||||
|
|
@ -245,6 +279,7 @@ test("editable bindings preserve local drafts across authoritative refresh and o
|
|||
const options = { provider, draftStore: drafts, commandSink: intent => commands.push(intent) };
|
||||
renderEveSurface(surface(""), host, options);
|
||||
const composer = host.querySelector("textarea");
|
||||
assert.equal(composer.maxLength, 2000);
|
||||
composer.value = "The world should remember this.";
|
||||
composer.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
|
||||
|
|
@ -265,6 +300,20 @@ test("editable bindings preserve local drafts across authoritative refresh and o
|
|||
}
|
||||
});
|
||||
|
||||
test("an explicit empty draft directive clears no unrelated bindings", () => {
|
||||
const drafts = new EveBrowserDraftStore();
|
||||
drafts.set("ghostlight", "ghostlight.play", "channel_id", "private:host");
|
||||
drafts.set("ghostlight", "ghostlight.play", "message", "still drafting");
|
||||
|
||||
drafts.clear("ghostlight", "ghostlight.play", []);
|
||||
assert.equal(drafts.get("ghostlight", "ghostlight.play", "channel_id"), "private:host");
|
||||
assert.equal(drafts.get("ghostlight", "ghostlight.play", "message"), "still drafting");
|
||||
|
||||
drafts.clear("ghostlight", "ghostlight.play");
|
||||
assert.equal(drafts.has("ghostlight", "ghostlight.play", "channel_id"), false);
|
||||
assert.equal(drafts.has("ghostlight", "ghostlight.play", "message"), false);
|
||||
});
|
||||
|
||||
test("operations capture untouched authored choice defaults", () => {
|
||||
const restoreDom = installDom();
|
||||
try {
|
||||
|
|
@ -394,7 +443,7 @@ test("component captures extend rather than disappear behind operation descripto
|
|||
}
|
||||
});
|
||||
|
||||
test("authoritative refresh preserves the renderer-owned command result", () => {
|
||||
test("authoritative refresh preserves the renderer-owned accessible command result", () => {
|
||||
const restoreDom = installDom();
|
||||
try {
|
||||
const host = document.querySelector("#a");
|
||||
|
|
@ -406,7 +455,7 @@ test("authoritative refresh preserves the renderer-owned command result", () =>
|
|||
renderEveSurface(surface(7), host);
|
||||
const result = document.createElement("section");
|
||||
result.className = "eve-command-result-region";
|
||||
result.setAttribute("role", "error");
|
||||
result.setAttribute("role", "alert");
|
||||
result.textContent = "The command was denied.";
|
||||
host.append(result);
|
||||
|
||||
|
|
@ -418,3 +467,188 @@ test("authoritative refresh preserves the renderer-owned command result", () =>
|
|||
restoreDom();
|
||||
}
|
||||
});
|
||||
|
||||
test("transient projection controls use the selected provider command boundary", async () => {
|
||||
const restoreDom = installDom();
|
||||
try {
|
||||
const host = document.querySelector("#a");
|
||||
const commands = [];
|
||||
const descriptor = (command, payloadSchema) => ({
|
||||
schema: "gamecult.eve.command.v1",
|
||||
command,
|
||||
payloadSchema,
|
||||
});
|
||||
const surfaceDocument = {
|
||||
type: "surface-state",
|
||||
schema: "gamecult.eve.surface.v1",
|
||||
providerId: "ghostlight",
|
||||
providerKind: "narrative.simulation",
|
||||
title: "Ghostlight",
|
||||
version: 7,
|
||||
updatedAtUtc: "2026-08-22T00:00:00Z",
|
||||
surface: {
|
||||
id: "ghostlight.play",
|
||||
styles: {},
|
||||
root: {
|
||||
id: "root",
|
||||
kind: "control.button",
|
||||
props: { label: "Assess", command: "world.assess" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
commands: [descriptor("world.assess", "ghostlight.player_action_assess.v1")],
|
||||
};
|
||||
const transientProjection = {
|
||||
type: "surface-state",
|
||||
schema: "gamecult.eve.surface.v1",
|
||||
providerId: "ghostlight",
|
||||
providerKind: "narrative.simulation.command-result",
|
||||
title: "Assessment",
|
||||
version: 7,
|
||||
updatedAtUtc: "2026-08-22T00:00:01Z",
|
||||
surface: {
|
||||
id: "ghostlight.command-result",
|
||||
styles: {},
|
||||
root: {
|
||||
id: "roll",
|
||||
kind: "control.button",
|
||||
props: {
|
||||
label: "Roll the d20",
|
||||
command: "world.attempt",
|
||||
action: { assessment_digest: "sha256:assessment" },
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
commands: [descriptor("world.attempt", "ghostlight.player_action_attempt.v1")],
|
||||
};
|
||||
const receipt = command => ({
|
||||
schema: "gamecult.eve.command_receipt.v1",
|
||||
receiptId: `receipt:${command}`,
|
||||
commandId: `command:${command}`,
|
||||
command,
|
||||
state: "accepted",
|
||||
ownerRepo: "GameCult/Ghostlight",
|
||||
authority: "WorldKernel",
|
||||
providerId: "ghostlight",
|
||||
surfaceId: "ghostlight.play",
|
||||
message: "accepted",
|
||||
diagnostics: [],
|
||||
issuedAtUtc: "2026-08-22T00:00:02Z",
|
||||
sourceVersion: 7,
|
||||
});
|
||||
const transport = {
|
||||
providerAdvertisement: async () => ({
|
||||
providerId: "ghostlight",
|
||||
surfaces: [{
|
||||
surfaceId: "ghostlight.play",
|
||||
worldInteraction: {
|
||||
commandBoundary: "ghostlight.eve.commands",
|
||||
receiptSchema: "gamecult.eve.command_result.v1",
|
||||
},
|
||||
}],
|
||||
}),
|
||||
surface: async () => surfaceDocument,
|
||||
submitCommand: async intent => {
|
||||
commands.push(intent);
|
||||
return {
|
||||
schema: "gamecult.eve.command_result.v1",
|
||||
receipt: receipt(intent.operation.operationId),
|
||||
...(intent.operation.operationId === "world.assess" ? { transientProjection } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
const providerHost = new EveBrowserProviderHost(host, transport, { pollMs: 0 });
|
||||
await providerHost.start();
|
||||
|
||||
host.querySelector("button").click();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const roll = host.querySelector(".eve-command-result-region button");
|
||||
assert.equal(roll.textContent, "Roll the d20");
|
||||
roll.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands.map(command => command.operation.operationId), ["world.assess", "world.attempt"]);
|
||||
assert.equal(commands[1].surfaceId, "ghostlight.play");
|
||||
assert.equal(commands[1].operation.schemaId, "ghostlight.player_action_attempt.v1");
|
||||
assert.equal(commands[1].commandBoundary, "ghostlight.eve.commands");
|
||||
assert.equal(commands[1].payload.assessment_digest, "sha256:assessment");
|
||||
providerHost.stop();
|
||||
} finally {
|
||||
restoreDom();
|
||||
}
|
||||
});
|
||||
|
||||
test("provider host replaces stale receipts with immediate renderer-local progress", async () => {
|
||||
const restoreDom = installDom();
|
||||
try {
|
||||
const host = document.querySelector("#a");
|
||||
let finish;
|
||||
const commandResult = new Promise(resolve => { finish = resolve; });
|
||||
const transport = {
|
||||
providerAdvertisement: async () => ({
|
||||
providerId: "ghostlight",
|
||||
surfaces: [{
|
||||
surfaceId: "ghostlight.play",
|
||||
worldInteraction: {
|
||||
commandBoundary: "ghostlight.eve.commands",
|
||||
receiptSchema: "gamecult.eve.command_result.v1",
|
||||
},
|
||||
}],
|
||||
}),
|
||||
surface: async () => ({
|
||||
type: "surface-state",
|
||||
schema: "gamecult.eve.surface.v1",
|
||||
providerId: "ghostlight",
|
||||
title: "Ghostlight",
|
||||
version: 7,
|
||||
surface: {
|
||||
id: "ghostlight.play",
|
||||
root: {
|
||||
id: "assess",
|
||||
kind: "control.button",
|
||||
props: { label: "Assess", command: "world.assess" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
commands: [{
|
||||
schema: "gamecult.eve.command.v1",
|
||||
command: "world.assess",
|
||||
payloadSchema: "ghostlight.player_action_assess.v1",
|
||||
}],
|
||||
}),
|
||||
submitCommand: async () => commandResult,
|
||||
};
|
||||
const providerHost = new EveBrowserProviderHost(host, transport, { pollMs: 0 });
|
||||
await providerHost.start();
|
||||
|
||||
host.querySelector("button").click();
|
||||
assert.equal(host.querySelector(".eve-command-result-message").textContent, "Working…");
|
||||
assert.equal(host.querySelector(".eve-command-result-region").getAttribute("role"), "status");
|
||||
|
||||
finish({
|
||||
schema: "gamecult.eve.command_result.v1",
|
||||
receipt: {
|
||||
schema: "gamecult.eve.command_receipt.v1",
|
||||
receiptId: "receipt:assess",
|
||||
commandId: "command:assess",
|
||||
command: "world.assess",
|
||||
state: "denied",
|
||||
ownerRepo: "GameCult/Ghostlight",
|
||||
authority: "WorldKernel",
|
||||
providerId: "ghostlight",
|
||||
surfaceId: "ghostlight.play",
|
||||
message: "No world state changed.",
|
||||
diagnostics: [],
|
||||
issuedAtUtc: "2026-08-23T00:00:00Z",
|
||||
sourceVersion: 7,
|
||||
},
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.equal(host.querySelector(".eve-command-result-message").textContent, "No world state changed.");
|
||||
assert.equal(host.querySelector(".eve-command-result-region").getAttribute("role"), "alert");
|
||||
providerHost.stop();
|
||||
} finally {
|
||||
restoreDom();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue