@filsilva/helios-cli 0.10.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/README.md +171 -0
- package/bin/helios.js +34 -0
- package/dist/client/assets/HeliosSessionWorker.browser-BYYjDIKH.js +3 -0
- package/dist/client/assets/HeliosSessionWorker.browser-BYYjDIKH.js.map +1 -0
- package/dist/client/assets/d3force3dWorker-BKANL9of.js +2 -0
- package/dist/client/assets/d3force3dWorker-BKANL9of.js.map +1 -0
- package/dist/client/assets/index-CP7mSmLx.js +9530 -0
- package/dist/client/assets/index-CP7mSmLx.js.map +1 -0
- package/dist/client/assets/layoutWorker-Lc8iIdmf.js +2 -0
- package/dist/client/assets/layoutWorker-Lc8iIdmf.js.map +1 -0
- package/dist/client/index.html +27 -0
- package/package.json +40 -0
- package/skills/helios-cli/SKILL.md +118 -0
- package/skills/helios-cli/references/behaviors.md +47 -0
- package/skills/helios-cli/references/layouts.md +77 -0
- package/skills/helios-cli/references/mappers.md +119 -0
- package/skills/helios-cli/references/metrics.md +83 -0
- package/skills/helios-cli/references/networks.md +53 -0
- package/skills/helios-cli/references/persistence.md +136 -0
- package/skills/helios-cli/references/positions.md +63 -0
- package/skills/helios-cli/references/rendering-export.md +56 -0
- package/skills/helios-cli/references/rpc-methods.md +83 -0
- package/src/cli.js +488 -0
- package/src/client/index.html +27 -0
- package/src/client/main.js +2210 -0
- package/src/daemon/SessionDaemon.js +1065 -0
- package/src/daemon/entry.js +36 -0
- package/src/protocol/jsonl.js +88 -0
- package/src/shared/cliConfig.js +52 -0
- package/src/shared/fileSessionStore.js +202 -0
- package/src/shared/fs.js +59 -0
- package/src/shared/networkFormats.js +55 -0
- package/src/shared/networkInspect.js +81 -0
- package/src/shared/paths.js +43 -0
- package/src/shared/sessionClient.js +88 -0
- package/src/shared/sessionId.js +5 -0
- package/src/shared/sessionRegistry.js +53 -0
- package/src/shared/sessionSurfaces.js +199 -0
- package/vite.config.js +47 -0
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { chromium } from 'playwright';
|
|
8
|
+
import { WebSocketServer } from 'ws';
|
|
9
|
+
import { createJsonLineParser, encodeMessage } from '../protocol/jsonl.js';
|
|
10
|
+
import { ensureClientBundle, ensureStateDirs } from '../shared/fs.js';
|
|
11
|
+
import { decodeBinaryFromJson, encodeBinaryForJson, FileSessionStore } from '../shared/fileSessionStore.js';
|
|
12
|
+
import { inferNetworkFormat } from '../shared/networkFormats.js';
|
|
13
|
+
import { clientDistDir, sessionSocketPath, sessionStatePath, stateRoot, storageSessionsDir } from '../shared/paths.js';
|
|
14
|
+
import {
|
|
15
|
+
deleteSessionMeta,
|
|
16
|
+
loadSessionState,
|
|
17
|
+
saveSessionMeta,
|
|
18
|
+
saveSessionState,
|
|
19
|
+
} from '../shared/sessionRegistry.js';
|
|
20
|
+
|
|
21
|
+
function mimeTypeForExtension(filePath) {
|
|
22
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
23
|
+
switch (extension) {
|
|
24
|
+
case '.html': return 'text/html; charset=utf-8';
|
|
25
|
+
case '.js': return 'text/javascript; charset=utf-8';
|
|
26
|
+
case '.css': return 'text/css; charset=utf-8';
|
|
27
|
+
case '.json': return 'application/json; charset=utf-8';
|
|
28
|
+
case '.map': return 'application/json; charset=utf-8';
|
|
29
|
+
case '.wasm': return 'application/wasm';
|
|
30
|
+
case '.svg': return 'image/svg+xml';
|
|
31
|
+
case '.png': return 'image/png';
|
|
32
|
+
default: return 'application/octet-stream';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function encodeBufferBase64(buffer) {
|
|
37
|
+
return Buffer.from(buffer).toString('base64');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function decodeBase64ToBuffer(value) {
|
|
41
|
+
return Buffer.from(String(value ?? ''), 'base64');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writableNetworkOutputPath(outputPath, format) {
|
|
45
|
+
if (!outputPath) return outputPath;
|
|
46
|
+
const normalizedFormat = String(format ?? '').toLowerCase();
|
|
47
|
+
return normalizedFormat === 'gt' && String(outputPath).toLowerCase().endsWith('.gt.zst')
|
|
48
|
+
? outputPath.slice(0, -4)
|
|
49
|
+
: outputPath;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sanitizeDownloadFilename(value, fallback = 'helios-download') {
|
|
53
|
+
const text = String(value ?? '').trim().replace(/[\\/:*?"<>|\u0000-\u001F]/g, '_');
|
|
54
|
+
return text || fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function uniqueDownloadPath(directory, filename) {
|
|
58
|
+
const safeFilename = sanitizeDownloadFilename(filename);
|
|
59
|
+
const extension = path.extname(safeFilename);
|
|
60
|
+
const stem = extension ? safeFilename.slice(0, -extension.length) : safeFilename;
|
|
61
|
+
let candidate = path.join(directory, safeFilename);
|
|
62
|
+
for (let index = 1; index < 1000; index += 1) {
|
|
63
|
+
try {
|
|
64
|
+
await fs.access(candidate);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error?.code === 'ENOENT') return candidate;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
candidate = path.join(directory, `${stem}-${index}${extension}`);
|
|
70
|
+
}
|
|
71
|
+
return path.join(directory, `${stem}-${Date.now()}${extension}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeLayoutValue(value, fallback = 'gpu-force') {
|
|
75
|
+
const normalized = String(value ?? fallback).trim().toLowerCase();
|
|
76
|
+
if (normalized === 'static' || normalized === 'none') return 'static';
|
|
77
|
+
if (normalized === 'd3force3d' || normalized === 'd3-force-3d') return 'd3force3d';
|
|
78
|
+
if (normalized === 'worker:jitter' || normalized === 'jitter') return 'worker:jitter';
|
|
79
|
+
if (normalized === 'worker:force3d' || normalized === 'worker' || normalized === 'force3d') return 'worker:force3d';
|
|
80
|
+
return 'gpu-force';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeActualRenderer(value) {
|
|
84
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
85
|
+
if (normalized === 'webgpu') return 'webgpu';
|
|
86
|
+
if (normalized === 'webgl2' || normalized === 'webgl') return 'webgl2';
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function evaluateManagedGpuPolicy({
|
|
91
|
+
mode = 'headed',
|
|
92
|
+
rendererPreference = 'auto',
|
|
93
|
+
noGpu = false,
|
|
94
|
+
actualRenderer = null,
|
|
95
|
+
webgl = null,
|
|
96
|
+
webgpu = null,
|
|
97
|
+
} = {}) {
|
|
98
|
+
const normalizedRenderer = normalizeActualRenderer(actualRenderer);
|
|
99
|
+
const webglHardware = Boolean(webgl?.hardware);
|
|
100
|
+
const webgpuHardware = Boolean(webgpu && webgpu.isFallbackAdapter !== true);
|
|
101
|
+
|
|
102
|
+
if (noGpu) {
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
actualRenderer: normalizedRenderer,
|
|
106
|
+
fallbackUsed: false,
|
|
107
|
+
allowSoftware: true,
|
|
108
|
+
reason: 'GPU requirement disabled by --no-gpu',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!webglHardware && !webgpuHardware) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
actualRenderer: normalizedRenderer,
|
|
116
|
+
fallbackUsed: false,
|
|
117
|
+
allowSoftware: false,
|
|
118
|
+
reason: 'No hardware-accelerated WebGPU or WebGL renderer is available',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (rendererPreference === 'webgl') {
|
|
123
|
+
if (normalizedRenderer !== 'webgl2' || !webglHardware) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
actualRenderer: normalizedRenderer,
|
|
127
|
+
fallbackUsed: false,
|
|
128
|
+
allowSoftware: false,
|
|
129
|
+
reason: 'Renderer webgl requires a hardware-accelerated WebGL2 runtime',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
actualRenderer: 'webgl2',
|
|
135
|
+
fallbackUsed: false,
|
|
136
|
+
allowSoftware: false,
|
|
137
|
+
reason: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (rendererPreference === 'webgpu') {
|
|
142
|
+
if (normalizedRenderer === 'webgpu' && webgpuHardware) {
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
actualRenderer: 'webgpu',
|
|
146
|
+
fallbackUsed: false,
|
|
147
|
+
allowSoftware: false,
|
|
148
|
+
reason: null,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (mode === 'headless' && normalizedRenderer === 'webgl2' && webglHardware && !webgpuHardware) {
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
actualRenderer: 'webgl2',
|
|
155
|
+
fallbackUsed: true,
|
|
156
|
+
allowSoftware: false,
|
|
157
|
+
reason: 'Headless session fell back from WebGPU to WebGL2 because WebGPU was unavailable',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
actualRenderer: normalizedRenderer,
|
|
163
|
+
fallbackUsed: false,
|
|
164
|
+
allowSoftware: false,
|
|
165
|
+
reason: mode === 'headless'
|
|
166
|
+
? 'Renderer webgpu requires hardware WebGPU or the allowed headless fallback to hardware WebGL2'
|
|
167
|
+
: 'Renderer webgpu requires hardware WebGPU',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (normalizedRenderer === 'webgpu' && webgpuHardware) {
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
actualRenderer: 'webgpu',
|
|
175
|
+
fallbackUsed: false,
|
|
176
|
+
allowSoftware: false,
|
|
177
|
+
reason: null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (normalizedRenderer === 'webgl2' && webglHardware) {
|
|
182
|
+
return {
|
|
183
|
+
ok: true,
|
|
184
|
+
actualRenderer: 'webgl2',
|
|
185
|
+
fallbackUsed: false,
|
|
186
|
+
allowSoftware: false,
|
|
187
|
+
reason: null,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
actualRenderer: normalizedRenderer,
|
|
194
|
+
fallbackUsed: false,
|
|
195
|
+
allowSoftware: false,
|
|
196
|
+
reason: 'Renderer initialized without a supported hardware-accelerated backend',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export class SessionDaemon {
|
|
201
|
+
constructor(config = {}) {
|
|
202
|
+
this.config = {
|
|
203
|
+
mode: 'headed',
|
|
204
|
+
open: false,
|
|
205
|
+
renderer: 'webgpu',
|
|
206
|
+
layout: 'gpu-force',
|
|
207
|
+
runtime: 'cli',
|
|
208
|
+
browserChannel: null,
|
|
209
|
+
sessionId: null,
|
|
210
|
+
networkPath: null,
|
|
211
|
+
noGpu: false,
|
|
212
|
+
...config,
|
|
213
|
+
};
|
|
214
|
+
this.config.layout = normalizeLayoutValue(this.config.layout);
|
|
215
|
+
this.sessionId = this.config.sessionId;
|
|
216
|
+
this.socketPath = sessionSocketPath(this.sessionId);
|
|
217
|
+
this.httpServer = null;
|
|
218
|
+
this.wsServer = null;
|
|
219
|
+
this.controlServer = null;
|
|
220
|
+
this.browser = null;
|
|
221
|
+
this.browserContext = null;
|
|
222
|
+
this.browserPage = null;
|
|
223
|
+
this.bridgeSocket = null;
|
|
224
|
+
this.bridgeReady = false;
|
|
225
|
+
this.bridgeRequests = new Map();
|
|
226
|
+
this.bridgeWaiters = new Set();
|
|
227
|
+
this.controlConnections = new Set();
|
|
228
|
+
this.subscriptions = new Map();
|
|
229
|
+
this.pendingStop = false;
|
|
230
|
+
this.metadata = null;
|
|
231
|
+
this.nextConnectionId = 1;
|
|
232
|
+
this.gpu = null;
|
|
233
|
+
this.sessionStore = new FileSessionStore();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async start() {
|
|
237
|
+
await ensureStateDirs();
|
|
238
|
+
await ensureClientBundle();
|
|
239
|
+
await fs.mkdir(path.dirname(this.socketPath), { recursive: true });
|
|
240
|
+
if (process.platform !== 'win32') {
|
|
241
|
+
try {
|
|
242
|
+
await fs.unlink(this.socketPath);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
this.metadata = this.buildMetadata({ status: 'starting' });
|
|
249
|
+
await saveSessionMeta(this.sessionId, this.metadata);
|
|
250
|
+
await this.startHttpServer();
|
|
251
|
+
await this.startBridgeServer();
|
|
252
|
+
await this.startControlServer();
|
|
253
|
+
if (this.config.mode === 'headed' || this.config.mode === 'headless') {
|
|
254
|
+
await this.launchManagedBrowser();
|
|
255
|
+
} else if (this.config.open) {
|
|
256
|
+
await this.openExternalBrowser();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this.metadata = this.buildMetadata({ status: 'ready' });
|
|
260
|
+
await saveSessionMeta(this.sessionId, this.metadata);
|
|
261
|
+
|
|
262
|
+
if (this.config.networkPath) {
|
|
263
|
+
this.waitForBridge({ timeoutMs: 30_000 })
|
|
264
|
+
.then(() => this.handleNetworkLoad({ path: this.config.networkPath }))
|
|
265
|
+
.catch((error) => {
|
|
266
|
+
this.emitEvent('session.warning', { message: error?.message ?? String(error) });
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
buildMetadata(extra = {}) {
|
|
272
|
+
return {
|
|
273
|
+
sessionId: this.sessionId,
|
|
274
|
+
pid: process.pid,
|
|
275
|
+
status: extra.status ?? this.metadata?.status ?? 'unknown',
|
|
276
|
+
mode: this.config.mode,
|
|
277
|
+
renderer: this.config.renderer,
|
|
278
|
+
layout: this.config.layout,
|
|
279
|
+
runtime: this.config.runtime,
|
|
280
|
+
surface: this.config.surface ?? null,
|
|
281
|
+
client: this.config.client ?? null,
|
|
282
|
+
browserChannel: this.config.browserChannel ?? null,
|
|
283
|
+
noGpu: this.config.noGpu === true,
|
|
284
|
+
url: this.sessionUrl(),
|
|
285
|
+
controlSocket: this.socketPath,
|
|
286
|
+
sessionStatePath: sessionStatePath(this.sessionId),
|
|
287
|
+
storageRoot: stateRoot,
|
|
288
|
+
storageSessionsPath: storageSessionsDir,
|
|
289
|
+
httpPort: this.httpPort ?? null,
|
|
290
|
+
bridgeConnected: this.bridgeReady,
|
|
291
|
+
gpu: extra.gpu ?? this.gpu ?? null,
|
|
292
|
+
networkPath: this.config.networkPath ?? null,
|
|
293
|
+
createdAt: this.metadata?.createdAt ?? new Date().toISOString(),
|
|
294
|
+
updatedAt: new Date().toISOString(),
|
|
295
|
+
lastError: extra.lastError ?? this.metadata?.lastError ?? null,
|
|
296
|
+
persistence: extra.persistence ?? this.metadata?.persistence ?? null,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
sessionUrl() {
|
|
301
|
+
const params = new URLSearchParams({ sessionId: this.sessionId });
|
|
302
|
+
if (this.config.runtime && this.config.runtime !== 'cli') params.set('runtime', this.config.runtime);
|
|
303
|
+
return `http://127.0.0.1:${this.httpPort}/?${params.toString()}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async updateMetadata(extra = {}) {
|
|
307
|
+
this.metadata = this.buildMetadata(extra);
|
|
308
|
+
await saveSessionMeta(this.sessionId, this.metadata);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async startHttpServer() {
|
|
312
|
+
this.httpServer = http.createServer(async (request, response) => {
|
|
313
|
+
try {
|
|
314
|
+
const url = new URL(request.url ?? '/', this.sessionUrl());
|
|
315
|
+
if (url.pathname.startsWith('/api/storage/')) {
|
|
316
|
+
await this.handleStorageApi(request, response, url);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (url.pathname === '/api/config') {
|
|
320
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
321
|
+
response.end(JSON.stringify({
|
|
322
|
+
sessionId: this.sessionId,
|
|
323
|
+
renderer: this.config.renderer,
|
|
324
|
+
layout: this.config.layout,
|
|
325
|
+
runtime: this.config.runtime,
|
|
326
|
+
mode: this.config.mode,
|
|
327
|
+
noGpu: this.config.noGpu === true,
|
|
328
|
+
storage: {
|
|
329
|
+
type: 'remote',
|
|
330
|
+
root: stateRoot,
|
|
331
|
+
sessionsPath: storageSessionsDir,
|
|
332
|
+
},
|
|
333
|
+
}));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const relativePath = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '');
|
|
337
|
+
const filePath = path.join(clientDistDir, relativePath);
|
|
338
|
+
const normalized = path.normalize(filePath);
|
|
339
|
+
if (!normalized.startsWith(clientDistDir)) {
|
|
340
|
+
response.writeHead(403).end('Forbidden');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const body = await fs.readFile(normalized);
|
|
344
|
+
response.writeHead(200, { 'content-type': mimeTypeForExtension(normalized) });
|
|
345
|
+
response.end(body);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
response.writeHead(error?.code === 'ENOENT' ? 404 : 500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
348
|
+
response.end(error?.message ?? 'Server error');
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
await new Promise((resolve) => this.httpServer.listen(0, '127.0.0.1', resolve));
|
|
352
|
+
this.httpPort = this.httpServer.address().port;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async readJsonRequest(request, { maxBytes = 256 * 1024 * 1024 } = {}) {
|
|
356
|
+
const chunks = [];
|
|
357
|
+
let size = 0;
|
|
358
|
+
for await (const chunk of request) {
|
|
359
|
+
size += chunk.length;
|
|
360
|
+
if (size > maxBytes) {
|
|
361
|
+
const error = new Error('Request body is too large');
|
|
362
|
+
error.statusCode = 413;
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
chunks.push(chunk);
|
|
366
|
+
}
|
|
367
|
+
if (chunks.length <= 0) return {};
|
|
368
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
369
|
+
return text.trim() ? JSON.parse(text) : {};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
writeJsonResponse(response, statusCode, payload) {
|
|
373
|
+
response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
|
374
|
+
response.end(JSON.stringify(encodeBinaryForJson(payload)));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async handleStorageApi(request, response, url) {
|
|
378
|
+
const method = request.method ?? 'GET';
|
|
379
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
380
|
+
const resource = segments[2] ?? null;
|
|
381
|
+
const id = segments[3] ? decodeURIComponent(segments.slice(3).join('/')) : null;
|
|
382
|
+
|
|
383
|
+
if (resource === 'sessions' && method === 'GET') {
|
|
384
|
+
this.writeJsonResponse(response, 200, await this.sessionStore.listSessions());
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (resource === 'session' && method === 'POST') {
|
|
388
|
+
const payload = decodeBinaryFromJson(await this.readJsonRequest(request));
|
|
389
|
+
this.writeJsonResponse(response, 200, await this.sessionStore.putSession(payload.record ?? payload));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (resource === 'session' && id && method === 'GET') {
|
|
393
|
+
const record = await this.sessionStore.getSession(id);
|
|
394
|
+
this.writeJsonResponse(response, record ? 200 : 404, record ?? { error: 'not-found', id });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (resource === 'session' && id && method === 'DELETE') {
|
|
398
|
+
this.writeJsonResponse(response, 200, { deleted: await this.sessionStore.deleteSession(id), id });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (resource === 'unfinished' && method === 'GET') {
|
|
402
|
+
const workspaceId = url.searchParams.get('workspaceId');
|
|
403
|
+
this.writeJsonResponse(response, 200, {
|
|
404
|
+
sessionId: await this.sessionStore.getUnfinishedSessionId(workspaceId),
|
|
405
|
+
});
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (resource === 'unfinished' && (method === 'PUT' || method === 'POST')) {
|
|
409
|
+
const payload = await this.readJsonRequest(request);
|
|
410
|
+
const sessionId = await this.sessionStore.setUnfinishedSessionId(payload.sessionId ?? payload.id ?? null, payload.workspaceId ?? null);
|
|
411
|
+
this.writeJsonResponse(response, 200, { sessionId });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this.writeJsonResponse(response, 404, { error: 'unknown-storage-endpoint' });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async startBridgeServer() {
|
|
419
|
+
this.wsServer = new WebSocketServer({ noServer: true });
|
|
420
|
+
this.httpServer.on('upgrade', (request, socket, head) => {
|
|
421
|
+
const url = new URL(request.url ?? '/', this.sessionUrl());
|
|
422
|
+
if (url.pathname !== '/bridge') {
|
|
423
|
+
socket.destroy();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
this.wsServer.handleUpgrade(request, socket, head, (ws) => {
|
|
427
|
+
this.wsServer.emit('connection', ws, request);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
this.wsServer.on('connection', (socket) => {
|
|
431
|
+
this.attachBridgeSocket(socket);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
attachBridgeSocket(socket) {
|
|
436
|
+
if (this.bridgeSocket && this.bridgeSocket !== socket) {
|
|
437
|
+
try {
|
|
438
|
+
this.bridgeSocket.close();
|
|
439
|
+
} catch (_) {
|
|
440
|
+
// ignore
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
this.bridgeSocket = socket;
|
|
444
|
+
this.bridgeReady = false;
|
|
445
|
+
this.updateMetadata().catch(() => {});
|
|
446
|
+
socket.on('message', (data) => {
|
|
447
|
+
const message = JSON.parse(String(data));
|
|
448
|
+
this.handleBridgeMessage(message);
|
|
449
|
+
});
|
|
450
|
+
socket.on('close', () => {
|
|
451
|
+
if (this.bridgeSocket === socket) {
|
|
452
|
+
this.bridgeSocket = null;
|
|
453
|
+
this.bridgeReady = false;
|
|
454
|
+
this.updateMetadata().catch(() => {});
|
|
455
|
+
this.emitEvent('bridge.disconnected', { sessionId: this.sessionId });
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
handleBridgeMessage(message) {
|
|
461
|
+
if (Object.prototype.hasOwnProperty.call(message ?? {}, 'id') && this.bridgeRequests.has(message.id)) {
|
|
462
|
+
const pending = this.bridgeRequests.get(message.id);
|
|
463
|
+
this.bridgeRequests.delete(message.id);
|
|
464
|
+
if (message.error) {
|
|
465
|
+
const error = new Error(message.error.message ?? 'Bridge request failed');
|
|
466
|
+
error.code = message.error.code;
|
|
467
|
+
error.data = message.error.data;
|
|
468
|
+
pending.reject(error);
|
|
469
|
+
} else {
|
|
470
|
+
pending.resolve(message.result);
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (message?.method === 'bridge.ready') {
|
|
475
|
+
this.bridgeReady = true;
|
|
476
|
+
this.updateMetadata().catch(() => {});
|
|
477
|
+
for (const waiter of this.bridgeWaiters) waiter.resolve(true);
|
|
478
|
+
this.bridgeWaiters.clear();
|
|
479
|
+
this.emitEvent('bridge.ready', message.params ?? {});
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (message?.method === 'bridge.event') {
|
|
483
|
+
const type = message.params?.type ?? 'bridge.event';
|
|
484
|
+
const detail = message.params?.detail ?? {};
|
|
485
|
+
if (type === 'persistence.snapshot') {
|
|
486
|
+
this.updateSessionState(detail).catch((error) => {
|
|
487
|
+
this.emitEvent('session.warning', { message: error?.message ?? String(error) });
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
this.emitEvent(type, detail);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async updateSessionState(detail = {}) {
|
|
495
|
+
const previous = await loadSessionState(this.sessionId);
|
|
496
|
+
const state = {
|
|
497
|
+
kind: 'helios-cli-session-state',
|
|
498
|
+
version: 1,
|
|
499
|
+
sessionId: this.sessionId,
|
|
500
|
+
persistenceId: detail.persistenceId ?? previous?.persistenceId ?? this.sessionId,
|
|
501
|
+
storage: detail.storage ?? previous?.storage ?? null,
|
|
502
|
+
status: detail.status ?? previous?.status ?? null,
|
|
503
|
+
overrides: detail.overrides ?? previous?.overrides ?? {},
|
|
504
|
+
dirtyState: detail.dirtyState ?? previous?.dirtyState ?? { controls: {}, sections: {}, panels: {} },
|
|
505
|
+
journal: Array.isArray(detail.journal) ? detail.journal : (previous?.journal ?? []),
|
|
506
|
+
checkpointSeq: Number.isFinite(detail.checkpointSeq)
|
|
507
|
+
? Number(detail.checkpointSeq)
|
|
508
|
+
: (previous?.checkpointSeq ?? 0),
|
|
509
|
+
networkData: detail.networkData ?? previous?.networkData ?? null,
|
|
510
|
+
savedAt: detail.savedAt ?? previous?.savedAt ?? null,
|
|
511
|
+
updatedAt: new Date().toISOString(),
|
|
512
|
+
};
|
|
513
|
+
await saveSessionState(this.sessionId, state);
|
|
514
|
+
await this.updateMetadata({
|
|
515
|
+
persistence: {
|
|
516
|
+
statePath: sessionStatePath(this.sessionId),
|
|
517
|
+
overrideCount: state.status?.overrideCount ?? Object.keys(state.overrides ?? {}).length,
|
|
518
|
+
journalCount: state.status?.journalCount ?? state.journal.length,
|
|
519
|
+
checkpointSeq: state.checkpointSeq,
|
|
520
|
+
networkData: state.networkData ?? null,
|
|
521
|
+
updatedAt: state.updatedAt,
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
return state;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async startControlServer() {
|
|
528
|
+
this.controlServer = net.createServer((socket) => this.attachControlConnection(socket));
|
|
529
|
+
await new Promise((resolve) => this.controlServer.listen(this.socketPath, resolve));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
attachControlConnection(socket) {
|
|
533
|
+
const connection = {
|
|
534
|
+
id: this.nextConnectionId++,
|
|
535
|
+
socket,
|
|
536
|
+
subscriptions: new Set(),
|
|
537
|
+
};
|
|
538
|
+
this.controlConnections.add(connection);
|
|
539
|
+
const parser = createJsonLineParser((message) => {
|
|
540
|
+
this.handleControlMessage(connection, message).catch((error) => {
|
|
541
|
+
socket.write(encodeMessage({
|
|
542
|
+
jsonrpc: '2.0',
|
|
543
|
+
id: message?.id ?? null,
|
|
544
|
+
error: {
|
|
545
|
+
code: -32000,
|
|
546
|
+
message: error?.message ?? String(error),
|
|
547
|
+
},
|
|
548
|
+
}));
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
socket.setEncoding('utf8');
|
|
552
|
+
socket.on('data', parser);
|
|
553
|
+
socket.on('close', () => {
|
|
554
|
+
for (const subscriptionId of connection.subscriptions) {
|
|
555
|
+
this.subscriptions.delete(subscriptionId);
|
|
556
|
+
}
|
|
557
|
+
this.controlConnections.delete(connection);
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async handleControlMessage(connection, message) {
|
|
562
|
+
if (!message || typeof message !== 'object') return;
|
|
563
|
+
const response = {
|
|
564
|
+
jsonrpc: '2.0',
|
|
565
|
+
id: message.id ?? null,
|
|
566
|
+
};
|
|
567
|
+
try {
|
|
568
|
+
response.result = await this.dispatchMethod(connection, message.method, message.params ?? {});
|
|
569
|
+
} catch (error) {
|
|
570
|
+
response.error = {
|
|
571
|
+
code: error?.code ?? -32000,
|
|
572
|
+
message: error?.message ?? String(error),
|
|
573
|
+
data: error?.data ?? null,
|
|
574
|
+
};
|
|
575
|
+
delete response.result;
|
|
576
|
+
}
|
|
577
|
+
connection.socket.write(encodeMessage(response));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async dispatchMethod(connection, method, params) {
|
|
581
|
+
switch (method) {
|
|
582
|
+
case 'session.getInfo':
|
|
583
|
+
return this.buildMetadata();
|
|
584
|
+
case 'session.getStateFile':
|
|
585
|
+
return await loadSessionState(this.sessionId);
|
|
586
|
+
case 'session.stop':
|
|
587
|
+
setTimeout(() => {
|
|
588
|
+
this.stop().catch(() => {});
|
|
589
|
+
}, 0);
|
|
590
|
+
return { stopping: true };
|
|
591
|
+
case 'events.subscribe':
|
|
592
|
+
return this.subscribe(connection, params);
|
|
593
|
+
case 'events.unsubscribe':
|
|
594
|
+
return this.unsubscribe(connection, params);
|
|
595
|
+
case 'network.load':
|
|
596
|
+
return this.handleNetworkLoad(params);
|
|
597
|
+
case 'network.replace':
|
|
598
|
+
return this.handleNetworkReplace(params);
|
|
599
|
+
case 'network.save':
|
|
600
|
+
return this.handleNetworkSave(params);
|
|
601
|
+
case 'export.figure':
|
|
602
|
+
return this.handleExportFigure(params);
|
|
603
|
+
case 'browser.reload':
|
|
604
|
+
return this.handleBrowserReload(params);
|
|
605
|
+
default:
|
|
606
|
+
return this.callBridge(method, params);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
subscribe(connection, params = {}) {
|
|
611
|
+
const subscriptionId = params.subscriptionId ?? randomUUID();
|
|
612
|
+
this.subscriptions.set(subscriptionId, { connection });
|
|
613
|
+
connection.subscriptions.add(subscriptionId);
|
|
614
|
+
return { subscriptionId };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
unsubscribe(connection, params = {}) {
|
|
618
|
+
const subscriptionId = params.subscriptionId;
|
|
619
|
+
if (!subscriptionId) return { removed: false };
|
|
620
|
+
const entry = this.subscriptions.get(subscriptionId);
|
|
621
|
+
if (!entry || entry.connection !== connection) return { removed: false };
|
|
622
|
+
this.subscriptions.delete(subscriptionId);
|
|
623
|
+
connection.subscriptions.delete(subscriptionId);
|
|
624
|
+
return { removed: true };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
emitEvent(type, detail) {
|
|
628
|
+
const message = {
|
|
629
|
+
jsonrpc: '2.0',
|
|
630
|
+
method: 'events.notification',
|
|
631
|
+
params: {
|
|
632
|
+
type,
|
|
633
|
+
detail,
|
|
634
|
+
sessionId: this.sessionId,
|
|
635
|
+
timestamp: new Date().toISOString(),
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
for (const { connection } of this.subscriptions.values()) {
|
|
639
|
+
connection.socket.write(encodeMessage(message));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async waitForBridge({ timeoutMs = 30_000 } = {}) {
|
|
644
|
+
if (this.bridgeReady && this.bridgeSocket) return true;
|
|
645
|
+
return new Promise((resolve, reject) => {
|
|
646
|
+
const timeout = setTimeout(() => {
|
|
647
|
+
this.bridgeWaiters.delete(waiter);
|
|
648
|
+
const error = new Error(`Timed out waiting for browser bridge in session ${this.sessionId}`);
|
|
649
|
+
error.code = -32010;
|
|
650
|
+
reject(error);
|
|
651
|
+
}, timeoutMs);
|
|
652
|
+
const waiter = {
|
|
653
|
+
resolve: (value) => {
|
|
654
|
+
clearTimeout(timeout);
|
|
655
|
+
resolve(value);
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
this.bridgeWaiters.add(waiter);
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async callBridge(method, params = {}, { timeoutMs = 30_000 } = {}) {
|
|
663
|
+
await this.waitForBridge({ timeoutMs });
|
|
664
|
+
if (!this.bridgeSocket) {
|
|
665
|
+
const error = new Error('Browser bridge is not connected');
|
|
666
|
+
error.code = -32011;
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
const id = randomUUID();
|
|
670
|
+
const payload = { jsonrpc: '2.0', id, method, params };
|
|
671
|
+
const promise = new Promise((resolve, reject) => {
|
|
672
|
+
const timeout = setTimeout(() => {
|
|
673
|
+
this.bridgeRequests.delete(id);
|
|
674
|
+
const error = new Error(`Timed out waiting for browser bridge method ${method}`);
|
|
675
|
+
error.code = -32012;
|
|
676
|
+
reject(error);
|
|
677
|
+
}, timeoutMs);
|
|
678
|
+
this.bridgeRequests.set(id, {
|
|
679
|
+
resolve: (value) => {
|
|
680
|
+
clearTimeout(timeout);
|
|
681
|
+
resolve(value);
|
|
682
|
+
},
|
|
683
|
+
reject: (error) => {
|
|
684
|
+
clearTimeout(timeout);
|
|
685
|
+
reject(error);
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
this.bridgeSocket.send(JSON.stringify(payload));
|
|
690
|
+
return promise;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async handleNetworkLoad(params = {}) {
|
|
694
|
+
const filePath = params.path ?? params.filePath;
|
|
695
|
+
if (!filePath) {
|
|
696
|
+
const error = new Error('network.load requires params.path');
|
|
697
|
+
error.code = -32602;
|
|
698
|
+
throw error;
|
|
699
|
+
}
|
|
700
|
+
const format = params.format ?? inferNetworkFormat(filePath);
|
|
701
|
+
const bytes = await fs.readFile(filePath);
|
|
702
|
+
const result = await this.callBridge('network.loadPayload', {
|
|
703
|
+
name: path.basename(filePath),
|
|
704
|
+
format,
|
|
705
|
+
base64: encodeBufferBase64(bytes),
|
|
706
|
+
options: params.options ?? {},
|
|
707
|
+
});
|
|
708
|
+
await this.restoreDocumentSidecar(filePath, { reason: 'network.load' });
|
|
709
|
+
if (this.config.runtime === 'desktop' || params.markSaved === true) {
|
|
710
|
+
await this.callBridge('persistence.documentSaved', {
|
|
711
|
+
reason: params.reason ?? 'network.load',
|
|
712
|
+
filePath,
|
|
713
|
+
format,
|
|
714
|
+
}).catch(() => null);
|
|
715
|
+
}
|
|
716
|
+
return result;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async handleNetworkReplace(params = {}) {
|
|
720
|
+
if (params.path || params.filePath) {
|
|
721
|
+
return this.handleNetworkLoad(params);
|
|
722
|
+
}
|
|
723
|
+
return this.callBridge('network.replace', params);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async handleNetworkSave(params = {}) {
|
|
727
|
+
const requestedFormat = params.format ?? (params.outputPath ? inferNetworkFormat(params.outputPath) : null);
|
|
728
|
+
const outputPath = writableNetworkOutputPath(params.outputPath ?? null, requestedFormat);
|
|
729
|
+
const result = await this.callBridge('network.savePayload', {
|
|
730
|
+
...params,
|
|
731
|
+
outputPath,
|
|
732
|
+
filename: outputPath ? path.basename(outputPath) : params.filename,
|
|
733
|
+
format: requestedFormat ?? params.format,
|
|
734
|
+
});
|
|
735
|
+
if (outputPath && result?.base64) {
|
|
736
|
+
await fs.writeFile(outputPath, decodeBase64ToBuffer(result.base64));
|
|
737
|
+
await this.writeDocumentSidecar(outputPath, params);
|
|
738
|
+
if (params.markSaved === true) {
|
|
739
|
+
await this.callBridge('persistence.documentSaved', {
|
|
740
|
+
reason: params.reason ?? 'network.save',
|
|
741
|
+
filePath: outputPath,
|
|
742
|
+
format: params.format ?? result.format ?? inferNetworkFormat(outputPath),
|
|
743
|
+
}).catch(() => null);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
...result,
|
|
748
|
+
wroteFile: Boolean(outputPath && result?.base64),
|
|
749
|
+
outputPath: outputPath ?? null,
|
|
750
|
+
base64: outputPath ? undefined : result?.base64,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
documentSidecarPath(filePath) {
|
|
755
|
+
return `${filePath}.helios-state.json`;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
formatCarriesHeliosState(format) {
|
|
759
|
+
return ['xnet', 'zxnet', 'bxnet'].includes(String(format ?? '').toLowerCase());
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async writeDocumentSidecar(outputPath, params = {}) {
|
|
763
|
+
const format = params.format ?? inferNetworkFormat(outputPath);
|
|
764
|
+
const sidecarPath = this.documentSidecarPath(outputPath);
|
|
765
|
+
if (this.formatCarriesHeliosState(format)) {
|
|
766
|
+
await fs.rm(sidecarPath, { force: true }).catch(() => {});
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
if (params.includeVisualization !== true) return null;
|
|
770
|
+
const snapshot = await this.callBridge('persistence.exportDocumentState', {
|
|
771
|
+
reason: params.reason ?? 'network.save',
|
|
772
|
+
includeCurrentPositions: params.includeCurrentPositions !== false,
|
|
773
|
+
trackedOnly: params.trackedOnly !== false,
|
|
774
|
+
}).catch(() => null);
|
|
775
|
+
if (!snapshot) return null;
|
|
776
|
+
const payload = {
|
|
777
|
+
schema: 'helios-desktop.document-sidecar',
|
|
778
|
+
version: 1,
|
|
779
|
+
networkFile: path.basename(outputPath),
|
|
780
|
+
format,
|
|
781
|
+
savedAt: new Date().toISOString(),
|
|
782
|
+
visualizationState: snapshot,
|
|
783
|
+
};
|
|
784
|
+
await fs.writeFile(sidecarPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
785
|
+
return sidecarPath;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async restoreDocumentSidecar(filePath, options = {}) {
|
|
789
|
+
const format = inferNetworkFormat(filePath);
|
|
790
|
+
if (this.formatCarriesHeliosState(format)) return null;
|
|
791
|
+
const sidecarPath = this.documentSidecarPath(filePath);
|
|
792
|
+
let parsed = null;
|
|
793
|
+
try {
|
|
794
|
+
parsed = JSON.parse(await fs.readFile(sidecarPath, 'utf8'));
|
|
795
|
+
} catch (error) {
|
|
796
|
+
if (error?.code === 'ENOENT') return null;
|
|
797
|
+
throw error;
|
|
798
|
+
}
|
|
799
|
+
const visualizationState = parsed?.visualizationState ?? parsed;
|
|
800
|
+
if (!visualizationState) return null;
|
|
801
|
+
return this.callBridge('persistence.restoreDocumentState', {
|
|
802
|
+
visualizationState,
|
|
803
|
+
reason: options.reason ?? 'document-sidecar-restore',
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async handleExportFigure(params = {}) {
|
|
808
|
+
const result = await this.callBridge('export.figurePayload', params);
|
|
809
|
+
const outputPath = params.outputPath ?? null;
|
|
810
|
+
if (outputPath && result?.base64) {
|
|
811
|
+
await fs.writeFile(outputPath, decodeBase64ToBuffer(result.base64));
|
|
812
|
+
}
|
|
813
|
+
return {
|
|
814
|
+
...result,
|
|
815
|
+
wroteFile: Boolean(outputPath && result?.base64),
|
|
816
|
+
outputPath: outputPath ?? null,
|
|
817
|
+
base64: outputPath ? undefined : result?.base64,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async handleBrowserReload(params = {}) {
|
|
822
|
+
if (!this.browserPage) {
|
|
823
|
+
const error = new Error('browser.reload requires a managed headed or headless browser session');
|
|
824
|
+
error.code = -32602;
|
|
825
|
+
throw error;
|
|
826
|
+
}
|
|
827
|
+
this.bridgeReady = false;
|
|
828
|
+
await this.updateMetadata();
|
|
829
|
+
await this.browserPage.reload({ waitUntil: params.waitUntil ?? 'networkidle' });
|
|
830
|
+
await this.browserPage.waitForFunction(() => Boolean(window.__HELIOS_CLI_RUNTIME__?.ready), null, {
|
|
831
|
+
timeout: params.timeoutMs ?? 30_000,
|
|
832
|
+
});
|
|
833
|
+
await this.waitForBridge({ timeoutMs: params.timeoutMs ?? 30_000 });
|
|
834
|
+
return {
|
|
835
|
+
reloaded: true,
|
|
836
|
+
runtime: await this.browserPage.evaluate(() => window.__HELIOS_CLI_RUNTIME__ ?? null),
|
|
837
|
+
metadata: this.buildMetadata(),
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
managedBrowserLaunchOptions() {
|
|
842
|
+
const headed = this.config.mode === 'headed';
|
|
843
|
+
const args = this.config.noGpu
|
|
844
|
+
? [
|
|
845
|
+
'--disable-gpu',
|
|
846
|
+
'--enable-webgl',
|
|
847
|
+
'--use-angle=swiftshader',
|
|
848
|
+
'--enable-unsafe-swiftshader',
|
|
849
|
+
]
|
|
850
|
+
: [
|
|
851
|
+
'--enable-gpu',
|
|
852
|
+
'--enable-webgl',
|
|
853
|
+
'--ignore-gpu-blocklist',
|
|
854
|
+
'--enable-unsafe-webgpu',
|
|
855
|
+
'--disable-software-rasterizer',
|
|
856
|
+
];
|
|
857
|
+
if (headed) args.push('--window-size=1600,1000');
|
|
858
|
+
const options = {
|
|
859
|
+
headless: this.config.mode === 'headless',
|
|
860
|
+
args,
|
|
861
|
+
};
|
|
862
|
+
const browserChannel = String(this.config.browserChannel ?? '').trim();
|
|
863
|
+
if (browserChannel) {
|
|
864
|
+
options.channel = browserChannel;
|
|
865
|
+
}
|
|
866
|
+
return options;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
managedBrowserContextOptions() {
|
|
870
|
+
const base = {
|
|
871
|
+
acceptDownloads: true,
|
|
872
|
+
};
|
|
873
|
+
if (this.config.mode === 'headed') {
|
|
874
|
+
return {
|
|
875
|
+
...base,
|
|
876
|
+
viewport: null,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
...base,
|
|
881
|
+
viewport: { width: 1600, height: 1000 },
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async validateGpuRuntime() {
|
|
886
|
+
const gpu = await this.browserPage.evaluate(async ({ rendererPreference, mode, noGpu }) => {
|
|
887
|
+
const canvas = document.createElement('canvas');
|
|
888
|
+
let gl = null;
|
|
889
|
+
try {
|
|
890
|
+
gl = canvas.getContext('webgl2', { failIfMajorPerformanceCaveat: true })
|
|
891
|
+
|| canvas.getContext('webgl', { failIfMajorPerformanceCaveat: true });
|
|
892
|
+
} catch (_) {
|
|
893
|
+
gl = null;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
let webgl = null;
|
|
897
|
+
if (gl) {
|
|
898
|
+
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
|
|
899
|
+
const renderer = debugInfo
|
|
900
|
+
? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL)
|
|
901
|
+
: gl.getParameter(gl.RENDERER);
|
|
902
|
+
const vendor = debugInfo
|
|
903
|
+
? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL)
|
|
904
|
+
: gl.getParameter(gl.VENDOR);
|
|
905
|
+
const text = `${renderer ?? ''} ${vendor ?? ''}`.toLowerCase();
|
|
906
|
+
webgl = {
|
|
907
|
+
api: typeof WebGL2RenderingContext !== 'undefined' && gl instanceof WebGL2RenderingContext ? 'webgl2' : 'webgl',
|
|
908
|
+
renderer: renderer ?? null,
|
|
909
|
+
vendor: vendor ?? null,
|
|
910
|
+
hardware: !/(swiftshader|llvmpipe|software|mesa offscreen|microsoft basic render)/i.test(text),
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
let webgpu = null;
|
|
915
|
+
if (navigator.gpu?.requestAdapter) {
|
|
916
|
+
const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
|
|
917
|
+
if (adapter) {
|
|
918
|
+
let info = null;
|
|
919
|
+
if (typeof adapter.requestAdapterInfo === 'function') {
|
|
920
|
+
try {
|
|
921
|
+
info = await adapter.requestAdapterInfo();
|
|
922
|
+
} catch (_) {
|
|
923
|
+
info = null;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
webgpu = {
|
|
927
|
+
isFallbackAdapter: adapter.isFallbackAdapter ?? null,
|
|
928
|
+
features: Array.from(adapter.features ?? []),
|
|
929
|
+
info,
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const runtime = window.__HELIOS_CLI_RUNTIME__ ?? null;
|
|
935
|
+
const actualRenderer = runtime?.renderer ?? null;
|
|
936
|
+
|
|
937
|
+
return {
|
|
938
|
+
actualRenderer,
|
|
939
|
+
requestedRenderer: rendererPreference,
|
|
940
|
+
mode,
|
|
941
|
+
noGpu,
|
|
942
|
+
webgl,
|
|
943
|
+
webgpu,
|
|
944
|
+
window: {
|
|
945
|
+
innerWidth: window.innerWidth,
|
|
946
|
+
innerHeight: window.innerHeight,
|
|
947
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
948
|
+
},
|
|
949
|
+
};
|
|
950
|
+
}, {
|
|
951
|
+
rendererPreference: this.config.renderer,
|
|
952
|
+
mode: this.config.mode,
|
|
953
|
+
noGpu: this.config.noGpu === true,
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
const decision = evaluateManagedGpuPolicy({
|
|
957
|
+
mode: this.config.mode,
|
|
958
|
+
rendererPreference: this.config.renderer,
|
|
959
|
+
noGpu: this.config.noGpu === true,
|
|
960
|
+
actualRenderer: gpu.actualRenderer,
|
|
961
|
+
webgl: gpu.webgl,
|
|
962
|
+
webgpu: gpu.webgpu,
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
this.gpu = {
|
|
966
|
+
...gpu,
|
|
967
|
+
...decision,
|
|
968
|
+
};
|
|
969
|
+
await this.updateMetadata({ gpu });
|
|
970
|
+
this.emitEvent('browser.gpu', this.gpu);
|
|
971
|
+
|
|
972
|
+
if (!this.gpu?.ok) {
|
|
973
|
+
const error = new Error(this.gpu?.reason ?? `Managed browser session ${this.sessionId} did not get a hardware GPU path`);
|
|
974
|
+
error.code = -32020;
|
|
975
|
+
error.data = this.gpu;
|
|
976
|
+
throw error;
|
|
977
|
+
}
|
|
978
|
+
return this.gpu;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async launchManagedBrowser() {
|
|
982
|
+
this.browser = await chromium.launch(this.managedBrowserLaunchOptions());
|
|
983
|
+
this.browserContext = await this.browser.newContext(this.managedBrowserContextOptions());
|
|
984
|
+
this.browserPage = await this.browserContext.newPage();
|
|
985
|
+
this.browserPage.on('console', (msg) => {
|
|
986
|
+
this.emitEvent('browser.console', { type: msg.type(), text: msg.text() });
|
|
987
|
+
});
|
|
988
|
+
this.browserPage.on('pageerror', (error) => {
|
|
989
|
+
this.emitEvent('browser.pageerror', { message: error?.message ?? String(error) });
|
|
990
|
+
});
|
|
991
|
+
this.browserPage.on('download', (download) => {
|
|
992
|
+
this.saveManagedBrowserDownload(download).catch((error) => {
|
|
993
|
+
this.emitEvent('browser.downloadError', { message: error?.message ?? String(error) });
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
await this.browserPage.goto(this.sessionUrl(), { waitUntil: 'networkidle' });
|
|
997
|
+
await this.browserPage.waitForFunction(() => Boolean(window.__HELIOS_CLI_RUNTIME__?.ready), null, { timeout: 30_000 });
|
|
998
|
+
await this.validateGpuRuntime();
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async saveManagedBrowserDownload(download) {
|
|
1002
|
+
const directory = path.join(os.homedir(), 'Downloads');
|
|
1003
|
+
await fs.mkdir(directory, { recursive: true });
|
|
1004
|
+
const suggestedFilename = sanitizeDownloadFilename(download.suggestedFilename?.() ?? null);
|
|
1005
|
+
const outputPath = await uniqueDownloadPath(directory, suggestedFilename);
|
|
1006
|
+
await download.saveAs(outputPath);
|
|
1007
|
+
this.emitEvent('browser.download', {
|
|
1008
|
+
path: outputPath,
|
|
1009
|
+
suggestedFilename,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async openExternalBrowser() {
|
|
1014
|
+
const url = this.sessionUrl();
|
|
1015
|
+
if (process.platform === 'darwin') {
|
|
1016
|
+
const { spawn } = await import('node:child_process');
|
|
1017
|
+
spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (process.platform === 'win32') {
|
|
1021
|
+
const { spawn } = await import('node:child_process');
|
|
1022
|
+
spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const { spawn } = await import('node:child_process');
|
|
1026
|
+
spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async stop() {
|
|
1030
|
+
if (this.pendingStop) return;
|
|
1031
|
+
this.pendingStop = true;
|
|
1032
|
+
try {
|
|
1033
|
+
this.emitEvent('session.stopping', { sessionId: this.sessionId });
|
|
1034
|
+
for (const connection of this.controlConnections) {
|
|
1035
|
+
try {
|
|
1036
|
+
connection.socket.end();
|
|
1037
|
+
} catch (_) {
|
|
1038
|
+
// ignore
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
if (this.bridgeSocket) {
|
|
1042
|
+
try {
|
|
1043
|
+
this.bridgeSocket.close();
|
|
1044
|
+
} catch (_) {
|
|
1045
|
+
// ignore
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
await this.browserContext?.close?.();
|
|
1049
|
+
await this.browser?.close?.();
|
|
1050
|
+
await new Promise((resolve) => this.controlServer?.close(resolve));
|
|
1051
|
+
await new Promise((resolve) => this.httpServer?.close(resolve));
|
|
1052
|
+
this.wsServer?.close?.();
|
|
1053
|
+
if (process.platform !== 'win32') {
|
|
1054
|
+
try {
|
|
1055
|
+
await fs.unlink(this.socketPath);
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
await deleteSessionMeta(this.sessionId);
|
|
1061
|
+
} finally {
|
|
1062
|
+
setTimeout(() => process.exit(0), 25);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|