windows-development-sandbox 1.27.1 → 1.28.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/dist/app/context.d.ts +5 -0
- package/dist/app/context.js +13 -0
- package/dist/app/features/accounts/accounts.def.d.ts +16 -0
- package/dist/app/features/accounts/accounts.run.d.ts +4 -0
- package/dist/app/features/accounts/services.def.d.ts +15 -1
- package/dist/app/features/accounts/services.def.js +67 -31
- package/dist/app/features/accounts/services.run.d.ts +7 -2
- package/dist/app/features/accounts/services.run.js +25 -6
- package/dist/app/features/defs.d.ts +17 -0
- package/dist/app/registry.d.ts +1 -1
- package/dist/app/registry.js +15 -2
- package/dist/bin/agent-sandbox.js +2 -2
- package/dist/bin/{chunk-RSIX7C6D.js → chunk-CB44HTTN.js} +444 -175
- package/dist/bin/{chunk-4TLSWPG7.js → chunk-UZ5S3EMB.js} +1 -1
- package/dist/bin/{chunk-QUHNXHVG.js → chunk-ZMZI3625.js} +46 -41
- package/dist/bin/claude-host.js +2 -2
- package/dist/bin/claude-sbx.js +2 -2
- package/dist/bin/codex-sbx.js +2 -2
- package/dist/bin/gemini-sbx.js +2 -2
- package/dist/bin/sandbox.js +2 -2
- package/dist/bin/sbx-hide.js +2 -2
- package/dist/bin/sbx-show.js +2 -2
- package/dist/cli/menu/menu.js +6 -1
- package/dist/cli/menu/pages/accounts-page.js +9 -5
- package/dist/core/schema/sandbox.js +1 -1
- package/dist/guest/apps/app-installer.js +7 -5
- package/dist/guest/apps/comet-unpack.js +2 -2
- package/dist/guest/daemon/heartbeat.js +1 -0
- package/dist/guest/governor/core.d.ts +53 -1
- package/dist/guest/governor/core.js +95 -1
- package/dist/guest/governor/decide.d.ts +4 -0
- package/dist/guest/governor/decide.js +2 -0
- package/dist/guest/governor/governor.js +23 -9
- package/dist/guest/runtime/pools.d.ts +4 -4
- package/dist/guest/runtime/pools.js +4 -15
- package/dist/guest/runtime/process.d.ts +5 -2
- package/dist/guest/runtime/process.js +23 -3
- package/dist/guest/toolchains/engines/epic-launcher.d.ts +6 -0
- package/dist/guest/toolchains/engines/epic-launcher.js +19 -1
- package/dist/guest/toolchains/engines/unreal-engines.js +2 -1
- package/dist/guest/toolchains/installers.d.ts +3 -3
- package/dist/guest/toolchains/installers.js +5 -5
- package/dist/guest/toolchains/native/unreal.js +22 -3
- package/dist/guest/toolchains/native/vcruntime.js +2 -2
- package/dist/guest/toolchains/native/vs-persist.d.ts +2 -2
- package/dist/guest/toolchains/runtimes.js +7 -6
- package/dist/guest/toolchains/signature.d.ts +2 -0
- package/dist/guest/toolchains/signature.js +9 -2
- package/dist/guest/toolchains/step-scheduler.js +1 -1
- package/dist/host/accounts/deliver.js +15 -7
- package/dist/host/life/crash-report.d.ts +4 -0
- package/dist/host/life/crash-report.js +30 -1
- package/dist/host/life/last-end.js +1 -0
- package/dist/host/pc-installs/discover.js +5 -2
- package/dist/host/pc-installs/unreal.d.ts +3 -1
- package/dist/host/pc-installs/unreal.js +27 -1
- package/dist/host/tools/windows-terminal.d.ts +4 -1
- package/dist/host/tools/windows-terminal.js +25 -9
- package/dist/runtime/agent.js +39 -23
- package/dist/runtime/daemon.js +279 -121
- package/dist/runtime/db.js +37 -17
- package/dist/runtime/main.js +340 -109
- package/dist/runtime/netproxy.js +31 -13
- package/dist/runtime/tool.js +37 -16
- package/dist/runtime/view.js +150 -38
- package/dist/selftest/agent-paths.test.js +3 -1
- package/dist/selftest/app.test.js +20 -2
- package/dist/selftest/boot-runner.test.js +15 -1
- package/dist/selftest/channel.test.js +33 -0
- package/dist/selftest/engines.test.js +22 -1
- package/dist/selftest/heavy-load.test.js +116 -4
- package/dist/selftest/live-terminal.test.js +103 -0
- package/dist/selftest/menu-screens.js +52 -1
- package/dist/selftest/menu.test.js +29 -3
- package/dist/selftest/personalization.test.js +2 -1
- package/dist/selftest/service-accounts.test.js +77 -2
- package/dist/selftest/support/engine-golden.js +2 -0
- package/dist/selftest/ui-text.test.js +4 -0
- package/dist/shared/accounts/kinds.js +9 -9
- package/dist/shared/apps/catalogue-gamedev.js +1 -0
- package/dist/shared/apps/catalogue.d.ts +2 -0
- package/dist/shared/channel/schema.d.ts +2 -0
- package/dist/shared/channel/schema.js +4 -0
- package/dist/shared/concurrency/pools.js +1 -1
- package/dist/shared/download/archive.js +4 -2
- package/dist/shared/download/index.d.ts +1 -1
- package/dist/shared/download/index.js +1 -1
- package/dist/shared/download/spawn.d.ts +7 -1
- package/dist/shared/download/spawn.js +16 -1
- package/dist/shared/memory/index.d.ts +1 -0
- package/dist/shared/memory/index.js +1 -0
- package/dist/shared/memory/policy.d.ts +12 -0
- package/dist/shared/memory/policy.js +4 -0
- package/dist/shared/memory/unreal-build.d.ts +2 -0
- package/dist/shared/memory/unreal-build.js +21 -0
- package/dist/shared/paths/pc-installs.d.ts +8 -0
- package/dist/shared/pty/clipboard-watch.d.ts +4 -0
- package/dist/shared/pty/clipboard-watch.js +40 -0
- package/dist/shared/pty/ctrl-c.d.ts +13 -7
- package/dist/shared/pty/ctrl-c.js +35 -19
- package/dist/shared/pty/index.d.ts +1 -0
- package/dist/shared/pty/index.js +1 -0
- package/dist/shared/pty/terminal.d.ts +1 -1
- package/dist/shared/pty/terminal.js +16 -13
- package/dist/shared/unreal/launcher-files.d.ts +7 -0
- package/dist/shared/unreal/launcher-files.js +32 -1
- package/dist/shared/web/ctrl-c.d.ts +26 -0
- package/dist/shared/web/ctrl-c.js +64 -0
- package/dist/shared/web/index.d.ts +1 -0
- package/dist/shared/web/index.js +1 -0
- package/dist/web/index.html +1 -1
- package/dist/web/{main-VPMYTSUL.js → main-7NY4X6YU.js} +67 -67
- package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
- package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
- package/native/csharp/StorageHelper/AssemblyInfo.cs +3 -3
- package/package.json +1 -1
- package/sandbox/csharp/ClipboardWatch.cs +59 -0
- package/sandbox/csharp/MemGov.cs +752 -7
- package/sandbox/scripts/watch-clipboard.ps1 +8 -0
- package/schemas/config.schema.json +10 -10
- package/schemas/sandbox-config.schema.json +9 -9
|
@@ -55,7 +55,7 @@ export const SERVICE_SPECS = {
|
|
|
55
55
|
},
|
|
56
56
|
steam: {
|
|
57
57
|
kind: 'steam',
|
|
58
|
-
label: 'Steam
|
|
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: '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
215
|
+
label: 'Perplexity app',
|
|
216
216
|
usedBy: ['perplexityapp'],
|
|
217
217
|
sources: ['sandbox'],
|
|
218
218
|
singleHolder: false,
|
|
@@ -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
|
/**
|
|
@@ -853,6 +853,7 @@ export declare const PressureStatusSchema: z.ZodObject<{
|
|
|
853
853
|
pid: z.ZodNumber;
|
|
854
854
|
seconds: z.ZodNumber;
|
|
855
855
|
}, z.core.$strip>>;
|
|
856
|
+
held: z.ZodOptional<z.ZodNumber>;
|
|
856
857
|
cpuPercent: z.ZodOptional<z.ZodNumber>;
|
|
857
858
|
pcLow: z.ZodBoolean;
|
|
858
859
|
lastAction: z.ZodString;
|
|
@@ -875,6 +876,7 @@ export declare const HeartbeatSchema: z.ZodObject<{
|
|
|
875
876
|
privateMb: z.ZodNumber;
|
|
876
877
|
}, z.core.$strip>>;
|
|
877
878
|
diskQueueReported: z.ZodOptional<z.ZodNumber>;
|
|
879
|
+
held: z.ZodOptional<z.ZodNumber>;
|
|
878
880
|
phase: z.ZodEnum<{
|
|
879
881
|
ready: "ready";
|
|
880
882
|
working: "working";
|
|
@@ -133,6 +133,8 @@ export const PressureStatusSchema = z.object({
|
|
|
133
133
|
lowered: z.number().int().min(0).max(1024),
|
|
134
134
|
/** Compilers waiting their turn right now (name, process id, seconds until they may go on). */
|
|
135
135
|
paused: z.array(z.object({ name: Text(64), pid: z.number().int(), seconds: z.number().int().min(0).max(60) })).max(8),
|
|
136
|
+
/** Build programs the memory gate holds right now (let go one at a time). */
|
|
137
|
+
held: z.number().int().min(0).max(1024).optional(),
|
|
136
138
|
/** The CPU share the compilers get together (percent; 100 = no cap). */
|
|
137
139
|
cpuPercent: z.number().int().min(0).max(100).optional(),
|
|
138
140
|
/** The PC itself asked for slowing down (its memory or CPU is low). */
|
|
@@ -157,6 +159,8 @@ export const HeartbeatSchema = z.object({
|
|
|
157
159
|
topProcesses: z.array(z.object({ name: Text(64), privateMb: z.number().int().min(0).max(1e7) })).max(5),
|
|
158
160
|
/** The disk queue this PC last reported while asking the sandbox to slow down. */
|
|
159
161
|
diskQueueReported: z.number().min(0).max(100000).optional(),
|
|
162
|
+
/** Build programs the memory gate holds right now (let go one at a time). */
|
|
163
|
+
held: z.number().int().min(0).max(1024).optional(),
|
|
160
164
|
/** "ending": the sandbox is shutting down in order (idle shutdown, sandbox stop, Windows shutting down). */
|
|
161
165
|
phase: z.enum(['ready', 'working', 'ending']),
|
|
162
166
|
});
|
|
@@ -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(
|
|
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?:
|
|
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, ...
|
|
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
|
});
|
|
@@ -72,3 +72,15 @@ export declare function maxCompilers(cpus: number, profile: LoadProfile): number
|
|
|
72
72
|
* `CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER`).
|
|
73
73
|
*/
|
|
74
74
|
export declare function cargoLinkerVar(host: string): string;
|
|
75
|
+
/** The memory gate in memgov: new build programs wait when the agents' programs grow faster than this. */
|
|
76
|
+
export interface BurstGate {
|
|
77
|
+
/** Growth (MB of committed memory) that counts as a burst... */
|
|
78
|
+
readonly growthMb: number;
|
|
79
|
+
/** ...within this many ms. */
|
|
80
|
+
readonly windowMs: number;
|
|
81
|
+
/** Held programs go on one at a time, this far apart (oldest first). */
|
|
82
|
+
readonly releaseMs: number;
|
|
83
|
+
/** No program waits longer than this (memgov's own cap is 30000); never killed. */
|
|
84
|
+
readonly maxHoldMs: number;
|
|
85
|
+
}
|
|
86
|
+
export declare const BURST_GATE: Readonly<Record<LoadProfile, BurstGate>>;
|
|
@@ -115,3 +115,7 @@ export function maxCompilers(cpus, profile) {
|
|
|
115
115
|
export function cargoLinkerVar(host) {
|
|
116
116
|
return 'CARGO_TARGET_' + host.toUpperCase().replace(/[^A-Z0-9]/g, '_') + '_LINKER';
|
|
117
117
|
}
|
|
118
|
+
export const BURST_GATE = {
|
|
119
|
+
normal: { growthMb: 4096, windowMs: 3000, releaseMs: 1500, maxHoldMs: 30000 },
|
|
120
|
+
gentle: { growthMb: 3072, windowMs: 3000, releaseMs: 2000, maxHoldMs: 30000 },
|
|
121
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// UnrealBuildTool's parallel compiles in the sandbox. Left alone it starts one compiler per core whenever the sandbox
|
|
2
|
+
// looks free (free RAM / 1.5 GB), and each cl.exe with Unreal's precompiled headers commits about 2.6 GB: a build start
|
|
3
|
+
// asks for 50 GB within seconds, faster than the governor can step in, and Windows closes the sandbox.
|
|
4
|
+
// MaxParallelActions caps it before the build starts; UnrealBuildTool runs one build per engine at a time, so the cap
|
|
5
|
+
// is the whole budget.
|
|
6
|
+
/** The XML namespace UnrealBuildTool reads (XmlConfigFile.SchemaNamespaceURI). */
|
|
7
|
+
const NAMESPACE = 'https://www.unrealengine.com/BuildConfiguration';
|
|
8
|
+
/** BuildConfiguration.xml with at most `jobs` compiles and links at once (pure). */
|
|
9
|
+
export function ubtBuildConfiguration(jobs) {
|
|
10
|
+
const max = Math.max(1, Math.floor(jobs));
|
|
11
|
+
return [
|
|
12
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
13
|
+
'<!-- Written by the sandbox at boot: compiles at once from the build budget (run\\budget.json, buildJobs). -->',
|
|
14
|
+
'<Configuration xmlns="' + NAMESPACE + '">',
|
|
15
|
+
' <BuildConfiguration>',
|
|
16
|
+
' <MaxParallelActions>' + max + '</MaxParallelActions>',
|
|
17
|
+
' </BuildConfiguration>',
|
|
18
|
+
'</Configuration>',
|
|
19
|
+
'',
|
|
20
|
+
].join('\r\n');
|
|
21
|
+
}
|
|
@@ -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 {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Windows: tells the terminal client when the clipboard changes, e.g. you just copied text in the sandbox window (which
|
|
2
|
+
// copies on select). sandbox\scripts\watch-clipboard.ps1 prints a line per change and ends with this process.
|
|
3
|
+
// Only built-ins here: view.js bundles this.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { SYSTEM } from '../system.js';
|
|
6
|
+
import { scriptArgs } from '../scripts.js';
|
|
7
|
+
/** The most output kept while waiting for the end of a line. */
|
|
8
|
+
const MAX_PENDING = 1024;
|
|
9
|
+
/** Calls `onChange` for each clipboard change from now on. No-op off Windows. */
|
|
10
|
+
export function watchClipboard(onChange) {
|
|
11
|
+
if (process.platform !== 'win32')
|
|
12
|
+
return { stop: () => undefined };
|
|
13
|
+
let child = null;
|
|
14
|
+
try {
|
|
15
|
+
child = spawn(SYSTEM.powershell(), scriptArgs('watch-clipboard.ps1', ['-ProcessId', String(process.pid)]), {
|
|
16
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Without the watcher a copy is only recognised from the program's own selection: nothing else is lost.
|
|
22
|
+
return { stop: () => undefined };
|
|
23
|
+
}
|
|
24
|
+
let pending = '';
|
|
25
|
+
child.stdout?.setEncoding('latin1');
|
|
26
|
+
child.stdout?.on('data', (text) => {
|
|
27
|
+
const lines = (pending + text).split('\n');
|
|
28
|
+
pending = (lines.pop() ?? '').slice(-MAX_PENDING);
|
|
29
|
+
for (const line of lines)
|
|
30
|
+
if (line.trim() === 'changed')
|
|
31
|
+
onChange();
|
|
32
|
+
});
|
|
33
|
+
child.on('error', () => undefined);
|
|
34
|
+
return {
|
|
35
|
+
stop: () => {
|
|
36
|
+
if (child && child.exitCode === null && child.signalCode === null)
|
|
37
|
+
child.kill();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
3
|
-
/** What the title shows while a Ctrl+C is held back. */
|
|
4
|
-
export declare const CTRL_C_HINT = "Ctrl+C again to send it to the program (Esc stops an agent)";
|
|
1
|
+
import { CTRL_C_CONFIRM_MS, CTRL_C_COPY_HINT, CTRL_C_HINT } from '../web/index.js';
|
|
2
|
+
export { CTRL_C_CONFIRM_MS, CTRL_C_COPY_HINT, CTRL_C_HINT };
|
|
5
3
|
export declare class CtrlCGuard {
|
|
6
|
-
private
|
|
4
|
+
private readonly decider;
|
|
7
5
|
private lastTitle;
|
|
8
|
-
|
|
6
|
+
constructor(options?: {
|
|
7
|
+
confirm?: boolean;
|
|
8
|
+
});
|
|
9
|
+
/** What to send for one read from the keyboard: a lone Ctrl+C only when it is not held back or taken as "copy". */
|
|
9
10
|
push(chunk: Buffer, nowMs: number): {
|
|
10
11
|
send: Buffer;
|
|
11
12
|
held: boolean;
|
|
13
|
+
copied: boolean;
|
|
12
14
|
};
|
|
15
|
+
/** The clipboard changed since the last key: you just copied. */
|
|
16
|
+
clipboardChanged(): void;
|
|
17
|
+
/** The program wrote to the clipboard: that is a selection too. */
|
|
18
|
+
watchClipboard(raw: string): void;
|
|
13
19
|
/** Remembers the title the program last set (from its output), to put it back after the hint. */
|
|
14
20
|
watchOutput(text: string): void;
|
|
15
21
|
/** The escape sequence that shows the hint, and the one that puts the program's title back. */
|
|
16
|
-
hintTitle(): string;
|
|
22
|
+
hintTitle(kind?: 'hold' | 'copy'): string;
|
|
17
23
|
restoreTitle(): string;
|
|
18
24
|
}
|
|
@@ -1,25 +1,41 @@
|
|
|
1
|
-
// A
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export
|
|
1
|
+
// A Ctrl+C is easy to send by accident: it is "copy" everywhere else. Two rules keep it from stopping an agent.
|
|
2
|
+
// 1. After you select text in the program (a mouse drag it received, or it wrote the clipboard with OSC 52), or the
|
|
3
|
+
// clipboard changed (you copied: the sandbox window copies on select), every Ctrl+C is "copy" and is never sent,
|
|
4
|
+
// until you press another key or click.
|
|
5
|
+
// 2. With attach.confirmCtrlC a lone Ctrl+C is held back until it is pressed again within a moment.
|
|
6
|
+
// Esc still stops an agent at once. The rules live in CtrlCDecider (pure, shared with the web terminal).
|
|
7
|
+
import { CTRL_C_CONFIRM_MS, CTRL_C_COPY_HINT, CTRL_C_HINT, CtrlCDecider, classifyInput, hasClipboardWrite, } from '../web/index.js';
|
|
8
|
+
export { CTRL_C_CONFIRM_MS, CTRL_C_COPY_HINT, CTRL_C_HINT };
|
|
9
9
|
const OSC_TITLE = /\x1b\][02];([^\x07\x1b]{0,256})(?:\x07|\x1b\\)/g;
|
|
10
10
|
export class CtrlCGuard {
|
|
11
|
-
|
|
11
|
+
decider;
|
|
12
12
|
lastTitle = '';
|
|
13
|
-
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.decider = new CtrlCDecider(options.confirm !== false);
|
|
15
|
+
}
|
|
16
|
+
/** What to send for one read from the keyboard: a lone Ctrl+C only when it is not held back or taken as "copy". */
|
|
14
17
|
push(chunk, nowMs) {
|
|
15
|
-
if (chunk.length
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return { send:
|
|
18
|
+
if (chunk.length === 1 && chunk[0] === 0x03) {
|
|
19
|
+
const action = this.decider.ctrlC(nowMs);
|
|
20
|
+
if (action === 'send')
|
|
21
|
+
return { send: chunk, held: false, copied: false };
|
|
22
|
+
return { send: Buffer.alloc(0), held: action === 'hold', copied: action === 'copy' };
|
|
20
23
|
}
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
const c = classifyInput(chunk.toString('latin1'));
|
|
25
|
+
if (c.keys || c.mouse === 'click')
|
|
26
|
+
this.decider.otherInput();
|
|
27
|
+
else if (c.mouse === 'drag')
|
|
28
|
+
this.decider.selection();
|
|
29
|
+
return { send: chunk, held: false, copied: false };
|
|
30
|
+
}
|
|
31
|
+
/** The clipboard changed since the last key: you just copied. */
|
|
32
|
+
clipboardChanged() {
|
|
33
|
+
this.decider.selection();
|
|
34
|
+
}
|
|
35
|
+
/** The program wrote to the clipboard: that is a selection too. */
|
|
36
|
+
watchClipboard(raw) {
|
|
37
|
+
if (hasClipboardWrite(raw))
|
|
38
|
+
this.decider.selection();
|
|
23
39
|
}
|
|
24
40
|
/** Remembers the title the program last set (from its output), to put it back after the hint. */
|
|
25
41
|
watchOutput(text) {
|
|
@@ -27,8 +43,8 @@ export class CtrlCGuard {
|
|
|
27
43
|
this.lastTitle = m[1];
|
|
28
44
|
}
|
|
29
45
|
/** The escape sequence that shows the hint, and the one that puts the program's title back. */
|
|
30
|
-
hintTitle() {
|
|
31
|
-
return '\x1b]0;' + CTRL_C_HINT + '\x07';
|
|
46
|
+
hintTitle(kind = 'hold') {
|
|
47
|
+
return '\x1b]0;' + (kind === 'copy' ? CTRL_C_COPY_HINT : CTRL_C_HINT) + '\x07';
|
|
32
48
|
}
|
|
33
49
|
restoreTitle() {
|
|
34
50
|
return '\x1b]0;' + this.lastTitle.replace(/[\x00-\x1f\x7f]/g, '') + '\x07';
|
|
@@ -6,4 +6,5 @@ export { MouseModes } from './mouse-modes.js';
|
|
|
6
6
|
export { JOIN_MS, InputJoiner } from './input-joiner.js';
|
|
7
7
|
export { CTRL_C_CONFIRM_MS, CTRL_C_HINT, CtrlCGuard } from './ctrl-c.js';
|
|
8
8
|
export { type VtInput, enableVtInput } from './console-input.js';
|
|
9
|
+
export { watchClipboard } from './clipboard-watch.js';
|
|
9
10
|
export { OutputFilter } from './output-filter.js';
|
package/dist/shared/pty/index.js
CHANGED
|
@@ -6,4 +6,5 @@ export { MouseModes } from './mouse-modes.js';
|
|
|
6
6
|
export { JOIN_MS, InputJoiner } from './input-joiner.js';
|
|
7
7
|
export { CTRL_C_CONFIRM_MS, CTRL_C_HINT, CtrlCGuard } from './ctrl-c.js';
|
|
8
8
|
export { enableVtInput } from './console-input.js';
|
|
9
|
+
export { watchClipboard } from './clipboard-watch.js';
|
|
9
10
|
export { OutputFilter } from './output-filter.js';
|
|
@@ -19,7 +19,7 @@ export declare const MOUSE_OFF = "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[
|
|
|
19
19
|
*/
|
|
20
20
|
export declare function runTerminalClient(socket: net.Socket, token: string,
|
|
21
21
|
/** clipboardWrites: the agent's "copy" (OSC 52) reaches the clipboard; see OutputFilterOptions. */
|
|
22
|
-
/** confirmCtrlC: a lone Ctrl+C is held back until
|
|
22
|
+
/** confirmCtrlC (default true): a lone Ctrl+C is held back until pressed again; after a selection it is "copy". */
|
|
23
23
|
options?: {
|
|
24
24
|
detachKey?: number;
|
|
25
25
|
clipboardWrites?: boolean;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FrameReader, encodeFrame, FrameType, resizeFrame, PING_INTERVAL_MS, helloFrame, } from './protocol.js';
|
|
2
2
|
import { OutputFilter } from './output-filter.js';
|
|
3
|
+
import { watchClipboard } from './clipboard-watch.js';
|
|
3
4
|
import { enableVtInput } from './console-input.js';
|
|
4
5
|
import { InputJoiner, JOIN_MS } from './input-joiner.js';
|
|
5
6
|
import { CTRL_C_CONFIRM_MS, CtrlCGuard } from './ctrl-c.js';
|
|
@@ -40,7 +41,7 @@ const VT_WAIT_MS = 10000;
|
|
|
40
41
|
*/
|
|
41
42
|
export function runTerminalClient(socket, token,
|
|
42
43
|
/** clipboardWrites: the agent's "copy" (OSC 52) reaches the clipboard; see OutputFilterOptions. */
|
|
43
|
-
/** confirmCtrlC: a lone Ctrl+C is held back until
|
|
44
|
+
/** confirmCtrlC (default true): a lone Ctrl+C is held back until pressed again; after a selection it is "copy". */
|
|
44
45
|
options = {}) {
|
|
45
46
|
const stdin = process.stdin;
|
|
46
47
|
const stdout = process.stdout;
|
|
@@ -75,6 +76,7 @@ options = {}) {
|
|
|
75
76
|
clearTimeout(joinTimer);
|
|
76
77
|
clearTimeout(hintTimer);
|
|
77
78
|
clearTimeout(vtTimer);
|
|
79
|
+
clipboard.stop();
|
|
78
80
|
socket.destroy();
|
|
79
81
|
// The agent's mouse tracking ends with it: your terminal's wheel scrolls again (and clicks select text). Reports
|
|
80
82
|
// the terminal sent before it read that are dropped here, or they would reach your shell as text.
|
|
@@ -98,24 +100,24 @@ options = {}) {
|
|
|
98
100
|
};
|
|
99
101
|
// Mouse reports the agent has not asked for (motion without 1002/1003 ...) are dropped: see MouseModes.
|
|
100
102
|
const sendInput = (data) => write(keys.push(passthrough ? mouse.filter(data) : data));
|
|
101
|
-
const guard = options.confirmCtrlC
|
|
103
|
+
const guard = new CtrlCGuard({ confirm: options.confirmCtrlC !== false });
|
|
102
104
|
let hintTimer;
|
|
105
|
+
// Copying in your terminal (select) changes the clipboard: the Ctrl+C that follows is "copy", not an interrupt.
|
|
106
|
+
const clipboard = watchClipboard(() => guard.clipboardChanged());
|
|
103
107
|
const onInput = (chunk) => {
|
|
104
108
|
if (options.detachKey !== undefined && chunk.includes(options.detachKey)) {
|
|
105
109
|
finish({ reason: 'detach' });
|
|
106
110
|
return;
|
|
107
111
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
if (decided.send.length === 1 && decided.send[0] === 0x03)
|
|
117
|
-
stdout.write(guard.restoreTitle());
|
|
112
|
+
const decided = guard.push(chunk, Date.now());
|
|
113
|
+
clearTimeout(hintTimer);
|
|
114
|
+
if (decided.held || decided.copied) {
|
|
115
|
+
stdout.write(guard.hintTitle(decided.copied ? 'copy' : 'hold'));
|
|
116
|
+
hintTimer = setTimeout(() => stdout.write(guard.restoreTitle()), CTRL_C_CONFIRM_MS);
|
|
117
|
+
return;
|
|
118
118
|
}
|
|
119
|
+
if (decided.send.length === 1 && decided.send[0] === 0x03)
|
|
120
|
+
stdout.write(guard.restoreTitle());
|
|
119
121
|
clearTimeout(joinTimer);
|
|
120
122
|
sendInput(joiner.push(chunk));
|
|
121
123
|
// A lone Esc key press is a "sequence" that never completes: send it after a moment.
|
|
@@ -159,9 +161,10 @@ options = {}) {
|
|
|
159
161
|
}
|
|
160
162
|
for (const frame of frames) {
|
|
161
163
|
if (frame.type === FrameType.Output) {
|
|
164
|
+
guard.watchClipboard(frame.payload.toString('latin1'));
|
|
162
165
|
const out = filter.push(frame.payload);
|
|
163
166
|
mouse.watch(out);
|
|
164
|
-
guard
|
|
167
|
+
guard.watchOutput(out.toString('latin1'));
|
|
165
168
|
stdout.write(out);
|
|
166
169
|
}
|
|
167
170
|
else if (frame.type === FrameType.Console)
|
|
@@ -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) =>
|
|
232
|
+
return fs.readdirSync(dir).filter((f) => ITEM_NAME.test(f));
|
|
202
233
|
}
|
|
203
234
|
catch {
|
|
204
235
|
return [];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** A second Ctrl+C within this long sends it. */
|
|
2
|
+
export declare const CTRL_C_CONFIRM_MS = 1500;
|
|
3
|
+
/** What the title shows while a Ctrl+C is held back. */
|
|
4
|
+
export declare const CTRL_C_HINT = "Ctrl+C again to send it to the program (Esc stops an agent)";
|
|
5
|
+
/** What the title shows when a Ctrl+C was taken as "copy". */
|
|
6
|
+
export declare const CTRL_C_COPY_HINT = "Copied: Ctrl+C after selecting text is not sent to the program (Esc stops an agent)";
|
|
7
|
+
export type CtrlCAction = 'send' | 'hold' | 'copy';
|
|
8
|
+
export declare class CtrlCDecider {
|
|
9
|
+
private readonly confirm;
|
|
10
|
+
private armedAt;
|
|
11
|
+
private selected;
|
|
12
|
+
constructor(confirm?: boolean);
|
|
13
|
+
/** Text was selected in the program: Ctrl+C is "copy" from now on. */
|
|
14
|
+
selection(): void;
|
|
15
|
+
/** Any other key or a click: Ctrl+C is an interrupt again. */
|
|
16
|
+
otherInput(): void;
|
|
17
|
+
/** What to do with a lone Ctrl+C pressed at `nowMs`. */
|
|
18
|
+
ctrlC(nowMs: number): CtrlCAction;
|
|
19
|
+
}
|
|
20
|
+
/** What typed input holds: the strongest mouse report (a drag, else a click), and whether anything else is in it. */
|
|
21
|
+
export declare function classifyInput(text: string): {
|
|
22
|
+
readonly mouse: 'drag' | 'click' | null;
|
|
23
|
+
readonly keys: boolean;
|
|
24
|
+
};
|
|
25
|
+
/** True when program output asks the terminal to write the clipboard (OSC 52). */
|
|
26
|
+
export declare function hasClipboardWrite(text: string): boolean;
|