arcane-os 0.71.1 → 0.72.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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.72.0
4
+
5
+ - Add optional `native.launchContext` defaults for ordinary packaged launch and
6
+ public `resolveNativeLaunchContext()` through `arcane-os/core/host`. Native
7
+ applications and external MCP can select the same app-owned state, workspace,
8
+ shared endpoint and log without reconstructing platform paths.
9
+ - Extend `readCoreLaunchContext()` with explicit defaults and opt-in native
10
+ resolution. Preserve complete explicit launch records, selected workspace
11
+ values, ordinary stdio ownership and the existing no-option entry path.
12
+ - Forward packaged launch defaults through Windows and macOS launchers while
13
+ preserving window, profile, service and shared-host lifecycle behavior. No
14
+ stored-data migration or dependency change is introduced.
15
+
16
+ ## 0.71.2
17
+
18
+ - Preserve complete browser Kokoro synthesis input through the selected runtime's
19
+ phonemization and tokenizer. Process every token in ordered model-capacity
20
+ segments and retain all resulting audio, with one model owner and request-local
21
+ cancellation. Keep short-input behavior, voice, speed and the public API intact.
22
+ - Include the browser decision runtime entry in an explicitly configured DBOPFS
23
+ model store. Retain the original resource URL across activations, import the
24
+ complete stored self-contained module, and join cancellation cleanup before
25
+ settling callers. Preserve direct loading when no store is configured.
26
+
3
27
  ## 0.71.1
4
28
 
5
29
  - Correct explicit disabled PWA lifecycle in standalone source development by
package/README.md CHANGED
@@ -20,7 +20,7 @@ selected SDK projection or an explicit live SDK source mount. Both profiles
20
20
  share the theme, packaging, event, cancellation,
21
21
  and browser run contracts while retaining their selected application layout.
22
22
 
23
- This checkout defines the `0.71.1` SDK contract. Development applications declare
23
+ This checkout defines the `0.72.0` SDK contract. Development applications declare
24
24
  `arcane-os` as `latest` and retain their lockfile. Exact SDK pins begin when Roshi
25
25
  designates a production build. Follow [Consumer updates](docs/consumer-updates.md)
26
26
  to refresh and compare the installed SDK and lockfile with the published channel;
@@ -204,6 +204,9 @@ class BrowserDecisionModel {
204
204
  pending: new Map(),
205
205
  evaluations: new Set(),
206
206
  resources: null,
207
+ runtimeController: null,
208
+ runtimePreparation: null,
209
+ runtimeUrl: null,
207
210
  cleanup: null,
208
211
  reason: undefined
209
212
  };
@@ -263,9 +266,58 @@ class BrowserDecisionModel {
263
266
  }
264
267
  const id = this.#nextId++;
265
268
  activation.pending.set(id, activation.load);
