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.
Files changed (60) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +6 -2
  3. package/browser-runtime/ai/core-onnx.mjs +96 -0
  4. package/browser-runtime/ai/onnx-tensors.mjs +85 -0
  5. package/browser-runtime/core/client.mjs +152 -17
  6. package/docs/architecture.md +5 -2
  7. package/docs/platform-targets.md +30 -13
  8. package/docs/reference/README.md +22 -3
  9. package/docs/reference/availability-and-normalization.md +14 -7
  10. package/docs/reference/cli.md +22 -10
  11. package/docs/reference/core-client.md +87 -0
  12. package/docs/reference/core-native-packaging.md +129 -9
  13. package/docs/reference/core-preferences.md +49 -0
  14. package/docs/reference/diarization.md +206 -0
  15. package/docs/reference/git-text-snapshot.md +104 -0
  16. package/docs/reference/inventory/package-api.json +205 -23
  17. package/docs/reference/local-ai.md +145 -11
  18. package/docs/reference/protocols.md +28 -19
  19. package/docs/reference/pwa.md +74 -4
  20. package/docs/reference/sdk-api.md +589 -39
  21. package/package.json +11 -3
  22. package/schemas/arcane-app.schema.json +3 -3
  23. package/schemas/arcane-package.schema.json +2 -2
  24. package/schemas/native-build-plan.schema.json +27 -1
  25. package/schemas/target-adapter.schema.json +2 -0
  26. package/src/app-descriptor.mjs +1 -1
  27. package/src/cli/main.mjs +14 -4
  28. package/src/constants.mjs +2 -0
  29. package/src/core/host.mjs +14 -5
  30. package/src/core/hosts/macos/ArcaneHost.h +86 -0
  31. package/src/core/hosts/macos/ArcaneHost.m +455 -0
  32. package/src/core/hosts/macos/ArcaneLauncher.m +432 -0
  33. package/src/core/hosts/windows/ArcaneLauncher.cs +4 -1
  34. package/src/core/services/local-ai.mjs +82 -4
  35. package/src/core/services/packaged-web.mjs +89 -0
  36. package/src/core/services/preferences.mjs +201 -0
  37. package/src/core-classic-source.mjs +2 -2
  38. package/src/dev-server.mjs +2 -5
  39. package/src/diarization/build.mjs +35 -0
  40. package/src/diarization/index.mjs +432 -0
  41. package/src/diarization/native/CMakeLists.txt +9 -0
  42. package/src/diarization/native/main.cpp +269 -0
  43. package/src/diarization/service.mjs +129 -0
  44. package/src/git-text-snapshot.mjs +265 -0
  45. package/src/import-map.mjs +9 -4
  46. package/src/index.mjs +1 -0
  47. package/src/local-ai/config.mjs +1 -1
  48. package/src/local-ai/install.mjs +134 -19
  49. package/src/local-ai/onnx-worker.mjs +85 -0
  50. package/src/local-ai/onnx.mjs +368 -0
  51. package/src/native/macos-provider.mjs +291 -0
  52. package/src/native/portable-layout.mjs +10 -2
  53. package/src/native/portable-provider.mjs +10 -5
  54. package/src/native-plan.mjs +9 -1
  55. package/src/native-provider-loader.mjs +3 -1
  56. package/src/process.mjs +123 -17
  57. package/src/scaffold.mjs +1 -1
  58. package/src/targets/index.mjs +11 -1
  59. package/src/templates/workspace-template.mjs +6 -5
  60. package/src/toolchain.mjs +42 -13
