windows-development-sandbox 1.27.1 → 1.27.3

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 (78) hide show
  1. package/dist/app/context.d.ts +5 -0
  2. package/dist/app/context.js +13 -0
  3. package/dist/app/features/accounts/accounts.def.d.ts +16 -0
  4. package/dist/app/features/accounts/accounts.run.d.ts +4 -0
  5. package/dist/app/features/accounts/services.def.d.ts +15 -1
  6. package/dist/app/features/accounts/services.def.js +67 -31
  7. package/dist/app/features/accounts/services.run.d.ts +7 -2
  8. package/dist/app/features/accounts/services.run.js +25 -6
  9. package/dist/app/features/defs.d.ts +17 -0
  10. package/dist/app/registry.d.ts +1 -1
  11. package/dist/app/registry.js +15 -2
  12. package/dist/bin/agent-sandbox.js +2 -2
  13. package/dist/bin/{chunk-QUHNXHVG.js → chunk-4M5SZZ5P.js} +46 -41
  14. package/dist/bin/{chunk-4TLSWPG7.js → chunk-FA5VWLP4.js} +1 -1
  15. package/dist/bin/{chunk-RSIX7C6D.js → chunk-RU7XRN62.js} +249 -101
  16. package/dist/bin/claude-host.js +2 -2
  17. package/dist/bin/claude-sbx.js +2 -2
  18. package/dist/bin/codex-sbx.js +2 -2
  19. package/dist/bin/gemini-sbx.js +2 -2
  20. package/dist/bin/sandbox.js +2 -2
  21. package/dist/bin/sbx-hide.js +2 -2
  22. package/dist/bin/sbx-show.js +2 -2
  23. package/dist/cli/menu/menu.js +6 -1
  24. package/dist/cli/menu/pages/accounts-page.js +9 -5
  25. package/dist/guest/apps/app-installer.js +7 -5
  26. package/dist/guest/apps/comet-unpack.js +2 -2
  27. package/dist/guest/runtime/pools.d.ts +4 -4
  28. package/dist/guest/runtime/pools.js +4 -15
  29. package/dist/guest/runtime/process.d.ts +5 -2
  30. package/dist/guest/runtime/process.js +23 -3
  31. package/dist/guest/toolchains/engines/epic-launcher.d.ts +6 -0
  32. package/dist/guest/toolchains/engines/epic-launcher.js +19 -1
  33. package/dist/guest/toolchains/engines/unreal-engines.js +2 -1
  34. package/dist/guest/toolchains/installers.d.ts +3 -3
  35. package/dist/guest/toolchains/installers.js +5 -5
  36. package/dist/guest/toolchains/native/vcruntime.js +2 -2
  37. package/dist/guest/toolchains/native/vs-persist.d.ts +2 -2
  38. package/dist/guest/toolchains/signature.d.ts +2 -0
  39. package/dist/guest/toolchains/signature.js +9 -2
  40. package/dist/guest/toolchains/step-scheduler.js +1 -1
  41. package/dist/host/accounts/deliver.js +15 -7
  42. package/dist/host/pc-installs/discover.js +5 -2
  43. package/dist/host/pc-installs/unreal.d.ts +3 -1
  44. package/dist/host/pc-installs/unreal.js +27 -1
  45. package/dist/runtime/agent.js +34 -22
  46. package/dist/runtime/daemon.js +96 -51
  47. package/dist/runtime/db.js +32 -16
  48. package/dist/runtime/main.js +98 -53
  49. package/dist/runtime/netproxy.js +26 -12
  50. package/dist/runtime/tool.js +32 -15
  51. package/dist/runtime/view.js +26 -12
  52. package/dist/selftest/app.test.js +20 -2
  53. package/dist/selftest/boot-runner.test.js +15 -1
  54. package/dist/selftest/engines.test.js +22 -1
  55. package/dist/selftest/menu-screens.js +52 -1
  56. package/dist/selftest/menu.test.js +29 -3
  57. package/dist/selftest/service-accounts.test.js +77 -2
  58. package/dist/selftest/ui-text.test.js +4 -0
  59. package/dist/shared/accounts/kinds.js +9 -9
  60. package/dist/shared/apps/catalogue-gamedev.js +1 -0
  61. package/dist/shared/apps/catalogue.d.ts +2 -0
  62. package/dist/shared/concurrency/pools.js +1 -1
  63. package/dist/shared/download/archive.js +4 -2
  64. package/dist/shared/download/index.d.ts +1 -1
  65. package/dist/shared/download/index.js +1 -1
  66. package/dist/shared/download/spawn.d.ts +7 -1
  67. package/dist/shared/download/spawn.js +16 -1
  68. package/dist/shared/paths/pc-installs.d.ts +8 -0
  69. package/dist/shared/unreal/launcher-files.d.ts +7 -0
  70. package/dist/shared/unreal/launcher-files.js +32 -1
  71. package/dist/web/index.html +1 -1
  72. package/dist/web/{main-VPMYTSUL.js → main-NKM7ECSB.js} +59 -59
  73. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  74. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  75. package/native/csharp/StorageHelper/AssemblyInfo.cs +3 -3
  76. package/package.json +1 -1
  77. package/schemas/config.schema.json +9 -9
  78. package/schemas/sandbox-config.schema.json +9 -9
