@acertijosapps/electron-viewer 0.0.0-stage → 1.0.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/LICENSE +20 -0
- package/README.md +226 -2
- package/bin/electron-viewer.js +102 -0
- package/lib/cdp.js +112 -0
- package/lib/electron.js +834 -0
- package/mcp/server.js +349 -0
- package/media/viewer.jpg +0 -0
- package/package.json +59 -4
- package/public/index.html +58 -0
- package/public/style.css +234 -0
- package/public/viewer.js +435 -0
- package/server.js +455 -0
package/server.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// Visor web de apps Electron.
|
|
2
|
+
// Video: `Page.startScreencast` (JPEG) de la ventana elegida, reenviado por WebSocket a un canvas.
|
|
3
|
+
// Modo marco: capturas de pantalla del área de la ventana desde el proceso main (desktopCapturer),
|
|
4
|
+
// con barra de título, menús y diálogos nativos. Input: `Input.dispatchMouseEvent` y `Input.dispatchKeyEvent`.
|
|
5
|
+
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { WebSocketServer } from 'ws';
|
|
11
|
+
import {
|
|
12
|
+
attachApp,
|
|
13
|
+
captureNative,
|
|
14
|
+
createActions,
|
|
15
|
+
dispatchKey,
|
|
16
|
+
pageClient,
|
|
17
|
+
projectInfo,
|
|
18
|
+
resolveWindow,
|
|
19
|
+
viewport,
|
|
20
|
+
} from './lib/electron.js';
|
|
21
|
+
|
|
22
|
+
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const PUBLIC = path.join(ROOT, 'public');
|
|
24
|
+
const PORT = Number(process.env.PORT) || 8094;
|
|
25
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
26
|
+
const PROJECT = process.env.ELECTRON_VIEWER_PROJECT || process.cwd();
|
|
27
|
+
const MAX_DIM = Number(process.env.MAX_DIM) || 1920; // lado mayor de los frames del screencast
|
|
28
|
+
const QUALITY = Number(process.env.QUALITY) || 80; // calidad JPEG
|
|
29
|
+
const NATIVE_INTERVAL_MS = 250; // modo marco: una captura cada 250 ms como mucho
|
|
30
|
+
const MAX_CLIENT_BUFFER = 4 * 1024 * 1024;
|
|
31
|
+
const MAX_BODY_BYTES = 256 * 1024;
|
|
32
|
+
const FLAG_SCREENCAST = 0;
|
|
33
|
+
const FLAG_NATIVE = 1;
|
|
34
|
+
|
|
35
|
+
const LOOPBACK = ['localhost', '127.0.0.1', '[::1]', '::1'];
|
|
36
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
37
|
+
|
|
38
|
+
function log(...args) {
|
|
39
|
+
console.log(new Date().toISOString().slice(11, 23), ...args);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const BUTTON_BITS = { left: 1, right: 2, middle: 4 };
|
|
43
|
+
|
|
44
|
+
class WindowSession {
|
|
45
|
+
constructor(app, targetId) {
|
|
46
|
+
this.app = app;
|
|
47
|
+
this.id = targetId;
|
|
48
|
+
this.clients = new Set();
|
|
49
|
+
this.page = null;
|
|
50
|
+
this.ready = null;
|
|
51
|
+
this.viewport = null;
|
|
52
|
+
this.lastFrame = null;
|
|
53
|
+
this.frameMode = false;
|
|
54
|
+
this.content = null; // área del contenido dentro de la captura nativa
|
|
55
|
+
this.queue = Promise.resolve();
|
|
56
|
+
this.pendingMove = null;
|
|
57
|
+
this.onScreencast = (e) => this.handleScreencast(e);
|
|
58
|
+
this.onClose = () => {
|
|
59
|
+
log(this.app.name, this.id.slice(0, 8), 'window closed');
|
|
60
|
+
this.broadcastJson({ type: 'closed' });
|
|
61
|
+
for (const ws of this.clients) ws.close(1000, 'window closed');
|
|
62
|
+
this.dispose();
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async add(ws) {
|
|
67
|
+
this.clients.add(ws);
|
|
68
|
+
if (!this.ready) this.ready = this.start();
|
|
69
|
+
await this.ready;
|
|
70
|
+
if (this.disposed || ws.readyState !== ws.OPEN) return;
|
|
71
|
+
this.send(ws, this.meta());
|
|
72
|
+
if (this.lastFrame) ws.send(this.lastFrame);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
remove(ws) {
|
|
76
|
+
this.clients.delete(ws);
|
|
77
|
+
if (this.clients.size === 0) this.dispose();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async start() {
|
|
81
|
+
const page = await pageClient(this.app, this.id);
|
|
82
|
+
this.page = page;
|
|
83
|
+
page.on('Page.screencastFrame', this.onScreencast);
|
|
84
|
+
page.on('close', this.onClose);
|
|
85
|
+
// Una ventana minimizada o tapada deja de pintar y el screencast se congela; con el foco y el
|
|
86
|
+
// ciclo de vida emulados sigue activa mientras alguien la mira.
|
|
87
|
+
await page.send('Emulation.setFocusEmulationEnabled', { enabled: true }).catch(() => {});
|
|
88
|
+
await page.send('Page.setWebLifecycleState', { state: 'active' }).catch(() => {});
|
|
89
|
+
this.viewport = await viewport(page);
|
|
90
|
+
await this.startScreencast();
|
|
91
|
+
log(this.app.name, this.id.slice(0, 8), `session ${this.viewport.width}×${this.viewport.height}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
startScreencast() {
|
|
95
|
+
return this.page.send('Page.startScreencast', { format: 'jpeg', quality: QUALITY, maxWidth: MAX_DIM, maxHeight: MAX_DIM });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
dispose() {
|
|
99
|
+
if (this.disposed) return;
|
|
100
|
+
this.disposed = true;
|
|
101
|
+
this.frameMode = false;
|
|
102
|
+
sessions.delete(this.key);
|
|
103
|
+
const page = this.page;
|
|
104
|
+
if (!page) return;
|
|
105
|
+
page.off('Page.screencastFrame', this.onScreencast);
|
|
106
|
+
page.off('close', this.onClose);
|
|
107
|
+
page.send('Page.stopScreencast').catch(() => {});
|
|
108
|
+
page.send('Emulation.setFocusEmulationEnabled', { enabled: false }).catch(() => {});
|
|
109
|
+
log(this.app.name, this.id.slice(0, 8), 'no viewers left; stopping');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
meta() {
|
|
113
|
+
return {
|
|
114
|
+
type: 'meta',
|
|
115
|
+
window: this.id,
|
|
116
|
+
app: { id: this.app.id, name: this.app.name, main: this.app.main },
|
|
117
|
+
width: this.viewport?.width,
|
|
118
|
+
height: this.viewport?.height,
|
|
119
|
+
frame: this.frameMode,
|
|
120
|
+
content: this.frameMode ? this.content : null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
handleScreencast({ data, metadata, sessionId }) {
|
|
125
|
+
this.page.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
|
|
126
|
+
if (this.frameMode) return;
|
|
127
|
+
const { deviceWidth: width, deviceHeight: height } = metadata;
|
|
128
|
+
if (width && height && (width !== this.viewport?.width || height !== this.viewport?.height)) {
|
|
129
|
+
this.viewport = { ...this.viewport, width, height };
|
|
130
|
+
this.broadcastJson(this.meta());
|
|
131
|
+
}
|
|
132
|
+
this.broadcastFrame(FLAG_SCREENCAST, Buffer.from(data, 'base64'));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
broadcastFrame(flag, jpeg) {
|
|
136
|
+
const msg = Buffer.concat([Buffer.from([flag]), jpeg]);
|
|
137
|
+
this.lastFrame = msg;
|
|
138
|
+
for (const ws of this.clients) {
|
|
139
|
+
// Un cliente lento se salta frames: el siguiente lo pone al día.
|
|
140
|
+
if (ws.readyState === ws.OPEN && ws.bufferedAmount < MAX_CLIENT_BUFFER) ws.send(msg);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async setFrameMode(on) {
|
|
145
|
+
if (on === this.frameMode) return;
|
|
146
|
+
if (on && !this.app.main) throw new Error('Frame capture needs the main process: launch the app from the viewer or attach with an inspect port');
|
|
147
|
+
this.frameMode = on;
|
|
148
|
+
if (on) {
|
|
149
|
+
await this.page.send('Page.stopScreencast').catch(() => {});
|
|
150
|
+
this.nativeLoop();
|
|
151
|
+
} else {
|
|
152
|
+
this.content = null;
|
|
153
|
+
await this.startScreencast();
|
|
154
|
+
}
|
|
155
|
+
this.broadcastJson(this.meta());
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async nativeLoop() {
|
|
159
|
+
while (this.frameMode && !this.disposed) {
|
|
160
|
+
const started = Date.now();
|
|
161
|
+
try {
|
|
162
|
+
const shot = await captureNative(this.app, this.id, { format: 'jpeg', quality: QUALITY, maxDim: MAX_DIM });
|
|
163
|
+
if (!this.frameMode) break;
|
|
164
|
+
if (JSON.stringify(shot.content) !== JSON.stringify(this.content)) {
|
|
165
|
+
this.content = shot.content;
|
|
166
|
+
this.broadcastJson(this.meta());
|
|
167
|
+
}
|
|
168
|
+
this.broadcastFrame(FLAG_NATIVE, shot.data);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
log(this.app.name, 'frame capture failed:', err.message);
|
|
171
|
+
this.broadcastJson({ type: 'error', message: err.message });
|
|
172
|
+
await this.setFrameMode(false).catch(() => {});
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
await sleep(Math.max(0, NATIVE_INTERVAL_MS - (Date.now() - started)));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
send(ws, obj) {
|
|
180
|
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
broadcastJson(obj) {
|
|
184
|
+
for (const ws of this.clients) this.send(ws, obj);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// El input va en orden por una cola; los movimientos pendientes se fusionan en el último.
|
|
188
|
+
enqueue(fn) {
|
|
189
|
+
this.queue = this.queue.then(fn).catch((err) => log(this.app.name, 'input failed:', err.message));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
point(msg) {
|
|
193
|
+
const clamp = (v) => Math.min(1, Math.max(0, Number(v) || 0));
|
|
194
|
+
return { x: clamp(msg.x) * this.viewport.width, y: clamp(msg.y) * this.viewport.height };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async handle(msg, ws) {
|
|
198
|
+
if (!this.page || !this.viewport) return;
|
|
199
|
+
switch (msg.type) {
|
|
200
|
+
case 'mouse': {
|
|
201
|
+
const { x, y } = this.point(msg);
|
|
202
|
+
const modifiers = Number(msg.modifiers) || 0;
|
|
203
|
+
const button = BUTTON_BITS[msg.button] ? msg.button : 'none';
|
|
204
|
+
if (msg.action === 'move') {
|
|
205
|
+
const queued = this.pendingMove;
|
|
206
|
+
this.pendingMove = { x, y, modifiers, buttons: Number(msg.buttons) || 0 };
|
|
207
|
+
if (queued) return;
|
|
208
|
+
this.enqueue(() => {
|
|
209
|
+
const m = this.pendingMove;
|
|
210
|
+
this.pendingMove = null;
|
|
211
|
+
const pressed = Object.keys(BUTTON_BITS).find((b) => m.buttons & BUTTON_BITS[b]) || 'none';
|
|
212
|
+
return this.page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: m.x, y: m.y, modifiers: m.modifiers, buttons: m.buttons, button: pressed });
|
|
213
|
+
});
|
|
214
|
+
} else if (msg.action === 'down' || msg.action === 'up') {
|
|
215
|
+
this.enqueue(() =>
|
|
216
|
+
this.page.send('Input.dispatchMouseEvent', {
|
|
217
|
+
type: msg.action === 'down' ? 'mousePressed' : 'mouseReleased',
|
|
218
|
+
x,
|
|
219
|
+
y,
|
|
220
|
+
button,
|
|
221
|
+
buttons: Number(msg.buttons) || 0,
|
|
222
|
+
clickCount: Math.max(1, Math.min(3, Number(msg.clickCount) || 1)),
|
|
223
|
+
modifiers,
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case 'wheel': {
|
|
230
|
+
const { x, y } = this.point(msg);
|
|
231
|
+
this.enqueue(() =>
|
|
232
|
+
this.page.send('Input.dispatchMouseEvent', {
|
|
233
|
+
type: 'mouseWheel',
|
|
234
|
+
x,
|
|
235
|
+
y,
|
|
236
|
+
deltaX: Number(msg.dx) || 0,
|
|
237
|
+
deltaY: Number(msg.dy) || 0,
|
|
238
|
+
modifiers: Number(msg.modifiers) || 0,
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case 'key': {
|
|
244
|
+
if (typeof msg.key !== 'string') return;
|
|
245
|
+
const def = { key: msg.key, code: String(msg.code || ''), keyCode: Number(msg.keyCode) || 0, repeat: Boolean(msg.repeat) };
|
|
246
|
+
if (typeof msg.text === 'string' && [...msg.text].length === 1) def.text = msg.text;
|
|
247
|
+
else if (msg.key === 'Enter') def.text = '\r';
|
|
248
|
+
this.enqueue(() => dispatchKey(this.page, msg.action === 'up' ? 'up' : 'down', def, Number(msg.modifiers) || 0));
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
case 'text':
|
|
252
|
+
if (typeof msg.text === 'string' && msg.text) this.enqueue(() => this.page.send('Input.insertText', { text: msg.text.slice(0, 10000) }));
|
|
253
|
+
break;
|
|
254
|
+
case 'frame':
|
|
255
|
+
try {
|
|
256
|
+
await this.setFrameMode(Boolean(msg.on));
|
|
257
|
+
} catch (err) {
|
|
258
|
+
this.send(ws, { type: 'error', message: err.message });
|
|
259
|
+
this.send(ws, this.meta());
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const sessions = new Map();
|
|
267
|
+
|
|
268
|
+
// ---------- API HTTP (la usa el MCP) ----------
|
|
269
|
+
|
|
270
|
+
const actions = createActions();
|
|
271
|
+
|
|
272
|
+
const API = {
|
|
273
|
+
'GET /api/windows': actions.windows,
|
|
274
|
+
'GET /api/project': () => projectInfo(PROJECT),
|
|
275
|
+
'GET /api/ax-tree': actions.axTree,
|
|
276
|
+
'GET /api/logs': actions.logs,
|
|
277
|
+
'POST /api/launch': ({ project }) => actions.launch({ project: project || PROJECT }),
|
|
278
|
+
'POST /api/attach': actions.attach,
|
|
279
|
+
'POST /api/click': actions.click,
|
|
280
|
+
'POST /api/drag': actions.drag,
|
|
281
|
+
'POST /api/hover': actions.hover,
|
|
282
|
+
'POST /api/scroll': actions.scroll,
|
|
283
|
+
'POST /api/text': actions.text,
|
|
284
|
+
'POST /api/key': actions.key,
|
|
285
|
+
'POST /api/resize': actions.resize,
|
|
286
|
+
'POST /api/reload': actions.reload,
|
|
287
|
+
'POST /api/evaluate': actions.evaluate,
|
|
288
|
+
'POST /api/evaluate-main': actions.evaluateMain,
|
|
289
|
+
'POST /api/devtools': actions.devtools,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const hostnameOf = (value) => {
|
|
293
|
+
try {
|
|
294
|
+
return new URL(value.includes('://') ? value : `http://${value}`).hostname;
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
function isLocalOrigin(origin) {
|
|
301
|
+
if (!origin) return true;
|
|
302
|
+
return LOOPBACK.includes(hostnameOf(origin));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Con el visor solo en loopback, un Host ajeno es DNS rebinding: una web que se hace pasar por localhost.
|
|
306
|
+
function isLocalHost(host) {
|
|
307
|
+
if (!LOOPBACK.includes(HOST)) return true;
|
|
308
|
+
return LOOPBACK.includes(hostnameOf(host || ''));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function readJson(req) {
|
|
312
|
+
return new Promise((resolve, reject) => {
|
|
313
|
+
let size = 0;
|
|
314
|
+
const chunks = [];
|
|
315
|
+
req.on('data', (c) => {
|
|
316
|
+
size += c.length;
|
|
317
|
+
if (size > MAX_BODY_BYTES) {
|
|
318
|
+
reject(Object.assign(new Error('Request body too large'), { status: 413 }));
|
|
319
|
+
req.destroy();
|
|
320
|
+
} else chunks.push(c);
|
|
321
|
+
});
|
|
322
|
+
req.on('end', () => {
|
|
323
|
+
try {
|
|
324
|
+
resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {});
|
|
325
|
+
} catch {
|
|
326
|
+
reject(Object.assign(new Error('Invalid JSON'), { status: 400 }));
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
req.on('error', reject);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function handleApi(req, res, url) {
|
|
334
|
+
const route = `${req.method} ${url.pathname}`;
|
|
335
|
+
const params = Object.fromEntries(url.searchParams);
|
|
336
|
+
if (route === 'GET /api/screenshot') {
|
|
337
|
+
const shot = await actions.screenshot(params);
|
|
338
|
+
const headers = {
|
|
339
|
+
'content-type': shot.mimeType,
|
|
340
|
+
'cache-control': 'no-store',
|
|
341
|
+
'x-image-width': shot.width,
|
|
342
|
+
'x-image-height': shot.height,
|
|
343
|
+
'x-window': shot.window,
|
|
344
|
+
};
|
|
345
|
+
if (shot.viewport) Object.assign(headers, { 'x-viewport-width': shot.viewport.width, 'x-viewport-height': shot.viewport.height });
|
|
346
|
+
if (shot.content) headers['x-content'] = JSON.stringify(shot.content);
|
|
347
|
+
res.writeHead(200, headers);
|
|
348
|
+
res.end(shot.data);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const handler = API[route];
|
|
352
|
+
if (!handler) throw Object.assign(new Error('Not found'), { status: 404 });
|
|
353
|
+
let input = params;
|
|
354
|
+
if (req.method === 'POST') {
|
|
355
|
+
// JSON obligatorio: una página ajena no puede mandarlo sin preflight CORS, que aquí no se concede.
|
|
356
|
+
if (!/^application\/json\b/.test(req.headers['content-type'] || '') || !isLocalOrigin(req.headers.origin)) {
|
|
357
|
+
throw Object.assign(new Error('application/json from a local origin is required'), { status: 403 });
|
|
358
|
+
}
|
|
359
|
+
input = { ...params, ...(await readJson(req)) };
|
|
360
|
+
}
|
|
361
|
+
const result = await handler(input);
|
|
362
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
363
|
+
res.end(JSON.stringify(result ?? null));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
|
|
367
|
+
|
|
368
|
+
const server = http.createServer(async (req, res) => {
|
|
369
|
+
const url = new URL(req.url, 'http://localhost');
|
|
370
|
+
try {
|
|
371
|
+
if (!isLocalHost(req.headers.host)) throw Object.assign(new Error('Forbidden host'), { status: 403 });
|
|
372
|
+
if (url.pathname.startsWith('/api/')) {
|
|
373
|
+
await handleApi(req, res, url);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const file = path.normalize(path.join(PUBLIC, url.pathname === '/' ? 'index.html' : url.pathname));
|
|
377
|
+
if (!file.startsWith(PUBLIC + path.sep)) throw Object.assign(new Error('forbidden'), { status: 403 });
|
|
378
|
+
const body = await readFile(file);
|
|
379
|
+
res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream', 'cache-control': 'no-store' });
|
|
380
|
+
res.end(body);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
const status = err.status || (err.code === 'ENOENT' ? 404 : 500);
|
|
383
|
+
if (url.pathname.startsWith('/api/')) {
|
|
384
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
385
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
|
|
389
|
+
res.end(status === 500 ? err.message : String(status));
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const wss = new WebSocketServer({
|
|
394
|
+
server,
|
|
395
|
+
path: '/ws',
|
|
396
|
+
// Cualquier página abierta en el navegador podría conectarse a localhost y manejar la app;
|
|
397
|
+
// solo se aceptan orígenes locales.
|
|
398
|
+
verifyClient: ({ origin, req }) => isLocalOrigin(origin) && isLocalHost(req.headers.host),
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
wss.on('connection', async (ws, req) => {
|
|
402
|
+
const windowId = new URL(req.url, 'http://x').searchParams.get('window') || '';
|
|
403
|
+
let w;
|
|
404
|
+
try {
|
|
405
|
+
w = await resolveWindow(windowId);
|
|
406
|
+
if (w.id !== windowId) throw new Error(`Unknown window ${windowId}`);
|
|
407
|
+
} catch (err) {
|
|
408
|
+
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
|
409
|
+
ws.close(1008, 'unknown window');
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const key = `${w.app.cdpPort}/${w.id}`;
|
|
413
|
+
let session = sessions.get(key);
|
|
414
|
+
if (!session) {
|
|
415
|
+
session = new WindowSession(w.app, w.id);
|
|
416
|
+
session.key = key;
|
|
417
|
+
sessions.set(key, session);
|
|
418
|
+
}
|
|
419
|
+
ws.on('close', () => session.remove(ws));
|
|
420
|
+
ws.on('message', (data, isBinary) => {
|
|
421
|
+
if (isBinary) return;
|
|
422
|
+
let msg;
|
|
423
|
+
try {
|
|
424
|
+
msg = JSON.parse(data.toString());
|
|
425
|
+
} catch {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
session.handle(msg, ws).catch((err) => log(w.app.name, 'message failed:', err.message));
|
|
429
|
+
});
|
|
430
|
+
try {
|
|
431
|
+
await session.add(ws);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
log(w.app.name, 'could not start:', err.message);
|
|
434
|
+
session.send(ws, { type: 'error', message: err.message });
|
|
435
|
+
ws.close();
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
if (process.env.ELECTRON_VIEWER_ATTACH) {
|
|
440
|
+
attachApp({ port: process.env.ELECTRON_VIEWER_ATTACH, inspectPort: process.env.ELECTRON_VIEWER_INSPECT })
|
|
441
|
+
.then((app) => log(`attached to ${app.name} on port ${app.cdpPort}`))
|
|
442
|
+
.catch((err) => log('attach failed:', err.message));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
server.listen(PORT, HOST, () => {
|
|
446
|
+
const project = projectInfo(PROJECT);
|
|
447
|
+
log(`Electron Viewer on http://${LOOPBACK.includes(HOST) ? 'localhost' : HOST}:${PORT}/ (project: ${project.dir}${project.launchable ? '' : ', not launchable'})`);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
451
|
+
process.on(sig, () => {
|
|
452
|
+
for (const s of [...sessions.values()]) s.dispose();
|
|
453
|
+
setTimeout(() => process.exit(0), 200);
|
|
454
|
+
});
|
|
455
|
+
}
|