@@ -0,0 +1,368 @@
1
+ import {Worker} from 'node:worker_threads';
2
+ import Is from 'strong-type';
3
+ import {createArcaneEventSource} from '../event-manager.mjs';
4
+ import {createEventQueue} from '../event-queue.mjs';
5
+
6
+ const is=new Is(false);
7
+
8
+ function failure(code,message,cause){
9
+ const error=new Error(message,{cause});
10
+ error.code=code;
11
+ return error;
12
+ }
13
+
14
+ function cancelled(reason){
15
+ const error=failure('ARCANE_CANCELLED','The ONNX operation was cancelled.',reason);
16
+ error.name='AbortError';
17
+ return error;
18
+ }
19
+
20
+ function describeError(error){
21
+ if(!(error instanceof Error))return {name:'Error',message:String(error),cause:error};
22
+ const result={...error,name:error.name,message:error.message,stack:error.stack};
23
+ if(error.cause!==undefined)result.cause=error.cause instanceof Error?describeError(error.cause):error.cause;
24
+ if(error instanceof AggregateError)result.errors=error.errors.map(describeError);
25
+ return result;
26
+ }
27
+
28
+ function restoreError(record){
29
+ return Object.assign(new Error(record.message,{cause:record.cause}),record);
30
+ }
31
+
32
+ function reportBackgroundError(error){
33
+ if(is.function(globalThis.reportError))globalThis.reportError(error);
34
+ else console.error(error);
35
+ }
36
+
37
+ function waitForRelease(task,signal){
38
+ if(!signal)return task;
39
+ if(signal.aborted)return Promise.reject(cancelled(signal.reason));
40
+ return new Promise(function joinRelease(resolve,reject){
41
+ function abort(){reject(cancelled(signal.reason));}
42
+ function complete(result){signal.removeEventListener('abort',abort);resolve(result);}
43
+ function failed(error){signal.removeEventListener('abort',abort);reject(error);}
44
+ signal.addEventListener('abort',abort,{once:true});
45
+ void task.then(complete,failed);
46
+ });
47
+ }
48
+
49
+ export function createONNXRuntime({modulePath,onEvent,signal}={}){
50
+ const owner={};
51
+ const events=createArcaneEventSource(owner,{source:'local-ai.onnx',eventTypes:['onnx.state']});
52
+ const diagnosticFailures=[];
53
+ const diagnostics=createEventQueue(deliverDiagnostic,{onFailure:reportBackgroundError});
54
+ const sessions=new Map();
55
+ let nextRequestId=0;
56
+ let closing=false;
57
+ let closed=false;
58
+ let closeTask;
59
+
60
+ async function deliverDiagnostic(event){
61
+ if(is.function(onEvent)){
62
+ try{await onEvent(event);return;}
63
+ catch(error){diagnosticFailures.push(error);reportBackgroundError(error);}
64
+ }
65
+ if(event.type==='local-ai.onnx.stdout'||event.type==='local-ai.onnx.stderr'){
66
+ process.stderr.write(event.message);
67
+ }
68
+ }
69
+
70
+ function captureOutput(entry,stream,type){
71
+ const ended=new Promise(function outputEnded(resolve){
72
+ stream.once('end',resolve);
73
+ stream.once('close',resolve);
74
+ stream.once('error',resolve);
75
+ });
76
+ stream.setEncoding('utf8');
77
+ stream.on('data',function workerOutput(content){
78
+ stream.pause();
79
+ void diagnostics.enqueue({type,message:content,data:{id:entry.state.id}}).then(
80
+ function outputDelivered(){stream.resume();},
81
+ function outputDeliveryFailed(error){
82
+ diagnosticFailures.push(error);
83
+ reportBackgroundError(error);
84
+ process.stderr.write(content);
85
+ stream.resume();
86
+ }
87
+ );
88
+ });
89
+ stream.on('error',function workerOutputError(error){
90
+ diagnosticFailures.push(error);
91
+ reportBackgroundError(error);
92
+ });
93
+ return ended;
94
+ }
95
+
96
+ function current(){
97
+ return {sessions:[...sessions.values()].map(function sessionState(entry){
98
+ return structuredClone(entry.state);
99
+ }),closed};
100
+ }
101
+
102
+ function publish(entry){
103
+ const state=current();
104
+ events.dispatch('onnx.state',state);
105
+ diagnostics.enqueue({
106
+ type:'local-ai.onnx.state',
107
+ message:entry?`ONNX session ${entry.state.id} is ${entry.state.state}.`:'The ONNX runtime is closed.',
108
+ data:entry?structuredClone(entry.state):state
109
+ });
110
+ }
111
+
112
+ function setState(entry,state,loaded,error=null){
113
+ entry.state={...entry.state,state,loaded,error:error?describeError(error):null};
114
+ publish(entry);
115
+ }
116
+
117
+ function subscribe(listener){
118
+ if(!is.function(listener))throw new TypeError('An ONNX state listener must be a function.');
119
+ if(closed){
120
+ listener(current());
121
+ return function unsubscribeClosedRuntime(){};
122
+ }
123
+ const unsubscribe=events.on('onnx.state',function deliverState(event){listener(event.detail);});
124
+ try{listener(current());}
125
+ catch(error){unsubscribe();throw error;}
126
+ return unsubscribe;
127
+ }
128
+
129
+ function assertOpen(operationSignal){
130
+ if(operationSignal?.aborted)throw cancelled(operationSignal.reason);
131
+ if(closing||closed)throw failure('LOCAL_AI_RUNTIME_CLOSED','The ONNX runtime is closing or closed.');
132
+ }
133
+
134
+ function settle(entry,operation,error,result){
135
+ if(operation.settled)return;
136
+ operation.settled=true;
137
+ operation.signal?.removeEventListener('abort',operation.cancel);
138
+ if(entry.active===operation)entry.active=null;
139
+ if(error)operation.reject(error);
140
+ else operation.resolve(result);
141
+ }
142
+
143
+ function rejectOperations(entry,error){
144
+ if(entry.active)settle(entry,entry.active,error);
145
+ for(const operation of entry.queue.splice(0))settle(entry,operation,error);
146
+ }
147
+
148
+ function observeStop(entry,reason,failed=false){
149
+ void stopSession(entry,reason,failed).catch(reportBackgroundError);
150
+ }
151
+
152
+ function stopSession(entry,reason,failed=false){
153
+ if(entry.stopTask)return entry.stopTask;
154
+ if(entry.exited)return Promise.resolve(structuredClone(entry.state));
155
+ const active=Boolean(entry.active);
156
+ const worker=entry.worker;
157
+ entry.stopping=true;
158
+ if(failed)entry.terminalError??=reason;
159
+ entry.stopTask=Promise.resolve().then(async function releaseSession(){
160
+ if(active||failed||entry.terminalError){
161
+ entry.terminationRequested=true;
162
+ await worker.terminate();
163
+ }else{
164
+ try{worker.postMessage({operation:'unload',requestId:++nextRequestId});}
165
+ catch(error){
166
+ entry.releaseError=error;
167
+ entry.terminationRequested=true;
168
+ await worker.terminate();
169
+ }
170
+ }
171
+ await entry.exit;
172
+ await Promise.all(entry.outputTasks);
173
+ if(entry.releaseError)throw entry.releaseError;
174
+ return structuredClone(entry.state);
175
+ });
176
+ // A native call has no AbortSignal. Stop delivery immediately, retain
177
+ // unloading state, and join actual worker exit before reporting release.
178
+ rejectOperations(entry,reason);
179
+ setState(entry,entry.terminalError?'error':'unloading',entry.state.loaded,entry.terminalError);
180
+ return entry.stopTask;
181
+ }
182
+
183
+ function dispatchNext(entry){
184
+ if(closing||entry.stopping||entry.exited||entry.active)return;
185
+ const operation=entry.queue.shift();
186
+ if(!operation)return;
187
+ if(operation.signal?.aborted){
188
+ settle(entry,operation,cancelled(operation.signal.reason));
189
+ dispatchNext(entry);
190
+ return;
191
+ }
192
+ entry.active=operation;
193
+ try{
194
+ entry.worker.postMessage({requestId:operation.requestId,operation:operation.kind,...operation.payload});
195
+ }catch(error){
196
+ if(operation.kind==='load')observeStop(entry,error,true);
197
+ else{
198
+ settle(entry,operation,error);
199
+ setState(entry,'error',entry.state.loaded,error);
200
+ dispatchNext(entry);
201
+ }
202
+ }
203
+ }
204
+
205
+ function enqueue(entry,kind,payload,operationSignal,deferred=false){
206
+ return new Promise(function queueOperation(resolve,reject){
207
+ const operation={kind,payload,signal:operationSignal,requestId:++nextRequestId,resolve,reject,settled:false,cancel};
208
+ function cancel(){
209
+ if(operation.settled)return;
210
+ const error=cancelled(operationSignal.reason);
211
+ if(entry.active===operation||kind==='load')observeStop(entry,error);
212
+ else{
213
+ const index=entry.queue.indexOf(operation);
214
+ if(index>=0)entry.queue.splice(index,1);
215
+ settle(entry,operation,error);
216
+ }
217
+ }
218
+ entry.queue.push(operation);
219
+ operationSignal?.addEventListener('abort',cancel,{once:true});
220
+ if(operationSignal?.aborted)cancel();
221
+ if(!deferred)dispatchNext(entry);
222
+ });
223
+ }
224
+
225
+ function receiveResult(entry,message){
226
+ if(message.operation==='unload'){
227
+ if(message.error)entry.releaseError=restoreError(message.error);
228
+ return;
229
+ }
230
+ if(closing||entry.stopping||entry.exited)return;
231
+ const operation=entry.active;
232
+ if(!operation||operation.requestId!==message.requestId)return;
233
+ if(operation.signal?.aborted){operation.cancel();return;}
234
+ if(message.error){
235
+ const error=restoreError(message.error);
236
+ if(operation.kind==='load')observeStop(entry,error,true);
237
+ else{
238
+ settle(entry,operation,error);
239
+ setState(entry,'error',true,error);
240
+ dispatchNext(entry);
241
+ }
242
+ return;
243
+ }
244
+ if(operation.kind==='load'){
245
+ entry.state={...entry.state,...message.result};
246
+ setState(entry,'ready',true);
247
+ }else if(entry.state.state==='error')setState(entry,'ready',true);
248
+ if(closing||entry.stopping||operation.settled)return;
249
+ settle(entry,operation,null,operation.kind==='load'?structuredClone(entry.state):message.result);
250
+ dispatchNext(entry);
251
+ }
252
+
253
+ function workerExited(entry,code){
254
+ entry.exited=true;
255
+ entry.worker=null;
256
+ if(!entry.stopping||(!entry.terminationRequested&&code!==0)){
257
+ entry.terminalError??=failure('LOCAL_AI_WORKER_EXITED',`The ONNX worker exited unexpectedly with code ${code}.`);
258
+ rejectOperations(entry,entry.terminalError);
259
+ }
260
+ const error=entry.releaseError??entry.terminalError;
261
+ try{setState(entry,error?'error':'unloaded',false,error);}
262
+ finally{entry.resolveExit(code);}
263
+ }
264
+
265
+ async function load({id,model,sessionOptions,signal:operationSignal}={}){
266
+ assertOpen(operationSignal);
267
+ if(!is.string(id)||!is.string(model))throw new TypeError('ONNX load requires a session id and model path.');
268
+ const previous=sessions.get(id);
269
+ if(previous?.stopTask){
270
+ try{await waitForRelease(previous.stopTask,operationSignal);}
271
+ catch(error){
272
+ // The previous operation retains its release failure. A new
273
+ // explicit load can proceed once its worker has actually exited.
274
+ if(operationSignal?.aborted||!previous.exited)throw error;
275
+ }
276
+ }
277
+ assertOpen(operationSignal);
278
+ if(sessions.get(id)!==previous){
279
+ throw failure('LOCAL_AI_SESSION_EXISTS',`ONNX session ${String(id)} was loaded by another request.`);
280
+ }
281
+ if(previous&&!previous.exited){
282
+ throw failure('LOCAL_AI_SESSION_EXISTS',`ONNX session ${String(id)} already exists. Unload it before loading a replacement.`);
283
+ }
284
+ const entry={
285
+ state:{id,model,state:'loading',loaded:false,error:null,inputNames:[],outputNames:[],inputMetadata:[],outputMetadata:[]},
286
+ worker:null,active:null,queue:[],stopping:false,exited:false,stopTask:null,terminalError:null,releaseError:null,terminationRequested:false,outputTasks:[]
287
+ };
288
+ entry.exit=new Promise(function workerExit(resolve){entry.resolveExit=resolve;});
289
+ sessions.set(id,entry);
290
+ try{
291
+ entry.worker=new Worker(new URL('./onnx-worker.mjs',import.meta.url),{
292
+ workerData:{modulePath},stdout:true,stderr:true
293
+ });
294
+ entry.outputTasks.push(
295
+ captureOutput(entry,entry.worker.stdout,'local-ai.onnx.stdout'),
296
+ captureOutput(entry,entry.worker.stderr,'local-ai.onnx.stderr')
297
+ );
298
+ entry.worker.on('message',function workerMessage(message){receiveResult(entry,message);});
299
+ entry.worker.on('messageerror',function workerMessageError(error){observeStop(entry,error,true);});
300
+ entry.worker.on('error',function workerError(error){
301
+ entry.terminalError=error;
302
+ entry.stopping=true;
303
+ rejectOperations(entry,error);
304
+ setState(entry,'error',entry.state.loaded,error);
305
+ });
306
+ entry.worker.once('exit',function workerExit(code){workerExited(entry,code);});
307
+ }catch(error){
308
+ entry.exited=true;
309
+ setState(entry,'error',false,error);
310
+ entry.resolveExit(null);
311
+ throw error;
312
+ }
313
+ // Queue load before publishing readiness so a synchronous subscriber
314
+ // can enqueue inference or request release without racing ownership.
315
+ const task=enqueue(entry,'load',{model,sessionOptions},operationSignal,true);
316
+ publish(entry);
317
+ dispatchNext(entry);
318
+ return task;
319
+ }
320
+
321
+ async function run({id,feeds,fetches,runOptions,signal:operationSignal}={}){
322
+ assertOpen(operationSignal);
323
+ const entry=sessions.get(id);
324
+ if(!entry||entry.exited||entry.stopping){
325
+ throw failure('LOCAL_AI_SESSION_UNAVAILABLE',`ONNX session ${String(id)} is not loaded or loading.`);
326
+ }
327
+ return enqueue(entry,'run',{feeds,fetches,runOptions},operationSignal);
328
+ }
329
+
330
+ async function unload({id,signal:operationSignal}={}){
331
+ if(operationSignal?.aborted)throw cancelled(operationSignal.reason);
332
+ const entry=sessions.get(id);
333
+ if(!entry)throw failure('LOCAL_AI_SESSION_UNAVAILABLE',`ONNX session ${String(id)} does not exist.`);
334
+ const result=await stopSession(entry,failure('LOCAL_AI_SESSION_UNLOADED',`ONNX session ${String(id)} is unloading.`));
335
+ if(operationSignal?.aborted)throw cancelled(operationSignal.reason);
336
+ return result;
337
+ }
338
+
339
+ function close(){
340
+ if(closeTask)return closeTask;
341
+ closing=true;
342
+ signal?.removeEventListener('abort',lifetimeAborted);
343
+ closeTask=Promise.resolve().then(async function closeRuntime(){
344
+ const results=await Promise.allSettled([...sessions.values()].map(function releaseEntry(entry){
345
+ return stopSession(entry,failure('LOCAL_AI_RUNTIME_CLOSED','The ONNX runtime is closing.'));
346
+ }));
347
+ await Promise.all([...sessions.values()].filter(function exited(entry){return entry.exited;})
348
+ .flatMap(function outputs(entry){return entry.outputTasks;}));
349
+ const failures=results.filter(function rejected(result){return result.status==='rejected';})
350
+ .map(function rejection(result){return result.reason;});
351
+ closed=[...sessions.values()].every(function exited(entry){return entry.exited;});
352
+ if(closed)publish();
353
+ try{await diagnostics.drain();}
354
+ catch(error){failures.push(error);}
355
+ failures.push(...diagnosticFailures);
356
+ if(closed)events.dispose();
357
+ if(failures.length)throw new AggregateError(failures,'The ONNX runtime closed with errors.');
358
+ return current();
359
+ });
360
+ return closeTask;
361
+ }
362
+
363
+ function lifetimeAborted(){void close().catch(reportBackgroundError);}
364
+
365
+ signal?.addEventListener('abort',lifetimeAborted,{once:true});
366
+ if(signal?.aborted)lifetimeAborted();
367
+ return {load,run,unload,current,subscribe,close};
368
+ }
@@ -0,0 +1,291 @@
1
+ import {createWriteStream} from 'node:fs';
2
+ import {chmod, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile} from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {Readable} from 'node:stream';
5
+ import {pipeline} from 'node:stream/promises';
6
+ import Is from 'strong-type';
7
+ import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
8
+ import {runProcess} from '../process.mjs';
9
+ import {createPortableNativeProvider} from './portable-provider.mjs';
10
+ import {outputFile, writeOutput} from './portable-layout.mjs';
11
+
12
+ const is = new Is(false);
13
+ const PROTOCOL = 'arcane-native-builder/1';
14
+ const MANIFEST = 'arcane-native.json';
15
+ const HOST_FILES = ['Arcane', 'runtime/node', 'runtime/NODE-LICENSE', 'LICENSE', 'COMMERCIAL-LICENSE.md', 'NOTICE'];
16
+ const EXECUTABLE = '../MacOS/Arcane';
17
+
18
+ function macosTarget(value) {
19
+ if (!['arm64', 'x64'].includes(value?.architecture)
20
+ || value.target !== `macos-${value.architecture}` || value.platform !== 'macos' || value.format !== 'app') {
21
+ throw new ArcaneError(ERROR_CODES.targetUnavailable, 'The macOS provider assembles macos-arm64 and macos-x64 application bundles.');
22
+ }
23
+ return {...value};
24
+ }
25
+
26
+ function portableRequest(target) {
27
+ return {...target, target: 'portable', format: 'portable'};
28
+ }
29
+
30
+ async function emit(onEvent, event) {
31
+ if (is.function(onEvent)) await onEvent(event);
32
+ }
33
+
34
+ async function hostFilesMissing(directory) {
35
+ const missing = [];
36
+ for (const relative of HOST_FILES) {
37
+ try {
38
+ if (!(await stat(path.join(directory, relative))).isFile()) missing.push(relative);
39
+ } catch (error) {
40
+ if (error.code !== 'ENOENT') throw error;
41
+ missing.push(relative);
42
+ }
43
+ }
44
+ return missing;
45
+ }
46
+
47
+ async function requireHostDirectory(directory) {
48
+ const resolved = await realpath(directory);
49
+ const missing = await hostFilesMissing(resolved);
50
+ if (missing.length) {
51
+ throw new ArcaneError(ERROR_CODES.prerequisiteMissing, 'The selected macOS host assets are incomplete.', {
52
+ details: {hostDirectory: resolved, missing}
53
+ });
54
+ }
55
+ return resolved;
56
+ }
57
+
58
+ async function removeOwnedDirectories(directories, error) {
59
+ const failures = [error];
60
+ for (const directory of directories) {
61
+ try { await rm(directory, {recursive: true, force: true}); }
62
+ catch (cleanupError) { failures.push(cleanupError); }
63
+ }
64
+ if (failures.length > 1) {
65
+ throw new AggregateError(failures, `macOS preparation failed; inspect the owned outputs at ${directories.join(', ')}.`);
66
+ }
67
+ throw error;
68
+ }
69
+
70
+ async function releasedHostDirectory(outputRoot, version, target, {signal, onEvent}) {
71
+ const cache = path.join(outputRoot, '.arcane-native-hosts', version);
72
+ const destination = path.join(cache, target.target);
73
+ try { return await requireHostDirectory(destination); }
74
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
75
+ throwIfAborted(signal);
76
+ await mkdir(cache, {recursive: true});
77
+ const temporary = await mkdtemp(path.join(cache, `preparing-${target.target}-`));
78
+ const archiveName = `arcane-native-macos-${target.architecture}.tar.gz`;
79
+ const archive = path.join(temporary, archiveName);
80
+ const extracted = path.join(temporary, 'host');
81
+ const url = `https://github.com/TheWizardNexus/arcane-os-sdk/releases/download/${version}/${archiveName}`;
82
+ try {
83
+ await emit(onEvent, {type: 'native.host.download.started', target: target.target, version, url});
84
+ throwIfAborted(signal);
85
+ const response = await fetch(url, {signal});
86
+ if (!response.ok) {
87
+ throw new ArcaneError(ERROR_CODES.prerequisiteMissing, `The SDK macOS host download returned HTTP ${response.status}.`, {
88
+ details: {url, status: response.status, response: await response.text()}
89
+ });
90
+ }
91
+ if (!response.body) throw new ArcaneError(ERROR_CODES.operationFailed, 'The SDK macOS host download returned no content.', {details: {url}});
92
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(archive, {flags: 'wx'}), {signal});
93
+ throwIfAborted(signal);
94
+ await mkdir(extracted);
95
+ await runProcess('tar', ['-xzf', archive, '-C', extracted], {cwd: temporary, signal, onEvent});
96
+ await requireHostDirectory(extracted);
97
+ throwIfAborted(signal);
98
+ try { await rename(extracted, destination); }
99
+ catch (error) {
100
+ // A concurrent build may have completed this exact SDK/architecture.
101
+ if (!['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error.code)) throw error;
102
+ try { await requireHostDirectory(destination); }
103
+ catch { throw error; }
104
+ }
105
+ await rm(temporary, {recursive: true, force: true});
106
+ await emit(onEvent, {type: 'native.host.download.completed', target: target.target, version, hostDirectory: destination});
107
+ throwIfAborted(signal);
108
+ return destination;
109
+ } catch (error) {
110
+ return removeOwnedDirectories([temporary], error);
111
+ }
112
+ }
113
+
114
+ function contains(root, candidate) {
115
+ const relative = path.relative(root, candidate);
116
+ return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
117
+ }
118
+
119
+ async function copyHostAssets(source, resources, files, signal, relative = '', ancestors = new Set()) {
120
+ throwIfAborted(signal);
121
+ const canonical = await realpath(source);
122
+ const output = await realpath(path.dirname(resources));
123
+ if (contains(canonical, output) || contains(output, canonical) || ancestors.has(canonical)) {
124
+ throw new ArcaneError(ERROR_CODES.usage, 'macOS host assets overlap their output or contain a directory cycle.', {
125
+ details: {source, outputRoot: resources}
126
+ });
127
+ }
128
+ const parents = new Set([...ancestors, canonical]);
129
+ for (const entry of await readdir(source, {withFileTypes: true})) {
130
+ throwIfAborted(signal);
131
+ const selected = path.join(source, entry.name);
132
+ const destination = relative ? `${relative}/${entry.name}` : entry.name;
133
+ const information = await stat(selected);
134
+ if (information.isDirectory()) {
135
+ await mkdir(outputFile(resources, destination), {recursive: true});
136
+ await copyHostAssets(selected, resources, files, signal, destination, parents);
137
+ } else if (information.isFile()) {
138
+ const content = await readFile(selected, {signal});
139
+ const launcher = destination === 'Arcane';
140
+ const actual = launcher ? path.resolve(resources, EXECUTABLE) : outputFile(resources, destination);
141
+ if (launcher) await writeFile(actual, content, {flag: 'wx', signal});
142
+ else await writeOutput(resources, destination, content, files, signal);
143
+ // These two selected assets are executables even when assembly is
144
+ // performed on a filesystem that cannot represent Unix mode bits.
145
+ const mode = launcher || destination === 'runtime/node' ? (information.mode & 0o777) | 0o111 : information.mode & 0o777;
146
+ await chmod(actual, mode);
147
+ } else {
148
+ throw new ArcaneError(ERROR_CODES.operationFailed, `Cannot copy macOS host asset ${selected}.`);
149
+ }
150
+ }
151
+ }
152
+
153
+ function plistText(value) {
154
+ return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
155
+ .replaceAll('"', '&quot;').replaceAll("'", '&apos;');
156
+ }
157
+
158
+ function infoPlist(application) {
159
+ const values = {
160
+ CFBundleExecutable: 'Arcane', CFBundleIdentifier: `org.arcane.${application.id}`,
161
+ CFBundleName: application.displayName ?? application.id, CFBundleDisplayName: application.displayName ?? application.id,
162
+ CFBundlePackageType: 'APPL', CFBundleVersion: application.version ?? '1.0.0',
163
+ CFBundleShortVersionString: application.version ?? '1.0.0', LSMinimumSystemVersion: '11.0', NSPrincipalClass: 'NSApplication'
164
+ };
165
+ return [
166
+ '<?xml version="1.0" encoding="UTF-8"?>',
167
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
168
+ '<plist version="1.0"><dict>',
169
+ ...Object.entries(values).map(function property([key, value]) { return ` <key>${key}</key><string>${plistText(value)}</string>`; }),
170
+ '</dict></plist>', ''
171
+ ].join('\n');
172
+ }
173
+
174
+ async function requireBundle(resources) {
175
+ const root = await realpath(resources);
176
+ for (const relative of [...HOST_FILES.filter(function resource(name) { return name !== 'Arcane'; }), EXECUTABLE, '../Info.plist']) {
177
+ if (!(await stat(path.resolve(root, relative))).isFile()) {
178
+ throw new ArcaneError(ERROR_CODES.prerequisiteMissing, `The macOS application file is unavailable: ${relative}.`);
179
+ }
180
+ }
181
+ return root;
182
+ }
183
+
184
+ /** Compose complete portable app content with the selected SDK macOS host. */
185
+ export function createMacOSNativeProvider({services, hostDirectory} = {}) {
186
+ const portable = createPortableNativeProvider({services, packagedWeb: true, webKitDocumentLifecycle: true});
187
+ return {
188
+ protocol: PROTOCOL,
189
+ async describe() {
190
+ return {protocol: PROTOCOL, id: 'arcane-sdk-macos', targets: ['macos-arm64', 'macos-x64'], executable: true,
191
+ serviceConfiguration: 'explicit release module factories'};
192
+ },
193
+ async doctor({targetRequest, signal, onEvent} = {}) {
194
+ throwIfAborted(signal);
195
+ const target = macosTarget(targetRequest);
196
+ const core = await portable.doctor({targetRequest: portableRequest(target), signal});
197
+ const missing = hostDirectory === undefined ? [] : await hostFilesMissing(path.resolve(hostDirectory));
198
+ const result = {
199
+ target: target.target, executable: true, ready: core.ready && missing.length === 0,
200
+ sdkRoot: core.sdkRoot, missing: [...core.missing, ...missing],
201
+ hostDirectory: hostDirectory === undefined ? null : path.resolve(hostDirectory),
202
+ hostSource: hostDirectory === undefined ? 'sdk-release' : 'selected-directory',
203
+ downloadRequired: hostDirectory === undefined,
204
+ runtimePrerequisite: 'macOS 11 or newer with AppKit and WebKit, also satisfying the selected Node runtime requirements',
205
+ buildPrerequisite: hostDirectory === undefined ? 'tar' : null
206
+ };
207
+ await emit(onEvent, {type: 'native.doctor.completed', ...result});
208
+ throwIfAborted(signal);
209
+ return result;
210
+ },
211
+ async prepare({targetRequest, signal, onEvent} = {}) {
212
+ throwIfAborted(signal);
213
+ const target = macosTarget(targetRequest);
214
+ const selection = await portable.prepare({targetRequest: portableRequest(target), signal});
215
+ const selected = hostDirectory === undefined ? undefined : await requireHostDirectory(hostDirectory);
216
+ const result = {...selection, target, executable: true, ...(selected ? {hostDirectory: selected} : {})};
217
+ await emit(onEvent, {type: 'native.prepared', target: target.target, sdkVersion: selection.sdkVersion});
218
+ throwIfAborted(signal);
219
+ return result;
220
+ },
221
+ async build(request = {}) {
222
+ const {signal, onEvent} = request;
223
+ throwIfAborted(signal);
224
+ const target = macosTarget(request.targetRequest);
225
+ const selected = hostDirectory ?? request.toolchain?.hostDirectory;
226
+ const selectedHost = selected === undefined ? null : await requireHostDirectory(selected);
227
+ const artifact = await portable.build({
228
+ ...request, targetRequest: portableRequest(target),
229
+ async onEvent(event) {
230
+ if (event.type !== 'native.payload.completed') await emit(onEvent, {...event, target: target.target});
231
+ }
232
+ });
233
+ const portableRoot = artifact.target.rootDir;
234
+ let assembly;
235
+ try {
236
+ const host = selectedHost ?? await releasedHostDirectory(path.resolve(request.outputRoot), artifact.manifest.sdk.version, target, {signal, onEvent});
237
+ throwIfAborted(signal);
238
+ assembly = await mkdtemp(path.join(path.resolve(request.outputRoot), 'arcane-macos-'));
239
+ const plist = outputFile(assembly, `${request.appDescriptor.id}.app/Contents/Info.plist`);
240
+ const contents = path.dirname(plist);
241
+ const root = path.join(contents, 'Resources');
242
+ const bundleRoot = path.dirname(contents);
243
+ await mkdir(path.join(contents, 'MacOS'), {recursive: true});
244
+ await rename(portableRoot, root);
245
+ const files = [...artifact.manifest.files];
246
+ await copyHostAssets(host, root, files, signal);
247
+ await writeFile(plist, infoPlist(request.appDescriptor), {flag: 'wx', signal});
248
+ const manifest = {
249
+ ...artifact.manifest, kind: 'arcane-macos-native', target,
250
+ host: {
251
+ executable: EXECUTABLE, platform: 'macos', architecture: target.architecture, runtime: 'webkit',
252
+ coreExecutable: 'runtime/node', coreEntry: 'runtime/arcane-core.mjs',
253
+ bundleIdentifier: `org.arcane.${request.appDescriptor.id}`, infoPlist: '../Info.plist'
254
+ },
255
+ files
256
+ };
257
+ await writeFile(path.join(root, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, {signal});
258
+ throwIfAborted(signal);
259
+ await emit(onEvent, {type: 'native.payload.completed', target: target.target, appId: request.appDescriptor.id, outputRoot: root, bundleRoot});
260
+ throwIfAborted(signal);
261
+ return {...artifact, target: {...target, rootDir: root}, bundleRoot, manifest};
262
+ } catch (error) {
263
+ return removeOwnedDirectories(assembly ? [portableRoot, assembly] : [portableRoot], error);
264
+ }
265
+ },
266
+ async verify({artifact, targetRequest, signal, onEvent} = {}) {
267
+ throwIfAborted(signal);
268
+ const target = macosTarget(targetRequest);
269
+ const result = await portable.verify({artifact, targetRequest: portableRequest(target), signal});
270
+ const root = await requireBundle(result.outputRoot);
271
+ const verified = {...result, target: target.target, executable: true, bundleRoot: path.dirname(path.dirname(root))};
272
+ await emit(onEvent, {type: 'native.verified', target: target.target, outputRoot: root});
273
+ throwIfAborted(signal);
274
+ return verified;
275
+ },
276
+ async run({artifact, targetRequest, signal, onEvent} = {}) {
277
+ throwIfAborted(signal);
278
+ const target = macosTarget(targetRequest);
279
+ if (process.platform !== 'darwin' || process.arch !== target.architecture) {
280
+ throw new ArcaneError(ERROR_CODES.nativeRunUnsupported, `Running this macOS ${target.architecture} artifact requires a matching macOS host.`);
281
+ }
282
+ const root = await requireBundle(artifact?.target?.rootDir);
283
+ return runProcess(path.resolve(root, EXECUTABLE), ['--close-on-stdin-eof'], {
284
+ cwd: root, signal, onEvent, cancellationMode: 'close-input'
285
+ });
286
+ }
287
+ };
288
+ }
289
+
290
+ export const arcaneNativeBuilderProvider = createMacOSNativeProvider();
291
+ export default arcaneNativeBuilderProvider;