@acertijosapps/electron-viewer 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/server.js ADDED
@@ -0,0 +1,349 @@
1
+ // MCP del visor: herramientas para que Claude maneje apps Electron.
2
+ // Habla con la API HTTP del visor (server.js) para que cada acción se vea en vivo en el panel;
3
+ // si el visor no está corriendo, usa CDP directo con la misma lógica (lib/electron.js).
4
+ // stdout es del protocolo MCP: los logs van a stderr.
5
+
6
+ import { readFileSync } from 'node:fs';
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import { z } from 'zod';
10
+ import { KEY_NAMES, createActions } from '../lib/electron.js';
11
+
12
+ const VIEWER_URL = (process.env.ELECTRON_VIEWER_URL || 'http://localhost:8094').replace(/\/$/, '');
13
+ const SCREENSHOT_MAX = Number(process.env.SCREENSHOT_MAX) || 1024;
14
+ const VIEWER_TIMEOUT_MS = 60000;
15
+
16
+ const direct = createActions();
17
+
18
+ // Error de red (visor apagado) → modo directo. Un error HTTP del visor sí se propaga.
19
+ class ViewerDown extends Error {}
20
+
21
+ async function viewer(method, route, params = {}) {
22
+ const url = new URL(VIEWER_URL + route);
23
+ const init = { method, signal: AbortSignal.timeout(VIEWER_TIMEOUT_MS) };
24
+ if (method === 'GET') {
25
+ for (const [k, v] of Object.entries(params)) if (v !== undefined) url.searchParams.set(k, v);
26
+ } else {
27
+ init.headers = { 'content-type': 'application/json' };
28
+ init.body = JSON.stringify(params);
29
+ }
30
+ let res;
31
+ try {
32
+ res = await fetch(url, init);
33
+ } catch (err) {
34
+ if (err.name === 'TimeoutError') throw new Error(`The viewer did not answer within ${VIEWER_TIMEOUT_MS / 1000} s`);
35
+ throw new ViewerDown(err.message);
36
+ }
37
+ if (!res.ok) {
38
+ const body = await res.json().catch(() => ({}));
39
+ throw new Error(body.error || `HTTP ${res.status}`);
40
+ }
41
+ const type = res.headers.get('content-type') || '';
42
+ if (!type.startsWith('image/')) return res.json();
43
+ const content = res.headers.get('x-content');
44
+ const vw = res.headers.get('x-viewport-width');
45
+ return {
46
+ data: Buffer.from(await res.arrayBuffer()),
47
+ mimeType: type,
48
+ width: Number(res.headers.get('x-image-width')),
49
+ height: Number(res.headers.get('x-image-height')),
50
+ window: res.headers.get('x-window'),
51
+ viewport: vw ? { width: Number(vw), height: Number(res.headers.get('x-viewport-height')) } : undefined,
52
+ content: content ? JSON.parse(content) : undefined,
53
+ };
54
+ }
55
+
56
+ // Ejecuta una acción por el visor o, si no está corriendo, por CDP directo.
57
+ async function run(action, method, route, params = {}) {
58
+ try {
59
+ return { result: await viewer(method, route, params), via: 'viewer' };
60
+ } catch (err) {
61
+ if (!(err instanceof ViewerDown)) throw err;
62
+ return { result: await direct[action](params), via: 'cdp' };
63
+ }
64
+ }
65
+
66
+ const text = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] });
67
+
68
+ const windowArg = z
69
+ .string()
70
+ .optional()
71
+ .describe('Window: target id from list_windows or part of its title. Optional when only one window is open.');
72
+ const appArg = z.string().optional().describe('App: id (its CDP port) or name from list_windows. Optional with a single app.');
73
+ const coord = (what) => z.number().min(0).max(1).describe(`${what}, normalized 0–1 over the screenshot`);
74
+ const modifiersArg = z.array(z.enum(['alt', 'ctrl', 'meta', 'shift'])).optional().describe('Modifier keys held during the action (meta = ⌘ on macOS)');
75
+
76
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
77
+ const server = new McpServer({ name: 'electron-viewer', version });
78
+
79
+ server.registerTool(
80
+ 'list_windows',
81
+ {
82
+ title: 'List windows',
83
+ description:
84
+ '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). ' +
86
+ 'Includes the viewer URL to watch a window live in a browser pane.',
87
+ annotations: { readOnlyHint: true },
88
+ },
89
+ async () => {
90
+ const { result: apps, via } = await run('windows', 'GET', '/api/windows');
91
+ return text({
92
+ apps,
93
+ viewer: via === 'viewer' ? VIEWER_URL : 'not running (start it with `npx @acertijosapps/electron-viewer`)',
94
+ });
95
+ },
96
+ );
97
+
98
+ server.registerTool(
99
+ 'launch_app',
100
+ {
101
+ title: 'Launch app',
102
+ 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.',
105
+ inputSchema: {
106
+ project: z.string().optional().describe('Project folder (default: the folder the viewer or MCP was started in)'),
107
+ },
108
+ },
109
+ async ({ project }) => text((await run('launch', 'POST', '/api/launch', { project })).result),
110
+ );
111
+
112
+ server.registerTool(
113
+ 'attach',
114
+ {
115
+ title: 'Attach to app',
116
+ description:
117
+ 'Attaches to an Electron app already running with --remote-debugging-port=<port> (and optionally ' +
118
+ '--inspect=<inspect_port> for the main process). Apps on ports 9222–9231 are found without attaching.',
119
+ inputSchema: {
120
+ port: z.number().int().min(1).max(65535).describe('CDP port (--remote-debugging-port)'),
121
+ inspect_port: z.number().int().min(1).max(65535).optional().describe('Main process inspector port (--inspect)'),
122
+ },
123
+ },
124
+ async ({ port, inspect_port }) => text((await run('attach', 'POST', '/api/attach', { port, inspectPort: inspect_port })).result),
125
+ );
126
+
127
+ server.registerTool(
128
+ 'screenshot',
129
+ {
130
+ title: 'Screenshot',
131
+ description:
132
+ '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.',
136
+ inputSchema: {
137
+ window: windowArg,
138
+ frame: z.boolean().optional().describe('Include the native window frame, menus and dialogs'),
139
+ },
140
+ annotations: { readOnlyHint: true },
141
+ },
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}).`;
149
+ return {
150
+ content: [
151
+ { type: 'image', data: shot.data.toString('base64'), mimeType: shot.mimeType },
152
+ { type: 'text', text: `${note} Window ${shot.window}.` },
153
+ ],
154
+ };
155
+ },
156
+ );
157
+
158
+ server.registerTool(
159
+ 'click',
160
+ {
161
+ title: 'Click',
162
+ description: 'Clicks a point of the window content (Input.dispatchMouseEvent). count=2 is a double click.',
163
+ inputSchema: {
164
+ x: coord('x coordinate'),
165
+ y: coord('y coordinate'),
166
+ button: z.enum(['left', 'right', 'middle']).default('left').describe('Mouse button'),
167
+ count: z.number().int().min(1).max(3).default(1).describe('Click count (2 = double click)'),
168
+ modifiers: modifiersArg,
169
+ window: windowArg,
170
+ },
171
+ },
172
+ async ({ x, y, button, count, modifiers, window }) => {
173
+ await run('click', 'POST', '/api/click', { window, x, y, button, count, modifiers });
174
+ return text(`${count === 2 ? 'double-' : ''}clicked ${button} at (${x.toFixed(3)}, ${y.toFixed(3)})`);
175
+ },
176
+ );
177
+
178
+ server.registerTool(
179
+ 'drag',
180
+ {
181
+ title: 'Drag',
182
+ description: 'Presses the left button at one point, moves to another with intermediate steps and releases (a real drag).',
183
+ inputSchema: {
184
+ x1: coord('Start x'),
185
+ y1: coord('Start y'),
186
+ x2: coord('End x'),
187
+ y2: coord('End y'),
188
+ duration_ms: z.number().int().min(0).max(10000).default(300).describe('Drag duration in ms'),
189
+ modifiers: modifiersArg,
190
+ window: windowArg,
191
+ },
192
+ },
193
+ async ({ x1, y1, x2, y2, duration_ms, modifiers, window }) => {
194
+ await run('drag', 'POST', '/api/drag', { window, x1, y1, x2, y2, ms: duration_ms, modifiers });
195
+ return text(`dragged (${x1.toFixed(3)}, ${y1.toFixed(3)}) → (${x2.toFixed(3)}, ${y2.toFixed(3)}) in ${duration_ms} ms`);
196
+ },
197
+ );
198
+
199
+ server.registerTool(
200
+ 'hover',
201
+ {
202
+ title: 'Hover',
203
+ description: 'Moves the mouse to a point without clicking (hover effects, tooltips).',
204
+ inputSchema: { x: coord('x coordinate'), y: coord('y coordinate'), window: windowArg },
205
+ },
206
+ async ({ x, y, window }) => {
207
+ await run('hover', 'POST', '/api/hover', { window, x, y });
208
+ return text(`hovering at (${x.toFixed(3)}, ${y.toFixed(3)})`);
209
+ },
210
+ );
211
+
212
+ server.registerTool(
213
+ 'scroll',
214
+ {
215
+ title: 'Scroll',
216
+ description: 'Scrolls with the mouse wheel at a point. Positive delta_y scrolls down; deltas are in CSS pixels.',
217
+ inputSchema: {
218
+ x: coord('x coordinate').default(0.5),
219
+ y: coord('y coordinate').default(0.5),
220
+ delta_x: z.number().default(0).describe('Horizontal delta in CSS px'),
221
+ delta_y: z.number().default(400).describe('Vertical delta in CSS px'),
222
+ window: windowArg,
223
+ },
224
+ },
225
+ async ({ x, y, delta_x, delta_y, window }) => {
226
+ await run('scroll', 'POST', '/api/scroll', { window, x, y, dx: delta_x, dy: delta_y });
227
+ return text(`scrolled (${delta_x}, ${delta_y}) at (${x.toFixed(3)}, ${y.toFixed(3)})`);
228
+ },
229
+ );
230
+
231
+ server.registerTool(
232
+ 'type_text',
233
+ {
234
+ title: 'Type text',
235
+ description: 'Types text into the focused element (Input.insertText; any Unicode). Click the field first; for Enter or shortcuts use press_key.',
236
+ inputSchema: { text: z.string().min(1).max(10000).describe('Text to type'), window: windowArg },
237
+ },
238
+ async ({ text: value, window }) => {
239
+ await run('text', 'POST', '/api/text', { window, text: value });
240
+ return text(`typed ${JSON.stringify(value)}`);
241
+ },
242
+ );
243
+
244
+ server.registerTool(
245
+ 'press_key',
246
+ {
247
+ title: 'Press key',
248
+ description:
249
+ `Presses a key, optionally with modifiers (e.g. key "a" + ["meta"] selects all on macOS). Named keys: ${KEY_NAMES.join(', ')}; ` +
250
+ 'or any single character.',
251
+ inputSchema: { key: z.string().min(1).describe('Key name or single character'), modifiers: modifiersArg, window: windowArg },
252
+ },
253
+ async ({ key, modifiers, window }) => {
254
+ await run('key', 'POST', '/api/key', { window, key, modifiers });
255
+ return text(`pressed ${[...(modifiers || []), key].join('+')}`);
256
+ },
257
+ );
258
+
259
+ server.registerTool(
260
+ 'resize_window',
261
+ {
262
+ title: 'Resize window',
263
+ description:
264
+ '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.',
266
+ inputSchema: {
267
+ width: z.number().int().min(0).max(8000).describe('Content width in CSS px'),
268
+ height: z.number().int().min(0).max(8000).describe('Content height in CSS px'),
269
+ window: windowArg,
270
+ },
271
+ },
272
+ async ({ width, height, window }) => text((await run('resize', 'POST', '/api/resize', { window, width, height })).result),
273
+ );
274
+
275
+ server.registerTool(
276
+ 'reload',
277
+ {
278
+ title: 'Reload',
279
+ description: 'Reloads the page of the window (renderer only; the main process keeps running).',
280
+ inputSchema: { ignore_cache: z.boolean().optional().describe('Bypass the cache'), window: windowArg },
281
+ },
282
+ async ({ ignore_cache, window }) => {
283
+ await run('reload', 'POST', '/api/reload', { window, ignoreCache: ignore_cache });
284
+ return text('reloaded');
285
+ },
286
+ );
287
+
288
+ server.registerTool(
289
+ 'evaluate',
290
+ {
291
+ title: 'Evaluate in renderer',
292
+ description:
293
+ 'Evaluates a JavaScript expression in the window page (main world) and returns its JSON value. Promises are awaited.',
294
+ inputSchema: { expression: z.string().min(1).describe('JavaScript expression'), window: windowArg },
295
+ },
296
+ async ({ expression, window }) => text((await run('evaluate', 'POST', '/api/evaluate', { window, expression })).result),
297
+ );
298
+
299
+ server.registerTool(
300
+ 'evaluate_main',
301
+ {
302
+ title: 'Evaluate in main process',
303
+ description:
304
+ "Evaluates a JavaScript expression in the app's main process (Node inspector) and returns its JSON value. " +
305
+ "`require` is available, e.g. require('electron').BrowserWindow.getAllWindows().map(w => w.getTitle()). " +
306
+ 'Promises are awaited. Needs main=true in list_windows.',
307
+ inputSchema: { expression: z.string().min(1).describe('JavaScript expression'), app: appArg },
308
+ },
309
+ async ({ expression, app }) => text((await run('evaluateMain', 'POST', '/api/evaluate-main', { app, expression })).result),
310
+ );
311
+
312
+ server.registerTool(
313
+ 'get_accessibility_tree',
314
+ {
315
+ title: 'Accessibility tree',
316
+ description:
317
+ 'Returns the accessibility tree of the window as indented text (role "name" value [state]). Interactive ' +
318
+ 'elements carry @(x, y): their center, normalized like click coordinates.',
319
+ inputSchema: {
320
+ interactive_only: z.boolean().default(false).describe('Only interactive elements, as a flat list'),
321
+ window: windowArg,
322
+ },
323
+ annotations: { readOnlyHint: true },
324
+ },
325
+ async ({ interactive_only, window }) =>
326
+ text((await run('axTree', 'GET', '/api/ax-tree', { window, interactiveOnly: interactive_only })).result.tree),
327
+ );
328
+
329
+ server.registerTool(
330
+ 'get_console_logs',
331
+ {
332
+ title: 'Console logs',
333
+ description:
334
+ 'Console messages and uncaught exceptions of the app: renderer windows and main process (up to the last 1000). ' +
335
+ 'source "stdout" returns the raw output of an app started with launch_app.',
336
+ inputSchema: {
337
+ level: z.string().optional().describe('Comma-separated levels to keep: log, info, warn, error, debug'),
338
+ source: z.enum(['all', 'renderer', 'main', 'stdout']).default('all').describe('Where the messages come from'),
339
+ limit: z.number().int().min(1).max(1000).default(100).describe('Most recent entries to return'),
340
+ window: windowArg.describe('Only this window (plus the main process)'),
341
+ app: appArg,
342
+ },
343
+ annotations: { readOnlyHint: true },
344
+ },
345
+ async ({ level, source, limit, window, app }) => text((await run('logs', 'GET', '/api/logs', { level, source, limit, window, app })).result),
346
+ );
347
+
348
+ await server.connect(new StdioServerTransport());
349
+ console.error(`electron-viewer MCP ready (viewer: ${VIEWER_URL})`);
Binary file
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@acertijosapps/electron-viewer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
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.",
6
+ "type": "module",
7
+ "main": "server.js",
8
+ "bin": {
9
+ "electron-viewer": "bin/electron-viewer.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "server.js",
14
+ "lib",
15
+ "mcp",
16
+ "public",
17
+ "media",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "start": "node bin/electron-viewer.js",
23
+ "mcp": "node bin/electron-viewer.js mcp",
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",
26
+ "prepare": "git config core.hooksPath .githooks || exit 0"
27
+ },
28
+ "keywords": [
29
+ "electron",
30
+ "cdp",
31
+ "chrome-devtools-protocol",
32
+ "screencast",
33
+ "viewer",
34
+ "automation",
35
+ "mcp",
36
+ "model-context-protocol",
37
+ "claude-code"
38
+ ],
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/acertijosapps/electron-viewer.git"
42
+ },
43
+ "author": "AcertijosApps <acertijos.contacto@gmail.com> (https://acertijosapps.com/librerias/)",
44
+ "license": "MIT",
45
+ "bugs": {
46
+ "url": "https://github.com/acertijosapps/electron-viewer/issues"
47
+ },
48
+ "homepage": "https://acertijosapps.com/librerias/",
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "publishConfig": {
53
+ "registry": "https://registry.npmjs.org/",
54
+ "access": "public"
55
+ },
56
+ "dependencies": {
57
+ "@modelcontextprotocol/sdk": "1.32.1",
58
+ "ws": "^8.18.0",
59
+ "zod": "4.6.7"
60
+ }
61
+ }
@@ -0,0 +1,58 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Electron Viewer</title>
7
+ <link rel="stylesheet" href="style.css" />
8
+ </head>
9
+ <body>
10
+ <header class="bar">
11
+ <select id="window" aria-label="Window"></select>
12
+ <button id="launch-top" class="primary" hidden>Launch</button>
13
+ <span id="status" aria-live="polite"></span>
14
+ </header>
15
+ <main id="stage">
16
+ <canvas id="screen" width="0" height="0" aria-label="App window"></canvas>
17
+ <textarea id="keys" aria-label="Keyboard input for the app" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
18
+ <div id="placeholder">
19
+ <p id="placeholder-text">Connecting…</p>
20
+ <button id="launch" class="primary" hidden>Launch app</button>
21
+ <form id="attach" hidden>
22
+ <label for="attach-port">or attach to a debugging port</label>
23
+ <input id="attach-port" type="number" min="1" max="65535" value="9222" aria-label="CDP port" />
24
+ <button type="submit">Attach</button>
25
+ </form>
26
+ </div>
27
+ </main>
28
+ <footer class="bar controls">
29
+ <div class="group" role="group" aria-label="Page">
30
+ <button id="reload" title="Reload">
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
+ </button>
33
+ <button id="devtools" title="Toggle DevTools">
34
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 8-4 4 4 4M15 8l4 4-4 4" /></svg>
35
+ </button>
36
+ </div>
37
+ <div class="group" role="group" aria-label="Window size">
38
+ <select id="size" title="Window size">
39
+ <option value="">Window size</option>
40
+ <option value="1440x900">1440 × 900</option>
41
+ <option value="1280x800">1280 × 800</option>
42
+ <option value="1024x768">1024 × 768</option>
43
+ <option value="800x600">800 × 600</option>
44
+ <option value="390x844">390 × 844</option>
45
+ </select>
46
+ </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>
50
+ </button>
51
+ <button id="screenshot" title="Save screenshot (PNG)">
52
+ <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
+ </button>
54
+ </div>
55
+ </footer>
56
+ <script type="module" src="viewer.js"></script>
57
+ </body>
58
+ </html>