arcane-os 0.71.2 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.72.0
4
+
5
+ - Add optional `native.launchContext` defaults for ordinary packaged launch and
6
+ public `resolveNativeLaunchContext()` through `arcane-os/core/host`. Native
7
+ applications and external MCP can select the same app-owned state, workspace,
8
+ shared endpoint and log without reconstructing platform paths.
9
+ - Extend `readCoreLaunchContext()` with explicit defaults and opt-in native
10
+ resolution. Preserve complete explicit launch records, selected workspace
11
+ values, ordinary stdio ownership and the existing no-option entry path.
12
+ - Forward packaged launch defaults through Windows and macOS launchers while
13
+ preserving window, profile, service and shared-host lifecycle behavior. No
14
+ stored-data migration or dependency change is introduced.
15
+
3
16
  ## 0.71.2
4
17
 
5
18
  - Preserve complete browser Kokoro synthesis input through the selected runtime's
package/README.md CHANGED
@@ -20,7 +20,7 @@ selected SDK projection or an explicit live SDK source mount. Both profiles
20
20
  share the theme, packaging, event, cancellation,
21
21
  and browser run contracts while retaining their selected application layout.
22
22
 
23
- This checkout defines the `0.71.2` SDK contract. Development applications declare
23
+ This checkout defines the `0.72.0` SDK contract. Development applications declare
24
24
  `arcane-os` as `latest` and retain their lockfile. Exact SDK pins begin when Roshi
25
25
  designates a production build. Follow [Consumer updates](docs/consumer-updates.md)
26
26
  to refresh and compare the installed SDK and lockfile with the published channel;
@@ -43,6 +43,7 @@ alone does not make bare module names resolve in a browser.
43
43
  | Use the `arcane` command | [CLI reference](cli.md) |
44
44
  | Install an app and cache selected resources offline | [Progressive web applications](pwa.md) |
