arcane-os 0.55.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +5 -3
  3. package/browser-runtime/ai/core-local.mjs +306 -0
  4. package/browser-runtime/core/client.mjs +75 -14
  5. package/browser-runtime/core/contracts.mjs +2 -1
  6. package/docs/consumer-updates.md +82 -0
  7. package/docs/publishing.md +41 -18
  8. package/docs/reference/README.md +31 -1
  9. package/docs/reference/availability-and-normalization.md +11 -3
  10. package/docs/reference/cli.md +9 -1
  11. package/docs/reference/core-client.md +45 -2
  12. package/docs/reference/core-native-packaging.md +195 -11
  13. package/docs/reference/core-runtime.md +10 -0
  14. package/docs/reference/inventory/package-api.json +160 -20
  15. package/docs/reference/local-ai.md +197 -0
  16. package/docs/reference/protocols.md +91 -14
  17. package/docs/reference/pwa.md +17 -0
  18. package/docs/reference/runtime-modules.md +6 -5
  19. package/docs/reference/sdk-api.md +165 -26
  20. package/package.json +5 -1
  21. package/runtime/arcane/modules/LocalAIReadiness.js +3 -1
  22. package/schemas/arcane-app.schema.json +64 -0
  23. package/schemas/arcane-package.schema.json +54 -0
  24. package/src/app-descriptor.mjs +35 -2
  25. package/src/app-layout.mjs +6 -2
  26. package/src/cli/main.mjs +8 -2
  27. package/src/core/host.mjs +23 -0
  28. package/src/core/hosts/arcane-core-loader.cjs +2 -0
  29. package/src/core/hosts/linux/arcane_core_process.c +568 -0
  30. package/src/core/hosts/linux/arcane_core_process.h +127 -0
  31. package/src/core/hosts/macos/ArcaneCoreProcess.h +85 -0
  32. package/src/core/hosts/macos/ArcaneCoreProcess.m +357 -0
  33. package/src/core/hosts/sea-launcher.cjs +17 -0
  34. package/src/core/hosts/windows/ArcaneCoreProcess.cs +416 -0
  35. package/src/core/hosts/windows/ArcaneHost.cs +695 -0
  36. package/src/core/hosts/windows/ArcaneHost.manifest +22 -0
  37. package/src/core/hosts/windows/ArcaneLauncher.cs +239 -0
  38. package/src/core/runtime.mjs +15 -3
  39. package/src/core/services/local-ai.mjs +543 -0
  40. package/src/core-classic-source.mjs +2 -2
  41. package/src/dev-server.mjs +7 -0
  42. package/src/import-map.mjs +1 -0
  43. package/src/local-ai/archive.mjs +52 -0
  44. package/src/local-ai/config.mjs +62 -0
  45. package/src/local-ai/dev.mjs +390 -0
  46. package/src/local-ai/discover.mjs +71 -0
  47. package/src/local-ai/http.mjs +106 -0
  48. package/src/local-ai/install.mjs +344 -0
  49. package/src/local-ai/server.mjs +262 -0
  50. package/src/native/portable-layout.mjs +29 -4
  51. package/src/native/portable-provider.mjs +39 -8
  52. package/src/native/windows-provider.mjs +262 -0
  53. package/src/native-provider-loader.mjs +8 -2
  54. package/src/packager/core.mjs +89 -15
  55. package/src/process.mjs +74 -12
  56. package/src/toolchain.mjs +38 -8
@@ -61,7 +61,7 @@ function serviceSelection(services, release) {
61
61
  if (!is.string(service?.module) || !release.files.includes(service.module)) {
62
62
  throw new ArcaneError(ERROR_CODES.prerequisiteMissing, `The selected release does not contain service module ${String(service?.module)}.`);
63
63
  }
64
- return {module: service.module, options: service.options === undefined ? {} : service.options};
64
+ return {...service, options: service.options === undefined ? {} : service.options};
65
65
  });
66
66
  }
67
67
 
@@ -81,7 +81,7 @@ function eventOwnerPath(release) {
81
81
  }
82
82
 
83
83
  /** Package-owned assembly; never imports an OS checkout or application service. */
