@playcraft/cli 0.1.12 → 0.1.14
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/workspace-runtime/serve.js +13 -8
- package/dist/workspace-runtime/server/desktop-host.js +74 -11
- package/dist/workspace-runtime/server/desktop-preview-config.js +81 -0
- package/dist/workspace-runtime/server/desktop-preview-service.js +157 -0
- package/dist/workspace-runtime/server/desktop-preview-static.js +53 -0
- package/dist/workspace-runtime/server/desktop-preview.js +189 -0
- package/dist/workspace-runtime/server/dispatch.js +9 -3
- package/dist/workspace-runtime/server/rfb-watch-gate.js +168 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +24 -9
- package/dist/workspace-runtime/workspaces/prepare.js +11 -3
- package/dist/workspace-runtime/workspaces/registry.js +2 -2
- package/package.json +3 -3
|
@@ -120,19 +120,24 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
120
120
|
state,
|
|
121
121
|
url: `http://${formatHost(config.listen.host)}:${port}`,
|
|
122
122
|
async close() {
|
|
123
|
+
const serverClosed = closeServer(server);
|
|
123
124
|
await closeCliCredentials(state);
|
|
124
125
|
await gitCredentials?.close();
|
|
125
|
-
disposeDesktopHost(state);
|
|
126
|
-
disposeTerminalHost(state);
|
|
127
|
-
await closeServer(server);
|
|
128
126
|
try {
|
|
129
|
-
await
|
|
127
|
+
await disposeDesktopHost(state);
|
|
130
128
|
}
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
finally {
|
|
130
|
+
disposeTerminalHost(state);
|
|
131
|
+
try {
|
|
132
|
+
await native?.close?.();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* adapter shutdown is best-effort */
|
|
136
|
+
}
|
|
137
|
+
await serverClosed;
|
|
138
|
+
await journal?.close();
|
|
139
|
+
lock.release();
|
|
133
140
|
}
|
|
134
|
-
await journal?.close();
|
|
135
|
-
lock.release();
|
|
136
141
|
},
|
|
137
142
|
};
|
|
138
143
|
}
|
|
@@ -2,6 +2,9 @@ import { connect as connectTcp } from 'node:net';
|
|
|
2
2
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
3
3
|
import { desktopHealthSchema, desktopResourceName, runtimeError, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
4
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
5
|
+
import { createRfbWatchGate } from './rfb-watch-gate.js';
|
|
6
|
+
import { DesktopPreviewError, DesktopPreviewHost } from './desktop-preview.js';
|
|
7
|
+
import { WorkspaceContextError } from '../workspaces/context-error.js';
|
|
5
8
|
/**
|
|
6
9
|
* A takeover holds the desktop for this long, whether or not a page is watching, and ends on its
|
|
7
10
|
* own unless the user renews it.
|
|
@@ -24,10 +27,11 @@ export function desktopHost(state) {
|
|
|
24
27
|
}
|
|
25
28
|
return host;
|
|
26
29
|
}
|
|
27
|
-
export function disposeDesktopHost(state) {
|
|
28
|
-
hosts.get(state)
|
|
30
|
+
export async function disposeDesktopHost(state) {
|
|
31
|
+
const host = hosts.get(state);
|
|
29
32
|
hosts.delete(state);
|
|
30
33
|
sources.delete(state);
|
|
34
|
+
await host?.dispose();
|
|
31
35
|
}
|
|
32
36
|
export class DesktopHost {
|
|
33
37
|
state;
|
|
@@ -41,6 +45,8 @@ export class DesktopHost {
|
|
|
41
45
|
holdMs = holdDuration(process.env.PLAYCRAFT_DESKTOP_HOLD_MS);
|
|
42
46
|
holdExpiresAt;
|
|
43
47
|
holdTimer;
|
|
48
|
+
preview;
|
|
49
|
+
disposed = false;
|
|
44
50
|
constructor(state) {
|
|
45
51
|
this.state = state;
|
|
46
52
|
this.source = sources.get(state);
|
|
@@ -66,7 +72,7 @@ export class DesktopHost {
|
|
|
66
72
|
return this.snapshot();
|
|
67
73
|
}
|
|
68
74
|
async handleHttp(request, response, url) {
|
|
69
|
-
if (this.state.journal.destroyed()) {
|
|
75
|
+
if (this.disposed || this.state.journal.destroyed()) {
|
|
70
76
|
writeError(response, 'runtime_destroyed');
|
|
71
77
|
return;
|
|
72
78
|
}
|
|
@@ -74,6 +80,43 @@ export class DesktopHost {
|
|
|
74
80
|
writeJson(response, 200, await this.refresh());
|
|
75
81
|
return;
|
|
76
82
|
}
|
|
83
|
+
if (request.method === 'POST' && url.pathname === '/runtime/desktop/open') {
|
|
84
|
+
await this.refresh();
|
|
85
|
+
if (this.currentStatus() !== 'ready') {
|
|
86
|
+
writeError(response, this.currentStatus());
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (this.holder === 'human') {
|
|
90
|
+
writeJson(response, 200, this.snapshot());
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
this.preview ??= new DesktopPreviewHost(this.state, {
|
|
95
|
+
canNavigate: () => !this.disposed && !this.state.journal.destroyed() && this.holder !== 'human',
|
|
96
|
+
});
|
|
97
|
+
await this.preview.open(url.searchParams.get('workspaceId') ?? undefined);
|
|
98
|
+
if (this.disposed || this.state.journal.destroyed()) {
|
|
99
|
+
writeError(response, 'runtime_destroyed');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const health = await this.refresh();
|
|
103
|
+
if (this.disposed || this.state.journal.destroyed()) {
|
|
104
|
+
writeError(response, 'runtime_destroyed');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (health.status !== 'ready') {
|
|
108
|
+
writeError(response, health.status);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
writeJson(response, 200, health);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
const reason = error instanceof DesktopPreviewError ? error.code
|
|
115
|
+
: error instanceof WorkspaceContextError ? error.message : 'desktop_preview_start_failed';
|
|
116
|
+
writeError(response, reason);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
77
120
|
if (request.method === 'POST' && url.pathname === '/runtime/desktop/takeover') {
|
|
78
121
|
await this.refresh();
|
|
79
122
|
if (this.currentStatus() !== 'ready') {
|
|
@@ -118,7 +161,7 @@ export class DesktopHost {
|
|
|
118
161
|
rejectUpgrade(socket, 401, runtimeError('unauthenticated', 'control_token_required'));
|
|
119
162
|
return;
|
|
120
163
|
}
|
|
121
|
-
if (this.state.journal.destroyed()) {
|
|
164
|
+
if (this.disposed || this.state.journal.destroyed()) {
|
|
122
165
|
rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'runtime_destroyed'));
|
|
123
166
|
return;
|
|
124
167
|
}
|
|
@@ -133,9 +176,11 @@ export class DesktopHost {
|
|
|
133
176
|
}
|
|
134
177
|
this.sockets.handleUpgrade(request, socket, head, (ws) => this.attach(ws, generation));
|
|
135
178
|
}
|
|
136
|
-
dispose() {
|
|
179
|
+
async dispose() {
|
|
180
|
+
this.disposed = true;
|
|
137
181
|
this.release();
|
|
138
182
|
this.sockets.close();
|
|
183
|
+
await this.preview?.dispose();
|
|
139
184
|
}
|
|
140
185
|
release() {
|
|
141
186
|
this.clearHold();
|
|
@@ -190,6 +235,7 @@ export class DesktopHost {
|
|
|
190
235
|
}
|
|
191
236
|
else if (this.upstream) {
|
|
192
237
|
const upstream = new WebSocket(`ws://127.0.0.1:${this.upstream.port}/`);
|
|
238
|
+
const watch = createRfbWatchGate();
|
|
193
239
|
viewer.upstream = upstream;
|
|
194
240
|
viewer.detach = () => upstream.close();
|
|
195
241
|
upstream.on('error', () => {
|
|
@@ -204,12 +250,34 @@ export class DesktopHost {
|
|
|
204
250
|
return;
|
|
205
251
|
}
|
|
206
252
|
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
253
|
+
watch.observeServer(chunk);
|
|
254
|
+
if (watch.failed) {
|
|
255
|
+
ws.close(1008, 'desktop_protocol_error');
|
|
256
|
+
upstream.close();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
207
259
|
send(chunk, isBinary);
|
|
208
260
|
});
|
|
209
261
|
upstream.on('close', () => {
|
|
210
262
|
if (ws.readyState === WebSocket.OPEN)
|
|
211
263
|
ws.close();
|
|
212
264
|
});
|
|
265
|
+
ws.on('message', (data, isBinary) => {
|
|
266
|
+
if (this.generation !== generation)
|
|
267
|
+
return;
|
|
268
|
+
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
269
|
+
if (viewer.upstream?.readyState !== WebSocket.OPEN)
|
|
270
|
+
return;
|
|
271
|
+
const outgoing = watch.clientBytes(chunk, this.holder === 'human');
|
|
272
|
+
if (watch.failed) {
|
|
273
|
+
ws.close(1008, 'desktop_protocol_error');
|
|
274
|
+
upstream.close();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
for (const message of outgoing) {
|
|
278
|
+
viewer.upstream.send(message, { binary: isBinary });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
213
281
|
}
|
|
214
282
|
this.viewers.add(viewer);
|
|
215
283
|
let closed = false;
|
|
@@ -223,12 +291,6 @@ export class DesktopHost {
|
|
|
223
291
|
};
|
|
224
292
|
ws.on('close', close);
|
|
225
293
|
ws.on('error', close);
|
|
226
|
-
ws.on('message', (data, isBinary) => {
|
|
227
|
-
if (this.holder !== 'human' || this.generation !== generation)
|
|
228
|
-
return;
|
|
229
|
-
if (viewer.upstream?.readyState === WebSocket.OPEN)
|
|
230
|
-
viewer.upstream.send(data, { binary: isBinary });
|
|
231
|
-
});
|
|
232
294
|
}
|
|
233
295
|
rejectPlainConnect(response, url) {
|
|
234
296
|
if (this.currentStatus() !== 'ready') {
|
|
@@ -293,6 +355,7 @@ export class DesktopHost {
|
|
|
293
355
|
}
|
|
294
356
|
export function desktopRoute(pathname) {
|
|
295
357
|
return (pathname === '/runtime/desktop/health' ||
|
|
358
|
+
pathname === '/runtime/desktop/open' ||
|
|
296
359
|
pathname === '/runtime/desktop/connect' ||
|
|
297
360
|
pathname === '/runtime/desktop/takeover' ||
|
|
298
361
|
pathname === '/runtime/desktop/renew' ||
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
3
|
+
import { DesktopPreviewError } from './desktop-preview.js';
|
|
4
|
+
import JSON5 from 'json5';
|
|
5
|
+
export async function previewPlan(root) {
|
|
6
|
+
const config = await optionalJson(root, '.playcraft/workspace.json');
|
|
7
|
+
const preview = object(config?.preview);
|
|
8
|
+
if (config?.preview !== undefined) {
|
|
9
|
+
if (!preview || typeof preview !== 'object' || typeof preview.command !== 'string' || !preview.command.trim()
|
|
10
|
+
|| (preview.cwd !== undefined && typeof preview.cwd !== 'string') || (preview.path !== undefined && typeof preview.path !== 'string')) {
|
|
11
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
12
|
+
}
|
|
13
|
+
const cwd = typeof preview.cwd === 'string' ? preview.cwd : '.';
|
|
14
|
+
if (isAbsolute(cwd))
|
|
15
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
16
|
+
let realRoot;
|
|
17
|
+
try {
|
|
18
|
+
realRoot = await realpath(join(root, cwd));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
22
|
+
}
|
|
23
|
+
const resolved = relative(await realpath(root), realRoot);
|
|
24
|
+
if (resolved === '..' || resolved.startsWith(`..${sep}`) || !(await stat(realRoot)).isDirectory()) {
|
|
25
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
26
|
+
}
|
|
27
|
+
return { root: realRoot, command: preview.command, path: entryPath(preview.path), dependencies: await exists(join(realRoot, 'package.json')) };
|
|
28
|
+
}
|
|
29
|
+
const manifest = await optionalJson(root, 'package.json');
|
|
30
|
+
if (manifest) {
|
|
31
|
+
const dev = object(manifest.scripts)?.dev;
|
|
32
|
+
const vite = object(manifest.dependencies)?.vite ?? object(manifest.devDependencies)?.vite;
|
|
33
|
+
if (typeof dev === 'string' && /^\s*vite(?:\s|$)/.test(dev) && typeof vite === 'string') {
|
|
34
|
+
return { root, command: 'pnpm run dev --host 127.0.0.1 --port {port} --strictPort', path: '/', dependencies: true };
|
|
35
|
+
}
|
|
36
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
37
|
+
}
|
|
38
|
+
return { root, path: '/', dependencies: false, welcome: !await exists(join(root, 'index.html')) };
|
|
39
|
+
}
|
|
40
|
+
function entryPath(path) {
|
|
41
|
+
if (path === undefined)
|
|
42
|
+
return '/';
|
|
43
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || path.includes('\\')) {
|
|
44
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
45
|
+
}
|
|
46
|
+
const url = new URL(path, 'http://127.0.0.1');
|
|
47
|
+
if (url.origin !== 'http://127.0.0.1')
|
|
48
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
49
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
50
|
+
}
|
|
51
|
+
async function exists(path) { try {
|
|
52
|
+
return (await stat(path)).isFile();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
} }
|
|
57
|
+
export async function requiresNodeModules(root) {
|
|
58
|
+
const manifest = await optionalJson(root, 'package.json');
|
|
59
|
+
return ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
|
|
60
|
+
.some((key) => Object.keys(object(manifest?.[key]) ?? {}).length > 0);
|
|
61
|
+
}
|
|
62
|
+
async function optionalJson(root, path) {
|
|
63
|
+
try {
|
|
64
|
+
const resolved = await realpath(join(root, path));
|
|
65
|
+
const location = relative(await realpath(root), resolved);
|
|
66
|
+
if (location === '..' || location.startsWith(`..${sep}`))
|
|
67
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
68
|
+
const result = object(JSON5.parse(await readFile(resolved, 'utf8')));
|
|
69
|
+
if (!result)
|
|
70
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code === 'ENOENT')
|
|
75
|
+
return undefined;
|
|
76
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function object(value) {
|
|
80
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
81
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
import { startLauncherProcess } from '../codex/launcher-client.js';
|
|
5
|
+
import { DesktopPreviewError, quote } from './desktop-preview.js';
|
|
6
|
+
// The supervisor runs under the same developer identity as its service. It proves that the
|
|
7
|
+
// loopback listener belongs to its process group before Runtime probes the configured page.
|
|
8
|
+
const supervisor = String.raw `
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const {spawn,execFileSync} = require('node:child_process');
|
|
11
|
+
const [command,portText,marker] = process.argv.slice(1);
|
|
12
|
+
const port = Number(portText);
|
|
13
|
+
const child = spawn(process.platform==='linux'?'/usr/bin/bash':'/bin/bash',['-lc',command],{stdio:['ignore','inherit','inherit']});
|
|
14
|
+
child.on('error',()=>process.exit(97));
|
|
15
|
+
child.on('exit',code=>process.exit(code === 0 ? 97 : code || 97));
|
|
16
|
+
function listeners() {
|
|
17
|
+
if (process.platform === 'linux') {
|
|
18
|
+
const sockets = ['tcp','tcp6'].flatMap(kind => fs.readFileSync('/proc/net/'+kind,'utf8').trim().split('\n').slice(1).flatMap(line=>{
|
|
19
|
+
const columns=line.trim().split(/\s+/); const [address,p]=columns[1].split(':');
|
|
20
|
+
return parseInt(p,16)===port && columns[3]==='0A' ? [{id:columns[9],local:address==='0100007F'||address==='00000000000000000000000001000000'}] : [];
|
|
21
|
+
}));
|
|
22
|
+
const owned=new Set();
|
|
23
|
+
for (const pid of fs.readdirSync('/proc').filter(name=>/^\d+$/.test(name))) {
|
|
24
|
+
try {
|
|
25
|
+
const stat=fs.readFileSync('/proc/'+pid+'/stat','utf8');
|
|
26
|
+
if (Number(stat.slice(stat.lastIndexOf(')')+2).split(' ')[2])!==process.pid) continue;
|
|
27
|
+
for (const fd of fs.readdirSync('/proc/'+pid+'/fd')) {
|
|
28
|
+
try { const match=fs.readlinkSync('/proc/'+pid+'/fd/'+fd).match(/^socket:\[(\d+)\]$/); if(match)owned.add(match[1]); } catch {}
|
|
29
|
+
}
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
return sockets.map(socket=>({local:socket.local,owned:owned.has(socket.id)}));
|
|
33
|
+
}
|
|
34
|
+
const pids=new Set(execFileSync('/bin/ps',['-axo','pid=,pgid='],{encoding:'utf8'}).trim().split('\n').flatMap(line=>{
|
|
35
|
+
const [pid,group]=line.trim().split(/\s+/); return Number(group)===process.pid?[Number(pid)]:[];
|
|
36
|
+
}));
|
|
37
|
+
let listing='';try{listing=execFileSync('/usr/sbin/lsof',['-nP','-iTCP:'+port,'-sTCP:LISTEN','-Fpn'],{encoding:'utf8',stdio:['ignore','pipe','ignore']});}catch{}
|
|
38
|
+
let pid=0;return listing.split('\n').flatMap(line=>{
|
|
39
|
+
if(line.startsWith('p'))pid=Number(line.slice(1));
|
|
40
|
+
return line.startsWith('n')?[{local:line.slice(1).startsWith('127.0.0.1:')||line.slice(1).startsWith('[::1]:'),owned:pids.has(pid)}]:[];
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
let ready=false;
|
|
44
|
+
const timer=setInterval(()=>{
|
|
45
|
+
try {
|
|
46
|
+
const found=listeners();
|
|
47
|
+
if(found.some(socket=>!socket.local||!socket.owned)){console.log(marker+':port');process.exit(98);}
|
|
48
|
+
if(!ready&&found.some(socket=>socket.owned)){ready=true;clearInterval(timer);console.log(marker+':ready');}
|
|
49
|
+
} catch {console.log(marker+':failed');process.exit(97);}
|
|
50
|
+
},50);
|
|
51
|
+
`;
|
|
52
|
+
export async function startManagedPreview(input) {
|
|
53
|
+
const { state, options } = input;
|
|
54
|
+
const socketPath = options.launcherSocket ?? process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
55
|
+
if (!socketPath)
|
|
56
|
+
throw new DesktopPreviewError('desktop_preview_start_failed');
|
|
57
|
+
const port = await reservePort();
|
|
58
|
+
const url = new URL(input.path, `http://127.0.0.1:${port}/`).href;
|
|
59
|
+
const marker = `PLAYCRAFT_PREVIEW_${randomUUID()}`;
|
|
60
|
+
const command = `export HOST=127.0.0.1 PORT=${port} PLAYCRAFT_PREVIEW_PORT=${port}; exec ${quote(process.execPath)} -e ${quote(supervisor)} -- ${quote(input.command.replaceAll('{port}', String(port)))} ${port} ${quote(marker)}`;
|
|
61
|
+
const processHandle = await launchPreviewProcess({ socketPath,
|
|
62
|
+
processId: `desktop-preview-service-${randomUUID()}`, runtimeEpoch: state.runtimeEpoch,
|
|
63
|
+
argv: ['/usr/bin/bash', '-lc', command], cwd: input.root, env: {} }, options, input.deadline, input.cancelled, 'desktop_preview_start_failed');
|
|
64
|
+
if (!processHandle)
|
|
65
|
+
return undefined;
|
|
66
|
+
let alive = true;
|
|
67
|
+
let stopped = false;
|
|
68
|
+
let stopping;
|
|
69
|
+
let owned = false;
|
|
70
|
+
let failure;
|
|
71
|
+
let tail = '';
|
|
72
|
+
processHandle.onExit((code) => { alive = false; failure ??= code === 98 ? 'desktop_preview_port_unavailable' : 'desktop_preview_start_failed'; });
|
|
73
|
+
processHandle.stdout?.on('data', (chunk) => {
|
|
74
|
+
tail = (tail + chunk.toString('utf8')).slice(-8192);
|
|
75
|
+
if (tail.includes(`${marker}:ready`))
|
|
76
|
+
owned = true;
|
|
77
|
+
if (tail.includes(`${marker}:port`))
|
|
78
|
+
failure = 'desktop_preview_port_unavailable';
|
|
79
|
+
if (tail.includes(`${marker}:failed`))
|
|
80
|
+
failure = 'desktop_preview_start_failed';
|
|
81
|
+
});
|
|
82
|
+
processHandle.stderr?.resume();
|
|
83
|
+
const stop = () => stopping ??= processHandle.stop().then(() => { stopped = true; alive = false; });
|
|
84
|
+
try {
|
|
85
|
+
while (Date.now() < input.deadline) {
|
|
86
|
+
if (input.cancelled()) {
|
|
87
|
+
await stop();
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (!alive || failure)
|
|
91
|
+
throw new DesktopPreviewError(failure ?? 'desktop_preview_start_failed');
|
|
92
|
+
if (owned) {
|
|
93
|
+
try {
|
|
94
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(Math.min(500, Math.max(1, input.deadline - Date.now()))) });
|
|
95
|
+
await response.body?.cancel();
|
|
96
|
+
if (response.ok && alive)
|
|
97
|
+
return { url, alive: () => alive && !stopped && !stopping, stop };
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
}
|
|
101
|
+
await delay(options.pollIntervalMs ?? 100);
|
|
102
|
+
}
|
|
103
|
+
throw new DesktopPreviewError('desktop_preview_timeout');
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
await stop();
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Starting the launcher is bounded too; late replies are stopped after cancellation. */
|
|
111
|
+
export async function launchPreviewProcess(input, options, deadline, cancelled, failure) {
|
|
112
|
+
let finished = false;
|
|
113
|
+
let abandoned = false;
|
|
114
|
+
let failed = false;
|
|
115
|
+
let child;
|
|
116
|
+
void (options.startProcess ?? startLauncherProcess)(input).then((value) => {
|
|
117
|
+
child = value;
|
|
118
|
+
finished = true;
|
|
119
|
+
if (abandoned)
|
|
120
|
+
void value.stop().catch(() => undefined);
|
|
121
|
+
}, () => { finished = true; failed = true; });
|
|
122
|
+
while (!finished) {
|
|
123
|
+
if (cancelled()) {
|
|
124
|
+
abandoned = true;
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
if (Date.now() >= deadline) {
|
|
128
|
+
abandoned = true;
|
|
129
|
+
throw new DesktopPreviewError('desktop_preview_timeout');
|
|
130
|
+
}
|
|
131
|
+
await delay(Math.min(options.pollIntervalMs ?? 50, Math.max(1, deadline - Date.now())));
|
|
132
|
+
}
|
|
133
|
+
if (failed || !child)
|
|
134
|
+
throw new DesktopPreviewError(failure);
|
|
135
|
+
if (cancelled()) {
|
|
136
|
+
await child.stop();
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
return child;
|
|
140
|
+
}
|
|
141
|
+
async function reservePort() {
|
|
142
|
+
const server = createServer();
|
|
143
|
+
try {
|
|
144
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
145
|
+
const address = server.address();
|
|
146
|
+
if (!address || typeof address === 'string')
|
|
147
|
+
throw new Error('port_unavailable');
|
|
148
|
+
return address.port;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new DesktopPreviewError('desktop_preview_port_unavailable');
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
if (server.listening)
|
|
155
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { extname, join, relative, sep } from 'node:path';
|
|
4
|
+
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
|
|
5
|
+
'.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml',
|
|
6
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.wasm': 'application/wasm' };
|
|
7
|
+
export async function startStaticPreview(root, welcome = false) {
|
|
8
|
+
const realRoot = await realpath(root);
|
|
9
|
+
const server = createServer((request, response) => {
|
|
10
|
+
void (async () => {
|
|
11
|
+
try {
|
|
12
|
+
const pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname);
|
|
13
|
+
if (welcome && pathname === '/') {
|
|
14
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
15
|
+
response.end(welcomePage());
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
19
|
+
if (segments.some((segment) => segment.startsWith('.')))
|
|
20
|
+
throw new Error('hidden_path');
|
|
21
|
+
const target = await realpath(join(realRoot, ...segments, ...(pathname.endsWith('/') ? ['index.html'] : [])));
|
|
22
|
+
const path = relative(realRoot, target);
|
|
23
|
+
if (path === '..' || path.startsWith(`..${sep}`) || !(await stat(target)).isFile())
|
|
24
|
+
throw new Error('outside_root');
|
|
25
|
+
const body = await readFile(target);
|
|
26
|
+
response.writeHead(200, { 'content-type': types[extname(target)] ?? 'application/octet-stream', 'cache-control': 'no-store' });
|
|
27
|
+
response.end(request.method === 'HEAD' ? undefined : body);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
response.writeHead(404);
|
|
31
|
+
response.end();
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
});
|
|
35
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
36
|
+
const address = server.address();
|
|
37
|
+
if (!address || typeof address === 'string')
|
|
38
|
+
throw new Error('preview_port_unavailable');
|
|
39
|
+
let stopped = false;
|
|
40
|
+
return { url: `http://127.0.0.1:${address.port}/`, alive: () => server.listening && !stopped,
|
|
41
|
+
async stop() {
|
|
42
|
+
if (stopped)
|
|
43
|
+
return;
|
|
44
|
+
stopped = true;
|
|
45
|
+
server.closeAllConnections();
|
|
46
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
47
|
+
} };
|
|
48
|
+
}
|
|
49
|
+
function welcomePage() {
|
|
50
|
+
const messages = { en: { title: 'Your workspace desktop is ready', body: 'Add a project to see its preview here.' },
|
|
51
|
+
zh: { title: '工作区桌面已就绪', body: '添加项目后,这里会显示项目预览。' } };
|
|
52
|
+
return `<!doctype html><html lang="en"><meta charset="utf-8"><title>${messages.en.title}</title><style>body{margin:0;background:#f1f0eb;color:#252724;font:22px system-ui;display:grid;min-height:100vh;place-items:center}main{max-width:680px;padding:48px}h1{font-size:42px}p{line-height:1.7;color:#596159}</style><main><h1>${messages.en.title}</h1><p>${messages.en.body}</p><h2 lang="zh">${messages.zh.title}</h2><p lang="zh">${messages.zh.body}</p></main></html>`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { lstat } from 'node:fs/promises';
|
|
5
|
+
import { workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
|
|
6
|
+
import { startStaticPreview } from './desktop-preview-static.js';
|
|
7
|
+
import { previewPlan, requiresNodeModules } from './desktop-preview-config.js';
|
|
8
|
+
import { launchPreviewProcess, startManagedPreview } from './desktop-preview-service.js';
|
|
9
|
+
import { planInstall, scheduleDependencyInstall, readDependencies } from '../workspaces/dependencies.js';
|
|
10
|
+
export class DesktopPreviewError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code) {
|
|
13
|
+
super(code);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = 'DesktopPreviewError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** One visible browser; project services and browser profiles survive workspace switches. */
|
|
19
|
+
export class DesktopPreviewHost {
|
|
20
|
+
state;
|
|
21
|
+
options;
|
|
22
|
+
pending = new Map();
|
|
23
|
+
servers = new Map();
|
|
24
|
+
emptyDependencyReady = new Map();
|
|
25
|
+
activeBrowser;
|
|
26
|
+
activation = Promise.resolve();
|
|
27
|
+
disposed = false;
|
|
28
|
+
disposal;
|
|
29
|
+
requestedKey;
|
|
30
|
+
constructor(state, options = {}) {
|
|
31
|
+
this.state = state;
|
|
32
|
+
this.options = options;
|
|
33
|
+
}
|
|
34
|
+
open(workspaceId) {
|
|
35
|
+
if (!this.canNavigate())
|
|
36
|
+
return Promise.resolve();
|
|
37
|
+
const key = workspaceId ?? 'single';
|
|
38
|
+
this.requestedKey = key;
|
|
39
|
+
const existing = this.pending.get(key);
|
|
40
|
+
if (existing)
|
|
41
|
+
return existing;
|
|
42
|
+
const task = this.openWorkspace(key, workspaceId).catch((error) => {
|
|
43
|
+
if (error instanceof DesktopPreviewError || error instanceof WorkspaceContextError)
|
|
44
|
+
throw error;
|
|
45
|
+
throw new DesktopPreviewError('desktop_preview_start_failed');
|
|
46
|
+
}).finally(() => this.pending.delete(key));
|
|
47
|
+
this.pending.set(key, task);
|
|
48
|
+
return task;
|
|
49
|
+
}
|
|
50
|
+
dispose() {
|
|
51
|
+
if (this.disposal)
|
|
52
|
+
return this.disposal;
|
|
53
|
+
this.disposed = true;
|
|
54
|
+
this.disposal = (async () => {
|
|
55
|
+
const stopped = await Promise.allSettled([
|
|
56
|
+
...(this.activeBrowser ? [this.activeBrowser.process.stop()] : []),
|
|
57
|
+
...[...this.servers.values()].map(({ server }) => server.stop()),
|
|
58
|
+
]);
|
|
59
|
+
await Promise.allSettled([...this.pending.values()]);
|
|
60
|
+
this.servers.clear();
|
|
61
|
+
this.activeBrowser = undefined;
|
|
62
|
+
if (stopped.some((result) => result.status === 'rejected'))
|
|
63
|
+
throw new DesktopPreviewError('desktop_preview_start_failed');
|
|
64
|
+
})();
|
|
65
|
+
return this.disposal;
|
|
66
|
+
}
|
|
67
|
+
canNavigate() {
|
|
68
|
+
return !this.disposed && !this.state.journal.destroyed() && (this.options.canNavigate?.() ?? true);
|
|
69
|
+
}
|
|
70
|
+
async openWorkspace(key, workspaceId) {
|
|
71
|
+
const root = await workspaceRoot(this.state, workspaceId);
|
|
72
|
+
const deadline = Date.now() + (this.options.timeoutMs ?? 90_000);
|
|
73
|
+
if (!this.canNavigate())
|
|
74
|
+
return;
|
|
75
|
+
const plan = await previewPlan(root);
|
|
76
|
+
if (!this.canNavigate())
|
|
77
|
+
return;
|
|
78
|
+
if (plan.dependencies) {
|
|
79
|
+
const dependencyDeadline = Math.min(deadline, Date.now() + (this.options.dependencyTimeoutMs ?? 60_000));
|
|
80
|
+
if (!await this.ensureDependencies(key, plan.root, dependencyDeadline))
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!this.canNavigate())
|
|
84
|
+
return;
|
|
85
|
+
const signature = JSON.stringify(plan);
|
|
86
|
+
const entry = this.servers.get(key);
|
|
87
|
+
let server = entry?.server;
|
|
88
|
+
if (!server?.alive() || entry?.signature !== signature) {
|
|
89
|
+
await server?.stop();
|
|
90
|
+
if (!this.canNavigate())
|
|
91
|
+
return;
|
|
92
|
+
server = plan.command ? await startManagedPreview({ state: this.state, options: this.options,
|
|
93
|
+
root: plan.root, command: plan.command, path: plan.path, deadline, cancelled: () => !this.canNavigate() }) : await startStaticPreview(root, plan.welcome);
|
|
94
|
+
if (!server)
|
|
95
|
+
return;
|
|
96
|
+
if (!this.canNavigate()) {
|
|
97
|
+
await server.stop();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
this.servers.set(key, { server, signature });
|
|
101
|
+
}
|
|
102
|
+
const current = this.activation.then(() => this.activate(key, root, server, deadline));
|
|
103
|
+
this.activation = current.catch(() => undefined);
|
|
104
|
+
await current;
|
|
105
|
+
}
|
|
106
|
+
async ensureDependencies(key, root, deadline) {
|
|
107
|
+
const initialPlan = await planInstall(root);
|
|
108
|
+
if (initialPlan.kind === 'install' && !await requiresNodeModules(root)
|
|
109
|
+
&& this.emptyDependencyReady.get(root) === initialPlan.lockHash)
|
|
110
|
+
return this.canNavigate();
|
|
111
|
+
let finished = false;
|
|
112
|
+
const install = () => {
|
|
113
|
+
finished = false;
|
|
114
|
+
void scheduleDependencyInstall(this.state, key, root, () => this.canNavigate()).finally(() => { finished = true; });
|
|
115
|
+
};
|
|
116
|
+
if (!this.canNavigate())
|
|
117
|
+
return false;
|
|
118
|
+
install();
|
|
119
|
+
while (Date.now() < deadline) {
|
|
120
|
+
if (!this.canNavigate())
|
|
121
|
+
return false;
|
|
122
|
+
if (finished) {
|
|
123
|
+
const current = await planInstall(root);
|
|
124
|
+
if (!this.canNavigate())
|
|
125
|
+
return false;
|
|
126
|
+
const record = readDependencies(this.state, key);
|
|
127
|
+
if (record.status === 'failed')
|
|
128
|
+
throw new DesktopPreviewError('dependencies_not_ready');
|
|
129
|
+
if (current.kind === 'skip' && record.status === 'none')
|
|
130
|
+
return true;
|
|
131
|
+
if (current.kind === 'install' && record.status === 'ready' && !await requiresNodeModules(root)) {
|
|
132
|
+
this.emptyDependencyReady.set(root, current.lockHash);
|
|
133
|
+
return this.canNavigate();
|
|
134
|
+
}
|
|
135
|
+
if (current.kind === 'install' && record.lockHash === current.lockHash) {
|
|
136
|
+
const modules = await lstat(join(root, 'node_modules')).catch(() => undefined);
|
|
137
|
+
if (record.status === 'ready' && modules?.isDirectory() && !modules.isSymbolicLink())
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
// A changed manifest or lockfile needs a current install before this project can start.
|
|
141
|
+
if (!this.canNavigate())
|
|
142
|
+
return false;
|
|
143
|
+
install();
|
|
144
|
+
}
|
|
145
|
+
await delay(this.options.pollIntervalMs ?? 100);
|
|
146
|
+
}
|
|
147
|
+
if (!this.canNavigate())
|
|
148
|
+
return false;
|
|
149
|
+
throw new DesktopPreviewError('dependencies_not_ready');
|
|
150
|
+
}
|
|
151
|
+
async activate(key, root, server, deadline) {
|
|
152
|
+
const canActivate = () => this.canNavigate() && this.requestedKey === key;
|
|
153
|
+
if (!canActivate())
|
|
154
|
+
return;
|
|
155
|
+
if (this.activeBrowser?.alive && this.activeBrowser.key === key && this.activeBrowser.server === server)
|
|
156
|
+
return;
|
|
157
|
+
if (Date.now() >= deadline)
|
|
158
|
+
throw new DesktopPreviewError('desktop_preview_timeout');
|
|
159
|
+
const socketPath = this.options.launcherSocket ?? process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
160
|
+
if (!socketPath)
|
|
161
|
+
throw new DesktopPreviewError('desktop_browser_unavailable');
|
|
162
|
+
if (this.activeBrowser)
|
|
163
|
+
await this.activeBrowser.process.stop();
|
|
164
|
+
this.activeBrowser = undefined;
|
|
165
|
+
if (!canActivate())
|
|
166
|
+
return;
|
|
167
|
+
const profile = join(this.options.profileRoot ?? '/home/playcraft-dev/.cache/playcraft-desktop', key);
|
|
168
|
+
const executable = this.options.browserExecutable ?? '/usr/bin/chromium';
|
|
169
|
+
const command = `test -x ${quote(executable)} || exit 127; mkdir -p -- ${quote(profile)} || exit 1; exec ${quote(executable)} --no-first-run --no-default-browser-check --disable-dev-shm-usage --start-maximized --user-data-dir=${quote(profile)} --app=${quote(server.url)}`;
|
|
170
|
+
const child = await launchPreviewProcess({ socketPath,
|
|
171
|
+
processId: `desktop-preview-browser-${randomUUID()}`, runtimeEpoch: this.state.runtimeEpoch,
|
|
172
|
+
argv: ['/usr/bin/bash', '-lc', command], cwd: root, env: { DISPLAY: process.env.DISPLAY ?? ':0' } }, this.options, deadline, () => !canActivate(), 'desktop_browser_start_failed');
|
|
173
|
+
if (!child)
|
|
174
|
+
return;
|
|
175
|
+
const browser = { key, server, process: child, alive: true };
|
|
176
|
+
let exitCode;
|
|
177
|
+
child.onExit((code) => { browser.alive = false; exitCode = code; });
|
|
178
|
+
child.stdout?.resume();
|
|
179
|
+
child.stderr?.resume();
|
|
180
|
+
this.activeBrowser = browser;
|
|
181
|
+
await delay(150);
|
|
182
|
+
if (!browser.alive) {
|
|
183
|
+
await child.stop();
|
|
184
|
+
this.activeBrowser = undefined;
|
|
185
|
+
throw new DesktopPreviewError(exitCode === 127 ? 'desktop_browser_unavailable' : 'desktop_browser_start_failed');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function quote(value) { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
@@ -10,6 +10,7 @@ import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.
|
|
|
10
10
|
import { enableLocalProjectSkill, syncLocalProjectSkills } from '../../project-skills/local-store.js';
|
|
11
11
|
import { NativePreparationError } from '../codex/native-adapter.js';
|
|
12
12
|
import { disposeTerminalHost } from './terminal-host.js';
|
|
13
|
+
import { disposeDesktopHost } from './desktop-host.js';
|
|
13
14
|
import { captureWorkspaceTree, liveTreeDigest, materializeSnapshot, publishFileSnapshot, } from '../workspaces/file-snapshot.js';
|
|
14
15
|
import { objectStoreHeaders, oneSaveAtATime, rememberFileCheckpoint, unchangedCheckpoint, workspaceSaves, } from '../workspaces/checkpoint-saves.js';
|
|
15
16
|
import { InvalidWorkspacePathError, resolveWorkspaceFile, restoreWorkspaceFiles, snapshotWorkspaceFiles, writeWorkspaceFile, } from '../workspaces/files.js';
|
|
@@ -590,10 +591,15 @@ async function executeDestroy(state, scopeKey, operation) {
|
|
|
590
591
|
await state.gitCredentials?.destroy();
|
|
591
592
|
disposeTerminalHost(state);
|
|
592
593
|
try {
|
|
593
|
-
await state
|
|
594
|
+
await disposeDesktopHost(state);
|
|
594
595
|
}
|
|
595
|
-
|
|
596
|
-
|
|
596
|
+
finally {
|
|
597
|
+
try {
|
|
598
|
+
await state.native?.close?.();
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
/* Codex process shutdown is best-effort once the runtime is destroyed */
|
|
602
|
+
}
|
|
597
603
|
}
|
|
598
604
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
599
605
|
await recordNativeEvents(state, accepted, [
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFB (Remote Framebuffer, 远程帧缓冲) viewing needs a handshake and picture requests.
|
|
3
|
+
* This gate supports the sandbox's version 3.8 / None security negotiation.
|
|
4
|
+
* Input requires a hold for the entire message; unsupported framing closes the stream.
|
|
5
|
+
*/
|
|
6
|
+
const INPUT_TYPES = new Set([4, 5, 6, 250, 251, 255]);
|
|
7
|
+
const VERSION = Buffer.from('RFB 003.008\n');
|
|
8
|
+
const MAX_CLIPBOARD_BYTES = 1_000_000;
|
|
9
|
+
export function createRfbWatchGate() {
|
|
10
|
+
let server = Buffer.alloc(0);
|
|
11
|
+
let client = Buffer.alloc(0);
|
|
12
|
+
let phase = 'server-version';
|
|
13
|
+
let messageInputAllowed = false;
|
|
14
|
+
const fail = () => {
|
|
15
|
+
phase = 'failed';
|
|
16
|
+
server = Buffer.alloc(0);
|
|
17
|
+
client = Buffer.alloc(0);
|
|
18
|
+
};
|
|
19
|
+
const observeServer = (chunk) => {
|
|
20
|
+
if (phase === 'ready' || phase === 'failed')
|
|
21
|
+
return;
|
|
22
|
+
let offset = 0;
|
|
23
|
+
while (offset < chunk.length) {
|
|
24
|
+
let size = serverMessageSize(server, phase);
|
|
25
|
+
if (size === 'invalid') {
|
|
26
|
+
fail();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const count = Math.min(size - server.length, chunk.length - offset);
|
|
30
|
+
server = Buffer.concat([server, chunk.subarray(offset, offset + count)]);
|
|
31
|
+
offset += count;
|
|
32
|
+
size = serverMessageSize(server, phase);
|
|
33
|
+
if (size === 'invalid') {
|
|
34
|
+
fail();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (server.length < size)
|
|
38
|
+
continue;
|
|
39
|
+
if (phase === 'server-version' && server.equals(VERSION))
|
|
40
|
+
phase = 'client-version';
|
|
41
|
+
else if (phase === 'server-types' && server.subarray(1).includes(1))
|
|
42
|
+
phase = 'client-security';
|
|
43
|
+
else if (phase === 'server-result' && server.readUInt32BE(0) === 0)
|
|
44
|
+
phase = 'client-init';
|
|
45
|
+
else if (phase === 'server-init')
|
|
46
|
+
phase = 'ready';
|
|
47
|
+
else {
|
|
48
|
+
fail();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
server = Buffer.alloc(0);
|
|
52
|
+
// The first picture can share the ServerInit chunk; it needs no buffering.
|
|
53
|
+
if (phase === 'ready')
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const clientBytes = (chunk, allowInput) => {
|
|
58
|
+
if (phase === 'failed')
|
|
59
|
+
return [];
|
|
60
|
+
const outgoing = [];
|
|
61
|
+
let offset = 0;
|
|
62
|
+
if (client.length > 0)
|
|
63
|
+
messageInputAllowed &&= allowInput;
|
|
64
|
+
while (offset < chunk.length) {
|
|
65
|
+
if (client.length === 0)
|
|
66
|
+
messageInputAllowed = allowInput;
|
|
67
|
+
let size = phase === 'client-version' ? 12
|
|
68
|
+
: phase === 'client-security' || phase === 'client-init' ? 1
|
|
69
|
+
: phase === 'server-init' || phase === 'ready' ? clientMessageSize(client)
|
|
70
|
+
: 'invalid';
|
|
71
|
+
if (size === 'invalid') {
|
|
72
|
+
fail();
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const count = Math.min(size - client.length, chunk.length - offset);
|
|
76
|
+
client = Buffer.concat([client, chunk.subarray(offset, offset + count)]);
|
|
77
|
+
offset += count;
|
|
78
|
+
if (phase === 'server-init' || phase === 'ready')
|
|
79
|
+
size = clientMessageSize(client);
|
|
80
|
+
if (size === 'invalid') {
|
|
81
|
+
fail();
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
if (client.length < size)
|
|
85
|
+
continue;
|
|
86
|
+
if (phase === 'client-version' && client.equals(VERSION))
|
|
87
|
+
phase = 'server-types';
|
|
88
|
+
else if (phase === 'client-security' && client[0] === 1)
|
|
89
|
+
phase = 'server-result';
|
|
90
|
+
else if (phase === 'client-init' && client[0] <= 1)
|
|
91
|
+
phase = 'server-init';
|
|
92
|
+
else if (phase !== 'server-init' && phase !== 'ready') {
|
|
93
|
+
fail();
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
else if (INPUT_TYPES.has(client[0]) && !messageInputAllowed) {
|
|
97
|
+
client = Buffer.alloc(0);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
outgoing.push(client);
|
|
101
|
+
client = Buffer.alloc(0);
|
|
102
|
+
}
|
|
103
|
+
return outgoing;
|
|
104
|
+
};
|
|
105
|
+
return { get failed() { return phase === 'failed'; }, observeServer, clientBytes };
|
|
106
|
+
}
|
|
107
|
+
function serverMessageSize(buffer, phase) {
|
|
108
|
+
if (phase === 'server-version')
|
|
109
|
+
return 12;
|
|
110
|
+
if (phase === 'server-types') {
|
|
111
|
+
if (buffer.length < 1)
|
|
112
|
+
return 1;
|
|
113
|
+
return buffer[0] === 0 ? 'invalid' : 1 + buffer[0];
|
|
114
|
+
}
|
|
115
|
+
if (phase === 'server-result')
|
|
116
|
+
return 4;
|
|
117
|
+
if (phase === 'server-init') {
|
|
118
|
+
if (buffer.length < 24)
|
|
119
|
+
return 24;
|
|
120
|
+
const length = buffer.readUInt32BE(20);
|
|
121
|
+
return length > 4096 ? 'invalid' : 24 + length;
|
|
122
|
+
}
|
|
123
|
+
return 'invalid';
|
|
124
|
+
}
|
|
125
|
+
function clientMessageSize(buffer) {
|
|
126
|
+
if (buffer.length === 0)
|
|
127
|
+
return 1;
|
|
128
|
+
const type = buffer[0];
|
|
129
|
+
if (type === 0)
|
|
130
|
+
return 20;
|
|
131
|
+
if (type === 3 || type === 150)
|
|
132
|
+
return 10;
|
|
133
|
+
if (type === 4)
|
|
134
|
+
return 8;
|
|
135
|
+
if (type === 5)
|
|
136
|
+
return 6;
|
|
137
|
+
if (type === 250)
|
|
138
|
+
return 4;
|
|
139
|
+
if (type === 2) {
|
|
140
|
+
if (buffer.length < 4)
|
|
141
|
+
return 4;
|
|
142
|
+
const count = buffer.readUInt16BE(2);
|
|
143
|
+
return count > 64 ? 'invalid' : 4 + count * 4;
|
|
144
|
+
}
|
|
145
|
+
if (type === 6) {
|
|
146
|
+
if (buffer.length < 8)
|
|
147
|
+
return 8;
|
|
148
|
+
// Extended clipboard messages encode their payload size as a negative length.
|
|
149
|
+
const length = Math.abs(buffer.readInt32BE(4));
|
|
150
|
+
return length > MAX_CLIPBOARD_BYTES ? 'invalid' : 8 + length;
|
|
151
|
+
}
|
|
152
|
+
if (type === 248) {
|
|
153
|
+
if (buffer.length < 9)
|
|
154
|
+
return 9;
|
|
155
|
+
return buffer[8] > 64 ? 'invalid' : 9 + buffer[8];
|
|
156
|
+
}
|
|
157
|
+
if (type === 251) {
|
|
158
|
+
if (buffer.length < 8)
|
|
159
|
+
return 8;
|
|
160
|
+
return buffer[6] > 16 ? 'invalid' : 8 + buffer[6] * 16;
|
|
161
|
+
}
|
|
162
|
+
if (type === 255) {
|
|
163
|
+
if (buffer.length < 2)
|
|
164
|
+
return 2;
|
|
165
|
+
return buffer[1] === 0 ? 12 : 'invalid';
|
|
166
|
+
}
|
|
167
|
+
return 'invalid';
|
|
168
|
+
}
|
|
@@ -31,26 +31,41 @@ const running = new WeakMap();
|
|
|
31
31
|
/**
|
|
32
32
|
* Starts the install after a workspace is registered; opening the workspace never waits for it.
|
|
33
33
|
* Registering again retries a failed install and reinstalls after the manifest or lockfile
|
|
34
|
-
* changed. One install runs per workspace at a time:
|
|
35
|
-
*
|
|
34
|
+
* changed. One install runs per workspace at a time: the same directory shares its task; another
|
|
35
|
+
* directory waits for it before installing. The returned task covers the requested directory.
|
|
36
36
|
*/
|
|
37
|
-
export function scheduleDependencyInstall(state, workspaceId, root) {
|
|
37
|
+
export function scheduleDependencyInstall(state, workspaceId, root, shouldStart) {
|
|
38
38
|
const inFlight = running.get(state) ?? new Map();
|
|
39
39
|
running.set(state, inFlight);
|
|
40
40
|
const existing = inFlight.get(workspaceId);
|
|
41
|
-
if (existing)
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
if (existing?.root === root) {
|
|
42
|
+
if (!shouldStart)
|
|
43
|
+
existing.guards = undefined;
|
|
44
|
+
else
|
|
45
|
+
existing.guards?.push(shouldStart);
|
|
46
|
+
return existing.task;
|
|
47
|
+
}
|
|
48
|
+
const scheduled = { root, task: Promise.resolve(), guards: shouldStart ? [shouldStart] : undefined };
|
|
49
|
+
const begin = () => {
|
|
50
|
+
if (state.journal.destroyed() || (scheduled.guards && !scheduled.guards.some((guard) => guard())))
|
|
51
|
+
return;
|
|
52
|
+
return installOnce(state, workspaceId, root);
|
|
53
|
+
};
|
|
54
|
+
const task = (existing ? existing.task.then(begin) : begin() ?? Promise.resolve())
|
|
44
55
|
.catch((error) => {
|
|
45
56
|
console.error(`[workspace-runtime] dependency install for ${workspaceId} failed to run`, error);
|
|
46
57
|
})
|
|
47
|
-
.finally(() => inFlight.
|
|
48
|
-
|
|
58
|
+
.finally(() => { if (inFlight.get(workspaceId)?.task === task)
|
|
59
|
+
inFlight.delete(workspaceId); });
|
|
60
|
+
scheduled.task = task;
|
|
61
|
+
inFlight.set(workspaceId, scheduled);
|
|
49
62
|
return task;
|
|
50
63
|
}
|
|
51
64
|
/** Settles every install started in this process; used by tests and shutdown. */
|
|
52
65
|
export async function settleDependencyInstalls(state) {
|
|
53
|
-
|
|
66
|
+
while (running.get(state)?.size) {
|
|
67
|
+
await Promise.all([...(running.get(state)?.values() ?? [])].map((record) => record.task));
|
|
68
|
+
}
|
|
54
69
|
}
|
|
55
70
|
export function readDependencies(state, workspaceId) {
|
|
56
71
|
return state.journal.getWorkspaceDependencies(workspaceId) ?? { workspaceId, status: 'pending' };
|
|
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, sameWorkspaceRegistration, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { WorkspaceContextError } from './context-error.js';
|
|
7
7
|
import { scaffoldEmptyWorkspace } from './empty-template.js';
|
|
8
8
|
/** The caller holds the Workspace lock. Only an absent, never-published directory may be prepared. */
|
|
@@ -29,7 +29,11 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
29
29
|
throw new WorkspaceContextError('workspace_recovery_required');
|
|
30
30
|
if (await exists(target))
|
|
31
31
|
throw new WorkspaceContextError('workspace_checkout_conflict');
|
|
32
|
-
|
|
32
|
+
// An unfinished checkout recorded before the fork instructions were known must be cloned again.
|
|
33
|
+
const forkMatches = !previous || samePendingFork(previous.binding, binding);
|
|
34
|
+
if (previous && !forkMatches)
|
|
35
|
+
await rm(join(parent, previous.stagingName), { recursive: true, force: true });
|
|
36
|
+
const receipt = previous && forkMatches ? previous : {
|
|
33
37
|
binding,
|
|
34
38
|
stagingName: `.prepare-${binding.workspaceId}-${randomUUID()}`,
|
|
35
39
|
status: 'cloning',
|
|
@@ -172,11 +176,15 @@ async function exists(path) {
|
|
|
172
176
|
throw error;
|
|
173
177
|
}
|
|
174
178
|
}
|
|
179
|
+
function samePendingFork(left, right) {
|
|
180
|
+
const fork = (value) => value.targetKind === 'standalone' ? undefined : value.pendingFork;
|
|
181
|
+
return JSON.stringify(fork(left) ?? null) === JSON.stringify(fork(right) ?? null);
|
|
182
|
+
}
|
|
175
183
|
async function readReceipt(path, binding) {
|
|
176
184
|
try {
|
|
177
185
|
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
178
186
|
const parsed = workspaceRegistrationSchema.parse(value.binding);
|
|
179
|
-
if (
|
|
187
|
+
if (!sameWorkspaceRegistration(parsed, binding) ||
|
|
180
188
|
!['cloning', 'ready', 'published'].includes(value.status) ||
|
|
181
189
|
!new RegExp(`^\\.prepare-${binding.workspaceId}-[0-9a-f-]{36}$`).test(value.stagingName))
|
|
182
190
|
throw new Error();
|
|
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { lstat, realpath } from 'node:fs/promises';
|
|
3
3
|
import { join, relative } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
-
import { isLocalWorkspaceGitAuthMode, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { isLocalWorkspaceGitAuthMode, sameWorkspaceRegistration, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { prepareWorkspaceCheckout } from './prepare.js';
|
|
7
7
|
import { WorkspaceContextError } from './context-error.js';
|
|
8
8
|
import { scheduleDependencyInstall } from './dependencies.js';
|
|
@@ -64,7 +64,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
64
64
|
state.acceptedLeaseFence = leaseFence;
|
|
65
65
|
await state.journal.setAcceptedLeaseFence(leaseFence);
|
|
66
66
|
const existing = state.journal.getWorkspace(binding.workspaceId);
|
|
67
|
-
if (existing &&
|
|
67
|
+
if (existing && !sameWorkspaceRegistration(existing, binding))
|
|
68
68
|
throw new WorkspaceContextError('workspace_binding_conflict');
|
|
69
69
|
let root;
|
|
70
70
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@gltf-transform/core": "^4.3.0",
|
|
49
49
|
"@gltf-transform/extensions": "^4.3.0",
|
|
50
50
|
"@gltf-transform/functions": "^4.3.0",
|
|
51
|
-
"@playcraft/build": "^0.1.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
51
|
+
"@playcraft/build": "^0.1.14",
|
|
52
|
+
"@playcraft/common": "^0.1.14",
|
|
53
53
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
54
54
|
"chokidar": "^4.0.3",
|
|
55
55
|
"commander": "^13.1.0",
|