arcane-os 0.57.0 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +6 -2
- package/browser-runtime/ai/core-onnx.mjs +96 -0
- package/browser-runtime/ai/onnx-tensors.mjs +85 -0
- package/browser-runtime/core/client.mjs +152 -17
- package/docs/architecture.md +5 -2
- package/docs/platform-targets.md +30 -13
- package/docs/reference/README.md +22 -3
- package/docs/reference/availability-and-normalization.md +14 -7
- package/docs/reference/cli.md +22 -10
- package/docs/reference/core-client.md +87 -0
- package/docs/reference/core-native-packaging.md +129 -9
- package/docs/reference/core-preferences.md +49 -0
- package/docs/reference/diarization.md +206 -0
- package/docs/reference/git-text-snapshot.md +104 -0
- package/docs/reference/inventory/package-api.json +205 -23
- package/docs/reference/local-ai.md +145 -11
- package/docs/reference/protocols.md +28 -19
- package/docs/reference/pwa.md +74 -4
- package/docs/reference/sdk-api.md +589 -39
- package/package.json +11 -3
- package/schemas/arcane-app.schema.json +3 -3
- package/schemas/arcane-package.schema.json +2 -2
- package/schemas/native-build-plan.schema.json +27 -1
- package/schemas/target-adapter.schema.json +2 -0
- package/src/app-descriptor.mjs +1 -1
- package/src/cli/main.mjs +14 -4
- package/src/constants.mjs +2 -0
- package/src/core/host.mjs +14 -5
- package/src/core/hosts/macos/ArcaneHost.h +86 -0
- package/src/core/hosts/macos/ArcaneHost.m +455 -0
- package/src/core/hosts/macos/ArcaneLauncher.m +432 -0
- package/src/core/hosts/windows/ArcaneLauncher.cs +4 -1
- package/src/core/services/local-ai.mjs +82 -4
- package/src/core/services/packaged-web.mjs +89 -0
- package/src/core/services/preferences.mjs +201 -0
- package/src/core-classic-source.mjs +2 -2
- package/src/dev-server.mjs +2 -5
- package/src/diarization/build.mjs +35 -0
- package/src/diarization/index.mjs +432 -0
- package/src/diarization/native/CMakeLists.txt +9 -0
- package/src/diarization/native/main.cpp +269 -0
- package/src/diarization/service.mjs +129 -0
- package/src/git-text-snapshot.mjs +265 -0
- package/src/import-map.mjs +9 -4
- package/src/index.mjs +1 -0
- package/src/local-ai/config.mjs +1 -1
- package/src/local-ai/install.mjs +134 -19
- package/src/local-ai/onnx-worker.mjs +85 -0
- package/src/local-ai/onnx.mjs +368 -0
- package/src/native/macos-provider.mjs +291 -0
- package/src/native/portable-layout.mjs +10 -2
- package/src/native/portable-provider.mjs +10 -5
- package/src/native-plan.mjs +9 -1
- package/src/native-provider-loader.mjs +3 -1
- package/src/process.mjs +123 -17
- package/src/scaffold.mjs +1 -1
- package/src/targets/index.mjs +11 -1
- package/src/templates/workspace-template.mjs +6 -5
- package/src/toolchain.mjs +42 -13
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
#import "ArcaneHost.h"
|
|
2
|
+
#import <dispatch/dispatch.h>
|
|
3
|
+
|
|
4
|
+
static NSError *ArcaneLauncherError(NSString *code, NSString *message, id detail) {
|
|
5
|
+
NSMutableDictionary *info = [@{NSLocalizedDescriptionKey: message, @"code": code} mutableCopy];
|
|
6
|
+
if (detail) info[@"detail"] = detail;
|
|
7
|
+
return [NSError errorWithDomain:@"ArcaneLauncher" code:1 userInfo:info];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
static NSString *ArcaneAbsolutePath(NSString *filename, NSString *directory) {
|
|
11
|
+
return [(filename.isAbsolutePath ? filename : [directory stringByAppendingPathComponent:filename])
|
|
12
|
+
stringByStandardizingPath];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static NSDictionary *ArcaneReadRecord(NSURL *url, NSError **error) {
|
|
16
|
+
NSData *data = [NSData dataWithContentsOfURL:url options:0 error:error];
|
|
17
|
+
if (!data) return nil;
|
|
18
|
+
id record = [NSJSONSerialization JSONObjectWithData:data options:0 error:error];
|
|
19
|
+
if (!record) return nil;
|
|
20
|
+
if (![record isKindOfClass:NSDictionary.class]) {
|
|
21
|
+
*error = ArcaneLauncherError(@"CORE_LAUNCH_RECORD_INVALID", @"The selected file must contain a JSON object.", url);
|
|
22
|
+
return nil;
|
|
23
|
+
}
|
|
24
|
+
return record;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static NSString *ArcaneRequiredString(NSDictionary *record, NSString *key, NSError **error) {
|
|
28
|
+
id value = record[key];
|
|
29
|
+
if ([value isKindOfClass:NSString.class] && [value length]) return value;
|
|
30
|
+
*error = ArcaneLauncherError(@"CORE_LAUNCH_MANIFEST_INVALID",
|
|
31
|
+
[NSString stringWithFormat:@"The native manifest needs a string at %@.", key], record);
|
|
32
|
+
return nil;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static BOOL ArcaneExpectedDocumentCancellation(NSError *error) {
|
|
36
|
+
return ([error.domain isEqual:NSURLErrorDomain] && error.code == NSURLErrorCancelled)
|
|
37
|
+
|| [error.userInfo[@"code"] isEqual:@"CORE_DOCUMENT_RETIRED"];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
typedef NS_ENUM(NSUInteger, ArcaneDiagnosticStream) {
|
|
41
|
+
ArcaneDiagnosticNative,
|
|
42
|
+
ArcaneDiagnosticStandardOutput,
|
|
43
|
+
ArcaneDiagnosticStandardError
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
@interface ArcaneLauncher : NSObject <NSApplicationDelegate, ArcaneHostDelegate> {
|
|
47
|
+
ArcaneHost *_host;
|
|
48
|
+
NSString *_title;
|
|
49
|
+
NSString *_stateRoot;
|
|
50
|
+
NSString *_workingDirectory;
|
|
51
|
+
NSString *_launchFilename;
|
|
52
|
+
NSDictionary *_launchContext;
|
|
53
|
+
NSFileHandle *_nativeLog;
|
|
54
|
+
NSFileHandle *_stdoutLog;
|
|
55
|
+
NSFileHandle *_stderrLog;
|
|
56
|
+
dispatch_queue_t _diagnosticQueue;
|
|
57
|
+
BOOL _diagnosticsScheduled;
|
|
58
|
+
BOOL _diagnosticFailed;
|
|
59
|
+
BOOL _closeOnInputEnd;
|
|
60
|
+
BOOL _terminationRequested;
|
|
61
|
+
BOOL _finished;
|
|
62
|
+
BOOL _failurePresented;
|
|
63
|
+
BOOL _webReady;
|
|
64
|
+
int _exitStatus;
|
|
65
|
+
}
|
|
66
|
+
@property(nonatomic, readonly) int exitStatus;
|
|
67
|
+
@end
|
|
68
|
+
|
|
69
|
+
@implementation ArcaneLauncher
|
|
70
|
+
|
|
71
|
+
- (instancetype)init {
|
|
72
|
+
self = [super init];
|
|
73
|
+
if (self) _diagnosticQueue = dispatch_queue_create("org.arcane.launcher.diagnostics", DISPATCH_QUEUE_SERIAL);
|
|
74
|
+
return self;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
- (int)exitStatus { return _exitStatus; }
|
|
78
|
+
|
|
79
|
+
- (NSFileHandle *)openLog:(NSString *)name inDirectory:(NSString *)directory error:(NSError **)error {
|
|
80
|
+
NSURL *url = [NSURL fileURLWithPath:[directory stringByAppendingPathComponent:name]];
|
|
81
|
+
if (![NSData.data writeToURL:url options:NSDataWritingWithoutOverwriting error:error]) return nil;
|
|
82
|
+
return [NSFileHandle fileHandleForWritingToURL:url error:error];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
- (BOOL)openDiagnosticsAtRoot:(NSString *)stateRoot error:(NSError **)error {
|
|
86
|
+
NSString *directory = [stateRoot stringByAppendingPathComponent:@"Diagnostics"];
|
|
87
|
+
if (![NSFileManager.defaultManager createDirectoryAtPath:directory
|
|
88
|
+
withIntermediateDirectories:YES attributes:nil error:error]) return NO;
|
|
89
|
+
NSString *name = [NSString stringWithFormat:@"launch-%d-%@", NSProcessInfo.processInfo.processIdentifier, NSUUID.UUID.UUIDString];
|
|
90
|
+
_nativeLog = [self openLog:[name stringByAppendingString:@".jsonl"] inDirectory:directory error:error];
|
|
91
|
+
if (!_nativeLog) return NO;
|
|
92
|
+
_stdoutLog = [self openLog:[name stringByAppendingString:@".stdout"] inDirectory:directory error:error];
|
|
93
|
+
if (!_stdoutLog) return NO;
|
|
94
|
+
_stderrLog = [self openLog:[name stringByAppendingString:@".stderr"] inDirectory:directory error:error];
|
|
95
|
+
return _stderrLog != nil;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
- (void)scheduleDiagnostics {
|
|
99
|
+
if (_diagnosticsScheduled) return;
|
|
100
|
+
_diagnosticsScheduled = YES;
|
|
101
|
+
NSString *stateRoot = _stateRoot ?: [NSHomeDirectory() stringByAppendingPathComponent:@"Library/Application Support/Arcane"];
|
|
102
|
+
// Setup precedes writes on this queue, not application startup. An
|
|
103
|
+
// unavailable directory leaves complete stderr delivery operational.
|
|
104
|
+
dispatch_async(_diagnosticQueue, ^{
|
|
105
|
+
NSError *error = nil;
|
|
106
|
+
@try {
|
|
107
|
+
if ([self openDiagnosticsAtRoot:stateRoot error:&error]) return;
|
|
108
|
+
} @catch (NSException *exception) {
|
|
109
|
+
error = ArcaneLauncherError(@"CORE_DIAGNOSTIC_OPEN_FAILED", exception.reason ?: exception.name,
|
|
110
|
+
@{ @"name": exception.name, @"userInfo": exception.userInfo ?: @{}, @"callStackSymbols": exception.callStackSymbols });
|
|
111
|
+
}
|
|
112
|
+
self->_diagnosticFailed = YES;
|
|
113
|
+
[self writeDiagnosticError:error ?: ArcaneLauncherError(@"CORE_DIAGNOSTIC_OPEN_FAILED",
|
|
114
|
+
@"The application diagnostic files could not be opened.", nil) toLog:self->_nativeLog];
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
- (void)writeDiagnosticData:(NSData *)data toLog:(NSFileHandle *)log {
|
|
119
|
+
NSError *error = nil;
|
|
120
|
+
if (log && ![log writeData:data error:&error]) {
|
|
121
|
+
_diagnosticFailed = YES;
|
|
122
|
+
// The original chunk is still sent intact to stderr even if its file
|
|
123
|
+
// becomes unavailable. Log failures must remain visible too.
|
|
124
|
+
NSLog(@"Arcane diagnostic file failed: %@", error);
|
|
125
|
+
}
|
|
126
|
+
error = nil;
|
|
127
|
+
if (![NSFileHandle.fileHandleWithStandardError writeData:data error:&error]) {
|
|
128
|
+
_diagnosticFailed = YES;
|
|
129
|
+
NSLog(@"Arcane diagnostic stderr failed: %@", error);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
- (void)writeData:(NSData *)data stream:(ArcaneDiagnosticStream)stream {
|
|
134
|
+
// Retain the entire native chunk and preserve enqueue order without making
|
|
135
|
+
// the main-thread delegate wait for a file or a redirected stderr reader.
|
|
136
|
+
dispatch_async(_diagnosticQueue, ^{
|
|
137
|
+
NSFileHandle *log = stream == ArcaneDiagnosticNative ? self->_nativeLog
|
|
138
|
+
: stream == ArcaneDiagnosticStandardOutput ? self->_stdoutLog : self->_stderrLog;
|
|
139
|
+
[self writeDiagnosticData:data toLog:log];
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
- (void)writeDiagnosticError:(NSError *)error toLog:(NSFileHandle *)log {
|
|
144
|
+
// Foundation's archive preserves the complete native NSError/userInfo
|
|
145
|
+
// graph, including nested causes, URLs and binary values. JSON carries the
|
|
146
|
+
// reversible archive, not a shortened description of that native graph.
|
|
147
|
+
NSError *archiveError = nil;
|
|
148
|
+
NSData *archive = nil;
|
|
149
|
+
@try {
|
|
150
|
+
archive = [NSKeyedArchiver archivedDataWithRootObject:error requiringSecureCoding:NO error:&archiveError];
|
|
151
|
+
} @catch (NSException *exception) {
|
|
152
|
+
archiveError = ArcaneLauncherError(@"CORE_DIAGNOSTIC_ARCHIVE_FAILED", exception.reason ?: exception.name,
|
|
153
|
+
@{ @"name": exception.name, @"userInfo": exception.userInfo ?: @{} });
|
|
154
|
+
}
|
|
155
|
+
NSMutableDictionary *record = [@{ @"kind": @"native-error", @"domain": error.domain,
|
|
156
|
+
@"nativeCode": @(error.code), @"message": error.localizedDescription } mutableCopy];
|
|
157
|
+
if (archive) record[@"nativeArchiveBase64"] = [archive base64EncodedStringWithOptions:0];
|
|
158
|
+
else {
|
|
159
|
+
_diagnosticFailed = YES;
|
|
160
|
+
// No partial representation is claimed to be the complete native error.
|
|
161
|
+
record[@"representationFailure"] = archiveError.localizedDescription ?: @"The native error could not be archived.";
|
|
162
|
+
NSLog(@"Arcane native diagnostic could not be archived: %@; original: %@", archiveError, error);
|
|
163
|
+
}
|
|
164
|
+
NSError *jsonError = nil;
|
|
165
|
+
NSData *json = [NSJSONSerialization dataWithJSONObject:record options:0 error:&jsonError];
|
|
166
|
+
if (json) {
|
|
167
|
+
NSMutableData *line = [json mutableCopy];
|
|
168
|
+
[line appendData:[@"\n" dataUsingEncoding:NSUTF8StringEncoding]];
|
|
169
|
+
[self writeDiagnosticData:line toLog:log];
|
|
170
|
+
} else {
|
|
171
|
+
_diagnosticFailed = YES;
|
|
172
|
+
NSLog(@"Arcane diagnostic serialization failed: %@; original: %@", jsonError, error);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
- (void)recordError:(NSError *)error {
|
|
177
|
+
if (!ArcaneExpectedDocumentCancellation(error)) _exitStatus = 1;
|
|
178
|
+
dispatch_async(_diagnosticQueue, ^{ [self writeDiagnosticError:error toLog:self->_nativeLog]; });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
- (void)presentFailure:(NSString *)message {
|
|
182
|
+
_exitStatus = 1;
|
|
183
|
+
if (_terminationRequested || _finished) return;
|
|
184
|
+
if (_closeOnInputEnd) { [NSApp terminate:nil]; return; }
|
|
185
|
+
if (_failurePresented) return;
|
|
186
|
+
_failurePresented = YES;
|
|
187
|
+
NSAlert *alert = [[NSAlert alloc] init];
|
|
188
|
+
alert.alertStyle = NSAlertStyleCritical;
|
|
189
|
+
alert.messageText = message;
|
|
190
|
+
alert.informativeText = @"Close the application and try opening it again.";
|
|
191
|
+
[alert addButtonWithTitle:@"Close Application"];
|
|
192
|
+
if (_host.window.visible) {
|
|
193
|
+
[alert beginSheetModalForWindow:_host.window completionHandler:^(NSModalResponse response) {
|
|
194
|
+
[NSApp terminate:nil];
|
|
195
|
+
}];
|
|
196
|
+
} else {
|
|
197
|
+
[alert runModal];
|
|
198
|
+
[NSApp terminate:nil];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
- (BOOL)prepareAndOpen:(NSError **)error {
|
|
203
|
+
_workingDirectory = NSFileManager.defaultManager.currentDirectoryPath;
|
|
204
|
+
NSArray<NSString *> *arguments = NSProcessInfo.processInfo.arguments;
|
|
205
|
+
for (NSUInteger index = 1; index < arguments.count; index++) {
|
|
206
|
+
NSString *argument = arguments[index];
|
|
207
|
+
if ([argument isEqual:@"--arcane-launch-config"]) {
|
|
208
|
+
if (++index == arguments.count || ![arguments[index] length]) {
|
|
209
|
+
*error = ArcaneLauncherError(@"CORE_LAUNCH_ARGUMENT_INVALID", @"--arcane-launch-config needs a filename.", nil);
|
|
210
|
+
return NO;
|
|
211
|
+
}
|
|
212
|
+
_launchFilename = ArcaneAbsolutePath(arguments[index], _workingDirectory);
|
|
213
|
+
} else if ([argument isEqual:@"--close-on-stdin-eof"]) _closeOnInputEnd = YES;
|
|
214
|
+
else {
|
|
215
|
+
*error = ArcaneLauncherError(@"CORE_LAUNCH_ARGUMENT_INVALID", @"Unknown launcher argument.", argument);
|
|
216
|
+
return NO;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
NSURL *resources = NSBundle.mainBundle.resourceURL;
|
|
221
|
+
NSDictionary *manifest = ArcaneReadRecord([resources URLByAppendingPathComponent:@"arcane-native.json"], error);
|
|
222
|
+
if (!manifest) return NO;
|
|
223
|
+
NSDictionary *app = manifest[@"app"];
|
|
224
|
+
NSDictionary *client = manifest[@"client"];
|
|
225
|
+
NSDictionary *core = manifest[@"core"];
|
|
226
|
+
if (![app isKindOfClass:NSDictionary.class] || ![client isKindOfClass:NSDictionary.class]
|
|
227
|
+
|| ![core isKindOfClass:NSDictionary.class]) {
|
|
228
|
+
*error = ArcaneLauncherError(@"CORE_LAUNCH_MANIFEST_INVALID", @"The native manifest needs app, client and core records.", manifest);
|
|
229
|
+
return NO;
|
|
230
|
+
}
|
|
231
|
+
NSString *appID = ArcaneRequiredString(app, @"id", error);
|
|
232
|
+
if (!appID) return NO;
|
|
233
|
+
_title = [app[@"displayName"] isKindOfClass:NSString.class] ? app[@"displayName"] : appID;
|
|
234
|
+
if (_launchFilename) {
|
|
235
|
+
_launchContext = ArcaneReadRecord([NSURL fileURLWithPath:_launchFilename], error);
|
|
236
|
+
if (!_launchContext) return NO;
|
|
237
|
+
}
|
|
238
|
+
id selectedState = _launchContext[@"stateRoot"];
|
|
239
|
+
_stateRoot = [selectedState isKindOfClass:NSString.class]
|
|
240
|
+
? ArcaneAbsolutePath(selectedState, _workingDirectory)
|
|
241
|
+
: [[NSHomeDirectory() stringByAppendingPathComponent:@"Library/Application Support/Arcane"] stringByAppendingPathComponent:appID];
|
|
242
|
+
[self scheduleDiagnostics];
|
|
243
|
+
NSString *clientPath = ArcaneRequiredString(client, @"source", error);
|
|
244
|
+
if (!clientPath) return NO;
|
|
245
|
+
NSString *corePath = ArcaneRequiredString(core, @"entry", error);
|
|
246
|
+
if (!corePath) return NO;
|
|
247
|
+
NSString *classic = [NSString stringWithContentsOfURL:[resources URLByAppendingPathComponent:clientPath]
|
|
248
|
+
encoding:NSUTF8StringEncoding error:error];
|
|
249
|
+
if (!classic) return NO;
|
|
250
|
+
NSMutableArray<NSString *> *coreArguments = [NSMutableArray arrayWithObjects:
|
|
251
|
+
[resources URLByAppendingPathComponent:corePath].path,
|
|
252
|
+
@"--arcane-host-state-root", _stateRoot, nil];
|
|
253
|
+
if (_launchFilename) [coreArguments addObjectsFromArray:@[@"--arcane-launch-config", _launchFilename]];
|
|
254
|
+
|
|
255
|
+
NSMenu *menu = [[NSMenu alloc] initWithTitle:_title];
|
|
256
|
+
NSMenuItem *applicationItem = [[NSMenuItem alloc] initWithTitle:_title action:nil keyEquivalent:@""];
|
|
257
|
+
NSMenu *applicationMenu = [[NSMenu alloc] initWithTitle:_title];
|
|
258
|
+
[applicationMenu addItemWithTitle:[@"Quit " stringByAppendingString:_title] action:@selector(terminate:) keyEquivalent:@"q"];
|
|
259
|
+
applicationItem.submenu = applicationMenu;
|
|
260
|
+
[menu addItem:applicationItem];
|
|
261
|
+
NSApp.mainMenu = menu;
|
|
262
|
+
|
|
263
|
+
// The generated bundle ID owns its persistent default WebKit store on
|
|
264
|
+
// macOS 11+. stateRoot is Core/diagnostic storage, not a WebKit profile path.
|
|
265
|
+
_host = [ArcaneHost openApplicationURL:nil websiteDataStore:WKWebsiteDataStore.defaultDataStore
|
|
266
|
+
title:_title contentFrame:NSMakeRect(0, 0, 1100, 760) classicSource:classic
|
|
267
|
+
executableURL:[resources URLByAppendingPathComponent:@"runtime/node"] arguments:coreArguments
|
|
268
|
+
workingDirectoryURL:[NSURL fileURLWithPath:_workingDirectory isDirectory:YES] delegate:self];
|
|
269
|
+
[NSApp activateIgnoringOtherApps:YES];
|
|
270
|
+
if (_closeOnInputEnd) [self observeRunnerInput];
|
|
271
|
+
return YES;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
- (void)observeRunnerInput {
|
|
275
|
+
// The opt-in pipe signals runner lifetime only. It never holds the window
|
|
276
|
+
// or the Core drain open when the user closes the application first.
|
|
277
|
+
__weak ArcaneLauncher *owner = self;
|
|
278
|
+
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
|
|
279
|
+
@autoreleasepool {
|
|
280
|
+
NSError *error = nil;
|
|
281
|
+
NSFileHandle *input = NSFileHandle.fileHandleWithStandardInput;
|
|
282
|
+
for (;;) {
|
|
283
|
+
NSData *chunk = [input readDataUpToLength:4096 error:&error];
|
|
284
|
+
if (!chunk || !chunk.length) break;
|
|
285
|
+
}
|
|
286
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
287
|
+
ArcaneLauncher *launcher = owner;
|
|
288
|
+
if (!launcher || launcher->_finished) return;
|
|
289
|
+
if (error) [launcher recordError:error];
|
|
290
|
+
[NSApp terminate:nil];
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
- (void)applicationDidFinishLaunching:(NSNotification *)notification {
|
|
297
|
+
NSError *error = nil;
|
|
298
|
+
@try {
|
|
299
|
+
if ([self prepareAndOpen:&error]) return;
|
|
300
|
+
} @catch (NSException *exception) {
|
|
301
|
+
error = ArcaneLauncherError(@"CORE_LAUNCH_FAILED", exception.reason ?: exception.name,
|
|
302
|
+
@{ @"name": exception.name, @"userInfo": exception.userInfo ?: @{}, @"callStackSymbols": exception.callStackSymbols });
|
|
303
|
+
}
|
|
304
|
+
[self scheduleDiagnostics];
|
|
305
|
+
[self recordError:error ?: ArcaneLauncherError(@"CORE_LAUNCH_FAILED", @"Application startup failed.", nil)];
|
|
306
|
+
[self presentFailure:@"The application could not start."];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender { return YES; }
|
|
310
|
+
|
|
311
|
+
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender {
|
|
312
|
+
if (_terminationRequested) return NSTerminateLater;
|
|
313
|
+
_terminationRequested = YES;
|
|
314
|
+
// Reply after this callback returns, even if startup failed before a host
|
|
315
|
+
// existed or a directly closed window has already completed its drain.
|
|
316
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
317
|
+
void (^finished)(NSArray<NSError *> *) = ^(NSArray<NSError *> *errors) {
|
|
318
|
+
for (NSError *error in errors) [self recordError:error];
|
|
319
|
+
self->_finished = YES;
|
|
320
|
+
[sender replyToApplicationShouldTerminate:NO];
|
|
321
|
+
// Keep a natural main return with the real failure status instead
|
|
322
|
+
// of AppKit's unconditional successful process termination.
|
|
323
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
324
|
+
[sender stop:nil];
|
|
325
|
+
[sender postEvent:[NSEvent otherEventWithType:NSEventTypeApplicationDefined
|
|
326
|
+
location:NSZeroPoint modifierFlags:0 timestamp:0 windowNumber:0 context:nil
|
|
327
|
+
subtype:0 data1:0 data2:0] atStart:NO];
|
|
328
|
+
});
|
|
329
|
+
};
|
|
330
|
+
if (self->_host) [self->_host closeWithCompletion:finished];
|
|
331
|
+
else finished(@[]);
|
|
332
|
+
});
|
|
333
|
+
return NSTerminateLater;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
- (void)host:(ArcaneHost *)host didLaunchCoreProcess:(pid_t)processIdentifier {
|
|
337
|
+
NSData *data = [[NSString stringWithFormat:@"{\"kind\":\"core-process-started\",\"pid\":%d}\n", processIdentifier]
|
|
338
|
+
dataUsingEncoding:NSUTF8StringEncoding];
|
|
339
|
+
[self writeData:data stream:ArcaneDiagnosticNative];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
- (void)host:(ArcaneHost *)host receivedOutputData:(NSData *)data stream:(ArcaneCoreOutputStream)stream {
|
|
343
|
+
[self writeData:data stream:stream == ArcaneCoreStandardOutput
|
|
344
|
+
? ArcaneDiagnosticStandardOutput : ArcaneDiagnosticStandardError];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
- (void)host:(ArcaneHost *)host receivedFrameData:(NSData *)data {
|
|
348
|
+
NSError *error = nil;
|
|
349
|
+
id frame = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
|
350
|
+
// ArcaneHost diagnoses unreadable frames. Raw transport data has already
|
|
351
|
+
// reached the diagnostic owner, including all unrecognized event fields.
|
|
352
|
+
if (![frame isKindOfClass:NSDictionary.class] || ![frame[@"type"] isEqual:@"event"]) return;
|
|
353
|
+
id detail = frame[@"data"];
|
|
354
|
+
if ([frame[@"event"] isEqual:@"core.web.ready"]) {
|
|
355
|
+
if (_webReady || _terminationRequested) return;
|
|
356
|
+
NSString *url = [detail isKindOfClass:NSDictionary.class] ? detail[@"url"] : nil;
|
|
357
|
+
NSURL *applicationURL = [url isKindOfClass:NSString.class] ? [NSURL URLWithString:url] : nil;
|
|
358
|
+
if (!applicationURL.scheme || !applicationURL.host) {
|
|
359
|
+
[self recordError:ArcaneLauncherError(@"CORE_WEB_READY_INVALID", @"The packaged-web ready event has no application URL.", frame)];
|
|
360
|
+
[self presentFailure:@"The application could not open."];
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
_webReady = YES;
|
|
364
|
+
[host loadApplicationURL:applicationURL];
|
|
365
|
+
} else if ([frame[@"event"] isEqual:@"core.web.failed"]
|
|
366
|
+
|| ([frame[@"event"] isEqual:@"core.service.state"]
|
|
367
|
+
&& [detail isKindOfClass:NSDictionary.class]
|
|
368
|
+
&& [detail[@"name"] isEqual:@"packaged-web"] && [detail[@"state"] isEqual:@"failed"])) {
|
|
369
|
+
// Complete serialized service errors stay in the raw frame log, never
|
|
370
|
+
// in the ordinary native alert or the application's chat history.
|
|
371
|
+
[self presentFailure:_webReady ? @"The application connection stopped." : @"The application could not start."];
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
- (void)host:(ArcaneHost *)host didEncounterError:(NSError *)error {
|
|
376
|
+
[self recordError:error];
|
|
377
|
+
if (!_terminationRequested && !ArcaneExpectedDocumentCancellation(error)) {
|
|
378
|
+
[self presentFailure:@"The application encountered an error."];
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
- (void)host:(ArcaneHost *)host didExitCoreProcessWithStatus:(int)status reason:(NSTaskTerminationReason)reason {
|
|
383
|
+
if (status != 0 || reason != NSTaskTerminationReasonExit) _exitStatus = 1;
|
|
384
|
+
NSData *data = [[NSString stringWithFormat:@"{\"kind\":\"core-process-exited\",\"status\":%d,\"reason\":%ld}\n", status, (long)reason]
|
|
385
|
+
dataUsingEncoding:NSUTF8StringEncoding];
|
|
386
|
+
[self writeData:data stream:ArcaneDiagnosticNative];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
- (void)host:(ArcaneHost *)host coreDidCompleteWithErrors:(NSArray<NSError *> *)errors {
|
|
390
|
+
for (NSError *error in errors) [self recordError:error];
|
|
391
|
+
if (_terminationRequested) return;
|
|
392
|
+
if (host.isClosing) {
|
|
393
|
+
// A title-bar close already requested the host-owned drain. Join that
|
|
394
|
+
// same completion instead of treating its EOF as a runtime failure.
|
|
395
|
+
[NSApp terminate:nil];
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
[self recordError:ArcaneLauncherError(@"CORE_PROCESS_ENDED_UNEXPECTEDLY",
|
|
399
|
+
_webReady ? @"Core completed without an application or host close request."
|
|
400
|
+
: @"Core completed before the packaged web listener became ready, without a close request.",
|
|
401
|
+
@{ @"webReady": @(_webReady), @"errors": errors })];
|
|
402
|
+
[self presentFailure:_webReady ? @"The application connection stopped." : @"The application could not start."];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
- (void)closeDiagnostics {
|
|
406
|
+
// AppKit and Core are already drained. This is the first consumer that
|
|
407
|
+
// needs durable completion of every accepted diagnostic write.
|
|
408
|
+
dispatch_sync(_diagnosticQueue, ^{
|
|
409
|
+
for (id file in @[self->_nativeLog ?: NSNull.null, self->_stdoutLog ?: NSNull.null, self->_stderrLog ?: NSNull.null]) {
|
|
410
|
+
if (![file isKindOfClass:NSFileHandle.class]) continue;
|
|
411
|
+
NSError *error = nil;
|
|
412
|
+
if (![file closeAndReturnError:&error]) {
|
|
413
|
+
self->_diagnosticFailed = YES;
|
|
414
|
+
[self writeDiagnosticError:error toLog:nil];
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
if (_diagnosticFailed) _exitStatus = 1;
|
|
419
|
+
}
|
|
420
|
+
@end
|
|
421
|
+
|
|
422
|
+
int main(int argc, const char *argv[]) {
|
|
423
|
+
@autoreleasepool {
|
|
424
|
+
NSApplication *application = NSApplication.sharedApplication;
|
|
425
|
+
[application setActivationPolicy:NSApplicationActivationPolicyRegular];
|
|
426
|
+
ArcaneLauncher *launcher = [[ArcaneLauncher alloc] init];
|
|
427
|
+
application.delegate = launcher;
|
|
428
|
+
[application run];
|
|
429
|
+
[launcher closeDiagnostics];
|
|
430
|
+
return launcher.exitStatus;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -69,7 +69,10 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
69
69
|
Title = name,
|
|
70
70
|
ClassicClientSource = File.ReadAllText(Path.Combine(directory, RequiredString(client, "source"))),
|
|
71
71
|
CoreExecutable = Path.Combine(directory, "runtime", "ArcaneCore.exe"),
|
|
72
|
-
|
|
72
|
+
// Forward the actual host selection separately; Core keeps
|
|
73
|
+
// every explicit launch-record field unchanged.
|
|
74
|
+
CoreArguments = "--arcane-host-state-root " + QuoteArgument(profileParent)
|
|
75
|
+
+ (launchFilename == null ? String.Empty : " --arcane-launch-config " + QuoteArgument(launchFilename)),
|
|
73
76
|
// Relative explicit launch filenames retain their caller's
|
|
74
77
|
// meaning; the SEA locates its loader beside its executable.
|
|
75
78
|
CoreWorkingDirectory = Environment.CurrentDirectory
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import {createArcaneEventSource} from '../../event-manager.mjs';
|
|
2
2
|
import Is from 'strong-type';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
import {CoreError, serializeCoreError} from '../../../browser-runtime/core/contracts.mjs';
|
|
4
5
|
import {structuredOutputFormat, openAIResponseFormat} from '../../../browser-runtime/ai/twin-cloud.mjs';
|
|
5
6
|
import {normalizeLocalAIConfig} from '../../local-ai/config.mjs';
|
|
6
7
|
import {createLocalAIServer} from '../../local-ai/server.mjs';
|
|
8
|
+
import {createONNXRuntime} from '../../local-ai/onnx.mjs';
|
|
9
|
+
import {encodeTensorMap, decodeTensorMap, decodeTensorFetches} from '../../../browser-runtime/ai/onnx-tensors.mjs';
|
|
7
10
|
import {requestLocalJSON, streamLocalJSON} from '../../local-ai/http.mjs';
|
|
8
11
|
|
|
9
12
|
const is = new Is(false);
|
|
@@ -128,9 +131,27 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
|
|
|
128
131
|
const events = createArcaneEventSource(owner, {source: 'core-local-ai', eventTypes: ['localai.state']});
|
|
129
132
|
let context;
|
|
130
133
|
let closed = false;
|
|
134
|
+
let onnx;
|
|
135
|
+
let onnxRecovery;
|
|
136
|
+
let onnxRecoveryError;
|
|
137
|
+
let stopONNXSubscription;
|
|
138
|
+
const onnxSelected = config.runtimes.some(function selectedONNX(requirement) { return requirement.id === 'onnx'; });
|
|
139
|
+
const onnxRecord = runtimes.find(function installedONNX(runtime) { return runtime.id === 'onnx'; });
|
|
140
|
+
|
|
141
|
+
function onnxStatus() {
|
|
142
|
+
const current = onnx?.current();
|
|
143
|
+
return {
|
|
144
|
+
id: 'onnx', installed: Boolean(onnxRecord?.modulePath),
|
|
145
|
+
available: Boolean(current) && !current.closed && !closed && !onnxRecovery && !onnxRecoveryError && !lifetimeSignal.aborted,
|
|
146
|
+
state: closed ? 'closed' : onnxRecovery ? 'recovering' : onnxRecoveryError ? 'error'
|
|
147
|
+
: current?.closed ? 'closed' : lifetimeSignal.aborted ? 'closing' : onnx ? 'ready' : 'unavailable',
|
|
148
|
+
models: current?.sessions ?? [], error: onnxRecoveryError ?? null
|
|
149
|
+
};
|
|
150
|
+
}
|
|
131
151
|
|
|
132
152
|
function snapshot() {
|
|
133
153
|
const records = [...engines.values()].map(function runtimeRecord(engine) { return {...engine.state, models: [...engine.models]}; });
|
|
154
|
+
if (onnxSelected) records.push(onnxStatus());
|
|
134
155
|
const ollama = records.find(function ollamaRecord(record) { return record.id === 'ollama'; });
|
|
135
156
|
return {
|
|
136
157
|
runtimes: records,
|
|
@@ -145,6 +166,38 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
|
|
|
145
166
|
return state;
|
|
146
167
|
}
|
|
147
168
|
|
|
169
|
+
function startONNX() {
|
|
170
|
+
if (!onnxRecord?.modulePath) return;
|
|
171
|
+
onnx = createONNXRuntime({modulePath: onnxRecord.modulePath, signal: lifetimeSignal, onEvent});
|
|
172
|
+
stopONNXSubscription = onnx.subscribe(function onnxChanged() { publish(); });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function requireONNX() {
|
|
176
|
+
lifetimeSignal.throwIfAborted();
|
|
177
|
+
if (!onnxSelected) throw failure('LOCAL_AI_RUNTIME_NOT_SELECTED', 'ONNX is not selected in this application configuration.');
|
|
178
|
+
if (!onnx || onnx.current().closed || onnxRecovery || onnxRecoveryError) {
|
|
179
|
+
throw failure('LOCAL_AI_RUNTIME_UNAVAILABLE', 'The selected ONNX runtime is unavailable.');
|
|
180
|
+
}
|
|
181
|
+
return onnx;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function recoverONNX() {
|
|
185
|
+
if (onnxRecovery) return onnxRecovery;
|
|
186
|
+
onnxRecoveryError = undefined;
|
|
187
|
+
onnxRecovery = Promise.resolve().then(async function replaceONNXOwner() {
|
|
188
|
+
await onnx?.close();
|
|
189
|
+
stopONNXSubscription?.();
|
|
190
|
+
onnx = undefined;
|
|
191
|
+
lifetimeSignal.throwIfAborted();
|
|
192
|
+
startONNX();
|
|
193
|
+
});
|
|
194
|
+
function recovered() { onnxRecovery = undefined; if (!closed) publish(); }
|
|
195
|
+
function recoveryFailed(error) { onnxRecoveryError = serializeCoreError(error); recovered(); }
|
|
196
|
+
onnxRecovery.then(recovered, recoveryFailed);
|
|
197
|
+
publish();
|
|
198
|
+
return onnxRecovery;
|
|
199
|
+
}
|
|
200
|
+
|
|
148
201
|
function recordFailure(engine, error) {
|
|
149
202
|
if (!closed && error.code === 'LOCAL_AI_LOADING') {
|
|
150
203
|
engine.state = {...engine.state, available: false, state: 'loading', error: null};
|
|
@@ -443,11 +496,12 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
|
|
|
443
496
|
start(currentContext) {
|
|
444
497
|
context = currentContext;
|
|
445
498
|
for (const requirement of config.runtimes) {
|
|
446
|
-
if (!['llama.cpp', 'ollama'].includes(requirement.id)) {
|
|
499
|
+
if (!['llama.cpp', 'ollama', 'onnx'].includes(requirement.id)) {
|
|
447
500
|
throw failure('LOCAL_AI_RUNTIME_UNAVAILABLE', `Core runtime ${requirement.id} is not available in this service.`);
|
|
448
501
|
}
|
|
449
502
|
}
|
|
450
503
|
for (const requirement of config.runtimes) {
|
|
504
|
+
if (requirement.id === 'onnx') { if (!onnx) startONNX(); continue; }
|
|
451
505
|
if (engines.has(requirement.id)) continue;
|
|
452
506
|
const engine = {
|
|
453
507
|
id: requirement.id, models: [], revision: 0, modelChanges: new Map(), loads: new Map(), loadControllers: new Map(),
|
|
@@ -459,18 +513,38 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
|
|
|
459
513
|
publish();
|
|
460
514
|
},
|
|
461
515
|
methods: {
|
|
462
|
-
'localai.services.recover': async function recoverServices({runtimes: selected = [...engines.keys()]} = {}, request) {
|
|
516
|
+
'localai.services.recover': async function recoverServices({runtimes: selected = [...engines.keys(), ...(onnxSelected ? ['onnx'] : [])]} = {}, request) {
|
|
517
|
+
request.signal.throwIfAborted();
|
|
463
518
|
const selectedEngines = selected.map(function selectedEngine(id) {
|
|
519
|
+
if (id === 'onnx' && onnxSelected) return {id};
|
|
464
520
|
const engine = engines.get(id);
|
|
465
521
|
if (!engine) throw failure('LOCAL_AI_RUNTIME_NOT_SELECTED', `${id} is not selected in this application's localAI configuration.`);
|
|
466
522
|
return engine;
|
|
467
523
|
});
|
|
468
524
|
await Promise.all(selectedEngines.map(async function recoverEngine(engine) {
|
|
525
|
+
if (engine.id === 'onnx') {
|
|
526
|
+
await awaitReadiness(recoverONNX(), request.signal);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
469
529
|
await awaitReadiness(restartEngine(engine), request.signal);
|
|
470
530
|
await awaitReadiness(engine.ready, request.signal);
|
|
471
531
|
}));
|
|
472
532
|
return snapshot();
|
|
473
533
|
},
|
|
534
|
+
'onnx.status': function currentONNX() { return onnxStatus(); },
|
|
535
|
+
'onnx.load': function loadONNX({id, model, sessionOptions}, request) {
|
|
536
|
+
return requireONNX().load({id, model: path.resolve(appRoot ?? process.cwd(), model), sessionOptions,
|
|
537
|
+
signal: AbortSignal.any([lifetimeSignal, request.signal])});
|
|
538
|
+
},
|
|
539
|
+
'onnx.run': async function runONNX({id, feeds, fetches, runOptions}, request) {
|
|
540
|
+
const outputs = await requireONNX().run({id, feeds: decodeTensorMap(feeds),
|
|
541
|
+
fetches: decodeTensorFetches(fetches),
|
|
542
|
+
runOptions, signal: AbortSignal.any([lifetimeSignal, request.signal])});
|
|
543
|
+
return encodeTensorMap(outputs);
|
|
544
|
+
},
|
|
545
|
+
'onnx.unload': function unloadONNX({id}, request) {
|
|
546
|
+
return requireONNX().unload({id, signal: AbortSignal.any([lifetimeSignal, request.signal])});
|
|
547
|
+
},
|
|
474
548
|
'localai.status': async function currentStatus(_parameters, request) {
|
|
475
549
|
await Promise.all([...engines.values()].map(async function refreshEngine(engine) {
|
|
476
550
|
if (!engine.state.available) return;
|
|
@@ -528,8 +602,12 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
|
|
|
528
602
|
async dispose() {
|
|
529
603
|
closed = true;
|
|
530
604
|
lifetime.abort();
|
|
531
|
-
const results = await Promise.allSettled([
|
|
532
|
-
|
|
605
|
+
const results = await Promise.allSettled([
|
|
606
|
+
...[...engines.values()].map(function stopEngine(engine) { return engine.server.close(); }),
|
|
607
|
+
...(onnx ? [onnx.close()] : [])
|
|
608
|
+
]);
|
|
609
|
+
stopONNXSubscription?.();
|
|
610
|
+
await Promise.allSettled([...jobs, ...(onnxRecovery ? [onnxRecovery] : []), ...[...engines.values()].flatMap(function pendingWork(engine) {
|
|
533
611
|
return [...engine.loads.values(), ...(engine.recovering ? [engine.recovering] : [])];
|
|
534
612
|
})]);
|
|
535
613
|
events.dispose();
|