@acertijosapps/electron-viewer 1.0.0 → 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.
package/README.md CHANGED
@@ -4,17 +4,18 @@ Live viewer for Electron apps in the browser, plus an MCP server so an AI agent
4
4
  Built for the browser pane of AI coding tools (such as Claude Code): you and the agent watch and
5
5
  control the same app window, in real time.
6
6
 
7
- <img src="https://unpkg.com/@acertijosapps/electron-viewer/media/viewer.jpg" width="560" alt="Electron Viewer showing the example app: window selector and launch button on top, the live window in the middle, and the reload, DevTools, window size, frame and screenshot controls at the bottom" />
7
+ <img src="https://unpkg.com/@acertijosapps/electron-viewer/media/viewer.jpg" width="560" alt="Electron Viewer showing the example app: window selector and launch button on top, the live window in the middle, and the reload, DevTools, window size, touch and screenshot controls at the bottom" />
8
8
 
9
9
  - **Live video**: `Page.startScreencast` from the Chrome DevTools Protocol (CDP) over WebSocket. It keeps
10
10
  streaming while the app window is covered or minimized.
11
11
  - **Real input**: mouse move and hover, click, double click, right click, drag, wheel and full keyboard
12
12
  passthrough (including ⌘C/⌘V/⌘A on macOS, IME and dictation).
13
+ - **Touch**: a touch mode in the viewer turns the pointer into a finger, and the agent can tap, swipe,
14
+ long press and pinch, for touch-first and kiosk apps.
13
15
  - **Every app and window**: launch your project with `electron .` from the viewer, or attach to any app
14
16
  started with `--remote-debugging-port`. Apps on ports 9222–9231 are found automatically.
15
17
  - **Main process too**: launched apps also expose their main process (Node inspector), so the agent can
16
- run code there, resize the real window, open DevTools and capture the native frame.
17
- - **Native frame capture**: title bar, context menus and dialogs on top of the window.
18
+ run code there, resize the real window and open DevTools.
18
19
  - **MCP server**: the same actions as tools, plus JavaScript in the renderer and main process, the
19
20
  accessibility tree with click-ready coordinates, and console logs.
20
21
  - **Nothing to add to your app**: no preload, no dependency, no code changes.
@@ -32,28 +33,42 @@ control the same app window, in real time.
32
33
  From your Electron project folder:
33
34
 
34
35
  ```sh
35
- npx @acertijosapps/electron-viewer
36
+ npx @acertijosapps/electron-viewer@1.1.0
36
37
  ```
37
38
 
38
39
  Open <http://localhost:8094> and click **Launch**. The viewer runs `electron .` on free debugging
39
40
  ports (CDP from 9222, Node inspector from 9229). Deep link to a window: `?window=<target id>`.
40
41
 
41
- If your app is started by another tool (Electron Forge, electron-vite, a custom script), pass the flags
42
- yourself and the viewer finds it:
42
+ If your app is started by another tool (electron-vite, Electron Forge, a custom script), give the viewer
43
+ your own start command. `{cdpPort}` and `{inspectPort}` are replaced with free debugging ports, and the
44
+ Launch button (and `launch_app`) runs it in the project folder:
45
+
46
+ ```sh
47
+ npx @acertijosapps/electron-viewer@1.1.0 --launch-cmd "yarn dev --remoteDebuggingPort {cdpPort} --inspect {inspectPort}"
48
+ ```
49
+
50
+ That line is for electron-vite; for an npm script that ends in `electron .`, pass the flags after `--`:
51
+ `--launch-cmd "npm start -- --remote-debugging-port={cdpPort} --inspect={inspectPort}"`. The command is
52
+ only read from the command line or `ELECTRON_VIEWER_LAUNCH_CMD`, never from the HTTP API or the MCP.
53
+
54
+ Or start the app yourself with the flags and the viewer finds it:
43
55
 
44
56
  ```sh
45
57
  electron --remote-debugging-port=9222 --inspect=9229 .
46
58
  ```
47
59
 
48
- `--inspect` is optional; without it you get everything except the main-process features
49
- (`evaluate_main`, frame capture, real window resizing and in-app DevTools). To attach on another
50
- port, use the form in the viewer or `--attach`.
60
+ Apps on CDP ports 9222–9231 are found automatically, and so is their main process when its inspector is
61
+ on 9229–9238. `--inspect` is optional; without it you get everything except the main-process features
62
+ (`evaluate_main`, real window resizing and in-app DevTools). To attach on other ports, use the form in
63
+ the viewer or `--attach` and `--inspect`.
51
64
 
52
65
  ```text
53
66
  electron-viewer [options] Start the viewer
54
67
  electron-viewer mcp [options] Start the MCP server on stdio
55
68
 
56
69
  --project <dir> Project to launch with "electron ." (default: current folder)
70
+ --launch-cmd <cmd> Launch with this shell command instead; {cdpPort} and {inspectPort}
71
+ are replaced with the debugging ports
57
72
  -p, --port <port> Viewer port (default 8094, env PORT)
58
73
  --host <host> Interface to bind (default 127.0.0.1, env HOST)
59
74
  --attach <port> Attach at startup to an app on this CDP port
@@ -72,7 +87,7 @@ Other environment variables: `MAX_DIM` (longest side of the streamed frames, def
72
87
  Claude Code:
73
88
 
74
89
  ```sh
75
- claude mcp add electron-viewer -- npx -y @acertijosapps/electron-viewer mcp
90
+ claude mcp add electron-viewer -- npx -y @acertijosapps/electron-viewer@1.1.0 mcp
76
91
  ```
77
92
 
78
93
  Any other MCP client:
@@ -82,7 +97,7 @@ Any other MCP client:
82
97
  "mcpServers": {
83
98
  "electron-viewer": {
84
99
  "command": "npx",
85
- "args": ["-y", "@acertijosapps/electron-viewer", "mcp"]
100
+ "args": ["-y", "@acertijosapps/electron-viewer@1.1.0", "mcp"]
86
101
  }
87
102
  }
88
103
  }
@@ -91,7 +106,10 @@ Any other MCP client:
91
106
  Also listed in the [official MCP Registry](https://registry.modelcontextprotocol.io) as
92
107
  `io.github.acertijosapps/electron-viewer`.
93
108
 
94
- Keep the viewer running (`npx @acertijosapps/electron-viewer`) to watch what the agent does. Without
109
+ The examples pin an exact version. The viewer and the MCP can run code in your app, so pin the version
110
+ you reviewed instead of letting `npx` fetch whatever was published last, and update it on purpose.
111
+
112
+ Keep the viewer running (`npx @acertijosapps/electron-viewer@1.1.0`) to watch what the agent does. Without
95
113
  it, the MCP drives the app over CDP directly. Apps launched by either one are visible to the other.
96
114
 
97
115
  | Tool | What it does |
@@ -99,14 +117,18 @@ it, the MCP drives the app over CDP directly. Apps launched by either one are vi
99
117
  | `list_windows` | Running apps (with `main` when the main process is reachable) and their windows |
100
118
  | `launch_app` | Run `electron .` in the project on free debugging ports and wait for the first window |
101
119
  | `attach` | Attach to an app on a CDP port, optionally with its inspector port |
102
- | `screenshot` | PNG of the window content, longest side ≤ 1024 px; `frame: true` adds the native frame, menus and dialogs |
120
+ | `screenshot` | PNG of the window content, longest side ≤ 1024 px |
103
121
  | `click` | Left, right or middle click; `count: 2` for a double click; optional modifiers |
104
122
  | `drag` | Press, move in steps and release: a real drag |
105
123
  | `hover` | Move the mouse without clicking |
124
+ | `tap` | One-finger tap (touch events, `pointerType: "touch"`) |
125
+ | `swipe` | One-finger swipe: scrolls touch lists and drives swipe gestures |
126
+ | `long_press` | Hold one finger down for a while, 800 ms by default |
127
+ | `pinch` | Two-finger pinch around a point; `scale` > 1 zooms in, < 1 zooms out |
106
128
  | `scroll` | Mouse wheel at a point, deltas in CSS px |
107
129
  | `type_text` | Insert text into the focused element (any Unicode) |
108
130
  | `press_key` | Enter, Tab, Escape, arrows, F1–F12… or any character, with modifiers |
109
- | `resize_window` | Resize the content area (the real window with the main process, emulated without it) |
131
+ | `resize_window` | Resize the content area: the real window with the main process, an emulated viewport when the window can't reach the size (or with `emulate: true`) |
110
132
  | `reload` | Reload the window's page |
111
133
  | `evaluate` | Run JavaScript in the window's page and return the value |
112
134
  | `evaluate_main` | Run JavaScript in the main process, with `require('electron')` available |
@@ -128,7 +150,7 @@ Add the viewer to `.claude/launch.json` and open it with `preview_start electron
128
150
  {
129
151
  "name": "electron-viewer",
130
152
  "runtimeExecutable": "npx",
131
- "runtimeArgs": ["-y", "@acertijosapps/electron-viewer"],
153
+ "runtimeArgs": ["-y", "@acertijosapps/electron-viewer@1.1.0"],
132
154
  "port": 8094,
133
155
  "autoPort": false
134
156
  }
