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.
@@ -1,9 +1,10 @@
1
- import {mkdir, mkdtemp, readFile, realpath, rm, stat} from 'node:fs/promises';
1
+ import {readFile, rm, stat} from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import Is from 'strong-type';
4
- import {createCoreClassicSource} from '../core-classic-source.mjs';
5
4
  import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
6
- import {SDK_ROOT, CORE_FILES, outputFile, writeOutput, copyRelease, copyCoreRuntime, coreEntrySource} from './portable-layout.mjs';
5
+ import {SDK_ROOT, CORE_FILES, outputFile} from './portable-layout.mjs';
6
+ import {assemblePortable} from './portable-assembly.mjs';
7
+ import {publishNativeOutput} from './publish-output.mjs';
7
8
 
8
9
  const is = new Is(false);
9
10
  const PROTOCOL = 'arcane-native-builder/1';
@@ -20,66 +21,6 @@ async function emit(onEvent, event) {
20
21
  if (is.function(onEvent)) await onEvent(event);
21
22
  }
22
23
 
23
- function contains(root, candidate) {
24
- const relative = path.relative(root, candidate);
25
- return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
26
- }
27
-
28
- async function futureRealPath(location) {
29
- try { return await realpath(location); }
30
- catch (error) {
31
- if (error.code !== 'ENOENT') throw error;
32
- const parent = path.dirname(location);
33
- if (parent === location) throw error;
34
- return path.join(await futureRealPath(parent), path.basename(location));
35
- }
36
- }
37
-
38
- async function outputDirectory(request) {
39
- if (!is.string(request.outputRoot) || !request.outputRoot) {
40
- throw new ArcaneError(ERROR_CODES.usage, 'Portable assembly requires an outputRoot.');
41
- }
42
- const output = await futureRealPath(path.resolve(request.outputRoot));
43
- const protectedRoots = [SDK_ROOT, request.appReleaseRoot,
44
- ...(request.dependencies?.map(function dependencyRoot(item) { return item.releaseRoot; }) ?? []),
45
- ...(request.protectedRoots ?? [])];
46
- for (const location of protectedRoots) {
47
- if (!location) continue;
48
- const root = await futureRealPath(path.resolve(location));
49
- // SDK/release roots must never be replaced by generated output. A
50
- // caller may select its ordinary project-local dist descendant.
51
- if (contains(output, root) || (location !== SDK_ROOT && contains(root, output))) {
52
- throw new ArcaneError(ERROR_CODES.usage, `Portable output overlaps selected source: ${location}.`);
53
- }
54
- }
55
- await mkdir(output, {recursive: true});
56
- return output;
57
- }
58
-
59
- function serviceSelection(services, release) {
60
- return services.map(function selectService(service) {
61
- if (!is.string(service?.module) || !release.files.includes(service.module)) {
62
- throw new ArcaneError(ERROR_CODES.prerequisiteMissing, `The selected release does not contain service module ${String(service?.module)}.`);
63
- }
64
- return {...service, options: service.options === undefined ? {} : service.options};
65
- });
66
- }
67
-
68
- function eventOwnerPath(release) {
69
- const paths = release.files.filter(function sdkEventModule(relative) {
70
- return relative === 'sdk/event-manager.mjs' || relative.endsWith('/sdk/event-manager.mjs')
71
- || /^node_modules\/(?:@[^/]+\/)?[^/]+\/browser-runtime\/event-manager\.mjs$/u.test(relative);
72
- });
73
- if (paths.length !== 1) {
74
- throw new ArcaneError(ERROR_CODES.prerequisiteMissing,
75
- 'The portable app release must select one shared SDK event-manager module.',
76
- {details: {paths}});
77
- }
78
- // Native hosts mount the complete app directory at one local URL origin.
79
- // An origin-root URL keeps nested navigable documents on the same owner.
80
- return '/' + paths[0].split('/').map(encodeURIComponent).join('/');
81
- }
82
-
83
24
  /** Package-owned assembly; never imports an OS checkout or application service. */