266
- worker.postMessage(
267
- {id, op: 'load', payload: this.#configuration, storedResources: this.#store !== null}
268
- );
269
+ if (this.#store) {
270
+ activation.runtimeController = new AbortController();
271
+ // Retain the operation before a store progress callback can
272
+ // synchronously unload this activation.
273
+ activation.runtimePreparation = Promise.resolve().then(
274
+ async function prepareDecisionRuntimeEntry() {
275
+ if (!model.#isCurrent(activation)) return;
276
+ const configuration = model.#configuration;
277
+ const sourceUrl = new URL(configuration.runtime.moduleUrl, import.meta.url).href;
278
+ const response = await model.#store.fetchResource(sourceUrl, {
279
+ signal: activation.runtimeController.signal,
280
+ onProgress: function reportRuntimeEntryProgress(progress) {
281
+ if (!model.#isCurrent(activation)) return;
282
+ model.#progress = progress;
283
+ model.#publish();
284
+ }
285
+ });
286
+ if (!model.#isCurrent(activation)) return;
287
+ if (response.status < 200 || response.status >= 300) {
288
+ const error = new Error(
289
+ `The decision runtime returned HTTP ${response.status} ${response.statusText}.`
290
+ );
291
+ error.code = 'ARCANE_DECISION_RUNTIME_DOWNLOAD_FAILED';
292
+ error.response = response;
293
+ throw error;
294
+ }
295
+ // Import the complete stored module without source
296
+ // rewriting. This activation owns its URL until stop.
297
+ activation.runtimeUrl = URL.createObjectURL(
298
+ new Blob([response.file], {type: 'text/javascript'})
299
+ );
300
+ worker.postMessage({
301
+ id,
302
+ op: 'load',
303
+ payload: {
304
+ ...configuration,
305
+ runtime: {...configuration.runtime, moduleUrl: activation.runtimeUrl}
306
+ },
307
+ storedResources: true
308
+ });
309
+ }
310
+ );
311
+ activation.runtimePreparation.catch(
312
+ function stopFailedRuntimePreparation(error) {
313
+ if (model.#isCurrent(activation)) model.#stop(activation, error, 'error');
314
+ }
315
+ );
316
+ } else {
317
+ worker.postMessage(
318
+ {id, op: 'load', payload: this.#configuration, storedResources: false}
319
+ );
320
+ }
269
321
  } catch (error) {
270
322
  this.#stop(activation, error, 'error');
271
323
  }
@@ -333,11 +385,42 @@ class BrowserDecisionModel {
333
385
  this.#state = state;
334
386
  this.#progress = null;
335
387
  this.#error = reason;
336
- if (activation.resources) {
388
+ const cleanup = [];
389
+ activation.runtimeController?.abort(reason);
390
+ if (activation.runtimePreparation) {
391
+ cleanup.push(activation.runtimePreparation.catch(
392
+ function preserveRuntimePreparationFailure(error) {
393
+ if (error === reason || error?.name === 'AbortError') return;
394
+ throw error;
395
+ }
396
+ ));
397
+ }
398
+ if (activation.resources) cleanup.push(activation.resources.close(reason));
399
+ if (activation.worker) {
400
+ for (const [type, listener] of Object.entries(activation.listeners ?? {})) {
401
+ activation.worker.removeEventListener(type, listener);
402
+ }
403
+ activation.worker.terminate();
404
+ }
405
+ if (activation.runtimeUrl !== null) {
406
+ try {
407
+ URL.revokeObjectURL(activation.runtimeUrl);
408
+ } catch (error) {
409
+ cleanup.push(Promise.reject(error));
410
+ }
411
+ activation.runtimeUrl = null;
412
+ }
413
+ if (cleanup.length > 0) {
337
414
  const model = this;
338
- activation.cleanup = activation.resources.close(reason).catch(
339
- function preserveResourceCleanupFailure(error) {
340
- activation.reason = new AggregateError([reason, error], 'Unable to settle decision model resources.');
415
+ activation.cleanup = Promise.allSettled(cleanup).then(
416
+ function settleDecisionResourceCleanup(results) {
417
+ const failures = results.filter(function failedCleanup(result) {
418
+ return result.status === 'rejected';
419
+ }).map(function cleanupReason(result) {
420
+ return result.reason;
421
+ });
422
+ if (failures.length === 0) return;
423
+ activation.reason = new AggregateError([reason, ...failures], 'Unable to settle decision model resources.');
341
424
  throw activation.reason;
342
425
  }
343
426
  );
@@ -350,12 +433,6 @@ class BrowserDecisionModel {
350
433
  }
351
434
  );
352
435
  }
353
- if (activation.worker) {
354
- for (const [type, listener] of Object.entries(activation.listeners ?? {})) {
355
- activation.worker.removeEventListener(type, listener);
356
- }
357
- activation.worker.terminate();
358
- }
359
436
  activation.load.reject(reason);
360
437
  for (const operation of activation.pending.values()) operation.reject(reason);
361
438
  activation.pending.clear();
@@ -0,0 +1,116 @@
1
+ import Is from '../dependencies/strong-type/index.js';
2
+
3
+ const is = new Is(false);
4
+
5
+ function throwIfCancelled(signal) {
6
+ if (signal?.aborted) {
7
+ throw signal.reason ?? new DOMException('Speech synthesis was cancelled.', 'AbortError');
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Compose the selected Kokoro public classes without changing loaded instances.
13
+ * The original synthesizer remains the sole owner of its model and disposal.
14
+ */
15
+ export async function createCompleteKokoroSynthesis({
16
+ KokoroTTS,
17
+ synthesizer,
18
+ repository,
19
+ signal,
20
+ onProgress,
21
+ }) {
22
+ throwIfCancelled(signal);
23
+ const Tokenizer = synthesizer.tokenizer.constructor;
24
+ class CompleteKokoroTokenizer extends Tokenizer {
25
+ _call(text, options = {}) {
26
+ return super._call(text, { ...options, truncation: false });
27
+ }
28
+ }
29
+ // Kokoro 1.2.1's factory uses the tokenizer's default revision. Reuse that
30
+ // same public loading contract through the existing Worker resource router.
31
+ const tokenizer = await CompleteKokoroTokenizer.from_pretrained(repository, {
32
+ progress_callback: onProgress,
33
+ });
34
+ throwIfCancelled(signal);
35
+ const contextLength = tokenizer.model_max_length;
36
+ if (!is.safeInteger(contextLength) || contextLength < 3) {
37
+ throw new Error('The selected Kokoro tokenizer does not expose its model context length.');
38
+ }
39
+ const contentCapacity = contextLength - 2;
40
+ function boundaryIds(text) {
41
+ const encoded = tokenizer(text, {
42
+ add_special_tokens: false,
43
+ return_tensor: false,
44
+ truncation: false,
45
+ });
46
+ return new Set(encoded.input_ids.map(function tokenId(id) { return BigInt(id); }));
47
+ }
48
+ const punctuation = boundaryIds(';:,.!?—…');
49
+ const spaces = boundaryIds(' ');
50
+
51
+ function segmentEnd(tokens, start, limit, contentEnd) {
52
+ if (limit === contentEnd) return limit;
53
+ for (const boundaries of [punctuation, spaces]) {
54
+ for (let index = limit - 1; index >= start; index -= 1) {
55
+ if (boundaries.has(tokens[index])) return index + 1;
56
+ }
57
+ }
58
+ return limit;
59
+ }
60
+
61
+ class CompleteKokoroTTS extends KokoroTTS {
62
+ #signal;
63
+
64
+ constructor(requestSignal) {
65
+ super(synthesizer.model, tokenizer);
66
+ this.#signal = requestSignal;
67
+ }
68
+
69
+ async generate_from_ids(inputIds, options = {}) {
70
+ throwIfCancelled(this.#signal);
71
+ if (inputIds.dims.at(-1) <= contextLength) {
72
+ const output = await super.generate_from_ids(inputIds, options);
73
+ throwIfCancelled(this.#signal);
74
+ return output;
75
+ }
76
+ const Tensor = inputIds.constructor;
77
+ const tokens = inputIds.data;
78
+ const contentEnd = tokens.length - 1;
79
+ const outputs = [];
80
+ let sampleCount = 0;
81
+ for (let start = 1; start < contentEnd;) {
82
+ throwIfCancelled(this.#signal);
83
+ const end = segmentEnd(tokens, start, Math.min(start + contentCapacity, contentEnd), contentEnd);
84
+ // Only the original outer padding positions are replaced. All
85
+ // content tokens, including boundary punctuation/space, survive.
86
+ const data = new BigInt64Array(end - start + 2);
87
+ data[0] = tokens[0];
88
+ data.set(tokens.subarray(start, end), 1);
89
+ data[data.length - 1] = tokens[tokens.length - 1];
90
+ const ids = new Tensor('int64', data, [1, data.length]);
91
+ const output = await super.generate_from_ids(ids, options);
92
+ throwIfCancelled(this.#signal);
93
+ outputs.push(output);
94
+ sampleCount += output.audio.length;
95
+ start = end;
96
+ }
97
+ const audio = new Float32Array(sampleCount);
98
+ let offset = 0;
99
+ for (const output of outputs) {
100
+ audio.set(output.audio, offset);
101
+ offset += output.audio.length;
102
+ }
103
+ throwIfCancelled(this.#signal);
104
+ const RawAudio = outputs[0].constructor;
105
+ return new RawAudio(audio, outputs[0].sampling_rate);
106
+ }
107
+ }
108
+
109
+ return async function synthesizeCompleteKokoro(text, { voice, speed, signal: requestSignal } = {}) {
110
+ throwIfCancelled(requestSignal);
111
+ // Each invocation retains its own signal while sharing the one loaded
112
+ // model. Inherited generate() still owns all upstream phonemization.
113
+ const request = new CompleteKokoroTTS(requestSignal);
114
+ return request.generate(text, { voice, speed });
115
+ };
116
+ }
@@ -1,5 +1,6 @@
1
1
  import Is from "../dependencies/strong-type/index.js";
2
2
  import { createModelResourceClient, createModelResourceHost } from './model-resource-bridge.mjs';
3
+ import { createCompleteKokoroSynthesis } from './kokoro-complete-input.mjs';
3
4
 
4
5
  const is = new Is(false);
5
6
 
@@ -1493,54 +1494,70 @@ async function createWhisperEngine(namespace, configuration, signal, report) {
1493
1494
  }
1494
1495
 
1495
1496
  async function createKokoroEngine(namespace, configuration, signal, report) {
1496
- if (!is.function(namespace?.KokoroTTS?.from_pretrained)) {
1497
- throw workerError(
1498
- "ARCANE_AI_PROVIDER_UNAVAILABLE",
1499
- "The Kokoro runtime does not export KokoroTTS.",
1500
- undefined,
1501
- "kokoro-tts-constructor-export-missing",
1497
+ if (!is.function(namespace?.KokoroTTS?.from_pretrained)) {
1498
+ throw workerError(
1499
+ 'ARCANE_AI_PROVIDER_UNAVAILABLE',
1500
+ 'The Kokoro runtime does not export KokoroTTS.',
1501
+ undefined,
1502
+ 'kokoro-tts-constructor-export-missing',
1503
+ );
1504
+ }
1505
+ throwIfAborted(signal, 'tts-load-cancelled');
1506
+ const synthesizer = await namespace.KokoroTTS.from_pretrained(
1507
+ configuration.model.repository,
1508
+ {
1509
+ device: configuration.execution?.device ?? 'wasm',
1510
+ dtype: configuration.model.dtype ?? 'q8',
1511
+ revision: configuration.model.revision,
1512
+ progress_callback: report,
1513
+ },
1502
1514
  );
1503
- }
1504
- throwIfAborted(signal, "tts-load-cancelled");
1505
- const synthesizer = await namespace.KokoroTTS.from_pretrained(
1506
- configuration.model.repository,
1507
- {
1508
- device: configuration.execution?.device ?? "wasm",
1509
- dtype: configuration.model.dtype ?? "q8",
1510
- revision: configuration.model.revision,
1511
- progress_callback: report,
1512
- },
1513
- );
1514
- try {
1515
- throwIfAborted(signal, "tts-load-cancelled");
1516
- } catch (error) {
1515
+ let synthesize;
1517
1516
  try {
1518
- await disposeEngine(synthesizer);
1519
- } catch {
1520
- // Preserve the cancellation boundary while the owning Worker terminates.
1517
+ throwIfAborted(signal, 'tts-load-cancelled');
1518
+ synthesize = await createCompleteKokoroSynthesis({
1519
+ KokoroTTS: namespace.KokoroTTS,
1520
+ synthesizer,
1521
+ repository: configuration.model.repository,
1522
+ signal,
1523
+ onProgress: report,
1524
+ });
1525
+ throwIfAborted(signal, 'tts-load-cancelled');
1526
+ } catch (error) {
1527
+ try {
1528
+ await disposeEngine(synthesizer);
1529
+ } catch (cleanupError) {
1530
+ throw new AggregateError([error, cleanupError], 'Kokoro preparation and model cleanup failed.', { cause: error });
1531
+ }
1532
+ throwIfAborted(signal, 'tts-load-cancelled');
1533
+ throw error;
1521
1534
  }
1522
- throw error;
1523
- }
1524
- return completeValue({
1525
- async synthesize(input, { signal: requestSignal } = {}) {
1526
- throwIfAborted(requestSignal, "tts-synthesis-cancelled");
1527
- const output = await synthesizer.generate(input.text, {
1528
- voice: input.voice,
1529
- speed: input.speed,
1530
- signal: requestSignal,
1531
- });
1532
- throwIfAborted(requestSignal, "tts-synthesis-cancelled");
1533
- const audio = is.instanceCheck(output?.audio, Float32Array)
1534
- ? output.audio
1535
- : new Float32Array(output?.audio ?? []);
1536
- return completeValue({
1537
- audio,
1538
- sampleRate: output?.sampling_rate,
1539
- voice: input.voice,
1540
- });
1541
- },
1542
- dispose: () => disposeEngine(synthesizer),
1543
- });
1535
+ return completeValue({
1536
+ async synthesize(input, { signal: requestSignal } = {}) {
1537
+ throwIfAborted(requestSignal, 'tts-synthesis-cancelled');
1538
+ let output;
1539
+ try {
1540
+ output = await synthesize(input.text, {
1541
+ voice: input.voice,
1542
+ speed: input.speed,
1543
+ signal: requestSignal,
1544
+ });
1545
+ } catch (error) {
1546
+ throwIfAborted(requestSignal, 'tts-synthesis-cancelled');
1547
+ throw error;
1548
+ }
1549
+ throwIfAborted(requestSignal, 'tts-synthesis-cancelled');
1550
+ const audio = is.instanceCheck(output?.audio, Float32Array)
1551
+ ? output.audio
1552
+ : new Float32Array(output?.audio ?? []);
1553
+ return completeValue({
1554
+ audio,
1555
+ sampleRate: output?.sampling_rate,
1556
+ voice: input.voice,
1557
+ });
1558
+ },
1559
+ dispose: function disposeKokoroEngine() { return disposeEngine(synthesizer); },
1560
+ });
1544
1561
  }
1545
1562
 
1546
1563
  function callerVoiceIds(configuration) {
@@ -43,6 +43,7 @@ alone does not make bare module names resolve in a browser.
43
43
  | Use the `arcane` command | [CLI reference](cli.md) |
44
44
  | Install an app and cache selected resources offline | [Progressive web applications](pwa.md) |
45
45
  | Disable source PWA generation or explicitly retire confirmed generated output | [Source PWA retirement](pwa.md#disable-source-pwa-generation-and-retire-generated-output) |
46
+ | Retire the prior worker after explicitly disabling standalone source PWA | [Disabled-worker browser lifecycle](pwa.md#disabled-worker-retirement-on-the-source-server) |
46
47
  | Generate named browser imports or inspect the selected physical runtime | [`arcane import-map`](cli.md#arcane-import-map) and [browser runtime delivery](protocols.md#browser-runtime-delivery) |
47
48
  | Connect an application through the shared WebSocket transport | [Shared WebSocket clients](websocket-client.md) |
48
49
  | Choose browser, native, cloud, or cross-host behavior | [Availability and normalization](availability-and-normalization.md) |
@@ -68,9 +68,9 @@ it does not substitute a quantized graph or another model.
68
68
  | `revision` | Upstream revision, default `main`; select a stable revision when repeatable model selection is needed. |
69
69
  | `device` | Transformers.js backend, default `webgpu`; an explicit supported alternative is caller-owned. No automatic backend fallback. |
70
70
  | `dtype` | Exact upstream precision selection; defaults to `fp16` for Laya and `fp32` for Julia. For Laya FP32, supply `dtype: 'fp32'`. Unsupported selections fail at the selected upstream loader rather than being replaced. |
71
- | `runtime.moduleUrl` | Defaults to the self-contained CDN entry `https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0/dist/transformers.min.js`. The `.web.js` build expects a bundler to resolve its bare ONNX Runtime import and is unsuitable for a native module Worker. An explicit compatible public runtime URL may be supplied. |
71
+ | `runtime.moduleUrl` | Defaults to the self-contained CDN entry `https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0/dist/transformers.min.js`. The `.web.js` build expects a bundler to resolve its bare ONNX Runtime import and is unsuitable for a native module Worker. An explicit compatible public runtime URL may be supplied. With `store`, the SDK stores and materializes the complete selected entry for that activation. |
72
72
  | `runtime.wasmPaths` | Optional upstream ONNX WASM location passed to the selected runtime's public environment API. |
73
- | `store` | Optional existing SDK DBOPFS model or speech artifact store exposing `fetchResource()`. The store remains outside Worker configuration; the SDK bridges the loader's actual resource requests to it. |
73
+ | `store` | Optional existing SDK DBOPFS model or speech artifact store exposing `fetchResource()`. The store remains outside Worker configuration; the SDK uses it for the runtime entry and bridges the loader's actual resource requests to it. |
74
74
 
75
75
  Without `store`, the runtime, tokenizer and weights use normal upstream loading
76
76
  and caching. With `store`, the selected loader still owns filenames and model
@@ -110,17 +110,27 @@ await decisions.load();
110
110
  ```
111
111
 
112
112
  The same `store` may serve other SDK model clients. The application supplies no
113
- tokenizer/configuration/graph manifest and performs no file routing. Inside the
114
- dedicated decision Worker, Transformers.js's public `env.fetch` hook routes the
113
+ tokenizer/configuration/graph manifest and performs no file routing. Explicit
114
+ activation first opens `runtime.moduleUrl` through `store.fetchResource()`.
115
+ The SDK materializes the complete stored entry as a JavaScript object URL for
116
+ the dedicated Worker, without rewriting its content. The configured source URL
117
+ is retained for later activations, so they reuse the same stored resource rather
118
+ than saving a temporary object URL. Construction still starts no download.
119
+
120
+ Inside the dedicated decision Worker, Transformers.js's public `env.fetch` hook routes the
115
121
  actual selected tokenizer, configuration, ONNX graph and external-data requests
116
122
  to `store.fetchResource(input, options)`. Its competing browser, custom and
117
123
  filesystem model caches are disabled only for this explicit stored mode. The
118
124
  default upstream ONNX WASM binary and factory preloads use this same hook.
119
125
  Native support-file fetches in the dedicated Worker also use the store. The
120
- runtime module itself still loads from `runtime.moduleUrl` through normal module
121
- loading. Native ESM imports remain browser-owned: an upstream custom factory
122
- import outside its preload path is not claimed as a DBOPFS resource. No second
123
- application cache or model substitution is introduced.
126
+ default runtime entry is self-contained. Entry materialization does not rewrite
127
+ dependency imports in custom modules or preserve their original `import.meta.url`
128
+ base. Custom stored entries must work from an object URL; relative imports or
129
+ support-file URLs resolved against that module's original location require a
130
+ compatible self-contained entry. Native absolute ESM dependencies and an upstream
131
+ custom factory import outside its preload path remain browser-owned. No second
132
+ application cache or model substitution is introduced. Without `store`, the
133
+ configured runtime URL keeps its direct native module-loading behavior.
124
134
 
125
135
  The store returns `{file, status, statusText, headers, url, redirected}`. The
126
136
  Worker reconstructs the complete response with its actual HTTP status and
@@ -138,9 +148,14 @@ url}`. `completed` counts closed persistent shards; `total` is `null` until the
138
148
  resource finishes. This progress carries no network-size, rate or ETA fields.
139
149
  Cancellation aborts the owned fetch and joins reader/writer cleanup before the
140
150
  resource owner becomes available to a later request. Interrupted closed shards
141
- are retained. `unload()` and `dispose()` still acknowledge their lifecycle
142
- transition synchronously; outstanding `load()`/`evaluate()` calls settle after
143
- their resource cleanup.
151
+ are retained. Each activation owns its runtime object URL and revokes it after
152
+ terminating the Worker on cancellation, failure, unload or disposal. A cancelled
153
+ runtime-entry fetch is joined before its waiting calls settle; a late response
154
+ cannot activate a replacement Worker. Runtime-entry HTTP failures retain the
155
+ complete stored response in `error.response` for developer diagnostics.
156
+ `unload()` and `dispose()` still acknowledge their lifecycle transition
157
+ synchronously; outstanding `load()`/`evaluate()` calls settle after their
158
+ resource cleanup.
144
159
 
145
160
  ## Complete rows and results
146
161
 
@@ -1065,11 +1065,32 @@ owners. Only `'plain'` selects exact input; omission and other values retain
1065
1065
  the existing narration cleanup. Applications never supply the internal
1066
1066
  `speechInputPrepared` argument.
1067
1067
 
1068
- This selection governs SDK text treatment, not the selected engine's input
1069
- capacity or pronunciation. [Kokoro.js 1.2.1](https://github.com/hexgrad/kokoro/blob/664c76a704021239ba59c84dcbaa4d3dece01fe9/kokoro.js/src/kokoro.js)
1070
- still tokenizes with truncation in both `generate()` and `stream()`; plain input
1071
- does not establish complete audio for an oversized engine input. The SDK's existing complete-text forwarding
1072
- fixture does not prove upstream synthesis completeness.
1068
+ This selection governs SDK text treatment, not pronunciation. The browser Kokoro
1069
+ adapter preserves the selected runtime's own text normalization and phonemization,
1070
+ then uses a genuine tokenizer subclass with truncation disabled. Its Kokoro
1071
+ subclass sends every resulting content token through the public
1072
+ `generate_from_ids()` method. Short inputs retain one inference; longer inputs
1073
+ use ordered segments within the selected engine's inference capacity and return
1074
+ all audio in order. This is token-based engine transport, not a character limit,
1075
+ text estimate, summary, or input discard.
1076
+
1077
+ The [selected Kokoro model](https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX/blob/1939ad2a8e416c0acfeecc08a694d14ef25f2231/README.md#python)
1078
+ accepts 510 content tokens plus its two outer padding positions per inference.
1079
+ Each segment receives that padding independently. Upstream voice-style selection,
1080
+ speed, waveform generation and sample rate remain upstream-owned. The SDK reuses
1081
+ one loaded model; it does not copy the private phonemizer, replace instance methods,
1082
+ load a second model, or import another Transformers runtime. Its tokenizer
1083
+ reconstruction uses the same effective selection as the
1084
+ [Kokoro 1.2.1 factory](https://github.com/hexgrad/kokoro/blob/664c76a704021239ba59c84dcbaa4d3dece01fe9/kokoro.js/src/kokoro.js).
1085
+ That factory ignores a `revision` option; this change does not establish revision
1086
+ pinning that the upstream factory does not implement.
1087
+
1088
+ Cancellation is request-local and checked between inference segments and before
1089
+ returning audio. As with the existing Worker contract, cancellation prevents later
1090
+ work and output while an already-running upstream inference may finish. Unload
1091
+ and disposal retain their existing Worker-termination boundary. Source and
1092
+ controlled-fixture coverage of token/audio preservation are distinct from speech
1093
+ quality or execution evidence for a particular model, browser and device.
1073
1094
 
1074
1095
  ## Choose a device or reduce memory use
1075
1096
 
@@ -210,6 +210,64 @@ details and [the client contract](core-client.md) for native bridge integration.
210
210
 
211
211
  ## Launch-time locations
212
212
 
213
+ An application can select defaults for ordinary double-click launch in its
214
+ descriptor, without a replacement launcher or a required command-line file:
215
+
216
+ ```json
217
+ {
218
+ "native": {
219
+ "launchContext": {
220
+ "sharedHost": {},
221
+ "dispatch": {"destination": "Moon Cheese Receiving Dock"}
222
+ }
223
+ }
224
+ }
225
+ ```
226
+
227
+ This is the `native` fragment of the application's complete descriptor. The
228
+ optional `native.launchContext` object is copied intact to the portable
229
+ manifest and generated Core entry. Its presence explicitly selects native
230
+ location defaults. `sharedHost:{}` opts into the shared Core endpoint and log;
231
+ omitting `sharedHost` retains stdio-owned Core lifetime. Omitting
232
+ `native.launchContext` retains the previous generated-entry behavior entirely.
233
+ Windows and macOS launchers use the same packaged defaults for native state
234
+ selection, so an ordinary executable launch and external MCP can select the
235
+ same app-owned Core.
236
+
237
+ `resolveNativeLaunchContext({appId,context={}})` from `arcane-os/core/host` is
238
+ the public, synchronous location owner for native applications and external MCP.
239
+ It returns a new complete context and fills only omitted fields:
240
+
241
+ | Field | SDK default |
242
+ |---|---|
243
+ | `stateRoot` on Windows | `%LOCALAPPDATA%/Arcane/<appId>`; standard home `AppData/Local` fallback when the environment value is absent |
244
+ | `stateRoot` on macOS | `~/Library/Application Support/Arcane/<appId>` |
245
+ | `stateRoot` on Linux | Absolute `$XDG_DATA_HOME/Arcane/<appId>`, otherwise `~/.local/share/Arcane/<appId>` |
246
+ | `workspaceRoot` | `<resolved stateRoot>/Workspace` |
247
+ | `sharedHost.endpoint`, only when `sharedHost` is selected | Windows named pipe `\\.\pipe\Arcane-` followed by the URL-encoded complete resolved state path; Linux/macOS socket `<resolved stateRoot>/core.sock` |
248
+ | `sharedHost.logFile`, only when `sharedHost` is selected | `<resolved stateRoot>/Core.log` |
249
+
250
+ Windows/macOS roots retain the existing native host locations. Linux is the
251
+ portable Node host convention; an external composing host supplies its actual
252
+ state root when it selects another location. Android and other host adaptations
253
+ must supply `stateRoot` explicitly and own native IPC/process adaptation.
254
+ The resolver never reads saved preferences, creates directories, changes the
255
+ environment, starts services, or migrates existing data. The generated opted-in
256
+ shared entry creates its selected state directory because its socket/log need
257
+ that parent; application services continue to own workspace I/O. Custom
258
+ endpoint/log parent directories retain the existing shared-host requirements.
259
+
260
+ Explicit `stateRoot`, `workspaceRoot`, endpoint, log filename and additional
261
+ fields remain authored values; the resolver derives omitted paths from the
262
+ absolute selected state location. Relative explicit paths retain the caller's
263
+ working-directory meaning, so UI and MCP callers must select the same location.
264
+ No hashing, app-local OS path composition, directory scanning, or new repository
265
+ location rule is involved. `resolveArcaneDataPaths()` continues to own its
266
+ separate `ArcaneData/Repos` contract. A selected native context needs a nonempty
267
+ string `appId` and state directory; a selected `sharedHost` must be an object.
268
+ Malformed resolver inputs report `TypeError`; ordinary OS endpoint requirements
269
+ remain at the IPC operation.
270
+
213
271
  For one app-owned Core shared by the native window and a separate MCP process,
214
272
  the launch context may explicitly select `sharedHost:{endpoint,logFile}`. The
215
273
  generated native child then connects through a stdio bridge, starting an
@@ -224,27 +282,36 @@ or `null`; the shared host replays that current listener to a newly attached
224
282
  macOS bridge, without restarting the server or retaining historical events.
225
283
 
226
284
  The generated Core entry accepts `--arcane-launch-config <path>`. The public
227
- `readCoreLaunchContext({argv=process.argv.slice(2)}={})` function in
228
- `arcane-os/core/host` reads that explicitly selected JSON object. A native host
285
+ `readCoreLaunchContext({argv=process.argv.slice(2),defaults={},appId}={})`
286
+ function in `arcane-os/core/host` reads that explicitly selected JSON object.
287
+ Precedence is packaged `defaults`, then the native host's actual state-root
288
+ argument, then every complete top-level field from the explicit JSON object.
289
+ Nested records replace their default field as a whole; they are not deep-merged.
290
+ An `appId` explicitly opts the result into `resolveNativeLaunchContext`; the
291
+ generated entry supplies it only when `native.launchContext` is present.
292
+ A native host
229
293
  can separately supply `--arcane-host-state-root <directory>` for the reader's
230
294
  `stateRoot` default. Every field in the explicit JSON object takes precedence
231
295
  unchanged, including an explicitly present `stateRoot` with a relative, null or
232
- other application-owned value. With neither argument the reader returns `{}`.
296
+ other application-owned value on the ordinary non-resolving reader. With neither
297
+ argument nor defaults/appId the reader still returns `{}`.
233
298
  Missing files, missing argument values and malformed JSON report their actual
234
299
  errors; the reader does not search for configuration, resolve or validate the
235
- state directory, rewrite the file or change the process environment.
300
+ state directory, rewrite the file or change the process environment. The
301
+ explicitly opted-in resolver supplies its documented defaults after this merge.
236
302
 
237
303
  The generated entry supplies an artifact-derived `appRoot` and then applies the
238
304
  complete explicit launch context. Every service factory receives that same
239
305
  object as its second argument, preserving its authored first argument. A
240
306
  launcher can supply the user's selected `workspaceRoot` and writable
241
307
  `stateRoot` at launch instead of embedding this machine's paths in an app
242
- package. The host owns those selections; the SDK does not choose a product's
243
- workspace, namespace, saved preferences or models. JSON values and additional
308
+ package. Explicit product selections take precedence over the opted-in SDK
309
+ location defaults. The SDK chooses no saved preference filename or model. JSON values and additional
244
310
  application-owned fields remain unchanged, including an app-selected
245
311
  `preferencesFile`. The state directory supplies no preference filename and
246
312
  triggers no stored-data discovery or migration. Linux and other composing hosts
247
- may pass the same separate argument; the reader chooses no platform directory.
313
+ may pass the same separate argument; the ordinary reader chooses no platform
314
+ directory until native resolution is explicitly selected.
248
315
 
249
316
  ## Selected local-AI runtimes
250
317
 
@@ -146,8 +146,46 @@ events may change; applications own those domain methods and state.
146
146
 
147
147
  ## Native launch and platforms
148
148
 
149
- The generated portable Core entry keeps ordinary stdio behavior unless the
150
- explicit launch context contains:
149
+ The generated portable Core entry keeps ordinary stdio behavior unless its
150
+ selected launch context contains `sharedHost`. An application can opt in for
151
+ ordinary packaged executable launch with `native.launchContext:{sharedHost:{}}`
152
+ in its complete descriptor. The SDK supplies the app's platform state root,
153
+ workspace, endpoint and diagnostic log through `resolveNativeLaunchContext`.
154
+ The same public owner is available to external MCP:
155
+
156
+ ```js
157
+ import {readCoreLaunchContext, connectSharedCoreHost} from 'arcane-os/core/host';
158
+
159
+ const context = await readCoreLaunchContext({
160
+ appId: appDescriptor.id,
161
+ defaults: appDescriptor.native.launchContext
162
+ });
163
+ const connection = await connectSharedCoreHost({
164
+ endpoint: context.sharedHost.endpoint
165
+ });
166
+ // Invoke the application's actual services through connection.client.
167
+ // Disconnect this MCP client without shutting down the application's host.
168
+ await connection.close();
169
+ ```
170
+
171
+ This example connects to a running native/headless host. The existing `start`
172
+ option can start the packaged Core entry explicitly when independent MCP-first
173
+ startup is needed; the entry applies those same packaged defaults. Pass the
174
+ same explicit `--arcane-launch-config` file to both entries for user-selected
175
+ locations. A caller that already owns a complete context can use the pure
176
+ `resolveNativeLaunchContext({appId,context})` directly instead of reading argv.
177
+ For MCP-first startup, prepare the returned `context.stateRoot` with Node's
178
+ `mkdir(context.stateRoot,{recursive:true})` before selecting `start`, because
179
+ the parent's startup diagnostic log opens before the headless child runs.
180
+ This uses the resolved directory directly, without duplicating platform path
181
+ rules. Pass `context.sharedHost.logFile` to that existing `start` record and
182
+ the same launch file to the packaged Core command. Custom endpoint/log parent
183
+ directories remain caller-selected.
184
+ See [launch-time locations](core-native-packaging.md#launch-time-locations) for
185
+ the exact platform defaults, precedence, location override and directory rules.
186
+ No app must reconstruct OS directory rules.
187
+
188
+ An explicit launch file can also select the existing full endpoint contract:
151
189
 
152
190
  ```json
153
191
  {
@@ -163,8 +201,9 @@ option, the native Core child is a stdio-to-shared-Core bridge. It connects or
163
201
  launches the same entry with `--arcane-core-headless`; the headless entry claims
164
202
  the endpoint before importing services. Closing the native window ends its
165
203
  bridge, while the independent Core host remains available. Service options and
166
- the complete launch context retain their existing values. There is no implicit
167
- shared mode or new application descriptor field.
204
+ the complete launch context retain their existing values. Shared mode remains
205
+ explicit: the new descriptor option selects defaults only for that application,
206
+ and the existing no-option stdio path is unchanged.
168
207
 
169
208
  `startSharedCoreBridge({endpoint,start,input=process.stdin,
170
209
  output=process.stdout,signal,onError})` is also public. It returns
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "sdkVersion": "0.71.0",
3
+ "sdkVersion": "0.71.1",
4
4
  "environment": {
5
5
  "runtime": "Node.js for Node entrypoints; browser for browser-only entrypoints",
6
6
  "minimumVersion": "22.23.2 for Node entrypoints",
@@ -1185,7 +1185,7 @@
1185
1185
  ],
1186
1186
  "primaryImport": "arcane-os",
1187
1187
  "group": "Headless toolchain operations",
1188
- "summary": "Starts one owned browser development server for the selected application.",
1188
+ "summary": "Starts one owned browser development server for the selected application, including native worker retirement when standalone source PWA is explicitly disabled and its root worker is absent.",
1189
1189
  "availability": "Node; selected operation may produce browser or native output",
1190
1190
  "protocol": "Shared headless operation API + arcane-cli-events/1",
1191
1191
  "normalization": "SDK-normalized inputs, errors, events, and documented result"
@@ -2446,7 +2446,7 @@
2446
2446
  ],
2447
2447
  "primaryImport": "arcane-os",
2448
2448
  "group": "Workspace, doctor, repository, and server",
2449
- "summary": "Starts an owned source or packaged browser server with complete selected routes, HTTPS by default, explicit HTTP selection, listener readiness and shutdown.",
2449
+ "summary": "Starts an owned source or packaged browser server with complete selected routes, HTTPS by default, explicit HTTP selection, listener readiness and shutdown; explicitly disabled standalone source PWA retires an absent root worker through its native lifecycle.",
2450
2450
  "availability": "Node control plane; browser data plane",
2451
2451
  "protocol": "Node ESM",
2452
2452
  "normalization": "SDK-normalized inputs, errors, events, and documented result"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.71.1",
3
+ "version": "0.72.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -123,7 +123,7 @@
123
123
  "test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
124
124
  "test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
125
125
  "test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/app-selection.test.mjs test/contracts.test.mjs test/conversation-closing-report.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/native-windows-icon.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
126
- "test:functional": "node ./bin/arcane-test.mjs test/ai-ollama-readiness.test.mjs test/core-ollama-lifecycle.test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-decisions.test.mjs test/browser-speech-providers.test.mjs test/browser-speech-artifacts.test.mjs test/browser-speech-resource-bridge.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/codex-app-server.test.mjs test/mcp-stdio-server.test.mjs test/core-classic-source.test.mjs test/core-client.test.mjs test/core-client-installation.test.mjs test/core-ai-installation.test.mjs test/core-llama-lifecycle.test.mjs test/core-development.test.mjs test/core-host.test.mjs test/core-shared-host.test.mjs test/core-model-assets.test.mjs test/core-native-services.test.mjs test/core-packaged-web.test.mjs test/core-runtime.test.mjs test/core-stdio.test.mjs test/dbopfs-committed-events.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-model-resources.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/local-image-editing.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-media.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/model-resource-bridge.test.mjs test/native-decisions.test.mjs test/native-onnx.test.mjs test/native-module-packaging.test.mjs test/native-module-resource-references.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/rendered-print.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/theme-manager.test.mjs test/twin-cloud-images.test.mjs test/twin-cloud.test.mjs test/websocket-client-import-map.test.mjs test/websocket.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
126
+ "test:functional": "node ./bin/arcane-test.mjs test/ai-ollama-readiness.test.mjs test/core-ollama-lifecycle.test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-decisions.test.mjs test/browser-kokoro-complete-input.test.mjs test/browser-speech-providers.test.mjs test/browser-speech-artifacts.test.mjs test/browser-speech-resource-bridge.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/codex-app-server.test.mjs test/mcp-stdio-server.test.mjs test/core-classic-source.test.mjs test/core-client.test.mjs test/core-client-installation.test.mjs test/core-ai-installation.test.mjs test/core-llama-lifecycle.test.mjs test/core-development.test.mjs test/core-host.test.mjs test/core-shared-host.test.mjs test/core-model-assets.test.mjs test/core-native-services.test.mjs test/core-packaged-web.test.mjs test/core-runtime.test.mjs test/core-stdio.test.mjs test/dbopfs-committed-events.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-model-resources.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/local-image-editing.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-media.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/model-resource-bridge.test.mjs test/native-decisions.test.mjs test/native-onnx.test.mjs test/native-module-packaging.test.mjs test/native-module-resource-references.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/rendered-print.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/theme-manager.test.mjs test/twin-cloud-images.test.mjs test/twin-cloud.test.mjs test/websocket-client-import-map.test.mjs test/websocket.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
127
127
  "test:integration": "node ./bin/arcane-test.mjs test/installed-package-runtime.test.mjs test/root-app-layout.test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-macos-provider.test.mjs test/native-plan.test.mjs test/native-portable-provider.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
128
128
  "test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.test.mjs test/html-import-packaged-script.test.mjs test/logging-regression.test.mjs test/markdown-speech.test.mjs test/prepared-speech.test.mjs test/native-provider-generation.test.mjs test/speech-queue-regression.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
129
129
  "check": "node tools/check-source.mjs && npm test",
@@ -132,6 +132,10 @@
132
132
  }
133
133
  },
134
134
  "localAI": { "$ref": "#/$defs/localAI" },
135
+ "launchContext": {
136
+ "type": "object",
137
+ "description": "Optional complete native launch defaults. Presence opts into SDK per-app native locations; sharedHost:{} explicitly selects the shared endpoint. Explicit launch JSON fields take precedence unchanged."
138
+ },
135
139
  "window": {
136
140
  "type": "object",
137
141
  "additionalProperties": false,
@@ -254,7 +254,7 @@ function validateNativeWindow(value){
254
254
  }
255
255
 
256
256
  function validateNative(value,appId){
257
- assertOnlyKeys(value,new Set(['type','icon','order','bundledApps','documentCatalog','localAI','services','window']),'descriptor.native');
257
+ assertOnlyKeys(value,new Set(['type','icon','order','bundledApps','documentCatalog','localAI','services','window','launchContext']),'descriptor.native');
258
258
  if(!NATIVE_TYPES.has(value.type))fail('descriptor.native.type is unsupported.');
259
259
  const icon=value.icon===null?null:normalizeRelativePath(value.icon,'descriptor.native.icon');
260
260
  if(!is.integer(value.order)||value.order<0){
@@ -265,6 +265,9 @@ function validateNative(value,appId){
265
265
  });
266
266
  const documentCatalog=validateDocumentCatalog(value.documentCatalog);
267
267
  const window=validateNativeWindow(value.window);
268
+ if(value.launchContext!==undefined&&!isObject(value.launchContext)){
269
+ fail('descriptor.native.launchContext must be an object.');
270
+ }
268
271
  let services;
269
272
  if(value.services!==undefined){
270
273
  if(!is.array(value.services))fail('descriptor.native.services must be an array.');
@@ -279,6 +282,7 @@ function validateNative(value,appId){
279
282
  order:value.order,
280
283
  bundledApps,
281
284
  ...(window===undefined?{}:{window}),
285
+ ...(value.launchContext===undefined?{}:{launchContext:value.launchContext}),
282
286
  ...(services===undefined?{}:{services}),
283
287
  ...(value.localAI===undefined?{}:{localAI:normalizeLocalAIConfig(value.localAI)}),
284
288
  ...(documentCatalog?{documentCatalog}:{})
@@ -416,6 +420,7 @@ export function projectNativeDescriptor(descriptor,{source}={}){
416
420
  ...(value.native.documentCatalog?{documentCatalog:value.native.documentCatalog}:{}),
417
421
  ...(value.native.services===undefined?{}:{services:value.native.services}),
418
422
  ...(value.native.window===undefined?{}:{window:value.native.window}),
423
+ ...(value.native.launchContext===undefined?{}:{launchContext:value.native.launchContext}),
419
424
  ...(value.native.localAI===undefined?{}:{localAI:value.native.localAI}),
420
425
  include:[...value.package.include]
421
426
  };
@@ -462,6 +467,7 @@ function synthesizedDescriptor(packageManifest,nativeDescriptor){
462
467
  bundledApps:[...(native.bundledApps??[])],
463
468
  ...(native.services===undefined?{}:{services:native.services}),
464
469
  ...(native.window===undefined?{}:{window:native.window}),
470
+ ...(native.launchContext===undefined?{}:{launchContext:native.launchContext}),
465
471
  ...((packageManifest.localAI??native.localAI)===undefined?{}:{localAI:packageManifest.localAI??native.localAI}),
466
472
  ...(native.documentCatalog?{documentCatalog:native.documentCatalog}:{})
467
473
  },
package/src/core/host.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import {readFile} from 'node:fs/promises';
2
+ import {homedir} from 'node:os';
3
+ import path from 'node:path';
2
4
  import Is from 'strong-type';
3
5
  import {createCoreRuntime} from './runtime.mjs';
4
6
  import {startCoreStdio} from './stdio.mjs';
@@ -7,32 +9,90 @@ export {startSharedCoreHost, connectSharedCoreHost, runSharedCoreHost, startShar
7
9
 
8
10
  const is = new Is(false);
9
11
 
12
+ /** Resolve one app's native locations without creating or migrating data. */
13
+ export function resolveNativeLaunchContext({appId, context = {}} = {}) {
14
+ if (!is.string(appId) || !appId) throw new TypeError('Native launch resolution requires an appId.');
15
+ if (context === null || !is.object(context) || is.array(context)) {
16
+ throw new TypeError('Native launch context must be an object.');
17
+ }
18
+ let stateRoot = context.stateRoot;
19
+ if (stateRoot === undefined) {
20
+ const home = homedir();
21
+ switch (process.platform) {
22
+ case 'win32':
23
+ stateRoot = path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'Arcane', appId);
24
+ break;
25
+ case 'darwin':
26
+ stateRoot = path.join(home, 'Library', 'Application Support', 'Arcane', appId);
27
+ break;
28
+ case 'linux': {
29
+ const xdg = process.env.XDG_DATA_HOME;
30
+ stateRoot = path.join(xdg && path.isAbsolute(xdg) ? xdg : path.join(home, '.local', 'share'), 'Arcane', appId);
31
+ break;
32
+ }
33
+ default:
34
+ throw new TypeError('This native host must supply its persistent stateRoot.');
35
+ }
36
+ }
37
+ if (!is.string(stateRoot) || !stateRoot) throw new TypeError('Native stateRoot must be a nonempty directory path.');
38
+ const root = path.resolve(stateRoot);
39
+ const resolved = {
40
+ ...context,
41
+ stateRoot,
42
+ workspaceRoot: context.workspaceRoot === undefined ? path.join(root, 'Workspace') : context.workspaceRoot
43
+ };
44
+ if (context.sharedHost !== undefined) {
45
+ if (context.sharedHost === null || !is.object(context.sharedHost) || is.array(context.sharedHost)) {
46
+ throw new TypeError('Native sharedHost must be an object.');
47
+ }
48
+ // Encode the complete selected Windows path as a pipe-name component;
49
+ // Unix IPC uses an ordinary file in that same persistent app directory.
50
+ const endpoint = process.platform === 'win32'
51
+ ? '\\\\.\\pipe\\Arcane-' + encodeURIComponent(root)
52
+ : path.join(root, 'core.sock');
53
+ resolved.sharedHost = {
54
+ ...context.sharedHost,
55
+ endpoint: context.sharedHost.endpoint === undefined ? endpoint : context.sharedHost.endpoint,
56
+ logFile: context.sharedHost.logFile === undefined ? path.join(root, 'Core.log') : context.sharedHost.logFile
57
+ };
58
+ }
59
+ return resolved;
60
+ }
61
+
10
62
  /**
11
- * The host's separate state-root argument supplies a launch-context default.
63
+ * Packaged defaults precede the host's actual state-root argument.
12
64
  * Every field in an explicitly selected launch JSON object takes precedence
13
65
  * unchanged; field meanings belong to the launch owner and individual services.
66
+ * appId explicitly selects native location resolution; ordinary calls keep the
67
+ * existing read-only launch-file contract without choosing any locations.
14
68
  */
15
- export async function readCoreLaunchContext({argv = process.argv.slice(2)} = {}) {
69
+ export async function readCoreLaunchContext({argv = process.argv.slice(2), defaults = {}, appId} = {}) {
70
+ if (defaults === null || !is.object(defaults) || is.array(defaults)) {
71
+ throw new TypeError('Core launch defaults must be an object.');
72
+ }
16
73
  const stateFlag = argv.indexOf('--arcane-host-state-root');
17
- const defaults = {};
74
+ const context = {...defaults};
18
75
  if (stateFlag !== -1) {
19
76
  const stateRoot = argv[stateFlag + 1];
20
77
  if (stateRoot === undefined || stateRoot === '') {
21
78
  throw new TypeError('--arcane-host-state-root requires a directory.');
22
79
  }
23
- defaults.stateRoot = stateRoot;
80
+ context.stateRoot = stateRoot;
24
81
  }
25
82
  const flag = argv.indexOf('--arcane-launch-config');
26
- if (flag === -1) return defaults;
27
- const filename = argv[flag + 1];
28
- if (filename === undefined || filename === '') {
29
- throw new TypeError('--arcane-launch-config requires a filename.');
30
- }
31
- const context = JSON.parse(await readFile(filename, 'utf8'));
32
- if (context === null || !is.object(context) || is.array(context)) {
33
- throw new TypeError('Core launch configuration must be a JSON object.');
83
+ let selected = context;
84
+ if (flag !== -1) {
85
+ const filename = argv[flag + 1];
86
+ if (filename === undefined || filename === '') {
87
+ throw new TypeError('--arcane-launch-config requires a filename.');
88
+ }
89
+ const explicit = JSON.parse(await readFile(filename, 'utf8'));
90
+ if (explicit === null || !is.object(explicit) || is.array(explicit)) {
91
+ throw new TypeError('Core launch configuration must be a JSON object.');
92
+ }
93
+ selected = {...context, ...explicit};
34
94
  }
35
- return {...defaults, ...context};
95
+ return appId === undefined ? selected : resolveNativeLaunchContext({appId, context: selected});
36
96
  }
37
97
 
38
98
  /** Compose app services with the shared dispatcher and draining transport. */
@@ -231,14 +231,25 @@ typedef NS_ENUM(NSUInteger, ArcaneDiagnosticStream) {
231
231
  NSString *appID = ArcaneRequiredString(app, @"id", error);
232
232
  if (!appID) return NO;
233
233
  _title = [app[@"displayName"] isKindOfClass:NSString.class] ? app[@"displayName"] : appID;
234
+ id defaults = manifest[@"launchContext"];
235
+ if (defaults && ![defaults isKindOfClass:NSDictionary.class]) {
236
+ *error = ArcaneLauncherError(@"CORE_LAUNCH_MANIFEST_INVALID", @"The native launchContext must be an object.", defaults);
237
+ return NO;
238
+ }
239
+ NSMutableDictionary *launchContext = defaults ? [defaults mutableCopy] : [NSMutableDictionary dictionary];
234
240
  if (_launchFilename) {
235
- _launchContext = ArcaneReadRecord([NSURL fileURLWithPath:_launchFilename], error);
236
- if (!_launchContext) return NO;
241
+ NSDictionary *explicitContext = ArcaneReadRecord([NSURL fileURLWithPath:_launchFilename], error);
242
+ if (!explicitContext) return NO;
243
+ [launchContext addEntriesFromDictionary:explicitContext];
237
244
  }
245
+ _launchContext = launchContext;
246
+ // The opted-in Node resolver uses HOME when supplied; retain the previous
247
+ // native default unchanged for applications without launchContext.
248
+ NSString *nativeHome = defaults ? (NSProcessInfo.processInfo.environment[@"HOME"] ?: NSHomeDirectory()) : NSHomeDirectory();
238
249
  id selectedState = _launchContext[@"stateRoot"];
239
250
  _stateRoot = [selectedState isKindOfClass:NSString.class]
240
251
  ? ArcaneAbsolutePath(selectedState, _workingDirectory)
241
- : [[NSHomeDirectory() stringByAppendingPathComponent:@"Library/Application Support/Arcane"] stringByAppendingPathComponent:appID];
252
+ : [[nativeHome stringByAppendingPathComponent:@"Library/Application Support/Arcane"] stringByAppendingPathComponent:appID];
242
253
  [self scheduleDiagnostics];
243
254
  NSString *clientPath = ArcaneRequiredString(client, @"source", error);
244
255
  if (!clientPath) return NO;
@@ -57,13 +57,33 @@ namespace Arcane.Core.Hosts.Windows
57
57
  Dictionary<string, object> host = manifest.TryGetValue("host", out hostValue) ? hostValue as Dictionary<string, object> : null;
58
58
  string icon = host != null && host.TryGetValue("icon", out iconValue) ? iconValue as string : null;
59
59
 
60
- // Core rereads this exact selected file. Do not normalize,
61
- // rewrite, filter or copy the application's launch record.
62
- Dictionary<string, object> launchContext = launchFilename == null ? null : ReadRecord(launchFilename);
60
+ // Native UI and generated Core use the same packaged defaults.
61
+ // Core rereads the explicit file; every explicit field wins.
62
+ Dictionary<string, object> launchContext = manifest.ContainsKey("launchContext")
63
+ ? new Dictionary<string, object>(RequiredRecord(manifest, "launchContext"))
64
+ : new Dictionary<string, object>();
65
+ if (launchFilename != null)
66
+ {
67
+ foreach (KeyValuePair<string, object> field in ReadRecord(launchFilename))
68
+ launchContext[field.Key] = field.Value;
69
+ }
70
+ string defaultStateBase = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
71
+ if (manifest.ContainsKey("launchContext"))
72
+ {
73
+ // Match the public Node resolver for this opt-in contract,
74
+ // including an explicitly selected process environment.
75
+ defaultStateBase = Environment.GetEnvironmentVariable("LOCALAPPDATA");
76
+ if (String.IsNullOrEmpty(defaultStateBase))
77
+ {
78
+ string home = Environment.GetEnvironmentVariable("USERPROFILE");
79
+ if (String.IsNullOrEmpty(home)) home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
80
+ defaultStateBase = Path.Combine(home, "AppData", "Local");
81
+ }
82
+ }
63
83
  object stateRoot;
64
- string profileParent = launchContext != null && launchContext.TryGetValue("stateRoot", out stateRoot) && stateRoot is string
84
+ string profileParent = launchContext.TryGetValue("stateRoot", out stateRoot) && stateRoot is string
65
85
  ? Path.GetFullPath((string)stateRoot)
66
- : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Arcane", appId);
86
+ : Path.Combine(defaultStateBase, "Arcane", appId);
67
87
  OpenDiagnostics(Path.Combine(profileParent, "Diagnostics"));
68
88
  ArcaneHostOptions options = new ArcaneHostOptions
69
89
  {
@@ -151,6 +151,7 @@ export async function copyCoreRuntime(root, files, signal, {
151
151
  }
152
152
 
153
153
  export function coreEntrySource(application, version, services, {localAI, runtimes = [], packagedWeb = false} = {}) {
154
+ const launchDefaults = application.native?.launchContext;
154
155
  const imageSelected = localAI?.runtimes?.some(function selectedImage(requirement) {
155
156
  return (typeof requirement === 'string' ? requirement : requirement.id) === 'stable-diffusion.cpp';
156
157
  }) ?? false;
@@ -199,6 +200,7 @@ export function coreEntrySource(application, version, services, {localAI, runtim
199
200
  "import {fileURLToPath} from 'node:url';",
200
201
  "import path from 'node:path';",
201
202
  "import {isSea} from 'node:sea';",
203
+ ...(launchDefaults === undefined ? [] : ["import {mkdir} from 'node:fs/promises';"]),
202
204
  "import {readCoreLaunchContext, startCoreHost, runSharedCoreHost, startSharedCoreBridge} from 'arcane-os/core/host';",
203
205
  '',
204
206
  ...(localAI === undefined ? [] : [
@@ -241,9 +243,15 @@ export function coreEntrySource(application, version, services, {localAI, runtim
241
243
  ' return resolved;',
242
244
  ' }),'
243
245
  ]),
244
- ' ...await readCoreLaunchContext()',
246
+ launchDefaults === undefined ? ' ...await readCoreLaunchContext()'
247
+ : ` ...await readCoreLaunchContext({appId: ${JSON.stringify(application.id)}, defaults: JSON.parse(${JSON.stringify(JSON.stringify(launchDefaults))})})`,
245
248
  '};',
246
249
  '',
250
+ ...(launchDefaults === undefined ? [] : [
251
+ '// The shared endpoint and diagnostic log require their selected state directory.',
252
+ 'if (context.sharedHost !== undefined) await mkdir(context.stateRoot, {recursive: true});',
253
+ ''
254
+ ]),
247
255
  ...(packagedWeb ? ['let packagedWeb;', ''] : []),
248
256
  'async function createServices(register) {',
249
257
  ` const [${imports.map(function binding(selection) { return selection.binding; }).join(', ')}] = await Promise.all([`,
@@ -197,6 +197,7 @@ export function createPortableNativeProvider({services, packagedWeb = false, web
197
197
  webRoot: 'app',
198
198
  start: release.manifest?.app?.start ?? './index.html',
199
199
  ...(appDescriptor.native?.window === undefined ? {} : {window: {...appDescriptor.native.window}}),
200
+ ...(appDescriptor.native?.launchContext === undefined ? {} : {launchContext: appDescriptor.native.launchContext}),
200
201
  core: {
201
202
  entry: 'runtime/arcane-core.mjs', transport: 'stdio', protocol: 'arcane/1', services: selectedServices,
202
203
  ...(localAI === undefined ? {} : {localAIRuntimes: bundled.runtimes})