@@ -136,19 +158,6 @@ Add the viewer to `.claude/launch.json` and open it with `preview_start electron
136
158
  }
137
159
  ```
138
160
 
139
- ## Native frame capture
140
-
141
- The **frame** button (and `screenshot` with `frame: true`) captures the screen area of the window from
142
- the main process with `desktopCapturer`: the title bar, native context menus and dialogs on top of the
143
- window show up, at a few frames per second. Anything else covering the window shows up too, and the
144
- macOS menu bar at the top of the screen is not included. In frame mode, clicks on the content still
145
- work; the title bar and native menus can't be clicked from the viewer.
146
-
147
- On macOS the app needs the **Screen Recording** permission (System Settings › Privacy & Security ›
148
- Screen & System Audio Recording). For an app run with `electron .`, that is the `Electron.app` inside
149
- the project's `node_modules/electron/dist`; the error message gives the exact path. Restart the app
150
- after allowing it.
151
-
152
161
  ## HTTP API
153
162
 
154
163
  The MCP uses the viewer's API; you can call it too. `window` (target id or part of the title) goes in
@@ -160,7 +169,7 @@ the query string or the body and is optional with a single window. The server on
160
169
  | --- | --- |
161
170
  | `GET /api/windows` | `[{ id, name, cdpPort, inspectPort, main, windows: [{ id, title, url }] }]` |
162
171
  | `GET /api/project` | The project the viewer launches and whether it has Electron installed |
163
- | `GET /api/screenshot?max=<px>&frame=1` | PNG; viewport size in `X-Viewport-Width` / `X-Viewport-Height` |
172
+ | `GET /api/screenshot?max=<px>` | PNG; viewport size in `X-Viewport-Width` / `X-Viewport-Height` |
164
173
  | `GET /api/ax-tree?interactiveOnly=true` | `{ tree }` |
165
174
  | `GET /api/logs?level=&source=&limit=` | `{ logs }` or `{ stdout }` |
166
175
  | `POST /api/launch` | `{ project? }` |
@@ -168,10 +177,14 @@ the query string or the body and is optional with a single window. The server on
168
177
  | `POST /api/click` | `{ x, y, button?, count?, modifiers? }` |
169
178
  | `POST /api/drag` | `{ x1, y1, x2, y2, ms? }` |
170
179
  | `POST /api/hover` | `{ x, y }` |
180
+ | `POST /api/tap` | `{ x, y }` |
181
+ | `POST /api/swipe` | `{ x1, y1, x2, y2, ms? }` |
182
+ | `POST /api/long-press` | `{ x, y, ms? }` |
183
+ | `POST /api/pinch` | `{ x?, y?, scale, ms? }` |
171
184
  | `POST /api/scroll` | `{ x, y, dx, dy }` |
172
185
  | `POST /api/text` | `{ text }` |
173
186
  | `POST /api/key` | `{ key, modifiers? }` |
174
- | `POST /api/resize` | `{ width, height }` (0 × 0 restores the window size) |
187
+ | `POST /api/resize` | `{ width, height, emulate? }` (0 × 0 restores the window size) |
175
188
  | `POST /api/reload` | `{ ignoreCache? }` |
176
189
  | `POST /api/evaluate` | `{ expression }` |
177
190
  | `POST /api/evaluate-main` | `{ expression, app? }` |
@@ -189,24 +202,32 @@ the query string or the body and is optional with a single window. The server on
189
202
  textarea holds the focus: keys are sent with `Input.dispatchKeyEvent`, and text that arrives without
190
203
  key events (IME, dictation) with `Input.insertText`. On macOS, ⌘A, ⌘C, ⌘V, ⌘X and ⌘Z are sent as
191
204
  editing commands, because the native menu that normally handles them never sees CDP events.
205
+ - Touch goes through `Input.dispatchTouchEvent`. The first touch action on a window also turns on touch
206
+ emulation (`navigator.maxTouchPoints` 5, `pointer: coarse`) until the viewer or MCP that sent it exits.
192
207
  - Each process keeps one connection per window and per main process, which also collects console
193
208
  messages and exceptions (the last 1000 per app).
209
+ - For an app with no known inspector, each Node inspector on ports 9229–9238 is asked once, read only,
210
+ whether it is Electron and which CDP port its app listens on. Any other Node process started with
211
+ `--inspect` in that range gets one short connection (it logs `Debugger attached.`) and is left alone.
194
212
  - Launched and attached apps are remembered in `~/.cache/electron-viewer`, so the viewer and the MCP see
195
213
  the same apps. Launched apps write their output to a log file there and keep running if the viewer
196
214
  or MCP exits.
197
215
 
198
216
  ## Limitations
199
217
 
200
- - CDP only sees web content. Native UI (title bar, menus, dialogs, tray, notifications) is visible only
201
- in frame mode and can't be clicked; use `evaluate_main` to drive it from the main process.
202
- - Synchronous dialogs (`dialog.showMessageBoxSync`) block the main process: `evaluate_main` and frame
203
- capture wait until the dialog is closed.
218
+ - CDP only sees web content. Native UI (title bar, menus, dialogs, tray, notifications) isn't shown or
219
+ clickable; use `evaluate_main` to drive it from the main process. Capturing it would mean capturing the
220
+ screen, and with it any other window on top of the app, so the viewer doesn't.
221
+ - Synchronous dialogs (`dialog.showMessageBoxSync`) block the main process: `evaluate_main` waits until
222
+ the dialog is closed.
204
223
  - Each `WebContentsView` is a separate CDP target, so it is listed as its own window. `<webview>` tags are
205
224
  not listed.
225
+ - A long press reaches the app as a held touch, so apps that time `touchstart` themselves see it, but
226
+ Chromium on desktop doesn't turn it into a context menu. The viewer's touch mode is one finger; use
227
+ `pinch` for two.
206
228
  - No audio.
207
- - Frame mode runs at a few frames per second and, on macOS, needs the Screen Recording permission.
208
- - Apps that build before starting (Forge, electron-vite) can't be launched from the viewer; start them
209
- with the debugging flags and they are picked up.
229
+ - A `--launch-cmd` must pass the ports on to Electron; how depends on the tool. Launched apps keep running
230
+ when the viewer exits; close them from the app (or its terminal).
210
231
 