84
25
  export function createPortableNativeProvider({services, packagedWeb = false, webKitDocumentLifecycle = false} = {}) {
85
26
  return {
@@ -117,108 +58,23 @@ export function createPortableNativeProvider({services, packagedWeb = false, web
117
58
  return result;
118
59
  },
119
60
  async build(request = {}) {
120
- const {appDescriptor, release, signal, onEvent} = request;
121
- throwIfAborted(signal);
122
- const target = portableTarget(request.targetRequest);
123
- const localAI = appDescriptor.native?.localAI;
124
- const selectedServices = serviceSelection(services ?? appDescriptor.native?.services ?? [], release);
125
- const eventOwnerModuleURL = eventOwnerPath(release);
126
- const output = await outputDirectory(request);
127
- throwIfAborted(signal);
128
- // Unique assembly directories preserve earlier outputs and avoid
129
- // overwriting an app's files or a concurrent packaging operation.
130
- const root = await mkdtemp(path.join(output, 'arcane-portable-'));
131
- const files = [];
61
+ throwIfAborted(request.signal);
62
+ portableTarget(request.targetRequest);
63
+ const artifact = await assemblePortable(request, {services, packagedWeb, webKitDocumentLifecycle});
64
+ const sourceRoot = artifact.target.rootDir;
132
65
  try {
133
- await emit(onEvent, {type: 'native.payload.started', appId: appDescriptor.id, outputRoot: root});
134
- async function bundleSelectedRuntimes() {
135
- if (localAI === undefined) return {runtimes: [], files: []};
136
- const {bundleLocalAIRuntimes} = await import('../local-ai/install.mjs');
137
- return bundleLocalAIRuntimes({
138
- runtimes: localAI.runtimes,
139
- directory: path.join(output, 'local-ai-runtimes'),
140
- outputRoot: root,
141
- platform: target.platform === 'windows' ? 'win32' : target.platform === 'macos' ? 'darwin' : target.platform,
142
- architecture: target.architecture,
143
- signal,
144
- onEvent
145
- });
146
- }
147
- // These outputs have separate directories. Join both owners
148
- // before cleanup so neither can keep writing a removed assembly.
149
- const preparation = await Promise.allSettled([
150
- copyRelease({...request, releaseRoot: request.appReleaseRoot}, 'app', root, files, signal),
151
- bundleSelectedRuntimes()
152
- ]);
153
- const failures = preparation.filter(function failedPreparation(result) { return result.status === 'rejected'; });
154
- if (failures.length === 1) throw failures[0].reason;
155
- if (failures.length > 1) {
156
- throw new AggregateError(failures.map(function preparationError(result) { return result.reason; }),
157
- 'Portable application and local runtime preparation failed.');
158
- }
159
- const bundled = preparation[1].value;
160
- files.push(...bundled.files);
161
- const dependencies = [];
162
- for (const [index, dependency] of (request.dependencies ?? []).entries()) {
163
- const directory = `dependencies/${index}`;
164
- await copyRelease(dependency, directory, root, files, signal);
165
- dependencies.push({appId: dependency.appId, root: directory, release: dependency.release});
166
- }
167
- const sdk = await copyCoreRuntime(root, files, signal);
168
- const selectedSdk = request.selectedSdk;
169
- let appSdk;
170
- if (selectedSdk && release.files.some(function selectedPackageFile(relative) {
171
- return relative.startsWith(`${selectedSdk.packageSource}/`);
172
- })) {
173
- const destination = `app/${selectedSdk.packageSource}`;
174
- let metadata;
175
- if (release.files.includes(`${selectedSdk.packageSource}/package.json`)) {
176
- metadata = JSON.parse(await readFile(outputFile(root, `${destination}/package.json`), 'utf8'));
177
- } else {
178
- metadata = await copyCoreRuntime(root, files, signal, {
179
- sourceRoot: selectedSdk.packageRoot, sdkDestination: destination
180
- });
66
+ const root = await publishNativeOutput({
67
+ sourceRoot, outputRoot: path.dirname(sourceRoot), appId: artifact.app.id,
68
+ signal: request.signal,
69
+ async onPublished(outputRoot) {
70
+ await emit(request.onEvent, {type: 'native.payload.completed', appId: artifact.app.id, outputRoot});
181
71
  }
182
- appSdk = {name: metadata.name, version: metadata.version, root: destination};
183
- }
184
- await writeOutput(root, 'package.json', '{"type":"module"}\n', files, signal);
185
- await writeOutput(root, 'runtime/arcane-core.mjs', coreEntrySource(appDescriptor, sdk.version, selectedServices,
186
- {localAI, runtimes: bundled.runtimes, packagedWeb}), files, signal);
187
- await writeOutput(root, 'runtime/arcane-api.js', await createCoreClassicSource({
188
- eventOwnerModuleURL, replayRuntimeState: true, webKitDocumentLifecycle
189
- }), files, signal);
190
- const manifest = {
191
- schemaVersion: 1,
192
- kind: 'arcane-portable-native',
193
- sdk: {name: sdk.name, version: sdk.version},
194
- ...(appSdk ? {appSdk} : {}),
195
- app: appDescriptor,
196
- target,
197
- webRoot: 'app',
198
- start: release.manifest?.app?.start ?? './index.html',
199
- ...(appDescriptor.native?.window === undefined ? {} : {window: {...appDescriptor.native.window}}),
200
- ...(appDescriptor.native?.launchContext === undefined ? {} : {launchContext: appDescriptor.native.launchContext}),
201
- core: {
202
- entry: 'runtime/arcane-core.mjs', transport: 'stdio', protocol: 'arcane/1', services: selectedServices,
203
- ...(localAI === undefined ? {} : {localAIRuntimes: bundled.runtimes})
204
- },
205
- client: {
206
- source: 'runtime/arcane-api.js', injection: 'document-created', eventOwnerModuleURL, replayRuntimeState: true,
207
- ...(webKitDocumentLifecycle ? {webKitDocumentLifecycle: true} : {})
208
- },
209
- dependencies,
210
- minimumCoreVersion: request.minimumCoreVersion ?? null,
211
- files: [...files, MANIFEST]
212
- };
213
- await writeOutput(root, MANIFEST, JSON.stringify(manifest, null, 2) + '\n', files, signal);
214
- throwIfAborted(signal);
215
- await emit(onEvent, {type: 'native.payload.completed', appId: appDescriptor.id, outputRoot: root});
216
- throwIfAborted(signal);
217
- return {app: appDescriptor, target: {...target, rootDir: root}, manifest};
72
+ });
73
+ return {...artifact, target: {...artifact.target, rootDir: root}};
218
74
  } catch (error) {
219
- try { await rm(root, {recursive: true, force: true}); }
75
+ try { await rm(sourceRoot, {recursive: true, force: true}); }
220
76
  catch (cleanupError) {
221
- throw new AggregateError([error, cleanupError], `Portable assembly failed; its incomplete output remains at ${root}.`);
77
+ throw new AggregateError([error, cleanupError], `Portable publication failed; its assembly remains at ${sourceRoot}.`);
222
78
  }
223
79
  throw error;
224
80
  }
@@ -0,0 +1,101 @@
1
+ import {lstat, mkdir, readFile, rename, rm, writeFile} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
4
+ import {outputFile} from './portable-layout.mjs';
5
+
6
+ /** Publish only a complete native artifact, retaining the previous app until acceptance. */
7
+ export async function publishNativeOutput({sourceRoot, outputRoot, appId, bundle = false, signal, onPublished}) {
8
+ const parent = path.resolve(outputRoot);
9
+ const source = path.resolve(sourceRoot);
10
+ const name = bundle ? `${appId}.app` : appId;
11
+ // Keep valid application IDs usable on Windows without changing their
12
+ // descriptor identity, including when another platform assembles the app.
13
+ const outputName = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu.test(name) ? `_${name}` : name;
14
+ const destination = outputFile(parent, outputName);
15
+ if (path.dirname(destination) !== parent || source === destination) {
16
+ throw new ArcaneError(ERROR_CODES.usage, 'Native publication requires a separate app-specific output child.');
17
+ }
18
+ const reservation = path.join(parent, `.arcane-publish-${path.basename(destination)}`);
19
+ const backup = path.join(reservation, 'previous');
20
+ const state = {sourceRoot: source, outputRoot: destination, reservation, backup, promoted: false, committed: false};
21
+ throwIfAborted(signal);
22
+ try { await mkdir(reservation); }
23
+ catch (error) {
24
+ if (error.code !== 'EEXIST') throw error;
25
+ throw new ArcaneError(ERROR_CODES.operationFailed,
26
+ `Native output publication is already reserved at ${reservation}. Finish or recover that publication before retrying.`,
27
+ {cause: error, details: state});
28
+ }
29
+ let previous = false;
30
+ let failure;
31
+ let retainReservation = false;
32
+ try {
33
+ // This short filesystem reservation protects only final replacement,
34
+ // including across processes. Assembly and other apps stay independent.
35
+ const acquiredAt = new Date();
36
+ const ttlMs = 5 * 60 * 1000;
37
+ await writeFile(path.join(reservation, 'owner.json'), JSON.stringify({
38
+ pid: process.pid, acquiredAt: acquiredAt.toISOString(), expiresAt: new Date(acquiredAt.getTime() + ttlMs).toISOString(), ttlMs,
39
+ sourceRoot: source, outputRoot: destination, reservation, backup,
40
+ recovery: 'Expiry is informational and never permits automatic takeover. After the owning build has stopped, inspect both outputs. Restore previous if publication did not finish; preserve the current output if it did. Remove this reservation only after recovery.'
41
+ }, null, 2) + '\n', {flag: 'wx'});
42
+ throwIfAborted(signal);
43
+ let existing;
44
+ try { existing = await lstat(destination); }
45
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
46
+ if (existing) {
47
+ // Replacement is scoped to this app's generated artifact, never a
48
+ // cache, source directory, linked folder or unrelated user data.
49
+ if (!existing.isDirectory() || existing.isSymbolicLink()) {
50
+ throw new ArcaneError(ERROR_CODES.usage, `Native output is not a replaceable app directory: ${destination}.`);
51
+ }
52
+ const manifestRoot = bundle ? path.join(destination, 'Contents', 'Resources') : destination;
53
+ const manifest = JSON.parse(await readFile(path.join(manifestRoot, 'arcane-native.json'), 'utf8'));
54
+ if (manifest.app?.id !== appId || !['arcane-portable-native', 'arcane-windows-native', 'arcane-macos-native'].includes(manifest.kind)) {
55
+ throw new ArcaneError(ERROR_CODES.usage, `Native output belongs to a different artifact: ${destination}.`);
56
+ }
57
+ throwIfAborted(signal);
58
+ await rename(destination, backup);
59
+ previous = true;
60
+ }
61
+ throwIfAborted(signal);
62
+ await rename(source, destination);
63
+ state.promoted = true;
64
+ throwIfAborted(signal);
65
+ // Completion observers see the actual final paths. Their ordinary
66
+ // rejection/cancellation still rolls back before the build is accepted.
67
+ await onPublished?.(destination);
68
+ throwIfAborted(signal);
69
+ state.committed = true;
70
+ } catch (error) {
71
+ const failures = [error];
72
+ if (state.promoted) {
73
+ try {
74
+ await rename(destination, source);
75
+ state.promoted = false;
76
+ } catch (restoreError) { failures.push(restoreError); }
77
+ }
78
+ if (previous && !state.promoted) {
79
+ try {
80
+ await rename(backup, destination);
81
+ previous = false;
82
+ } catch (restoreError) { failures.push(restoreError); }
83
+ }
84
+ retainReservation = failures.length > 1;
85
+ failure = retainReservation
86
+ ? new ArcaneError(ERROR_CODES.operationFailed,
87
+ `Native output recovery is incomplete. Preserve and inspect ${destination}, ${source} and ${reservation}.`,
88
+ {cause: new AggregateError(failures, 'Native publication and recovery failed.'), details: state})
89
+ : error;
90
+ }
91
+ if (!retainReservation) {
92
+ try { await rm(reservation, {recursive: true, force: true}); }
93
+ catch (cleanupError) {
94
+ failure = new ArcaneError(ERROR_CODES.operationFailed,
95
+ `Native output ${state.committed ? 'was published' : 'was not accepted'}, and publication cleanup failed at ${reservation}.`,
96
+ {cause: failure ? new AggregateError([failure, cleanupError], 'Native publication cleanup failed.') : cleanupError, details: state});
97
+ }
98
+ }
99
+ if (failure) throw failure;
100
+ return destination;
101
+ }
@@ -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
  import {createWindowsApplicationIcon} from './windows-icon.mjs';
12
14
 
@@ -275,13 +277,13 @@ export function createWindowsNativeProvider({services, hostDirectory} = {}) {
275
277
  const target = windowsTarget(request.targetRequest);
276
278
  const selected = hostDirectory ?? request.toolchain?.hostDirectory;
277
279
  const selectedHost = selected === undefined ? null : await requireHostDirectory(selected);
278
- const artifact = await portable.build({
280
+ const artifact = await assemblePortable({
279
281
  ...request,
280
282
  targetRequest: portableRequest(target),
281
283
  async onEvent(event) {
282
- if (event.type !== 'native.payload.completed') await emit(onEvent, {...event, target: 'windows-x64'});
284
+ await emit(onEvent, {...event, target: 'windows-x64'});
283
285
  }
284
- });
286
+ }, {services});
285
287
  const root = artifact.target.rootDir;
286
288
  try {
287
289
  const host = selectedHost ?? await releasedHostDirectory(path.resolve(request.outputRoot), artifact.manifest.sdk.version, {signal, onEvent});
@@ -304,9 +306,13 @@ export function createWindowsNativeProvider({services, hostDirectory} = {}) {
304
306
  };
305
307
  await writeFile(path.join(root, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, {signal});
306
308
  throwIfAborted(signal);
307
- await emit(onEvent, {type: 'native.payload.completed', target: 'windows-x64', appId: request.appDescriptor.id, outputRoot: root});
308
- throwIfAborted(signal);
309
- return {...artifact, target: {...target, rootDir: root}, manifest};
309
+ const output = await publishNativeOutput({
310
+ sourceRoot: root, outputRoot: path.dirname(root), appId: request.appDescriptor.id, signal,
311
+ async onPublished(outputRoot) {
312
+ await emit(onEvent, {type: 'native.payload.completed', target: 'windows-x64', appId: request.appDescriptor.id, outputRoot});
313
+ }
314
+ });
315
+ return {...artifact, target: {...target, rootDir: output}, manifest};
310
316
  } catch (error) {
311
317
  return removeOwnedDirectory(root, error);
312
318
  }