chrome-bridge-mcp 1.26.0 → 1.28.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 +18 -6
- package/extension/lib/emulation.js +61 -0
- package/extension/lib/motion.js +356 -0
- package/extension/lib/trusted-input.js +67 -8
- package/extension/manifest.json +2 -2
- package/extension/popup.css +1 -0
- package/extension/popup.js +1 -0
- package/extension/service-worker.js +326 -16
- package/package.json +10 -2
- package/server/cli.js +3 -4
- package/server/heap.js +111 -0
- package/server/index.js +3 -2
- package/server/launcher.js +66 -4
- package/server/lighthouse.js +72 -0
- package/server/protocol.js +8 -1
- package/server/tools.js +481 -19
- package/server/trace-analysis.js +208 -0
- package/server/upload.js +37 -0
- package/server/video.js +58 -0
- package/server/ws-manager.js +41 -2
- package/tools/cws-upload.mjs +10 -3
package/README.md
CHANGED
|
@@ -164,22 +164,23 @@ user-script toggle isn't available.
|
|
|
164
164
|
|
|
165
165
|
## Tools
|
|
166
166
|
|
|
167
|
-
|
|
168
|
-
default; the other
|
|
167
|
+
66 in total. `core` (43 tools, every tool used in 101 real sessions) loads by
|
|
168
|
+
default; the other 23 sit in seven optional caps that the agent switches on
|
|
169
169
|
mid-session with `get_status({enable: ["visual"]})`, with no restart
|
|
170
170
|
(`--caps` still sets them at startup).
|
|
171
171
|
|
|
172
|
-