84
- export function createPortableNativeProvider({services = []} = {}) {
84
+ export function createPortableNativeProvider({services} = {}) {
85
85
  return {
86
86
  protocol: PROTOCOL,
87
87
  async describe() {
@@ -120,7 +120,8 @@ export function createPortableNativeProvider({services = []} = {}) {
120
120
  const {appDescriptor, release, signal, onEvent} = request;
121
121
  throwIfAborted(signal);
122
122
  const target = portableTarget(request.targetRequest);
123
- const selectedServices = serviceSelection(services, release);
123
+ const localAI = appDescriptor.native?.localAI;
124
+ const selectedServices = serviceSelection(services ?? appDescriptor.native?.services ?? [], release);
124
125
  const eventOwnerModuleURL = eventOwnerPath(release);
125
126
  const output = await outputDirectory(request);
126
127
  throwIfAborted(signal);
@@ -130,7 +131,33 @@ export function createPortableNativeProvider({services = []} = {}) {
130
131
  const files = [];
131
132
  try {
132
133
  await emit(onEvent, {type: 'native.payload.started', appId: appDescriptor.id, outputRoot: root});
133
- await copyRelease({...request, releaseRoot: request.appReleaseRoot}, 'app', root, files, signal);
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,
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);
134
161
  const dependencies = [];
135
162
  for (const [index, dependency] of (request.dependencies ?? []).entries()) {
136
163
  const directory = `dependencies/${index}`;
@@ -155,8 +182,9 @@ export function createPortableNativeProvider({services = []} = {}) {
155
182
  appSdk = {name: metadata.name, version: metadata.version, root: destination};
156
183
  }
157
184
  await writeOutput(root, 'package.json', '{"type":"module"}\n', files, signal);
158
- await writeOutput(root, 'runtime/arcane-core.mjs', coreEntrySource(appDescriptor, sdk.version, selectedServices), files, signal);
159
- await writeOutput(root, 'runtime/arcane-api.js', await createCoreClassicSource({eventOwnerModuleURL}), files, signal);
185
+ await writeOutput(root, 'runtime/arcane-core.mjs', coreEntrySource(appDescriptor, sdk.version, selectedServices,
186
+ {localAI, runtimes: bundled.runtimes}), files, signal);
187
+ await writeOutput(root, 'runtime/arcane-api.js', await createCoreClassicSource({eventOwnerModuleURL, replayRuntimeState: true}), files, signal);
160
188
  const manifest = {
161
189
  schemaVersion: 1,
162
190
  kind: 'arcane-portable-native',
@@ -166,8 +194,11 @@ export function createPortableNativeProvider({services = []} = {}) {
166
194
  target,
167
195
  webRoot: 'app',
168
196
  start: release.manifest?.app?.start ?? './index.html',
169
- core: {entry: 'runtime/arcane-core.mjs', transport: 'stdio', protocol: 'arcane/1', services: selectedServices},
170
- client: {source: 'runtime/arcane-api.js', injection: 'document-created', eventOwnerModuleURL},
197
+ core: {
198
+ entry: 'runtime/arcane-core.mjs', transport: 'stdio', protocol: 'arcane/1', services: selectedServices,
199
+ ...(localAI === undefined ? {} : {localAIRuntimes: bundled.runtimes})
200
+ },
201
+ client: {source: 'runtime/arcane-api.js', injection: 'document-created', eventOwnerModuleURL, replayRuntimeState: true},
171
202
  dependencies,
172
203
  minimumCoreVersion: request.minimumCoreVersion ?? null,
173
204
  files: [...files, MANIFEST]
@@ -0,0 +1,262 @@
1
+ import {createWriteStream} from 'node:fs';
2
+ import {mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile} from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {Readable} from 'node:stream';
5
+ import {pipeline} from 'node:stream/promises';
6
+ import Is from 'strong-type';
7
+ import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
8
+ import {runProcess} from '../process.mjs';
9
+ import {createPortableNativeProvider} from './portable-provider.mjs';
10
+ import {writeOutput} from './portable-layout.mjs';
11
+
12
+ const is = new Is(false);
13
+ const PROTOCOL = 'arcane-native-builder/1';
14
+ const MANIFEST = 'arcane-native.json';
15
+ const HOST_ASSET = 'arcane-native-windows-x64.tar.gz';
16
+ const HOST_FILES = [
17
+ 'Arcane.exe',
18
+ 'Arcane.exe.config',
19
+ 'Microsoft.Web.WebView2.Core.dll',
20
+ 'Microsoft.Web.WebView2.WinForms.dll',
21
+ 'WebView2Loader.dll',
22
+ 'runtime/ArcaneCore.exe',
23
+ 'runtime/arcane-core-loader.cjs',
24
+ 'runtime/NODE-LICENSE',
25
+ 'WEBVIEW2-LICENSE',
26
+ 'LICENSE',
27
+ 'COMMERCIAL-LICENSE.md',
28
+ 'NOTICE'
29
+ ];
30
+
31
+ function windowsTarget(value) {
32
+ if (value?.target !== 'windows-x64' || value.platform !== 'windows'
33
+ || value.architecture !== 'x64' || value.format !== 'exe') {
34
+ throw new ArcaneError(ERROR_CODES.targetUnavailable, 'The Windows provider assembles windows-x64 executables.');
35
+ }
36
+ return {...value};
37
+ }
38
+
39
+ function portableRequest(target) {
40
+ return {...target, target: 'portable', format: 'portable'};
41
+ }
42
+
43
+ async function emit(onEvent, event) {
44
+ if (is.function(onEvent)) await onEvent(event);
45
+ }
46
+
47
+ async function hostFilesMissing(directory) {
48
+ const missing = [];
49
+ for (const relative of HOST_FILES) {
50
+ try {
51
+ if (!(await stat(path.join(directory, relative))).isFile()) missing.push(relative);
52
+ } catch (error) {
53
+ if (error.code !== 'ENOENT') throw error;
54
+ missing.push(relative);
55
+ }
56
+ }
57
+ return missing;
58
+ }
59
+
60
+ async function requireHostDirectory(directory) {
61
+ const resolved = await realpath(directory);
62
+ const missing = await hostFilesMissing(resolved);
63
+ if (missing.length) {
64
+ throw new ArcaneError(ERROR_CODES.prerequisiteMissing, 'The selected Windows host assets are incomplete.', {
65
+ details: {hostDirectory: resolved, missing}
66
+ });
67
+ }
68
+ return resolved;
69
+ }
70
+
71
+ async function removeOwnedDirectory(directory, error) {
72
+ try { await rm(directory, {recursive: true, force: true}); }
73
+ catch (cleanupError) {
74
+ throw new AggregateError([error, cleanupError], `Windows preparation failed; incomplete output remains at ${directory}.`);
75
+ }
76
+ throw error;
77
+ }
78
+
79
+ async function releasedHostDirectory(outputRoot, version, {signal, onEvent}) {
80
+ const cache = path.join(outputRoot, '.arcane-native-hosts', version);
81
+ const destination = path.join(cache, 'windows-x64');
82
+ try { return await requireHostDirectory(destination); }
83
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
84
+ throwIfAborted(signal);
85
+ await mkdir(cache, {recursive: true});
86
+ const temporary = await mkdtemp(path.join(cache, 'preparing-windows-x64-'));
87
+ const archive = path.join(temporary, HOST_ASSET);
88
+ const extracted = path.join(temporary, 'host');
89
+ const url = `https://github.com/TheWizardNexus/arcane-os-sdk/releases/download/${version}/${HOST_ASSET}`;
90
+ try {
91
+ await emit(onEvent, {type: 'native.host.download.started', target: 'windows-x64', version, url});
92
+ throwIfAborted(signal);
93
+ const response = await fetch(url, {signal});
94
+ if (!response.ok) {
95
+ throw new ArcaneError(ERROR_CODES.prerequisiteMissing, `The SDK Windows host download returned HTTP ${response.status}.`, {
96
+ details: {url, status: response.status, response: await response.text()}
97
+ });
98
+ }
99
+ if (!response.body) throw new ArcaneError(ERROR_CODES.operationFailed, 'The SDK Windows host download returned no content.', {details: {url}});
100
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(archive, {flags: 'wx'}), {signal});
101
+ throwIfAborted(signal);
102
+ await mkdir(extracted);
103
+ await runProcess('tar', ['-xzf', archive, '-C', extracted], {cwd: temporary, signal, onEvent});
104
+ await requireHostDirectory(extracted);
105
+ throwIfAborted(signal);
106
+ try { await rename(extracted, destination); }
107
+ catch (error) {
108
+ // Another preparation may have finished this exact version while
109
+ // this download was in flight. Preserve its complete asset tree.
110
+ if (!['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error.code)) throw error;
111
+ try { await requireHostDirectory(destination); }
112
+ catch { throw error; }
113
+ }
114
+ await rm(temporary, {recursive: true, force: true});
115
+ await emit(onEvent, {type: 'native.host.download.completed', target: 'windows-x64', version, hostDirectory: destination});
116
+ throwIfAborted(signal);
117
+ return destination;
118
+ } catch (error) {
119
+ return removeOwnedDirectory(temporary, error);
120
+ }
121
+ }
122
+
123
+ function contains(root, candidate) {
124
+ const relative = path.relative(root, candidate);
125
+ return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
126
+ }
127
+
128
+ async function copyHostAssets(source, root, files, signal, relative = '', ancestors = new Set()) {
129
+ throwIfAborted(signal);
130
+ const canonical = await realpath(source);
131
+ const output = await realpath(root);
132
+ if (contains(canonical, output) || contains(output, canonical) || ancestors.has(canonical)) {
133
+ throw new ArcaneError(ERROR_CODES.usage, 'Windows host assets overlap their output or contain a directory cycle.', {
134
+ details: {source, outputRoot: root}
135
+ });
136
+ }
137
+ const parents = new Set([...ancestors, canonical]);
138
+ for (const entry of await readdir(source, {withFileTypes: true})) {
139
+ throwIfAborted(signal);
140
+ const selected = path.join(source, entry.name);
141
+ const destination = relative ? `${relative}/${entry.name}` : entry.name;
142
+ const information = await stat(selected);
143
+ if (information.isDirectory()) {
144
+ await mkdir(path.join(root, destination), {recursive: true});
145
+ await copyHostAssets(selected, root, files, signal, destination, parents);
146
+ } else if (information.isFile()) {
147
+ await writeOutput(root, destination, await readFile(selected, {signal}), files, signal);
148
+ } else {
149
+ throw new ArcaneError(ERROR_CODES.operationFailed, `Cannot copy Windows host asset ${selected}.`);
150
+ }
151
+ }
152
+ }
153
+
154
+ /** Compose app-owned services with the SDK's precompiled Windows host. */
155
+ export function createWindowsNativeProvider({services, hostDirectory} = {}) {
156
+ const portable = createPortableNativeProvider(services === undefined ? {} : {services});
157
+ return {
158
+ protocol: PROTOCOL,
159
+ async describe() {
160
+ return {
161
+ protocol: PROTOCOL,
162
+ id: 'arcane-sdk-windows-x64',
163
+ targets: ['windows-x64'],
164
+ executable: true,
165
+ serviceConfiguration: 'explicit release module factories'
166
+ };
167
+ },
168
+ async doctor({targetRequest, signal, onEvent} = {}) {
169
+ throwIfAborted(signal);
170
+ const target = windowsTarget(targetRequest);
171
+ const core = await portable.doctor({targetRequest: portableRequest(target), signal});
172
+ const missing = hostDirectory === undefined ? [] : await hostFilesMissing(path.resolve(hostDirectory));
173
+ const result = {
174
+ target: 'windows-x64', executable: true, ready: core.ready && missing.length === 0,
175
+ sdkRoot: core.sdkRoot, missing: [...core.missing, ...missing],
176
+ hostDirectory: hostDirectory === undefined ? null : path.resolve(hostDirectory),
177
+ hostSource: hostDirectory === undefined ? 'sdk-release' : 'selected-directory',
178
+ downloadRequired: hostDirectory === undefined,
179
+ runtimePrerequisite: 'Microsoft Edge WebView2 Runtime',
180
+ buildPrerequisite: hostDirectory === undefined ? 'tar' : null
181
+ };
182
+ await emit(onEvent, {type: 'native.doctor.completed', ...result});
183
+ throwIfAborted(signal);
184
+ return result;
185
+ },
186
+ async prepare({targetRequest, signal, onEvent} = {}) {
187
+ throwIfAborted(signal);
188
+ const target = windowsTarget(targetRequest);
189
+ const selection = await portable.prepare({targetRequest: portableRequest(target), signal});
190
+ const selected = hostDirectory === undefined ? undefined : await requireHostDirectory(hostDirectory);
191
+ // Downloads belong to build(), which has the caller's selected
192
+ // outputRoot. Preparation never invents a machine-wide cache.
193
+ const result = {...selection, target, executable: true, ...(selected ? {hostDirectory: selected} : {})};
194
+ await emit(onEvent, {type: 'native.prepared', target: 'windows-x64', sdkVersion: selection.sdkVersion});
195
+ throwIfAborted(signal);
196
+ return result;
197
+ },
198
+ async build(request = {}) {
199
+ const {signal, onEvent} = request;
200
+ throwIfAborted(signal);
201
+ const target = windowsTarget(request.targetRequest);
202
+ const selected = hostDirectory ?? request.toolchain?.hostDirectory;
203
+ const selectedHost = selected === undefined ? null : await requireHostDirectory(selected);
204
+ const artifact = await portable.build({
205
+ ...request,
206
+ targetRequest: portableRequest(target),
207
+ async onEvent(event) {
208
+ if (event.type !== 'native.payload.completed') await emit(onEvent, {...event, target: 'windows-x64'});
209
+ }
210
+ });
211
+ const root = artifact.target.rootDir;
212
+ try {
213
+ const host = selectedHost ?? await releasedHostDirectory(path.resolve(request.outputRoot), artifact.manifest.sdk.version, {signal, onEvent});
214
+ const files = [...artifact.manifest.files];
215
+ await copyHostAssets(host, root, files, signal);
216
+ const manifest = {
217
+ ...artifact.manifest,
218
+ kind: 'arcane-windows-native',
219
+ target,
220
+ host: {
221
+ executable: 'Arcane.exe',
222
+ platform: 'windows', architecture: 'x64', runtime: 'webview2',
223
+ coreExecutable: 'runtime/ArcaneCore.exe',
224
+ coreLoader: 'runtime/arcane-core-loader.cjs'
225
+ },
226
+ files
227
+ };
228
+ await writeFile(path.join(root, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, {signal});
229
+ throwIfAborted(signal);
230
+ await emit(onEvent, {type: 'native.payload.completed', target: 'windows-x64', appId: request.appDescriptor.id, outputRoot: root});
231
+ throwIfAborted(signal);
232
+ return {...artifact, target: {...target, rootDir: root}, manifest};
233
+ } catch (error) {
234
+ return removeOwnedDirectory(root, error);
235
+ }
236
+ },
237
+ async verify({artifact, targetRequest, signal, onEvent} = {}) {
238
+ throwIfAborted(signal);
239
+ const target = windowsTarget(targetRequest);
240
+ const result = await portable.verify({artifact, targetRequest: portableRequest(target), signal});
241
+ await requireHostDirectory(result.outputRoot);
242
+ const verified = {...result, target: 'windows-x64', executable: true};
243
+ await emit(onEvent, {type: 'native.verified', target: 'windows-x64', outputRoot: result.outputRoot});
244
+ throwIfAborted(signal);
245
+ return verified;
246
+ },
247
+ async run({artifact, targetRequest, signal, onEvent} = {}) {
248
+ throwIfAborted(signal);
249
+ windowsTarget(targetRequest);
250
+ if (process.platform !== 'win32' || process.arch !== 'x64') {
251
+ throw new ArcaneError(ERROR_CODES.nativeRunUnsupported, 'Running this Windows x64 artifact requires a Windows x64 host.');
252
+ }
253
+ const root = await requireHostDirectory(artifact?.target?.rootDir);
254
+ return runProcess(path.join(root, 'Arcane.exe'), ['--close-on-stdin-eof'], {
255
+ cwd: root, signal, onEvent, cancellationMode: 'close-input'
256
+ });
257
+ }
258
+ };
259
+ }
260
+
261
+ export const arcaneNativeBuilderProvider = createWindowsNativeProvider();
262
+ export default arcaneNativeBuilderProvider;
@@ -22,6 +22,11 @@ const FIXED_NATIVE_PROVIDER_PATHS={
22
22
  'android-arm64':providerPath('android-native-provider.mjs')
23
23
  };
24
24
 
25
+ const SDK_NATIVE_PROVIDER_PATHS={
26
+ portable:'./native/portable-provider.mjs',
27
+ 'windows-x64':'./native/windows-provider.mjs'
28
+ };
29
+
25
30
  export const ARCANE_NATIVE_PROVIDER_PATHS={
26
31
  portable:[...FIXED_NATIVE_PROVIDER_PATHS.portable],
27
32
  'windows-x64':[...FIXED_NATIVE_PROVIDER_PATHS['windows-x64']],
@@ -133,10 +138,11 @@ export async function loadArcaneNativeProvider(options={}){
133
138
  target,
134
139
  message:`Loading the Arcane ${String(target)} provider.`
135
140
  });
136
- const location=arcaneRoot===undefined&&target==='portable'
141
+ const sdkProvider=SDK_NATIVE_PROVIDER_PATHS[target];
142
+ const location=arcaneRoot===undefined&&sdkProvider
137
143
  ?{
138
144
  canonicalRoot:path.resolve(fileURLToPath(new URL('../',import.meta.url))),
139
- providerPath:fileURLToPath(new URL('./native/portable-provider.mjs',import.meta.url)),
145
+ providerPath:fileURLToPath(new URL(sdkProvider,import.meta.url)),
140
146
  providerSource:'sdk-package'
141
147
  }
142
148
  :await resolveProviderLocation({arcaneRoot,target,inspect,canonicalize});
@@ -13,6 +13,8 @@ import {
13
13
  import path from 'node:path';
14
14
  import {pathToFileURL} from 'node:url';
15
15
  import {appRelativeRoot,resolveAppRoot,resolvePackageOutputRoot} from '../app-layout.mjs';
16
+ import {TARGET_IDS} from '../constants.mjs';
17
+ import {normalizeLocalAIConfig} from '../local-ai/config.mjs';
16
18
  import {readInstalledSdkLayout} from '../sdk-runtime-layout.mjs';
17
19
  import {withWorkspaceOperationLock} from '../workspace-operation-lock.mjs';
18
20
  import {materializeNativeModules} from './native-modules.mjs';
@@ -320,7 +322,7 @@ function normalizeOptionalRecord(value,label){
320
322
  export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.join(appRelativeRoot(rootConfig,appId),APP_CONFIG_NAME)){
321
323
  assertOnlyKeys(value,new Set([
322
324
  'schemaVersion','id','displayName','version','entry','strategy','security',
323
- 'localAIModelPolicy','include','exclude','shared','adapter','pwa','outputDirectory','documents'
325
+ 'localAIModelPolicy','localAI','include','exclude','shared','adapter','pwa','outputDirectory','documents','nativeResources'
324
326
  ]),`${appId}/${APP_CONFIG_NAME}`);
325
327
  if(value.schemaVersion!==1)fail(`${appId}/${APP_CONFIG_NAME}.schemaVersion must be 1.`);
326
328
  if(!is.string(value.id)||value.id!==appId||!APP_ID_PATTERN.test(value.id)){
@@ -335,6 +337,15 @@ export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.j
335
337
  );
336
338
  const include=validatePathList(value.include,`${appId}/${APP_CONFIG_NAME}.include`,{required:true});
337
339
  const exclude=validatePathList(value.exclude??[],`${appId}/${APP_CONFIG_NAME}.exclude`);
340
+ let nativeResources;
341
+ if(value.nativeResources!==undefined){
342
+ const label=`${appId}/${APP_CONFIG_NAME}.nativeResources`;
343
+ assertOnlyKeys(value.nativeResources,new Set(['include','exclude']),label);
344
+ nativeResources={
345
+ include:validatePathList(value.nativeResources.include,`${label}.include`),
346
+ exclude:validatePathList(value.nativeResources.exclude??[],`${label}.exclude`)
347
+ };
348
+ }
338
349
  if(include.some(allowed=>sameOrDescendant(APP_CONFIG_NAME,allowed))){
339
350
  fail(`${appId}/${APP_CONFIG_NAME}.include must not expose the authored package configuration.`);
340
351
  }
@@ -380,8 +391,10 @@ export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.j
380
391
  value.localAIModelPolicy,
381
392
  `${appId}/${APP_CONFIG_NAME}.localAIModelPolicy`
382
393
  )}),
394
+ ...(value.localAI===undefined?{}:{localAI:normalizeLocalAIConfig(value.localAI)}),
383
395
  include,
384
396
  exclude,
397
+ ...(nativeResources===undefined?{}:{nativeResources}),
385
398
  shared:[...value.shared],
386
399
  ...(adapter===undefined?{}:{adapter}),
387
400
  configPath
@@ -423,7 +436,8 @@ async function assertContainedRealPath(root,candidate,label){
423
436
  }
424
437
  }
425
438
 
426
- async function loadContext(requestedWorkspaceRoot,appId,{outputDirectory}={}){
439
+ async function loadContext(requestedWorkspaceRoot,appId,{outputDirectory,target='browser'}={}){
440
+ if(!TARGET_IDS.includes(target))fail(`Unsupported package target: ${String(target)}.`);
427
441
  const workspaceRoot=await realDirectory(normalizeWorkspaceRoot(requestedWorkspaceRoot),'Workspace root');
428
442
  const rootConfigPath=path.join(workspaceRoot,ROOT_CONFIG_NAME);
429
443
  const rootConfig=validateRootConfig(await readJson(rootConfigPath,ROOT_CONFIG_NAME),rootConfigPath);
@@ -436,14 +450,19 @@ async function loadContext(requestedWorkspaceRoot,appId,{outputDirectory}={}){
436
450
  if(outputDirectory!==undefined){
437
451
  config.outputDirectory=normalizeRelativePath(outputDirectory,'package outputDirectory');
438
452
  }
453
+ const outputRoot=resolvePackageOutputRoot(workspaceRoot,rootConfig,config,{target,outputDirectory});
454
+ if(target!=='browser'&&config.nativeResources?.include.length>0){
455
+ config.outputDirectory=path.relative(workspaceRoot,outputRoot).split(path.sep).join('/');
456
+ }
439
457
  return {
440
458
  workspaceRoot,
441
459
  rootConfig,
442
460
  appsRoot,
443
461
  appRoot,
444
462
  appId,
463
+ target,
445
464
  config,
446
- outputRoot:resolvePackageOutputRoot(workspaceRoot,rootConfig,config)
465
+ outputRoot
447
466
  };
448
467
  }
449
468
 
@@ -494,6 +513,11 @@ async function assertPackageOutputLocation(context){
494
513
  for(const selected of context.config.include){
495
514
  assertSelectedInput(context.appRoot,selected,context.config.exclude,context.appId);
496
515
  }
516
+ if(context.target!=='browser'&&context.config.nativeResources){
517
+ for(const selected of context.config.nativeResources.include){
518
+ assertSelectedInput(context.appRoot,selected,context.config.nativeResources.exclude,`${context.appId} native`);
519
+ }
520
+ }
497
521
  for(const sharedId of context.config.shared){
498
522
  for(const route of context.rootConfig.sharedPayloads[sharedId]){
499
523
  const sourceRoot=path.resolve(context.workspaceRoot,route.source);
@@ -526,6 +550,7 @@ async function collectSelectedPath({
526
550
  reject,
527
551
  records,
528
552
  destinations,
553
+ coalescedRecords,
529
554
  signal,
530
555
  label,
531
556
  allowRoot=false
@@ -555,6 +580,7 @@ async function collectSelectedPath({
555
580
  reject,
556
581
  records,
557
582
  destinations,
583
+ coalescedRecords,
558
584
  signal,
559
585
  label
560
586
  });
@@ -567,9 +593,15 @@ async function collectSelectedPath({
567
593
  fail(`${label} overlaps the generated release manifest.`);
568
594
  }
569
595
  const key=pathKey(normalizedDestination);
570
- if(destinations.has(key))fail(`Package destination collision: ${normalizedDestination}.`);
596
+ if(destinations.has(key)){
597
+ const existing=coalescedRecords?.get(key);
598
+ if(existing?.source===absolute&&existing.destination===normalizedDestination)return;
599
+ fail(`Package destination collision: ${normalizedDestination}.`);
600
+ }
571
601
  destinations.add(key);
572
- records.push({source:absolute,destination:normalizedDestination});
602
+ const record={source:absolute,destination:normalizedDestination};
603
+ records.push(record);
604
+ coalescedRecords?.set(key,record);
573
605
  }
574
606
 
575
607
  async function collectPackageRecords(context,{signal}={}){
@@ -613,6 +645,29 @@ async function collectPackageRecords(context,{signal}={}){
613
645
  }
614
646
  }
615
647
  }
648
+ if(context.target!=='browser'&&context.config.nativeResources){
649
+ const nativeRecords=[];
650
+ const coalescedRecords=new Map(records.map(record=>[pathKey(record.destination),record]));
651
+ for(const selected of context.config.nativeResources.include){
652
+ await collectSelectedPath({
653
+ sourceRoot:context.appRoot,
654
+ selected,
655
+ destination:appPackagePath(context,selected),
656
+ excludes:context.config.nativeResources.exclude,
657
+ reject:isAppSourceForbidden,
658
+ records:nativeRecords,
659
+ destinations,
660
+ coalescedRecords,
661
+ signal,
662
+ label:`${context.appId} nativeResources`
663
+ });
664
+ }
665
+ for(const record of nativeRecords){
666
+ record.appRelativePath=path.relative(context.appRoot,record.source).split(path.sep).join('/');
667
+ record.nativeOnly=true;
668
+ records.push(record);
669
+ }
670
+ }
616
671
  records.sort((left,right)=>compareText(left.destination,right.destination));
617
672
  if(!records.some(record=>pathKey(record.destination)===pathKey(appPackagePath(context, context.config.entry)))){
618
673
  fail(`Package entry is missing from the selected files: ${context.config.entry}.`);
@@ -671,7 +726,7 @@ async function optionalDescriptor(context){
671
726
 
672
727
  async function inspectContext(context,{signal,records}={}){
673
728
  const selectedRecords=records??await collectPackageRecords(context,{signal});
674
- const documents=await browserDocuments(selectedRecords,context.config.entry,context.config.documents);
729
+ const documents=await browserDocuments(selectedRecords.filter(record=>!record.nativeOnly),context.config.entry,context.config.documents);
675
730
  return {
676
731
  appId:context.appId,
677
732
  displayName:context.config.displayName,
@@ -683,11 +738,13 @@ async function inspectContext(context,{signal,records}={}){
683
738
  ...(context.config.pwa===undefined?{}:{pwa:copyJson(context.config.pwa)}),
684
739
  include:[...context.config.include],
685
740
  exclude:[...context.config.exclude],
741
+ ...(context.config.nativeResources===undefined?{}:{nativeResources:copyJson(context.config.nativeResources)}),
686
742
  shared:[...context.config.shared],
687
743
  ...(context.config.security===undefined?{}:{security:copyJson(context.config.security)}),
688
744
  ...(context.config.localAIModelPolicy===undefined?{}:{
689
745
  localAIModelPolicy:copyJson(context.config.localAIModelPolicy)
690
746
  }),
747
+ ...(context.config.localAI===undefined?{}:{localAI:copyJson(context.config.localAI)}),
691
748
  ...(context.config.adapter===undefined?{}:{adapter:context.config.adapter}),
692
749
  descriptor:await optionalDescriptor(context),
693
750
  browserDocuments:documents,
@@ -724,9 +781,9 @@ export async function discoverApps({workspaceRoot:requestedWorkspaceRoot}={}){
724
781
  return apps;
725
782
  }
726
783
 
727
- export async function inspectApp({workspaceRoot,appId,signal}={}){
784
+ export async function inspectApp({workspaceRoot,appId,signal,target,outputDirectory}={}){
728
785
  throwIfAborted(signal);
729
- const context=await loadContext(workspaceRoot,appId);
786
+ const context=await loadContext(workspaceRoot,appId,{target,outputDirectory});
730
787
  return inspectContext(context,{signal});
731
788
  }
732
789
 
@@ -817,7 +874,8 @@ function releaseManifest(context,files,pwaArtifacts,rootDocument){
817
874
  ...(context.config.security===undefined?{}:{security:copyJson(context.config.security)}),
818
875
  ...(context.config.localAIModelPolicy===undefined?{}:{
819
876
  localAIModelPolicy:copyJson(context.config.localAIModelPolicy)
820
- })
877
+ }),
878
+ ...(context.config.localAI===undefined?{}:{localAI:copyJson(context.config.localAI)})
821
879
  },
822
880
  files:[...files]
823
881
  };
@@ -846,7 +904,8 @@ async function replaceDirectory(stagingRoot,outputRoot){
846
904
  }
847
905
 
848
906
  async function packageWithContext(context,options={}){
849
- const {signal,onEvent,browserPwa=true,moduleFormat='import-map'}=options;
907
+ const {signal,onEvent,moduleFormat='import-map',browserPwa:requestedBrowserPwa=true}=options;
908
+ const browserPwa=context.target==='browser'&&requestedBrowserPwa;
850
909
  if(!['import-map','native'].includes(moduleFormat)){
851
910
  throw new TypeError('packageApp moduleFormat must be "import-map" or "native".');
852
911
  }
@@ -854,7 +913,9 @@ async function packageWithContext(context,options={}){
854
913
  const appPath=appRelativeRoot(context.rootConfig,context.appId);
855
914
  const entryPath=appPackagePath(context,context.config.entry);
856
915
  const records=await collectPackageRecords(context,{signal});
857
- const rootDocument = records.some(
916
+ const browserRecords=records.filter(record=>!record.nativeOnly);
917
+ const nativeRecords=records.filter(record=>record.nativeOnly);
918
+ const rootDocument = browserRecords.some(
858
919
  function selectedRootIndex(record) {
859
920
  return record.destination === 'index.html';
860
921
  }
@@ -901,7 +962,7 @@ async function packageWithContext(context,options={}){
901
962
  let promoted=false;
902
963
  try{
903
964
  async function copyBase() {
904
- await copyRecords(records,stagingRoot,{signal,onEvent});
965
+ await copyRecords(browserRecords,stagingRoot,{signal,onEvent});
905
966
  if (!rootDocument) {
906
967
  await writePackageLauncher(context, stagingRoot);
907
968
  }
@@ -913,6 +974,8 @@ async function packageWithContext(context,options={}){
913
974
  workspaceRoot:context.workspaceRoot,
914
975
  appRoot:context.appRoot,
915
976
  outputRoot:stagingRoot,
977
+ target:context.target,
978
+ browserPwa,
916
979
  copyBase,
917
980
  signal,
918
981
  onEvent
@@ -971,7 +1034,7 @@ async function packageWithContext(context,options={}){
971
1034
  stagingRoot,
972
1035
  files,
973
1036
  documents:inspected.browserDocuments.map(function documentPath(document){return document.packagePath;}),
974
- sharedFiles:records.filter(function sharedRecord(record){return record.appRelativePath===undefined;})
1037
+ sharedFiles:browserRecords.filter(function sharedRecord(record){return record.appRelativePath===undefined;})
975
1038
  .map(function sharedPath(record){return record.destination;}),
976
1039
  signal,
977
1040
  onEvent
@@ -1007,8 +1070,8 @@ async function packageWithContext(context,options={}){
1007
1070
  const documentUrl=entryDocument?.bases[0]?.href
1008
1071
  ?new URL(entryDocument.bases[0].href,entryUrl):entryUrl;
1009
1072
  const pending=[{file:entryPath,documentUrl}];
1010
- const sharedFiles=new Set(records.filter(record=>record.appRelativePath===undefined).map(record=>record.destination));
1011
- const applicationFiles=new Set(records.filter(function applicationRecord(record){
1073
+ const sharedFiles=new Set(browserRecords.filter(record=>record.appRelativePath===undefined).map(record=>record.destination));
1074
+ const applicationFiles=new Set(browserRecords.filter(function applicationRecord(record){
1012
1075
  return record.appRelativePath!==undefined;
1013
1076
  }).map(function applicationDestination(record){return record.destination;}));
1014
1077
  for(const file of files){
@@ -1132,6 +1195,17 @@ async function packageWithContext(context,options={}){
1132
1195
  }
1133
1196
  files.sort(compareText);
1134
1197
  }
1198
+ // Native resources bypass browser rewriting, module projection and PWA generation.
1199
+ // The app's browser selection remains authoritative wherever both select a file.
1200
+ const finalDestinations=new Set(files.map(pathKey));
1201
+ for(const record of nativeRecords){
1202
+ if(finalDestinations.has(pathKey(record.destination))){
1203
+ fail(`Native resource overlaps generated package content: ${record.destination}.`);
1204
+ }
1205
+ }
1206
+ await copyRecords(nativeRecords,stagingRoot,{signal,onEvent});
1207
+ files.push(...nativeRecords.map(record=>record.destination));
1208
+ files.sort(compareText);
1135
1209
  const manifest=releaseManifest(context,files,pwaArtifacts,rootDocument);
1136
1210
  await writeFile(
1137
1211
  path.join(stagingRoot,RELEASE_MANIFEST_NAME),