@@ -7,7 +7,7 @@ import { Draft } from '../cli/menu/settings/draft.js';
7
7
  import { z } from 'zod';
8
8
  import { addedAgents, agentNameProblem, commandFromPackage, newAgent } from '../app/features/agents/index.js';
9
9
  import { APP_GROUPS, appRows, REQUEST_APP, requestAppUrl } from '../app/features/apps/index.js';
10
- import { appsCategories, appsItems } from '../cli/menu/pages/apps-page.js';
10
+ import { appsCategories, appsItems, stageTicks } from '../cli/menu/pages/apps-page.js';
11
11
  import { readMenuView, saveMenuView } from '../cli/menu/memory.js';
12
12
  import path from 'node:path';
13
13
  import { argPathKind, optionLabel, optionPathKind, plainDescription } from '../cli/menu/command-fields.js';
@@ -633,6 +633,24 @@ export const menuSuite = {
633
633
  appItems.every((i) => groupIds.includes(i.category ?? '')) &&
634
634
  appItems.length === appRows(user2, t.dir('apps-groups')).length &&
635
635
  APP_CATEGORIES.every((c) => APP_GROUPS.some((g) => g.members.includes(c.id))), 'the apps are in at most 7 groups (with your own programs); every catalogue category belongs to one and every app is listed', groupIds.join());
636
+ t.section('a Store app ticked on the Apps page is saved and shown on');
637
+ const appsFile = ConfigFile.user(t.at('apps-store', 'config.json'));
638
+ const appsDraft = new Draft();
639
+ const perplexityRow = (d) => appsItems(appsFile, t.dir('apps-store-data'), d).find((i) => i.value === 'perplexity-desktop')?.checked;
640
+ t.check(perplexityRow(appsDraft) === false, 'the Perplexity desktop app starts off');
641
+ const alreadyOn = appRows(appsFile, t.dir('apps-store-data'))
642
+ .filter((r) => r.on)
643
+ .map((r) => r.id);
644
+ stageTicks(appsFile, appsDraft, [...alreadyOn, 'perplexity-desktop']);
645
+ t.check(perplexityRow(appsDraft) === true && appsDraft.count() === 1, 'ticking it shows it on and stages one change');
646
+ const applied = appsDraft.apply();
647
+ t.check(applied.saved === 1 &&
648
+ applied.problems.length === 0 &&
649
+ appsFile.valueInFile(['apps', 'install']).includes('perplexity-desktop') &&
650
+ perplexityRow(new Draft()) === true, 'Ctrl+S writes it into apps.install and the page still shows it on');
651
+ const toggleCtx = { loaded: { configPath: appsFile.file }, options: {}, dataPath: t.dir('apps-store-data') };
652
+ await runDefinition('apps.toggle', toggleCtx, { id: 'perplexity-desktop', on: false }, quiet());
653
+ t.check(!(ConfigFile.user(appsFile.file).valueInFile(['apps', 'install']) ?? []).includes('perplexity-desktop'), 'and the web toggle (apps.toggle) turns it off in the same list');
636
654
  t.section('dialog box');
637
655
  const box = dialogBox({ title: 'Unsaved changes', lines: ['You have 2 unsaved changes.'], buttons: 'Save' }, 61);
638
656
  const base = Array.from({ length: 12 }, () => 'x'.repeat(60));
@@ -742,11 +760,19 @@ export const menuSuite = {
742
760
  const size = /\((\d+)x(\d+)\)/.exec(head);
743
761
  return !size || rows.length !== Number(size[2]) || rows.some((l) => Text.width(l) > Number(size[1]) - 1);
744
762
  });
