arcane-os 0.95.0 → 0.96.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.
@@ -0,0 +1,160 @@
1
+ import {randomUUID} from 'node:crypto';
2
+
3
+ /** Selected native assets from NVIDIA NeMo-Speech.cpp v0.2.0 models/index.json. */
4
+ export const NEMO_SPEECH_MODELS = {
5
+ stt: {
6
+ modelId: 'nemotron-3.5-asr-streaming-0.6b',
7
+ providerId: 'nemotron',
8
+ name: 'Nemotron 3.5 + speaker diarization',
9
+ members: [
10
+ {
11
+ path: 'nemotron-3.5-asr-streaming-0.6b.q8_0.gguf',
12
+ url: 'https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b/resolve/1c8deaecc64b91f034d73e08dd8b64625eb3395d/nemotron-3.5-asr-streaming-0.6b.q8_0.gguf'
13
+ },
14
+ {
15
+ path: 'Nemotron-3-Diarization.q8_0.gguf',
16
+ url: 'https://huggingface.co/nvidia/Nemotron-3-Diarization/resolve/f667ed73aee57d40cc39428eb768b4fd87a0a29e/Nemotron-3-Diarization.q8_0.gguf'
17
+ }
18
+ ],
19
+ resourcePaths: {
20
+ model: 'nemotron-3.5-asr-streaming-0.6b.q8_0.gguf',
21
+ diarization: 'Nemotron-3-Diarization.q8_0.gguf'
22
+ }
23
+ },
24
+ tts: {
25
+ modelId: 'magpie',
26
+ providerId: 'magpie-native',
27
+ name: 'Magpie + NanoCodec',
28
+ members: [
29
+ {
30
+ path: 'magpie_tts_multilingual_357m.v2607.f16.gguf',
31
+ url: 'https://huggingface.co/nvidia/magpie_tts_multilingual_357m/resolve/19806879b16d3f2ccf28fb112b1bcd16a3c7923e/magpie_tts_multilingual_357m.v2607.f16.gguf'
32
+ },
33
+ {
34
+ path: 'nemo_nano_codec_22khz_1.89kbps_21.5fps.decoder.f16.gguf',
35
+ url: 'https://huggingface.co/nvidia/nemo-nano-codec-22khz-1.89kbps-21.5fps/resolve/fc00890b604aa2de298d2641ffc6c5f6caf8c4d7/nemo_nano_codec_22khz_1.89kbps_21.5fps.decoder.f16.gguf'
36
+ }
37
+ ],
38
+ archives: [
39
+ {
40
+ url: 'https://huggingface.co/nvidia/magpie_tts_multilingual_357m/resolve/19806879b16d3f2ccf28fb112b1bcd16a3c7923e/magpie_tts_multilingual_357m.nemo',
41
+ format: 'tar',
42
+ // These offsets select upstream TAR segments at the transport
43
+ // boundary. Their complete selected members stay unchanged.
44
+ ranges: [
45
+ {start: 0, end: 20619263, stopBefore: 'model_weights.ckpt'},
46
+ {start: 1459750400, end: 1470207999}
47
+ ],
48
+ members: [
49
+ {
50
+ path: 'tokenizer/05adc40366e149b69319acd4b28a4919_pt_br_prondict-v1.0.dict',
51
+ member: '05adc40366e149b69319acd4b28a4919_pt_br_prondict-v1.0.dict'
52
+ },
53
+ {
54
+ path: 'tokenizer/339da71c54b046f98cbcf38ef6d4ff67_hindi_phoneme_merged_phoneme_dict.dict',
55
+ member: '339da71c54b046f98cbcf38ef6d4ff67_hindi_phoneme_merged_phoneme_dict.dict'
56
+ },
57
+ {
58
+ path: 'tokenizer/41913ebaa70342058574da74293b7630_magpie_tts_multilingual_357m.nemo.speakers.json',
59
+ member: '41913ebaa70342058574da74293b7630_magpie_tts_multilingual_357m.nemo.speakers.json'
60
+ },
61
+ {
62
+ path: 'tokenizer/61d57a4ccb064d1e8e3d9f871c571932_heteronyms-052722',
63
+ member: '61d57a4ccb064d1e8e3d9f871c571932_heteronyms-052722'
64
+ },
65
+ {
66
+ path: 'tokenizer/74b3e832fe914a569fcd42b51d06b2f1_ipa_dict_nv23.05.txt',
67
+ member: '74b3e832fe914a569fcd42b51d06b2f1_ipa_dict_nv23.05.txt'
68
+ },
69
+ {
70
+ path: 'tokenizer/7dbc31751f224f2486090d59dc95b9f7_es_ES_nv230301.dict',
71
+ member: '7dbc31751f224f2486090d59dc95b9f7_es_ES_nv230301.dict'
72
+ },
73
+ {
74
+ path: 'tokenizer/c5e4ec2af5a14ce294f4b9edcc936535_de_nv230119.heteronym',
75
+ member: 'c5e4ec2af5a14ce294f4b9edcc936535_de_nv230119.heteronym'
76
+ },
77
+ {
78
+ path: 'tokenizer/cf01ab5c48c84f3282ef7888263361e5_de_nv230119.dict',
79
+ member: 'cf01ab5c48c84f3282ef7888263361e5_de_nv230119.dict'
80
+ },
81
+ {
82
+ path: 'tokenizer/dc7d60d6b15a4651b21c9ca2932b62c6_ipa_cmudict-0.7b_nv23.01.txt',
83
+ member: 'dc7d60d6b15a4651b21c9ca2932b62c6_ipa_cmudict-0.7b_nv23.01.txt'
84
+ },
85
+ {path: 'tokenizer/model_config.yaml', member: 'model_config.yaml'}
86
+ ]
87
+ }
88
+ ],
89
+ resourcePaths: {
90
+ model: 'magpie_tts_multilingual_357m.v2607.f16.gguf',
91
+ codec: 'nemo_nano_codec_22khz_1.89kbps_21.5fps.decoder.f16.gguf',
92
+ tokenizerConfig: 'tokenizer/model_config.yaml'
93
+ }
94
+ }
95
+ };
96
+
97
+ /** Explicitly acquire the complete ASR and diarization selection. */
98
+ export function prepareNemotronModels(options = {}) {
99
+ return prepareModels(NEMO_SPEECH_MODELS.stt, options);
100
+ }
101
+
102
+ /** Explicitly acquire Magpie, NanoCodec and the matching tokenizer members. */
103
+ export function prepareMagpieModels(options = {}) {
104
+ return prepareModels(NEMO_SPEECH_MODELS.tts, options);
105
+ }
106
+
107
+ async function prepareModels(selection, {modelAssets, workingDirectory, directories, id = randomUUID(), signal, onProgress, refresh = false}) {
108
+ if (typeof modelAssets?.prepare !== 'function' || typeof modelAssets?.release !== 'function') {
109
+ throw new TypeError('Native speech preparation requires the existing model-assets service.');
110
+ }
111
+ signal?.throwIfAborted();
112
+ let projection;
113
+ if (directories !== undefined && !refresh) {
114
+ if (typeof modelAssets.discover !== 'function' || typeof modelAssets.openExisting !== 'function') {
115
+ throw new TypeError('Installed native speech discovery requires the model-assets discover and openExisting methods.');
116
+ }
117
+ const discovered = await modelAssets.discover({
118
+ directories, signal,
119
+ catalog: [{id: selection.modelId, members: selection.members, archives: selection.archives,
120
+ entryPath: selection.resourcePaths.model}]
121
+ });
122
+ signal?.throwIfAborted();
123
+ const existing = discovered.models.find(function completeSelection(model) {
124
+ return model.complete && model.catalogId === selection.modelId;
125
+ });
126
+ if (existing) {
127
+ await onProgress?.({phase: 'reuse', completed: 0, total: existing.members.length, unit: 'files'});
128
+ signal?.throwIfAborted();
129
+ projection = await modelAssets.openExisting({id, directory: existing.directory, members: existing.members, signal});
130
+ }
131
+ }
132
+ const reused = projection !== undefined;
133
+ projection ??= await modelAssets.prepare({
134
+ id, workingDirectory, members: selection.members,
135
+ archives: selection.archives, signal, onProgress, refresh
136
+ });
137
+ let released;
138
+ function release() {
139
+ released ??= Promise.resolve().then(function releasePreparation() { return modelAssets.release(projection.id); });
140
+ return released;
141
+ }
142
+ try {
143
+ signal?.throwIfAborted();
144
+ if (reused) {
145
+ await onProgress?.({phase: 'ready', completed: projection.members.length, total: projection.members.length, unit: 'files'});
146
+ signal?.throwIfAborted();
147
+ }
148
+ return {
149
+ assetProjectionId: projection.id, directory: projection.directory,
150
+ members: projection.members, resourcePaths: {...selection.resourcePaths}, release
151
+ };
152
+ } catch (error) {
153
+ try {
154
+ await release();
155
+ } catch (cleanupError) {
156
+ throw new AggregateError([error, cleanupError], 'Native speech preparation and cleanup failed.', {cause: error});
157
+ }
158
+ throw error;
159
+ }
160
+ }
@@ -3,10 +3,12 @@ import {randomUUID} from 'node:crypto';
3
3
  import {createArcaneEventSource} from '../../event-manager.mjs';
