chrome-bridge-mcp 1.14.0 → 1.15.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 +5 -5
- package/extension/manifest.json +2 -2
- package/extension/service-worker.js +65 -4
- package/package.json +2 -2
- package/server/protocol.js +1 -1
- package/server/tools.js +157 -10
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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
6
|
2.75× fewer turns and 2.28× lower cost than the official "Claude in Chrome"
|
|
7
7
|
extension on a form-filling task, with ~3× the toolset and no paid plan.**
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
63 web-development tools (navigation, DOM inspection, visual regression, audits,
|
|
10
10
|
network mocking) over a local WebSocket bridge, plus a headless instance for CI.
|
|
11
11
|
Self-hosted, local-only.
|
|
12
12
|
|
|
@@ -43,7 +43,7 @@ Chrome Bridge → Details (on Chrome 135-137, enable Developer Mode instead).
|
|
|
43
43
|
| | Chrome Bridge | Claude in Chrome | Chrome DevTools MCP | Playwright MCP |
|
|
44
44
|
|---|---|---|---|---|
|
|
45
45
|
| **ChromeOS / Crostini** | **Yes** (real host) | No | Container only | Container only |
|
|
46
|
-
| **Tools** | **
|
|
46
|
+
| **Tools** | **63** (34 core) | ~20 | ~50 | 23 core (71 total) |
|
|
47
47
|
| **Requires paid plan** | **No** | Yes (Pro+) | No | No |
|
|
48
48
|
| **Network mocking** | **Yes** (stub/headers) | No | No | Yes |
|
|
49
49
|
| **Visual regression** | **Yes** (`screenshot_diff`) | No | No | No |
|
|
@@ -87,7 +87,7 @@ user-script toggle isn't available.
|
|
|
87
87
|
|
|
88
88
|
## Tools
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
63 in total, in seven groups. Only `core` (34 tools) loads by default; the rest
|
|
91
91
|
are opt-in via `--caps`.
|
|
92
92
|
|
|
93
93
|
| Group | N | What's in it |
|
|
@@ -153,7 +153,7 @@ paste into a chat.
|
|
|
153
153
|
|
|
154
154
|
## Documentation
|
|
155
155
|
|
|
156
|
-
- [docs/TOOLS.md](docs/TOOLS.md) — all
|
|
156
|
+
- [docs/TOOLS.md](docs/TOOLS.md) — all 63 tools, by group
|
|
157
157
|
- [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
|
|
158
158
|
- [bench/RESULTS.md](bench/RESULTS.md) — raw runs and inclusion rule
|
|
159
159
|
- [CHANGELOG.md](CHANGELOG.md)
|
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.15.0",
|
|
5
|
+
"description": "Bridge your browser to Claude Code: 63 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": [
|
|
@@ -941,7 +941,24 @@ async function cmdReadPage({ mode = 'text', tab_id, frame_id }) {
|
|
|
941
941
|
|
|
942
942
|
// --- create_tab ---
|
|
943
943
|
|
|
944
|
-
async function cmdCreateTab({ url, active = true }) {
|
|
944
|
+
async function cmdCreateTab({ url, active = true, new_window = false, left, top, width, height }) {
|
|
945
|
+
if (new_window) {
|
|
946
|
+
// windows.create con i bounds apre direttamente sul monitor scelto: left è
|
|
947
|
+
// la coordinata sulla scrivania virtuale, quindi è ciò che sceglie lo schermo.
|
|
948
|
+
const winOpts = { focused: active !== false };
|
|
949
|
+
if (url) winOpts.url = url;
|
|
950
|
+
for (const [k, v] of [['left', left], ['top', top], ['width', width], ['height', height]]) {
|
|
951
|
+
if (typeof v === 'number') winOpts[k] = v;
|
|
952
|
+
}
|
|
953
|
+
const win = await chrome.windows.create(winOpts);
|
|
954
|
+
const tab = (win.tabs || [])[0];
|
|
955
|
+
if (url && tab) {
|
|
956
|
+
await waitForComplete(tab.id);
|
|
957
|
+
const updated = await chrome.tabs.get(tab.id);
|
|
958
|
+
return { id: updated.id, url: updated.url, title: updated.title, window_id: win.id };
|
|
959
|
+
}
|
|
960
|
+
return { id: tab?.id, url: tab?.url || 'chrome://newtab', title: tab?.title || '', window_id: win.id };
|
|
961
|
+
}
|
|
945
962
|
const opts = { active };
|
|
946
963
|
if (url) opts.url = url;
|
|
947
964
|
const tab = await chrome.tabs.create(opts);
|
|
@@ -1075,11 +1092,30 @@ async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid'
|
|
|
1075
1092
|
};
|
|
1076
1093
|
}
|
|
1077
1094
|
|
|
1078
|
-
async function cmdMoveTab({ tab_id, window_id, index = -1 }) {
|
|
1095
|
+
async function cmdMoveTab({ tab_id, window_id, new_window = false, left, top, width, height, index = -1 }) {
|
|
1079
1096
|
if (typeof tab_id !== 'number') throw new Error('Missing required parameter: tab_id');
|
|
1080
|
-
if (typeof window_id !== 'number') throw new Error('
|
|
1097
|
+
if (typeof window_id !== 'number' && !new_window) throw new Error('Provide window_id or new_window: true');
|
|
1081
1098
|
|
|
1082
1099
|
const before = await chrome.tabs.get(tab_id);
|
|
1100
|
+
|
|
1101
|
+
if (new_window) {
|
|
1102
|
+
// tabs.move vuole una finestra che esiste già; per estrarre in una finestra
|
|
1103
|
+
// nuova (e posizionata) l'unica via è windows.create({tabId}).
|
|
1104
|
+
const winOpts = { tabId: tab_id };
|
|
1105
|
+
for (const [k, v] of [['left', left], ['top', top], ['width', width], ['height', height]]) {
|
|
1106
|
+
if (typeof v === 'number') winOpts[k] = v;
|
|
1107
|
+
}
|
|
1108
|
+
const win = await chrome.windows.create(winOpts);
|
|
1109
|
+
return {
|
|
1110
|
+
moved: tab_id,
|
|
1111
|
+
url: before.url,
|
|
1112
|
+
from_window: before.windowId,
|
|
1113
|
+
to_window: win.id,
|
|
1114
|
+
new_window: true,
|
|
1115
|
+
index: 0,
|
|
1116
|
+
same_window: before.windowId === win.id,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1083
1119
|
const moved = await chrome.tabs.move(tab_id, { windowId: window_id, index });
|
|
1084
1120
|
const tab = Array.isArray(moved) ? moved[0] : moved;
|
|
1085
1121
|
|
|
@@ -1103,6 +1139,21 @@ async function cmdTabAction({ action, tab_id, bypass_cache = false }) {
|
|
|
1103
1139
|
await chrome.tabs.remove(tabId);
|
|
1104
1140
|
return { action, closed: tabId };
|
|
1105
1141
|
}
|
|
1142
|
+
if (action === 'discard') {
|
|
1143
|
+
// La scheda resta nella barra e si ricarica al focus: il processo muore, la
|
|
1144
|
+
// RAM torna. La tmux session dietro un terminale non c'entra: vive nel
|
|
1145
|
+
// container, non nel renderer.
|
|
1146
|
+
const t = await chrome.tabs.discard(tabId);
|
|
1147
|
+
return { action, tabId: t?.id ?? tabId, discarded: true };
|
|
1148
|
+
}
|
|
1149
|
+
if (action === 'mute' || action === 'unmute') {
|
|
1150
|
+
await chrome.tabs.update(tabId, { muted: action === 'mute' });
|
|
1151
|
+
return { action, tabId, muted: action === 'mute' };
|
|
1152
|
+
}
|
|
1153
|
+
if (action === 'duplicate') {
|
|
1154
|
+
const d = await chrome.tabs.duplicate(tabId);
|
|
1155
|
+
return { action, source: tabId, duplicated: d.id, window_id: d.windowId };
|
|
1156
|
+
}
|
|
1106
1157
|
if (action === 'activate') {
|
|
1107
1158
|
const tab = await chrome.tabs.get(tabId);
|
|
1108
1159
|
await chrome.tabs.update(tabId, { active: true });
|
|
@@ -3626,9 +3677,19 @@ async function cmdSetGeolocation({ latitude, longitude, accuracy = 10, reset = f
|
|
|
3626
3677
|
|
|
3627
3678
|
// --- manage_downloads ---
|
|
3628
3679
|
|
|
3629
|
-
async function cmdManageDownloads({ action, timeout = 30000, limit = 10 }) {
|
|
3680
|
+
async function cmdManageDownloads({ action, url, filename, timeout = 30000, limit = 10 }) {
|
|
3630
3681
|
if (!action) throw new Error('Missing required parameter: action');
|
|
3631
3682
|
|
|
3683
|
+
if (action === 'download') {
|
|
3684
|
+
if (!url) throw new Error('action=download requires url');
|
|
3685
|
+
// Parte dal browser, quindi con i cookie di sessione dell'origine: un file
|
|
3686
|
+
// dietro login si scarica senza far passare i byte dal bridge.
|
|
3687
|
+
const opts = { url, saveAs: false };
|
|
3688
|
+
if (filename) opts.filename = filename;
|
|
3689
|
+
const id = await chrome.downloads.download(opts);
|
|
3690
|
+
return { started: true, id, url };
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3632
3693
|
if (action === 'list') {
|
|
3633
3694
|
const items = await chrome.downloads.search({ orderBy: ['-startTime'], limit });
|
|
3634
3695
|
return {
|
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.15.0",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "MCP server with
|
|
6
|
+
"description": "MCP server with 63 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
package/server/tools.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
|
-
import { basename, extname, join } from 'node:path';
|
|
10
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
11
11
|
import { parse as parseHtml } from 'node-html-parser';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { runAssert } from './assertions.js';
|
|
@@ -19,6 +19,9 @@ import { evaluateSecurityHeaders } from './security-headers.js';
|
|
|
19
19
|
import { consoleLines, networkLines, interactivesLines, linksLines } from './formatters.js';
|
|
20
20
|
|
|
21
21
|
const SESSIONS_DIR = join(homedir(), '.config', 'chrome-bridge', 'sessions');
|
|
22
|
+
// Sovrascrivibile nei test: i layout sono un file solo, non una directory.
|
|
23
|
+
const LAYOUTS_FILE = process.env.CHROME_BRIDGE_LAYOUTS_FILE
|
|
24
|
+
|| join(homedir(), '.config', 'chrome-bridge', 'layouts.json');
|
|
22
25
|
const RECORDINGS_DIR = process.env.CHROME_BRIDGE_RECORD_DIR || join(homedir(), '.config', 'chrome-bridge', 'recordings');
|
|
23
26
|
|
|
24
27
|
// Comandi rumore per un replay: letture interne (tabSnapshot) o senza effetto
|
|
@@ -291,6 +294,7 @@ export const TOOL_ANNOTATIONS = {
|
|
|
291
294
|
create_tab: rw({ open: true }),
|
|
292
295
|
move_tab: rw({ idempotent: true }), // rispostare dove è già = stesso esito
|
|
293
296
|
tile_windows: rw({ idempotent: true }),
|
|
297
|
+
window_layout: rw({ destructive: true, idempotent: true }), // save sovrascrive l'omonimo, restore sposta finestre
|
|
294
298
|
navigate: rw({ idempotent: true, open: true }),
|
|
295
299
|
tab_action: rw({ destructive: true, open: true }), // close chiude una tab dell'utente
|
|
296
300
|
|
|
@@ -876,9 +880,14 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
876
880
|
{
|
|
877
881
|
url: z.string().optional().describe('URL to open (default: new tab page)'),
|
|
878
882
|
active: z.boolean().optional().default(true).describe('false opens the tab in the background, leaving the current one focused'),
|
|
883
|
+
new_window: z.boolean().optional().default(false).describe('Open in a fresh window instead of a tab; with left/top it lands on the chosen monitor'),
|
|
884
|
+
left: z.number().optional().describe('Window x on the virtual desktop (new_window)'),
|
|
885
|
+
top: z.number().optional().describe('Window y (new_window)'),
|
|
886
|
+
width: z.number().optional().describe('Window width px (new_window)'),
|
|
887
|
+
height: z.number().optional().describe('Window height px (new_window)'),
|
|
879
888
|
},
|
|
880
|
-
async ({ url, active }) => {
|
|
881
|
-
const data = await send(MessageType.CREATE_TAB, { url, active });
|
|
889
|
+
async ({ url, active, new_window, left, top, width, height }) => {
|
|
890
|
+
const data = await send(MessageType.CREATE_TAB, { url, active, new_window: new_window === true || undefined, left, top, width, height });
|
|
882
891
|
if (data?.id != null) sessionTabId = data.id;
|
|
883
892
|
return {
|
|
884
893
|
content: [{
|
|
@@ -1342,7 +1351,7 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1342
1351
|
+ 'tab and anything unsaved in it, and cannot be undone — it may be a tab the user is working in. '
|
|
1343
1352
|
+ 'reload and navigation drop injected CSS, emulations and page hooks.',
|
|
1344
1353
|
{
|
|
1345
|
-
action: z.enum(['close', 'activate', 'reload', 'back', 'forward']).describe('close cannot be undone; reload drops injected CSS and
|
|
1354
|
+
action: z.enum(['close', 'activate', 'reload', 'back', 'forward', 'discard', 'mute', 'unmute', 'duplicate']).describe('close cannot be undone; discard frees memory, the tab reloads on focus; reload drops injected CSS and hooks'),
|
|
1346
1355
|
bypass_cache: z.boolean().optional().default(false).describe('reload only'),
|
|
1347
1356
|
tab_id: tabId,
|
|
1348
1357
|
},
|
|
@@ -1659,12 +1668,15 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1659
1668
|
'manage_downloads',
|
|
1660
1669
|
'List downloads or wait for one to complete. Files land in the browser Downloads folder, not on the server.',
|
|
1661
1670
|
{
|
|
1662
|
-
action: z.enum(['list', 'wait_for_complete']).describe('wait_for_complete blocks until the newest
|
|
1671
|
+
action: z.enum(['list', 'wait_for_complete', 'download']).describe('download starts one with the browser cookie jar; wait_for_complete blocks until the newest finishes'),
|
|
1672
|
+
url: z.string().optional().describe('What to download (action=download); sent with the session cookies of its origin'),
|
|
1673
|
+
filename: z.string().optional().describe('Relative path inside the Downloads folder (action=download)'),
|
|
1663
1674
|
timeout: z.number().optional().default(30000).describe('Max ms (wait_for_complete)'),
|
|
1664
1675
|
limit: z.number().optional().default(10).describe('Max download entries returned, newest first'),
|
|
1665
1676
|
},
|
|
1666
|
-
async ({ action, timeout, limit }) => {
|
|
1667
|
-
|
|
1677
|
+
async ({ action, url, filename, timeout, limit }) => {
|
|
1678
|
+
if (action === 'download' && !url) throw new Error('action=download requires url');
|
|
1679
|
+
const data = await send(MessageType.MANAGE_DOWNLOADS, { action, url, filename, timeout, limit });
|
|
1668
1680
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
1669
1681
|
}
|
|
1670
1682
|
);
|
|
@@ -1751,11 +1763,19 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1751
1763
|
+ 'an app window, and the refusal is reported verbatim rather than retried.',
|
|
1752
1764
|
{
|
|
1753
1765
|
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'),
|
|
1766
|
+
window_id: z.number().optional().describe('Destination window; get_tabs reports windowId for every tab'),
|
|
1767
|
+
new_window: z.boolean().optional().default(false).describe('Extract the tab into a fresh window instead — tabs.move needs an existing window, this does not'),
|
|
1768
|
+
left: z.number().optional().describe('New window x (new_window)'),
|
|
1769
|
+
top: z.number().optional().describe('New window y (new_window)'),
|
|
1770
|
+
width: z.number().optional().describe('New window width px (new_window)'),
|
|
1771
|
+
height: z.number().optional().describe('New window height px (new_window)'),
|
|
1755
1772
|
index: z.number().optional().default(-1).describe('Position in the destination window; -1 appends at the end'),
|
|
1756
1773
|
},
|
|
1757
|
-
async ({ tab_id, window_id, index }) => {
|
|
1758
|
-
|
|
1774
|
+
async ({ tab_id, window_id, new_window, left, top, width, height, index }) => {
|
|
1775
|
+
if (window_id == null && new_window !== true) {
|
|
1776
|
+
throw new Error('Provide window_id (an existing window) or new_window: true');
|
|
1777
|
+
}
|
|
1778
|
+
const data = await send(MessageType.MOVE_TAB, { tab_id, window_id, new_window: new_window === true || undefined, left, top, width, height, index: index ?? -1 });
|
|
1759
1779
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
1760
1780
|
}
|
|
1761
1781
|
);
|
|
@@ -1786,6 +1806,133 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1786
1806
|
}
|
|
1787
1807
|
);
|
|
1788
1808
|
|
|
1809
|
+
// --- window_layout ---
|
|
1810
|
+
server.tool(
|
|
1811
|
+
'window_layout',
|
|
1812
|
+
'Save the current window arrangement under a name, restore one, list or delete saved ones. save overwrites '
|
|
1813
|
+
+ 'a same-named layout without asking. Window ids do not survive a browser restart, so restore recognises '
|
|
1814
|
+
+ 'windows by the overlap of their tab URLs (same type required) and repositions the best match — windows '
|
|
1815
|
+
+ 'it cannot recognise are reported, not guessed. Needs extension >= 1.14.0 for window geometry.',
|
|
1816
|
+
{
|
|
1817
|
+
action: z.enum(['save', 'restore', 'list', 'delete']).describe('save snapshots every window; restore repositions the recognised ones'),
|
|
1818
|
+
name: z.string().optional().describe('Layout name, required except for list'),
|
|
1819
|
+
},
|
|
1820
|
+
async ({ action, name }) => {
|
|
1821
|
+
const readLayouts = async () => {
|
|
1822
|
+
try { return JSON.parse(await readFile(LAYOUTS_FILE, 'utf8')); } catch { return {}; }
|
|
1823
|
+
};
|
|
1824
|
+
|
|
1825
|
+
if (action === 'list') {
|
|
1826
|
+
const all = await readLayouts();
|
|
1827
|
+
const rows = Object.entries(all).map(([n, l]) => ({ name: n, windows: l.windows.length, savedAt: l.savedAt }));
|
|
1828
|
+
return { content: [{ type: 'text', text: jsonText({ layouts: rows }) }] };
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
if (!name || !/^[\w-]+$/.test(name)) throw new Error('name is required and must match [\\w-]+');
|
|
1832
|
+
|
|
1833
|
+
if (action === 'delete') {
|
|
1834
|
+
const all = await readLayouts();
|
|
1835
|
+
const existed = Boolean(all[name]);
|
|
1836
|
+
delete all[name];
|
|
1837
|
+
await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
|
|
1838
|
+
await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
|
|
1839
|
+
return { content: [{ type: 'text', text: jsonText({ deleted: name, existed }) }] };
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// save e restore hanno bisogno della fotografia corrente
|
|
1843
|
+
const snap = await send(MessageType.GET_TABS, { include_windows: true });
|
|
1844
|
+
if (Array.isArray(snap) || !snap?.windows) {
|
|
1845
|
+
// Un'estensione < 1.14.0 risponde con il solo array di schede: meglio
|
|
1846
|
+
// dirlo che fallire su una proprietà mancante.
|
|
1847
|
+
throw new Error('window_layout needs extension >= 1.14.0: get_tabs returned no window geometry');
|
|
1848
|
+
}
|
|
1849
|
+
const urlsByWindow = new Map();
|
|
1850
|
+
for (const t of snap.tabs || []) {
|
|
1851
|
+
if (!urlsByWindow.has(t.windowId)) urlsByWindow.set(t.windowId, []);
|
|
1852
|
+
urlsByWindow.get(t.windowId).push(t.url || '');
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
if (action === 'save') {
|
|
1856
|
+
const all = await readLayouts();
|
|
1857
|
+
all[name] = {
|
|
1858
|
+
savedAt: new Date().toISOString(),
|
|
1859
|
+
windows: snap.windows.map((w) => ({
|
|
1860
|
+
type: w.type,
|
|
1861
|
+
state: w.state,
|
|
1862
|
+
left: w.left, top: w.top, width: w.width, height: w.height,
|
|
1863
|
+
tabs: urlsByWindow.get(w.id) || [],
|
|
1864
|
+
})),
|
|
1865
|
+
};
|
|
1866
|
+
await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
|
|
1867
|
+
await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
|
|
1868
|
+
return { content: [{ type: 'text', text: jsonText({ saved: name, windows: all[name].windows.length }) }] };
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// restore
|
|
1872
|
+
const all = await readLayouts();
|
|
1873
|
+
const layout = all[name];
|
|
1874
|
+
if (!layout) {
|
|
1875
|
+
throw new Error(`Layout "${name}" not found — saved: ${Object.keys(all).join(', ') || 'none'}`);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// Matching: sovrapposizione degli URL (Jaccard), a parità di tipo. Greedy
|
|
1879
|
+
// sulla coppia migliore. Gli id non entrano mai: non sopravvivono al
|
|
1880
|
+
// riavvio del browser.
|
|
1881
|
+
const current = snap.windows.map((w) => ({ ...w, urls: new Set(urlsByWindow.get(w.id) || []) }));
|
|
1882
|
+
const pairs = [];
|
|
1883
|
+
layout.windows.forEach((savedWin, si) => {
|
|
1884
|
+
current.forEach((cur, ci) => {
|
|
1885
|
+
if (savedWin.type !== cur.type) return;
|
|
1886
|
+
const savedUrls = new Set(savedWin.tabs);
|
|
1887
|
+
let shared = 0;
|
|
1888
|
+
for (const u of savedUrls) if (cur.urls.has(u)) shared += 1;
|
|
1889
|
+
const union = new Set([...savedUrls, ...cur.urls]).size || 1;
|
|
1890
|
+
const score = shared / union;
|
|
1891
|
+
if (score > 0) pairs.push({ si, ci, score });
|
|
1892
|
+
});
|
|
1893
|
+
});
|
|
1894
|
+
pairs.sort((a, b) => b.score - a.score);
|
|
1895
|
+
|
|
1896
|
+
const usedSaved = new Set();
|
|
1897
|
+
const usedCurrent = new Set();
|
|
1898
|
+
const results = [];
|
|
1899
|
+
for (const { si, ci, score } of pairs) {
|
|
1900
|
+
if (usedSaved.has(si) || usedCurrent.has(ci)) continue;
|
|
1901
|
+
usedSaved.add(si); usedCurrent.add(ci);
|
|
1902
|
+
const savedWin = layout.windows[si];
|
|
1903
|
+
const cur = current[ci];
|
|
1904
|
+
const anyTab = (snap.tabs || []).find((t) => t.windowId === cur.id);
|
|
1905
|
+
if (!anyTab) { results.push({ window_id: cur.id, error: 'no tab to address the window with' }); continue; }
|
|
1906
|
+
// Una finestra da massimizzare riceve SOLO lo stato: i bounds verrebbero
|
|
1907
|
+
// accettati e ignorati, e il confronto richiesto/ottenuto mentirebbe.
|
|
1908
|
+
const params = savedWin.state === 'normal'
|
|
1909
|
+
? { tab_id: anyTab.id, left: savedWin.left, top: savedWin.top, width: savedWin.width, height: savedWin.height, state: 'normal' }
|
|
1910
|
+
: { tab_id: anyTab.id, state: savedWin.state };
|
|
1911
|
+
try {
|
|
1912
|
+
const r = await send(MessageType.VIEWPORT_RESIZE, params);
|
|
1913
|
+
results.push({ window_id: cur.id, score: Number(score.toFixed(2)), requested: params, window: r?.window ?? r?.actual ?? null });
|
|
1914
|
+
} catch (e) {
|
|
1915
|
+
results.push({ window_id: cur.id, score: Number(score.toFixed(2)), error: e.message });
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
const unmatchedSaved = layout.windows.filter((_, i) => !usedSaved.has(i)).length;
|
|
1920
|
+
const unmatchedCurrent = current.filter((_, i) => !usedCurrent.has(i)).map((w) => w.id);
|
|
1921
|
+
return {
|
|
1922
|
+
content: [{
|
|
1923
|
+
type: 'text',
|
|
1924
|
+
text: jsonText({
|
|
1925
|
+
restored: name,
|
|
1926
|
+
matched: results.length,
|
|
1927
|
+
unmatched_saved: unmatchedSaved,
|
|
1928
|
+
unmatched_current: unmatchedCurrent,
|
|
1929
|
+
results,
|
|
1930
|
+
}),
|
|
1931
|
+
}],
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
);
|
|
1935
|
+
|
|
1789
1936
|
// --- set_zoom ---
|
|
1790
1937
|
server.tool(
|
|
1791
1938
|
'set_zoom',
|