211
232
  ## Contributing
212
233
 
@@ -17,6 +17,10 @@ Usage:
17
17
  Common options:
18
18
  --project <dir> Electron project to launch with "electron ." (default:
19
19
  current folder, env ELECTRON_VIEWER_PROJECT)
20
+ --launch-cmd <cmd> Launch with this shell command instead (env
21
+ ELECTRON_VIEWER_LAUNCH_CMD); {cdpPort} and {inspectPort}
22
+ are replaced with the debugging ports, e.g.
23
+ "yarn dev --remoteDebuggingPort {cdpPort} --inspect {inspectPort}"
20
24
 
21
25
  Viewer options:
22
26
  -p, --port <port> Port to listen on (default: 8094, env PORT)
@@ -24,8 +28,8 @@ Viewer options:
24
28
  --attach <port> Attach at startup to an app already running with
25
29
  --remote-debugging-port=<port>
26
30
  --inspect <port> With --attach: the app's main process inspector
27
- (--inspect=<port>), for evaluate_main, frame capture,
28
- DevTools and real window resizing
31
+ (--inspect=<port>), for evaluate_main, DevTools and
32
+ real window resizing
29
33
 
30
34
  MCP options:
31
35
  --viewer-url <url> Viewer to talk to (default: http://localhost:8094,
@@ -82,6 +86,12 @@ for (let i = 0; i < args.length; i++) {
82
86
  case '--project':
83
87
  process.env.ELECTRON_VIEWER_PROJECT = path.resolve(value());
84
88
  break;
89
+ case '--launch-cmd': {
90
+ const cmd = value();
91
+ if (!cmd.includes('{cdpPort}')) fail('--launch-cmd must include {cdpPort}');
92
+ process.env.ELECTRON_VIEWER_LAUNCH_CMD = cmd;
93
+ break;
94
+ }
85
95
  case '--attach':
86
96
  process.env.ELECTRON_VIEWER_ATTACH = portValue(flag, value());
87
97
  break;
package/lib/electron.js CHANGED
@@ -19,6 +19,8 @@ const RESERVED_PORTS = new Set([8081, 8082, 8093, 8094]);
19
19
  const STATE_DIR = process.env.ELECTRON_VIEWER_STATE_DIR || path.join(os.homedir(), '.cache', 'electron-viewer');
20
20
  const STATE_FILE = path.join(STATE_DIR, 'apps.json');
21
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;
22
24
  const FIRST_WINDOW_TIMEOUT_MS = 10000;
23
25
  const MAX_LOGS = 1000;
24
26
  const IS_MAC = process.platform === 'darwin';
@@ -101,7 +103,49 @@ export async function listApps() {
101
103
  }),
102
104
  );
103
105
  if (stale.length) updateState((s) => stale.forEach((p) => delete s[p]));
104
- return apps.filter(Boolean).sort((a, b) => a.cdpPort - b.cdpPort);
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
+ }
105
149
  }
106
150
 
107
151
  const isWindowTarget = (t) => t.type === 'page' && !t.url.startsWith('devtools://');
@@ -229,6 +273,12 @@ export async function resolveElectron(dir) {
229
273
  return downloaded;
230
274
  }
231
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
+
232
282
  export function projectInfo(project) {
233
283
  const dir = projectDir(project);
234
284
  let name = path.basename(dir);
@@ -238,11 +288,13 @@ export function projectInfo(project) {
238
288
  } catch {
239
289
  // sin package.json: el nombre de la carpeta
240
290
  }
291
+ const command = launchCommand();
292
+ if (command) return { dir, name, command, launchable: true };
241
293
  try {
242
294
  electronPackageDir(dir);
243
- return { dir, name, launchable: true };
295
+ return { dir, name, command: 'electron .', launchable: true };
244
296
  } catch (err) {
245
- return { dir, name, launchable: false, reason: err.message };
297
+ return { dir, name, command: 'electron .', launchable: false, reason: err.message };
246
298
  }
247
299
  }
248
300
 
