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.
- package/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/browser-runtime/ai/core-model-assets.mjs +74 -0
- package/docs/reference/README.md +9 -0
- package/docs/reference/ai/browser-speech.md +61 -10
- package/docs/reference/core-client.md +35 -0
- package/docs/reference/core-native-packaging.md +38 -6
- package/docs/reference/inventory/package-api.json +133 -5
- package/docs/reference/inventory/runtime-modules.json +39 -13
- package/docs/reference/model-assets.md +164 -3
- package/docs/reference/native-speech.md +105 -4
- package/docs/reference/runtime-modules.md +157 -11
- package/docs/reference/sdk-api.md +247 -4
- package/package.json +3 -3
- package/src/core/host.mjs +1 -0
- package/src/core/services/local-ai.mjs +46 -12
- package/src/core/services/model-assets.mjs +491 -59
- package/src/core/services/speech.mjs +2 -2
- package/src/local-ai/nemo-models.mjs +160 -0
- package/src/local-ai/nemotron/index.mjs +63 -7
- package/src/native/macos-provider.mjs +14 -7
- package/src/native/portable-assembly.mjs +180 -0
- package/src/native/portable-provider.mjs +17 -161
- package/src/native/publish-output.mjs +101 -0
- package/src/native/windows-provider.mjs +12 -6
|
@@ -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
|
-
|
|
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 (
|
|
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:
|
|
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
|
|
229
|
+
const artifact = await assemblePortable({
|
|
228
230
|
...request, targetRequest: portableRequest(target),
|
|
229
231
|
async onEvent(event) {
|
|
230
|
-
|
|
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,
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
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
|
+
}
|