@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 +58 -37
- package/bin/electron-viewer.js +12 -2
- package/lib/electron.js +173 -87
- package/mcp/server.js +88 -19
- package/media/viewer.jpg +0 -0
- package/package.json +3 -3
- package/public/index.html +13 -8
- package/public/style.css +16 -4
- package/public/viewer.js +93 -21
- package/server.js +28 -55
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,
|
|
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
|
|
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,
|
|
42
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
-
|
|
208
|
-
|
|
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
|
|
package/bin/electron-viewer.js
CHANGED
|
@@ -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,
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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
|
-
|
|
579
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
857
|
+
reached = await viewport(page);
|
|
858
|
+
if (reached.width === W && reached.height === H) return { ...reached, via: 'window' };
|
|
774
859
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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,
|
|
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 :
|
|
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
|
|
104
|
-
'
|
|
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.
|
|
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
|
|
143
|
-
const { result: shot } = await run('screenshot', 'GET', '/api/screenshot', { window, max: SCREENSHOT_MAX
|
|
144
|
-
const
|
|
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
|
-
'
|
|
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 }) =>
|
|
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.
|
|
5
|
-
"description": "Visor web de apps Electron en vivo (screencast por CDP, input de mouse y teclado,
|
|
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"
|
|
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"
|
|
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"
|
|
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="
|
|
48
|
-
<button id="
|
|
49
|
-
<svg viewBox="0 0 24 24" aria-hidden="true"><
|
|
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
|
-
|
|
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
|
|
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}${
|
|
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.
|
|
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)}
|
|
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
|
-
|
|
319
|
-
|
|
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', () =>
|
|
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
|
-
//
|
|
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;
|