|
|
173
173
|
|
|
174
174
|
| Group | N | What's in it |
|
|
175
175
|
|---|---|---|
|
|
176
176
|
| Core & Navigation | 13 | tabs, windows, `navigate`, `screenshot`, `tile_windows` |
|
|
177
177
|
| Interaction | 11 | `click`, `fill_form`, `upload_file`, dialogs, clipboard |
|
|
178
|
-
| DOM & Inspection |
|
|
178
|
+
| DOM & Inspection | 12 | `read_page`, `extract`, `extract_table`, `query_dom`, `get_css_styles`, `watch_dom` |
|
|
179
179
|
| Debugging & Network | 8 | `execute_js`, console, network log, mocking, `track_events` |
|
|
180
180
|
| Visual & Responsive | 5 | `screenshot_diff`, viewport and zoom, media emulation |
|
|
181
181
|
| Audits | 2 | `audit` (a11y, keyboard, SEO, security, links, vitals, css, resources, cache in one call), `cookie_audit` |
|
|
182
182
|
| State, Storage & Files | 9 | storage, fixtures, MHTML, recording, `assert` |
|
|
183
|
+
| Motion & Performance | 6 | `animations`, `frames` (long frames, CLS, INP), `perf_trace`, `screencast`, `lighthouse` and `heap_snapshot` (launch mode) |
|
|
183
184
|
|
|
184
185
|
Every tool, with the notes that matter: [docs/TOOLS.md](docs/TOOLS.md).
|
|
185
186
|
|
|
@@ -206,6 +207,7 @@ Environment variables, each with a matching CLI flag:
|
|
|
206
207
|
| `CHROME_BRIDGE_HOST` / `--host` | `127.0.0.1` | `0.0.0.0` **only** where the browser lives outside the container (ChromeOS/Crostini port-forward) — and only with a token |
|
|
207
208
|
| `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
|
|
208
209
|
| `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. Optional caps can also be switched on at runtime with `get_status({enable})` |
|
|
210
|
+
| `CHROME_BRIDGE_ALWAYS_LOAD` / `--always-load` | 22 most used tools | Tools kept in context when the client loads the server whole; the others carry `_meta['anthropic/alwaysLoad'] = false` and wait behind tool search (Claude Code 2.1.285+). `all` loads every tool; a comma list picks them |
|
|
209
211
|
| `CHROME_BRIDGE_NO_JS` / `--no-js` | unset | No arbitrary JavaScript in the page: `execute_js` and `modify_dom` leave the schema, `wait_for(condition=function)` and `javascript:`/`data:` URLs are refused. `get_status` reports `js_evaluation` |
|
|
210
212
|
| `CHROME_BRIDGE_READ_ROOT` / `--read-root` | unset | Where `upload_file` may read from. Unset: any file except keys and credentials (`~/.ssh`, `~/.aws`, `~/.gnupg`, `.env*`, `id_*`, `*.pem`, `*.key`, …), symlinks resolved first. Set: only files under this directory, whatever their name — the way to upload a certificate key on purpose |
|
|
211
213
|
| `CHROME_BRIDGE_BROWSER` | unset | Launch mode only: the browser binary, e.g. `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`. Unset: the first of Chromium, Edge, Brave, Google Chrome found in the standard paths (on Linux Chromium, then Google Chrome). Google Chrome 137+ cannot load the extension |
|
|
@@ -236,6 +238,14 @@ do with them.
|
|
|
236
238
|
|
|
237
239
|
- **Who can connect:** see above — loopback bind, `chrome-extension://` origin
|
|
238
240
|
check, `CHROME_BRIDGE_TOKEN` on both handshakes.
|
|
241
|
+
- **Launch mode also opens a DevTools port** on 127.0.0.1 (chosen by the
|
|
242
|
+
system), for `lighthouse` and `heap_snapshot`. CDP has no authentication:
|
|
243
|
+
any local process can drive that dedicated browser while it runs. Its profile
|
|
244
|
+
is temporary and holds nothing of yours; your own Chrome never gets a port.
|
|
245
|
+
- **The debugger bar is the signal.** `click`/`press_key` with `trusted`,
|
|
246
|
+
`emulate_media via: "debugger"`, `perf_trace` and `screencast` attach
|
|
247
|
+
`chrome.debugger` to one tab only for that work; Chrome shows its "started
|
|
248
|
+
debugging this browser" bar meanwhile, and cancelling it ends the work.
|
|
239
249
|
- **The real risk is prompt injection.** A page can ask the agent to run
|
|
240
250
|
JavaScript, send a request with your cookies, submit a form or upload a file.
|
|
241
251
|
Treat every page you automate as untrusted input, and review what the agent
|
|
@@ -295,16 +305,18 @@ count.
|
|
|
295
305
|
| `read_console` returns `note=Instrumentation not loaded` | The page was opened before the extension, "Capture console & metrics" is off, or the page isn't injectable (`chrome://`). Reload it. |
|
|
296
306
|
| Screenshot times out, or `image readback failed` | A minimized or fully covered window stops producing frames; captures fail after 10 s. Bring the window forward. |
|
|
297
307
|
| `wait_for`/`scroll` return `page_hidden: true`, pages stop updating | Same cause: Chrome does not render a hidden page and slows its timers (after a few minutes, to one wake-up per minute). `get_page_info` reports `visibility`. Bring the window on screen, or `create_tab` with `new_window` and bounds. |
|
|
298
|
-
| `screenshot presets` says `NOT APPLIED` | Presets resize the window; they do not emulate a phone (no device pixel ratio, UA or touch). The window manager enforces a minimum width, and no window exceeds the screen (e.g. at most a 1536×686 viewport on a 1536×864 ChromeOS screen). For phone emulation
|
|
308
|
+
| `screenshot presets` says `NOT APPLIED` | Presets resize the window; they do not emulate a phone (no device pixel ratio, UA or touch). The window manager enforces a minimum width, and no window exceeds the screen (e.g. at most a 1536×686 viewport on a 1536×864 ChromeOS screen). For phone emulation use `emulate_media` with `device` (via debugger), or a headless browser for larger viewports. |
|
|
299
309
|
| `type_text` returns `mismatch: true` | The field rejected the value. Events from an extension have `isTrusted: false`, and some widgets (date pickers, search boxes with tokenizers) discard them. Try `mode: 'keys'`, then the site's own controls (the calendar buttons), or `handoff`. |
|
|
300
310
|
| `[media removed: request limit]` instead of a screenshot | Written by the MCP client, not by the bridge: too many images in one request. Use `save_to`, or `element_screenshot` with `region` and a small `scale`. |
|
|
301
311
|
| Commands work, then stop | The MV3 service worker restarted and in-memory state (network log, diff baselines, HTTP auth) was reset. Re-run the monitoring call. |
|
|
302
312
|
| Extension dropped on every ChromeOS reboot | Install from the Web Store instead of Load unpacked. |
|
|
303
313
|
| Tool missing from the list | It's in an opt-in group. Check `get_status` → `caps_available`, then set `CHROME_BRIDGE_CAPS=all`. |
|
|
314
|
+
| `claude mcp get` shows no command, arguments or environment | Expected with the plugin: since Claude Code 2.1.285 it hides them for stdio servers that plugins provide (variable names stay). `get_status` reports the server and extension versions, mode, host, port and caps; `/mcp` shows the connection. |
|
|
315
|
+
| Popup says «In attesa: un altro browser è collegato», `get_status` lists `refused_browsers` | Two browsers with the extension reach the same port. The one already connected keeps the bridge; the other retries every 15 s and takes over when the first disconnects. `get_status` → `browser` names the connected one. Before this, the last one to connect took over silently. |
|
|
304
316
|
|
|
305
317
|
## Documentation
|
|
306
318
|
|
|
307
|
-
- [docs/TOOLS.md](docs/TOOLS.md) — all
|
|
319
|
+
- [docs/TOOLS.md](docs/TOOLS.md) — all 66 tools, by group
|
|
308
320
|
- [docs/CAPABILITIES.md](docs/CAPABILITIES.md) — what the bridge gets past and what it does not, one dated state per wall
|
|
309
321
|
- [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
|
|
310
322
|
- [docs/PERFORMANCE.md](docs/PERFORMANCE.md) — latency per tool on the real path, `npm run bench:latency`
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* emulate_media via chrome.debugger: dalle opzioni del tool ai comandi CDP.
|
|
3
|
+
*
|
|
4
|
+
* Con il debugger l'emulazione è quella di DevTools: le regole
|
|
5
|
+
* @media (prefers-reduced-motion) dei fogli del sito valgono davvero, mentre
|
|
6
|
+
* il ripiego nella pagina cambia solo matchMedia e azzera tutte le durate.
|
|
7
|
+
* CPU, rete, viewport con DPR e touch esistono solo qui.
|
|
8
|
+
*
|
|
9
|
+
* Pura: restituisce coppie [metodo, parametri], così i test la provano senza
|
|
10
|
+
* browser. Il service worker le manda in ordine sulla scheda tenuta.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Profili di rete di DevTools (Chrome 154): latenza in ms, banda in byte/s,
|
|
14
|
+
// già scalate come le applica DevTools.
|
|
15
|
+
export const NETWORK_PROFILES = {
|
|
16
|
+
offline: { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0 },
|
|
17
|
+
'3g': { offline: false, latency: 2000, downloadThroughput: 45000, uploadThroughput: 45000 },
|
|
18
|
+
'slow-4g': { offline: false, latency: 562.5, downloadThroughput: 180000, uploadThroughput: 84375 },
|
|
19
|
+
'fast-4g': { offline: false, latency: 165, downloadThroughput: 1012500, uploadThroughput: 168750 },
|
|
20
|
+
none: { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} o - colorScheme, reducedMotion, contrast, printMode,
|
|
25
|
+
* cpu_throttle, network, device {width,height,dpr,mobile}, touch
|
|
26
|
+
* @returns {{ commands: Array<[string, object]>, emulated: object }}
|
|
27
|
+
*/
|
|
28
|
+
export function emulationCommands(o = {}) {
|
|
29
|
+
const commands = [];
|
|
30
|
+
const emulated = {};
|
|
31
|
+
const features = [];
|
|
32
|
+
if (o.colorScheme) { features.push({ name: 'prefers-color-scheme', value: o.colorScheme }); emulated.colorScheme = o.colorScheme; }
|
|
33
|
+
if (o.reducedMotion) { features.push({ name: 'prefers-reduced-motion', value: o.reducedMotion }); emulated.reducedMotion = o.reducedMotion; }
|
|
34
|
+
if (o.contrast) { features.push({ name: 'prefers-contrast', value: o.contrast }); emulated.contrast = o.contrast; }
|
|
35
|
+
if (features.length || o.printMode) {
|
|
36
|
+
commands.push(['Emulation.setEmulatedMedia', { media: o.printMode ? 'print' : '', features }]);
|
|
37
|
+
if (o.printMode) emulated.printMode = true;
|
|
38
|
+
}
|
|
39
|
+
if (o.cpu_throttle != null) {
|
|
40
|
+
const rate = Math.max(1, Number(o.cpu_throttle));
|
|
41
|
+
commands.push(['Emulation.setCPUThrottlingRate', { rate }]);
|
|
42
|
+
emulated.cpu_throttle = rate;
|
|
43
|
+
}
|
|
44
|
+
if (o.network) {
|
|
45
|
+
const p = NETWORK_PROFILES[o.network];
|
|
46
|
+
if (!p) throw new Error(`Unknown network profile ${o.network}: use ${Object.keys(NETWORK_PROFILES).join(', ')}`);
|
|
47
|
+
commands.push(['Network.emulateNetworkConditions', p]);
|
|
48
|
+
emulated.network = o.network;
|
|
49
|
+
}
|
|
50
|
+
if (o.device) {
|
|
51
|
+
const { width, height, dpr = 1, mobile = false } = o.device;
|
|
52
|
+
if (!(width > 0 && height > 0)) throw new Error('device needs width and height in CSS px');
|
|
53
|
+
commands.push(['Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: dpr, mobile }]);
|
|
54
|
+
emulated.device = { width, height, dpr, mobile };
|
|
55
|
+
}
|
|
56
|
+
if (o.touch != null) {
|
|
57
|
+
commands.push(['Emulation.setTouchEmulationEnabled', { enabled: Boolean(o.touch), ...(o.touch && { maxTouchPoints: 5 }) }]);
|
|
58
|
+
emulated.touch = Boolean(o.touch);
|
|
59
|
+
}
|
|
60
|
+
return { commands, emulated };
|
|
61
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Misure di movimento nella pagina, per i tool `animations` e `frames`.
|
|
3
|
+
*
|
|
4
|
+
* Tutto da script di pagina: il dominio CDP `Animation` e
|
|
5
|
+
* `PerformanceTimeline` non sono ammessi a chrome.debugger da un'estensione
|
|
6
|
+
* (prova del 01/10/2026, Chromium 154), quindi si leggono
|
|
7
|
+
* document.getAnimations() e i PerformanceObserver come farebbe il sito.
|
|
8
|
+
*
|
|
9
|
+
* Tre operazioni su globalThis.__cbMotion:
|
|
10
|
+
* - snapshot(opts): le animazioni presenti adesso;
|
|
11
|
+
* - start(opts): apre una finestra di registrazione (animazioni viste a ogni
|
|
12
|
+
* fotogramma, Long Animation Frames, layout shift, interazioni, ritmo rAF);
|
|
13
|
+
* - stop(): chiude la finestra e restituisce la sintesi.
|
|
14
|
+
* Lo stato della finestra vive nella pagina: una navigazione lo perde, e stop
|
|
15
|
+
* lo dice.
|
|
16
|
+
*
|
|
17
|
+
* Non è un modulo ES: chrome.scripting serializza solo la funzione iniettata,
|
|
18
|
+
* quindi il service worker inietta questo file (files:, world MAIN) e poi
|
|
19
|
+
* chiama __cbMotion. Nei test Node si importa per side effect, come
|
|
20
|
+
* element-label.js.
|
|
21
|
+
*/
|
|
22
|
+
(() => {
|
|
23
|
+
// Proprietà che il compositor di Chrome anima senza il thread principale.
|
|
24
|
+
// È una stima: will-change, layer e dimensioni possono cambiare l'esito.
|
|
25
|
+
const COMPOSITABLE = new Set(['transform', 'translate', 'rotate', 'scale', 'opacity', 'filter', 'backdrop-filter']);
|
|
26
|
+
// Una «dissolvenza»: con prefers-reduced-motion restano accettabili.
|
|
27
|
+
const FADE = new Set(['opacity', 'visibility']);
|
|
28
|
+
const KF_META = new Set(['offset', 'computedOffset', 'easing', 'composite']);
|
|
29
|
+
const EASING_MAX = 200;
|
|
30
|
+
|
|
31
|
+
const round = (n, d = 0) => (typeof n === 'number' && Number.isFinite(n) ? Math.round(n * 10 ** d) / 10 ** d : n);
|
|
32
|
+
const kebab = (p) => (p.startsWith('--') ? p : p.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`));
|
|
33
|
+
// linear() di una molla ha decine di punti: si taglia il testo e si dice
|
|
34
|
+
// quanti punti aveva, così due curve restano distinguibili.
|
|
35
|
+
const shortEasing = (e) => {
|
|
36
|
+
const s = String(e);
|
|
37
|
+
if (s.length <= EASING_MAX) return s;
|
|
38
|
+
const points = s.startsWith('linear(') ? s.slice(7, -1).split(',').length : null;
|
|
39
|
+
return `${s.slice(0, EASING_MAX)}…${points ? ` (${points} points)` : ''}`;
|
|
40
|
+
};
|
|
41
|
+
// Durate e tempi: numeri in ms, oppure CSSNumericValue (percentuali delle
|
|
42
|
+
// timeline di scroll) e 'auto', resi come testo.
|
|
43
|
+
const timeValue = (v) => {
|
|
44
|
+
if (v == null) return null;
|
|
45
|
+
if (typeof v === 'number') return Number.isFinite(v) ? round(v) : 'infinite';
|
|
46
|
+
return String(v);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const stableSelector = (el) => {
|
|
50
|
+
if (!el || el.nodeType !== 1) return null;
|
|
51
|
+
if (el.id && /^[A-Za-z][\w-]*$/.test(el.id)) return `#${el.id}`;
|
|
52
|
+
const testid = el.getAttribute && el.getAttribute('data-testid');
|
|
53
|
+
if (testid) return `[data-testid="${testid.replace(/"/g, '\\"')}"]`;
|
|
54
|
+
const parts = [];
|
|
55
|
+
let node = el;
|
|
56
|
+
const root = el.ownerDocument && el.ownerDocument.documentElement;
|
|
57
|
+
while (node && node.nodeType === 1 && node !== root) {
|
|
58
|
+
let part = node.tagName.toLowerCase();
|
|
59
|
+
if (node.id && /^[A-Za-z][\w-]*$/.test(node.id)) { parts.unshift(`#${node.id}`); break; }
|
|
60
|
+
const parent = node.parentElement;
|
|
61
|
+
if (parent) {
|
|
62
|
+
const sameTag = [...parent.children].filter((c) => c.tagName === node.tagName);
|
|
63
|
+
if (sameTag.length > 1) part += `:nth-of-type(${sameTag.indexOf(node) + 1})`;
|
|
64
|
+
}
|
|
65
|
+
parts.unshift(part);
|
|
66
|
+
node = node.parentElement;
|
|
67
|
+
}
|
|
68
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const timelineKind = (a) => {
|
|
72
|
+
const t = a.timeline;
|
|
73
|
+
if (t == null) return 'none';
|
|
74
|
+
if (typeof ViewTimeline !== 'undefined' && t instanceof ViewTimeline) return 'view';
|
|
75
|
+
if (typeof ScrollTimeline !== 'undefined' && t instanceof ScrollTimeline) return 'scroll';
|
|
76
|
+
if (typeof document !== 'undefined' && t === document.timeline) return 'document';
|
|
77
|
+
return 'other';
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function describe(a) {
|
|
81
|
+
const effect = a.effect || null;
|
|
82
|
+
const target = effect && effect.target;
|
|
83
|
+
const pseudo = (effect && effect.pseudoElement) || null;
|
|
84
|
+
let keyframes = [];
|
|
85
|
+
try { keyframes = effect && effect.getKeyframes ? effect.getKeyframes() : []; } catch { /* effetto senza keyframe leggibili */ }
|
|
86
|
+
const props = new Set();
|
|
87
|
+
for (const k of keyframes) for (const p of Object.keys(k)) if (!KF_META.has(p)) props.add(kebab(p));
|
|
88
|
+
if (!props.size && a.transitionProperty) props.add(a.transitionProperty);
|
|
89
|
+
const properties = [...props];
|
|
90
|
+
|
|
91
|
+
const timing = effect && effect.getTiming ? effect.getTiming() : {};
|
|
92
|
+
const computed = effect && effect.getComputedTiming ? effect.getComputedTiming() : {};
|
|
93
|
+
// Animazioni CSS: la curva sta nei keyframe (animation-timing-function), e
|
|
94
|
+
// la timing dell'effetto è 'linear'. Transizioni e WAAPI: nella timing.
|
|
95
|
+
const kfEasings = [...new Set(keyframes.map((k) => k.easing).filter((e) => e && e !== 'linear'))];
|
|
96
|
+
let easing = timing.easing || 'linear';
|
|
97
|
+
if (easing === 'linear' && kfEasings.length) easing = kfEasings.length === 1 ? kfEasings[0] : kfEasings;
|
|
98
|
+
easing = Array.isArray(easing) ? easing.map(shortEasing) : shortEasing(easing);
|
|
99
|
+
|
|
100
|
+
const kind = a.constructor && a.constructor.name ? a.constructor.name : 'Animation';
|
|
101
|
+
const out = {
|
|
102
|
+
kind,
|
|
103
|
+
name: a.animationName || a.transitionProperty || a.id || null,
|
|
104
|
+
selector: stableSelector(target),
|
|
105
|
+
...(pseudo && { pseudo }),
|
|
106
|
+
properties,
|
|
107
|
+
duration_ms: timeValue(timing.duration),
|
|
108
|
+
delay_ms: timeValue(timing.delay),
|
|
109
|
+
...(timing.endDelay ? { end_delay_ms: timeValue(timing.endDelay) } : {}),
|
|
110
|
+
easing,
|
|
111
|
+
iterations: timing.iterations === Infinity ? 'infinite' : timing.iterations,
|
|
112
|
+
...(timing.direction && timing.direction !== 'normal' ? { direction: timing.direction } : {}),
|
|
113
|
+
...(timing.fill && timing.fill !== 'auto' && timing.fill !== 'none' ? { fill: timing.fill } : {}),
|
|
114
|
+
play_state: a.playState,
|
|
115
|
+
progress: computed.progress == null ? null : round(computed.progress, 2),
|
|
116
|
+
timeline: timelineKind(a),
|
|
117
|
+
view_transition: Boolean(pseudo && pseudo.startsWith('::view-transition')),
|
|
118
|
+
composited_estimate: properties.length > 0 && properties.every((p) => COMPOSITABLE.has(p)),
|
|
119
|
+
fade_only: properties.length > 0 && properties.every((p) => FADE.has(p)),
|
|
120
|
+
};
|
|
121
|
+
if (a.playbackRate != null && a.playbackRate !== 1) out.playback_rate = a.playbackRate;
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const inScope = (a, scope) => {
|
|
126
|
+
if (!scope) return true;
|
|
127
|
+
const t = a.effect && a.effect.target;
|
|
128
|
+
try { return Boolean(t && t.closest && t.closest(scope)); } catch { return false; }
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function summarize(list) {
|
|
132
|
+
const by = (key) => list.reduce((m, x) => { m[x[key]] = (m[x[key]] || 0) + 1; return m; }, {});
|
|
133
|
+
const props = {};
|
|
134
|
+
for (const x of list) for (const p of x.properties) props[p] = (props[p] || 0) + 1;
|
|
135
|
+
const durations = list.map((x) => x.duration_ms).filter((d) => typeof d === 'number');
|
|
136
|
+
const easings = [...new Set(list.flatMap((x) => (Array.isArray(x.easing) ? x.easing : [x.easing])))];
|
|
137
|
+
return {
|
|
138
|
+
count: list.length,
|
|
139
|
+
by_kind: by('kind'),
|
|
140
|
+
by_play_state: by('play_state'),
|
|
141
|
+
infinite: list.filter((x) => x.iterations === 'infinite').length,
|
|
142
|
+
max_duration_ms: durations.length ? Math.max(...durations) : null,
|
|
143
|
+
properties: props,
|
|
144
|
+
not_composited_estimate: list.filter((x) => !x.composited_estimate).length,
|
|
145
|
+
not_fade_only: list.filter((x) => !x.fade_only).length,
|
|
146
|
+
view_transitions: list.filter((x) => x.view_transition).length,
|
|
147
|
+
easings,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const reducedMotion = () => {
|
|
152
|
+
try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return null; }
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
function snapshot({ scope = null, limit = 50 } = {}) {
|
|
156
|
+
const list = document.getAnimations().filter((a) => inScope(a, scope)).map(describe);
|
|
157
|
+
return {
|
|
158
|
+
prefers_reduced_motion: reducedMotion(),
|
|
159
|
+
summary: summarize(list),
|
|
160
|
+
animations: list.slice(0, limit),
|
|
161
|
+
...(list.length > limit ? { truncated: list.length - limit } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- finestra di registrazione ---
|
|
166
|
+
|
|
167
|
+
const supported = () => (typeof PerformanceObserver !== 'undefined' && PerformanceObserver.supportedEntryTypes) || [];
|
|
168
|
+
|
|
169
|
+
function start({ scope = null } = {}) {
|
|
170
|
+
if (globalThis.__cbMotionRec) stopObservers(globalThis.__cbMotionRec);
|
|
171
|
+
const t0 = performance.now();
|
|
172
|
+
const rec = {
|
|
173
|
+
t0, scope, observers: [], seen: new Map(), loaf: [], shifts: [], events: [],
|
|
174
|
+
frames: [], raf: 0, active: true, hiddenDuring: false,
|
|
175
|
+
};
|
|
176
|
+
globalThis.__cbMotionRec = rec;
|
|
177
|
+
const types = supported();
|
|
178
|
+
const observe = (type, opts, onEntry) => {
|
|
179
|
+
if (!types.includes(type)) return false;
|
|
180
|
+
const po = new PerformanceObserver((l) => { for (const e of l.getEntries()) onEntry(e); });
|
|
181
|
+
try { po.observe({ type, ...opts }); } catch { return false; }
|
|
182
|
+
rec.observers.push({ po, onEntry });
|
|
183
|
+
return true;
|
|
184
|
+
};
|
|
185
|
+
rec.support = {
|
|
186
|
+
long_animation_frame: observe('long-animation-frame', {}, (e) => { if (e.startTime >= t0) rec.loaf.push(e); }),
|
|
187
|
+
layout_shift: observe('layout-shift', {}, (e) => { if (e.startTime >= t0) rec.shifts.push(e); }),
|
|
188
|
+
event_timing: observe('event', { durationThreshold: 16 }, (e) => { if (e.startTime >= t0) rec.events.push(e); }),
|
|
189
|
+
};
|
|
190
|
+
const sample = () => {
|
|
191
|
+
if (!rec.active) return;
|
|
192
|
+
const now = performance.now();
|
|
193
|
+
rec.frames.push(now);
|
|
194
|
+
for (const a of document.getAnimations()) {
|
|
195
|
+
if (!rec.seen.has(a) && inScope(a, scope)) rec.seen.set(a, round(now - t0));
|
|
196
|
+
}
|
|
197
|
+
rec.raf = requestAnimationFrame(sample);
|
|
198
|
+
};
|
|
199
|
+
rec.onVisibility = () => { if (document.visibilityState === 'hidden') rec.hiddenDuring = true; };
|
|
200
|
+
document.addEventListener('visibilitychange', rec.onVisibility);
|
|
201
|
+
// Una transizione di 150 ms può nascere e finire fra due campioni rAF solo
|
|
202
|
+
// se la pagina perde fotogrammi: transitionrun e animationstart la
|
|
203
|
+
// segnalano comunque, e il campione successivo la trova nel documento.
|
|
204
|
+
rec.onStartEvent = () => {
|
|
205
|
+
for (const a of document.getAnimations()) {
|
|
206
|
+
if (!rec.seen.has(a) && inScope(a, scope)) rec.seen.set(a, round(performance.now() - t0));
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
document.addEventListener('transitionrun', rec.onStartEvent, true);
|
|
210
|
+
document.addEventListener('animationstart', rec.onStartEvent, true);
|
|
211
|
+
sample();
|
|
212
|
+
return { recording: true, support: rec.support };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stopObservers(rec) {
|
|
216
|
+
rec.active = false;
|
|
217
|
+
cancelAnimationFrame(rec.raf);
|
|
218
|
+
// takeRecords: le voci già prodotte ma non ancora consegnate al callback
|
|
219
|
+
// (l'ultimo fotogramma della finestra) non vanno perse.
|
|
220
|
+
for (const { po, onEntry } of rec.observers) {
|
|
221
|
+
try { for (const e of po.takeRecords()) onEntry(e); po.disconnect(); } catch { /* già scollegato */ }
|
|
222
|
+
}
|
|
223
|
+
document.removeEventListener('visibilitychange', rec.onVisibility);
|
|
224
|
+
document.removeEventListener('transitionrun', rec.onStartEvent, true);
|
|
225
|
+
document.removeEventListener('animationstart', rec.onStartEvent, true);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Ritmo dei fotogrammi del thread principale. L'intervallo tipico (mediana)
|
|
229
|
+
// stima la frequenza dello schermo; un intervallo oltre 1,5 volte la mediana
|
|
230
|
+
// è almeno un fotogramma perso. Le animazioni composte possono restare
|
|
231
|
+
// fluide anche quando il thread principale salta.
|
|
232
|
+
function frameStats(times) {
|
|
233
|
+
if (times.length < 3) return { count: times.length, note: 'Too few frames: was the page hidden or the window too short?' };
|
|
234
|
+
const gaps = [];
|
|
235
|
+
for (let i = 1; i < times.length; i++) gaps.push(times[i] - times[i - 1]);
|
|
236
|
+
const sorted = [...gaps].sort((x, y) => x - y);
|
|
237
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
238
|
+
const span = times[times.length - 1] - times[0];
|
|
239
|
+
const dropped = gaps.reduce((n, g) => n + (g > median * 1.5 ? Math.round(g / median) - 1 : 0), 0);
|
|
240
|
+
return {
|
|
241
|
+
count: times.length,
|
|
242
|
+
fps: round((gaps.length * 1000) / span, 1),
|
|
243
|
+
refresh_estimate_hz: round(1000 / median),
|
|
244
|
+
max_gap_ms: round(sorted[sorted.length - 1]),
|
|
245
|
+
dropped_estimate: dropped,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function loafEntry(e) {
|
|
250
|
+
const scripts = (e.scripts || [])
|
|
251
|
+
.map((s) => ({
|
|
252
|
+
invoker: s.invoker || null,
|
|
253
|
+
type: s.invokerType || null,
|
|
254
|
+
source: s.sourceURL ? `${s.sourceURL}${s.sourceFunctionName ? ` ${s.sourceFunctionName}` : ''}${s.sourceCharPosition >= 0 ? `:${s.sourceCharPosition}` : ''}` : (s.sourceFunctionName || null),
|
|
255
|
+
duration_ms: round(s.duration),
|
|
256
|
+
...(s.forcedStyleAndLayoutDuration ? { forced_layout_ms: round(s.forcedStyleAndLayoutDuration) } : {}),
|
|
257
|
+
}))
|
|
258
|
+
.sort((x, y) => y.duration_ms - x.duration_ms)
|
|
259
|
+
.slice(0, 3);
|
|
260
|
+
const start = e.startTime;
|
|
261
|
+
return {
|
|
262
|
+
duration_ms: round(e.duration),
|
|
263
|
+
blocking_ms: round(e.blockingDuration),
|
|
264
|
+
// Fasi del fotogramma: script prima del rendering, poi stile e layout.
|
|
265
|
+
...(e.renderStart ? { render_start_ms: round(e.renderStart - start) } : {}),
|
|
266
|
+
...(e.styleAndLayoutStart ? { style_layout_start_ms: round(e.styleAndLayoutStart - start) } : {}),
|
|
267
|
+
scripts,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function shiftsSummary(entries) {
|
|
272
|
+
const counted = entries.filter((e) => !e.hadRecentInput);
|
|
273
|
+
const cls = counted.reduce((s, e) => s + e.value, 0);
|
|
274
|
+
const top = [...counted].sort((x, y) => y.value - x.value).slice(0, 5).map((e) => ({
|
|
275
|
+
value: round(e.value, 4),
|
|
276
|
+
sources: (e.sources || []).slice(0, 3).map((s) => ({
|
|
277
|
+
selector: stableSelector(s.node),
|
|
278
|
+
...(s.previousRect && s.currentRect ? { moved_px: { x: round(s.currentRect.x - s.previousRect.x), y: round(s.currentRect.y - s.previousRect.y) } } : {}),
|
|
279
|
+
})),
|
|
280
|
+
}));
|
|
281
|
+
return {
|
|
282
|
+
cls: round(cls, 4),
|
|
283
|
+
count: counted.length,
|
|
284
|
+
...(entries.length > counted.length ? { after_input_excluded: entries.length - counted.length } : {}),
|
|
285
|
+
top,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// INP sulla finestra: la più lunga fra le interazioni (interactionId > 0).
|
|
290
|
+
// Con meno di 50 interazioni è anche il valore che web-vitals riporterebbe.
|
|
291
|
+
// Le durate degli Event Timing sono arrotondate a 8 ms dal browser. Le fasi
|
|
292
|
+
// si calcolano sull'insieme delle voci dell'interazione (pointerdown,
|
|
293
|
+
// pointerup, click…), come web-vitals/attribution: la voce più lunga da
|
|
294
|
+
// sola è spesso un pointerup senza handler e senza target.
|
|
295
|
+
function interactionsSummary(entries) {
|
|
296
|
+
const byId = new Map();
|
|
297
|
+
for (const e of entries) {
|
|
298
|
+
if (!e.interactionId) continue;
|
|
299
|
+
if (!byId.has(e.interactionId)) byId.set(e.interactionId, []);
|
|
300
|
+
byId.get(e.interactionId).push(e);
|
|
301
|
+
}
|
|
302
|
+
const list = [...byId.values()].map((group) => ({ group, duration: Math.max(...group.map((e) => e.duration)) }))
|
|
303
|
+
.sort((x, y) => y.duration - x.duration);
|
|
304
|
+
if (!list.length) return { count: 0, inp_ms: null, note: 'No interaction in the window. Synthetic events have no interactionId: use action.trusted for INP.' };
|
|
305
|
+
const { group, duration } = list[0];
|
|
306
|
+
const start = Math.min(...group.map((e) => e.startTime));
|
|
307
|
+
const procStart = Math.min(...group.map((e) => e.processingStart));
|
|
308
|
+
const procEnd = Math.max(...group.map((e) => e.processingEnd));
|
|
309
|
+
const main = [...group].sort((x, y) => (y.processingEnd - y.processingStart) - (x.processingEnd - x.processingStart))
|
|
310
|
+
.find((e) => e.target) || group[0];
|
|
311
|
+
return {
|
|
312
|
+
count: list.length,
|
|
313
|
+
inp_ms: round(duration),
|
|
314
|
+
worst: {
|
|
315
|
+
event: main.name,
|
|
316
|
+
events: [...new Set(group.map((e) => e.name))],
|
|
317
|
+
selector: stableSelector(main.target),
|
|
318
|
+
input_delay_ms: round(procStart - start),
|
|
319
|
+
processing_ms: round(procEnd - procStart),
|
|
320
|
+
presentation_delay_ms: round(Math.max(0, start + duration - procEnd)),
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function stop({ threshold_ms = 50, limit = 10 } = {}) {
|
|
326
|
+
const rec = globalThis.__cbMotionRec;
|
|
327
|
+
if (!rec) return { error: 'No recording in this page: it was never started, or the page navigated and lost it.' };
|
|
328
|
+
stopObservers(rec);
|
|
329
|
+
delete globalThis.__cbMotionRec;
|
|
330
|
+
const t0 = rec.t0;
|
|
331
|
+
const animations = [...rec.seen.entries()].map(([a, at]) => ({ first_seen_ms: at, ...describe(a) }));
|
|
332
|
+
const long = rec.loaf.filter((e) => e.duration >= threshold_ms).sort((x, y) => y.duration - x.duration);
|
|
333
|
+
return {
|
|
334
|
+
window_ms: round(performance.now() - t0),
|
|
335
|
+
prefers_reduced_motion: reducedMotion(),
|
|
336
|
+
...(rec.hiddenDuring ? { page_hidden_during: true } : {}),
|
|
337
|
+
support: rec.support,
|
|
338
|
+
frames: frameStats(rec.frames),
|
|
339
|
+
long_frames: {
|
|
340
|
+
threshold_ms,
|
|
341
|
+
count: long.length,
|
|
342
|
+
total_blocking_ms: round(long.reduce((s, e) => s + (e.blockingDuration || 0), 0)),
|
|
343
|
+
worst: long.slice(0, limit).map((e) => ({ ...loafEntry(e), at_ms: round(e.startTime - t0) })),
|
|
344
|
+
},
|
|
345
|
+
layout_shifts: shiftsSummary(rec.shifts),
|
|
346
|
+
interactions: interactionsSummary(rec.events),
|
|
347
|
+
animations: {
|
|
348
|
+
summary: summarize(animations),
|
|
349
|
+
list: animations.slice(0, limit * 3),
|
|
350
|
+
...(animations.length > limit * 3 ? { truncated: animations.length - limit * 3 } : {}),
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
globalThis.__cbMotion = { snapshot, start, stop, _internals: { describe, summarize, frameStats, shiftsSummary, interactionsSummary, loafEntry, shortEasing } };
|
|
356
|
+
})();
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* scorciatoie di tastiera gestite dal browser.
|
|
6
6
|
*
|
|
7
7
|
* Il debugger si aggancia solo per la durata dell'azione e si stacca subito:
|
|
8
|
-
* la barra «sta eseguendo il debug» compare solo in quel momento.
|
|
8
|
+
* la barra «sta eseguendo il debug» compare solo in quel momento. Eccezione:
|
|
9
|
+
* una scheda «tenuta» (holdDebugger, per l'emulazione di emulate_media, che
|
|
10
|
+
* vive solo finché il debugger è agganciato) resta agganciata fino al
|
|
11
|
+
* rilascio, e withDebugger usa quell'aggancio senza staccarlo.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
// Tasti con nome: code e keyCode di Windows che Input.dispatchKeyEvent vuole
|
|
@@ -73,20 +76,76 @@ export function mouseClickEvents(x, y, { button = 'left', count = 1 } = {}) {
|
|
|
73
76
|
return events;
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
// Schede tenute agganciate: tabId → insieme dei motivi ('emulation', …).
|
|
80
|
+
// Chrome stacca il debugger da solo quando l'utente preme «Annulla» sulla
|
|
81
|
+
// barra o la scheda si chiude: onDetach toglie la voce, e chi la teneva lo
|
|
82
|
+
// scopre con debuggerHeld.
|
|
83
|
+
const held = new Map();
|
|
84
|
+
let detachListener = false;
|
|
85
|
+
|
|
86
|
+
function listenDetach() {
|
|
87
|
+
if (detachListener || !globalThis.chrome?.debugger?.onDetach) return;
|
|
88
|
+
detachListener = true;
|
|
89
|
+
chrome.debugger.onDetach.addListener((source, reason) => {
|
|
90
|
+
if (source.tabId == null || !held.has(source.tabId)) return;
|
|
91
|
+
held.delete(source.tabId);
|
|
92
|
+
lastDetach.set(source.tabId, reason);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const lastDetach = new Map();
|
|
96
|
+
|
|
97
|
+
async function attach(tabId, purpose) {
|
|
98
|
+
try {
|
|
99
|
+
await chrome.debugger.attach({ tabId }, '1.3');
|
|
100
|
+
} catch (e) {
|
|
101
|
+
const msg = String(e?.message ?? e);
|
|
102
|
+
if (/already attached/i.test(msg)) throw new Error(`${purpose} unavailable: another debugger (DevTools?) is attached to this tab — close it, or retry without it`);
|
|
103
|
+
throw new Error(`${purpose} unavailable: ${msg}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Tiene il debugger agganciato alla scheda per un motivo, finché releaseDebugger. */
|
|
108
|
+
export async function holdDebugger(tabId, why, purpose = why) {
|
|
109
|
+
listenDetach();
|
|
110
|
+
let reasons = held.get(tabId);
|
|
111
|
+
if (!reasons) {
|
|
112
|
+
await attach(tabId, purpose);
|
|
113
|
+
reasons = new Set();
|
|
114
|
+
held.set(tabId, reasons);
|
|
115
|
+
lastDetach.delete(tabId);
|
|
116
|
+
}
|
|
117
|
+
reasons.add(why);
|
|
118
|
+
return (method, params) => chrome.debugger.sendCommand({ tabId }, method, params);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Toglie un motivo; senza più motivi il debugger si stacca e la barra sparisce. */
|
|
122
|
+
export async function releaseDebugger(tabId, why) {
|
|
123
|
+
const reasons = held.get(tabId);
|
|
124
|
+
if (!reasons) return false;
|
|
125
|
+
reasons.delete(why);
|
|
126
|
+
if (reasons.size) return true;
|
|
127
|
+
held.delete(tabId);
|
|
128
|
+
await chrome.debugger.detach({ tabId }).catch(() => {});
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Stato della tenuta: held, i motivi, o il motivo dello stacco (canceled_by_user, target_closed). */
|
|
133
|
+
export function debuggerHeld(tabId) {
|
|
134
|
+
const reasons = held.get(tabId);
|
|
135
|
+
if (reasons) return { held: true, reasons: [...reasons] };
|
|
136
|
+
return { held: false, ...(lastDetach.has(tabId) && { detached: lastDetach.get(tabId) }) };
|
|
137
|
+
}
|
|
138
|
+
|
|
76
139
|
/**
|
|
77
140
|
* Aggancia il debugger alla scheda, esegue fn(send) e si stacca sempre, anche
|
|
78
141
|
* su errore. Un DevTools già aperto sulla scheda impedisce l'aggancio: lo dice.
|
|
142
|
+
* Su una scheda tenuta usa l'aggancio esistente e non lo stacca.
|
|
79
143
|
*/
|
|
80
144
|
export async function withDebugger(tabId, fn) {
|
|
81
145
|
const target = { tabId };
|
|
82
|
-
try {
|
|
83
|
-
await chrome.debugger.attach(target, '1.3');
|
|
84
|
-
} catch (e) {
|
|
85
|
-
const msg = String(e?.message ?? e);
|
|
86
|
-
if (/already attached/i.test(msg)) throw new Error('trusted input unavailable: another debugger (DevTools?) is attached to this tab — close it, or retry without trusted');
|
|
87
|
-
throw new Error(`trusted input unavailable: ${msg}`);
|
|
88
|
-
}
|
|
89
146
|
const send = (method, params) => chrome.debugger.sendCommand(target, method, params);
|
|
147
|
+
if (held.has(tabId)) return fn(send);
|
|
148
|
+
await attach(tabId, 'trusted input');
|
|
90
149
|
try {
|
|
91
150
|
return await fn(send);
|
|
92
151
|
} finally {
|
package/extension/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome Bridge for Claude Code",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "Bridge your browser to Claude Code:
|
|
4
|
+
"version": "1.28.0",
|
|
5
|
+
"description": "Bridge your browser to Claude Code: 66 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
|
|
6
6
|
"homepage_url": "https://github.com/frsorrentino/chrome-bridge",
|
|
7
7
|
"minimum_chrome_version": "135",
|
|
8
8
|
"permissions": [
|
package/extension/popup.css
CHANGED
|
@@ -58,6 +58,7 @@ body {
|
|
|
58
58
|
.dot.connected { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
|
|
59
59
|
.dot.connecting { background: #f39c12; }
|
|
60
60
|
.dot.disconnected { background: var(--err); }
|
|
61
|
+
.dot.refused { background: #f39c12; }
|
|
61
62
|
#status-text { font-weight: 600; }
|
|
62
63
|
.status-row .ws-url {
|
|
63
64
|
margin-left: auto;
|