45
45
  | Disable source PWA generation or explicitly retire confirmed generated output | [Source PWA retirement](pwa.md#disable-source-pwa-generation-and-retire-generated-output) |
46
+ | Retire the prior worker after explicitly disabling standalone source PWA | [Disabled-worker browser lifecycle](pwa.md#disabled-worker-retirement-on-the-source-server) |
46
47
  | Generate named browser imports or inspect the selected physical runtime | [`arcane import-map`](cli.md#arcane-import-map) and [browser runtime delivery](protocols.md#browser-runtime-delivery) |
47
48
  | Connect an application through the shared WebSocket transport | [Shared WebSocket clients](websocket-client.md) |
48
49
  | Choose browser, native, cloud, or cross-host behavior | [Availability and normalization](availability-and-normalization.md) |
@@ -210,6 +210,64 @@ details and [the client contract](core-client.md) for native bridge integration.
210
210
 
211
211
  ## Launch-time locations
212
212
 
213
+ An application can select defaults for ordinary double-click launch in its
214
+ descriptor, without a replacement launcher or a required command-line file:
215
+
216
+ ```json
217
+ {
218
+ "native": {
219
+ "launchContext": {
220
+ "sharedHost": {},
221
+ "dispatch": {"destination": "Moon Cheese Receiving Dock"}
222
+ }
223
+ }
224
+ }
225
+ ```
226
+
227
+ This is the `native` fragment of the application's complete descriptor. The
228
+ optional `native.launchContext` object is copied intact to the portable
229
+ manifest and generated Core entry. Its presence explicitly selects native
230
+ location defaults. `sharedHost:{}` opts into the shared Core endpoint and log;
231
+ omitting `sharedHost` retains stdio-owned Core lifetime. Omitting
232
+ `native.launchContext` retains the previous generated-entry behavior entirely.
233
+ Windows and macOS launchers use the same packaged defaults for native state
234
+ selection, so an ordinary executable launch and external MCP can select the
235
+ same app-owned Core.
236
+
237
+ `resolveNativeLaunchContext({appId,context={}})` from `arcane-os/core/host` is
238
+ the public, synchronous location owner for native applications and external MCP.
239
+ It returns a new complete context and fills only omitted fields:
240
+
241
+ | Field | SDK default |
242
+ |---|---|
243
+ | `stateRoot` on Windows | `%LOCALAPPDATA%/Arcane/<appId>`; standard home `AppData/Local` fallback when the environment value is absent |
244
+ | `stateRoot` on macOS | `~/Library/Application Support/Arcane/<appId>` |
245
+ | `stateRoot` on Linux | Absolute `$XDG_DATA_HOME/Arcane/<appId>`, otherwise `~/.local/share/Arcane/<appId>` |
246
+ | `workspaceRoot` | `<resolved stateRoot>/Workspace` |
247
+ | `sharedHost.endpoint`, only when `sharedHost` is selected | Windows named pipe `\\.\pipe\Arcane-` followed by the URL-encoded complete resolved state path; Linux/macOS socket `<resolved stateRoot>/core.sock` |
248
+ | `sharedHost.logFile`, only when `sharedHost` is selected | `<resolved stateRoot>/Core.log` |
249
+
250
+ Windows/macOS roots retain the existing native host locations. Linux is the
251
+ portable Node host convention; an external composing host supplies its actual
252
+ state root when it selects another location. Android and other host adaptations
253
+ must supply `stateRoot` explicitly and own native IPC/process adaptation.
254
+ The resolver never reads saved preferences, creates directories, changes the
255
+ environment, starts services, or migrates existing data. The generated opted-in
256
+ shared entry creates its selected state directory because its socket/log need
257
+ that parent; application services continue to own workspace I/O. Custom
258
+ endpoint/log parent directories retain the existing shared-host requirements.
259
+
260
+ Explicit `stateRoot`, `workspaceRoot`, endpoint, log filename and additional
261
+ fields remain authored values; the resolver derives omitted paths from the
262
+ absolute selected state location. Relative explicit paths retain the caller's
263
+ working-directory meaning, so UI and MCP callers must select the same location.
264
+ No hashing, app-local OS path composition, directory scanning, or new repository
265
+ location rule is involved. `resolveArcaneDataPaths()` continues to own its
266
+ separate `ArcaneData/Repos` contract. A selected native context needs a nonempty
267
+ string `appId` and state directory; a selected `sharedHost` must be an object.
268
+ Malformed resolver inputs report `TypeError`; ordinary OS endpoint requirements
269
+ remain at the IPC operation.
270
+
213
271
  For one app-owned Core shared by the native window and a separate MCP process,
214
272
  the launch context may explicitly select `sharedHost:{endpoint,logFile}`. The
215
273
  generated native child then connects through a stdio bridge, starting an
@@ -224,27 +282,36 @@ or `null`; the shared host replays that current listener to a newly attached
224
282
  macOS bridge, without restarting the server or retaining historical events.
225
283
 
226
284
  The generated Core entry accepts `--arcane-launch-config <path>`. The public
227
- `readCoreLaunchContext({argv=process.argv.slice(2)}={})` function in
228
- `arcane-os/core/host` reads that explicitly selected JSON object. A native host
285
+ `readCoreLaunchContext({argv=process.argv.slice(2),defaults={},appId}={})`
286
+ function in `arcane-os/core/host` reads that explicitly selected JSON object.
287
+ Precedence is packaged `defaults`, then the native host's actual state-root
288
+ argument, then every complete top-level field from the explicit JSON object.
289
+ Nested records replace their default field as a whole; they are not deep-merged.
290
+ An `appId` explicitly opts the result into `resolveNativeLaunchContext`; the
291
+ generated entry supplies it only when `native.launchContext` is present.
292
+ A native host
229
293
  can separately supply `--arcane-host-state-root <directory>` for the reader's
230
294
  `stateRoot` default. Every field in the explicit JSON object takes precedence
231
295
  unchanged, including an explicitly present `stateRoot` with a relative, null or
232
- other application-owned value. With neither argument the reader returns `{}`.
296
+ other application-owned value on the ordinary non-resolving reader. With neither
297
+ argument nor defaults/appId the reader still returns `{}`.
233
298
  Missing files, missing argument values and malformed JSON report their actual
234
299
  errors; the reader does not search for configuration, resolve or validate the
235
- state directory, rewrite the file or change the process environment.
300
+ state directory, rewrite the file or change the process environment. The
301
+ explicitly opted-in resolver supplies its documented defaults after this merge.
236
302
 
237
303
  The generated entry supplies an artifact-derived `appRoot` and then applies the
238
304
  complete explicit launch context. Every service factory receives that same
239
305
  object as its second argument, preserving its authored first argument. A
240
306
  launcher can supply the user's selected `workspaceRoot` and writable
241
307
  `stateRoot` at launch instead of embedding this machine's paths in an app
242
- package. The host owns those selections; the SDK does not choose a product's
243
- workspace, namespace, saved preferences or models. JSON values and additional
308
+ package. Explicit product selections take precedence over the opted-in SDK
309
+ location defaults. The SDK chooses no saved preference filename or model. JSON values and additional
244
310
  application-owned fields remain unchanged, including an app-selected
245
311
  `preferencesFile`. The state directory supplies no preference filename and
246
312
  triggers no stored-data discovery or migration. Linux and other composing hosts
247
- may pass the same separate argument; the reader chooses no platform directory.
313
+ may pass the same separate argument; the ordinary reader chooses no platform
314
+ directory until native resolution is explicitly selected.
248
315
 
249
316
  ## Selected local-AI runtimes
250
317
 
@@ -146,8 +146,46 @@ events may change; applications own those domain methods and state.
146
146
 
147
147
  ## Native launch and platforms
148
148
 
149
- The generated portable Core entry keeps ordinary stdio behavior unless the
150
- explicit launch context contains:
149
+ The generated portable Core entry keeps ordinary stdio behavior unless its
150
+ selected launch context contains `sharedHost`. An application can opt in for
151
+ ordinary packaged executable launch with `native.launchContext:{sharedHost:{}}`
152
+ in its complete descriptor. The SDK supplies the app's platform state root,
153
+ workspace, endpoint and diagnostic log through `resolveNativeLaunchContext`.
154
+ The same public owner is available to external MCP:
155
+
156
+ ```js
157
+ import {readCoreLaunchContext, connectSharedCoreHost} from 'arcane-os/core/host';
158
+
159
+ const context = await readCoreLaunchContext({
160
+ appId: appDescriptor.id,
161
+ defaults: appDescriptor.native.launchContext
162
+ });
163
+ const connection = await connectSharedCoreHost({
164
+ endpoint: context.sharedHost.endpoint
165
+ });
166
+ // Invoke the application's actual services through connection.client.
167
+ // Disconnect this MCP client without shutting down the application's host.
168
+ await connection.close();
169
+ ```
170
+
171
+ This example connects to a running native/headless host. The existing `start`
172
+ option can start the packaged Core entry explicitly when independent MCP-first
173
+ startup is needed; the entry applies those same packaged defaults. Pass the
174
+ same explicit `--arcane-launch-config` file to both entries for user-selected
175
+ locations. A caller that already owns a complete context can use the pure
176
+ `resolveNativeLaunchContext({appId,context})` directly instead of reading argv.
177
+ For MCP-first startup, prepare the returned `context.stateRoot` with Node's
178
+ `mkdir(context.stateRoot,{recursive:true})` before selecting `start`, because
179
+ the parent's startup diagnostic log opens before the headless child runs.
180
+ This uses the resolved directory directly, without duplicating platform path
181
+ rules. Pass `context.sharedHost.logFile` to that existing `start` record and
182
+ the same launch file to the packaged Core command. Custom endpoint/log parent
183
+ directories remain caller-selected.
184
+ See [launch-time locations](core-native-packaging.md#launch-time-locations) for
185
+ the exact platform defaults, precedence, location override and directory rules.
186
+ No app must reconstruct OS directory rules.
187
+
188
+ An explicit launch file can also select the existing full endpoint contract:
151
189
 
152
190
  ```json
153
191
  {
@@ -163,8 +201,9 @@ option, the native Core child is a stdio-to-shared-Core bridge. It connects or
163
201
  launches the same entry with `--arcane-core-headless`; the headless entry claims
164
202
  the endpoint before importing services. Closing the native window ends its
165
203
  bridge, while the independent Core host remains available. Service options and
166
- the complete launch context retain their existing values. There is no implicit
167
- shared mode or new application descriptor field.
204
+ the complete launch context retain their existing values. Shared mode remains
205
+ explicit: the new descriptor option selects defaults only for that application,
206
+ and the existing no-option stdio path is unchanged.
168
207
 
169
208
  `startSharedCoreBridge({endpoint,start,input=process.stdin,
170
209
  output=process.stdout,signal,onError})` is also public. It returns
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "sdkVersion": "0.71.0",
3
+ "sdkVersion": "0.71.1",
4
4
  "environment": {
5
5
  "runtime": "Node.js for Node entrypoints; browser for browser-only entrypoints",
6
6
  "minimumVersion": "22.23.2 for Node entrypoints",
@@ -1185,7 +1185,7 @@
1185
1185
  ],
1186
1186
  "primaryImport": "arcane-os",
1187
1187
  "group": "Headless toolchain operations",
1188
- "summary": "Starts one owned browser development server for the selected application.",
1188
+ "summary": "Starts one owned browser development server for the selected application, including native worker retirement when standalone source PWA is explicitly disabled and its root worker is absent.",
1189
1189
  "availability": "Node; selected operation may produce browser or native output",
1190
1190
  "protocol": "Shared headless operation API + arcane-cli-events/1",
1191
1191
  "normalization": "SDK-normalized inputs, errors, events, and documented result"
@@ -2446,7 +2446,7 @@
2446
2446
  ],
2447
2447
  "primaryImport": "arcane-os",
2448
2448
  "group": "Workspace, doctor, repository, and server",
2449
- "summary": "Starts an owned source or packaged browser server with complete selected routes, HTTPS by default, explicit HTTP selection, listener readiness and shutdown.",
2449
+ "summary": "Starts an owned source or packaged browser server with complete selected routes, HTTPS by default, explicit HTTP selection, listener readiness and shutdown; explicitly disabled standalone source PWA retires an absent root worker through its native lifecycle.",
2450
2450
  "availability": "Node control plane; browser data plane",
2451
2451
  "protocol": "Node ESM",
2452
2452
  "normalization": "SDK-normalized inputs, errors, events, and documented result"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.71.2",
3
+ "version": "0.72.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -132,6 +132,10 @@
132
132
  }
133
133
  },
134
134
  "localAI": { "$ref": "#/$defs/localAI" },
135
+ "launchContext": {
136
+ "type": "object",
137
+ "description": "Optional complete native launch defaults. Presence opts into SDK per-app native locations; sharedHost:{} explicitly selects the shared endpoint. Explicit launch JSON fields take precedence unchanged."
138
+ },
135
139
  "window": {
136
140
  "type": "object",
137
141
  "additionalProperties": false,
@@ -254,7 +254,7 @@ function validateNativeWindow(value){
254
254
  }
255
255
 
256
256
  function validateNative(value,appId){
257
- assertOnlyKeys(value,new Set(['type','icon','order','bundledApps','documentCatalog','localAI','services','window']),'descriptor.native');
257
+ assertOnlyKeys(value,new Set(['type','icon','order','bundledApps','documentCatalog','localAI','services','window','launchContext']),'descriptor.native');
258
258
  if(!NATIVE_TYPES.has(value.type))fail('descriptor.native.type is unsupported.');
259
259
  const icon=value.icon===null?null:normalizeRelativePath(value.icon,'descriptor.native.icon');
260
260
  if(!is.integer(value.order)||value.order<0){
@@ -265,6 +265,9 @@ function validateNative(value,appId){
265
265
  });
266
266
  const documentCatalog=validateDocumentCatalog(value.documentCatalog);
267
267
  const window=validateNativeWindow(value.window);
268
+ if(value.launchContext!==undefined&&!isObject(value.launchContext)){
269
+ fail('descriptor.native.launchContext must be an object.');
270
+ }
268
271
  let services;
269
272
  if(value.services!==undefined){
270
273
  if(!is.array(value.services))fail('descriptor.native.services must be an array.');
@@ -279,6 +282,7 @@ function validateNative(value,appId){
279
282
  order:value.order,
280
283
  bundledApps,
281
284
  ...(window===undefined?{}:{window}),
285
+ ...(value.launchContext===undefined?{}:{launchContext:value.launchContext}),
282
286
  ...(services===undefined?{}:{services}),
283
287
  ...(value.localAI===undefined?{}:{localAI:normalizeLocalAIConfig(value.localAI)}),
284
288
  ...(documentCatalog?{documentCatalog}:{})
@@ -416,6 +420,7 @@ export function projectNativeDescriptor(descriptor,{source}={}){
416
420
  ...(value.native.documentCatalog?{documentCatalog:value.native.documentCatalog}:{}),
417
421
  ...(value.native.services===undefined?{}:{services:value.native.services}),
418
422
  ...(value.native.window===undefined?{}:{window:value.native.window}),
423
+ ...(value.native.launchContext===undefined?{}:{launchContext:value.native.launchContext}),
419
424
  ...(value.native.localAI===undefined?{}:{localAI:value.native.localAI}),
420
425
  include:[...value.package.include]
421
426
  };
@@ -462,6 +467,7 @@ function synthesizedDescriptor(packageManifest,nativeDescriptor){
462
467
  bundledApps:[...(native.bundledApps??[])],
463
468
  ...(native.services===undefined?{}:{services:native.services}),
464
469
  ...(native.window===undefined?{}:{window:native.window}),
470
+ ...(native.launchContext===undefined?{}:{launchContext:native.launchContext}),
465
471
  ...((packageManifest.localAI??native.localAI)===undefined?{}:{localAI:packageManifest.localAI??native.localAI}),
466
472
  ...(native.documentCatalog?{documentCatalog:native.documentCatalog}:{})
467
473
  },
package/src/core/host.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import {readFile} from 'node:fs/promises';
2
+ import {homedir} from 'node:os';
3
+ import path from 'node:path';
2
4
  import Is from 'strong-type';
3
5
  import {createCoreRuntime} from './runtime.mjs';
4
6
  import {startCoreStdio} from './stdio.mjs';
@@ -7,32 +9,90 @@ export {startSharedCoreHost, connectSharedCoreHost, runSharedCoreHost, startShar
7
9
 
8
10
  const is = new Is(false);
9
11
 
12
+ /** Resolve one app's native locations without creating or migrating data. */
13
+ export function resolveNativeLaunchContext({appId, context = {}} = {}) {
14
+ if (!is.string(appId) || !appId) throw new TypeError('Native launch resolution requires an appId.');
15
+ if (context === null || !is.object(context) || is.array(context)) {
16
+ throw new TypeError('Native launch context must be an object.');
17
+ }
18
+ let stateRoot = context.stateRoot;
19
+ if (stateRoot === undefined) {
20
+ const home = homedir();
21
+ switch (process.platform) {
22
+ case 'win32':
23
+ stateRoot = path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'Arcane', appId);
24
+ break;
25
+ case 'darwin':
26
+ stateRoot = path.join(home, 'Library', 'Application Support', 'Arcane', appId);
27
+ break;
28
+ case 'linux': {
29
+ const xdg = process.env.XDG_DATA_HOME;
30
+ stateRoot = path.join(xdg && path.isAbsolute(xdg) ? xdg : path.join(home, '.local', 'share'), 'Arcane', appId);
31
+ break;
32
+ }
33
+ default:
34
+ throw new TypeError('This native host must supply its persistent stateRoot.');
35
+ }
36
+ }
37
+ if (!is.string(stateRoot) || !stateRoot) throw new TypeError('Native stateRoot must be a nonempty directory path.');
38
+ const root = path.resolve(stateRoot);
39
+ const resolved = {
40
+ ...context,
41
+ stateRoot,
42
+ workspaceRoot: context.workspaceRoot === undefined ? path.join(root, 'Workspace') : context.workspaceRoot
43
+ };
44
+ if (context.sharedHost !== undefined) {
45
+ if (context.sharedHost === null || !is.object(context.sharedHost) || is.array(context.sharedHost)) {
46
+ throw new TypeError('Native sharedHost must be an object.');
47
+ }
48
+ // Encode the complete selected Windows path as a pipe-name component;
49
+ // Unix IPC uses an ordinary file in that same persistent app directory.
50
+ const endpoint = process.platform === 'win32'
51
+ ? '\\\\.\\pipe\\Arcane-' + encodeURIComponent(root)
52
+ : path.join(root, 'core.sock');
53
+ resolved.sharedHost = {
54
+ ...context.sharedHost,
55
+ endpoint: context.sharedHost.endpoint === undefined ? endpoint : context.sharedHost.endpoint,
56
+ logFile: context.sharedHost.logFile === undefined ? path.join(root, 'Core.log') : context.sharedHost.logFile
57
+ };
58
+ }
59
+ return resolved;
60
+ }
61
+
10
62
  /**
11
- * The host's separate state-root argument supplies a launch-context default.
63
+ * Packaged defaults precede the host's actual state-root argument.
12
64
  * Every field in an explicitly selected launch JSON object takes precedence
13
65
  * unchanged; field meanings belong to the launch owner and individual services.
66
+ * appId explicitly selects native location resolution; ordinary calls keep the
67
+ * existing read-only launch-file contract without choosing any locations.
14
68
  */
15
- export async function readCoreLaunchContext({argv = process.argv.slice(2)} = {}) {
69
+ export async function readCoreLaunchContext({argv = process.argv.slice(2), defaults = {}, appId} = {}) {
70
+ if (defaults === null || !is.object(defaults) || is.array(defaults)) {
71
+ throw new TypeError('Core launch defaults must be an object.');
72
+ }
16
73
  const stateFlag = argv.indexOf('--arcane-host-state-root');
17
- const defaults = {};
74
+ const context = {...defaults};
18
75
  if (stateFlag !== -1) {
19
76
  const stateRoot = argv[stateFlag + 1];
20
77
  if (stateRoot === undefined || stateRoot === '') {
21
78
  throw new TypeError('--arcane-host-state-root requires a directory.');
22
79
  }
23
- defaults.stateRoot = stateRoot;
80
+ context.stateRoot = stateRoot;
24
81
  }
25
82
  const flag = argv.indexOf('--arcane-launch-config');
26
- if (flag === -1) return defaults;
27
- const filename = argv[flag + 1];
28
- if (filename === undefined || filename === '') {
29
- throw new TypeError('--arcane-launch-config requires a filename.');
30
- }
31
- const context = JSON.parse(await readFile(filename, 'utf8'));
32
- if (context === null || !is.object(context) || is.array(context)) {
33
- throw new TypeError('Core launch configuration must be a JSON object.');
83
+ let selected = context;
84
+ if (flag !== -1) {
85
+ const filename = argv[flag + 1];
86
+ if (filename === undefined || filename === '') {
87
+ throw new TypeError('--arcane-launch-config requires a filename.');
88
+ }
89
+ const explicit = JSON.parse(await readFile(filename, 'utf8'));
90
+ if (explicit === null || !is.object(explicit) || is.array(explicit)) {
91
+ throw new TypeError('Core launch configuration must be a JSON object.');
92
+ }
93
+ selected = {...context, ...explicit};
34
94
  }
35
- return {...defaults, ...context};
95
+ return appId === undefined ? selected : resolveNativeLaunchContext({appId, context: selected});
36
96
  }
37
97
 
38
98
  /** Compose app services with the shared dispatcher and draining transport. */
@@ -231,14 +231,25 @@ typedef NS_ENUM(NSUInteger, ArcaneDiagnosticStream) {
231
231
  NSString *appID = ArcaneRequiredString(app, @"id", error);
232
232
  if (!appID) return NO;
233
233
  _title = [app[@"displayName"] isKindOfClass:NSString.class] ? app[@"displayName"] : appID;
234
+ id defaults = manifest[@"launchContext"];
235
+ if (defaults && ![defaults isKindOfClass:NSDictionary.class]) {
236
+ *error = ArcaneLauncherError(@"CORE_LAUNCH_MANIFEST_INVALID", @"The native launchContext must be an object.", defaults);
237
+ return NO;
238
+ }
239
+ NSMutableDictionary *launchContext = defaults ? [defaults mutableCopy] : [NSMutableDictionary dictionary];
234
240
  if (_launchFilename) {
235
- _launchContext = ArcaneReadRecord([NSURL fileURLWithPath:_launchFilename], error);
236
- if (!_launchContext) return NO;
241
+ NSDictionary *explicitContext = ArcaneReadRecord([NSURL fileURLWithPath:_launchFilename], error);
242
+ if (!explicitContext) return NO;
243
+ [launchContext addEntriesFromDictionary:explicitContext];
237
244
  }
245
+ _launchContext = launchContext;
246
+ // The opted-in Node resolver uses HOME when supplied; retain the previous
247
+ // native default unchanged for applications without launchContext.
248
+ NSString *nativeHome = defaults ? (NSProcessInfo.processInfo.environment[@"HOME"] ?: NSHomeDirectory()) : NSHomeDirectory();
238
249
  id selectedState = _launchContext[@"stateRoot"];
239
250
  _stateRoot = [selectedState isKindOfClass:NSString.class]
240
251
  ? ArcaneAbsolutePath(selectedState, _workingDirectory)
241
- : [[NSHomeDirectory() stringByAppendingPathComponent:@"Library/Application Support/Arcane"] stringByAppendingPathComponent:appID];
252
+ : [[nativeHome stringByAppendingPathComponent:@"Library/Application Support/Arcane"] stringByAppendingPathComponent:appID];
242
253
  [self scheduleDiagnostics];
243
254
  NSString *clientPath = ArcaneRequiredString(client, @"source", error);
244
255
  if (!clientPath) return NO;
@@ -57,13 +57,33 @@ namespace Arcane.Core.Hosts.Windows
57
57
  Dictionary<string, object> host = manifest.TryGetValue("host", out hostValue) ? hostValue as Dictionary<string, object> : null;
58
58
  string icon = host != null && host.TryGetValue("icon", out iconValue) ? iconValue as string : null;
59
59
 
60
- // Core rereads this exact selected file. Do not normalize,
61
- // rewrite, filter or copy the application's launch record.
62
- Dictionary<string, object> launchContext = launchFilename == null ? null : ReadRecord(launchFilename);
60
+ // Native UI and generated Core use the same packaged defaults.
61
+ // Core rereads the explicit file; every explicit field wins.
62
+ Dictionary<string, object> launchContext = manifest.ContainsKey("launchContext")
63
+ ? new Dictionary<string, object>(RequiredRecord(manifest, "launchContext"))
64
+ : new Dictionary<string, object>();
65
+ if (launchFilename != null)
66
+ {
67
+ foreach (KeyValuePair<string, object> field in ReadRecord(launchFilename))
68
+ launchContext[field.Key] = field.Value;
69
+ }
70
+ string defaultStateBase = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
71
+ if (manifest.ContainsKey("launchContext"))
72
+ {
73
+ // Match the public Node resolver for this opt-in contract,
74
+ // including an explicitly selected process environment.
75
+ defaultStateBase = Environment.GetEnvironmentVariable("LOCALAPPDATA");
76
+ if (String.IsNullOrEmpty(defaultStateBase))
77
+ {
78
+ string home = Environment.GetEnvironmentVariable("USERPROFILE");
79
+ if (String.IsNullOrEmpty(home)) home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
80
+ defaultStateBase = Path.Combine(home, "AppData", "Local");
81
+ }
82
+ }
63
83
  object stateRoot;
64
- string profileParent = launchContext != null && launchContext.TryGetValue("stateRoot", out stateRoot) && stateRoot is string
84
+ string profileParent = launchContext.TryGetValue("stateRoot", out stateRoot) && stateRoot is string
65
85
  ? Path.GetFullPath((string)stateRoot)
66
- : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Arcane", appId);
86
+ : Path.Combine(defaultStateBase, "Arcane", appId);
67
87
  OpenDiagnostics(Path.Combine(profileParent, "Diagnostics"));
68
88
  ArcaneHostOptions options = new ArcaneHostOptions
69
89
  {
@@ -151,6 +151,7 @@ export async function copyCoreRuntime(root, files, signal, {
151
151
  }
152
152
 
153
153
  export function coreEntrySource(application, version, services, {localAI, runtimes = [], packagedWeb = false} = {}) {
154
+ const launchDefaults = application.native?.launchContext;
154
155
  const imageSelected = localAI?.runtimes?.some(function selectedImage(requirement) {
155
156
  return (typeof requirement === 'string' ? requirement : requirement.id) === 'stable-diffusion.cpp';
156
157
  }) ?? false;
@@ -199,6 +200,7 @@ export function coreEntrySource(application, version, services, {localAI, runtim
199
200
  "import {fileURLToPath} from 'node:url';",
200
201
  "import path from 'node:path';",
201
202
  "import {isSea} from 'node:sea';",
203
+ ...(launchDefaults === undefined ? [] : ["import {mkdir} from 'node:fs/promises';"]),
202
204
  "import {readCoreLaunchContext, startCoreHost, runSharedCoreHost, startSharedCoreBridge} from 'arcane-os/core/host';",
203
205
  '',
204
206
  ...(localAI === undefined ? [] : [
@@ -241,9 +243,15 @@ export function coreEntrySource(application, version, services, {localAI, runtim
241
243
  ' return resolved;',
242
244
  ' }),'
243
245
  ]),
244
- ' ...await readCoreLaunchContext()',
246
+ launchDefaults === undefined ? ' ...await readCoreLaunchContext()'
247
+ : ` ...await readCoreLaunchContext({appId: ${JSON.stringify(application.id)}, defaults: JSON.parse(${JSON.stringify(JSON.stringify(launchDefaults))})})`,
245
248
  '};',
246
249
  '',
250
+ ...(launchDefaults === undefined ? [] : [
251
+ '// The shared endpoint and diagnostic log require their selected state directory.',
252
+ 'if (context.sharedHost !== undefined) await mkdir(context.stateRoot, {recursive: true});',
253
+ ''
254
+ ]),
247
255
  ...(packagedWeb ? ['let packagedWeb;', ''] : []),
248
256
  'async function createServices(register) {',
249
257
  ` const [${imports.map(function binding(selection) { return selection.binding; }).join(', ')}] = await Promise.all([`,
@@ -197,6 +197,7 @@ export function createPortableNativeProvider({services, packagedWeb = false, web
197
197
  webRoot: 'app',
198
198
  start: release.manifest?.app?.start ?? './index.html',
199
199
  ...(appDescriptor.native?.window === undefined ? {} : {window: {...appDescriptor.native.window}}),
200
+ ...(appDescriptor.native?.launchContext === undefined ? {} : {launchContext: appDescriptor.native.launchContext}),
200
201
  core: {
201
202
  entry: 'runtime/arcane-core.mjs', transport: 'stdio', protocol: 'arcane/1', services: selectedServices,
202
203
  ...(localAI === undefined ? {} : {localAIRuntimes: bundled.runtimes})