iframer-toolkit 0.10.6 → 0.11.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 +66 -65
- package/dist/cli.js +2935 -1776
- package/dist/local-server.cjs +1503 -1407
- package/dist/mcp-server.cjs +59 -115
- package/extension/README.md +10 -8
- package/extension/background.js +135 -84
- package/extension/manifest.json +1 -1
- package/extension/native-host.cjs +105 -29
- package/extension/popup.js +13 -4
- package/package.json +3 -6
- package/skills/iframer.md +103 -31
package/extension/background.js
CHANGED
|
@@ -12,10 +12,17 @@
|
|
|
12
12
|
const PORT_START = 3022;
|
|
13
13
|
const PORT_END = 3042;
|
|
14
14
|
const RECONNECT_MS = 2000;
|
|
15
|
+
// Windows takes about 2s to refuse a dead port, and a busy server can take a
|
|
16
|
+
// moment to accept, so don't call a port a miss too quickly.
|
|
17
|
+
const OPEN_TIMEOUT_MS = 1500;
|
|
18
|
+
// The server answers a good token with server_info and a bad one with close 4001.
|
|
19
|
+
const AUTH_TIMEOUT_MS = 5000;
|
|
20
|
+
// A cold node start can take seconds on Windows (antivirus scanning).
|
|
21
|
+
const NATIVE_TIMEOUT_MS = 8000;
|
|
15
22
|
|
|
16
23
|
let ws = null;
|
|
17
24
|
let currentPort = null;
|
|
18
|
-
let
|
|
25
|
+
let scanPromise = null;
|
|
19
26
|
|
|
20
27
|
async function getToken() {
|
|
21
28
|
const { token } = await chrome.storage.local.get("token");
|
|
@@ -28,12 +35,16 @@ async function getToken() {
|
|
|
28
35
|
// isn't installed (→ legacy port scan).
|
|
29
36
|
function nativeCall(msg) {
|
|
30
37
|
return new Promise((resolve) => {
|
|
38
|
+
// A host that never answers must not block connecting: treat it as absent.
|
|
39
|
+
const timer = setTimeout(() => resolve(null), NATIVE_TIMEOUT_MS);
|
|
31
40
|
try {
|
|
32
41
|
chrome.runtime.sendNativeMessage("com.iframer.token", msg, (resp) => {
|
|
33
42
|
void chrome.runtime.lastError; // host not installed — that's fine
|
|
43
|
+
clearTimeout(timer);
|
|
34
44
|
resolve(resp || null);
|
|
35
45
|
});
|
|
36
46
|
} catch {
|
|
47
|
+
clearTimeout(timer);
|
|
37
48
|
resolve(null);
|
|
38
49
|
}
|
|
39
50
|
});
|
|
@@ -51,6 +62,9 @@ async function fetchTokenNatively() {
|
|
|
51
62
|
// DISPLAY NAME. Returns {email, id} or null if not signed in.
|
|
52
63
|
function getChromeProfile() {
|
|
53
64
|
return new Promise((resolve) => {
|
|
65
|
+
// Not every Chromium build implements this API. The email is only a display
|
|
66
|
+
// name, so a call that never returns must not hold up connecting.
|
|
67
|
+
setTimeout(() => resolve(null), 1000);
|
|
54
68
|
try {
|
|
55
69
|
chrome.identity.getProfileUserInfo({ accountStatus: "ANY" }, (info) => {
|
|
56
70
|
if (chrome.runtime.lastError) return resolve(null);
|
|
@@ -111,9 +125,17 @@ async function sendHello(sock) {
|
|
|
111
125
|
}
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
128
|
+
// One at a time: this reads, merges, then writes, so two overlapping calls could
|
|
129
|
+
// put back the old value (a live connection showing as "disconnected").
|
|
130
|
+
let statusWrites = Promise.resolve();
|
|
131
|
+
function setStatus(patch) {
|
|
132
|
+
statusWrites = statusWrites
|
|
133
|
+
.then(async () => {
|
|
134
|
+
const prev = (await chrome.storage.local.get("status")).status || {};
|
|
135
|
+
await chrome.storage.local.set({ status: { ...prev, ...patch } });
|
|
136
|
+
})
|
|
137
|
+
.catch(() => {});
|
|
138
|
+
return statusWrites;
|
|
117
139
|
}
|
|
118
140
|
|
|
119
141
|
// Numeric semver compare: is `a` newer than `b`? (x.y.z, extra parts ignored.)
|
|
@@ -156,71 +178,104 @@ async function checkForUpdate(serverVersion) {
|
|
|
156
178
|
let nextScanAt = 0;
|
|
157
179
|
let scanBackoffMs = 0;
|
|
158
180
|
const SCAN_BACKOFF_MAX_MS = 60_000;
|
|
181
|
+
// Asking the native host dials nothing, so there's no console noise to back off
|
|
182
|
+
// from: keep it quick, so a server that just started gets found.
|
|
183
|
+
const HOST_POLL_MAX_MS = 10_000;
|
|
159
184
|
|
|
160
185
|
function resetScanBackoff() {
|
|
161
186
|
nextScanAt = 0;
|
|
162
187
|
scanBackoffMs = 0;
|
|
163
188
|
}
|
|
164
189
|
|
|
165
|
-
function bumpScanBackoff() {
|
|
166
|
-
scanBackoffMs = Math.min(scanBackoffMs ? scanBackoffMs * 2 : 5_000,
|
|
190
|
+
function bumpScanBackoff(maxMs = SCAN_BACKOFF_MAX_MS) {
|
|
191
|
+
scanBackoffMs = Math.min(scanBackoffMs ? scanBackoffMs * 2 : 5_000, maxMs);
|
|
167
192
|
nextScanAt = Date.now() + scanBackoffMs;
|
|
168
193
|
}
|
|
169
194
|
|
|
170
|
-
|
|
171
|
-
if (
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
}
|
|
195
|
+
function connectLoop() {
|
|
196
|
+
if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve();
|
|
197
|
+
if (scanPromise) return scanPromise;
|
|
198
|
+
if (Date.now() < nextScanAt) return Promise.resolve();
|
|
199
|
+
scanPromise = runScan()
|
|
200
|
+
.catch(() => {})
|
|
201
|
+
.finally(() => {
|
|
202
|
+
scanPromise = null;
|
|
203
|
+
});
|
|
204
|
+
return scanPromise;
|
|
205
|
+
}
|
|
190
206
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
// For an explicit action (new token, auto-pair): skip the backoff, let any scan
|
|
208
|
+
// still using the old token finish, then connect fresh and wait for the outcome.
|
|
209
|
+
async function reconnectNow() {
|
|
210
|
+
resetScanBackoff();
|
|
211
|
+
if (scanPromise) await scanPromise;
|
|
212
|
+
try { if (ws) ws.close(); } catch {}
|
|
213
|
+
ws = null;
|
|
214
|
+
await connectLoop();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Try each known token on this port. Returns "ok", "bad-token" or "miss".
|
|
218
|
+
async function connectToPort(port, tokens) {
|
|
219
|
+
let result = "miss";
|
|
220
|
+
for (const token of tokens) {
|
|
221
|
+
result = await tryConnect(port, token);
|
|
222
|
+
if (result === "ok") await chrome.storage.local.set({ token });
|
|
223
|
+
if (result !== "bad-token") return result;
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function runScan() {
|
|
229
|
+
// Ask the native host where the server is. When it answers, we never dial
|
|
230
|
+
// blind: a dead port dialed = a console error logged, so we only open a
|
|
231
|
+
// socket to a port it found a live server on.
|
|
232
|
+
const info = await nativeCall({ cmd: "get-info" });
|
|
233
|
+
const stored = await getToken();
|
|
234
|
+
// The host's token first, then any stored or pasted one. Neither overwrites
|
|
235
|
+
// the other until the server has actually accepted it.
|
|
236
|
+
const tokens = [...new Set([info && info.token, stored].filter(Boolean).map(String))];
|
|
237
|
+
if (!tokens.length) {
|
|
238
|
+
await setStatus({ connected: false, reason: "no-token" });
|
|
239
|
+
bumpScanBackoff();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (!stored) await chrome.storage.local.set({ token: tokens[0] });
|
|
243
|
+
|
|
244
|
+
let result = "miss";
|
|
245
|
+
const hostKnowsServer = !!(info && info.ok && "alive" in info);
|
|
246
|
+
if (hostKnowsServer) {
|
|
247
|
+
if (!info.alive || !info.port) {
|
|
248
|
+
// Server simply isn't running (it starts with an iframer session).
|
|
249
|
+
// Don't attempt any connection — zero console noise.
|
|
250
|
+
await setStatus({ connected: false, reason: "server-not-running" });
|
|
251
|
+
bumpScanBackoff(HOST_POLL_MAX_MS);
|
|
206
252
|
return;
|
|
207
253
|
}
|
|
208
|
-
|
|
254
|
+
result = await connectToPort(info.port, tokens);
|
|
255
|
+
} else {
|
|
209
256
|
// No native host (or an old copy) — legacy full port scan.
|
|
210
257
|
for (let port = PORT_START; port <= PORT_END; port++) {
|
|
211
|
-
const
|
|
212
|
-
if (ok) {
|
|
213
|
-
|
|
214
|
-
|
|
258
|
+
const r = await connectToPort(port, tokens);
|
|
259
|
+
if (r === "ok") {
|
|
260
|
+
result = r;
|
|
261
|
+
break;
|
|
215
262
|
}
|
|
263
|
+
// Keep looking after a rejection: another user's server can hold a lower
|
|
264
|
+
// port on the same machine, with ours further up the window.
|
|
265
|
+
if (r === "bad-token") result = r;
|
|
216
266
|
}
|
|
217
|
-
await setStatus({ connected: false, reason: "server-not-found" });
|
|
218
|
-
bumpScanBackoff();
|
|
219
|
-
} finally {
|
|
220
|
-
scanning = false;
|
|
221
267
|
}
|
|
268
|
+
|
|
269
|
+
if (result === "ok") {
|
|
270
|
+
resetScanBackoff();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
await setStatus({ connected: false, reason: result === "bad-token" ? "bad-token" : "server-not-found" });
|
|
274
|
+
bumpScanBackoff(hostKnowsServer ? HOST_POLL_MAX_MS : SCAN_BACKOFF_MAX_MS);
|
|
222
275
|
}
|
|
223
276
|
|
|
277
|
+
// Resolves "ok" only after the server has accepted the token. An open socket
|
|
278
|
+
// proves nothing: auth happens after it opens.
|
|
224
279
|
function tryConnect(port, token) {
|
|
225
280
|
return new Promise((resolve) => {
|
|
226
281
|
let settled = false;
|
|
@@ -230,47 +285,44 @@ function tryConnect(port, token) {
|
|
|
230
285
|
// goes in a first {type:"auth"} message instead; the server waits for it.
|
|
231
286
|
sock = new WebSocket(`ws://127.0.0.1:${port}/extension/ws`);
|
|
232
287
|
} catch {
|
|
233
|
-
resolve(
|
|
288
|
+
resolve("miss");
|
|
234
289
|
return;
|
|
235
290
|
}
|
|
291
|
+
let timer;
|
|
236
292
|
const done = (val) => {
|
|
293
|
+
clearTimeout(timer);
|
|
237
294
|
if (!settled) {
|
|
238
295
|
settled = true;
|
|
239
296
|
resolve(val);
|
|
240
297
|
}
|
|
241
298
|
};
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
299
|
+
const giveUpAfter = (ms) => {
|
|
300
|
+
clearTimeout(timer);
|
|
301
|
+
timer = setTimeout(() => {
|
|
302
|
+
try { sock.close(); } catch {}
|
|
303
|
+
done("miss");
|
|
304
|
+
}, ms);
|
|
305
|
+
};
|
|
306
|
+
giveUpAfter(OPEN_TIMEOUT_MS);
|
|
247
307
|
|
|
248
308
|
sock.onopen = () => {
|
|
249
|
-
|
|
250
|
-
ws = sock;
|
|
251
|
-
currentPort = port;
|
|
252
|
-
wire(sock);
|
|
309
|
+
giveUpAfter(AUTH_TIMEOUT_MS);
|
|
253
310
|
try {
|
|
254
311
|
sock.send(JSON.stringify({ type: "auth", token }));
|
|
255
312
|
} catch {}
|
|
313
|
+
};
|
|
314
|
+
// The server only sends us anything once the token is accepted.
|
|
315
|
+
sock.onmessage = (ev) => {
|
|
316
|
+
ws = sock;
|
|
317
|
+
currentPort = port;
|
|
318
|
+
wire(sock);
|
|
319
|
+
handleMessage(sock, ev.data);
|
|
256
320
|
sendHello(sock);
|
|
257
321
|
setStatus({ connected: true, port, reason: null });
|
|
258
|
-
done(
|
|
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);
|
|
322
|
+
done("ok");
|
|
273
323
|
};
|
|
324
|
+
sock.onclose = (ev) => done(ev.code === 4001 ? "bad-token" : "miss");
|
|
325
|
+
sock.onerror = () => done("miss");
|
|
274
326
|
});
|
|
275
327
|
}
|
|
276
328
|
|
|
@@ -561,6 +613,11 @@ chrome.debugger.onDetach.addListener((source, reason) => {
|
|
|
561
613
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
562
614
|
(async () => {
|
|
563
615
|
if (msg.cmd === "get-state") {
|
|
616
|
+
// Opening the popup means someone is waiting: retry now, not on the backoff.
|
|
617
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
618
|
+
resetScanBackoff();
|
|
619
|
+
connectLoop();
|
|
620
|
+
}
|
|
564
621
|
const { status } = await chrome.storage.local.get("status");
|
|
565
622
|
const { profileId, profileName } = await ensureProfile();
|
|
566
623
|
sendResponse({ ok: true, status: status || { connected: false }, profileId, profileName });
|
|
@@ -595,24 +652,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
|
595
652
|
if (!token) {
|
|
596
653
|
sendResponse({
|
|
597
654
|
ok: false,
|
|
598
|
-
error: "Pairing host not reachable. Run `iframer install extension` in a terminal, then
|
|
655
|
+
error: "Pairing host not reachable. Run `iframer install extension chrome` in a terminal, then try again.",
|
|
599
656
|
});
|
|
600
657
|
return;
|
|
601
658
|
}
|
|
602
659
|
await chrome.storage.local.set({ token });
|
|
603
|
-
|
|
604
|
-
try { if (ws) ws.close(); } catch {}
|
|
605
|
-
ws = null;
|
|
606
|
-
await connectLoop();
|
|
660
|
+
await reconnectNow();
|
|
607
661
|
const { status } = await chrome.storage.local.get("status");
|
|
608
662
|
sendResponse({ ok: true, token, status: status || { connected: false } });
|
|
609
663
|
} else if (msg.cmd === "set-token") {
|
|
610
664
|
await chrome.storage.local.set({ token: msg.token || "" });
|
|
611
665
|
// Force a fresh connection attempt with the new token.
|
|
612
|
-
|
|
613
|
-
try { if (ws) ws.close(); } catch {}
|
|
614
|
-
ws = null;
|
|
615
|
-
await connectLoop();
|
|
666
|
+
await reconnectNow();
|
|
616
667
|
const { status } = await chrome.storage.local.get("status");
|
|
617
668
|
sendResponse({ ok: true, status: status || { connected: false } });
|
|
618
669
|
} else {
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "iframer",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.11.0",
|
|
5
5
|
"description": "Let iframer drive a Chrome tab you choose, on your real logged-in session.",
|
|
6
6
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp2wbce98k+xDnEgtuzLyNXYBLEDY0nqChuvolb94B3VV1HsHFUulUyNe92eN6MjQuadDohQ2j5mj7zWs4yQoixgTQPe7Gm/+GHWdgxwM2s9t5opnLHwF90LPiJm6q1NGWLM4aK6JT+2I6FnUGmj2bzB4WIlf55C3aMEBoXTqIzG2U8s2cxkXGebWWqbPlMK/Phsmsvo4POWnidbGHPhBOq0iyPTu7RdSDlNdJdJ+dtdNXkMmltKUS881MQI9T6GcCCKhIKcZrJGGnk0xphlWyl+ublTAO0tbQSVB/K7D3JmCxMv690TPtYUPPhGi+C6VCv3IeutLmNo60mvux3wbGQIDAQAB",
|
|
7
7
|
"permissions": [
|
|
@@ -12,9 +12,16 @@
|
|
|
12
12
|
// stdlib) and must read the same secret locations as src/lib/auth/crypto.ts.
|
|
13
13
|
|
|
14
14
|
const fs = require("fs");
|
|
15
|
+
const http = require("http");
|
|
15
16
|
const os = require("os");
|
|
16
17
|
const path = require("path");
|
|
17
18
|
|
|
19
|
+
// Same window the local server allocates its port from
|
|
20
|
+
// (src/mcp/local-server.ts, extension/background.js).
|
|
21
|
+
const PORT_START = 3022;
|
|
22
|
+
const PORT_END = 3042;
|
|
23
|
+
const PROBE_TIMEOUT_MS = 1500;
|
|
24
|
+
|
|
18
25
|
function readToken() {
|
|
19
26
|
if (process.env.IFRAMER_SECRET) return process.env.IFRAMER_SECRET;
|
|
20
27
|
const dataDir = process.env.IFRAMER_DATA_DIR || path.join(os.homedir(), ".iframer");
|
|
@@ -33,34 +40,107 @@ function readToken() {
|
|
|
33
40
|
return "";
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
// server writes on startup (src/lib/browser/registry.ts) and checks the pid is
|
|
38
|
-
// actually alive — so the extension can connect straight to the right port
|
|
39
|
-
// instead of blind-scanning 21 ports (each dead dial logs a console error).
|
|
40
|
-
function readServerInfo() {
|
|
43
|
+
function readRegisteredPort() {
|
|
41
44
|
const dataDir = process.env.IFRAMER_DATA_DIR || path.join(os.homedir(), ".iframer");
|
|
42
45
|
try {
|
|
43
46
|
const info = JSON.parse(fs.readFileSync(path.join(dataDir, "server.json"), "utf8"));
|
|
44
|
-
|
|
45
|
-
if (info && typeof info.pid === "number") {
|
|
46
|
-
try {
|
|
47
|
-
process.kill(info.pid, 0);
|
|
48
|
-
alive = true;
|
|
49
|
-
} catch {
|
|
50
|
-
/* pid gone — stale registry */
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return { port: info && typeof info.port === "number" ? info.port : null, alive };
|
|
47
|
+
return info && typeof info.port === "number" ? info.port : null;
|
|
54
48
|
} catch {
|
|
55
|
-
return
|
|
49
|
+
return null;
|
|
56
50
|
}
|
|
57
51
|
}
|
|
58
52
|
|
|
59
|
-
function
|
|
53
|
+
function probe(port) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
let settled = false;
|
|
56
|
+
const finish = (ok) => {
|
|
57
|
+
if (settled) return;
|
|
58
|
+
settled = true;
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
resolve(ok);
|
|
61
|
+
};
|
|
62
|
+
const req = http.get({ host: "127.0.0.1", port, path: "/health", agent: false }, (res) => {
|
|
63
|
+
let body = "";
|
|
64
|
+
res.setEncoding("utf8");
|
|
65
|
+
res.on("data", (d) => {
|
|
66
|
+
if (body.length < 4096) body += d;
|
|
67
|
+
});
|
|
68
|
+
res.on("end", () => {
|
|
69
|
+
try {
|
|
70
|
+
finish(JSON.parse(body).ok === true);
|
|
71
|
+
} catch {
|
|
72
|
+
finish(false);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
res.on("error", () => finish(false));
|
|
76
|
+
});
|
|
77
|
+
req.on("error", () => finish(false));
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
req.destroy();
|
|
80
|
+
finish(false);
|
|
81
|
+
}, PROBE_TIMEOUT_MS);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Where is the iframer server right now? Only a server that actually answers
|
|
86
|
+
// counts. A pid check can't decide this: on Windows a reused pid looks alive,
|
|
87
|
+
// and a server running as another user looks dead. Probing from here also lets
|
|
88
|
+
// the extension find the port without dialing dead ones (each dead dial logs a
|
|
89
|
+
// console error in the extension).
|
|
90
|
+
async function locateServer() {
|
|
91
|
+
const registered = readRegisteredPort();
|
|
92
|
+
if (registered && (await probe(registered))) return { port: registered, alive: true };
|
|
93
|
+
|
|
94
|
+
const ports = [];
|
|
95
|
+
for (let p = PORT_START; p <= PORT_END; p++) if (p !== registered) ports.push(p);
|
|
96
|
+
// All at once: Windows takes about 2s to refuse a closed port, one by one
|
|
97
|
+
// would take most of a minute.
|
|
98
|
+
const found = await new Promise((resolve) => {
|
|
99
|
+
let left = ports.length;
|
|
100
|
+
for (const p of ports) {
|
|
101
|
+
probe(p).then((ok) => {
|
|
102
|
+
if (ok) resolve(p);
|
|
103
|
+
else if (--left === 0) resolve(null);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
return found ? { port: found, alive: true } : { port: registered, alive: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function send(msg, done) {
|
|
60
111
|
const payload = Buffer.from(JSON.stringify(msg), "utf8");
|
|
61
112
|
const header = Buffer.alloc(4);
|
|
62
113
|
header.writeUInt32LE(payload.length, 0);
|
|
63
|
-
process.stdout.write(Buffer.concat([header, payload]));
|
|
114
|
+
process.stdout.write(Buffer.concat([header, payload]), done);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Chrome may close stdin while a reply is still being worked out, so exit only
|
|
118
|
+
// once stdin has ended AND every reply has been written.
|
|
119
|
+
let pending = 0;
|
|
120
|
+
let stdinEnded = false;
|
|
121
|
+
function exitIfDone() {
|
|
122
|
+
if (stdinEnded && pending === 0) process.exit(0);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function reply(msg) {
|
|
126
|
+
let out;
|
|
127
|
+
try {
|
|
128
|
+
if (msg && msg.cmd === "get-token") {
|
|
129
|
+
const token = readToken();
|
|
130
|
+
out = token ? { ok: true, token } : { ok: false, error: "no secret file found — run any iframer command once to create it" };
|
|
131
|
+
} else if (msg && msg.cmd === "get-info") {
|
|
132
|
+
// Token + live server location in one call.
|
|
133
|
+
out = { ok: true, token: readToken() || null, ...(await locateServer()) };
|
|
134
|
+
} else {
|
|
135
|
+
out = { ok: false, error: "unknown command" };
|
|
136
|
+
}
|
|
137
|
+
} catch (e) {
|
|
138
|
+
out = { ok: false, error: String((e && e.message) || e) };
|
|
139
|
+
}
|
|
140
|
+
send(out, () => {
|
|
141
|
+
pending--;
|
|
142
|
+
exitIfDone();
|
|
143
|
+
});
|
|
64
144
|
}
|
|
65
145
|
|
|
66
146
|
let buf = Buffer.alloc(0);
|
|
@@ -75,17 +155,13 @@ process.stdin.on("data", (chunk) => {
|
|
|
75
155
|
try {
|
|
76
156
|
msg = JSON.parse(body.toString("utf8"));
|
|
77
157
|
} catch {
|
|
78
|
-
/*
|
|
79
|
-
}
|
|
80
|
-
if (msg && msg.cmd === "get-token") {
|
|
81
|
-
const token = readToken();
|
|
82
|
-
send(token ? { ok: true, token } : { ok: false, error: "no secret file found — run any iframer command once to create it" });
|
|
83
|
-
} else if (msg && msg.cmd === "get-info") {
|
|
84
|
-
// Token + live server location in one call.
|
|
85
|
-
send({ ok: true, token: readToken() || null, ...readServerInfo() });
|
|
86
|
-
} else {
|
|
87
|
-
send({ ok: false, error: "unknown command" });
|
|
158
|
+
/* answered as an unknown command */
|
|
88
159
|
}
|
|
160
|
+
pending++;
|
|
161
|
+
reply(msg);
|
|
89
162
|
}
|
|
90
163
|
});
|
|
91
|
-
process.stdin.on("end", () =>
|
|
164
|
+
process.stdin.on("end", () => {
|
|
165
|
+
stdinEnded = true;
|
|
166
|
+
exitIfDone();
|
|
167
|
+
});
|
package/extension/popup.js
CHANGED
|
@@ -15,10 +15,10 @@ function renderStatus(status) {
|
|
|
15
15
|
const connected = status && status.connected;
|
|
16
16
|
$("dot").className = "dot" + (connected ? " on" : "");
|
|
17
17
|
const reasonText = {
|
|
18
|
-
"no-token": "No token
|
|
19
|
-
"server-not-found": "iframer server not found.
|
|
18
|
+
"no-token": "No token yet. Run `iframer install extension chrome` in a terminal and this pairs by itself, or paste the output of `iframer extension token` below.",
|
|
19
|
+
"server-not-found": "iframer server not found. It starts with your Claude/iframer session. Will connect by itself.",
|
|
20
20
|
"server-not-running": "iframer server not running — it starts with your Claude/iframer session. Will connect by itself.",
|
|
21
|
-
"bad-token": "Token rejected.
|
|
21
|
+
"bad-token": "Token rejected by the server. Paste the output of `iframer extension token` below.",
|
|
22
22
|
disconnected: "Disconnected — retrying…",
|
|
23
23
|
};
|
|
24
24
|
if (connected) {
|
|
@@ -86,7 +86,7 @@ $("autopair").addEventListener("click", async () => {
|
|
|
86
86
|
const res = await send("auto-pair");
|
|
87
87
|
if (res.ok) {
|
|
88
88
|
if (res.token) $("token").value = res.token;
|
|
89
|
-
$("pairmsg").textContent = "✓ Paired.";
|
|
89
|
+
$("pairmsg").textContent = res.status && res.status.connected ? "✓ Paired." : "Token saved. See the status above.";
|
|
90
90
|
renderStatus(res.status);
|
|
91
91
|
} else {
|
|
92
92
|
$("pairmsg").textContent = res.error || "Auto-pair failed — paste the token manually below.";
|
|
@@ -105,4 +105,13 @@ $("save").addEventListener("click", async () => {
|
|
|
105
105
|
$("save").textContent = "Save & connect";
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
// Keep the popup live: pairing usually finishes while it's open.
|
|
109
|
+
chrome.storage.onChanged.addListener((changes, area) => {
|
|
110
|
+
if (area !== "local") return;
|
|
111
|
+
if (changes.status) renderStatus(changes.status.newValue);
|
|
112
|
+
if (changes.token && changes.token.newValue && document.activeElement !== $("token")) {
|
|
113
|
+
$("token").value = changes.token.newValue;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
108
117
|
init();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iframer-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Browser access toolkit for AI agents — CLI and MCP server for headless/headful browsing, login flows, and pipeline execution.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"iframer",
|
|
@@ -39,9 +39,6 @@
|
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
41
|
"start": "bun run index.ts",
|
|
42
|
-
"start:docker": "docker compose up --build -d",
|
|
43
|
-
"stop:docker": "docker compose down",
|
|
44
|
-
"logs:docker": "docker compose logs -f api",
|
|
45
42
|
"test:smoke": "node tests/smoke.js",
|
|
46
43
|
"mcp:install": "node bin/cli.js install-mcp",
|
|
47
44
|
"mcp:remove": "node bin/cli.js remove-mcp",
|
|
@@ -50,13 +47,13 @@
|
|
|
50
47
|
"test": "bun test tests/unit",
|
|
51
48
|
"test:extension": "node tests/integration/extension-bridge.mjs",
|
|
52
49
|
"prepublishOnly": "bun run build",
|
|
53
|
-
"test:cdp-relay": "bun tests/integration/cdp-relay.mjs"
|
|
50
|
+
"test:cdp-relay": "bun tests/integration/cdp-relay.mjs",
|
|
51
|
+
"test:kit": "bun tests/integration/kit-bridge.mjs"
|
|
54
52
|
},
|
|
55
53
|
"dependencies": {
|
|
56
54
|
"@anthropic-ai/sdk": "^0.80.0",
|
|
57
55
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
58
56
|
"better-sqlite3": "^12.8.0",
|
|
59
|
-
"fingerprint-generator": "^2.1.81",
|
|
60
57
|
"patchright": "^1.58.2",
|
|
61
58
|
"playwright-core": "^1.58.0",
|
|
62
59
|
"ws": "^8.18.0",
|