chrome-bridge-mcp 1.11.1 → 1.14.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 +113 -130
- package/extension/lib/tile-layout.js +75 -0
- package/extension/manifest.json +2 -2
- package/extension/service-worker.js +189 -6
- package/package.json +2 -2
- package/server/protocol.js +4 -1
- package/server/tools.js +61 -6
- package/tools/cws-upload.mjs +56 -1
package/README.md
CHANGED
|
@@ -1,166 +1,141 @@
|
|
|
1
1
|
# Chrome Bridge
|
|
2
2
|
|
|
3
|
-
       [](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**An MCP server that gives Claude Code your real, logged-in Chrome — measured
|
|
6
|
+
2.75× fewer turns and 2.28× lower cost than the official "Claude in Chrome"
|
|
7
|
+
extension on a form-filling task, with ~3× the toolset and no paid plan.**
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
62 web-development tools (navigation, DOM inspection, visual regression, audits,
|
|
10
|
+
network mocking) over a local WebSocket bridge, plus a headless instance for CI.
|
|
11
|
+
Self-hosted, local-only.
|
|
8
12
|
|
|
9
|
-
##
|
|
13
|
+
## Quickstart
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
**Requires** Node.js 18+ and Chrome 135+.
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
```bash
|
|
18
|
+
git clone git@github.com:frsorrentino/chrome-bridge.git
|
|
19
|
+
cd chrome-bridge && ./install.sh
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
1. Open `chrome://extensions`, enable **Developer mode**, click **Load
|
|
23
|
+
unpacked**, select the `extension/` folder.
|
|
24
|
+
2. Restart Claude Code.
|
|
15
25
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
26
|
+
Then ask for something like *"open localhost:3000, run an accessibility audit
|
|
27
|
+
and find the Sign Up button"*: Claude Code calls `navigate`,
|
|
28
|
+
`accessibility_audit` and `find_text`. Because `navigate` already returns
|
|
29
|
+
element refs, `click(ref="n1")` follows with no discovery turn in between.
|
|
20
30
|
|
|
21
|
-
On
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
[bench/RESULTS.md](bench/RESULTS.md).
|
|
31
|
+
> **On ChromeOS/Crostini** install from the [Chrome Web
|
|
32
|
+
> Store](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
|
|
33
|
+
> instead: an unpacked extension is dropped on every reboot, because the
|
|
34
|
+
> container isn't mounted when Chrome starts.
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
- **Honest caveat:** per *turn*, Chrome Bridge costs slightly more than Claude in Chrome (41,985 vs 36,613 cache-read tokens; $0.0373 vs $0.0337). The win is in the number of turns.
|
|
36
|
+
`install.sh` registers the MCP server with `--scope user`. To do it by hand:
|
|
37
|
+
`claude mcp add --scope user chrome-bridge node /path/to/server/index.js`.
|
|
38
|
+
For `execute_js`, enable **Allow user scripts** in `chrome://extensions` →
|
|
39
|
+
Chrome Bridge → Details (on Chrome 135-137, enable Developer Mode instead).
|
|
32
40
|
|
|
33
|
-
|
|
34
|
-
Chrome Bridge ships 60 specialized tools (31 core by default) compared to ~20 in Claude in Chrome. It is also the only automation that drives the real host Chrome on ChromeOS/Crostini.
|
|
41
|
+
## Why Chrome Bridge?
|
|
35
42
|
|
|
36
43
|
| | Chrome Bridge | Claude in Chrome | Chrome DevTools MCP | Playwright MCP |
|
|
37
44
|
|---|---|---|---|---|
|
|
38
45
|
| **ChromeOS / Crostini** | **Yes** (real host) | No | Container only | Container only |
|
|
39
|
-
| **Tools** | **
|
|
40
|
-
| **Requires
|
|
41
|
-
| **Network
|
|
42
|
-
| **Visual
|
|
43
|
-
| **Audits (
|
|
46
|
+
| **Tools** | **62** (33 core) | ~20 | ~50 | 23 core (71 total) |
|
|
47
|
+
| **Requires paid plan** | **No** | Yes (Pro+) | No | No |
|
|
48
|
+
| **Network mocking** | **Yes** (stub/headers) | No | No | Yes |
|
|
49
|
+
| **Visual regression** | **Yes** (`screenshot_diff`) | No | No | No |
|
|
50
|
+
| **Audits (a11y/SEO/sec)** | **Yes** (full suite) | No | Partial | No |
|
|
44
51
|
| **Headless / CI** | **Yes** | No | Yes | Yes |
|
|
45
|
-
| **GIF /
|
|
46
|
-
| **Breakpoints /
|
|
47
|
-
|
|
48
|
-
## Quickstart
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
git clone git@github.com:frsorrentino/chrome-bridge.git && cd chrome-bridge && ./install.sh
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
1. Load the extension: Open `chrome://extensions`, enable **Developer mode**, click **Load unpacked**, and select the `extension/` folder.
|
|
55
|
-
2. Restart Claude Code.
|
|
56
|
-
|
|
57
|
-
> On **ChromeOS/Crostini**, install the published extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb) instead of loading it unpacked — a filesystem-loaded extension is dropped on every reboot (the container isn't mounted when Chrome starts).
|
|
58
|
-
|
|
59
|
-
### Example Session
|
|
60
|
-
> *"Open localhost:3000, run an accessibility audit, and find the 'Sign Up' button"*
|
|
61
|
-
|
|
62
|
-
Claude Code calls `navigate` (returning clickable element refs), `accessibility_audit`, and `find_text`. Because `navigate` returns refs like `n1`, the agent can immediately call `click(ref="n1")` without a discovery turn.
|
|
52
|
+
| **GIF / video** | No | **Yes** | Partial | No |
|
|
53
|
+
| **Breakpoints / heap** | No | No | **Yes** | No |
|
|
63
54
|
|
|
64
|
-
|
|
55
|
+
It wins on **round trips, not payload size**: short element refs instead of the
|
|
56
|
+
screenshot-and-click loop, `fill_form` filling N fields in one call, table
|
|
57
|
+
filtering done server-side. Per single turn it actually costs slightly *more*.
|
|
65
58
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
- **Payload Escape Hatches**: `save_to` on `read_page`, `extract`, `screenshot` and `http_request` writes the result to a file and returns the path — the bytes never enter the context unless the agent decides to read them. `read_page(mode="markdown")` keeps headings, links and tables at a fraction of the HTML cost. The [CLI](#cli) skips MCP schemas entirely, and recorded flows [replay](#launch-mode-headless--ci) without any model in the loop.
|
|
59
|
+
The full benchmark — method, every raw run including the unfavourable ones, and
|
|
60
|
+
what the harness can't measure — is in
|
|
61
|
+
[docs/EFFICIENCY.md](docs/EFFICIENCY.md).
|
|
70
62
|
|
|
71
|
-
##
|
|
63
|
+
## Using it
|
|
72
64
|
|
|
73
|
-
|
|
74
|
-
- **Network Mocking**: Block/redirect requests, rewrite headers, or stub response bodies with synthetic data.
|
|
75
|
-
- **Visual Regression**: Use `screenshot_diff` to compare current pages against named baselines.
|
|
76
|
-
- **Structured Extraction**: One-call `extract` for repeated data and `extract_table` with server-side `where` filtering.
|
|
65
|
+
Beyond the MCP tools, two lanes keep work away from the model entirely.
|
|
77
66
|
|
|
78
|
-
|
|
67
|
+
**CLI** — batch operations, piped through `grep` or `jq` before anything reaches
|
|
68
|
+
the context:
|
|
79
69
|
|
|
70
|
+
```bash
|
|
71
|
+
chrome-bridge navigate --url https://example.com
|
|
72
|
+
chrome-bridge read_console --level error | head -20
|
|
73
|
+
chrome-bridge assert --selector "#success" --text "Done"
|
|
74
|
+
chrome-bridge replay --file ./recordings/login.jsonl
|
|
80
75
|
```
|
|
81
|
-
Claude Code <--stdio--> MCP Server <--WebSocket :8765--> Chrome Extension
|
|
82
|
-
(server/) (extension/, MV3)
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
The **MCP Server** (Node.js) handles the protocol and tool logic. The **Chrome Extension** (MV3) executes commands via Chrome APIs. User scripts (`execute_js`) run via `chrome.userScripts.execute()`, requiring the "Allow user scripts" toggle in extension settings.
|
|
86
|
-
|
|
87
|
-
## Tools (60 total)
|
|
88
76
|
|
|
89
|
-
|
|
90
|
-
|
|
77
|
+
**Launch mode** — a dedicated Chromium instance with an ephemeral profile, for
|
|
78
|
+
isolated sessions or CI:
|
|
91
79
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
### DOM & Inspection (11)
|
|
96
|
-
`read_page`, `extract`, `get_page_info`, `query_dom`, `modify_dom`, `find_text`, `get_interactives`, `inject_css`, `highlight_elements`, `watch_dom`, `measure_spacing`.
|
|
80
|
+
```bash
|
|
81
|
+
node server/index.js --launch --headless
|
|
82
|
+
```
|
|
97
83
|
|
|
98
|
-
|
|
99
|
-
|
|
84
|
+
Pair it with `session_record` + `replay` for smoke tests with no model in the
|
|
85
|
+
loop. In launch mode `execute_js` falls back to `new Function` when the
|
|
86
|
+
user-script toggle isn't available.
|
|
100
87
|
|
|
101
|
-
|
|
102
|
-
`element_screenshot`, `full_page_screenshot`, `screenshot_diff`, `viewport_resize`, `set_zoom`, `emulate_media`, `set_geolocation`.
|
|
88
|
+
## Tools
|
|
103
89
|
|
|
104
|
-
|
|
105
|
-
|
|
90
|
+
62 in total, in seven groups. Only `core` (33 tools) loads by default; the rest
|
|
91
|
+
are opt-in via `--caps`.
|
|
106
92
|
|
|
107
|
-
|
|
108
|
-
|
|
93
|
+
| Group | N | What's in it |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| Core & Navigation | 9 | tabs, windows, `navigate`, `screenshot`, `tile_windows` |
|
|
96
|
+
| Interaction | 11 | `click`, `fill_form`, `upload_file`, dialogs, clipboard |
|
|
97
|
+
| DOM & Inspection | 11 | `read_page`, `extract`, `query_dom`, `watch_dom` |
|
|
98
|
+
| Debugging & Network | 9 | `execute_js`, console, network log, mocking, Web Vitals |
|
|
99
|
+
| Visual & Responsive | 7 | `screenshot_diff`, viewport, zoom, media emulation |
|
|
100
|
+
| Audits | 6 | a11y, SEO, security headers, links, `extract_table` |
|
|
101
|
+
| State, Storage & Files | 9 | storage, fixtures, MHTML, recording, `assert` |
|
|
109
102
|
|
|
110
|
-
|
|
103
|
+
Every tool, with the notes that matter: [docs/TOOLS.md](docs/TOOLS.md).
|
|
111
104
|
|
|
112
|
-
|
|
105
|
+
## How it works
|
|
113
106
|
|
|
114
|
-
### From Source
|
|
115
|
-
```bash
|
|
116
|
-
git clone git@github.com:frsorrentino/chrome-bridge.git
|
|
117
|
-
cd chrome-bridge
|
|
118
|
-
./install.sh
|
|
119
107
|
```
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
### Manual Registration
|
|
123
|
-
```bash
|
|
124
|
-
claude mcp add --scope user chrome-bridge node /path/to/server/index.js
|
|
108
|
+
Claude Code <--stdio--> MCP Server <--WebSocket :8765--> Chrome Extension
|
|
109
|
+
(server/) (extension/, MV3)
|
|
125
110
|
```
|
|
126
111
|
|
|
127
|
-
|
|
112
|
+
The Node.js server handles the protocol and tool logic; the MV3 extension
|
|
113
|
+
executes commands through Chrome APIs. User scripts (`execute_js`) run via
|
|
114
|
+
`chrome.userScripts.execute()`.
|
|
128
115
|
|
|
129
|
-
## Configuration
|
|
116
|
+
## Configuration and security
|
|
130
117
|
|
|
131
|
-
|
|
132
|
-
- `CHROME_BRIDGE_PORT`: Default `8765`.
|
|
133
|
-
- `CHROME_BRIDGE_HOST` / `--host`: Bind address, default `127.0.0.1`. Set to `0.0.0.0` **only** where the browser lives outside the container (ChromeOS/Crostini port-forward) — and pair it with a token.
|
|
134
|
-
- `CHROME_BRIDGE_TOKEN`: Shared secret required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind is not loopback.
|
|
135
|
-
- `CHROME_BRIDGE_CAPS` / `--caps`: Tool groups to load (`core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, or `all`). `install.sh` registers with `all`; the bare server defaults to `core` (31 tools). `get_status` reports `caps_active` and `caps_available`.
|
|
118
|
+
Environment variables, each with a matching CLI flag:
|
|
136
119
|
|
|
137
|
-
|
|
120
|
+
| Variable | Default | Notes |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `CHROME_BRIDGE_PORT` | `8765` | |
|
|
123
|
+
| `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 |
|
|
124
|
+
| `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
|
|
125
|
+
| `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. `install.sh` uses `all` |
|
|
138
126
|
|
|
139
|
-
|
|
127
|
+
The bridge binds loopback, accepts extension connections only from a
|
|
128
|
+
`chrome-extension://` origin, and — when a token is set — requires it on both
|
|
129
|
+
handshakes. Without one, any local process could act as a relay and reach
|
|
130
|
+
`execute_js` inside your authenticated browser session. Secondary MCP instances
|
|
131
|
+
connect via loopback and are acknowledged with `relay_init_ok`, so a foreign
|
|
132
|
+
process holding the port fails fast instead of timing out per command.
|
|
140
133
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
chrome-bridge replay --file ./recordings/login.jsonl
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
## Launch Mode (Headless / CI)
|
|
150
|
-
|
|
151
|
-
Launch mode starts a dedicated Chromium instance with an ephemeral profile:
|
|
152
|
-
```bash
|
|
153
|
-
node server/index.js --launch --headless
|
|
154
|
-
```
|
|
155
|
-
Perfect for CI smoke tests using `session_record` + `replay`. Note: `execute_js` uses a `new Function` fallback in launch mode if the user-script toggle is unavailable.
|
|
156
|
-
|
|
157
|
-
## Security
|
|
158
|
-
|
|
159
|
-
- **Loopback by default**: the WebSocket server binds `127.0.0.1`. Exposing it to the network is opt-in via `CHROME_BRIDGE_HOST`/`--host`.
|
|
160
|
-
- **Origin validation**: extension connections are only accepted from a `chrome-extension://` origin, and — with a loopback bind — only from loopback.
|
|
161
|
-
- **Token on both handshakes**: `CHROME_BRIDGE_TOKEN`, when set, is required for `ext_init` *and* `relay_init`. Without it, any local process could act as a relay and reach `execute_js` in your authenticated browser session.
|
|
162
|
-
- **Relay security**: secondary MCP instances must connect via loopback and are acknowledged with `relay_init_ok`, so a foreign process holding the port fails fast instead of timing out per command.
|
|
163
|
-
- **What is *not* protected**: page content reaches the model unfiltered, so a hostile page's text is untrusted input; `get_storage`, `session_fixture`, HAR exports and screenshots are **not** redacted and may carry cookies, tokens or personal data. Do not point the automation at pages holding secrets you would not paste into a chat.
|
|
134
|
+
**What is *not* protected:** page content reaches the model unfiltered, so a
|
|
135
|
+
hostile page's text is untrusted input. `get_storage`, `session_fixture`, HAR
|
|
136
|
+
exports and screenshots are **not** redacted and may carry cookies, tokens or
|
|
137
|
+
personal data. Don't point the automation at pages holding secrets you wouldn't
|
|
138
|
+
paste into a chat.
|
|
164
139
|
|
|
165
140
|
## Troubleshooting
|
|
166
141
|
|
|
@@ -170,16 +145,24 @@ Perfect for CI smoke tests using `session_record` + `replay`. Note: `execute_js`
|
|
|
170
145
|
| Port 8765 already in use | Expected: a second MCP session becomes a **relay** and shares the one bridge. Set `CHROME_BRIDGE_PORT` for a separate one. |
|
|
171
146
|
| `Port N is held by a process that is not chrome-bridge` | Something else owns the port. Free it or change `CHROME_BRIDGE_PORT`. |
|
|
172
147
|
| `execute_js` fails | Enable **Allow user scripts** in `chrome://extensions` → Chrome Bridge → Details (Chrome 138+; on 135-137 enable Developer Mode). |
|
|
173
|
-
| `read_console` returns
|
|
148
|
+
| `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. |
|
|
174
149
|
| Screenshot times out | On ChromeOS a fully occluded window stops producing frames; captures fail after 10s. Bring the window forward. |
|
|
175
|
-
| Commands work then stop
|
|
176
|
-
| Extension
|
|
177
|
-
| Tool missing from the list | It's in an opt-in
|
|
150
|
+
| 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. |
|
|
151
|
+
| Extension dropped on every ChromeOS reboot | Install from the Web Store instead of Load unpacked. |
|
|
152
|
+
| Tool missing from the list | It's in an opt-in group. Check `get_status` → `caps_available`, then set `CHROME_BRIDGE_CAPS=all`. |
|
|
153
|
+
|
|
154
|
+
## Documentation
|
|
155
|
+
|
|
156
|
+
- [docs/TOOLS.md](docs/TOOLS.md) — all 62 tools, by group
|
|
157
|
+
- [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
|
|
158
|
+
- [bench/RESULTS.md](bench/RESULTS.md) — raw runs and inclusion rule
|
|
159
|
+
- [CHANGELOG.md](CHANGELOG.md)
|
|
178
160
|
|
|
179
161
|
## Tests
|
|
180
|
-
|
|
181
|
-
-
|
|
182
|
-
|
|
162
|
+
|
|
163
|
+
`npm test` (Chrome-free, ~22s) · `npm run test:e2e` (needs Chrome and a
|
|
164
|
+
connected extension) · `npm run measure` (schema cost).
|
|
183
165
|
|
|
184
166
|
## License
|
|
167
|
+
|
|
185
168
|
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Divide un'area rettangolare in `count` tessere che la riempiono per intero.
|
|
3
|
+
*
|
|
4
|
+
* Funzione pura, tenuta fuori dal service worker perché è l'unica parte con una
|
|
5
|
+
* geometria da sbagliare: il resto del tool è una sequenza di
|
|
6
|
+
* chrome.windows.update.
|
|
7
|
+
*
|
|
8
|
+
* Sul resto della divisione intera: 3 finestre su 1000px fanno 333 e avanza 1.
|
|
9
|
+
* Buttarlo lascia una striscia di scrivania scoperta, che è esattamente ciò che
|
|
10
|
+
* l'affiancamento dovrebbe eliminare. Qui il resto viene distribuito un pixel
|
|
11
|
+
* per volta sulle prime tessere: le larghezze differiscono al massimo di 1px e
|
|
12
|
+
* lo spazio resta pieno.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Ripartisce `total` in `parts` interi che sommano esattamente a `total`.
|
|
16
|
+
function split(total, parts) {
|
|
17
|
+
const base = Math.floor(total / parts);
|
|
18
|
+
const remainder = total - base * parts;
|
|
19
|
+
return Array.from({ length: parts }, (_, i) => base + (i < remainder ? 1 : 0));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Righe e colonne per una griglia il più quadrata possibile: con 5 finestre
|
|
23
|
+
// preferisce 3×2 a 5×1, che su uno schermo largo lascerebbe strisce inutilizzabili.
|
|
24
|
+
function gridShape(count) {
|
|
25
|
+
const cols = Math.ceil(Math.sqrt(count));
|
|
26
|
+
const rows = Math.ceil(count / cols);
|
|
27
|
+
return { cols, rows };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function computeTiles({ count, area, layout = 'grid', padding = 0 }) {
|
|
31
|
+
if (!count || count < 1) return [];
|
|
32
|
+
|
|
33
|
+
const box = {
|
|
34
|
+
left: area.left + padding,
|
|
35
|
+
top: area.top + padding,
|
|
36
|
+
width: area.width - padding * 2,
|
|
37
|
+
height: area.height - padding * 2,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let rows;
|
|
41
|
+
let colsPerRow;
|
|
42
|
+
if (layout === 'columns') {
|
|
43
|
+
rows = 1;
|
|
44
|
+
colsPerRow = [count];
|
|
45
|
+
} else if (layout === 'rows') {
|
|
46
|
+
rows = count;
|
|
47
|
+
colsPerRow = Array.from({ length: count }, () => 1);
|
|
48
|
+
} else {
|
|
49
|
+
const shape = gridShape(count);
|
|
50
|
+
rows = shape.rows;
|
|
51
|
+
// L'ultima riga può essere incompleta: le sue tessere si allargano a
|
|
52
|
+
// riempirla invece di lasciare un buco a destra.
|
|
53
|
+
colsPerRow = Array.from({ length: rows }, (_, r) => {
|
|
54
|
+
const remaining = count - r * shape.cols;
|
|
55
|
+
return Math.min(shape.cols, remaining);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const rowHeights = split(box.height, rows);
|
|
60
|
+
const tiles = [];
|
|
61
|
+
let top = box.top;
|
|
62
|
+
|
|
63
|
+
for (let r = 0; r < rows; r++) {
|
|
64
|
+
const cols = colsPerRow[r];
|
|
65
|
+
const colWidths = split(box.width, cols);
|
|
66
|
+
let left = box.left;
|
|
67
|
+
for (let c = 0; c < cols; c++) {
|
|
68
|
+
tiles.push({ left, top, width: colWidths[c], height: rowHeights[r] });
|
|
69
|
+
left += colWidths[c];
|
|
70
|
+
}
|
|
71
|
+
top += rowHeights[r];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return tiles;
|
|
75
|
+
}
|
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.14.0",
|
|
5
|
+
"description": "Bridge your browser to Claude Code: 62 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": [
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import './telemetry.js';
|
|
9
9
|
import { buildMarkdown } from './lib/page-markdown.js';
|
|
10
|
+
import { computeTiles } from './lib/tile-layout.js';
|
|
10
11
|
const { pushError } = globalThis.__cbTelemetry;
|
|
11
12
|
|
|
12
13
|
const DEFAULT_PORT = 8765;
|
|
@@ -321,7 +322,7 @@ async function executeCommand(msg) {
|
|
|
321
322
|
|
|
322
323
|
switch (type) {
|
|
323
324
|
case 'get_tabs':
|
|
324
|
-
return await cmdGetTabs();
|
|
325
|
+
return await cmdGetTabs(params);
|
|
325
326
|
case 'navigate':
|
|
326
327
|
return await cmdNavigate(params);
|
|
327
328
|
case 'screenshot':
|
|
@@ -386,6 +387,10 @@ async function executeCommand(msg) {
|
|
|
386
387
|
return await cmdGetFrames(params);
|
|
387
388
|
case 'tab_action':
|
|
388
389
|
return await cmdTabAction(params);
|
|
390
|
+
case 'move_tab':
|
|
391
|
+
return await cmdMoveTab(params);
|
|
392
|
+
case 'tile_windows':
|
|
393
|
+
return await cmdTileWindows(params);
|
|
389
394
|
case 'upload_file':
|
|
390
395
|
return await cmdUploadFile(params);
|
|
391
396
|
case 'wait_for_navigation':
|
|
@@ -644,15 +649,36 @@ async function bitmapToBase64Capped(bitmap, maxSide = MAX_IMAGE_SIDE) {
|
|
|
644
649
|
|
|
645
650
|
// --- Implementazione comandi ---
|
|
646
651
|
|
|
647
|
-
async function cmdGetTabs() {
|
|
652
|
+
async function cmdGetTabs({ include_windows = false } = {}) {
|
|
648
653
|
const tabs = await chrome.tabs.query({});
|
|
649
|
-
|
|
654
|
+
const list = tabs.map((t) => ({
|
|
650
655
|
id: t.id,
|
|
651
656
|
url: t.url,
|
|
652
657
|
title: t.title,
|
|
653
658
|
active: t.active,
|
|
654
659
|
windowId: t.windowId,
|
|
655
660
|
}));
|
|
661
|
+
if (!include_windows) return list;
|
|
662
|
+
|
|
663
|
+
// Senza geometria e stato non si può decidere dove mettere una finestra: si
|
|
664
|
+
// può solo indovinare. `left` in particolare è ciò che identifica il monitor
|
|
665
|
+
// su una scrivania virtuale multi-schermo.
|
|
666
|
+
const windows = (await chrome.windows.getAll({ populate: true })).map((w) => ({
|
|
667
|
+
id: w.id,
|
|
668
|
+
type: w.type,
|
|
669
|
+
state: w.state,
|
|
670
|
+
focused: w.focused,
|
|
671
|
+
left: w.left,
|
|
672
|
+
top: w.top,
|
|
673
|
+
width: w.width,
|
|
674
|
+
height: w.height,
|
|
675
|
+
tabs: (w.tabs || []).length,
|
|
676
|
+
// Una finestra le cui schede sono tutte chrome:// o chrome-untrusted:// non
|
|
677
|
+
// può fornire l'area del monitor a tile_windows: meglio saperlo prima di
|
|
678
|
+
// provarci.
|
|
679
|
+
scriptable: (w.tabs || []).some((t) => /^(https?|file):/i.test(t.url || '')),
|
|
680
|
+
}));
|
|
681
|
+
return { tabs: list, windows };
|
|
656
682
|
}
|
|
657
683
|
|
|
658
684
|
async function cmdNavigate({ url, tab_id }) {
|
|
@@ -930,6 +956,145 @@ async function cmdCreateTab({ url, active = true }) {
|
|
|
930
956
|
|
|
931
957
|
// --- tab_action ---
|
|
932
958
|
|
|
959
|
+
// --- move_tab ---
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Sposta una scheda in un'altra finestra senza ricrearla: la scheda conserva id,
|
|
963
|
+
* cronologia e stato della pagina.
|
|
964
|
+
*
|
|
965
|
+
* Motivo per cui esiste: sul Terminale ChromeOS garcon apre ogni sessione in una
|
|
966
|
+
* finestra separata, e consolidarle richiede di spostare schede
|
|
967
|
+
* `chrome-untrusted://terminal`. Su quello schema `chrome.tabs.create` è vietato;
|
|
968
|
+
* `move` non ricrea nulla, quindi potrebbe essere permesso. Se Chrome rifiuta, il
|
|
969
|
+
* messaggio viene propagato **testuale**: è il dato che si sta cercando, e
|
|
970
|
+
* riscriverlo lo distruggerebbe.
|
|
971
|
+
*/
|
|
972
|
+
// --- tile_windows ---
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Affianca le finestre di Chrome su un monitor, dividendone l'area utile in
|
|
976
|
+
* parti uguali che non lasciano spazi.
|
|
977
|
+
*
|
|
978
|
+
* L'area del monitor si legge da `screen.availLeft/availTop/availWidth/
|
|
979
|
+
* availHeight` eseguito in una scheda di una finestra che ci sta sopra. La via
|
|
980
|
+
* "giusta" sarebbe chrome.system.display, ma richiede un permesso install-time:
|
|
981
|
+
* su un'estensione già pubblicata significa una nuova revisione e il possibile
|
|
982
|
+
* ri-consenso degli utenti, per un dato che il DOM offre gratis.
|
|
983
|
+
*
|
|
984
|
+
* Il prezzo di questa scelta: serve almeno una scheda scriptabile fra le
|
|
985
|
+
* finestre bersaglio. Una finestra con sole schede chrome:// o
|
|
986
|
+
* chrome-untrusted:// non può fornire l'area, e il tool lo dice invece di
|
|
987
|
+
* ripiegare su misure inventate.
|
|
988
|
+
*/
|
|
989
|
+
async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid', padding = 0, include_types, area: explicitArea }) {
|
|
990
|
+
const types = include_types?.length ? include_types : ['normal'];
|
|
991
|
+
const all = await chrome.windows.getAll({ populate: true });
|
|
992
|
+
|
|
993
|
+
let targets = all.filter((w) => types.includes(w.type));
|
|
994
|
+
if (window_ids?.length) targets = all.filter((w) => window_ids.includes(w.id));
|
|
995
|
+
if (!targets.length) throw new Error(`Nessuna finestra da affiancare (tipi: ${types.join(', ')})`);
|
|
996
|
+
|
|
997
|
+
// Il monitor è quello della finestra di riferimento: su più schermi non
|
|
998
|
+
// esiste "lo schermo" senza dire quale.
|
|
999
|
+
const reference = reference_window_id
|
|
1000
|
+
? targets.find((w) => w.id === reference_window_id) ?? all.find((w) => w.id === reference_window_id)
|
|
1001
|
+
: targets.find((w) => w.focused) ?? targets[0];
|
|
1002
|
+
if (!reference) throw new Error(`Finestra di riferimento ${reference_window_id} non trovata`);
|
|
1003
|
+
|
|
1004
|
+
// Prima scheda scriptabile fra le finestre bersaglio, partendo dalla
|
|
1005
|
+
// riferimento: le pagine interne di Chrome non accettano injection.
|
|
1006
|
+
const candidates = [reference, ...targets.filter((w) => w.id !== reference.id)]
|
|
1007
|
+
.flatMap((w) => (w.tabs || []).map((t) => ({ windowId: w.id, tabId: t.id, url: t.url || '' })))
|
|
1008
|
+
.filter((t) => /^(https?|file):/i.test(t.url));
|
|
1009
|
+
|
|
1010
|
+
// Un'area esplicita salta la lettura da una scheda: è l'unica strada quando
|
|
1011
|
+
// le finestre bersaglio hanno solo pagine interne di Chrome, come quelle del
|
|
1012
|
+
// Terminale ChromeOS.
|
|
1013
|
+
let area = explicitArea ?? null;
|
|
1014
|
+
const failures = [];
|
|
1015
|
+
for (const cand of (area ? [] : candidates)) {
|
|
1016
|
+
try {
|
|
1017
|
+
const [res] = await chrome.scripting.executeScript({
|
|
1018
|
+
target: { tabId: cand.tabId },
|
|
1019
|
+
func: () => ({
|
|
1020
|
+
left: screen.availLeft ?? 0,
|
|
1021
|
+
top: screen.availTop ?? 0,
|
|
1022
|
+
width: screen.availWidth,
|
|
1023
|
+
height: screen.availHeight,
|
|
1024
|
+
}),
|
|
1025
|
+
world: 'MAIN',
|
|
1026
|
+
});
|
|
1027
|
+
if (res?.result?.width) { area = res.result; break; }
|
|
1028
|
+
} catch (e) {
|
|
1029
|
+
failures.push(`${cand.url.slice(0, 40)}: ${e.message}`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (!area) {
|
|
1033
|
+
throw new Error(
|
|
1034
|
+
'Impossibile leggere l\'area del monitor: nessuna scheda scriptabile fra le finestre bersaglio '
|
|
1035
|
+
+ '(le pagine chrome:// e chrome-untrusted:// non lo permettono). '
|
|
1036
|
+
+ `Apri una pagina http(s) in una di esse e riprova.${failures.length ? ' Tentativi: ' + failures.join(' | ') : ''}`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const tiles = computeTiles({ count: targets.length, area, layout, padding });
|
|
1041
|
+
const results = [];
|
|
1042
|
+
|
|
1043
|
+
for (let i = 0; i < targets.length; i++) {
|
|
1044
|
+
const w = targets[i];
|
|
1045
|
+
const tile = tiles[i];
|
|
1046
|
+
try {
|
|
1047
|
+
// Una finestra massimizzata accetta i bounds e li ignora: va riportata a
|
|
1048
|
+
// 'normal' prima, o la chiamata riesce senza che nulla si muova.
|
|
1049
|
+
if (w.state !== 'normal') {
|
|
1050
|
+
await chrome.windows.update(w.id, { state: 'normal' });
|
|
1051
|
+
}
|
|
1052
|
+
const updated = await chrome.windows.update(w.id, tile);
|
|
1053
|
+
const actual = { left: updated.left, top: updated.top, width: updated.width, height: updated.height };
|
|
1054
|
+
results.push({
|
|
1055
|
+
window_id: w.id,
|
|
1056
|
+
type: w.type,
|
|
1057
|
+
requested: tile,
|
|
1058
|
+
actual,
|
|
1059
|
+
// Il window manager può rifiutare in silenzio: senza il confronto, una
|
|
1060
|
+
// finestra rimasta ferma passerebbe per affiancata.
|
|
1061
|
+
applied: actual.left === tile.left && actual.top === tile.top
|
|
1062
|
+
&& actual.width === tile.width && actual.height === tile.height,
|
|
1063
|
+
});
|
|
1064
|
+
} catch (e) {
|
|
1065
|
+
results.push({ window_id: w.id, type: w.type, requested: tile, error: e.message });
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
return {
|
|
1070
|
+
monitor: area,
|
|
1071
|
+
layout,
|
|
1072
|
+
windows: results.length,
|
|
1073
|
+
applied: results.filter((r) => r.applied).length,
|
|
1074
|
+
results,
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
async function cmdMoveTab({ tab_id, window_id, index = -1 }) {
|
|
1079
|
+
if (typeof tab_id !== 'number') throw new Error('Missing required parameter: tab_id');
|
|
1080
|
+
if (typeof window_id !== 'number') throw new Error('Missing required parameter: window_id');
|
|
1081
|
+
|
|
1082
|
+
const before = await chrome.tabs.get(tab_id);
|
|
1083
|
+
const moved = await chrome.tabs.move(tab_id, { windowId: window_id, index });
|
|
1084
|
+
const tab = Array.isArray(moved) ? moved[0] : moved;
|
|
1085
|
+
|
|
1086
|
+
return {
|
|
1087
|
+
moved: tab_id,
|
|
1088
|
+
url: before.url,
|
|
1089
|
+
from_window: before.windowId,
|
|
1090
|
+
to_window: tab?.windowId ?? window_id,
|
|
1091
|
+
index: tab?.index ?? index,
|
|
1092
|
+
// Chrome può accettare la chiamata e lasciare la scheda dov'era: senza questo
|
|
1093
|
+
// il chiamante vedrebbe un successo per un'operazione non avvenuta.
|
|
1094
|
+
same_window: before.windowId === (tab?.windowId ?? window_id),
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
933
1098
|
async function cmdTabAction({ action, tab_id, bypass_cache = false }) {
|
|
934
1099
|
if (!action) throw new Error('Missing required parameter: action');
|
|
935
1100
|
const tabId = await resolveTabId(tab_id);
|
|
@@ -1668,7 +1833,7 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
|
|
|
1668
1833
|
|
|
1669
1834
|
// --- viewport_resize ---
|
|
1670
1835
|
|
|
1671
|
-
async function cmdViewportResize({ preset, width, height, read_only = false, tab_id }) {
|
|
1836
|
+
async function cmdViewportResize({ preset, width, height, left, top, state, read_only = false, tab_id }) {
|
|
1672
1837
|
const tabId = await resolveTabId(tab_id);
|
|
1673
1838
|
const tab = await chrome.tabs.get(tabId);
|
|
1674
1839
|
|
|
@@ -1684,9 +1849,20 @@ async function cmdViewportResize({ preset, width, height, read_only = false, tab
|
|
|
1684
1849
|
const updateOpts = {};
|
|
1685
1850
|
if (targetW) updateOpts.width = targetW;
|
|
1686
1851
|
if (targetH) updateOpts.height = targetH;
|
|
1852
|
+
if (typeof left === 'number') updateOpts.left = left;
|
|
1853
|
+
if (typeof top === 'number') updateOpts.top = top;
|
|
1687
1854
|
|
|
1688
1855
|
if (!read_only) {
|
|
1689
|
-
|
|
1856
|
+
// Lo stato va cambiato PRIMA dei bounds: una finestra massimizzata li
|
|
1857
|
+
// accetta e li ignora, e la chiamata riuscirebbe senza spostare nulla.
|
|
1858
|
+
if (state) await chrome.windows.update(tab.windowId, { state });
|
|
1859
|
+
if (Object.keys(updateOpts).length === 0) {
|
|
1860
|
+
if (state) {
|
|
1861
|
+
const w = await chrome.windows.get(tab.windowId);
|
|
1862
|
+
return { requested: { state }, actual: { left: w.left, top: w.top, width: w.width, height: w.height, state: w.state } };
|
|
1863
|
+
}
|
|
1864
|
+
throw new Error('Provide preset, width, height, left, top or state');
|
|
1865
|
+
}
|
|
1690
1866
|
await chrome.windows.update(tab.windowId, updateOpts);
|
|
1691
1867
|
// La finestra si ridisegna in modo asincrono: senza la pausa si leggono le
|
|
1692
1868
|
// dimensioni vecchie e si riporta un risultato falso.
|
|
@@ -1704,7 +1880,14 @@ async function cmdViewportResize({ preset, width, height, read_only = false, tab
|
|
|
1704
1880
|
});
|
|
1705
1881
|
const actual = results?.[0]?.result ?? {};
|
|
1706
1882
|
if (read_only) return { actual };
|
|
1707
|
-
|
|
1883
|
+
const win = await chrome.windows.get(tab.windowId);
|
|
1884
|
+
return {
|
|
1885
|
+
requested: { width: targetW, height: targetH, preset: preset || null, left, top, state },
|
|
1886
|
+
actual,
|
|
1887
|
+
// Il window manager può accettare e ignorare: senza il confronto una
|
|
1888
|
+
// finestra rimasta ferma passerebbe per spostata.
|
|
1889
|
+
window: { left: win.left, top: win.top, width: win.width, height: win.height, state: win.state },
|
|
1890
|
+
};
|
|
1708
1891
|
}
|
|
1709
1892
|
|
|
1710
1893
|
// --- full_page_screenshot ---
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-bridge-mcp",
|
|
3
3
|
"mcpName": "io.github.frsorrentino/chrome-bridge",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.14.0",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "MCP server with
|
|
6
|
+
"description": "MCP server with 62 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
|
|
7
7
|
"main": "server/index.js",
|
|
8
8
|
"bin": {
|
|
9
9
|
"chrome-bridge-mcp": "server/index.js",
|
package/server/protocol.js
CHANGED
|
@@ -40,6 +40,8 @@ export const MessageType = Object.freeze({
|
|
|
40
40
|
PRESS_KEY: 'press_key',
|
|
41
41
|
GET_FRAMES: 'get_frames',
|
|
42
42
|
TAB_ACTION: 'tab_action',
|
|
43
|
+
MOVE_TAB: 'move_tab',
|
|
44
|
+
TILE_WINDOWS: 'tile_windows',
|
|
43
45
|
UPLOAD_FILE: 'upload_file',
|
|
44
46
|
WAIT_FOR_NAVIGATION: 'wait_for_navigation',
|
|
45
47
|
WAIT_FOR_NETWORK_IDLE: 'wait_for_network_idle',
|
|
@@ -82,7 +84,7 @@ export const MessageType = Object.freeze({
|
|
|
82
84
|
});
|
|
83
85
|
|
|
84
86
|
// Versione
|
|
85
|
-
export const VERSION = '1.
|
|
87
|
+
export const VERSION = '1.14.0';
|
|
86
88
|
|
|
87
89
|
// Configurazione
|
|
88
90
|
export const DEFAULT_PORT = 8765;
|
|
@@ -133,6 +135,7 @@ export function getTimeout(type) {
|
|
|
133
135
|
|| type === MessageType.MANAGE_DOWNLOADS
|
|
134
136
|
|| type === MessageType.SAVE_PAGE
|
|
135
137
|
|| type === MessageType.HTTP_REQUEST
|
|
138
|
+
|| type === MessageType.TILE_WINDOWS
|
|
136
139
|
|| type === MessageType.WAIT_FOR_FUNCTION
|
|
137
140
|
|| type === MessageType.SCROLL_UNTIL) return 60000;
|
|
138
141
|
return COMMAND_TIMEOUT_MS;
|
package/server/tools.js
CHANGED
|
@@ -289,6 +289,8 @@ export const TOOL_ANNOTATIONS = {
|
|
|
289
289
|
set_zoom: rw({ idempotent: true }),
|
|
290
290
|
viewport_resize: rw({ idempotent: true }),
|
|
291
291
|
create_tab: rw({ open: true }),
|
|
292
|
+
move_tab: rw({ idempotent: true }), // rispostare dove è già = stesso esito
|
|
293
|
+
tile_windows: rw({ idempotent: true }),
|
|
292
294
|
navigate: rw({ idempotent: true, open: true }),
|
|
293
295
|
tab_action: rw({ destructive: true, open: true }), // close chiude una tab dell'utente
|
|
294
296
|
|
|
@@ -478,10 +480,15 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
478
480
|
server.tool(
|
|
479
481
|
'get_tabs',
|
|
480
482
|
'List every open tab with id, url, title and active flag. Read-only. Use it to find a tab_id when the '
|
|
481
|
-
+ 'implicit target (last navigated tab, else the active one) is not the tab you mean.'
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
483
|
+
+ 'implicit target (last navigated tab, else the active one) is not the tab you mean. '
|
|
484
|
+
+ 'include_windows adds the windows themselves with their position, size, state and type — what you need '
|
|
485
|
+
+ 'before moving or tiling anything, and the only way to tell which monitor a window is on.',
|
|
486
|
+
{
|
|
487
|
+
include_windows: z.boolean().optional().default(false)
|
|
488
|
+
.describe('Also return the windows with bounds, state, type and tab count'),
|
|
489
|
+
},
|
|
490
|
+
async ({ include_windows }) => {
|
|
491
|
+
const data = await send(MessageType.GET_TABS, { include_windows: include_windows === true });
|
|
485
492
|
return {
|
|
486
493
|
content: [{
|
|
487
494
|
type: 'text',
|
|
@@ -1026,12 +1033,16 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1026
1033
|
{
|
|
1027
1034
|
action: z.enum(['set', 'get']).optional().default('set').describe('get reports the current viewport without resizing anything'),
|
|
1028
1035
|
preset: z.enum(['mobile', 'tablet', 'desktop']).optional().describe('375x812, 768x1024, 1440x900'),
|
|
1036
|
+
left: z.number().optional().describe('Window x on the virtual desktop; on multi-monitor this is what picks the screen'),
|
|
1037
|
+
top: z.number().optional().describe('Window y on the virtual desktop'),
|
|
1038
|
+
state: z.enum(['normal', 'maximized', 'fullscreen', 'minimized']).optional()
|
|
1039
|
+
.describe('Applied before bounds: a maximized window accepts left/top/width/height and ignores them'),
|
|
1029
1040
|
width: z.number().optional().describe('Overrides preset'),
|
|
1030
1041
|
height: z.number().optional().describe('Overrides preset'),
|
|
1031
1042
|
tab_id: tabId,
|
|
1032
1043
|
},
|
|
1033
|
-
async ({ action, preset, width, height, tab_id }) => {
|
|
1034
|
-
const data = await send(MessageType.VIEWPORT_RESIZE, { preset, width, height, read_only: (action ?? 'set') === 'get', tab_id });
|
|
1044
|
+
async ({ action, preset, width, height, left, top, state, tab_id }) => {
|
|
1045
|
+
const data = await send(MessageType.VIEWPORT_RESIZE, { preset, width, height, left, top, state, read_only: (action ?? 'set') === 'get', tab_id });
|
|
1035
1046
|
return {
|
|
1036
1047
|
content: [{
|
|
1037
1048
|
type: 'text',
|
|
@@ -1731,6 +1742,50 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1731
1742
|
}
|
|
1732
1743
|
);
|
|
1733
1744
|
|
|
1745
|
+
// --- move_tab ---
|
|
1746
|
+
server.tool(
|
|
1747
|
+
'move_tab',
|
|
1748
|
+
'Move an existing tab into another window (chrome.tabs.move), e.g. to consolidate windows that were '
|
|
1749
|
+
+ 'each opened separately. Does not create or close anything: the tab keeps its id, its history and its '
|
|
1750
|
+
+ 'page state. Both windows must be normal browser windows — Chrome refuses to move a tab into a popup or '
|
|
1751
|
+
+ 'an app window, and the refusal is reported verbatim rather than retried.',
|
|
1752
|
+
{
|
|
1753
|
+
tab_id: z.number().describe('Tab to move; get it from get_tabs'),
|
|
1754
|
+
window_id: z.number().describe('Destination window; get_tabs reports windowId for every tab'),
|
|
1755
|
+
index: z.number().optional().default(-1).describe('Position in the destination window; -1 appends at the end'),
|
|
1756
|
+
},
|
|
1757
|
+
async ({ tab_id, window_id, index }) => {
|
|
1758
|
+
const data = await send(MessageType.MOVE_TAB, { tab_id, window_id, index: index ?? -1 });
|
|
1759
|
+
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
1760
|
+
}
|
|
1761
|
+
);
|
|
1762
|
+
|
|
1763
|
+
// --- tile_windows ---
|
|
1764
|
+
server.tool(
|
|
1765
|
+
'tile_windows',
|
|
1766
|
+
'Tile Chrome windows over one monitor, splitting its usable area into equal parts that leave no gap. '
|
|
1767
|
+
+ 'Only Chrome windows: an extension cannot touch other applications. The monitor is chosen by pointing at '
|
|
1768
|
+
+ 'a window already on it, since the work area is read from a page there — so at least one target window '
|
|
1769
|
+
+ 'needs a scriptable tab (a chrome:// or chrome-untrusted:// tab cannot provide it). Maximized windows are '
|
|
1770
|
+
+ 'restored first, because a maximized window accepts bounds and ignores them.',
|
|
1771
|
+
{
|
|
1772
|
+
window_ids: z.array(z.number()).optional().describe('Windows to tile; omitted = every normal window on the reference monitor'),
|
|
1773
|
+
reference_window_id: z.number().optional().describe('Window whose monitor is used; omitted = the focused one'),
|
|
1774
|
+
layout: z.enum(['grid', 'columns', 'rows']).optional().default('grid').describe('columns splits left to right, rows top to bottom, grid keeps tiles as square as it can'),
|
|
1775
|
+
padding: z.number().optional().default(0).describe('Px of empty margin kept inside the work area'),
|
|
1776
|
+
include_types: z.array(z.enum(['normal', 'popup', 'app'])).optional().describe('Window types to include; omitted = normal only'),
|
|
1777
|
+
area: z.object({
|
|
1778
|
+
left: z.number(), top: z.number(), width: z.number(), height: z.number(),
|
|
1779
|
+
}).optional().describe('Monitor area to fill, when no target window has a scriptable tab to read it from'),
|
|
1780
|
+
},
|
|
1781
|
+
async ({ window_ids, reference_window_id, layout, padding, include_types, area }) => {
|
|
1782
|
+
const data = await send(MessageType.TILE_WINDOWS, {
|
|
1783
|
+
window_ids, reference_window_id, layout: layout ?? 'grid', padding: padding ?? 0, include_types, area,
|
|
1784
|
+
});
|
|
1785
|
+
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
1786
|
+
}
|
|
1787
|
+
);
|
|
1788
|
+
|
|
1734
1789
|
// --- set_zoom ---
|
|
1735
1790
|
server.tool(
|
|
1736
1791
|
'set_zoom',
|
package/tools/cws-upload.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* Uso:
|
|
14
14
|
* node tools/cws-upload.mjs dist/chrome-bridge-extension-1.11.0.zip # carica come bozza
|
|
15
15
|
* node tools/cws-upload.mjs dist/....zip --publish # carica e pubblica
|
|
16
|
+
* node tools/cws-upload.mjs --status # stato della bozza sullo store
|
|
16
17
|
* node tools/cws-upload.mjs --auth [porta] # URL di consenso + cattura del codice sul loopback
|
|
17
18
|
* node tools/cws-upload.mjs --exchange <code> [--redirect <uri>] # code → refresh token
|
|
18
19
|
*/
|
|
@@ -86,6 +87,24 @@ export function interpretUpload(body) {
|
|
|
86
87
|
return { ok: false, state: state ?? 'UNKNOWN', reasons };
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Distingue un'attesa da un guasto.
|
|
92
|
+
*
|
|
93
|
+
* Se un ritentativo periodico tratta "c'è una versione in revisione" come
|
|
94
|
+
* errore, ogni giro produce una notifica rossa per una situazione normale — e in
|
|
95
|
+
* due giorni nessuno guarda più le notifiche, incluse quelle vere.
|
|
96
|
+
*/
|
|
97
|
+
export function classifyFailure(reasons = []) {
|
|
98
|
+
const text = reasons.join(' ').toLowerCase();
|
|
99
|
+
const waiting = [
|
|
100
|
+
'in pending review',
|
|
101
|
+
'is in review',
|
|
102
|
+
'ready to publish',
|
|
103
|
+
'version number is the same as the published one',
|
|
104
|
+
];
|
|
105
|
+
return waiting.some((w) => text.includes(w)) ? 'locked' : 'error';
|
|
106
|
+
}
|
|
107
|
+
|
|
89
108
|
export function interpretPublish(body) {
|
|
90
109
|
const status = body?.status || [];
|
|
91
110
|
const ok = status.includes('OK') || status.includes('PUBLISHED_WITH_FRICTION_WARNING');
|
|
@@ -161,6 +180,36 @@ async function main() {
|
|
|
161
180
|
return;
|
|
162
181
|
}
|
|
163
182
|
|
|
183
|
+
if (argv.includes('--publish-only')) {
|
|
184
|
+
// Serve a distinguere due stati che l'API confonde in un solo messaggio di
|
|
185
|
+
// errore: "in revisione" (non si può fare nulla, si aspetta) e "pronta da
|
|
186
|
+
// pubblicare" (la revisione è passata e manca solo questa chiamata).
|
|
187
|
+
requireFields(cfg, ['client_id', 'client_secret', 'refresh_token', 'item_id']);
|
|
188
|
+
const token = await accessToken(cfg);
|
|
189
|
+
const res = await fetch(`https://www.googleapis.com/chromewebstore/v1.1/items/${cfg.item_id}/publish`, {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: { Authorization: `Bearer ${token}`, 'x-goog-api-version': '2', 'Content-Length': '0' },
|
|
192
|
+
});
|
|
193
|
+
const body = await res.json();
|
|
194
|
+
console.log(JSON.stringify({ http: res.status, ...body }, null, 2));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (argv.includes('--status')) {
|
|
199
|
+
requireFields(cfg, ['client_id', 'client_secret', 'refresh_token', 'item_id']);
|
|
200
|
+
const token = await accessToken(cfg);
|
|
201
|
+
const res = await fetch(
|
|
202
|
+
`https://www.googleapis.com/chromewebstore/v1.1/items/${cfg.item_id}?projection=DRAFT`,
|
|
203
|
+
{ headers: { Authorization: `Bearer ${token}`, 'x-goog-api-version': '2' } },
|
|
204
|
+
);
|
|
205
|
+
const body = await res.json();
|
|
206
|
+
// uploadState dice se la bozza esiste e in che stato è; crxVersion dice quale
|
|
207
|
+
// versione è nel cassetto, che è l'unico modo di distinguere "in revisione"
|
|
208
|
+
// da "mai caricata" senza guardare la vetrina.
|
|
209
|
+
console.log(JSON.stringify({ http: res.status, ...body }, null, 2));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
164
213
|
const zip = argv.find((a) => a.endsWith('.zip'));
|
|
165
214
|
if (!zip) throw new Error('Indica lo zip da caricare, o usa --auth / --exchange');
|
|
166
215
|
requireFields(cfg, ['client_id', 'client_secret', 'refresh_token', 'item_id']);
|
|
@@ -176,7 +225,13 @@ async function main() {
|
|
|
176
225
|
const upBody = await up.json();
|
|
177
226
|
const upRes = interpretUpload(upBody);
|
|
178
227
|
console.log(`upload ${zip} (${size} B): ${upRes.ok ? 'SUCCESS' : `FALLITO [${upRes.state}] ${upRes.reasons.join(' | ')}`}`);
|
|
179
|
-
if (!upRes.ok)
|
|
228
|
+
if (!upRes.ok) {
|
|
229
|
+
// 2 = si riproverà, 1 = serve una persona. Il ritentativo periodico
|
|
230
|
+
// distingue i due casi da qui.
|
|
231
|
+
const kind = classifyFailure(upRes.reasons);
|
|
232
|
+
console.log(`esito: ${kind === 'locked' ? 'ATTESA (si ritenta più tardi)' : 'ERRORE (serve intervento)'}`);
|
|
233
|
+
process.exit(kind === 'locked' ? 2 : 1);
|
|
234
|
+
}
|
|
180
235
|
|
|
181
236
|
if (!argv.includes('--publish')) {
|
|
182
237
|
console.log('bozza caricata; ripassa con --publish per mandarla in review');
|