iframer-toolkit 0.10.8 → 0.11.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/README.md +68 -65
- package/dist/cli.js +2620 -1769
- package/dist/local-server.cjs +767 -1200
- package/dist/mcp-server.cjs +26 -110
- 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 +44 -25
package/dist/mcp-server.cjs
CHANGED
|
@@ -112,8 +112,8 @@ function isPidAlive(pid) {
|
|
|
112
112
|
try {
|
|
113
113
|
process.kill(pid, 0);
|
|
114
114
|
return true;
|
|
115
|
-
} catch {
|
|
116
|
-
return
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return e.code === "EPERM";
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
function readServerInfo() {
|
|
@@ -208,7 +208,6 @@ var LocalServerManager = class {
|
|
|
208
208
|
const env = {
|
|
209
209
|
...process.env,
|
|
210
210
|
PORT: String(port),
|
|
211
|
-
IFRAMER_MODE: "local",
|
|
212
211
|
IFRAMER_DATA_DIR: dataDir
|
|
213
212
|
};
|
|
214
213
|
if (process.env.IFRAMER_SECRET) {
|
|
@@ -223,8 +222,12 @@ var LocalServerManager = class {
|
|
|
223
222
|
const child = (0, import_child_process.spawn)(command, args, {
|
|
224
223
|
env,
|
|
225
224
|
stdio: ["ignore", logFd, logFd],
|
|
226
|
-
detached: true
|
|
225
|
+
detached: true,
|
|
226
|
+
// On Windows a detached child otherwise gets its own console window,
|
|
227
|
+
// and closing that window kills the server.
|
|
228
|
+
windowsHide: true
|
|
227
229
|
});
|
|
230
|
+
child.on("error", (e) => console.error(`[iframer] could not start the local server: ${e.message}`));
|
|
228
231
|
child.unref();
|
|
229
232
|
import_fs3.default.closeSync(logFd);
|
|
230
233
|
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
|
|
@@ -374,9 +377,7 @@ function getErrorMessage(err2) {
|
|
|
374
377
|
|
|
375
378
|
// src/mcp/helpers.ts
|
|
376
379
|
var log2 = createLogger("mcp");
|
|
377
|
-
var BASE_URL = process.env.IFRAMER_URL || "http://localhost:3021";
|
|
378
380
|
var IFRAMER_SECRET = process.env.IFRAMER_SECRET;
|
|
379
|
-
var IFRAMER_MODE = process.env.IFRAMER_MODE;
|
|
380
381
|
var LOCAL_TOKEN = getLocalToken();
|
|
381
382
|
var localServer = new LocalServerManager();
|
|
382
383
|
async function ensureLocalServer() {
|
|
@@ -388,19 +389,6 @@ function authHeaders(secret) {
|
|
|
388
389
|
if (key) headers["x-api-key"] = key;
|
|
389
390
|
return headers;
|
|
390
391
|
}
|
|
391
|
-
async function apiPost(endpoint, body) {
|
|
392
|
-
const res = await fetch(`${BASE_URL}${endpoint}`, {
|
|
393
|
-
method: "POST",
|
|
394
|
-
headers: authHeaders(),
|
|
395
|
-
body: body ? JSON.stringify(body) : void 0,
|
|
396
|
-
signal: AbortSignal.timeout(18e4)
|
|
397
|
-
});
|
|
398
|
-
return res.json();
|
|
399
|
-
}
|
|
400
|
-
async function apiGet(endpoint) {
|
|
401
|
-
const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders(), signal: AbortSignal.timeout(3e4) });
|
|
402
|
-
return res.json();
|
|
403
|
-
}
|
|
404
392
|
async function localApiPost(endpoint, body, timeoutMs = 18e4) {
|
|
405
393
|
await ensureLocalServer();
|
|
406
394
|
const url = localServer.getBaseUrl();
|
|
@@ -424,15 +412,6 @@ async function localApiDelete(endpoint) {
|
|
|
424
412
|
const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(3e4) });
|
|
425
413
|
return res.json();
|
|
426
414
|
}
|
|
427
|
-
async function isDockerRunning() {
|
|
428
|
-
try {
|
|
429
|
-
const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(3e3) });
|
|
430
|
-
const data = await res.json();
|
|
431
|
-
return data.ok === true;
|
|
432
|
-
} catch {
|
|
433
|
-
return false;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
415
|
async function resolveScreenshotPath(url) {
|
|
437
416
|
try {
|
|
438
417
|
if (url.startsWith("file://")) {
|
|
@@ -448,7 +427,7 @@ async function resolveScreenshotPath(url) {
|
|
|
448
427
|
const fs10 = await import("fs");
|
|
449
428
|
const dir = import_path5.default.join(import_os3.default.tmpdir(), "iframer-screenshots", "screenshots");
|
|
450
429
|
fs10.mkdirSync(dir, { recursive: true });
|
|
451
|
-
const filePath = import_path5.default.join(dir, `
|
|
430
|
+
const filePath = import_path5.default.join(dir, `screenshot-${Date.now()}.jpg`);
|
|
452
431
|
fs10.writeFileSync(filePath, buf);
|
|
453
432
|
return filePath;
|
|
454
433
|
} catch {
|
|
@@ -467,7 +446,7 @@ function defaultFile() {
|
|
|
467
446
|
return import_path6.default.join(getDataDir(), "domain-modes.json");
|
|
468
447
|
}
|
|
469
448
|
var TTL_DAYS = 14;
|
|
470
|
-
var ESCALATION_LADDER = ["headless", "
|
|
449
|
+
var ESCALATION_LADDER = ["headless", "binary-headful"];
|
|
471
450
|
var DomainModeStore = class {
|
|
472
451
|
data = {};
|
|
473
452
|
filePath;
|
|
@@ -572,8 +551,6 @@ function registerStatusTool(server2) {
|
|
|
572
551
|
async () => {
|
|
573
552
|
try {
|
|
574
553
|
const status = {};
|
|
575
|
-
const dockerRunning = await isDockerRunning();
|
|
576
|
-
status.dockerApi = dockerRunning;
|
|
577
554
|
try {
|
|
578
555
|
const localHealth = await localApiGet("/health");
|
|
579
556
|
status.localServer = localHealth.ok;
|
|
@@ -591,13 +568,6 @@ function registerStatusTool(server2) {
|
|
|
591
568
|
if (inst.instances && inst.instances.length) status.liveInstances = inst.instances;
|
|
592
569
|
} catch {
|
|
593
570
|
}
|
|
594
|
-
if (dockerRunning) {
|
|
595
|
-
try {
|
|
596
|
-
const sessionData = await apiGet("/interactive/status");
|
|
597
|
-
status.dockerSession = sessionData.active ? { active: true, noVncUrl: sessionData.noVncUrl } : { active: false };
|
|
598
|
-
} catch {
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
571
|
try {
|
|
602
572
|
const credData = await localApiGet("/credentials");
|
|
603
573
|
status.credentials = credData.domains || [];
|
|
@@ -706,8 +676,8 @@ A captcha is blocking this run. Use the "recaptcha" step with an action:
|
|
|
706
676
|
{type:"recaptcha", action:"click"} \u2192 click the "I'm not a robot" checkbox
|
|
707
677
|
{type:"recaptcha", action:"answer", tiles:[...]} \u2192 select tiles + verify + re-check (handles refreshing grids)
|
|
708
678
|
{type:"recaptcha", action:"select"|"verify"} \u2192 manual tile-select / submit, if you need finer control
|
|
709
|
-
{type:"recaptcha", action:"solve"} \u2192 automatic vision solve (
|
|
710
|
-
Or {type:"solve-captcha"} for one-shot auto-detect + solve.
|
|
679
|
+
{type:"recaptcha", action:"solve"} \u2192 automatic vision solve (needs ANTHROPIC_API_KEY)
|
|
680
|
+
Or {type:"solve-captcha"} for one-shot auto-detect + solve (needs ANTHROPIC_API_KEY).
|
|
711
681
|
In binary-headful mode, prefer asking the user to solve it in the visible window.`;
|
|
712
682
|
function captchaBlocked(data) {
|
|
713
683
|
if (data.error?.errorType === "captcha-unsolvable") return true;
|
|
@@ -840,7 +810,7 @@ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstac
|
|
|
840
810
|
continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
841
811
|
continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
842
812
|
captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes. Also surfaces every HTTP >= 400 response per step \u2014 turn it on when a form submit fails with only a vague on-page message, to read the server's real answer."),
|
|
843
|
-
mode: import_zod3.z.enum(["headless", "binary-headful", "
|
|
813
|
+
mode: import_zod3.z.enum(["headless", "binary-headful", "extension"]).optional().describe("DO NOT SET unless the user explicitly requests a mode \u2014 iframer auto-selects and auto-escalates. 'extension' drives a real-Chrome tab (requires options.tabId from the `tabs` tool)."),
|
|
844
814
|
autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
|
|
845
815
|
instanceId: import_zod3.z.string().optional().describe("Named parallel browser (default 'default') \u2014 distinct ids drive several browsers at once, each with its own session state."),
|
|
846
816
|
tabId: import_zod3.z.number().optional().describe("mode='extension': the real-Chrome tab id to drive (from the `tabs` tool)."),
|
|
@@ -850,6 +820,13 @@ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstac
|
|
|
850
820
|
},
|
|
851
821
|
async (params) => {
|
|
852
822
|
try {
|
|
823
|
+
let runWithMode2 = function(mode) {
|
|
824
|
+
return localApiPost("/execute", {
|
|
825
|
+
steps,
|
|
826
|
+
options: { ...params.options, mode: mode || void 0 }
|
|
827
|
+
});
|
|
828
|
+
};
|
|
829
|
+
var runWithMode = runWithMode2;
|
|
853
830
|
const steps = normalizeSteps(params.steps);
|
|
854
831
|
if (params.options?.mode === "extension") {
|
|
855
832
|
const tabId = params.options?.tabId;
|
|
@@ -873,43 +850,9 @@ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstac
|
|
|
873
850
|
if (!extResult.ok) return { content: content2, isError: true };
|
|
874
851
|
return { content: content2 };
|
|
875
852
|
}
|
|
876
|
-
const dockerRunning = await isDockerRunning();
|
|
877
|
-
async function runWithMode(mode) {
|
|
878
|
-
if (mode === "docker-headful") {
|
|
879
|
-
if (!dockerRunning) {
|
|
880
|
-
return {
|
|
881
|
-
ok: false,
|
|
882
|
-
completedSteps: 0,
|
|
883
|
-
totalSteps: steps.length,
|
|
884
|
-
results: [],
|
|
885
|
-
finalState: { url: "", title: "" },
|
|
886
|
-
obstacles: [],
|
|
887
|
-
durationMs: 0,
|
|
888
|
-
modeUsed: "docker-headful",
|
|
889
|
-
error: {
|
|
890
|
-
failedAtStep: 0,
|
|
891
|
-
failedStep: steps[0],
|
|
892
|
-
errorType: "action-failed",
|
|
893
|
-
message: "docker-headful mode was requested but the Docker API is not reachable.",
|
|
894
|
-
pageState: { url: "", title: "" },
|
|
895
|
-
suggestion: "Start Docker with `bun run start:docker`, or omit options.mode.",
|
|
896
|
-
retryable: false
|
|
897
|
-
}
|
|
898
|
-
};
|
|
899
|
-
}
|
|
900
|
-
return apiPost("/execute", {
|
|
901
|
-
steps,
|
|
902
|
-
options: { ...params.options, mode: "docker-headful", autoEscalate: false }
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
return localApiPost("/execute", {
|
|
906
|
-
steps,
|
|
907
|
-
options: { ...params.options, mode: mode || void 0 }
|
|
908
|
-
});
|
|
909
|
-
}
|
|
910
853
|
let execResult;
|
|
911
854
|
try {
|
|
912
|
-
execResult = await
|
|
855
|
+
execResult = await runWithMode2(params.options?.mode);
|
|
913
856
|
} catch (execErr) {
|
|
914
857
|
const msg = execErr instanceof Error ? execErr.message : String(execErr);
|
|
915
858
|
const isCrash = /ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|fetch failed/i.test(msg);
|
|
@@ -920,7 +863,7 @@ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstac
|
|
|
920
863
|
} catch {
|
|
921
864
|
}
|
|
922
865
|
try {
|
|
923
|
-
execResult = await
|
|
866
|
+
execResult = await runWithMode2(params.options?.mode);
|
|
924
867
|
} catch (retryErr) {
|
|
925
868
|
return err(
|
|
926
869
|
`Browser server crashed and retry also failed.
|
|
@@ -936,14 +879,8 @@ Call \`session restart\` to reset, then retry.`
|
|
|
936
879
|
}
|
|
937
880
|
const requestedMode = params.options?.mode;
|
|
938
881
|
if (!execResult.ok && execResult.error?.errorType === "bot-blocked" && params.options?.autoEscalate !== false && !requestedMode) {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
if (nextMode === "docker-headful" && !dockerRunning) continue;
|
|
942
|
-
log2.info(`Auto-escalating to ${nextMode}`);
|
|
943
|
-
execResult = await runWithMode(nextMode);
|
|
944
|
-
if (execResult.ok) break;
|
|
945
|
-
if (execResult.error?.errorType !== "bot-blocked") break;
|
|
946
|
-
}
|
|
882
|
+
log2.info("Auto-escalating to binary-headful");
|
|
883
|
+
execResult = await runWithMode2("binary-headful");
|
|
947
884
|
}
|
|
948
885
|
const lines = formatExecuteResult(execResult);
|
|
949
886
|
let screenshotUrl = null;
|
|
@@ -991,7 +928,7 @@ During capture, drive the site like a user (open the inbox, send a message\u2026
|
|
|
991
928
|
staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
|
|
992
929
|
continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
993
930
|
continueOnError: import_zod4.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
994
|
-
mode: import_zod4.z.enum(["headless", "binary-headful", "
|
|
931
|
+
mode: import_zod4.z.enum(["headless", "binary-headful", "extension"]).optional().describe("Mode override. 'extension' captures a tab already open in the user's real Chrome (requires options.tabId from `tabs`)."),
|
|
995
932
|
tabId: import_zod4.z.number().optional().describe("mode='extension': the real-Chrome tab id (from `tabs`)."),
|
|
996
933
|
clientId: import_zod4.z.string().optional().describe("mode='extension': owning profile's clientId when the tab is ambiguous.")
|
|
997
934
|
}).optional()
|
|
@@ -1004,7 +941,6 @@ During capture, drive the site like a user (open the inbox, send a message\u2026
|
|
|
1004
941
|
options: { ...params.options, captureApi: true }
|
|
1005
942
|
};
|
|
1006
943
|
const mode = params.options?.mode;
|
|
1007
|
-
const dockerRunning = await isDockerRunning();
|
|
1008
944
|
let captureResult;
|
|
1009
945
|
if (mode === "extension") {
|
|
1010
946
|
if (typeof params.options?.tabId !== "number") {
|
|
@@ -1016,8 +952,6 @@ During capture, drive the site like a user (open the inbox, send a message\u2026
|
|
|
1016
952
|
steps,
|
|
1017
953
|
options: { ...params.options, captureApi: true }
|
|
1018
954
|
});
|
|
1019
|
-
} else if (mode === "docker-headful" && dockerRunning) {
|
|
1020
|
-
captureResult = await apiPost("/execute", execParams);
|
|
1021
955
|
} else {
|
|
1022
956
|
captureResult = await localApiPost("/execute", execParams);
|
|
1023
957
|
}
|
|
@@ -1253,13 +1187,6 @@ stop: save cookies/localStorage and close the browser \u2014 ALWAYS call when br
|
|
|
1253
1187
|
parts.push(`Local restart failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1254
1188
|
}
|
|
1255
1189
|
}
|
|
1256
|
-
try {
|
|
1257
|
-
if (await isDockerRunning()) {
|
|
1258
|
-
await apiPost("/browser/restart");
|
|
1259
|
-
parts.push("Docker browser restarted.");
|
|
1260
|
-
}
|
|
1261
|
-
} catch {
|
|
1262
|
-
}
|
|
1263
1190
|
parts.push("Credentials and knowledge cache are untouched. Next execute launches a fresh browser.");
|
|
1264
1191
|
return { content: [{ type: "text", text: parts.join(" ") }] };
|
|
1265
1192
|
}
|
|
@@ -2091,18 +2018,7 @@ function safeJsonLen(v) {
|
|
|
2091
2018
|
}
|
|
2092
2019
|
|
|
2093
2020
|
// src/mcp/server.ts
|
|
2094
|
-
var
|
|
2095
|
-
var INSTRUCTIONS = IS_DEV ? `iframer-dev \u2014 local development instance of iframer (connects to ${BASE_URL}).
|
|
2096
|
-
|
|
2097
|
-
CRITICAL RULES:
|
|
2098
|
-
1. NEVER ask the user "do you have credentials?". Call credentials action=list and read the response.
|
|
2099
|
-
2. NEVER ask the user for passwords in chat. Use credentials action=store (secure form) or the CLI fallback.
|
|
2100
|
-
3. DO NOT present options when you can just act. Check credentials, execute, show results.
|
|
2101
|
-
4. If execute fails, read the error. It tells you exactly what happened and where.
|
|
2102
|
-
5. Call "session" action=stop when done to save session state.
|
|
2103
|
-
|
|
2104
|
-
CAPTCHA: Use the "solve-captcha" step \u2014 auto-detects reCAPTCHA/hCaptcha and solves with vision AI.
|
|
2105
|
-
REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map APIs.` : `iframer \u2014 browser access for AI agents when normal methods fail.
|
|
2021
|
+
var INSTRUCTIONS = `iframer \u2014 browser access for AI agents when normal methods fail.
|
|
2106
2022
|
|
|
2107
2023
|
CRITICAL RULES:
|
|
2108
2024
|
1. NEVER ask "do you have credentials?". Call credentials action=list and read the response.
|
|
@@ -2119,7 +2035,7 @@ If headless detects Imperva or Akamai, immediately stop headless and pivot to he
|
|
|
2119
2035
|
|
|
2120
2036
|
BROWSER MODES: Don't specify options.mode \u2014 iframer auto-selects and auto-escalates (headless \u2192 binary-headful). Only set a mode if the user explicitly asks.
|
|
2121
2037
|
|
|
2122
|
-
CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window.
|
|
2038
|
+
CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window. Otherwise try the "solve-captcha" step (best effort, needs ANTHROPIC_API_KEY).
|
|
2123
2039
|
|
|
2124
2040
|
REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map/save API endpoints.`;
|
|
2125
2041
|
var server = new import_mcp.McpServer(
|
package/extension/README.md
CHANGED
|
@@ -81,22 +81,24 @@ build` instead; `iframer update` detects that and says so.)
|
|
|
81
81
|
## Pair it (one time, zero copying)
|
|
82
82
|
|
|
83
83
|
```sh
|
|
84
|
-
iframer install extension
|
|
84
|
+
iframer install extension chrome
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
on macOS, Linux or Windows. That installs a Chrome **native-messaging host**
|
|
88
88
|
(locked to this extension's pinned ID) that hands the extension the pairing
|
|
89
89
|
token straight from `~/.iframer/secret` — every profile and every Chromium-family
|
|
90
90
|
browser (Chrome, Brave, Edge, …) pairs itself, and re-pairs automatically after
|
|
91
|
-
a reinstall or a rotated secret. No copying, ever.
|
|
91
|
+
a reinstall or a rotated secret. No copying, ever. The command also tests the
|
|
92
|
+
host and starts the local server, and tells you if either one fails.
|
|
92
93
|
|
|
93
94
|
Manual fallback (host not installed): click the iframer toolbar icon → paste the
|
|
94
|
-
|
|
95
|
+
output of `iframer extension token` → **Save & connect**.
|
|
95
96
|
|
|
96
|
-
Either way the dot turns green
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
Either way the dot turns green only once the running iframer server has accepted
|
|
98
|
+
the token (the host locates the server; without a host the extension scans ports
|
|
99
|
+
3022–3042 on `127.0.0.1`, the window the server allocates its port from). The
|
|
100
|
+
token is the same machine-local secret the iframer CLI/MCP already use. It is
|
|
101
|
+
only ever sent to `127.0.0.1`.
|
|
100
102
|
|
|
101
103
|
## Use it
|
|
102
104
|
|
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.1",
|
|
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": [
|