4
4
  import {createNemotronHelper, deferred, encodeNemotronAudio, nemotronError} from './helper.mjs';
5
5
 
6
+ export {NEMO_SPEECH_MODELS, prepareNemotronModels, prepareMagpieModels} from '../nemo-models.mjs';
7
+
6
8
  /** Retain one selected recognizer and diarizer across explicit utterance endpoints. */
7
9
  export function createNemotronRuntime(
8
10
  {runtime, prepare, modelId = 'nemotron-3.5-asr-streaming-0.6b', modelPath,
9
- diarizationModelPath, gpu, onEvent} = {}
11
+ diarizationModelPath, modelAssets, assetProjectionId, resourcePaths, gpu, onEvent} = {}
10
12
  ) {
11
13
  if (!runtime && typeof prepare !== 'function') {
12
14
  throw new TypeError('Nemotron requires a prepared runtime or a prepare function.');
@@ -20,6 +22,10 @@ export function createNemotronRuntime(
20
22
  let selected = modelId;
21
23
  let selectedModelPath = modelPath ?? runtime?.modelPath;
22
24
  let selectedDiarizationPath = diarizationModelPath ?? runtime?.diarizationModelPath;
25
+ let selectedProjectionId = assetProjectionId;
26
+ let selectedResourcePaths = resourcePaths;
27
+ let retainedAssets = null;
28
+ let activePaths = null;
23
29
  let selectedGpu = gpu ?? runtime?.gpu;
24
30
  let streamSequence = 0;
25
31
  let state = 'unloaded';
@@ -37,8 +43,10 @@ export function createNemotronRuntime(
37
43
  return {
38
44
  providerId: 'nemotron', modelId: selected, state,
39
45
  loaded: state === 'ready', busy: active !== null,
40
- modelPath: selectedModelPath ?? null,
41
- diarizationModelPath: selectedDiarizationPath ?? null,
46
+ modelPath: activePaths?.model ?? selectedModelPath ?? null,
47
+ diarizationModelPath: activePaths ? activePaths.diarization ?? null : selectedDiarizationPath ?? null,
48
+ assetProjectionId: selectedProjectionId ?? null,
49
+ resourcePaths: selectedResourcePaths ? {...selectedResourcePaths} : null,
42
50
  requestedGpu: selectedGpu ?? null,
43
51
  observedBackend: null,
44
52
  nativeVersion: helper?.current().metadata?.version ?? null,
@@ -95,6 +103,11 @@ export function createNemotronRuntime(
95
103
  const changed = (options.modelId !== undefined && options.modelId !== selected)
96
104
  || (options.modelPath !== undefined && options.modelPath !== selectedModelPath)
97
105
  || (options.diarizationModelPath !== undefined && options.diarizationModelPath !== selectedDiarizationPath)
106
+ || (Object.hasOwn(options, 'assetProjectionId') && options.assetProjectionId !== selectedProjectionId)
107
+ || (Object.hasOwn(options, 'resourcePaths') && (
108
+ options.resourcePaths?.model !== selectedResourcePaths?.model
109
+ || options.resourcePaths?.diarization !== selectedResourcePaths?.diarization
110
+ ))
98
111
  || (options.gpu !== undefined && options.gpu !== selectedGpu);
99
112
  if ((loading || helper || active) && changed) {
100
113
  return Promise.reject(nemotronError('NEMOTRON_MODEL_LOADED', 'Unload the current Nemotron selection before replacing it.'));
@@ -107,12 +120,22 @@ export function createNemotronRuntime(
107
120
  selected = options.modelId ?? selected;
108
121
  selectedModelPath = options.modelPath ?? selectedModelPath;
109
122
  selectedDiarizationPath = options.diarizationModelPath ?? selectedDiarizationPath;
123
+ if (Object.hasOwn(options, 'assetProjectionId')) selectedProjectionId = options.assetProjectionId;
124
+ if (Object.hasOwn(options, 'resourcePaths')) selectedResourcePaths = options.resourcePaths;
110
125
  selectedGpu = options.gpu ?? selectedGpu;
126
+ if (selectedProjectionId && typeof modelAssets?.retain !== 'function') {
127
+ return Promise.reject(nemotronError('NEMOTRON_RESOURCES_UNAVAILABLE', 'A model projection requires the existing model-assets owner.'));
128
+ }
129
+ // Retain synchronously before callers can release preparation ownership.
130
+ const previousAssets = retainedAssets;
131
+ const assets = selectedProjectionId ? {use: modelAssets.retain(selectedProjectionId), release: null} : null;
132
+ retainedAssets = assets;
111
133
  state = 'loading';
112
134
  error = null;
113
135
  loadController = new AbortController();
114
136
  const ownerSignal = loadController.signal;
115
137
  async function loadModel() {
138
+ await releaseModelAssets(previousAssets);
116
139
  ownerSignal.throwIfAborted();
117
140
  if (!runtime) {
118
141
  runtime = await prepare(
@@ -123,10 +146,15 @@ export function createNemotronRuntime(
123
146
  selectedModelPath ??= runtime.modelPath;
124
147
  selectedDiarizationPath ??= runtime.diarizationModelPath;
125
148
  selectedGpu ??= runtime.gpu;
126
- if (typeof runtime.helperExecutable !== 'string' || typeof selectedModelPath !== 'string') {
149
+ const paths = assets ? {
150
+ model: projectedModelPath(assets, 'model'),
151
+ diarization: selectedResourcePaths?.diarization === undefined
152
+ ? undefined : projectedModelPath(assets, 'diarization')
153
+ } : {model: selectedModelPath, diarization: selectedDiarizationPath};
154
+ if (typeof runtime.helperExecutable !== 'string' || typeof paths.model !== 'string') {
127
155
  throw nemotronError('NEMOTRON_MODEL_UNAVAILABLE', 'Select the prepared Nemotron helperExecutable and ASR modelPath.');
128
156
  }
129
- if (selectedDiarizationPath !== undefined && typeof selectedDiarizationPath !== 'string') {
157
+ if (paths.diarization !== undefined && typeof paths.diarization !== 'string') {
130
158
  throw new TypeError('diarizationModelPath must be the selected local model path.');
131
159
  }
132
160
  if (!Number.isInteger(selectedGpu) || selectedGpu < -1) {
@@ -134,10 +162,11 @@ export function createNemotronRuntime(
134
162
  }
135
163
  const session = createNemotronHelper(
136
164
  {
137
- runtime, modelPath: selectedModelPath, diarizationModelPath: selectedDiarizationPath,
165
+ runtime, modelPath: paths.model, diarizationModelPath: paths.diarization,
138
166
  gpu: selectedGpu, onEvent
139
167
  }
140
168
  );
169
+ activePaths = paths;
141
170
  helper = session;
142
171
  function cancelLoad() {
143
172
  session.stop(ownerSignal.reason).catch(observeRetainedFailure);
@@ -179,7 +208,14 @@ export function createNemotronRuntime(
179
208
  ownerSignal.removeEventListener('abort', cancelLoad);
180
209
  }
181
210
  }
182
- function loadFailed(failure) {
211
+ async function loadFailed(failure) {
212
+ try {
213
+ await releaseModelAssets(assets);
214
+ } catch (cleanupFailure) {
215
+ failure = new AggregateError([failure, cleanupFailure], 'Nemotron model loading and asset cleanup failed.', {cause: failure});
216
+ }
217
+ if (retainedAssets === assets) retainedAssets = null;
218
+ activePaths = null;
183
219
  const cancelledLoad = ownerSignal.aborted && (
184
220
  failure === ownerSignal.reason || failure?.code === 'ARCANE_CANCELLED' || failure?.name === 'AbortError'
185
221
  );
@@ -200,6 +236,21 @@ export function createNemotronRuntime(
200
236
  return waitFor(loading, signal);
201
237
  }
202
238
 
239
+ function projectedModelPath(assets, role) {
240
+ const memberPath = selectedResourcePaths?.[role];
241
+ const member = assets.use.members.find(function selectedMember(value) { return value.path === memberPath; });
242
+ if (!member) {
243
+ throw nemotronError('NEMOTRON_RESOURCE_UNAVAILABLE', `The retained Nemotron projection has no member ${String(memberPath)} for ${role}.`);
244
+ }
245
+ return member.nativePath;
246
+ }
247
+
248
+ function releaseModelAssets(assets) {
249
+ if (!assets) return Promise.resolve();
250
+ assets.release ??= Promise.resolve().then(function releaseNativeModelUse() { return assets.use.release(); });
251
+ return assets.release;
252
+ }
253
+
203
254
  function openStream({sampleRate = 16000, languageCode, model, onUpdate, signal} = {}) {
204
255
  signal?.throwIfAborted();
205
256
  if (model !== undefined && model !== selected) {
@@ -547,6 +598,11 @@ export function createNemotronRuntime(
547
598
  const session = helper;
548
599
  try { if (session) await session.stop(); } catch (failure) { failures.push(failure); }
549
600
  if (helper === session) helper = null;
601
+ // stop joins actual native exit even when it reports diagnostics.
602
+ const assets = retainedAssets;
603
+ try { await releaseModelAssets(assets); } catch (failure) { failures.push(failure); }
604
+ if (retainedAssets === assets) retainedAssets = null;
605
+ activePaths = null;
550
606
  if (failures.length) throw new AggregateError(failures, 'Nemotron cleanup failed.');
551
607
  error = null;
552
608
  state = 'unloaded';
@@ -7,6 +7,8 @@ import Is from 'strong-type';
7
7
  import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
8
8
  import {runProcess} from '../process.mjs';
9
9
  import {createPortableNativeProvider} from './portable-provider.mjs';
10
+ import {assemblePortable} from './portable-assembly.mjs';
11
+ import {publishNativeOutput} from './publish-output.mjs';
10
12
  import {outputFile, writeOutput} from './portable-layout.mjs';
11
13
 
12
14
  const is = new Is(false);
@@ -224,19 +226,19 @@ export function createMacOSNativeProvider({services, hostDirectory} = {}) {
224
226
  const target = macosTarget(request.targetRequest);
225
227
  const selected = hostDirectory ?? request.toolchain?.hostDirectory;
226
228
  const selectedHost = selected === undefined ? null : await requireHostDirectory(selected);
227
- const artifact = await portable.build({
229
+ const artifact = await assemblePortable({
228
230
  ...request, targetRequest: portableRequest(target),
229
231
  async onEvent(event) {
230
- if (event.type !== 'native.payload.completed') await emit(onEvent, {...event, target: target.target});
232
+ await emit(onEvent, {...event, target: target.target});
231
233
  }
232
- });
234
+ }, {services, packagedWeb: true, webKitDocumentLifecycle: true});
233
235
  const portableRoot = artifact.target.rootDir;
234
236
  let assembly;
235
237
  try {
236
238
  const host = selectedHost ?? await releasedHostDirectory(path.resolve(request.outputRoot), artifact.manifest.sdk.version, target, {signal, onEvent});
237
239
  throwIfAborted(signal);
238
240
  assembly = await mkdtemp(path.join(path.resolve(request.outputRoot), 'arcane-macos-'));
239
- const plist = outputFile(assembly, `${request.appDescriptor.id}.app/Contents/Info.plist`);
241
+ const plist = outputFile(assembly, 'Contents/Info.plist');
240
242
  const contents = path.dirname(plist);
241
243
  const root = path.join(contents, 'Resources');
242
244
  const bundleRoot = path.dirname(contents);
@@ -256,9 +258,14 @@ export function createMacOSNativeProvider({services, hostDirectory} = {}) {
256
258
  };
257
259
  await writeFile(path.join(root, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, {signal});
258
260
  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};
261
+ const publishedBundle = await publishNativeOutput({
262
+ sourceRoot: bundleRoot, outputRoot: path.dirname(portableRoot), appId: request.appDescriptor.id, bundle: true, signal,
263
+ async onPublished(finalBundle) {
264
+ await emit(onEvent, {type: 'native.payload.completed', target: target.target, appId: request.appDescriptor.id,
265
+ outputRoot: path.join(finalBundle, 'Contents', 'Resources'), bundleRoot: finalBundle});
266
+ }
267
+ });
268
+ return {...artifact, target: {...target, rootDir: path.join(publishedBundle, 'Contents', 'Resources')}, bundleRoot: publishedBundle, manifest};
262
269
  } catch (error) {
263
270
  return removeOwnedDirectories(assembly ? [portableRoot, assembly] : [portableRoot], error);
264
271
  }
@@ -0,0 +1,180 @@
1
+ import {mkdir, mkdtemp, readFile, realpath, rm} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import Is from 'strong-type';
4
+ import {createCoreClassicSource} from '../core-classic-source.mjs';
5
+ import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
6
+ import {SDK_ROOT, outputFile, writeOutput, copyRelease, copyCoreRuntime, coreEntrySource} from './portable-layout.mjs';
7
+
8
+ const is = new Is(false);
9
+ const MANIFEST = 'arcane-native.json';
10
+
11
+ async function emit(onEvent, event) {
12
+ if (is.function(onEvent)) await onEvent(event);
13
+ }
14
+
15
+ function contains(root, candidate) {
16
+ const relative = path.relative(root, candidate);
17
+ return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
18
+ }
19
+
20
+ async function futureRealPath(location) {
21
+ try { return await realpath(location); }
22
+ catch (error) {
23
+ if (error.code !== 'ENOENT') throw error;
24
+ const parent = path.dirname(location);
25
+ if (parent === location) throw error;
26
+ return path.join(await futureRealPath(parent), path.basename(location));
27
+ }
28
+ }
29
+
30
+ async function outputDirectory(request) {
31
+ if (!is.string(request.outputRoot) || !request.outputRoot) {
32
+ throw new ArcaneError(ERROR_CODES.usage, 'Portable assembly requires an outputRoot.');
33
+ }
34
+ const output = await futureRealPath(path.resolve(request.outputRoot));
35
+ const protectedRoots = [SDK_ROOT, request.appReleaseRoot,
36
+ ...(request.dependencies?.map(function dependencyRoot(item) { return item.releaseRoot; }) ?? []),
37
+ ...(request.protectedRoots ?? [])];
38
+ for (const location of protectedRoots) {
39
+ if (!location) continue;
40
+ const root = await futureRealPath(path.resolve(location));
41
+ // SDK/release roots must never be replaced by generated output. A
42
+ // caller may select its ordinary project-local dist descendant.
43
+ if (contains(output, root) || (location !== SDK_ROOT && contains(root, output))) {
44
+ throw new ArcaneError(ERROR_CODES.usage, `Portable output overlaps selected source: ${location}.`);
45
+ }
46
+ }
47
+ await mkdir(output, {recursive: true});
48
+ return output;
49
+ }
50
+
51
+ function serviceSelection(services, release) {
52
+ return services.map(function selectService(service) {
53
+ if (!is.string(service?.module) || !release.files.includes(service.module)) {
54
+ throw new ArcaneError(ERROR_CODES.prerequisiteMissing, `The selected release does not contain service module ${String(service?.module)}.`);
55
+ }
56
+ return {...service, options: service.options === undefined ? {} : service.options};
57
+ });
58
+ }
59
+
60
+ function eventOwnerPath(release) {
61
+ const paths = release.files.filter(function sdkEventModule(relative) {
62
+ return relative === 'sdk/event-manager.mjs' || relative.endsWith('/sdk/event-manager.mjs')
63
+ || /^node_modules\/(?:@[^/]+\/)?[^/]+\/browser-runtime\/event-manager\.mjs$/u.test(relative);
64
+ });
65
+ if (paths.length !== 1) {
66
+ throw new ArcaneError(ERROR_CODES.prerequisiteMissing,
67
+ 'The portable app release must select one shared SDK event-manager module.',
68
+ {details: {paths}});
69
+ }
70
+ // Native hosts mount the complete app directory at one local URL origin.
71
+ // An origin-root URL keeps nested navigable documents on the same owner.
72
+ return '/' + paths[0].split('/').map(encodeURIComponent).join('/');
73
+ }
74
+
75
+ /** Shared unfinished payload assembly; the final platform provider publishes it. */
76
+ export async function assemblePortable(request = {}, {services, packagedWeb = false, webKitDocumentLifecycle = false} = {}) {
77
+ const {appDescriptor, release, signal, onEvent} = request;
78
+ throwIfAborted(signal);
79
+ const target = {...request.targetRequest};
80
+ const localAI = appDescriptor.native?.localAI;
81
+ const selectedServices = serviceSelection(services ?? appDescriptor.native?.services ?? [], release);
82
+ const eventOwnerModuleURL = eventOwnerPath(release);
83
+ const output = await outputDirectory(request);
84
+ throwIfAborted(signal);
85
+ // Unique assembly directories preserve earlier outputs and avoid
86
+ // overwriting an app's files or a concurrent packaging operation.
87
+ const root = await mkdtemp(path.join(output, 'arcane-portable-'));
88
+ const files = [];
89
+ try {
90
+ await emit(onEvent, {type: 'native.payload.started', appId: appDescriptor.id, outputRoot: root});
91
+ async function bundleSelectedRuntimes() {
92
+ if (localAI === undefined) return {runtimes: [], files: []};
93
+ const {bundleLocalAIRuntimes} = await import('../local-ai/install.mjs');
94
+ return bundleLocalAIRuntimes({
95
+ runtimes: localAI.runtimes,
96
+ directory: path.join(output, 'local-ai-runtimes'),
97
+ outputRoot: root,
98
+ platform: target.platform === 'windows' ? 'win32' : target.platform === 'macos' ? 'darwin' : target.platform,
99
+ architecture: target.architecture,
100
+ signal,
101
+ onEvent
102
+ });
103
+ }
104
+ // These outputs have separate directories. Join both owners
105
+ // before cleanup so neither can keep writing a removed assembly.
106
+ const preparation = await Promise.allSettled([
107
+ copyRelease({...request, releaseRoot: request.appReleaseRoot}, 'app', root, files, signal),
108
+ bundleSelectedRuntimes()
109
+ ]);
110
+ const failures = preparation.filter(function failedPreparation(result) { return result.status === 'rejected'; });
111
+ if (failures.length === 1) throw failures[0].reason;
112
+ if (failures.length > 1) {
113
+ throw new AggregateError(failures.map(function preparationError(result) { return result.reason; }),
114
+ 'Portable application and local runtime preparation failed.');
115
+ }
116
+ const bundled = preparation[1].value;
117
+ files.push(...bundled.files);
118
+ const dependencies = [];
119
+ for (const [index, dependency] of (request.dependencies ?? []).entries()) {
120
+ const directory = `dependencies/${index}`;
121
+ await copyRelease(dependency, directory, root, files, signal);
122
+ dependencies.push({appId: dependency.appId, root: directory, release: dependency.release});
123
+ }
124
+ const sdk = await copyCoreRuntime(root, files, signal);
125
+ const selectedSdk = request.selectedSdk;
126
+ let appSdk;
127
+ if (selectedSdk && release.files.some(function selectedPackageFile(relative) {
128
+ return relative.startsWith(`${selectedSdk.packageSource}/`);
129
+ })) {
130
+ const destination = `app/${selectedSdk.packageSource}`;
131
+ let metadata;
132
+ if (release.files.includes(`${selectedSdk.packageSource}/package.json`)) {
133
+ metadata = JSON.parse(await readFile(outputFile(root, `${destination}/package.json`), 'utf8'));
134
+ } else {
135
+ metadata = await copyCoreRuntime(root, files, signal, {
136
+ sourceRoot: selectedSdk.packageRoot, sdkDestination: destination
137
+ });
138
+ }
139
+ appSdk = {name: metadata.name, version: metadata.version, root: destination};
140
+ }
141
+ await writeOutput(root, 'package.json', '{"type":"module"}\n', files, signal);
142
+ await writeOutput(root, 'runtime/arcane-core.mjs', coreEntrySource(appDescriptor, sdk.version, selectedServices,
143
+ {localAI, runtimes: bundled.runtimes, packagedWeb}), files, signal);
144
+ await writeOutput(root, 'runtime/arcane-api.js', await createCoreClassicSource({
145
+ eventOwnerModuleURL, replayRuntimeState: true, webKitDocumentLifecycle
146
+ }), files, signal);
147
+ const manifest = {
148
+ schemaVersion: 1,
149
+ kind: 'arcane-portable-native',
150
+ sdk: {name: sdk.name, version: sdk.version},
151
+ ...(appSdk ? {appSdk} : {}),
152
+ app: appDescriptor,
153
+ target,
154
+ webRoot: 'app',
155
+ start: release.manifest?.app?.start ?? './index.html',
156
+ ...(appDescriptor.native?.window === undefined ? {} : {window: {...appDescriptor.native.window}}),
157
+ ...(appDescriptor.native?.launchContext === undefined ? {} : {launchContext: appDescriptor.native.launchContext}),
158
+ core: {
159
+ entry: 'runtime/arcane-core.mjs', transport: 'stdio', protocol: 'arcane/1', services: selectedServices,
160
+ ...(localAI === undefined ? {} : {localAIRuntimes: bundled.runtimes})
161
+ },
162
+ client: {
163
+ source: 'runtime/arcane-api.js', injection: 'document-created', eventOwnerModuleURL, replayRuntimeState: true,
164
+ ...(webKitDocumentLifecycle ? {webKitDocumentLifecycle: true} : {})
165
+ },
166
+ dependencies,
167
+ minimumCoreVersion: request.minimumCoreVersion ?? null,
168
+ files: [...files, MANIFEST]
169
+ };
170
+ await writeOutput(root, MANIFEST, JSON.stringify(manifest, null, 2) + '\n', files, signal);
171
+ throwIfAborted(signal);
172
+ return {app: appDescriptor, target: {...target, rootDir: root}, manifest};
173
+ } catch (error) {
174
+ try { await rm(root, {recursive: true, force: true}); }
175
+ catch (cleanupError) {
176
+ throw new AggregateError([error, cleanupError], `Portable assembly failed; its incomplete output remains at ${root}.`);
177
+ }
178
+ throw error;
179
+ }
180
+ }