745
- t.equal(overflow.map((b) => b.slice(0, b.indexOf('\n'))), [], 'every screen is exactly as tall as the terminal and never wider, at 200x60, 100x30, 80x24 and 60x20');
746
- t.check(SIZES.length === 4 &&
763
+ t.equal(overflow.map((b) => b.slice(0, b.indexOf('\n'))), [], 'every screen is exactly as tall as the terminal and never wider, at 200x60, 120x30, 100x30, 80x24 and 60x20');
764
+ t.check(SIZES.length === 5 &&
747
765
  screens.includes('=== 7 Maintenance · health (80x24) ===') &&
748
766
  screens.includes('unsaved: 2') &&
749
767
  screens.includes('Save'), 'all screens render at both sizes; the settings screen shows its unsaved count and the leave dialog offers Save');
768
+ const serviceScreens = blocks.filter((b) => /^6 Accounts · (epic|perplexity|comet) \((80x24|120x30)\)/.test(b));
769
+ const serviceRows = serviceScreens.flatMap((b) => b.split('\n').slice(1));
770
+ t.check(serviceScreens.length === 6 &&
771
+ serviceRows.every((l) => !/\bsandbox (account|launch|config)\b/.test(l)) &&
772
+ serviceRows.every((l) => !l.includes('(encrypted)') && !/[A-Za-z]…/.test(l.replace(/(file|sign-in|Forget)…/g, ''))) &&
773
+ serviceRows.some((l) => l.includes('On this PC') && l.includes('found')) &&
774
+ serviceRows.some((l) => l.includes('Perplexity API')) &&
775
+ !serviceRows.some((l) => l.includes('Perplexity API key')), 'the service pages read short at 80 and 120 columns: short names, a few facts, no cut words, no command hints');
750
776
  const out = process.env.MENU_SCREENS_OUT;
751
777
  if (out)
752
778
  fs.writeFileSync(out, screens, 'utf8');
@@ -18,9 +18,10 @@ import { clearSecrets, SecretStore } from '../host/secrets/index.js';
18
18
  import { applyServiceAccounts, readDelivered } from '../guest/accounts/apply.js';
19
19
  import { AccountWatch } from '../guest/accounts/watch.js';
20
20
  import { epicAdapter } from '../guest/accounts/kinds/epic.js';
21
- import { epicSettingsFile, epicSettingsFiles, fingerprint, readEpicLogin, SERVICE_KINDS, SERVICE_SPECS, serviceWanted, withEpicLogin, } from '../shared/accounts/index.js';
21
+ import { epicSettingsFile, epicSettingsFiles, fingerprint, readEpicLogin, SERVICE_KINDS, serviceWanted, withEpicLogin, } from '../shared/accounts/index.js';
22
22
  import { RunDir } from '../shared/channel/index.js';
23
23
  import { decodeText, encodeText, readBoundedFile } from '../shared/io/index.js';
24
+ import { isKindReady } from '../shared/readiness.js';
24
25
  const SIGN_IN = 'PlantedEpicSignIn' + 'AbCdEf0123456789+/'.repeat(6) + '==';
25
26
  const RENEWED = 'PlantedRenewed' + 'ZyXw9876543210'.repeat(6);
26
27
  const CFX = 'cfxk_PlantedServerKey_0123456789';
@@ -150,6 +151,11 @@ export const serviceAccountsSuite = {
150
151
  signIns.remove('epic');
151
152
  const fromPc = deliver({ pc: thisPc });
152
153
  t.check(fromPc.payloads.epic?.data === SIGN_IN && signIns.info('epic')?.source === 'pc', "shared with nothing saved: this PC's own sign-in is taken and saved, as import would");
154
+ for (const file of epicSettingsFiles(pcLocal))
155
+ fs.writeFileSync(file, INI.replace(SIGN_IN, RENEWED));
156
+ t.check(deliver({ pc: thisPc }).payloads.epic?.data === RENEWED && signIns.read('epic')?.data === RENEWED, "one taken from this PC follows it: the PC's renewed sign-in replaces the stale copy");
157
+ signIns.save('epic', { data: SIGN_IN }, 'sandbox');
158
+ t.check(deliver({ pc: thisPc }).payloads.epic?.data === SIGN_IN && signIns.info('epic')?.source === 'sandbox', 'one a sandbox handed back is newer than the PC’s and is kept');
153
159
  signIns.remove('epic');
154
160
  t.check(!deliver().payloads.epic, 'without this PC (pc left out) nothing is taken');
155
161
  const sharedRun = await f.run({}, config);
@@ -325,8 +331,77 @@ export const serviceAccountsSuite = {
325
331
  t.section('the accounts page');
326
332
  const row = serviceRow('epic', { ...config, accounts: { ...config.accounts, epic: { share: true } } }, signIns, launchLeases);
327
333
  const lines = pageLines(servicePage(row)).join('\n');
328
- t.check(lines.includes('Epic Games') && lines.includes(SERVICE_SPECS.epic.warnings[1]) && !lines.includes(SIGN_IN), 'a service shows its state and its warnings, never the sign-in');
334
+ t.check(lines.includes('Epic Games') &&
335
+ servicePage(row).sections[0].facts?.some((f) => f.description === 'The agent can use it.') === true &&
336
+ !lines.includes(SIGN_IN), 'a service shows its state and one short warning, never the sign-in');
329
337
  t.check(Object.values(accountsView.queries).some((q) => q.id === 'accounts.services') &&
330
338
  ['accounts.service.import', 'accounts.service.login', 'accounts.service.logout', 'settings.set'].every((id) => accountsView.operations.some((o) => o.id === id)), 'the accounts view offers the services and their actions on every surface');
339
+ t.section('every action a service page shows works from the menu');
340
+ const shareOn = { ...config, accounts: { ...config.accounts, perplexity: { share: true } } };
341
+ const pcFound = { localAppData: t.dir('svc-pc-found'), appData: '', programData: '', env: {} };
342
+ t.write(epicSettingsFile(pcFound.localAppData), INI);
343
+ const pcNone = { localAppData: t.dir('svc-pc-none'), appData: '', programData: '', env: {} };
344
+ const pageStore = new ServiceAccountStore(t.dir('svc-page'), SecretStore.memory());
345
+ const pageLeases = new AccountLeases(t.dir('svc-page-leases'));
346
+ const rowOn = (kind, pc, c = config) => serviceRow(kind, c, pageStore, pageLeases, pc);
347
+ const labelsOf = (row) => servicePage(row).sections[0].actions.map((a) => a.label);
348
+ const descriptionsOf = (r) => (servicePage(r).sections[0].facts ?? []).map((f) => ' ' + (f.description ?? '')).join('');
349
+ const epicFound = rowOn('epic', pcFound);
350
+ const epicText = pageLines(servicePage(epicFound)).join('\n') + descriptionsOf(epicFound);
351
+ t.check(epicFound.onPc === true &&
352
+ /On this PC\s+found/.test(epicText) &&
353
+ epicText.includes("takes this PC's") &&
354
+ !epicText.includes(SIGN_IN), 'a kind that comes from this PC says it is found there (never the sign-in), and what sharing does');
355
+ t.check(rowOn('epic', pcNone).onPc === false &&
356
+ /On this PC\s+not found/.test(pageLines(servicePage(rowOn('epic', pcNone))).join('\n')), 'and says not found when this PC has none');
357
+ t.check(labelsOf(epicFound).includes("Take this PC's sign-in") &&
358
+ !labelsOf(epicFound).some((l) => l.startsWith('Import…')), 'taking it from this PC has a plain name');
359
+ const perplexityLabels = labelsOf(rowOn('perplexity', pcFound, shareOn));
360
+ t.check(perplexityLabels.includes("Take this PC's sign-in") &&
361
+ perplexityLabels.includes('Import from a file…') &&
362
+ perplexityLabels.includes('Type the sign-in…'), 'the API key can be taken from this PC, read from a file or typed');
363
+ const cometRow = rowOn('comet', pcFound);
364
+ const cometText = pageLines(servicePage(cometRow)).join('\n') + descriptionsOf(cometRow);
365
+ t.check(cometRow.onPc === null &&
366
+ cometRow.sandboxOnly &&
367
+ labelsOf(cometRow).every((l) => l === 'Share with sandboxes') &&
368
+ cometText.includes('Sign in once inside a sandbox; it is kept for the next ones.'), 'Comet offers only sharing and says plainly to sign in once inside a sandbox');
369
+ // Every action a page shows is one its kind can do (import: a PC or file way; sign in: a typed way).
370
+ for (const kind of SERVICE_KINDS.filter(isKindReady)) {
371
+ const r = rowOn(kind, pcFound);
372
+ for (const a of servicePage(r).sections[0].actions ?? []) {
373
+ if (a.kind !== 'operation')
374
+ continue;
375
+ const src = HOST_SOURCES[kind];
376
+ const ok = a.op === 'accounts.service.import'
377
+ ? Boolean(src.fromPc || src.fromFile)
378
+ : a.op === 'accounts.service.login'
379
+ ? Boolean(src.fromAnswers)
380
+ : ['accounts.service.logout', 'settings.set'].includes(a.op);
381
+ t.check(ok, kind + ': "' + a.label + '" is something this kind can do');
382
+ }
383
+ }
384
+ // The paths themselves, through the menu's questions (fakeIO): a file, typed, forgotten.
385
+ const envFile = t.write(t.at('svc-page', 'keys.env'), 'PERPLEXITY_API_KEY=pplx-PlantedKey0123456789\n');
386
+ const fileIO = fakeIO([envFile, true]);
387
+ const fromFile = await importServiceFlow(fileIO.io, 'perplexity', undefined, { store: pageStore, pc: pcNone, share: true }, true);
388
+ t.check(pageStore.info('perplexity')?.source === 'file' &&
389
+ fileIO.seen.questions[0].kind === 'path' &&
390
+ !fromFile.message.includes('PlantedKey'), 'Import from a file asks for the file even when this PC could give one, and saves it');
391
+ await logoutServiceFlow(fakeIO([true]).io, 'perplexity', t.dir('svc-page-root'), pageStore, pageLeases);
392
+ const pcWithKey = { ...pcNone, env: { PERPLEXITY_API_KEY: 'pplx-FromThisPc0123456789' } };
393
+ const keyRow = rowOn('perplexity', pcWithKey);
394
+ t.check(keyRow.onPc === true, 'the API key in this terminal is found on this PC');
395
+ await importServiceFlow(fakeIO([true]).io, 'perplexity', undefined, {
396
+ store: pageStore,
397
+ pc: pcWithKey,
398
+ share: true,
399
+ });
400
+ t.check(pageStore.info('perplexity')?.source === 'pc', "Take this PC's sign-in saves the PC's key");
401
+ await loginServiceFlow(fakeIO(['pplx-Typed0123456789']).io, 'perplexity', { store: pageStore, share: true });
402
+ t.check(pageStore.info('perplexity')?.source === 'typed', 'Type the sign-in saves what was typed');
403
+ const savedRow = rowOn('perplexity', pcWithKey);
404
+ t.check(labelsOf(savedRow).includes('Forget…') && savedRow.state === 'saved', 'a saved sign-in offers Forget');
405
+ await t.throws(() => importServiceFlow(fakeIO([true]).io, 'comet', undefined, { store: pageStore, pc: pcWithKey, share: true }), 'inside a sandbox', 'Comet has nothing to import: the error says to sign in inside a sandbox');
331
406
  },
332
407
  };
@@ -104,6 +104,10 @@ function serviceRow(over) {
104
104
  canImport: true,
105
105
  canLogin: true,
106
106
  needsFile: false,
107
+ fromPc: false,
108
+ hasFileSource: false,
109
+ onPc: null,
110
+ sandboxOnly: false,
107
111
  ready: true,
108
112
  ...over,
109
113
  };
@@ -55,7 +55,7 @@ export const SERVICE_SPECS = {
55
55
  },
56
56
  steam: {
57
57
  kind: 'steam',
58
- label: 'Steam (SteamCMD)',
58
+ label: 'Steam',
59
59
  usedBy: ['steam'],
60
60
  sources: ['file', 'sandbox'],
61
61
  singleHolder: true,
@@ -91,7 +91,7 @@ export const SERVICE_SPECS = {
91
91
  },
92
92
  cfx: {
93
93
  kind: 'cfx',
94
- label: 'Cfx.re (FiveM / RedM server key)',
94
+ label: 'FiveM key',
95
95
  usedBy: ['fivem'],
96
96
  sources: ['typed', 'file'],
97
97
  singleHolder: false,
@@ -120,7 +120,7 @@ export const SERVICE_SPECS = {
120
120
  },
121
121
  keystore: {
122
122
  kind: 'keystore',
123
- label: 'Android upload keystore',
123
+ label: 'Android keystore',
124
124
  usedBy: ['android'],
125
125
  sources: ['file'],
126
126
  singleHolder: false,
@@ -135,7 +135,7 @@ export const SERVICE_SPECS = {
135
135
  },
136
136
  play: {
137
137
  kind: 'play',
138
- label: 'Google Play (service account)',
138
+ label: 'Google Play',
139
139
  usedBy: ['android'],
140
140
  sources: ['file'],
141
141
  singleHolder: false,
@@ -146,7 +146,7 @@ export const SERVICE_SPECS = {
146
146
  },
147
147
  expo: {
148
148
  kind: 'expo',
149
- label: 'Expo (EAS)',
149
+ label: 'Expo',
150
150
  usedBy: ['reactNative'],
151
151
  sources: ['typed'],
152
152
  singleHolder: false,
@@ -157,7 +157,7 @@ export const SERVICE_SPECS = {
157
157
  },
158
158
  appstore: {
159
159
  kind: 'appstore',
160
- label: 'App Store Connect API key',
160
+ label: 'App Store Connect',
161
161
  usedBy: ['reactNative', 'flutter'],
162
162
  sources: ['file', 'typed'],
163
163
  singleHolder: false,
@@ -171,7 +171,7 @@ export const SERVICE_SPECS = {
171
171
  },
172
172
  perplexity: {
173
173
  kind: 'perplexity',
174
- label: 'Perplexity API key',
174
+ label: 'Perplexity API',
175
175
  usedBy: ['perplexity'],
176
176
  sources: ['pc', 'file', 'typed'],
177
177
  singleHolder: false,
@@ -195,7 +195,7 @@ export const SERVICE_SPECS = {
195
195
  },
196
196
  comet: {
197
197
  kind: 'comet',
198
- label: 'Comet (Perplexity browser)',
198
+ label: 'Comet',
199
199
  usedBy: ['comet'],
200
200
  sources: ['sandbox'],
201
201
  singleHolder: false,
@@ -212,7 +212,7 @@ export const SERVICE_SPECS = {
212
212
  },
213
213
  perplexityapp: {
214
214
  kind: 'perplexityapp',
215
- label: 'Perplexity desktop app',
215
+ label: 'Perplexity app',
216
216
  usedBy: ['perplexityapp'],
217
217
  sources: ['sandbox'],
218
218
  singleHolder: false,
@@ -49,6 +49,7 @@ export const GAMEDEV_APPS = [
49
49
  size: '100 MB',
50
50
  source: { github: 'f3d-app/f3d', asset: '^F3D-[\\d.]+-Windows-x86_64\\.zip$' },
51
51
  exe: 'f3d.exe',
52
+ onPath: true,
52
53
  needs: ['mesa'],
53
54
  },
54
55
  {
@@ -73,6 +73,8 @@ export interface AppEntry {
73
73
  exe: string;
74
74
  /** A command-line tool: its folder goes on PATH (no desktop shortcut). */
75
75
  cli?: boolean;
76
+ /** A desktop program an agent also runs by name (F3D renders previews): its folder goes on PATH, shortcut kept. */
77
+ onPath?: boolean;
76
78
  /** Needs these first: 7-Zip to unpack an installer, Mesa for OpenGL without a GPU. */
77
79
  needs?: ('7zip' | 'mesa')[];
78
80
  /**
@@ -2,7 +2,7 @@
2
2
  // them to the downloader and extractor (guest/runtime/pools.ts installPools).
3
3
  import { Limiter } from './limiter.js';
4
4
  /** HTTP bodies. */
5
- export const net = new Limiter(4, 'net');
5
+ export const net = new Limiter(3, 'net');
6
6
  /** extractArchive, 7-Zip/NSIS unpack, npm ci/install, uv/pip, Steam, robocopy of big layouts, rustup/cargo. */
7
7
  export const heavy = new Limiter(2, 'heavy');
8
8
  /** Every msiexec, vcredist, DXSETUP and the Visual Studio bootstrapper: Windows runs one install at a time. */
@@ -34,12 +34,14 @@ export function extractArchive(archive, destination, members = []) {
34
34
  }
35
35
  async function extract(archive, destination, members) {
36
36
  fs.mkdirSync(destination, { recursive: true });
37
- let status = await spawnAsync(SYSTEM.tar(), ['-xf', archive, '-C', destination, ...members]);
37
+ let status = await spawnAsync(SYSTEM.tar(), ['-xf', archive, '-C', destination, ...members], {
38
+ lowPriority: true,
39
+ });
38
40
  if (status !== 0 && /\.(zip|nupkg)$/i.test(archive)) {
39
41
  const zip = archive.endsWith('.zip') ? archive : archive + '.zip';
40
42
  if (zip !== archive)
41
43
  fs.copyFileSync(archive, zip);
42
- status = await spawnAsync(SYSTEM.powershell(), scriptArgs('expand-archive.ps1', ['-Archive', zip, '-Destination', destination]));
44
+ status = await spawnAsync(SYSTEM.powershell(), scriptArgs('expand-archive.ps1', ['-Archive', zip, '-Destination', destination]), { lowPriority: true });
43
45
  }
44
46
  if (status !== 0)
45
47
  throw new Error('Could not extract ' + archive);
@@ -2,5 +2,5 @@ export { fetchRetry, fetchJson, fetchText } from './http.js';
2
2
  export { fetchBounded, fetchBoundedResponse, type BoundedResponse, type Fetcher, type RequestFetcher, } from './bounded-fetch.js';
3
3
  export { stagingFolder, moveIntoPlace, extractArchive, findFile, limitExtraction } from './archive.js';
4
4
  export { type ArchiveOptions, type Checksums, type DownloadWatcher, Downloader, downloader } from './downloader.js';
5
- export { spawnAsync } from './spawn.js';
5
+ export { spawnAsync, lowerPriority, type SpawnAsyncOptions } from './spawn.js';
6
6
  export { type ReleaseAsset, githubAsset, parseReleasePage, latestGithubTag, parseReleaseTags } from './github.js';
@@ -2,5 +2,5 @@ export { fetchRetry, fetchJson, fetchText } from './http.js';
2
2
  export { fetchBounded, fetchBoundedResponse, } from './bounded-fetch.js';
3
3
  export { stagingFolder, moveIntoPlace, extractArchive, findFile, limitExtraction } from './archive.js';
4
4
  export { Downloader, downloader } from './downloader.js';
5
- export { spawnAsync } from './spawn.js';
5
+ export { spawnAsync, lowerPriority } from './spawn.js';
6
6
  export { githubAsset, parseReleasePage, latestGithubTag, parseReleaseTags } from './github.js';
@@ -1,3 +1,9 @@
1
1
  import { type SpawnOptions } from 'node:child_process';
2
+ export interface SpawnAsyncOptions extends SpawnOptions {
3
+ /** Below-normal priority for the program and what it starts: unpacking must not make the desktop sluggish. */
4
+ readonly lowPriority?: boolean;
5
+ }
6
+ /** Lowers a process to below-normal priority; best effort (it may have ended already). */
7
+ export declare function lowerPriority(pid: number | undefined): void;
2
8
  /** Runs a program without blocking the event loop (parallel downloads keep going); resolves with its exit code. */
3
- export declare function spawnAsync(file: string, args: string[], options?: SpawnOptions): Promise<number>;
9
+ export declare function spawnAsync(file: string, args: string[], options?: SpawnAsyncOptions): Promise<number>;
@@ -1,8 +1,23 @@
1
+ import os from 'node:os';
1
2
  import { spawn } from 'node:child_process';
3
+ /** Lowers a process to below-normal priority; best effort (it may have ended already). */
4
+ export function lowerPriority(pid) {
5
+ if (pid === undefined)
6
+ return;
7
+ try {
8
+ os.setPriority(pid, os.constants.priority.PRIORITY_BELOW_NORMAL);
9
+ }
10
+ catch {
11
+ /* a normal priority only makes it a little slower to share the machine */
12
+ }
13
+ }
2
14
  /** Runs a program without blocking the event loop (parallel downloads keep going); resolves with its exit code. */
3
15
  export function spawnAsync(file, args, options = {}) {
16
+ const { lowPriority, ...spawnOptions } = options;
4
17
  return new Promise((resolve, reject) => {
5
- const child = spawn(file, args, { stdio: 'ignore', windowsHide: true, ...options });
18
+ const child = spawn(file, args, { stdio: 'ignore', windowsHide: true, ...spawnOptions });
19
+ if (lowPriority)
20
+ lowerPriority(child.pid);
6
21
  child.once('error', reject);
7
22
  child.once('close', (code) => resolve(code ?? -1));
8
23
  });
@@ -22,6 +22,14 @@ export interface PcInstall {
22
22
  readonly artifactId: string;
23
23
  readonly appVersion: string;
24
24
  };
25
+ /**
26
+ * The launcher's manifest of an engine (its Data\Manifests\<name>.item on this PC): without one the sandbox's
27
+ * launcher drops the engine from its library. Its paths name the sandbox's folder by the time it is in session.json.
28
+ */
29
+ readonly launcherItem?: {
30
+ readonly name: string;
31
+ readonly text: string;
32
+ };
25
33
  }
26
34
  /** What session.json says about them: no host paths. */
27
35
  export interface PcInstallManifest {
@@ -71,6 +71,13 @@ export declare function itemInfo(text: string): {
71
71
  export declare function incompleteDirs(...manifestDirs: string[]): Set<string>;
72
72
  /** The sizes the saved manifests give their engine folders (lower-case folder -> bytes). */
73
73
  export declare function itemSizes(saved: string): Map<string, number>;
74
+ /** A launcher manifest's file name (<hex id>.item). */
75
+ export declare const ITEM_NAME: RegExp;
76
+ /**
77
+ * A launcher manifest moved from folder `from` to `to`: every path in it under `from` (either slash) is under `to`, and
78
+ * a path elsewhere is dropped (it would only name this PC). Null when the text is not a manifest of `from`.
79
+ */
80
+ export declare function relocateItem(text: string, from: string, to: string): string | null;
74
81
  /** Whether the text is a launcher manifest of an app installed under `root`. */
75
82
  export declare function isItemUnder(text: string, root: string): boolean;
76
83
  /** Copies the launcher's manifests of apps under `root` into `saved` (changed ones only). Returns the file names. */
@@ -191,6 +191,37 @@ export function itemSizes(saved) {
191
191
  }
192
192
  return sizes;
193
193
  }
194
+ /** A launcher manifest's file name (<hex id>.item). */
195
+ export const ITEM_NAME = /^[A-Za-z0-9_-]{1,80}\.item$/;
196
+ /**
197
+ * A launcher manifest moved from folder `from` to `to`: every path in it under `from` (either slash) is under `to`, and
198
+ * a path elsewhere is dropped (it would only name this PC). Null when the text is not a manifest of `from`.
199
+ */
200
+ export function relocateItem(text, from, to) {
201
+ let item;
202
+ try {
203
+ item = JSON.parse(stripBom(text));
204
+ }
205
+ catch {
206
+ return null; // Not JSON: not a manifest.
207
+ }
208
+ if (!item || typeof item !== 'object' || Array.isArray(item))
209
+ return null;
210
+ const base = win.resolve(from).toLowerCase();
211
+ const out = {};
212
+ for (const [key, value] of Object.entries(item)) {
213
+ if (typeof value !== 'string' || !win.isAbsolute(value)) {
214
+ out[key] = value;
215
+ continue;
216
+ }
217
+ const full = win.resolve(value);
218
+ const low = full.toLowerCase();
219
+ if (low === base || low.startsWith(base + '\\'))
220
+ out[key] = to + full.slice(base.length);
221
+ }
222
+ const at = typeof out.InstallLocation === 'string' ? out.InstallLocation : '';
223
+ return at.toLowerCase() === to.toLowerCase() ? JSON.stringify(out, null, '\t') : null;
224
+ }
194
225
  /** Whether the text is a launcher manifest of an app installed under `root`. */
195
226
  export function isItemUnder(text, root) {
196
227
  const at = itemInstallLocation(text);
@@ -198,7 +229,7 @@ export function isItemUnder(text, root) {
198
229
  }
199
230
  function itemFiles(dir) {
200
231
  try {
201
- return fs.readdirSync(dir).filter((f) => /^[A-Za-z0-9_-]{1,80}\.item$/.test(f));
232
+ return fs.readdirSync(dir).filter((f) => ITEM_NAME.test(f));
202
233
  }
203
234
  catch {
204
235
  return [];
@@ -6,7 +6,7 @@
6
6
  <title>Sandbox</title>
7
7
  <link rel="icon" href="data:," />
8
8
  <link rel="stylesheet" href="/main-XT7T6ODM.css" />
9
- <script type="module" src="/main-VPMYTSUL.js"></script>
9
+ <script type="module" src="/main-NKM7ECSB.js"></script>
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>