iframer-toolkit 0.8.0 → 0.10.1
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/dist/cli.js +8922 -4191
- package/dist/local-server.cjs +6776 -0
- package/dist/mcp-server.cjs +827 -201
- package/extension/README.md +134 -0
- package/extension/background.js +639 -0
- package/extension/manifest.json +32 -0
- package/extension/native-host.cjs +91 -0
- package/extension/popup.html +72 -0
- package/extension/popup.js +108 -0
- package/package.json +13 -5
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
// iframer extension — background service worker.
|
|
2
|
+
//
|
|
3
|
+
// Dials OUT to the iframer local server over WebSocket (the server can't reach
|
|
4
|
+
// into Chrome; extensions can't listen, so the extension is always the client).
|
|
5
|
+
// Its ONE job is to be a CDP relay: it attaches chrome.debugger to the tab the
|
|
6
|
+
// server asks for and forwards the protocol both ways. The server connects to
|
|
7
|
+
// that relay with connectOverCDP and drives the tab through iframer's real
|
|
8
|
+
// pipeline. Discovery (`tabs`) uses chrome.tabs. Nothing is interpreted here.
|
|
9
|
+
|
|
10
|
+
// Keep in sync with PORT_SCAN_ATTEMPTS in src/mcp/local-server.ts — the server
|
|
11
|
+
// allocates its port from this exact window so the scan below can find it.
|
|
12
|
+
const PORT_START = 3022;
|
|
13
|
+
const PORT_END = 3042;
|
|
14
|
+
const RECONNECT_MS = 2000;
|
|
15
|
+
|
|
16
|
+
let ws = null;
|
|
17
|
+
let currentPort = null;
|
|
18
|
+
let scanning = false;
|
|
19
|
+
|
|
20
|
+
async function getToken() {
|
|
21
|
+
const { token } = await chrome.storage.local.get("token");
|
|
22
|
+
return token || "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Native-messaging host (installed by `iframer install extension`). One call
|
|
26
|
+
// answers everything: the pairing token, the server's registered port, and
|
|
27
|
+
// whether that server process is actually alive. Resolves null when the host
|
|
28
|
+
// isn't installed (→ legacy port scan).
|
|
29
|
+
function nativeCall(msg) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
try {
|
|
32
|
+
chrome.runtime.sendNativeMessage("com.iframer.token", msg, (resp) => {
|
|
33
|
+
void chrome.runtime.lastError; // host not installed — that's fine
|
|
34
|
+
resolve(resp || null);
|
|
35
|
+
});
|
|
36
|
+
} catch {
|
|
37
|
+
resolve(null);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Token via the host, for auto-pairing. Falls back to the older get-token
|
|
43
|
+
// command if the installed host copy predates get-info.
|
|
44
|
+
async function fetchTokenNatively() {
|
|
45
|
+
let resp = await nativeCall({ cmd: "get-info" });
|
|
46
|
+
if (!resp || !resp.token) resp = await nativeCall({ cmd: "get-token" });
|
|
47
|
+
return resp && resp.token ? String(resp.token) : "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Read the signed-in account from Chrome — its email is the natural profile
|
|
51
|
+
// DISPLAY NAME. Returns {email, id} or null if not signed in.
|
|
52
|
+
function getChromeProfile() {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
try {
|
|
55
|
+
chrome.identity.getProfileUserInfo({ accountStatus: "ANY" }, (info) => {
|
|
56
|
+
if (chrome.runtime.lastError) return resolve(null);
|
|
57
|
+
resolve(info && info.email ? info : null);
|
|
58
|
+
});
|
|
59
|
+
} catch {
|
|
60
|
+
// Older Chrome without the options arg.
|
|
61
|
+
try {
|
|
62
|
+
chrome.identity.getProfileUserInfo((info) => resolve(info && info.email ? info : null));
|
|
63
|
+
} catch {
|
|
64
|
+
resolve(null);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Stable per-INSTALL identity. The id is always a persistent random UUID —
|
|
71
|
+
// never the Google account (gaia) id: the same account signed into two
|
|
72
|
+
// browsers would collide, and the server de-dupes connections by profileId
|
|
73
|
+
// (it would evict the other browser as a "stale reconnect"). The account
|
|
74
|
+
// email is used only as the human-readable name; the popup label overrides it.
|
|
75
|
+
async function ensureProfile() {
|
|
76
|
+
const store = await chrome.storage.local.get(["profileId", "profileLabel"]);
|
|
77
|
+
const info = await getChromeProfile();
|
|
78
|
+
|
|
79
|
+
let profileId = store.profileId;
|
|
80
|
+
// Migration: earlier versions stored the gaia id — regenerate it.
|
|
81
|
+
if (!profileId || (info && info.id && profileId === info.id)) {
|
|
82
|
+
profileId = crypto.randomUUID();
|
|
83
|
+
await chrome.storage.local.set({ profileId });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const profileName =
|
|
87
|
+
(store.profileLabel && store.profileLabel.trim()) ||
|
|
88
|
+
(info && info.email) ||
|
|
89
|
+
`Chrome profile ${String(profileId).slice(0, 6)}`;
|
|
90
|
+
|
|
91
|
+
return { profileId, profileName, email: info && info.email };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function sendHello(sock) {
|
|
95
|
+
try {
|
|
96
|
+
const { profileId, profileName } = await ensureProfile();
|
|
97
|
+
// The await above yields — the server may have closed the socket in the
|
|
98
|
+
// meantime (e.g. it rejected our auth). send() on a closed socket logs a
|
|
99
|
+
// console error even inside try/catch, so check first.
|
|
100
|
+
if (sock.readyState !== WebSocket.OPEN) return;
|
|
101
|
+
sock.send(
|
|
102
|
+
JSON.stringify({
|
|
103
|
+
type: "hello",
|
|
104
|
+
profileId,
|
|
105
|
+
profileName,
|
|
106
|
+
extVersion: chrome.runtime.getManifest().version,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
} catch {
|
|
110
|
+
/* best-effort identity */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function setStatus(patch) {
|
|
115
|
+
const prev = (await chrome.storage.local.get("status")).status || {};
|
|
116
|
+
await chrome.storage.local.set({ status: { ...prev, ...patch } });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Numeric semver compare: is `a` newer than `b`? (x.y.z, extra parts ignored.)
|
|
120
|
+
function isNewer(a, b) {
|
|
121
|
+
const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
|
|
122
|
+
const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
|
|
123
|
+
for (let i = 0; i < 3; i++) {
|
|
124
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
125
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Compare the server's version (fresh after an npm update) to our own. Surface
|
|
131
|
+
// "update available" via a toolbar badge + stored state the popup reads. We do
|
|
132
|
+
// NOT self-reload (check-and-notify) — the user runs `iframer update`, which
|
|
133
|
+
// applies the update and triggers the reload.
|
|
134
|
+
async function checkForUpdate(serverVersion) {
|
|
135
|
+
const current = chrome.runtime.getManifest().version;
|
|
136
|
+
const available = serverVersion && isNewer(serverVersion, current) ? serverVersion : null;
|
|
137
|
+
try {
|
|
138
|
+
if (available) {
|
|
139
|
+
await chrome.action.setBadgeText({ text: "↑" });
|
|
140
|
+
await chrome.action.setBadgeBackgroundColor({ color: "#2a7" });
|
|
141
|
+
await chrome.action.setTitle({ title: `iframer — update available (v${available}), run 'iframer update'` });
|
|
142
|
+
} else {
|
|
143
|
+
await chrome.action.setBadgeText({ text: "" });
|
|
144
|
+
await chrome.action.setTitle({ title: "iframer" });
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
/* action API not available in some contexts */
|
|
148
|
+
}
|
|
149
|
+
await setStatus({ current, updateAvailable: available });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Back off when the server isn't there: each full failed scan of 21 ports logs
|
|
153
|
+
// 21 ERR_CONNECTION_REFUSED lines, so retrying every 2s floods the extension's
|
|
154
|
+
// error page. Grow the wait up to a minute; any explicit action (new token,
|
|
155
|
+
// auto-pair) resets it.
|
|
156
|
+
let nextScanAt = 0;
|
|
157
|
+
let scanBackoffMs = 0;
|
|
158
|
+
const SCAN_BACKOFF_MAX_MS = 60_000;
|
|
159
|
+
|
|
160
|
+
function resetScanBackoff() {
|
|
161
|
+
nextScanAt = 0;
|
|
162
|
+
scanBackoffMs = 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function bumpScanBackoff() {
|
|
166
|
+
scanBackoffMs = Math.min(scanBackoffMs ? scanBackoffMs * 2 : 5_000, SCAN_BACKOFF_MAX_MS);
|
|
167
|
+
nextScanAt = Date.now() + scanBackoffMs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function connectLoop() {
|
|
171
|
+
if (scanning || (ws && ws.readyState === WebSocket.OPEN)) return;
|
|
172
|
+
if (Date.now() < nextScanAt) return;
|
|
173
|
+
scanning = true;
|
|
174
|
+
try {
|
|
175
|
+
// Ask the native host where the server is. When it answers, we never dial
|
|
176
|
+
// blind: a dead port dialed = a console error logged, so we only open a
|
|
177
|
+
// socket to a port the registry says a LIVE server process owns.
|
|
178
|
+
const info = await nativeCall({ cmd: "get-info" });
|
|
179
|
+
|
|
180
|
+
let token = await getToken();
|
|
181
|
+
if (info && info.ok && info.token && info.token !== token) {
|
|
182
|
+
token = String(info.token);
|
|
183
|
+
await chrome.storage.local.set({ token });
|
|
184
|
+
}
|
|
185
|
+
if (!token) {
|
|
186
|
+
await setStatus({ connected: false, reason: "no-token" });
|
|
187
|
+
bumpScanBackoff();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (info && info.ok && "alive" in info) {
|
|
192
|
+
if (!info.alive || !info.port) {
|
|
193
|
+
// Server simply isn't running (it starts with an iframer session).
|
|
194
|
+
// Don't attempt any connection — zero console noise.
|
|
195
|
+
await setStatus({ connected: false, reason: "server-not-running" });
|
|
196
|
+
bumpScanBackoff();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const ok = await tryConnect(info.port, token);
|
|
200
|
+
if (ok) {
|
|
201
|
+
resetScanBackoff();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
await setStatus({ connected: false, reason: "server-not-found" });
|
|
205
|
+
bumpScanBackoff();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// No native host (or an old copy) — legacy full port scan.
|
|
210
|
+
for (let port = PORT_START; port <= PORT_END; port++) {
|
|
211
|
+
const ok = await tryConnect(port, token);
|
|
212
|
+
if (ok) {
|
|
213
|
+
resetScanBackoff();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
await setStatus({ connected: false, reason: "server-not-found" });
|
|
218
|
+
bumpScanBackoff();
|
|
219
|
+
} finally {
|
|
220
|
+
scanning = false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function tryConnect(port, token) {
|
|
225
|
+
return new Promise((resolve) => {
|
|
226
|
+
let settled = false;
|
|
227
|
+
let sock;
|
|
228
|
+
try {
|
|
229
|
+
// No token in the URL — query strings land in request logs. The token
|
|
230
|
+
// goes in a first {type:"auth"} message instead; the server waits for it.
|
|
231
|
+
sock = new WebSocket(`ws://127.0.0.1:${port}/extension/ws`);
|
|
232
|
+
} catch {
|
|
233
|
+
resolve(false);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const done = (val) => {
|
|
237
|
+
if (!settled) {
|
|
238
|
+
settled = true;
|
|
239
|
+
resolve(val);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
// If it doesn't open quickly, treat this port as a miss.
|
|
243
|
+
const timer = setTimeout(() => {
|
|
244
|
+
try { sock.close(); } catch {}
|
|
245
|
+
done(false);
|
|
246
|
+
}, 800);
|
|
247
|
+
|
|
248
|
+
sock.onopen = () => {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
ws = sock;
|
|
251
|
+
currentPort = port;
|
|
252
|
+
wire(sock);
|
|
253
|
+
try {
|
|
254
|
+
sock.send(JSON.stringify({ type: "auth", token }));
|
|
255
|
+
} catch {}
|
|
256
|
+
sendHello(sock);
|
|
257
|
+
setStatus({ connected: true, port, reason: null });
|
|
258
|
+
done(true);
|
|
259
|
+
};
|
|
260
|
+
sock.onclose = (ev) => {
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
// 4001 = server is there but rejected our token. Stop scanning; surface it.
|
|
263
|
+
if (ev.code === 4001) {
|
|
264
|
+
setStatus({ connected: false, reason: "bad-token" });
|
|
265
|
+
done(true); // "found" the server, just unauthorized — don't keep scanning
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
done(false);
|
|
269
|
+
};
|
|
270
|
+
sock.onerror = () => {
|
|
271
|
+
clearTimeout(timer);
|
|
272
|
+
done(false);
|
|
273
|
+
};
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function wire(sock) {
|
|
278
|
+
sock.onmessage = (ev) => handleMessage(sock, ev.data);
|
|
279
|
+
sock.onclose = (ev) => {
|
|
280
|
+
if (ws === sock) {
|
|
281
|
+
ws = null;
|
|
282
|
+
currentPort = null;
|
|
283
|
+
// Auth is checked AFTER the socket opens, so a bad token surfaces here.
|
|
284
|
+
setStatus({ connected: false, reason: ev && ev.code === 4001 ? "bad-token" : "disconnected" });
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
sock.onerror = () => {
|
|
288
|
+
try { sock.close(); } catch {}
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function reply(sock, id, ok, payload) {
|
|
293
|
+
try {
|
|
294
|
+
sock.send(JSON.stringify(ok ? { id, ok: true, result: payload } : { id, ok: false, error: payload }));
|
|
295
|
+
} catch {
|
|
296
|
+
/* socket gone */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function handleMessage(sock, raw) {
|
|
301
|
+
let msg;
|
|
302
|
+
try {
|
|
303
|
+
msg = JSON.parse(raw);
|
|
304
|
+
} catch {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const { id, type } = msg;
|
|
308
|
+
|
|
309
|
+
// Server announces its version on connect (one-way event, no id). Compare to
|
|
310
|
+
// our own manifest version and surface "update available" — the running
|
|
311
|
+
// extension is older than the files an `npm update` left on disk.
|
|
312
|
+
if (type === "server_info") {
|
|
313
|
+
checkForUpdate(msg.version);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (typeof id !== "number") return;
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
if (type === "ping") {
|
|
321
|
+
reply(sock, id, true, { pong: true });
|
|
322
|
+
} else if (type === "list_tabs") {
|
|
323
|
+
reply(sock, id, true, { tabs: await listTabs() });
|
|
324
|
+
} else if (type === "create_tab") {
|
|
325
|
+
reply(sock, id, true, await createTab(msg.url, { active: msg.active, windowId: msg.windowId }));
|
|
326
|
+
} else if (type === "group_tabs") {
|
|
327
|
+
reply(sock, id, true, await groupTabs(msg.tabIds, { title: msg.title, color: msg.color, collapsed: msg.collapsed, groupId: msg.groupId }));
|
|
328
|
+
} else if (type === "ungroup_tabs") {
|
|
329
|
+
reply(sock, id, true, await ungroupTabs(msg.tabIds));
|
|
330
|
+
} else if (type === "update_group") {
|
|
331
|
+
reply(sock, id, true, await updateGroup(msg.groupId, { title: msg.title, color: msg.color, collapsed: msg.collapsed }));
|
|
332
|
+
} else if (type === "list_groups") {
|
|
333
|
+
reply(sock, id, true, { groups: await listGroups() });
|
|
334
|
+
} else if (type === "reload") {
|
|
335
|
+
// Hot-reload the unpacked extension so new background.js takes effect
|
|
336
|
+
// without a manual chrome://extensions reload. Reply FIRST — reload()
|
|
337
|
+
// kills this worker immediately.
|
|
338
|
+
reply(sock, id, true, { reloading: true });
|
|
339
|
+
setTimeout(() => { try { chrome.runtime.reload(); } catch {} }, 100);
|
|
340
|
+
} else if (type === "cdp_attach") {
|
|
341
|
+
reply(sock, id, true, await cdpAttach(msg.tabId, !!msg.focus));
|
|
342
|
+
} else if (type === "cdp_command") {
|
|
343
|
+
reply(sock, id, true, await cdpCommand(msg.tabId, msg.sessionId, msg.method, msg.params));
|
|
344
|
+
} else if (type === "cdp_detach") {
|
|
345
|
+
await cdpDetach(msg.tabId);
|
|
346
|
+
reply(sock, id, true, { detached: true });
|
|
347
|
+
} else {
|
|
348
|
+
reply(sock, id, false, `Unknown message type: ${type}`);
|
|
349
|
+
}
|
|
350
|
+
} catch (e) {
|
|
351
|
+
reply(sock, id, false, e && e.message ? e.message : String(e));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Open a new tab in the user's real Chrome. Native browser control — not a
|
|
356
|
+
// page-context window.open (which is popup-blocked and uncontrollable).
|
|
357
|
+
async function createTab(url, opts = {}) {
|
|
358
|
+
const createProps = { active: opts.active !== false };
|
|
359
|
+
if (url) createProps.url = url;
|
|
360
|
+
if (typeof opts.windowId === "number") createProps.windowId = opts.windowId;
|
|
361
|
+
const t = await chrome.tabs.create(createProps);
|
|
362
|
+
return {
|
|
363
|
+
tab: {
|
|
364
|
+
id: t.id,
|
|
365
|
+
windowId: t.windowId,
|
|
366
|
+
title: t.title || "",
|
|
367
|
+
url: t.url || t.pendingUrl || url || "",
|
|
368
|
+
active: !!t.active,
|
|
369
|
+
favIconUrl: t.favIconUrl,
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Group tabs into a Chrome tab group (native — creates a real collapsible,
|
|
375
|
+
// colored group in the tab strip). Pass groupId to add to an existing group,
|
|
376
|
+
// omit to create a new one. title/color/collapsed style the group.
|
|
377
|
+
async function groupTabs(tabIds, opts = {}) {
|
|
378
|
+
if (!Array.isArray(tabIds) || tabIds.length === 0) throw new Error("group_tabs needs a non-empty tabIds array");
|
|
379
|
+
const groupProps = { tabIds };
|
|
380
|
+
if (typeof opts.groupId === "number") groupProps.groupId = opts.groupId;
|
|
381
|
+
const groupId = await chrome.tabs.group(groupProps);
|
|
382
|
+
const update = {};
|
|
383
|
+
if (typeof opts.title === "string") update.title = opts.title;
|
|
384
|
+
if (typeof opts.color === "string") update.color = opts.color;
|
|
385
|
+
if (typeof opts.collapsed === "boolean") update.collapsed = opts.collapsed;
|
|
386
|
+
let group = null;
|
|
387
|
+
if (Object.keys(update).length > 0) {
|
|
388
|
+
group = await chrome.tabGroups.update(groupId, update);
|
|
389
|
+
} else {
|
|
390
|
+
try { group = await chrome.tabGroups.get(groupId); } catch {}
|
|
391
|
+
}
|
|
392
|
+
return { groupId, title: group?.title || opts.title || "", color: group?.color || opts.color || "", collapsed: !!group?.collapsed, tabIds };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Remove tabs from their group (chrome.tabs.ungroup). If a group ends up empty
|
|
396
|
+
// Chrome deletes it automatically.
|
|
397
|
+
async function ungroupTabs(tabIds) {
|
|
398
|
+
if (!Array.isArray(tabIds) || tabIds.length === 0) throw new Error("ungroup_tabs needs a non-empty tabIds array");
|
|
399
|
+
await chrome.tabs.ungroup(tabIds);
|
|
400
|
+
return { ungrouped: tabIds };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Rename / recolor / collapse an EXISTING group by id (no tabIds needed).
|
|
404
|
+
async function updateGroup(groupId, opts = {}) {
|
|
405
|
+
if (typeof groupId !== "number") throw new Error("update_group needs a numeric groupId");
|
|
406
|
+
const update = {};
|
|
407
|
+
if (typeof opts.title === "string") update.title = opts.title;
|
|
408
|
+
if (typeof opts.color === "string") update.color = opts.color;
|
|
409
|
+
if (typeof opts.collapsed === "boolean") update.collapsed = opts.collapsed;
|
|
410
|
+
if (Object.keys(update).length === 0) throw new Error("update_group needs at least one of title, color, collapsed");
|
|
411
|
+
const g = await chrome.tabGroups.update(groupId, update);
|
|
412
|
+
return { groupId: g.id, title: g.title || "", color: g.color || "", collapsed: !!g.collapsed };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// List all tab groups in this browser (id, title, color, collapsed, windowId).
|
|
416
|
+
async function listGroups() {
|
|
417
|
+
const groups = await chrome.tabGroups.query({});
|
|
418
|
+
return groups.map((g) => ({ groupId: g.id, title: g.title || "", color: g.color || "", collapsed: !!g.collapsed, windowId: g.windowId }));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function listTabs() {
|
|
422
|
+
const tabs = await chrome.tabs.query({});
|
|
423
|
+
return tabs
|
|
424
|
+
.filter((t) => typeof t.id === "number")
|
|
425
|
+
.map((t) => ({
|
|
426
|
+
id: t.id,
|
|
427
|
+
windowId: t.windowId,
|
|
428
|
+
title: t.title || "",
|
|
429
|
+
url: t.url || "",
|
|
430
|
+
active: !!t.active,
|
|
431
|
+
favIconUrl: t.favIconUrl,
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
436
|
+
|
|
437
|
+
// Chrome discards (freezes) background tabs to save memory — a discarded tab
|
|
438
|
+
// has no content process, so a debugger attach lands on a dead target. Wake it
|
|
439
|
+
// by reloading before we try to drive it.
|
|
440
|
+
async function ensureAwake(tabId) {
|
|
441
|
+
try {
|
|
442
|
+
const t = await chrome.tabs.get(tabId);
|
|
443
|
+
if (t.discarded || t.status === "unloaded") {
|
|
444
|
+
await chrome.tabs.reload(tabId);
|
|
445
|
+
await waitForLoad(tabId);
|
|
446
|
+
await sleep(1500); // let the SPA boot after document load
|
|
447
|
+
}
|
|
448
|
+
} catch {
|
|
449
|
+
/* tab gone — the attach will surface the real error */
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function waitForLoad(tabId, timeoutMs = 30000) {
|
|
454
|
+
const deadline = Date.now() + timeoutMs;
|
|
455
|
+
while (Date.now() < deadline) {
|
|
456
|
+
try {
|
|
457
|
+
const t = await chrome.tabs.get(tabId);
|
|
458
|
+
if (t.status === "complete") return;
|
|
459
|
+
} catch {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
await sleep(150);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Make the target tab drivable. It must be the ACTIVE tab of its window —
|
|
467
|
+
// an inactive tab doesn't render, so CDP mouse events have nothing to
|
|
468
|
+
// hit-test against. But the WINDOW doesn't need OS focus: we activate the
|
|
469
|
+
// tab in place and (in cdpAttach) turn on CDP focus emulation so the page
|
|
470
|
+
// believes it's focused. stealFocus=true additionally raises the window —
|
|
471
|
+
// the old behavior, for the rare site that still misbehaves.
|
|
472
|
+
async function focusTab(tabId, stealFocus) {
|
|
473
|
+
try {
|
|
474
|
+
const t = await chrome.tabs.get(tabId);
|
|
475
|
+
// A minimized window produces no frames at all (screenshots stall, some
|
|
476
|
+
// sites wedge). Restore it WITHOUT taking focus — sitting behind the
|
|
477
|
+
// user's other windows is enough for rendering.
|
|
478
|
+
try {
|
|
479
|
+
const w = await chrome.windows.get(t.windowId);
|
|
480
|
+
if (w.state === "minimized") {
|
|
481
|
+
await chrome.windows.update(t.windowId, { state: "normal", focused: false });
|
|
482
|
+
}
|
|
483
|
+
} catch {
|
|
484
|
+
/* window query failed — proceed, attach will surface real errors */
|
|
485
|
+
}
|
|
486
|
+
if (stealFocus) await chrome.windows.update(t.windowId, { focused: true });
|
|
487
|
+
await chrome.tabs.update(tabId, { active: true });
|
|
488
|
+
await sleep(400); // let the tab un-throttle and paint
|
|
489
|
+
} catch {
|
|
490
|
+
/* tab/window gone — the attach will surface the real error */
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ─── CDP relay ──────────────────────────────────────────────────────
|
|
495
|
+
// Bridges chrome.debugger for a tab to iframer's server, which connects to it
|
|
496
|
+
// with connectOverCDP and drives the tab through the REAL iframer pipeline
|
|
497
|
+
// (find/click/snapshot/navigate/capture). Shows Chrome's debug banner while a
|
|
498
|
+
// run is in progress; the debugger detaches when the run finishes.
|
|
499
|
+
const attachedTabs = new Set();
|
|
500
|
+
|
|
501
|
+
function dbgSend(target, method, params) {
|
|
502
|
+
return new Promise((resolve, reject) => {
|
|
503
|
+
chrome.debugger.sendCommand(target, method, params || {}, (result) => {
|
|
504
|
+
const e = chrome.runtime.lastError;
|
|
505
|
+
if (e) reject(new Error(e.message));
|
|
506
|
+
else resolve(result);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function cdpAttach(tabId, stealFocus) {
|
|
512
|
+
await ensureAwake(tabId); // a discarded tab has no target to attach to
|
|
513
|
+
await focusTab(tabId, stealFocus); // active tab so CDP hit-testing/render works
|
|
514
|
+
if (!attachedTabs.has(tabId)) {
|
|
515
|
+
await chrome.debugger.attach({ tabId }, "1.3");
|
|
516
|
+
attachedTabs.add(tabId);
|
|
517
|
+
}
|
|
518
|
+
// Let the page run as if focused even when the window isn't: focus
|
|
519
|
+
// emulation makes document.hasFocus()/focus events behave, and the
|
|
520
|
+
// lifecycle override keeps Chrome from freezing an occluded window's tab.
|
|
521
|
+
// Best-effort — older Chromes may not know these commands.
|
|
522
|
+
try { await dbgSend({ tabId }, "Emulation.setFocusEmulationEnabled", { enabled: true }); } catch {}
|
|
523
|
+
try { await dbgSend({ tabId }, "Page.setWebLifecycleState", { state: "active" }); } catch {}
|
|
524
|
+
const info = await dbgSend({ tabId }, "Target.getTargetInfo");
|
|
525
|
+
return { targetInfo: info && info.targetInfo };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function cdpCommand(tabId, sessionId, method, params) {
|
|
529
|
+
const target = sessionId ? { tabId, sessionId } : { tabId };
|
|
530
|
+
return await dbgSend(target, method, params);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function cdpDetach(tabId) {
|
|
534
|
+
attachedTabs.delete(tabId);
|
|
535
|
+
try { await chrome.debugger.detach({ tabId }); } catch {}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Forward all debugger events for attached tabs to the server (tagged with the
|
|
539
|
+
// child sessionId when present, undefined for the top-level page).
|
|
540
|
+
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
541
|
+
if (!attachedTabs.has(source.tabId)) return;
|
|
542
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
543
|
+
try {
|
|
544
|
+
ws.send(JSON.stringify({ type: "cdp_event", tabId: source.tabId, sessionId: source.sessionId, method, params }));
|
|
545
|
+
} catch {}
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
chrome.debugger.onDetach.addListener((source, reason) => {
|
|
550
|
+
if (typeof source.tabId === "number" && attachedTabs.has(source.tabId)) {
|
|
551
|
+
attachedTabs.delete(source.tabId);
|
|
552
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
553
|
+
try {
|
|
554
|
+
ws.send(JSON.stringify({ type: "cdp_event", tabId: source.tabId, method: "Inspector.detached", params: { reason } }));
|
|
555
|
+
} catch {}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// ─── Popup <-> worker messaging ─────────────────────────────────────
|
|
561
|
+
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
562
|
+
(async () => {
|
|
563
|
+
if (msg.cmd === "get-state") {
|
|
564
|
+
const { status } = await chrome.storage.local.get("status");
|
|
565
|
+
const { profileId, profileName } = await ensureProfile();
|
|
566
|
+
sendResponse({ ok: true, status: status || { connected: false }, profileId, profileName });
|
|
567
|
+
} else if (msg.cmd === "set-label") {
|
|
568
|
+
await chrome.storage.local.set({ profileLabel: (msg.label || "").trim() });
|
|
569
|
+
if (ws && ws.readyState === WebSocket.OPEN) sendHello(ws); // push the new name to the server
|
|
570
|
+
const { profileId, profileName } = await ensureProfile();
|
|
571
|
+
sendResponse({ ok: true, profileId, profileName });
|
|
572
|
+
} else if (msg.cmd === "diag") {
|
|
573
|
+
// Report exactly what the loaded extension has, and whether a real
|
|
574
|
+
// executeScript against the active tab works.
|
|
575
|
+
const hostPermissions = chrome.runtime.getManifest().host_permissions || [];
|
|
576
|
+
const perms = await chrome.permissions.getAll();
|
|
577
|
+
let tabUrl = null, ok = false, value = null, error = null;
|
|
578
|
+
try {
|
|
579
|
+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
580
|
+
tabUrl = tab && tab.url;
|
|
581
|
+
if (tab) {
|
|
582
|
+
const [r] = await chrome.scripting.executeScript({
|
|
583
|
+
target: { tabId: tab.id },
|
|
584
|
+
func: () => location.href,
|
|
585
|
+
});
|
|
586
|
+
ok = true;
|
|
587
|
+
value = r && r.result;
|
|
588
|
+
}
|
|
589
|
+
} catch (e) {
|
|
590
|
+
error = e && e.message ? e.message : String(e);
|
|
591
|
+
}
|
|
592
|
+
sendResponse({ ok: true, hostPermissions, grantedOrigins: perms.origins || [], tabUrl, okExec: ok, value, error });
|
|
593
|
+
} else if (msg.cmd === "auto-pair") {
|
|
594
|
+
const token = await fetchTokenNatively();
|
|
595
|
+
if (!token) {
|
|
596
|
+
sendResponse({
|
|
597
|
+
ok: false,
|
|
598
|
+
error: "Pairing host not reachable. Run `iframer install extension` in a terminal, then restart the browser.",
|
|
599
|
+
});
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
await chrome.storage.local.set({ token });
|
|
603
|
+
resetScanBackoff();
|
|
604
|
+
try { if (ws) ws.close(); } catch {}
|
|
605
|
+
ws = null;
|
|
606
|
+
await connectLoop();
|
|
607
|
+
const { status } = await chrome.storage.local.get("status");
|
|
608
|
+
sendResponse({ ok: true, token, status: status || { connected: false } });
|
|
609
|
+
} else if (msg.cmd === "set-token") {
|
|
610
|
+
await chrome.storage.local.set({ token: msg.token || "" });
|
|
611
|
+
// Force a fresh connection attempt with the new token.
|
|
612
|
+
resetScanBackoff();
|
|
613
|
+
try { if (ws) ws.close(); } catch {}
|
|
614
|
+
ws = null;
|
|
615
|
+
await connectLoop();
|
|
616
|
+
const { status } = await chrome.storage.local.get("status");
|
|
617
|
+
sendResponse({ ok: true, status: status || { connected: false } });
|
|
618
|
+
} else {
|
|
619
|
+
sendResponse({ ok: false, error: "unknown command" });
|
|
620
|
+
}
|
|
621
|
+
})();
|
|
622
|
+
return true; // async response
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// Keep trying to (re)connect. Two layers, because an MV3 service worker is
|
|
626
|
+
// killed after ~30s idle and a plain setInterval dies with it:
|
|
627
|
+
// - setInterval: fast retry while the worker is awake.
|
|
628
|
+
// - chrome.alarms: wakes the (possibly-terminated) worker to reconnect.
|
|
629
|
+
connectLoop();
|
|
630
|
+
setInterval(() => {
|
|
631
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) connectLoop();
|
|
632
|
+
}, RECONNECT_MS);
|
|
633
|
+
|
|
634
|
+
chrome.alarms.create("iframer-reconnect", { periodInMinutes: 0.5 });
|
|
635
|
+
chrome.alarms.onAlarm.addListener((a) => {
|
|
636
|
+
if (a.name === "iframer-reconnect" && (!ws || ws.readyState !== WebSocket.OPEN)) {
|
|
637
|
+
connectLoop();
|
|
638
|
+
}
|
|
639
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "iframer",
|
|
4
|
+
"version": "0.10.1",
|
|
5
|
+
"description": "Let iframer drive a Chrome tab you choose, on your real logged-in session.",
|
|
6
|
+
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp2wbce98k+xDnEgtuzLyNXYBLEDY0nqChuvolb94B3VV1HsHFUulUyNe92eN6MjQuadDohQ2j5mj7zWs4yQoixgTQPe7Gm/+GHWdgxwM2s9t5opnLHwF90LPiJm6q1NGWLM4aK6JT+2I6FnUGmj2bzB4WIlf55C3aMEBoXTqIzG2U8s2cxkXGebWWqbPlMK/Phsmsvo4POWnidbGHPhBOq0iyPTu7RdSDlNdJdJ+dtdNXkMmltKUS881MQI9T6GcCCKhIKcZrJGGnk0xphlWyl+ublTAO0tbQSVB/K7D3JmCxMv690TPtYUPPhGi+C6VCv3IeutLmNo60mvux3wbGQIDAQAB",
|
|
7
|
+
"permissions": [
|
|
8
|
+
"tabs",
|
|
9
|
+
"tabGroups",
|
|
10
|
+
"scripting",
|
|
11
|
+
"storage",
|
|
12
|
+
"activeTab",
|
|
13
|
+
"alarms",
|
|
14
|
+
"identity",
|
|
15
|
+
"identity.email",
|
|
16
|
+
"debugger",
|
|
17
|
+
"nativeMessaging"
|
|
18
|
+
],
|
|
19
|
+
"host_permissions": [
|
|
20
|
+
"<all_urls>",
|
|
21
|
+
"http://127.0.0.1/*",
|
|
22
|
+
"http://localhost/*"
|
|
23
|
+
],
|
|
24
|
+
"background": {
|
|
25
|
+
"service_worker": "background.js",
|
|
26
|
+
"type": "module"
|
|
27
|
+
},
|
|
28
|
+
"action": {
|
|
29
|
+
"default_popup": "popup.html",
|
|
30
|
+
"default_title": "iframer"
|
|
31
|
+
}
|
|
32
|
+
}
|