rechrome 1.11.2 → 1.12.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/extension/connect.html +30 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/lib/background.mjs +368 -0
- package/extension/lib/ui/authToken.css +1366 -0
- package/extension/lib/ui/authToken.js +7171 -0
- package/extension/lib/ui/connect.js +181 -0
- package/extension/lib/ui/icon-16.png +0 -0
- package/extension/lib/ui/icon-32.png +0 -0
- package/extension/lib/ui/status.js +71 -0
- package/extension/manifest.json +34 -0
- package/extension/status.html +14 -0
- package/package.json +2 -1
- package/rech.js +40 -12
- package/rech.ts +40 -12
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { c as clientExports, j as jsxRuntimeExports, r as reactExports, B as Button, A as AuthTokenSection, T as TabItem, g as getOrCreateAuthToken } from "./authToken.js";
|
|
2
|
+
const SUPPORTED_PROTOCOL_VERSION = 2;
|
|
3
|
+
const ConnectApp = () => {
|
|
4
|
+
const [tabs, setTabs] = reactExports.useState([]);
|
|
5
|
+
const [status, setStatus] = reactExports.useState(null);
|
|
6
|
+
const [showButtons, setShowButtons] = reactExports.useState(true);
|
|
7
|
+
const [showTabList, setShowTabList] = reactExports.useState(true);
|
|
8
|
+
const [clientInfo, setClientInfo] = reactExports.useState("unknown");
|
|
9
|
+
const [mcpRelayUrl, setMcpRelayUrl] = reactExports.useState("");
|
|
10
|
+
const [newTab, setNewTab] = reactExports.useState(false);
|
|
11
|
+
reactExports.useEffect(() => {
|
|
12
|
+
const runAsync = async () => {
|
|
13
|
+
const params = new URLSearchParams(window.location.search);
|
|
14
|
+
const relayUrl = params.get("mcpRelayUrl");
|
|
15
|
+
if (!relayUrl) {
|
|
16
|
+
handleReject("Missing mcpRelayUrl parameter in URL.");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const host = new URL(relayUrl).hostname;
|
|
21
|
+
if (host !== "127.0.0.1" && host !== "[::1]") {
|
|
22
|
+
handleReject(`MCP extension only allows loopback connections (127.0.0.1 or [::1]). Received host: ${host}`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
} catch (e) {
|
|
26
|
+
handleReject(`Invalid mcpRelayUrl parameter in URL: ${relayUrl}. ${e}`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
setMcpRelayUrl(relayUrl);
|
|
30
|
+
let info = "unknown";
|
|
31
|
+
try {
|
|
32
|
+
const client = JSON.parse(params.get("client") || "{}");
|
|
33
|
+
info = `${client.name}/${client.version}`;
|
|
34
|
+
setClientInfo(info);
|
|
35
|
+
setStatus({
|
|
36
|
+
type: "connecting",
|
|
37
|
+
message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
|
|
38
|
+
});
|
|
39
|
+
} catch (e) {
|
|
40
|
+
setStatus({ type: "error", message: "Failed to parse client version." });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const parsedVersion = parseInt(params.get("protocolVersion") ?? "", 10);
|
|
44
|
+
const requiredVersion = isNaN(parsedVersion) ? 1 : parsedVersion;
|
|
45
|
+
if (requiredVersion > SUPPORTED_PROTOCOL_VERSION) {
|
|
46
|
+
const extensionVersion = chrome.runtime.getManifest().version;
|
|
47
|
+
setShowButtons(false);
|
|
48
|
+
setShowTabList(false);
|
|
49
|
+
setStatus({
|
|
50
|
+
type: "error",
|
|
51
|
+
versionMismatch: {
|
|
52
|
+
extensionVersion
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const expectedToken = getOrCreateAuthToken();
|
|
58
|
+
const token = params.get("token");
|
|
59
|
+
if (token === expectedToken) {
|
|
60
|
+
await connectToMCPRelay(relayUrl);
|
|
61
|
+
await handleConnectToTab(void 0, info);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (token) {
|
|
65
|
+
handleReject("Invalid token provided.");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await connectToMCPRelay(relayUrl);
|
|
69
|
+
if (params.get("newTab") === "true") {
|
|
70
|
+
setNewTab(true);
|
|
71
|
+
setShowTabList(false);
|
|
72
|
+
} else {
|
|
73
|
+
await loadTabs();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
void runAsync();
|
|
77
|
+
}, []);
|
|
78
|
+
const handleReject = reactExports.useCallback((message) => {
|
|
79
|
+
setShowButtons(false);
|
|
80
|
+
setShowTabList(false);
|
|
81
|
+
setStatus({ type: "error", message });
|
|
82
|
+
}, []);
|
|
83
|
+
const connectToMCPRelay = reactExports.useCallback(async (mcpRelayUrl2) => {
|
|
84
|
+
const response = await chrome.runtime.sendMessage({ type: "connectToMCPRelay", mcpRelayUrl: mcpRelayUrl2 });
|
|
85
|
+
if (!response.success)
|
|
86
|
+
handleReject(response.error);
|
|
87
|
+
}, [handleReject]);
|
|
88
|
+
const loadTabs = reactExports.useCallback(async () => {
|
|
89
|
+
const response = await chrome.runtime.sendMessage({ type: "getTabs" });
|
|
90
|
+
if (response.success)
|
|
91
|
+
setTabs(response.tabs);
|
|
92
|
+
else
|
|
93
|
+
setStatus({ type: "error", message: "Failed to load tabs: " + response.error });
|
|
94
|
+
}, []);
|
|
95
|
+
const handleConnectToTab = reactExports.useCallback(async (tab, clientInfoOverride) => {
|
|
96
|
+
const displayName = clientInfoOverride || clientInfo;
|
|
97
|
+
setShowButtons(false);
|
|
98
|
+
setShowTabList(false);
|
|
99
|
+
try {
|
|
100
|
+
const response = await chrome.runtime.sendMessage({
|
|
101
|
+
type: "connectToTab",
|
|
102
|
+
mcpRelayUrl,
|
|
103
|
+
tabId: tab?.id,
|
|
104
|
+
windowId: tab?.windowId
|
|
105
|
+
});
|
|
106
|
+
if (response?.success) {
|
|
107
|
+
setStatus({ type: "connected", message: `MCP client "${displayName}" connected. Do not close this tab — it maintains the connection to the browser. Closing it will disconnect the session.` });
|
|
108
|
+
} else {
|
|
109
|
+
setStatus({
|
|
110
|
+
type: "error",
|
|
111
|
+
message: response?.error || `MCP client "${displayName}" failed to connect.`
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
setStatus({
|
|
116
|
+
type: "error",
|
|
117
|
+
message: `MCP client "${displayName}" failed to connect: ${e}`
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}, [clientInfo, mcpRelayUrl]);
|
|
121
|
+
reactExports.useEffect(() => {
|
|
122
|
+
const listener = (message) => {
|
|
123
|
+
if (message.type === "connectionTimeout")
|
|
124
|
+
handleReject("Connection timed out.");
|
|
125
|
+
};
|
|
126
|
+
chrome.runtime.onMessage.addListener(listener);
|
|
127
|
+
return () => {
|
|
128
|
+
chrome.runtime.onMessage.removeListener(listener);
|
|
129
|
+
};
|
|
130
|
+
}, [handleReject]);
|
|
131
|
+
return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "app-container", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "content-wrapper", children: [
|
|
132
|
+
status && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "status-container", children: [
|
|
133
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(StatusBanner, { status }),
|
|
134
|
+
showButtons && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "button-container", children: newTab ? /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
|
|
135
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "primary", onClick: () => handleConnectToTab(), children: "Allow" }),
|
|
136
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "reject", onClick: () => handleReject("Connection rejected. This tab can be closed."), children: "Reject" })
|
|
137
|
+
] }) : /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "reject", onClick: () => handleReject("Connection rejected. This tab can be closed."), children: "Reject" }) })
|
|
138
|
+
] }),
|
|
139
|
+
status?.type === "connecting" && /* @__PURE__ */ jsxRuntimeExports.jsx(AuthTokenSection, {}),
|
|
140
|
+
showTabList && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
|
|
141
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "tab-section-title", children: "Select page to expose to MCP server:" }),
|
|
142
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("div", { children: tabs.map((tab) => /* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
143
|
+
TabItem,
|
|
144
|
+
{
|
|
145
|
+
tab,
|
|
146
|
+
button: /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "primary", onClick: () => handleConnectToTab(tab), children: "Connect" })
|
|
147
|
+
},
|
|
148
|
+
tab.id
|
|
149
|
+
)) })
|
|
150
|
+
] })
|
|
151
|
+
] }) });
|
|
152
|
+
};
|
|
153
|
+
const VersionMismatchError = ({ extensionVersion }) => {
|
|
154
|
+
const readmeUrl = "https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md";
|
|
155
|
+
const latestReleaseUrl = "https://github.com/microsoft/playwright-mcp/releases/latest";
|
|
156
|
+
return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
|
|
157
|
+
"Playwright MCP version trying to connect requires newer extension version (current version: ",
|
|
158
|
+
extensionVersion,
|
|
159
|
+
").",
|
|
160
|
+
" ",
|
|
161
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("a", { href: latestReleaseUrl, children: "Click here" }),
|
|
162
|
+
" to download latest version of the extension, then drag and drop it into the Chrome Extensions page.",
|
|
163
|
+
" ",
|
|
164
|
+
"See ",
|
|
165
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("a", { href: readmeUrl, target: "_blank", rel: "noopener noreferrer", children: "installation instructions" }),
|
|
166
|
+
" for more details."
|
|
167
|
+
] });
|
|
168
|
+
};
|
|
169
|
+
const StatusBanner = ({ status }) => {
|
|
170
|
+
return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: `status-banner ${status.type}`, children: "versionMismatch" in status ? /* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
171
|
+
VersionMismatchError,
|
|
172
|
+
{
|
|
173
|
+
extensionVersion: status.versionMismatch.extensionVersion
|
|
174
|
+
}
|
|
175
|
+
) : status.message });
|
|
176
|
+
};
|
|
177
|
+
const container = document.getElementById("root");
|
|
178
|
+
if (container) {
|
|
179
|
+
const root = clientExports.createRoot(container);
|
|
180
|
+
root.render(/* @__PURE__ */ jsxRuntimeExports.jsx(ConnectApp, {}));
|
|
181
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { c as clientExports, j as jsxRuntimeExports, r as reactExports, T as TabItem, B as Button, A as AuthTokenSection } from "./authToken.js";
|
|
2
|
+
const StatusApp = () => {
|
|
3
|
+
const [connections, setConnections] = reactExports.useState([]);
|
|
4
|
+
reactExports.useEffect(() => {
|
|
5
|
+
void loadStatus();
|
|
6
|
+
}, []);
|
|
7
|
+
const loadStatus = async () => {
|
|
8
|
+
const { connections: rawConnections = [] } = await chrome.runtime.sendMessage({ type: "getConnectionStatus" });
|
|
9
|
+
const fetchTab = async (id) => {
|
|
10
|
+
try {
|
|
11
|
+
const tab = await chrome.tabs.get(id);
|
|
12
|
+
return { id: tab.id, windowId: tab.windowId, title: tab.title, url: tab.url, favIconUrl: tab.favIconUrl };
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const resolved = await Promise.all(
|
|
18
|
+
rawConnections.map(async (c) => {
|
|
19
|
+
const connectedTab = await fetchTab(c.connectedTabId) ?? void 0;
|
|
20
|
+
const playwrightTabs = (await Promise.all(c.playwrightTabIds.map(fetchTab))).filter((t) => t !== null);
|
|
21
|
+
return { ...c, connectedTab, playwrightTabs };
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
setConnections(resolved);
|
|
25
|
+
};
|
|
26
|
+
const openTab = async (tabId) => {
|
|
27
|
+
await chrome.tabs.update(tabId, { active: true });
|
|
28
|
+
window.close();
|
|
29
|
+
};
|
|
30
|
+
const disconnect = async (mcpRelayUrl) => {
|
|
31
|
+
await chrome.runtime.sendMessage({ type: "disconnect", mcpRelayUrl });
|
|
32
|
+
void loadStatus();
|
|
33
|
+
};
|
|
34
|
+
return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "app-container", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "content-wrapper", children: [
|
|
35
|
+
connections.length === 0 ? /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "status-banner", children: "No MCP clients are currently connected." }) : connections.map((c, i) => /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
|
|
36
|
+
connections.length > 1 && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "tab-section-title", children: [
|
|
37
|
+
"Instance ",
|
|
38
|
+
i + 1,
|
|
39
|
+
":"
|
|
40
|
+
] }),
|
|
41
|
+
c.connectedTab && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
|
|
42
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "tab-section-title", children: "Page with connected MCP client:" }),
|
|
43
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
44
|
+
TabItem,
|
|
45
|
+
{
|
|
46
|
+
tab: c.connectedTab,
|
|
47
|
+
button: /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "primary", onClick: () => disconnect(c.mcpRelayUrl), children: "Disconnect" }),
|
|
48
|
+
onClick: () => openTab(c.connectedTabId)
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
] }),
|
|
52
|
+
c.playwrightTabs.length > 0 && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
|
|
53
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "tab-section-title", children: "Playwright managed tabs:" }),
|
|
54
|
+
c.playwrightTabs.map((tab) => /* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
55
|
+
TabItem,
|
|
56
|
+
{
|
|
57
|
+
tab,
|
|
58
|
+
onClick: () => openTab(tab.id)
|
|
59
|
+
},
|
|
60
|
+
tab.id
|
|
61
|
+
))
|
|
62
|
+
] })
|
|
63
|
+
] }, c.mcpRelayUrl)),
|
|
64
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(AuthTokenSection, {})
|
|
65
|
+
] }) });
|
|
66
|
+
};
|
|
67
|
+
const container = document.getElementById("root");
|
|
68
|
+
if (container) {
|
|
69
|
+
const root = clientExports.createRoot(container);
|
|
70
|
+
root.render(/* @__PURE__ */ jsxRuntimeExports.jsx(StatusApp, {}));
|
|
71
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyr3kV2bBGDawVu1+vxLx2epYDN18tw/MSHbBunj5XLXho2gYcU2IYjBFZBt65A48IDJJ94D7LEnoFuaJScjEbxmPaPqvE1UrvplkwWiUPoN/3d4bshwx+wybln1oM4jnO/qGcOkyWZ28RrEhewgRznPYisJhVkBZWi+YoSYaQTkURLxi3CZ68xmWVQLyec3v3sxnOYu+ibOBtBgOFI+WYEBQAvI38y4jUdOTyoHyWw32Plr+y/4fnOzxEXVs7emCdQpVeZuNbru4x1UltrhwtXgl37mIGoOQmf6PzafCO9MaQ+mmRSY5e8Ht/Z5/eWY30mwjbc7H3HAtYOUaPZbp8wIDAQAB",
|
|
4
|
+
"name": "Playwright MCP Bridge (multi-tab)",
|
|
5
|
+
"version": "0.0.68.3",
|
|
6
|
+
"description": "Share browser tabs with Playwright MCP server (multi-tab fork: Playwright can open and control multiple tabs)",
|
|
7
|
+
"permissions": [
|
|
8
|
+
"debugger",
|
|
9
|
+
"activeTab",
|
|
10
|
+
"tabs"
|
|
11
|
+
],
|
|
12
|
+
"host_permissions": [
|
|
13
|
+
"<all_urls>"
|
|
14
|
+
],
|
|
15
|
+
"background": {
|
|
16
|
+
"service_worker": "lib/background.mjs",
|
|
17
|
+
"type": "module"
|
|
18
|
+
},
|
|
19
|
+
"action": {
|
|
20
|
+
"default_title": "Playwright MCP Bridge",
|
|
21
|
+
"default_icon": {
|
|
22
|
+
"16": "icons/icon-16.png",
|
|
23
|
+
"32": "icons/icon-32.png",
|
|
24
|
+
"48": "icons/icon-48.png",
|
|
25
|
+
"128": "icons/icon-128.png"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"icons": {
|
|
29
|
+
"16": "icons/icon-16.png",
|
|
30
|
+
"32": "icons/icon-32.png",
|
|
31
|
+
"48": "icons/icon-48.png",
|
|
32
|
+
"128": "icons/icon-128.png"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Playwright MCP Bridge Status</title>
|
|
7
|
+
<script type="module" crossorigin src="/lib/ui/status.js"></script>
|
|
8
|
+
<link rel="modulepreload" crossorigin href="/lib/ui/authToken.js">
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/lib/ui/authToken.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rechrome",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/snomiao/rechrome.git"
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
".env.example",
|
|
14
14
|
"LICENSE",
|
|
15
15
|
"README.md",
|
|
16
|
+
"extension",
|
|
16
17
|
"rech.js",
|
|
17
18
|
"rech.ts",
|
|
18
19
|
"serve.js",
|
package/rech.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { file } from "bun";
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
|
-
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, constants as fsConstants } from "fs";
|
|
5
|
+
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, constants as fsConstants } from "fs";
|
|
6
6
|
import { hostname } from "os";
|
|
7
7
|
import { join, basename, dirname } from "path";
|
|
8
8
|
|
|
@@ -242,15 +242,35 @@ async function findChromeUserDataDir(): Promise<string | null> {
|
|
|
242
242
|
return null;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
)
|
|
245
|
+
// Bundled extension dist (shipped via package.json `files`). `import.meta.dir` resolves to the install
|
|
246
|
+
// location at runtime — under local dev that's the repo root, under bunx/npm it's the package dir.
|
|
247
|
+
const BUNDLED_EXTENSION_DIST_DIR = join(import.meta.dir, "extension");
|
|
248
|
+
// The legacy submodule path (pre-1.12). Kept for backwards-compat with users who installed from there.
|
|
249
|
+
const LEGACY_EXTENSION_DIST_DIR = join(import.meta.dir, "lib/playwright-multi-tab/lib/playwright-mcp/packages/extension/dist");
|
|
250
|
+
|
|
251
|
+
// Stable per-user location: we copy the bundled dist here so Chrome's recorded install path survives
|
|
252
|
+
// the ephemeral bunx temp dir being cleaned up between invocations.
|
|
253
|
+
export const EXTENSION_DIST_DIR = join(process.env.HOME!, ".rechrome", "extension");
|
|
254
|
+
|
|
255
|
+
// With the manifest `key` field set, Chrome derives this ID deterministically from the key (not the path),
|
|
256
|
+
// so we can locate the extension by ID even when the on-disk path differs from what Chrome stored.
|
|
257
|
+
export const EXTENSION_ID = "fokngfbogklgiffokdnekajodmhgfnhk";
|
|
258
|
+
|
|
259
|
+
async function ensureExtensionDistInstalled(): Promise<string> {
|
|
260
|
+
const source = existsSync(BUNDLED_EXTENSION_DIST_DIR)
|
|
261
|
+
? BUNDLED_EXTENSION_DIST_DIR
|
|
262
|
+
: existsSync(LEGACY_EXTENSION_DIST_DIR)
|
|
263
|
+
? LEGACY_EXTENSION_DIST_DIR
|
|
264
|
+
: null;
|
|
265
|
+
if (!source) return EXTENSION_DIST_DIR;
|
|
266
|
+
const sourceManifest = await file(join(source, "manifest.json")).text().catch(() => "");
|
|
267
|
+
const destManifest = await file(join(EXTENSION_DIST_DIR, "manifest.json")).text().catch(() => "");
|
|
268
|
+
if (sourceManifest && sourceManifest === destManifest) return EXTENSION_DIST_DIR;
|
|
269
|
+
mkdirSync(EXTENSION_DIST_DIR, { recursive: true });
|
|
270
|
+
cpSync(source, EXTENSION_DIST_DIR, { recursive: true, force: true });
|
|
271
|
+
return EXTENSION_DIST_DIR;
|
|
272
|
+
}
|
|
249
273
|
|
|
250
|
-
// Walk all Chrome profiles' Secure Preferences and find an extension
|
|
251
|
-
// whose loaded `path` matches our dist directory. The extension ID Chrome
|
|
252
|
-
// generates for an unpacked extension is path-dependent, so we cannot rely
|
|
253
|
-
// on a hardcoded ID across machines.
|
|
254
274
|
async function findInstalledExtension(
|
|
255
275
|
profileDir?: string,
|
|
256
276
|
): Promise<{ id: string; profile: string } | null> {
|
|
@@ -258,6 +278,11 @@ async function findInstalledExtension(
|
|
|
258
278
|
if (!userDataDir) return null;
|
|
259
279
|
const cache = await readChromeProfileCache();
|
|
260
280
|
const profiles = profileDir ? [profileDir] : (cache ? Object.keys(cache) : []);
|
|
281
|
+
// Resolve our known-good install paths up front for path-based fallback matching.
|
|
282
|
+
const knownPaths = new Set<string>();
|
|
283
|
+
for (const p of [EXTENSION_DIST_DIR, BUNDLED_EXTENSION_DIST_DIR, LEGACY_EXTENSION_DIST_DIR]) {
|
|
284
|
+
try { knownPaths.add(realpathSync(p)); } catch {}
|
|
285
|
+
}
|
|
261
286
|
for (const prof of profiles) {
|
|
262
287
|
const prefsPath = join(userDataDir, prof, "Secure Preferences");
|
|
263
288
|
const f = file(prefsPath);
|
|
@@ -267,11 +292,12 @@ async function findInstalledExtension(
|
|
|
267
292
|
const settings = data?.extensions?.settings ?? {};
|
|
268
293
|
for (const [extId, info] of Object.entries(settings as Record<string, any>)) {
|
|
269
294
|
if (!info?.path || info.state === 0) continue; // state 0 = explicitly disabled
|
|
295
|
+
// Primary: stable ID match (works when manifest `key` is set, regardless of path).
|
|
296
|
+
if (extId === EXTENSION_ID) return { id: extId, profile: prof };
|
|
297
|
+
// Fallback: path equality for legacy installs without a stable key.
|
|
270
298
|
let storedPath = info.path as string;
|
|
271
299
|
try { storedPath = realpathSync(storedPath); } catch {}
|
|
272
|
-
|
|
273
|
-
try { distPath = realpathSync(distPath); } catch {}
|
|
274
|
-
if (storedPath === distPath) return { id: extId, profile: prof };
|
|
300
|
+
if (knownPaths.has(storedPath)) return { id: extId, profile: prof };
|
|
275
301
|
}
|
|
276
302
|
} catch {}
|
|
277
303
|
}
|
|
@@ -675,6 +701,8 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
675
701
|
async function getExtAndToken(profileDir: string, profileDisplay: string, profileKey: string): Promise<{ extId: string; token: string } | null> {
|
|
676
702
|
// Extension check
|
|
677
703
|
let extId: string | undefined;
|
|
704
|
+
// Copy bundled dist to a stable per-user location so the install path survives bunx temp-dir cleanup.
|
|
705
|
+
await ensureExtensionDistInstalled();
|
|
678
706
|
while (true) {
|
|
679
707
|
const found = await findInstalledExtension(profileDir);
|
|
680
708
|
if (found) { extId = found.id; break; }
|
package/rech.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { file } from "bun";
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
|
-
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, constants as fsConstants } from "fs";
|
|
5
|
+
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, constants as fsConstants } from "fs";
|
|
6
6
|
import { hostname } from "os";
|
|
7
7
|
import { join, basename, dirname } from "path";
|
|
8
8
|
|
|
@@ -242,15 +242,35 @@ async function findChromeUserDataDir(): Promise<string | null> {
|
|
|
242
242
|
return null;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
)
|
|
245
|
+
// Bundled extension dist (shipped via package.json `files`). `import.meta.dir` resolves to the install
|
|
246
|
+
// location at runtime — under local dev that's the repo root, under bunx/npm it's the package dir.
|
|
247
|
+
const BUNDLED_EXTENSION_DIST_DIR = join(import.meta.dir, "extension");
|
|
248
|
+
// The legacy submodule path (pre-1.12). Kept for backwards-compat with users who installed from there.
|
|
249
|
+
const LEGACY_EXTENSION_DIST_DIR = join(import.meta.dir, "lib/playwright-multi-tab/lib/playwright-mcp/packages/extension/dist");
|
|
250
|
+
|
|
251
|
+
// Stable per-user location: we copy the bundled dist here so Chrome's recorded install path survives
|
|
252
|
+
// the ephemeral bunx temp dir being cleaned up between invocations.
|
|
253
|
+
export const EXTENSION_DIST_DIR = join(process.env.HOME!, ".rechrome", "extension");
|
|
254
|
+
|
|
255
|
+
// With the manifest `key` field set, Chrome derives this ID deterministically from the key (not the path),
|
|
256
|
+
// so we can locate the extension by ID even when the on-disk path differs from what Chrome stored.
|
|
257
|
+
export const EXTENSION_ID = "fokngfbogklgiffokdnekajodmhgfnhk";
|
|
258
|
+
|
|
259
|
+
async function ensureExtensionDistInstalled(): Promise<string> {
|
|
260
|
+
const source = existsSync(BUNDLED_EXTENSION_DIST_DIR)
|
|
261
|
+
? BUNDLED_EXTENSION_DIST_DIR
|
|
262
|
+
: existsSync(LEGACY_EXTENSION_DIST_DIR)
|
|
263
|
+
? LEGACY_EXTENSION_DIST_DIR
|
|
264
|
+
: null;
|
|
265
|
+
if (!source) return EXTENSION_DIST_DIR;
|
|
266
|
+
const sourceManifest = await file(join(source, "manifest.json")).text().catch(() => "");
|
|
267
|
+
const destManifest = await file(join(EXTENSION_DIST_DIR, "manifest.json")).text().catch(() => "");
|
|
268
|
+
if (sourceManifest && sourceManifest === destManifest) return EXTENSION_DIST_DIR;
|
|
269
|
+
mkdirSync(EXTENSION_DIST_DIR, { recursive: true });
|
|
270
|
+
cpSync(source, EXTENSION_DIST_DIR, { recursive: true, force: true });
|
|
271
|
+
return EXTENSION_DIST_DIR;
|
|
272
|
+
}
|
|
249
273
|
|
|
250
|
-
// Walk all Chrome profiles' Secure Preferences and find an extension
|
|
251
|
-
// whose loaded `path` matches our dist directory. The extension ID Chrome
|
|
252
|
-
// generates for an unpacked extension is path-dependent, so we cannot rely
|
|
253
|
-
// on a hardcoded ID across machines.
|
|
254
274
|
async function findInstalledExtension(
|
|
255
275
|
profileDir?: string,
|
|
256
276
|
): Promise<{ id: string; profile: string } | null> {
|
|
@@ -258,6 +278,11 @@ async function findInstalledExtension(
|
|
|
258
278
|
if (!userDataDir) return null;
|
|
259
279
|
const cache = await readChromeProfileCache();
|
|
260
280
|
const profiles = profileDir ? [profileDir] : (cache ? Object.keys(cache) : []);
|
|
281
|
+
// Resolve our known-good install paths up front for path-based fallback matching.
|
|
282
|
+
const knownPaths = new Set<string>();
|
|
283
|
+
for (const p of [EXTENSION_DIST_DIR, BUNDLED_EXTENSION_DIST_DIR, LEGACY_EXTENSION_DIST_DIR]) {
|
|
284
|
+
try { knownPaths.add(realpathSync(p)); } catch {}
|
|
285
|
+
}
|
|
261
286
|
for (const prof of profiles) {
|
|
262
287
|
const prefsPath = join(userDataDir, prof, "Secure Preferences");
|
|
263
288
|
const f = file(prefsPath);
|
|
@@ -267,11 +292,12 @@ async function findInstalledExtension(
|
|
|
267
292
|
const settings = data?.extensions?.settings ?? {};
|
|
268
293
|
for (const [extId, info] of Object.entries(settings as Record<string, any>)) {
|
|
269
294
|
if (!info?.path || info.state === 0) continue; // state 0 = explicitly disabled
|
|
295
|
+
// Primary: stable ID match (works when manifest `key` is set, regardless of path).
|
|
296
|
+
if (extId === EXTENSION_ID) return { id: extId, profile: prof };
|
|
297
|
+
// Fallback: path equality for legacy installs without a stable key.
|
|
270
298
|
let storedPath = info.path as string;
|
|
271
299
|
try { storedPath = realpathSync(storedPath); } catch {}
|
|
272
|
-
|
|
273
|
-
try { distPath = realpathSync(distPath); } catch {}
|
|
274
|
-
if (storedPath === distPath) return { id: extId, profile: prof };
|
|
300
|
+
if (knownPaths.has(storedPath)) return { id: extId, profile: prof };
|
|
275
301
|
}
|
|
276
302
|
} catch {}
|
|
277
303
|
}
|
|
@@ -675,6 +701,8 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
675
701
|
async function getExtAndToken(profileDir: string, profileDisplay: string, profileKey: string): Promise<{ extId: string; token: string } | null> {
|
|
676
702
|
// Extension check
|
|
677
703
|
let extId: string | undefined;
|
|
704
|
+
// Copy bundled dist to a stable per-user location so the install path survives bunx temp-dir cleanup.
|
|
705
|
+
await ensureExtensionDistInstalled();
|
|
678
706
|
while (true) {
|
|
679
707
|
const found = await findInstalledExtension(profileDir);
|
|
680
708
|
if (found) { extId = found.id; break; }
|