@@ -256,7 +308,11 @@ function tail(file, lines = 20) {
256
308
 
257
309
  export async function launchApp({ project, args = [] } = {}) {
258
310
  const info = projectInfo(project);
259
- const electron = await resolveElectron(info.dir);
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);
260
316
  const cdpPort = await freePort(CDP_PORT);
261
317
  const inspectPort = await freePort(INSPECT_PORT, [cdpPort]);
262
318
  mkdirSync(STATE_DIR, { recursive: true });
@@ -265,29 +321,27 @@ export async function launchApp({ project, args = [] } = {}) {
265
321
  const fd = openSync(log, 'w');
266
322
  const env = { ...process.env };
267
323
  delete env.ELECTRON_RUN_AS_NODE;
268
- const child = spawn(electron, [`--inspect=${inspectPort}`, `--remote-debugging-port=${cdpPort}`, '.', ...args], {
269
- cwd: info.dir,
270
- env,
271
- detached: true,
272
- stdio: ['ignore', fd, fd],
273
- });
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);
274
328
  closeSync(fd);
275
329
  let exited = null;
276
330
  child.once('exit', (code, signal) => (exited = `exited (code ${code}${signal ? `, ${signal}` : ''})`));
277
331
  child.once('error', (err) => (exited = err.message));
278
332
  child.unref();
279
333
 
280
- const deadline = Date.now() + LAUNCH_TIMEOUT_MS;
334
+ const deadline = Date.now() + (command ? LAUNCH_CMD_TIMEOUT_MS : LAUNCH_TIMEOUT_MS);
281
335
  while (!(await probe(cdpPort))) {
282
336
  if (exited || Date.now() > deadline) {
283
- if (!exited) child.kill();
337
+ if (!exited) killGroup(child);
284
338
  const output = tail(log);
285
339
  throw new Error(`${info.name} ${exited || 'did not open its debugging port in time'}${output ? `:\n${output}` : ''}`);
286
340
  }
287
341
  await sleep(200);
288
342
  }
289
343
  updateState((s) => {
290
- s[cdpPort] = { cdpPort, inspectPort, pid: child.pid, project: info.dir, name: info.name, log, startedAt: new Date().toISOString() };
344
+ s[cdpPort] = { cdpPort, inspectPort, pid: child.pid, project: info.dir, name: info.name, command: info.command, log, startedAt: new Date().toISOString() };
291
345
  });
292
346
  const until = Date.now() + FIRST_WINDOW_TIMEOUT_MS;
293
347
  let app;
@@ -299,6 +353,17 @@ export async function launchApp({ project, args = [] } = {}) {
299
353
  return app;
300
354
  }
301
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
+
302
367
  export async function attachApp({ port, inspectPort } = {}) {
303
368
  port = Number(port);
304
369
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw invalid('port must be a TCP port');
@@ -515,69 +580,26 @@ export async function viewport(page) {
515
580
 
516
581
  const BUTTONS = { left: 1, right: 2, middle: 4 };
517
582
 
518
- function mouse(page, type, x, y, extra = {}) {
519
- return page.send('Input.dispatchMouseEvent', { type, x, y, ...extra });
520
- }
521
-
522
- // ---------- Captura con el marco nativo ----------
523
- // desktopCapturer recorta la pantalla en el área de la ventana: sale la barra de título, menús
524
- // contextuales y diálogos encima de ella (y cualquier otra ventana que la tape).
525
-
526
- const nativeCaptureExpr = (targetId, { format, quality, maxDim }) => `(async () => {
527
- const { desktopCapturer, screen } = require('electron');
528
- const win = ${winExpr(targetId)};
529
- const bounds = win.getBounds();
530
- const content = win.getContentBounds();
531
- const display = screen.getDisplayMatching(bounds);
532
- const k = display.scaleFactor;
533
- let sources;
534
- try {
535
- sources = await desktopCapturer.getSources({
536
- types: ['screen'],
537
- thumbnailSize: { width: Math.round(display.size.width * k), height: Math.round(display.size.height * k) },
538
- });
539
- } catch (err) {
540
- const app = process.execPath.replace(/(\\.app)\\/.*$/, '$1');
541
- throw new Error(process.platform === 'darwin'
542
- ? 'Screen capture is not allowed. In System Settings › Privacy & Security › Screen & System Audio Recording, allow ' + app + ' and restart the app.'
543
- : 'Screen capture failed: ' + (err.message || err));
544
- }
545
- const source = sources.find((s) => s.display_id === String(display.id)) || sources[0];
546
- if (!source) throw new Error('No screen to capture');
547
- const shot = source.thumbnail;
548
- const scale = shot.getSize().width / display.bounds.width;
549
- const x0 = Math.max(bounds.x, display.bounds.x);
550
- const y0 = Math.max(bounds.y, display.bounds.y);
551
- const x1 = Math.min(bounds.x + bounds.width, display.bounds.x + display.bounds.width);
552
- const y1 = Math.min(bounds.y + bounds.height, display.bounds.y + display.bounds.height);
553
- if (x1 <= x0 || y1 <= y0 || win.isMinimized()) throw new Error('The window is not on screen');
554
- let img = shot.crop({
555
- x: Math.round((x0 - display.bounds.x) * scale),
556
- y: Math.round((y0 - display.bounds.y) * scale),
557
- width: Math.round((x1 - x0) * scale),
558
- height: Math.round((y1 - y0) * scale),
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 })),
559
598
  });
560
- const max = ${Number(maxDim) || 0};
561
- const size = img.getSize();
562
- if (max && Math.max(size.width, size.height) > max) {
563
- const f = max / Math.max(size.width, size.height);
564
- img = img.resize({ width: Math.round(size.width * f), height: Math.round(size.height * f), quality: 'good' });
565
- }
566
- const out = img.getSize();
567
- const w = x1 - x0;
568
- const h = y1 - y0;
569
- return {
570
- data: ${format === 'png' ? 'img.toPNG()' : `img.toJPEG(${Number(quality) || 80})`}.toString('base64'),
571
- width: out.width,
572
- height: out.height,
573
- // Área del contenido (lo que ve CDP) dentro de la imagen, normalizada 0–1.
574
- content: { x: (content.x - x0) / w, y: (content.y - y0) / h, width: content.width / w, height: content.height / h },
575
- };
576
- })()`;
599
+ }
577
600
 
578
- export async function captureNative(app, targetId, { format = 'jpeg', quality, maxDim } = {}) {
579
- const shot = await evaluateMain(app, nativeCaptureExpr(targetId, { format, quality, maxDim }), { timeout: 15000 });
580
- return { ...shot, data: Buffer.from(shot.data, 'base64'), mimeType: format === 'png' ? 'image/png' : 'image/jpeg' };
601
+ function mouse(page, type, x, y, extra = {}) {
602
+ return page.send('Input.dispatchMouseEvent', { type, x, y, ...extra });
581
603
  }
582
604
 
583
605
  // ---------- Árbol de accesibilidad ----------
@@ -683,12 +705,8 @@ export function createActions() {
683
705
  launch: ({ project } = {}) => launchApp({ project }),
684
706
  attach: ({ port, inspectPort }) => attachApp({ port, inspectPort }),
685
707
 
686
- screenshot: async ({ window, max, frame }) => {
708
+ screenshot: async ({ window, max }) => {
687
709
  const { w, page } = await target(window);
688
- if (frame && frame !== 'false' && frame !== '0') {
689
- const shot = await captureNative(w.app, w.id, { format: 'png', maxDim: Number(max) || undefined });
690
- return { ...shot, window: w.id, frame: true };
691
- }
692
710
  const vp = await viewport(page);
693
711
  const dpr = await evaluate(page, 'devicePixelRatio');
694
712
  const scale = Number(max) ? Math.min(1, Number(max) / (Math.max(vp.width, vp.height) * dpr)) : 1;
@@ -731,6 +749,65 @@ export function createActions() {
731
749
  return { ok: true };
732
750
  },
733
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
+
734
811
  hover: async ({ window, x, y }) => {
735
812
  const { page } = await target(window);
736
813
  const p = await at(page, x, y);
@@ -760,21 +837,30 @@ export function createActions() {
760
837
  return { ok: true };
761
838
  },
762
839
 
763
- resize: async ({ window, width, height }) => {
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 }) => {
764
843
  const { w, page } = await target(window);
765
844
  const fit = !Number(width) || !Number(height);
766
845
  if (!fit && (Number(width) < 100 || Number(height) < 100 || Number(width) > 8000 || Number(height) > 8000)) {
767
846
  throw invalid('width and height must be between 100 and 8000');
768
847
  }
769
- if (w.app.main) {
770
- await page.send('Emulation.clearDeviceMetricsOverride');
771
- if (!fit) await evaluateMain(w.app, `(${winExpr(w.id)}).setContentSize(${Math.round(width)}, ${Math.round(height)}), 0`);
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`);
772
856
  await sleep(150);
773
- return { ...(await viewport(page)), via: 'window' };
857
+ reached = await viewport(page);
858
+ if (reached.width === W && reached.height === H) return { ...reached, via: 'window' };
774
859
  }
775
- if (fit) await page.send('Emulation.clearDeviceMetricsOverride');
776
- else await page.send('Emulation.setDeviceMetricsOverride', { width: Math.round(width), height: Math.round(height), deviceScaleFactor: 0, mobile: false });
777
- return { ...(await viewport(page)), via: 'viewport emulation' };
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;
778
864
  },
779
865
 
780
866
  reload: async ({ window, ignoreCache }) => {
package/mcp/server.js CHANGED
@@ -40,7 +40,6 @@ async function viewer(method, route, params = {}) {
40
40
  }
41
41
  const type = res.headers.get('content-type') || '';
42
42
  if (!type.startsWith('image/')) return res.json();
43
- const content = res.headers.get('x-content');
44
43
  const vw = res.headers.get('x-viewport-width');
45
44
  return {
46
45
  data: Buffer.from(await res.arrayBuffer()),
@@ -49,7 +48,6 @@ async function viewer(method, route, params = {}) {
49
48
  height: Number(res.headers.get('x-image-height')),
50
49
  window: res.headers.get('x-window'),
51
50
  viewport: vw ? { width: Number(vw), height: Number(res.headers.get('x-viewport-height')) } : undefined,
52
- content: content ? JSON.parse(content) : undefined,
53
51
  };
54
52
  }
55
53
 
@@ -82,7 +80,7 @@ server.registerTool(
82
80
  title: 'List windows',
83
81
  description:
84
82
  'Lists the running Electron apps (found on CDP ports 9222–9231, launched or attached) and their windows. ' +
85
- 'main=true means the main process is reachable (evaluate_main, frame screenshots, real window resizing). ' +
83
+ 'main=true means the main process is reachable (evaluate_main, real window resizing). ' +
86
84
  'Includes the viewer URL to watch a window live in a browser pane.',
87
85
  annotations: { readOnlyHint: true },
88
86
  },
@@ -90,7 +88,7 @@ server.registerTool(
90
88
  const { result: apps, via } = await run('windows', 'GET', '/api/windows');
91
89
  return text({
92
90
  apps,
93
- viewer: via === 'viewer' ? VIEWER_URL : 'not running (start it with `npx @acertijosapps/electron-viewer`)',
91
+ viewer: via === 'viewer' ? VIEWER_URL : `not running (start it with \`npx @acertijosapps/electron-viewer@${version}\`)`,
94
92
  });
95
93
  },
96
94
  );
@@ -100,8 +98,9 @@ server.registerTool(
100
98
  {
101
99
  title: 'Launch app',
102
100
  description:
103
- 'Starts the Electron project with `electron .` (its own node_modules/electron) on free debugging ports, ' +
104
- 'so both the renderer (CDP) and the main process (Node inspector) are reachable. Waits for the first window.',
101
+ 'Starts the Electron project on free debugging ports, so both the renderer (CDP) and the main process ' +
102
+ '(Node inspector) are reachable, and waits for the first window. It runs `electron .` with the project\'s own ' +
103
+ 'Electron, or the launch command configured with --launch-cmd (e.g. a dev server).',
105
104
  inputSchema: {
106
105
  project: z.string().optional().describe('Project folder (default: the folder the viewer or MCP was started in)'),
107
106
  },
@@ -130,22 +129,15 @@ server.registerTool(
130
129
  title: 'Screenshot',
131
130
  description:
132
131
  'Captures the window content (PNG). For click, drag, hover and scroll use normalized coordinates: ' +
133
- 'x = pixel_x / image width, y = pixel_y / image height. With frame=true it captures the screen area of ' +
134
- 'the window instead, with its native title bar, menus and dialogs (needs main=true; on macOS the app needs ' +
135
- 'the Screen Recording permission); coordinates still refer to the content screenshot.',
132
+ 'x = pixel_x / image width, y = pixel_y / image height. Native UI (title bar, menus, dialogs) is not included.',
136
133
  inputSchema: {
137
134
  window: windowArg,
138
- frame: z.boolean().optional().describe('Include the native window frame, menus and dialogs'),
139
135
  },
140
136
  annotations: { readOnlyHint: true },
141
137
  },
142
- async ({ window, frame }) => {
143
- const { result: shot } = await run('screenshot', 'GET', '/api/screenshot', { window, max: SCREENSHOT_MAX, frame: frame ? 1 : undefined });
144
- const c = shot.content;
145
- const note = c
146
- ? `Window with frame, ${shot.width}×${shot.height} px. Content area: x ${c.x.toFixed(3)}–${(c.x + c.width).toFixed(3)}, ` +
147
- `y ${c.y.toFixed(3)}–${(c.y + c.height).toFixed(3)} of the image; click coordinates are normalized over the content only.`
148
- : `Viewport is ${shot.viewport?.width}×${shot.viewport?.height} CSS px (image ${shot.width}×${shot.height}, longest side ≤ ${SCREENSHOT_MAX}).`;
138
+ async ({ window }) => {
139
+ const { result: shot } = await run('screenshot', 'GET', '/api/screenshot', { window, max: SCREENSHOT_MAX });
140
+ const note = `Viewport is ${shot.viewport?.width}×${shot.viewport?.height} CSS px (image ${shot.width}×${shot.height}, longest side ≤ ${SCREENSHOT_MAX}).`;
149
141
  return {
150
142
  content: [
151
143
  { type: 'image', data: shot.data.toString('base64'), mimeType: shot.mimeType },
@@ -209,6 +201,80 @@ server.registerTool(
209
201
  },
210
202
  );
211
203
 
204
+ server.registerTool(
205
+ 'tap',
206
+ {
207
+ title: 'Tap',
208
+ description:
209
+ 'Taps a point with one finger (Input.dispatchTouchEvent): the page gets touch and pointer events with ' +
210
+ 'pointerType "touch". The first touch action turns on touch emulation for the window (maxTouchPoints, pointer: coarse).',
211
+ inputSchema: { x: coord('x coordinate'), y: coord('y coordinate'), window: windowArg },
212
+ },
213
+ async ({ x, y, window }) => {
214
+ await run('tap', 'POST', '/api/tap', { window, x, y });
215
+ return text(`tapped (${x.toFixed(3)}, ${y.toFixed(3)})`);
216
+ },
217
+ );
218
+
219
+ server.registerTool(
220
+ 'swipe',
221
+ {
222
+ title: 'Swipe',
223
+ description: 'Swipes one finger from one point to another (touch events; use drag for the mouse). Scrolls touch lists and drives swipe gestures.',
224
+ inputSchema: {
225
+ x1: coord('Start x'),
226
+ y1: coord('Start y'),
227
+ x2: coord('End x'),
228
+ y2: coord('End y'),
229
+ duration_ms: z.number().int().min(50).max(10000).default(300).describe('Gesture duration in ms'),
230
+ window: windowArg,
231
+ },
232
+ },
233
+ async ({ x1, y1, x2, y2, duration_ms, window }) => {
234
+ await run('swipe', 'POST', '/api/swipe', { window, x1, y1, x2, y2, ms: duration_ms });
235
+ return text(`swiped (${x1.toFixed(3)}, ${y1.toFixed(3)}) → (${x2.toFixed(3)}, ${y2.toFixed(3)}) in ${duration_ms} ms`);
236
+ },
237
+ );
238
+
239
+ server.registerTool(
240
+ 'long_press',
241
+ {
242
+ title: 'Long press',
243
+ description:
244
+ 'Holds one finger on a point and releases it. Apps that time touchstart themselves see a long press; ' +
245
+ "Chromium on desktop doesn't turn it into a context menu.",
246
+ inputSchema: {
247
+ x: coord('x coordinate'),
248
+ y: coord('y coordinate'),
249
+ duration_ms: z.number().int().min(100).max(10000).default(800).describe('How long the finger stays down, in ms'),
250
+ window: windowArg,
251
+ },
252
+ },
253
+ async ({ x, y, duration_ms, window }) => {
254
+ await run('longPress', 'POST', '/api/long-press', { window, x, y, ms: duration_ms });
255
+ return text(`long-pressed (${x.toFixed(3)}, ${y.toFixed(3)}) for ${duration_ms} ms`);
256
+ },
257
+ );
258
+
259
+ server.registerTool(
260
+ 'pinch',
261
+ {
262
+ title: 'Pinch',
263
+ description: 'Two-finger pinch centered on a point: scale > 1 spreads the fingers (zoom in), scale < 1 brings them together.',
264
+ inputSchema: {
265
+ x: coord('Center x').default(0.5),
266
+ y: coord('Center y').default(0.5),
267
+ scale: z.number().min(0.1).max(10).describe('Final finger distance ÷ initial distance, e.g. 2 or 0.5'),
268
+ duration_ms: z.number().int().min(100).max(10000).default(400).describe('Gesture duration in ms'),
269
+ window: windowArg,
270
+ },
271
+ },
272
+ async ({ x, y, scale, duration_ms, window }) => {
273
+ const { result } = await run('pinch', 'POST', '/api/pinch', { window, x, y, scale, ms: duration_ms });
274
+ return text(`pinched ×${result.scale} at (${x.toFixed(3)}, ${y.toFixed(3)})`);
275
+ },
276
+ );
277
+
212
278
  server.registerTool(
213
279
  'scroll',
214
280
  {
@@ -262,14 +328,17 @@ server.registerTool(
262
328
  title: 'Resize window',
263
329
  description:
264
330
  'Sets the size of the window content area in CSS px. With the main process it resizes the real window; ' +
265
- 'without it, it emulates the viewport. Width and height 0 restore the window size.',
331
+ 'if the window cannot reach that size (smaller screen, kiosk, full screen), without the main process or with ' +
332
+ 'emulate=true, the page viewport is emulated at the exact size instead. Width and height 0 restore the window size.',
266
333
  inputSchema: {
267
334
  width: z.number().int().min(0).max(8000).describe('Content width in CSS px'),
268
335
  height: z.number().int().min(0).max(8000).describe('Content height in CSS px'),
336
+ emulate: z.boolean().optional().describe('Emulate the viewport without touching the real window'),
269
337
  window: windowArg,
270
338
  },
271
339
  },
272
- async ({ width, height, window }) => text((await run('resize', 'POST', '/api/resize', { window, width, height })).result),
340
+ async ({ width, height, emulate, window }) =>
341
+ text((await run('resize', 'POST', '/api/resize', { window, width, height, emulate })).result),
273
342
  );
274
343
 
275
344
  server.registerTool(
package/media/viewer.jpg CHANGED
Binary file
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@acertijosapps/electron-viewer",
3
3
  "mcpName": "io.github.acertijosapps/electron-viewer",
4
- "version": "1.0.0",
5
- "description": "Visor web de apps Electron en vivo (screencast por CDP, input de mouse y teclado, captura con el marco nativo) y MCP para que un agente las maneje.",
4
+ "version": "1.1.0",
5
+ "description": "Visor web de apps Electron en vivo (screencast por CDP, input de mouse y teclado, logs y árbol de accesibilidad) y MCP para que un agente las maneje.",
6
6
  "type": "module",
7
7
  "main": "server.js",
8
8
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "start": "node bin/electron-viewer.js",
23
23
  "mcp": "node bin/electron-viewer.js mcp",
24
24
  "example": "node bin/electron-viewer.js --project example",
25
- "check": "node --check bin/electron-viewer.js && node --check server.js && node --check lib/cdp.js && node --check lib/electron.js && node --check mcp/server.js && node --check public/viewer.js",
25
+ "check": "node --check bin/electron-viewer.js && node --check server.js && node --check lib/cdp.js && node --check lib/electron.js && node --check mcp/server.js && node --check public/viewer.js && node scripts/check-version.js",
26
26
  "prepare": "git config core.hooksPath .githooks || exit 0"
27
27
  },
28
28
  "keywords": [
package/public/index.html CHANGED
@@ -8,7 +8,7 @@
8
8
  </head>
9
9
  <body>
10
10
  <header class="bar">
11
- <select id="window" aria-label="Window"></select>
11
+ <select id="window" aria-label="Window" data-tip="App window to show"></select>
12
12
  <button id="launch-top" class="primary" hidden>Launch</button>
13
13
  <span id="status" aria-live="polite"></span>
14
14
  </header>
@@ -27,16 +27,18 @@
27
27
  </main>
28
28
  <footer class="bar controls">
29
29
  <div class="group" role="group" aria-label="Page">
30
- <button id="reload" title="Reload">
30
+ <button id="reload" aria-label="Reload" data-tip="Reload the page">
31
31
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 5v5h-5" /><path d="M18.5 15a7 7 0 1 1-1.2-7.4L20 10" /></svg>
32
32
  </button>
33
- <button id="devtools" title="Toggle DevTools">
33
+ <button id="devtools" aria-label="DevTools" data-tip="Open or close DevTools">
34
34
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 8-4 4 4 4M15 8l4 4-4 4" /></svg>
35
35
  </button>
36
36
  </div>
37
37
  <div class="group" role="group" aria-label="Window size">
38
- <select id="size" title="Window size">
38
+ <select id="size" aria-label="Window size" data-tip="Resize the window content (emulated when the window can't reach the size)">
39
39
  <option value="">Window size</option>
40
+ <option value="1920x1080">1920 × 1080</option>
41
+ <option value="1080x1920">1080 × 1920</option>
40
42
  <option value="1440x900">1440 × 900</option>
41
43
  <option value="1280x800">1280 × 800</option>
42
44
  <option value="1024x768">1024 × 768</option>
@@ -44,15 +46,18 @@
44
46
  <option value="390x844">390 × 844</option>
45
47
  </select>
46
48
  </div>
47
- <div class="group" role="group" aria-label="Capture">
48
- <button id="frame" title="Show the native window frame, menus and dialogs (needs the main process)" aria-pressed="false">
49
- <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="4.5" width="17" height="15" rx="2" /><path d="M3.5 9h17" /></svg>
49
+ <div class="group" role="group" aria-label="Input">
50
+ <button id="touch" aria-label="Touch" data-tip="Send the pointer as touch (tap, swipe, long press)" aria-pressed="false">
51
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 11V5.5a1.5 1.5 0 0 1 3 0V11m0-1.5a1.5 1.5 0 0 1 3 0V12m0-1a1.5 1.5 0 0 1 3 0v3.5a6 6 0 0 1-6 6h-.5a6 6 0 0 1-5-2.7L4.4 14a1.5 1.5 0 0 1 2.4-1.8L9 14.5" /></svg>
50
52
  </button>
51
- <button id="screenshot" title="Save screenshot (PNG)">
53
+ </div>
54
+ <div class="group" role="group" aria-label="Capture">
55
+ <button id="screenshot" aria-label="Screenshot" data-tip="Save a screenshot (PNG)">
52
56
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8h3l2-2.5h6L17 8h3v11H4z" /><circle cx="12" cy="13" r="3.5" /></svg>
53
57
  </button>
54
58
  </div>
55
59
  </footer>
60
+ <div id="tip" role="tooltip" hidden></div>
56
61
  <script type="module" src="viewer.js"></script>
57
62
  </body>
58
63
  </html>
package/public/style.css CHANGED
@@ -197,10 +197,6 @@ button:disabled {
197
197
  outline-offset: -2px;
198
198
  }
199
199
 
200
- #screen.native {
201
- cursor: default;
202
- }
203
-
204
200
  .group select {
205
201
  border-radius: 6px;
206
202
  cursor: pointer;
@@ -232,3 +228,19 @@ button:disabled {
232
228
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
233
229
  font-size: 12px;
234
230
  }
231
+
232
+ /* Tooltip propio: el panel de navegador de algunas apps no muestra el de `title`. */
233
+ #tip {
234
+ position: fixed;
235
+ z-index: 10;
236
+ max-width: min(320px, calc(100vw - 16px));
237
+ padding: 5px 8px;
238
+ border: 1px solid var(--border);
239
+ border-radius: 6px;
240
+ background: var(--text);
241
+ color: var(--bg);
242
+ font-size: 12px;
243
+ line-height: 1.35;
244
+ pointer-events: none;
245
+ white-space: normal;
246
+ }
package/public/viewer.js CHANGED
@@ -10,7 +10,7 @@ const launchButton = $('#launch');
10
10
  const launchTop = $('#launch-top');
11
11
  const attachForm = $('#attach');
12
12
  const sizeSelect = $('#size');
13
- const frameButton = $('#frame');
13
+ const touchButton = $('#touch');
14
14
  const controls = $('.controls');
15
15
 
16
16
  const TARGETS_REFRESH_MS = 3000;
@@ -96,15 +96,12 @@ function renderStatus(text) {
96
96
  }
97
97
  if (!meta || performance.now() < noteUntil) return;
98
98
  statusEl.title = '';
99
- statusEl.textContent = `${meta.width}×${meta.height}${meta.frame ? ' · frame' : ''} · ${fps} fps`;
99
+ statusEl.textContent = `${meta.width}×${meta.height}${touchMode ? ' · touch' : ''} · ${fps} fps`;
100
100
  }
101
101
 
102
102
  function onJson(msg) {
103
103
  if (msg.type === 'meta') {
104
104
  meta = msg;
105
- frameButton.setAttribute('aria-pressed', String(Boolean(msg.frame)));
106
- frameButton.disabled = !msg.app?.main;
107
- canvas.classList.toggle('native', Boolean(msg.frame));
108
105
  sizeSelect.options[0].textContent = `Window size (${msg.width} × ${msg.height})`;
109
106
  renderStatus();
110
107
  } else if (msg.type === 'error') {
@@ -130,7 +127,6 @@ function showMessage(text, { launch = false } = {}) {
130
127
 
131
128
  function setControlsEnabled(enabled) {
132
129
  controls.querySelectorAll('button, select').forEach((b) => (b.disabled = !enabled));
133
- if (enabled) frameButton.disabled = !selected()?.app.main;
134
130
  }
135
131
 
136
132
  function disconnect() {
@@ -164,7 +160,7 @@ function renderLaunch() {
164
160
  launchTop.hidden = !project?.launchable;
165
161
  launchTop.disabled = !can;
166
162
  launchTop.textContent = launching ? 'Launching…' : `Launch ${project?.name ?? 'app'}`;
167
- launchTop.title = project ? `electron . in ${project.dir}` : '';
163
+ launchTop.dataset.tip = project ? `Run ${project.command} in ${project.dir}` : '';
168
164
  launchButton.textContent = launchTop.textContent;
169
165
  }
170
166
 
@@ -274,16 +270,15 @@ $('#devtools').onclick = async () => {
274
270
  sizeSelect.onchange = async () => {
275
271
  const [width, height] = sizeSelect.value ? sizeSelect.value.split('x').map(Number) : [0, 0];
276
272
  sizeSelect.value = '';
277
- await control('resize', { width, height });
273
+ const result = await control('resize', { width, height });
274
+ if (result?.note) renderStatus(result.note);
278
275
  };
279
276
 
280
- frameButton.onclick = () => send({ type: 'frame', on: !meta?.frame });
281
-
282
277
  $('#screenshot').onclick = async () => {
283
278
  if (!windowId) return;
284
279
  renderStatus('Saving screenshot…');
285
280
  try {
286
- const res = await api('GET', `screenshot?window=${encodeURIComponent(windowId)}${meta?.frame ? '&frame=1' : ''}`);
281
+ const res = await api('GET', `screenshot?window=${encodeURIComponent(windowId)}`);
287
282
  const a = document.createElement('a');
288
283
  a.href = URL.createObjectURL(await res.blob());
289
284
  const name = (selected()?.app.name || 'electron').replace(/[^\w.-]+/g, '-');
@@ -299,8 +294,7 @@ $('#screenshot').onclick = async () => {
299
294
 
300
295
  // ---------- Input ----------
301
296
 
302
- // Posición normalizada (0–1) dentro del contenido de la ventana, descontando el letterbox de
303
- // object-fit y, en modo marco, la barra de título y los bordes.
297
+ // Posición normalizada (0–1) dentro del contenido de la ventana, descontando el letterbox de object-fit.
304
298
  function toNorm(e) {
305
299
  const r = canvas.getBoundingClientRect();
306
300
  const ar = canvas.width / canvas.height;
@@ -315,13 +309,8 @@ function toNorm(e) {
315
309
  h = w / ar;
316
310
  oy = (r.height - h) / 2;
317
311
  }
318
- let x = (e.clientX - r.left - ox) / w;
319
- let y = (e.clientY - r.top - oy) / h;
320
- const c = meta?.frame && meta.content;
321
- if (c) {
322
- x = (x - c.x) / c.width;
323
- y = (y - c.y) / c.height;
324
- }
312
+ const x = (e.clientX - r.left - ox) / w;
313
+ const y = (e.clientY - r.top - oy) / h;
325
314
  return { x, y };
326
315
  }
327
316
 
@@ -334,6 +323,32 @@ function modifiersOf(e) {
334
323
  let lastDown = null;
335
324
  let pressed = false;
336
325
 
326
+ // Modo touch: el puntero del visor se manda como un dedo (Input.dispatchTouchEvent). Se recuerda
327
+ // en este navegador.
328
+ let touchMode = false;
329
+ try {
330
+ touchMode = localStorage.getItem('electron-viewer:touch') === '1';
331
+ } catch {
332
+ // sin almacenamiento: empieza en mouse
333
+ }
334
+
335
+ function renderTouch() {
336
+ touchButton.setAttribute('aria-pressed', String(touchMode));
337
+ touchButton.dataset.tip = touchMode ? 'Touch mode on: the pointer is a finger. Click to use the mouse' : 'Send the pointer as touch (tap, swipe, long press)';
338
+ canvas.style.cursor = touchMode ? 'pointer' : '';
339
+ renderStatus();
340
+ }
341
+
342
+ touchButton.onclick = () => {
343
+ touchMode = !touchMode;
344
+ try {
345
+ localStorage.setItem('electron-viewer:touch', touchMode ? '1' : '0');
346
+ } catch {
347
+ // no se recuerda
348
+ }
349
+ renderTouch();
350
+ };
351
+
337
352
  canvas.addEventListener('pointerdown', (e) => {
338
353
  if (!meta || !canvas.width) return;
339
354
  keys.focus({ preventScroll: true });
@@ -342,6 +357,11 @@ canvas.addEventListener('pointerdown', (e) => {
342
357
  e.preventDefault();
343
358
  canvas.setPointerCapture(e.pointerId);
344
359
  pressed = true;
360
+ if (touchMode) {
361
+ if (e.button === 0) send({ type: 'touch', action: 'start', ...p });
362
+ else pressed = false;
363
+ return;
364
+ }
345
365
  const button = BUTTONS[e.button] || 'left';
346
366
  const now = performance.now();
347
367
  const near = lastDown && Math.hypot(e.clientX - lastDown.cx, e.clientY - lastDown.cy) < DOUBLE_CLICK_SLOP_PX;
@@ -354,6 +374,10 @@ canvas.addEventListener('pointerup', (e) => {
354
374
  if (!pressed) return;
355
375
  pressed = false;
356
376
  const p = toNorm(e);
377
+ if (touchMode) {
378
+ send({ type: 'touch', action: 'end', ...p });
379
+ return;
380
+ }
357
381
  send({ type: 'mouse', action: 'up', x: p.x, y: p.y, button: BUTTONS[e.button] || 'left', buttons: e.buttons, clickCount: lastDown?.count || 1, modifiers: modifiersOf(e) });
358
382
  });
359
383
 
@@ -370,11 +394,19 @@ function flushMove() {
370
394
  moveEvent = null;
371
395
  if (!e) return;
372
396
  const p = toNorm(e);
397
+ if (touchMode) {
398
+ // Un dedo solo se mueve apoyado: sin hover.
399
+ if (pressed) send({ type: 'touch', action: 'move', ...p });
400
+ return;
401
+ }
373
402
  if (!pressed && !inside(p)) return;
374
403
  send({ type: 'mouse', action: 'move', ...p, buttons: e.buttons, modifiers: modifiersOf(e) });
375
404
  }
376
405
 
377
- canvas.addEventListener('pointercancel', () => (pressed = false));
406
+ canvas.addEventListener('pointercancel', (e) => {
407
+ if (pressed && touchMode) send({ type: 'touch', action: 'cancel', ...toNorm(e) });
408
+ pressed = false;
409
+ });
378
410
  canvas.addEventListener('contextmenu', (e) => e.preventDefault());
379
411
 
380
412
  let wheel = null;
@@ -430,6 +462,46 @@ keys.addEventListener('compositionend', () => {
430
462
  keys.value = '';
431
463
  });
432
464
 
465
+ // ---------- Tooltips ----------
466
+ // Uno solo, colocado junto al control bajo el puntero o con el foco del teclado. Se usa
467
+ // pointerover en el documento porque los botones deshabilitados no reciben eventos propios.
468
+
469
+ const tip = $('#tip');
470
+ let tipFor = null;
471
+
472
+ function showTip(el) {
473
+ const text = el?.dataset.tip;
474
+ if (!text) return hideTip();
475
+ tipFor = el;
476
+ tip.textContent = text;
477
+ tip.hidden = false;
478
+ const r = el.getBoundingClientRect();
479
+ const t = tip.getBoundingClientRect();
480
+ const gap = 6;
481
+ // Debajo si cabe (barra de arriba), si no encima (controles de abajo).
482
+ const top = r.bottom + gap + t.height <= innerHeight ? r.bottom + gap : r.top - gap - t.height;
483
+ const left = Math.min(innerWidth - t.width - 8, Math.max(8, r.left + r.width / 2 - t.width / 2));
484
+ tip.style.top = `${Math.max(8, top)}px`;
485
+ tip.style.left = `${left}px`;
486
+ }
487
+
488
+ function hideTip() {
489
+ tipFor = null;
490
+ tip.hidden = true;
491
+ }
492
+
493
+ document.addEventListener('pointerover', (e) => {
494
+ const el = e.target.closest?.('[data-tip]');
495
+ if (el !== tipFor) el ? showTip(el) : hideTip();
496
+ });
497
+ document.addEventListener('pointerdown', hideTip);
498
+ document.addEventListener('focusin', (e) => (e.target.matches?.(':focus-visible') ? showTip(e.target.closest('[data-tip]')) : hideTip()));
499
+ document.addEventListener('focusout', hideTip);
500
+ addEventListener('blur', hideTip);
501
+ // Con otro tamaño de página la posición ya no vale.
502
+ addEventListener('resize', hideTip);
503
+
504
+ renderTouch();
433
505
  setControlsEnabled(false);
434
506
  await loadProject();
435
507
  refreshTargets();
package/server.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // Visor web de apps Electron.
2
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`.
3
+ // Input: `Input.dispatchMouseEvent` y `Input.dispatchKeyEvent`.
5
4
 
6
5
  import http from 'node:http';
7
6
  import path from 'node:path';
@@ -10,9 +9,9 @@ import { fileURLToPath } from 'node:url';
10
9
  import { WebSocketServer } from 'ws';
11
10
  import {
12
11
  attachApp,
13
- captureNative,
14
12
  createActions,
15
13
  dispatchKey,
14
+ dispatchTouch,
16
15
  pageClient,
17
16
  projectInfo,
18
17
  resolveWindow,
@@ -26,11 +25,9 @@ const HOST = process.env.HOST || '127.0.0.1';
26
25
  const PROJECT = process.env.ELECTRON_VIEWER_PROJECT || process.cwd();
27
26
  const MAX_DIM = Number(process.env.MAX_DIM) || 1920; // lado mayor de los frames del screencast
28
27
  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
28
  const MAX_CLIENT_BUFFER = 4 * 1024 * 1024;
31
29
  const MAX_BODY_BYTES = 256 * 1024;
32
30
  const FLAG_SCREENCAST = 0;
33
- const FLAG_NATIVE = 1;
34
31
 
35
32
  const LOOPBACK = ['localhost', '127.0.0.1', '[::1]', '::1'];
36
33
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -50,10 +47,9 @@ class WindowSession {
50
47
  this.ready = null;
51
48
  this.viewport = null;
52
49
  this.lastFrame = null;
53
- this.frameMode = false;
54
- this.content = null; // área del contenido dentro de la captura nativa
55
50
  this.queue = Promise.resolve();
56
51
  this.pendingMove = null;
52
+ this.pendingTouch = null;
57
53
  this.onScreencast = (e) => this.handleScreencast(e);
58
54
  this.onClose = () => {
59
55
  log(this.app.name, this.id.slice(0, 8), 'window closed');
@@ -98,7 +94,6 @@ class WindowSession {
98
94
  dispose() {
99
95
  if (this.disposed) return;
100
96
  this.disposed = true;
101
- this.frameMode = false;
102
97
  sessions.delete(this.key);
103
98
  const page = this.page;
104
99
  if (!page) return;
@@ -116,14 +111,11 @@ class WindowSession {
116
111
  app: { id: this.app.id, name: this.app.name, main: this.app.main },
117
112
  width: this.viewport?.width,
118
113
  height: this.viewport?.height,
119
- frame: this.frameMode,
120
- content: this.frameMode ? this.content : null,
121
114
  };
122
115
  }
123
116
 
124
117
  handleScreencast({ data, metadata, sessionId }) {
125
118
  this.page.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
126
- if (this.frameMode) return;
127
119
  const { deviceWidth: width, deviceHeight: height } = metadata;
128
120
  if (width && height && (width !== this.viewport?.width || height !== this.viewport?.height)) {
129
121
  this.viewport = { ...this.viewport, width, height };
@@ -141,41 +133,6 @@ class WindowSession {
141
133
  }
142
134
  }
143
135
 
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
136
  send(ws, obj) {
180
137
  if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
181
138
  }
@@ -208,6 +165,7 @@ class WindowSession {
208
165
  this.enqueue(() => {
209
166
  const m = this.pendingMove;
210
167
  this.pendingMove = null;
168
+ this.pendingTouch = null;
211
169
  const pressed = Object.keys(BUTTON_BITS).find((b) => m.buttons & BUTTON_BITS[b]) || 'none';
212
170
  return this.page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: m.x, y: m.y, modifiers: m.modifiers, buttons: m.buttons, button: pressed });
213
171
  });
@@ -226,6 +184,26 @@ class WindowSession {
226
184
  }
227
185
  break;
228
186
  }
187
+ case 'touch': {
188
+ // Un dedo, como el puntero del visor. Los movimientos se fusionan igual que los del mouse.
189
+ const p = this.point(msg);
190
+ const type = { start: 'touchStart', move: 'touchMove', end: 'touchEnd', cancel: 'touchCancel' }[msg.action];
191
+ if (!type) return;
192
+ if (type === 'touchMove') {
193
+ const queued = this.pendingTouch;
194
+ this.pendingTouch = p;
195
+ if (queued) return;
196
+ this.enqueue(() => {
197
+ const next = this.pendingTouch;
198
+ this.pendingTouch = null;
199
+ return dispatchTouch(this.page, 'touchMove', [next]);
200
+ });
201
+ } else {
202
+ this.pendingTouch = null;
203
+ this.enqueue(() => dispatchTouch(this.page, type, [p]));
204
+ }
205
+ break;
206
+ }
229
207
  case 'wheel': {
230
208
  const { x, y } = this.point(msg);
231
209
  this.enqueue(() =>
@@ -251,14 +229,6 @@ class WindowSession {
251
229
  case 'text':
252
230
  if (typeof msg.text === 'string' && msg.text) this.enqueue(() => this.page.send('Input.insertText', { text: msg.text.slice(0, 10000) }));
253
231
  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
232
  }
263
233
  }
264
234
  }
@@ -279,6 +249,10 @@ const API = {
279
249
  'POST /api/click': actions.click,
280
250
  'POST /api/drag': actions.drag,
281
251
  'POST /api/hover': actions.hover,
252
+ 'POST /api/tap': actions.tap,
253
+ 'POST /api/swipe': actions.swipe,
254
+ 'POST /api/long-press': actions.longPress,
255
+ 'POST /api/pinch': actions.pinch,
282
256
  'POST /api/scroll': actions.scroll,
283
257
  'POST /api/text': actions.text,
284
258
  'POST /api/key': actions.key,
@@ -343,7 +317,6 @@ async function handleApi(req, res, url) {
343
317
  'x-window': shot.window,
344
318
  };
345
319
  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
320
  res.writeHead(200, headers);
348
321
  res.end(shot.data);
349
322
  return;