@acertijosapps/electron-viewer 0.0.0-stage → 1.1.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.
@@ -0,0 +1,920 @@
1
+ // Acceso a apps Electron por CDP, compartido por el visor (server.js) y el MCP (mcp/server.js).
2
+ // Renderer: `--remote-debugging-port` (un target "page" por ventana). Main: inspector de Node
3
+ // (`--inspect`), donde `require('electron')` está disponible gracias al command line API.
4
+
5
+ import os from 'node:os';
6
+ import net from 'node:net';
7
+ import path from 'node:path';
8
+ import { spawn } from 'node:child_process';
9
+ import { createRequire } from 'node:module';
10
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
11
+ import { CdpClient, evaluate, fetchJson } from './cdp.js';
12
+
13
+ export const CDP_PORT = Number(process.env.CDP_PORT) || 9222;
14
+ export const INSPECT_PORT = Number(process.env.INSPECT_PORT) || 9229;
15
+ const SCAN_PORTS = 10; // se buscan apps en CDP_PORT … CDP_PORT + 9
16
+ const RESERVED_PORTS = new Set([8081, 8082, 8093, 8094]);
17
+ // No en os.tmpdir(): los clientes MCP lanzan el servidor con un entorno mínimo (sin TMPDIR en macOS)
18
+ // y el visor y el MCP verían carpetas distintas.
19
+ const STATE_DIR = process.env.ELECTRON_VIEWER_STATE_DIR || path.join(os.homedir(), '.cache', 'electron-viewer');
20
+ const STATE_FILE = path.join(STATE_DIR, 'apps.json');
21
+ const LAUNCH_TIMEOUT_MS = 30000;
22
+ // Un comando propio (`yarn dev`) suele compilar antes de abrir la app: se le da más tiempo.
23
+ const LAUNCH_CMD_TIMEOUT_MS = 120000;
24
+ const FIRST_WINDOW_TIMEOUT_MS = 10000;
25
+ const MAX_LOGS = 1000;
26
+ const IS_MAC = process.platform === 'darwin';
27
+
28
+ // Error del que pide (dato inválido o recurso inexistente), para responder 4xx en la API.
29
+ export function invalid(message, status = 400) {
30
+ return Object.assign(new Error(message), { status });
31
+ }
32
+
33
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
34
+
35
+ // ---------- Registro de apps ----------
36
+ // Las apps lanzadas o adjuntadas se guardan en un archivo temporal: así el visor ve las que
37
+ // arrancó el MCP sin visor, y viceversa. Las demás se descubren escaneando los puertos CDP.
38
+
39
+ function readState() {
40
+ try {
41
+ return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ function updateState(fn) {
48
+ const state = readState();
49
+ fn(state);
50
+ mkdirSync(STATE_DIR, { recursive: true });
51
+ const tmp = `${STATE_FILE}.${process.pid}`;
52
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
53
+ renameSync(tmp, STATE_FILE);
54
+ }
55
+
56
+ // «Mozilla/5.0 (…) AppleWebKit/537.36 (KHTML, like Gecko) mi-app/1.2.0 Chrome/… Electron/…»
57
+ function nameFromUserAgent(ua) {
58
+ return ua.match(/\)\s([^\s/]+)\/[\w.-]+\sChrome\//)?.[1] || 'Electron app';
59
+ }
60
+
61
+ async function probe(port) {
62
+ const version = await fetchJson(port, '/json/version', { timeout: 800 }).catch(() => null);
63
+ // webSocketDebuggerUrl cambia en cada arranque: distingue una app nueva en el mismo puerto.
64
+ return version && { userAgent: version['User-Agent'] || '', instance: version.webSocketDebuggerUrl || '' };
65
+ }
66
+
67
+ function isAlive(pid) {
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ } catch (err) {
72
+ return err.code === 'EPERM';
73
+ }
74
+ }
75
+
76
+ export async function listApps() {
77
+ const state = readState();
78
+ const ports = new Set([...Array.from({ length: SCAN_PORTS }, (_, i) => CDP_PORT + i), ...Object.keys(state).map(Number)]);
79
+ const stale = [];
80
+ const apps = await Promise.all(
81
+ [...ports].map(async (port) => {
82
+ const known = state[port];
83
+ const info = await probe(port);
84
+ if (!info) {
85
+ // Una app lanzada que sigue viva solo tardó en responder: no se olvida.
86
+ if (known && !(known.pid && isAlive(known.pid))) stale.push(port);
87
+ return null;
88
+ }
89
+ // Un Chrome con depuración remota en el mismo rango no es una app Electron.
90
+ if (!known && !/Electron\//.test(info.userAgent)) return null;
91
+ const app = {
92
+ id: String(port),
93
+ name: known?.name || nameFromUserAgent(info.userAgent),
94
+ cdpPort: port,
95
+ inspectPort: known?.inspectPort ?? null,
96
+ pid: known?.pid ?? null,
97
+ project: known?.project ?? null,
98
+ log: known?.log ?? null,
99
+ electron: info.userAgent.match(/Electron\/([\w.-]+)/)?.[1] ?? null,
100
+ };
101
+ // Interno (runtimeOf); no enumerable para que no salga en la API.
102
+ return Object.defineProperty(app, 'instance', { value: info.instance });
103
+ }),
104
+ );
105
+ if (stale.length) updateState((s) => stale.forEach((p) => delete s[p]));
106
+ const found = apps.filter(Boolean).sort((a, b) => a.cdpPort - b.cdpPort);
107
+ await discoverInspectors(found);
108
+ return found;
109
+ }
110
+
111
+ // Inspectores del main de apps arrancadas a mano (`--inspect`): se buscan en INSPECT_PORT … +9 y
112
+ // se emparejan preguntándole a cada uno, solo leyendo, en qué puerto CDP escucha su app. El
113
+ // resultado se guarda por proceso (id del target): a cada inspector se le conecta una sola vez.
114
+ const inspectorPairs = new Map(); // id del target del inspector → Promise<puerto CDP | null>
115
+
116
+ async function discoverInspectors(apps) {
117
+ const missing = apps.filter((a) => !a.inspectPort);
118
+ if (!missing.length) return;
119
+ const used = new Set(apps.map((a) => a.inspectPort).filter(Boolean));
120
+ const ports = Array.from({ length: SCAN_PORTS }, (_, i) => INSPECT_PORT + i).filter((p) => !used.has(p));
121
+ await Promise.all(
122
+ ports.map(async (port) => {
123
+ const target = await inspectorTarget(port);
124
+ if (!target) return;
125
+ if (!inspectorPairs.has(target.id)) inspectorPairs.set(target.id, pairInspector(target).catch(() => null));
126
+ const cdpPort = await inspectorPairs.get(target.id);
127
+ const app = missing.find((a) => a.cdpPort === cdpPort);
128
+ if (!app) return;
129
+ app.inspectPort = port;
130
+ updateState((s) => {
131
+ s[app.cdpPort] = { ...s[app.cdpPort], cdpPort: app.cdpPort, inspectPort: port, name: s[app.cdpPort]?.name || app.name };
132
+ });
133
+ }),
134
+ );
135
+ }
136
+
137
+ async function pairInspector(target) {
138
+ const client = await CdpClient.connect(target.webSocketDebuggerUrl, { timeout: 1500 });
139
+ try {
140
+ // Cualquier proceso Node con --inspect puede estar en el rango: solo se lee, y nada si no es Electron.
141
+ return await evaluate(
142
+ client,
143
+ "process.versions.electron ? Number(require('electron').app.commandLine.getSwitchValue('remote-debugging-port')) || null : null",
144
+ { commandLineAPI: true, timeout: 1500 },
145
+ );
146
+ } finally {
147
+ client.close();
148
+ }
149
+ }
150
+
151
+ const isWindowTarget = (t) => t.type === 'page' && !t.url.startsWith('devtools://');
152
+
153
+ export async function listWindows() {
154
+ const apps = await listApps();
155
+ await Promise.all(
156
+ apps.map(async (app) => {
157
+ const targets = await fetchJson(app.cdpPort, '/json/list').catch(() => []);
158
+ app.windows = targets.filter(isWindowTarget).map((t) => ({ id: t.id, title: t.title, url: t.url }));
159
+ app.main = app.inspectPort ? Boolean(await inspectorTarget(app.inspectPort)) : false;
160
+ // Abre la conexión de cada ventana para empezar a juntar sus logs de consola.
161
+ for (const w of app.windows) pageClient(app, w.id).catch(() => {});
162
+ if (app.main) mainClient(app).catch(() => {});
163
+ }),
164
+ );
165
+ return apps;
166
+ }
167
+
168
+ export async function resolveApp(appArg) {
169
+ const apps = await listWindows();
170
+ if (appArg !== undefined && appArg !== null && appArg !== '') {
171
+ const key = String(appArg).toLowerCase();
172
+ const hit = apps.find((a) => a.id === key) || apps.find((a) => a.name.toLowerCase() === key);
173
+ if (!hit) throw invalid(`No app "${appArg}". Running: ${apps.map((a) => `${a.id} (${a.name})`).join(', ') || 'none'}`, 404);
174
+ return hit;
175
+ }
176
+ if (apps.length === 1) return apps[0];
177
+ if (!apps.length) throw invalid('No Electron app is running. Start one with launch_app or attach to its debugging port.', 404);
178
+ throw invalid(`Several apps are running; pass app: ${apps.map((a) => `${a.id} (${a.name})`).join(', ')}`);
179
+ }
180
+
181
+ // Ventana por id de target, por parte del título o, si solo hay una, esa.
182
+ export async function resolveWindow(windowArg) {
183
+ const apps = await listWindows();
184
+ const all = apps.flatMap((app) => app.windows.map((w) => ({ ...w, app })));
185
+ if (windowArg) {
186
+ const exact = all.find((w) => w.id === windowArg);
187
+ if (exact) return exact;
188
+ const key = String(windowArg).toLowerCase();
189
+ const matches = all.filter((w) => w.title.toLowerCase().includes(key));
190
+ if (matches.length === 1) return matches[0];
191
+ if (!matches.length) throw invalid(`No window "${windowArg}". Open: ${describeWindows(all)}`, 404);
192
+ throw invalid(`"${windowArg}" matches several windows: ${describeWindows(matches)}`);
193
+ }
194
+ if (all.length === 1) return all[0];
195
+ if (!all.length) {
196
+ throw invalid(
197
+ apps.length
198
+ ? 'The app has no windows open.'
199
+ : 'No Electron app is running. Start one with launch_app or attach to its debugging port.',
200
+ 404,
201
+ );
202
+ }
203
+ throw invalid(`Several windows are open; pass window (id or part of the title): ${describeWindows(all)}`);
204
+ }
205
+
206
+ const describeWindows = (list) => list.map((w) => `${w.id} "${w.title}"`).join(', ') || 'none';
207
+
208
+ // ---------- Lanzar y adjuntar ----------
209
+
210
+ function isPortFree(port) {
211
+ return new Promise((resolve) => {
212
+ const srv = net.createServer();
213
+ srv.once('error', () => resolve(false));
214
+ srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true)));
215
+ });
216
+ }
217
+
218
+ async function freePort(start, taken = []) {
219
+ for (let port = start; port < start + 100; port++) {
220
+ if (RESERVED_PORTS.has(port) || taken.includes(port)) continue;
221
+ // Responde a /json/version: es una app que aún no se registra, no un puerto libre.
222
+ if ((await isPortFree(port)) && !(await probe(port))) return port;
223
+ }
224
+ throw new Error(`No free port from ${start}`);
225
+ }
226
+
227
+ export function projectDir(project) {
228
+ return path.resolve(project || process.env.ELECTRON_VIEWER_PROJECT || process.cwd());
229
+ }
230
+
231
+ // Carpeta del paquete `electron` del proyecto. Se resuelve sin ejecutarlo: desde Electron 4x su
232
+ // index.js descarga el binario la primera vez, bloqueando y escribiendo en stdout (que en el MCP
233
+ // es del protocolo).
234
+ function electronPackageDir(dir) {
235
+ try {
236
+ return path.dirname(createRequire(path.join(path.resolve(dir), 'package.json')).resolve('electron'));
237
+ } catch (err) {
238
+ if (err.code === 'MODULE_NOT_FOUND') throw invalid(`Electron is not installed in ${dir} (npm install --save-dev electron)`, 404);
239
+ throw invalid(err.message.split('\n')[0]);
240
+ }
241
+ }
242
+
243
+ // Ruta del ejecutable, como la calcula el index.js del paquete; null si aún no se descargó.
244
+ function electronBinary(pkgDir) {
245
+ let rel = '';
246
+ try {
247
+ rel = readFileSync(path.join(pkgDir, 'path.txt'), 'utf8').trim();
248
+ } catch {
249
+ // sin path.txt: falta descargar el binario
250
+ }
251
+ if (process.env.ELECTRON_OVERRIDE_DIST_PATH) return path.join(process.env.ELECTRON_OVERRIDE_DIST_PATH, rel || 'electron');
252
+ const full = rel && path.join(pkgDir, 'dist', rel);
253
+ return full && existsSync(full) ? full : null;
254
+ }
255
+
256
+ export async function resolveElectron(dir) {
257
+ const pkgDir = electronPackageDir(dir);
258
+ const binary = electronBinary(pkgDir);
259
+ if (binary) return binary;
260
+ // Misma descarga que haría `require('electron')`, pero sin bloquear ni escribir en stdout.
261
+ await new Promise((resolve, reject) => {
262
+ let output = '';
263
+ const proc = spawn(process.execPath, [path.join(pkgDir, 'install.js')], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] });
264
+ proc.stdout.on('data', (d) => (output += d));
265
+ proc.stderr.on('data', (d) => (output += d));
266
+ proc.once('error', reject);
267
+ proc.once('exit', (code) =>
268
+ code === 0 ? resolve() : reject(new Error(`Could not download the Electron binary: ${output.trim().split('\n').slice(-3).join(' ')}`)),
269
+ );
270
+ });
271
+ const downloaded = electronBinary(pkgDir);
272
+ if (!downloaded) throw new Error(`Electron is installed in ${pkgDir} but its binary is missing`);
273
+ return downloaded;
274
+ }
275
+
276
+ // Comando de arranque propio (--launch-cmd). Solo desde la CLI o el entorno, nunca desde la API ni
277
+ // el MCP: si no, una petición local podría ejecutar cualquier cosa en la shell.
278
+ export function launchCommand() {
279
+ return process.env.ELECTRON_VIEWER_LAUNCH_CMD || null;
280
+ }
281
+
282
+ export function projectInfo(project) {
283
+ const dir = projectDir(project);
284
+ let name = path.basename(dir);
285
+ try {
286
+ const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
287
+ name = pkg.productName || pkg.name || name;
288
+ } catch {
289
+ // sin package.json: el nombre de la carpeta
290
+ }
291
+ const command = launchCommand();
292
+ if (command) return { dir, name, command, launchable: true };
293
+ try {
294
+ electronPackageDir(dir);
295
+ return { dir, name, command: 'electron .', launchable: true };
296
+ } catch (err) {
297
+ return { dir, name, command: 'electron .', launchable: false, reason: err.message };
298
+ }
299
+ }
300
+
301
+ function tail(file, lines = 20) {
302
+ try {
303
+ return readFileSync(file, 'utf8').trimEnd().split('\n').slice(-lines).join('\n');
304
+ } catch {
305
+ return '';
306
+ }
307
+ }
308
+
309
+ export async function launchApp({ project, args = [] } = {}) {
310
+ const info = projectInfo(project);
311
+ const command = launchCommand();
312
+ if (command && !command.includes('{cdpPort}')) {
313
+ throw invalid('The launch command must pass {cdpPort} to Electron, e.g. "yarn dev --remoteDebuggingPort {cdpPort} --inspect {inspectPort}"');
314
+ }
315
+ const electron = command ? null : await resolveElectron(info.dir);
316
+ const cdpPort = await freePort(CDP_PORT);
317
+ const inspectPort = await freePort(INSPECT_PORT, [cdpPort]);
318
+ mkdirSync(STATE_DIR, { recursive: true });
319
+ // La salida va a un archivo, no a un pipe: la app sigue viva aunque se cierre quien la lanzó.
320
+ const log = path.join(STATE_DIR, `app-${cdpPort}.log`);
321
+ const fd = openSync(log, 'w');
322
+ const env = { ...process.env };
323
+ delete env.ELECTRON_RUN_AS_NODE;
324
+ const options = { cwd: info.dir, env, detached: true, stdio: ['ignore', fd, fd] };
325
+ const child = command
326
+ ? spawn(command.replaceAll('{cdpPort}', cdpPort).replaceAll('{inspectPort}', inspectPort), { ...options, shell: true })
327
+ : spawn(electron, [`--inspect=${inspectPort}`, `--remote-debugging-port=${cdpPort}`, '.', ...args], options);
328
+ closeSync(fd);
329
+ let exited = null;
330
+ child.once('exit', (code, signal) => (exited = `exited (code ${code}${signal ? `, ${signal}` : ''})`));
331
+ child.once('error', (err) => (exited = err.message));
332
+ child.unref();
333
+
334
+ const deadline = Date.now() + (command ? LAUNCH_CMD_TIMEOUT_MS : LAUNCH_TIMEOUT_MS);
335
+ while (!(await probe(cdpPort))) {
336
+ if (exited || Date.now() > deadline) {
337
+ if (!exited) killGroup(child);
338
+ const output = tail(log);
339
+ throw new Error(`${info.name} ${exited || 'did not open its debugging port in time'}${output ? `:\n${output}` : ''}`);
340
+ }
341
+ await sleep(200);
342
+ }
343
+ updateState((s) => {
344
+ s[cdpPort] = { cdpPort, inspectPort, pid: child.pid, project: info.dir, name: info.name, command: info.command, log, startedAt: new Date().toISOString() };
345
+ });
346
+ const until = Date.now() + FIRST_WINDOW_TIMEOUT_MS;
347
+ let app;
348
+ do {
349
+ app = (await listWindows()).find((a) => a.cdpPort === cdpPort);
350
+ if (app?.windows.length) break;
351
+ await sleep(250);
352
+ } while (Date.now() < until);
353
+ return app;
354
+ }
355
+
356
+ // Con un comando propio, el hijo es una shell que arranca a su vez el dev server y Electron: se
357
+ // termina todo su grupo de procesos (detached lo hizo líder de grupo).
358
+ function killGroup(child) {
359
+ try {
360
+ if (process.platform === 'win32') child.kill();
361
+ else process.kill(-child.pid, 'SIGTERM');
362
+ } catch {
363
+ // ya terminó
364
+ }
365
+ }
366
+
367
+ export async function attachApp({ port, inspectPort } = {}) {
368
+ port = Number(port);
369
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw invalid('port must be a TCP port');
370
+ const info = await probe(port);
371
+ if (!info) throw invalid(`Nothing answers CDP on port ${port}. Start the app with --remote-debugging-port=${port}.`, 404);
372
+ if (inspectPort !== undefined && inspectPort !== null && inspectPort !== '') {
373
+ inspectPort = Number(inspectPort);
374
+ if (!(await inspectorTarget(inspectPort))) {
375
+ throw invalid(`No Node inspector on port ${inspectPort}. Start the app with --inspect=${inspectPort}.`, 404);
376
+ }
377
+ } else {
378
+ inspectPort = readState()[port]?.inspectPort ?? null;
379
+ }
380
+ updateState((s) => {
381
+ s[port] = { ...s[port], cdpPort: port, inspectPort, name: s[port]?.name || nameFromUserAgent(info.userAgent) };
382
+ });
383
+ return (await listWindows()).find((a) => a.cdpPort === port);
384
+ }
385
+
386
+ // ---------- Conexiones CDP ----------
387
+ // Una conexión por ventana y otra por proceso main, compartidas por todo el proceso (sesiones del
388
+ // visor y acciones). Cada una junta los logs de consola en el buffer de su app.
389
+
390
+ const runtimes = new Map(); // cdpPort → { pages, mains, logs, seen }
391
+
392
+ function runtimeOf(app) {
393
+ let rt = runtimes.get(app.cdpPort);
394
+ // Otra app (o la misma reiniciada) en el puerto: logs y conexiones empiezan de cero.
395
+ if (!rt || (app.instance && rt.instance !== app.instance)) {
396
+ rt = { instance: app.instance, pages: new Map(), mains: new Map(), logs: [], seen: new Set() };
397
+ runtimes.set(app.cdpPort, rt);
398
+ }
399
+ return rt;
400
+ }
401
+
402
+ function formatArg(arg) {
403
+ if (arg.type === 'string') return arg.value;
404
+ if ('value' in arg) return JSON.stringify(arg.value);
405
+ if (arg.unserializableValue) return arg.unserializableValue;
406
+ if (arg.preview?.properties) {
407
+ const props = arg.preview.properties.map((p) => `${p.name}: ${p.value ?? p.type}`).join(', ');
408
+ return arg.preview.subtype === 'array' ? `[${props}]` : `{${props}${arg.preview.overflow ? ', …' : ''}}`;
409
+ }
410
+ return arg.description ?? arg.type;
411
+ }
412
+
413
+ const LEVELS = { warning: 'warn', verbose: 'debug', assert: 'error', trace: 'log', dir: 'log', table: 'log' };
414
+
415
+ function pushLog(rt, entry) {
416
+ // Runtime.enable repite los mensajes anteriores al reconectar: se descartan los ya vistos.
417
+ const key = `${entry.source}|${entry.window || ''}|${entry.time}|${entry.text}`;
418
+ if (rt.seen.has(key)) return;
419
+ rt.seen.add(key);
420
+ rt.logs.push(entry);
421
+ if (rt.logs.length > MAX_LOGS) {
422
+ const old = rt.logs.splice(0, rt.logs.length - MAX_LOGS);
423
+ for (const e of old) rt.seen.delete(`${e.source}|${e.window || ''}|${e.time}|${e.text}`);
424
+ }
425
+ }
426
+
427
+ function collectLogs(rt, client, source, window) {
428
+ client.on('Runtime.consoleAPICalled', ({ type, args, timestamp }) =>
429
+ pushLog(rt, { time: timestamp, source, window, level: LEVELS[type] || type, text: args.map(formatArg).join(' ') }),
430
+ );
431
+ client.on('Runtime.exceptionThrown', ({ timestamp, exceptionDetails: d }) =>
432
+ pushLog(rt, { time: timestamp, source, window, level: 'error', text: d.exception?.description || d.text }),
433
+ );
434
+ client.on('Log.entryAdded', ({ entry }) =>
435
+ pushLog(rt, { time: entry.timestamp, source, window, level: LEVELS[entry.level] || entry.level, text: entry.url ? `${entry.text} (${entry.url})` : entry.text }),
436
+ );
437
+ }
438
+
439
+ function cached(map, key, connect) {
440
+ let p = map.get(key);
441
+ if (p) return p;
442
+ p = connect().then((client) => {
443
+ client.on('close', () => map.get(key) === p && map.delete(key));
444
+ return client;
445
+ });
446
+ p.catch(() => map.get(key) === p && map.delete(key));
447
+ map.set(key, p);
448
+ return p;
449
+ }
450
+
451
+ export function pageClient(app, targetId) {
452
+ const rt = runtimeOf(app);
453
+ return cached(rt.pages, targetId, async () => {
454
+ const client = await CdpClient.connect(`ws://127.0.0.1:${app.cdpPort}/devtools/page/${targetId}`);
455
+ collectLogs(rt, client, 'renderer', targetId);
456
+ await Promise.all([client.send('Runtime.enable'), client.send('Log.enable'), client.send('Page.enable')]);
457
+ return client;
458
+ });
459
+ }
460
+
461
+ async function inspectorTarget(port) {
462
+ const targets = await fetchJson(port, '/json/list', { timeout: 800 }).catch(() => null);
463
+ return targets?.find((t) => t.type === 'node') || null;
464
+ }
465
+
466
+ function mainClient(app) {
467
+ if (!app.inspectPort) {
468
+ return Promise.reject(
469
+ invalid(
470
+ `The main process of ${app.name} is not reachable. Launch the app with launch_app, or start it with ` +
471
+ '--inspect=<port> and attach with that inspect port.',
472
+ ),
473
+ );
474
+ }
475
+ const rt = runtimeOf(app);
476
+ return cached(rt.mains, app.inspectPort, async () => {
477
+ const target = await inspectorTarget(app.inspectPort);
478
+ if (!target) throw invalid(`No Node inspector on port ${app.inspectPort}`, 404);
479
+ const client = await CdpClient.connect(target.webSocketDebuggerUrl);
480
+ collectLogs(rt, client, 'main');
481
+ await client.send('Runtime.enable');
482
+ return client;
483
+ });
484
+ }
485
+
486
+ export async function evaluateMain(app, expression, { timeout } = {}) {
487
+ return evaluate(await mainClient(app), expression, { commandLineAPI: true, timeout });
488
+ }
489
+
490
+ // Expresión del main que obtiene el BrowserWindow de un target CDP (o lanza).
491
+ const winExpr = (targetId) => `(() => {
492
+ const { webContents, BrowserWindow } = require('electron');
493
+ const wc = webContents.fromDevToolsTargetId(${JSON.stringify(targetId)});
494
+ const win = wc && BrowserWindow.fromWebContents(wc);
495
+ if (!win) throw new Error('This target is not the page of a BrowserWindow');
496
+ return win;
497
+ })()`;
498
+
499
+ // ---------- Input ----------
500
+
501
+ const MODIFIER_BITS = { alt: 1, ctrl: 2, control: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
502
+
503
+ export function modifierMask(modifiers = []) {
504
+ if (typeof modifiers === 'number') return modifiers;
505
+ return modifiers.reduce((mask, m) => {
506
+ const bit = MODIFIER_BITS[String(m).toLowerCase()];
507
+ if (!bit) throw invalid(`Unknown modifier "${m}" (alt, ctrl, meta, shift)`);
508
+ return mask | bit;
509
+ }, 0);
510
+ }
511
+
512
+ const NAMED_KEYS = {
513
+ Enter: [13, '\r'],
514
+ Tab: [9],
515
+ Backspace: [8],
516
+ Delete: [46],
517
+ Escape: [27],
518
+ Space: [32, ' '],
519
+ ArrowUp: [38],
520
+ ArrowDown: [40],
521
+ ArrowLeft: [37],
522
+ ArrowRight: [39],
523
+ Home: [36],
524
+ End: [35],
525
+ PageUp: [33],
526
+ PageDown: [34],
527
+ Insert: [45],
528
+ ...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`F${i + 1}`, [112 + i]])),
529
+ };
530
+
531
+ const KEY_ALIASES = { esc: 'Escape', return: 'Enter', del: 'Delete', up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight', ' ': 'Space' };
532
+
533
+ export const KEY_NAMES = Object.keys(NAMED_KEYS);
534
+
535
+ export function keyDefinition(key) {
536
+ const name = KEY_ALIASES[String(key).toLowerCase()] || Object.keys(NAMED_KEYS).find((k) => k.toLowerCase() === String(key).toLowerCase());
537
+ if (name) {
538
+ const [keyCode, text] = NAMED_KEYS[name];
539
+ return { key: name === 'Space' ? ' ' : name, code: name, keyCode, text };
540
+ }
541
+ if (/^[a-z]$/i.test(key)) return { key, code: `Key${key.toUpperCase()}`, keyCode: key.toUpperCase().charCodeAt(0), text: key };
542
+ if (/^\d$/.test(key)) return { key, code: `Digit${key}`, keyCode: 48 + Number(key), text: key };
543
+ if ([...key].length === 1) return { key, code: '', keyCode: 0, text: key };
544
+ throw invalid(`Unknown key "${key}". Use a single character or one of: ${KEY_NAMES.join(', ')}`);
545
+ }
546
+
547
+ // En macOS los atajos de edición (⌘A, ⌘C…) los resuelve el menú nativo, que no ve los eventos de
548
+ // CDP: se mandan como comandos de edición del renderer.
549
+ const MAC_COMMANDS = { a: 'selectAll', c: 'copy', v: 'paste', x: 'cut', z: 'undo' };
550
+
551
+ function editCommands(key, mask) {
552
+ if (!IS_MAC || !(mask & 4) || mask & 3) return undefined;
553
+ const k = String(key).toLowerCase();
554
+ if (k === 'z' && mask & 8) return ['redo'];
555
+ return MAC_COMMANDS[k] ? [MAC_COMMANDS[k]] : undefined;
556
+ }
557
+
558
+ // type: 'down', 'up' o 'press' (down + up). def: { key, code, keyCode, text }.
559
+ export async function dispatchKey(page, type, def, mask = 0) {
560
+ // Con Ctrl o Meta la tecla no escribe texto (con Alt sí: ⌥E, ⌥N… en macOS).
561
+ const text = mask & 6 ? undefined : def.text;
562
+ const base = { key: def.key, code: def.code, windowsVirtualKeyCode: def.keyCode, modifiers: mask };
563
+ if (type !== 'up') {
564
+ await page.send('Input.dispatchKeyEvent', {
565
+ ...base,
566
+ type: text ? 'keyDown' : 'rawKeyDown',
567
+ text,
568
+ unmodifiedText: text,
569
+ autoRepeat: Boolean(def.repeat),
570
+ commands: editCommands(def.key, mask),
571
+ });
572
+ }
573
+ if (type !== 'down') await page.send('Input.dispatchKeyEvent', { ...base, type: 'keyUp' });
574
+ }
575
+
576
+ export async function viewport(page) {
577
+ const { cssVisualViewport: vp } = await page.send('Page.getLayoutMetrics');
578
+ return { width: vp.clientWidth, height: vp.clientHeight, pageX: vp.pageX, pageY: vp.pageY };
579
+ }
580
+
581
+ const BUTTONS = { left: 1, right: 2, middle: 4 };
582
+
583
+ // Touch: Input.dispatchTouchEvent llega como touch real (touchstart, pointerType "touch"). La
584
+ // primera vez se activa la emulación de touch de la ventana, para que la página se vea en una
585
+ // pantalla táctil (navigator.maxTouchPoints, pointer: coarse) mientras dure la conexión.
586
+ async function enableTouch(page) {
587
+ if (page.touchEnabled) return;
588
+ await page.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 });
589
+ page.touchEnabled = true;
590
+ }
591
+
592
+ // type: touchStart, touchMove, touchEnd o touchCancel; points en px CSS, con id estable por dedo.
593
+ export async function dispatchTouch(page, type, points = []) {
594
+ await enableTouch(page);
595
+ await page.send('Input.dispatchTouchEvent', {
596
+ type,
597
+ touchPoints: type === 'touchEnd' || type === 'touchCancel' ? [] : points.map((p, i) => ({ x: p.x, y: p.y, id: p.id ?? i, radiusX: 1, radiusY: 1, force: 1 })),
598
+ });
599
+ }
600
+
601
+ function mouse(page, type, x, y, extra = {}) {
602
+ return page.send('Input.dispatchMouseEvent', { type, x, y, ...extra });
603
+ }
604
+
605
+ // ---------- Árbol de accesibilidad ----------
606
+
607
+ const INTERACTIVE = new Set([
608
+ 'button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'combobox', 'listbox', 'option', 'menuitem',
609
+ 'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'slider', 'spinbutton', 'treeitem', 'listitem', 'row', 'cell',
610
+ ]);
611
+ const SKIP_ROLES = new Set(['generic', 'none', 'InlineTextBox', 'LineBreak']);
612
+ const MAX_AX_LINES = 1500;
613
+
614
+ async function accessibilityTree(page, { interactiveOnly = false } = {}) {
615
+ const { nodes } = await page.send('Accessibility.getFullAXTree');
616
+ const byId = new Map(nodes.map((n) => [n.nodeId, n]));
617
+ const vp = await viewport(page);
618
+ await page.send('DOM.getDocument', { depth: 0 }); // getBoxModel necesita el documento cargado
619
+ const lines = [];
620
+ let offScreen = 0;
621
+ // Centro normalizado del elemento, o null si está fuera de la vista.
622
+ const center = async (n) => {
623
+ if (!n.backendDOMNodeId) return '';
624
+ try {
625
+ const { model } = await page.send('DOM.getBoxModel', { backendNodeId: n.backendDOMNodeId });
626
+ const q = model.border;
627
+ const cx = (q[0] + q[2] + q[4] + q[6]) / 4;
628
+ const cy = (q[1] + q[3] + q[5] + q[7]) / 4;
629
+ if (cx < 0 || cy < 0 || cx > vp.width || cy > vp.height) return null;
630
+ return ` @(${(cx / vp.width).toFixed(3)}, ${(cy / vp.height).toFixed(3)})`;
631
+ } catch {
632
+ return '';
633
+ }
634
+ };
635
+ // Sin nombre accesible (p. ej. un <li>), se toma el primer texto de sus hijos.
636
+ const textOf = (n, depth = 0) => {
637
+ if (n.role?.value === 'StaticText') return n.name?.value || '';
638
+ if (depth > 4) return '';
639
+ for (const id of n.childIds || []) {
640
+ const child = byId.get(id);
641
+ const text = child && textOf(child, depth + 1);
642
+ if (text) return text;
643
+ }
644
+ return '';
645
+ };
646
+ const walk = async (n, depth, parentName) => {
647
+ if (lines.length >= MAX_AX_LINES) return;
648
+ const role = n.role?.value || '';
649
+ const interactive = INTERACTIVE.has(role);
650
+ const name = n.name?.value || (interactive ? textOf(n).slice(0, 80) : '');
651
+ const value = n.value?.value;
652
+ const show =
653
+ !n.ignored &&
654
+ !SKIP_ROLES.has(role) &&
655
+ !(role === 'StaticText' && (name === parentName || interactiveOnly)) &&
656
+ (!interactiveOnly || interactive) &&
657
+ (name || value !== undefined || interactive || !['StaticText', 'paragraph', 'group', 'section'].includes(role));
658
+ let nextDepth = depth;
659
+ if (show) {
660
+ const where = interactive ? await center(n) : '';
661
+ if (where === null && interactiveOnly) {
662
+ offScreen++;
663
+ } else {
664
+ const flags = (n.properties || [])
665
+ .filter((p) => ['focused', 'checked', 'disabled', 'expanded', 'selected', 'pressed'].includes(p.name) && p.value?.value)
666
+ .map((p) => (p.value.value === true ? p.name : `${p.name}=${p.value.value}`));
667
+ lines.push(
668
+ `${' '.repeat(interactiveOnly ? 0 : depth)}- ${role === 'StaticText' ? 'text' : role}` +
669
+ `${name ? ` ${JSON.stringify(name)}` : ''}${value !== undefined && value !== '' ? ` value=${JSON.stringify(value)}` : ''}` +
670
+ `${flags.length ? ` [${flags.join(', ')}]` : ''}${where === null ? ' (off screen)' : where}`,
671
+ );
672
+ }
673
+ nextDepth = depth + 1;
674
+ }
675
+ for (const id of n.childIds || []) {
676
+ const child = byId.get(id);
677
+ if (child) await walk(child, nextDepth, name || parentName);
678
+ }
679
+ };
680
+ const root = nodes.find((n) => !n.parentId) || nodes[0];
681
+ if (root) await walk(root, 0, '');
682
+ if (offScreen) lines.push(`(${offScreen} more off screen; scroll to reach them)`);
683
+ if (lines.length >= MAX_AX_LINES) lines.push(`… truncated at ${MAX_AX_LINES} lines; use interactive_only`);
684
+ return lines.join('\n');
685
+ }
686
+
687
+ // ---------- Acciones de alto nivel ----------
688
+ // Las usan la API HTTP del visor y el MCP cuando el visor no está corriendo.
689
+
690
+ const clamp01 = (v) => Math.min(1, Math.max(0, Number(v)));
691
+
692
+ export function createActions() {
693
+ const target = async (windowArg) => {
694
+ const w = await resolveWindow(windowArg);
695
+ return { w, page: await pageClient(w.app, w.id) };
696
+ };
697
+ const at = async (page, x, y) => {
698
+ const vp = await viewport(page);
699
+ return { x: clamp01(x) * vp.width, y: clamp01(y) * vp.height };
700
+ };
701
+
702
+ return {
703
+ windows: () => listWindows(),
704
+ project: ({ project } = {}) => projectInfo(project),
705
+ launch: ({ project } = {}) => launchApp({ project }),
706
+ attach: ({ port, inspectPort }) => attachApp({ port, inspectPort }),
707
+
708
+ screenshot: async ({ window, max }) => {
709
+ const { w, page } = await target(window);
710
+ const vp = await viewport(page);
711
+ const dpr = await evaluate(page, 'devicePixelRatio');
712
+ const scale = Number(max) ? Math.min(1, Number(max) / (Math.max(vp.width, vp.height) * dpr)) : 1;
713
+ const { data } = await page.send('Page.captureScreenshot', {
714
+ format: 'png',
715
+ clip: { x: vp.pageX, y: vp.pageY, width: vp.width, height: vp.height, scale },
716
+ });
717
+ const png = Buffer.from(data, 'base64');
718
+ return { data: png, mimeType: 'image/png', width: png.readUInt32BE(16), height: png.readUInt32BE(20), viewport: { width: vp.width, height: vp.height }, window: w.id };
719
+ },
720
+
721
+ click: async ({ window, x, y, button = 'left', count = 1, modifiers }) => {
722
+ const { page } = await target(window);
723
+ if (!BUTTONS[button]) throw invalid('button must be left, right or middle');
724
+ const p = await at(page, x, y);
725
+ const mask = modifierMask(modifiers);
726
+ await mouse(page, 'mouseMoved', p.x, p.y, { modifiers: mask });
727
+ for (let i = 1; i <= Math.max(1, Math.min(3, Number(count) || 1)); i++) {
728
+ await mouse(page, 'mousePressed', p.x, p.y, { button, buttons: BUTTONS[button], clickCount: i, modifiers: mask });
729
+ await mouse(page, 'mouseReleased', p.x, p.y, { button, buttons: 0, clickCount: i, modifiers: mask });
730
+ }
731
+ return { ok: true, x: p.x, y: p.y };
732
+ },
733
+
734
+ drag: async ({ window, x1, y1, x2, y2, ms = 300, modifiers }) => {
735
+ const { page } = await target(window);
736
+ const a = await at(page, x1, y1);
737
+ const b = await at(page, x2, y2);
738
+ const mask = modifierMask(modifiers);
739
+ const duration = Math.max(0, Math.min(10000, Number(ms) || 0));
740
+ const steps = Math.max(5, Math.round(duration / 16));
741
+ await mouse(page, 'mouseMoved', a.x, a.y, { modifiers: mask });
742
+ await mouse(page, 'mousePressed', a.x, a.y, { button: 'left', buttons: 1, clickCount: 1, modifiers: mask });
743
+ for (let i = 1; i <= steps; i++) {
744
+ await sleep(duration / steps);
745
+ const t = i / steps;
746
+ await mouse(page, 'mouseMoved', a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, { button: 'left', buttons: 1, modifiers: mask });
747
+ }
748
+ await mouse(page, 'mouseReleased', b.x, b.y, { button: 'left', buttons: 0, clickCount: 1, modifiers: mask });
749
+ return { ok: true };
750
+ },
751
+
752
+ tap: async ({ window, x, y }) => {
753
+ const { page } = await target(window);
754
+ const p = await at(page, x, y);
755
+ await dispatchTouch(page, 'touchStart', [p]);
756
+ await sleep(50);
757
+ await dispatchTouch(page, 'touchEnd');
758
+ return { ok: true };
759
+ },
760
+
761
+ swipe: async ({ window, x1, y1, x2, y2, ms = 300 }) => {
762
+ const { page } = await target(window);
763
+ const a = await at(page, x1, y1);
764
+ const b = await at(page, x2, y2);
765
+ const duration = Math.max(50, Math.min(10000, Number(ms) || 300));
766
+ const steps = Math.max(5, Math.round(duration / 16));
767
+ await dispatchTouch(page, 'touchStart', [a]);
768
+ for (let i = 1; i <= steps; i++) {
769
+ await sleep(duration / steps);
770
+ const t = i / steps;
771
+ await dispatchTouch(page, 'touchMove', [{ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }]);
772
+ }
773
+ await dispatchTouch(page, 'touchEnd');
774
+ return { ok: true };
775
+ },
776
+
777
+ longPress: async ({ window, x, y, ms = 800 }) => {
778
+ const { page } = await target(window);
779
+ const p = await at(page, x, y);
780
+ await dispatchTouch(page, 'touchStart', [p]);
781
+ await sleep(Math.max(100, Math.min(10000, Number(ms) || 800)));
782
+ await dispatchTouch(page, 'touchEnd');
783
+ return { ok: true };
784
+ },
785
+
786
+ // Dos dedos en horizontal alrededor de (x, y) que se separan (scale > 1) o se juntan (< 1).
787
+ pinch: async ({ window, x = 0.5, y = 0.5, scale = 2, ms = 400 }) => {
788
+ const { page } = await target(window);
789
+ const f = Number(scale);
790
+ if (!(f >= 0.1 && f <= 10) || f === 1) throw invalid('scale must be between 0.1 and 10, and not 1');
791
+ const vp = await viewport(page);
792
+ const c = await at(page, x, y);
793
+ const base = Math.min(vp.width, vp.height) * 0.1; // separación inicial al acercar
794
+ const from = f > 1 ? base : base / f;
795
+ const to = Math.min(from * f, Math.min(vp.width, vp.height) * 0.45);
796
+ const duration = Math.max(100, Math.min(10000, Number(ms) || 400));
797
+ const steps = Math.max(5, Math.round(duration / 16));
798
+ const fingers = (d) => [
799
+ { x: Math.max(0, c.x - d), y: c.y, id: 0 },
800
+ { x: Math.min(vp.width, c.x + d), y: c.y, id: 1 },
801
+ ];
802
+ await dispatchTouch(page, 'touchStart', fingers(from));
803
+ for (let i = 1; i <= steps; i++) {
804
+ await sleep(duration / steps);
805
+ await dispatchTouch(page, 'touchMove', fingers(from + (to - from) * (i / steps)));
806
+ }
807
+ await dispatchTouch(page, 'touchEnd');
808
+ return { ok: true, scale: Number((to / from).toFixed(3)) };
809
+ },
810
+
811
+ hover: async ({ window, x, y }) => {
812
+ const { page } = await target(window);
813
+ const p = await at(page, x, y);
814
+ await mouse(page, 'mouseMoved', p.x, p.y);
815
+ return { ok: true };
816
+ },
817
+
818
+ scroll: async ({ window, x = 0.5, y = 0.5, dx = 0, dy = 0 }) => {
819
+ const { page } = await target(window);
820
+ const p = await at(page, x, y);
821
+ await mouse(page, 'mouseMoved', p.x, p.y);
822
+ await mouse(page, 'mouseWheel', p.x, p.y, { deltaX: Number(dx) || 0, deltaY: Number(dy) || 0 });
823
+ return { ok: true };
824
+ },
825
+
826
+ text: async ({ window, text }) => {
827
+ if (typeof text !== 'string' || !text) throw invalid('text is required');
828
+ const { page } = await target(window);
829
+ await page.send('Input.insertText', { text });
830
+ return { ok: true };
831
+ },
832
+
833
+ key: async ({ window, key, modifiers }) => {
834
+ if (!key) throw invalid('key is required');
835
+ const { page } = await target(window);
836
+ await dispatchKey(page, 'press', keyDefinition(String(key)), modifierMask(modifiers));
837
+ return { ok: true };
838
+ },
839
+
840
+ // Con el main se cambia la ventana real; si no llega al tamaño pedido (pantalla más chica, kiosk,
841
+ // pantalla completa) o con emulate, se emula el viewport exacto. 0 × 0 vuelve al tamaño de la ventana.
842
+ resize: async ({ window, width, height, emulate }) => {
843
+ const { w, page } = await target(window);
844
+ const fit = !Number(width) || !Number(height);
845
+ if (!fit && (Number(width) < 100 || Number(height) < 100 || Number(width) > 8000 || Number(height) > 8000)) {
846
+ throw invalid('width and height must be between 100 and 8000');
847
+ }
848
+ await page.send('Emulation.clearDeviceMetricsOverride');
849
+ if (fit) return { ...(await viewport(page)), via: 'window' };
850
+ const W = Math.round(width);
851
+ const H = Math.round(height);
852
+ const force = emulate === true || emulate === 'true';
853
+ let reached = null;
854
+ if (w.app.main && !force) {
855
+ await evaluateMain(w.app, `(${winExpr(w.id)}).setContentSize(${W}, ${H}), 0`);
856
+ await sleep(150);
857
+ reached = await viewport(page);
858
+ if (reached.width === W && reached.height === H) return { ...reached, via: 'window' };
859
+ }
860
+ await page.send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 0, mobile: false });
861
+ const result = { ...(await viewport(page)), via: 'viewport emulation' };
862
+ if (reached) result.note = `The window only reached ${reached.width}×${reached.height} (screen size, kiosk or full screen), so the page is emulated at ${W}×${H}.`;
863
+ return result;
864
+ },
865
+
866
+ reload: async ({ window, ignoreCache }) => {
867
+ const { page } = await target(window);
868
+ await page.send('Page.reload', { ignoreCache: Boolean(ignoreCache) });
869
+ return { ok: true };
870
+ },
871
+
872
+ evaluate: async ({ window, expression }) => {
873
+ if (!expression) throw invalid('expression is required');
874
+ const { page } = await target(window);
875
+ return { value: await evaluate(page, String(expression)) };
876
+ },
877
+
878
+ evaluateMain: async ({ app, window, expression }) => {
879
+ if (!expression) throw invalid('expression is required');
880
+ const owner = window ? (await resolveWindow(window)).app : await resolveApp(app);
881
+ return { value: await evaluateMain(owner, String(expression)) };
882
+ },
883
+
884
+ axTree: async ({ window, interactiveOnly }) => {
885
+ const { page } = await target(window);
886
+ return { tree: await accessibilityTree(page, { interactiveOnly: interactiveOnly === true || interactiveOnly === 'true' }) };
887
+ },
888
+
889
+ logs: async ({ app, window, level, source = 'all', limit = 100 }) => {
890
+ const w = window ? await resolveWindow(window) : null;
891
+ const owner = w ? w.app : await resolveApp(app);
892
+ if (source === 'stdout') {
893
+ if (!owner.log) throw invalid('stdout is only available for apps started with launch_app');
894
+ return { app: owner.id, stdout: tail(owner.log, Math.min(1000, Number(limit) || 100)) };
895
+ }
896
+ // Asegura las conexiones (y el replay de mensajes previos) antes de leer el buffer.
897
+ await Promise.all([...owner.windows.map((x) => pageClient(owner, x.id)), owner.main ? mainClient(owner) : null].map((p) => p?.catch(() => {})));
898
+ await sleep(100);
899
+ const levels = level ? String(level).split(',') : null;
900
+ const logs = runtimeOf(owner)
901
+ .logs.filter((e) => (source === 'all' || e.source === source) && (!w || e.window === w.id || e.source === 'main') && (!levels || levels.includes(e.level)))
902
+ .slice(-Math.min(MAX_LOGS, Number(limit) || 100))
903
+ .map((e) => ({ ...e, time: new Date(e.time).toISOString() }));
904
+ return { app: owner.id, logs };
905
+ },
906
+
907
+ devtools: async ({ window }) => {
908
+ const { w } = await target(window);
909
+ if (w.app.main) {
910
+ const open = await evaluateMain(
911
+ w.app,
912
+ `(w => { const wc = w.webContents; if (wc.isDevToolsOpened()) { wc.closeDevTools(); return false; } wc.openDevTools({ mode: 'detach' }); return true; })(${winExpr(w.id)})`,
913
+ );
914
+ return { open };
915
+ }
916
+ const port = w.app.cdpPort;
917
+ return { url: `http://127.0.0.1:${port}/devtools/inspector.html?ws=127.0.0.1:${port}/devtools/page/${w.id}` };
918
+ },
919
+ };
920
+ }