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.
@@ -112,8 +112,8 @@ function isPidAlive(pid) {
112
112
  try {
113
113
  process.kill(pid, 0);
114
114
  return true;
115
- } catch {
116
- return false;
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, `docker-${Date.now()}.jpg`);
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", "docker-headful", "binary-headful"];
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 (docker-headful)
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", "docker-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)."),
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 runWithMode(params.options?.mode);
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 runWithMode(params.options?.mode);
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
- const escalation = ["docker-headful", "binary-headful"];
940
- for (const nextMode of escalation) {
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", "docker-headful", "extension"]).optional().describe("Mode override. 'extension' captures a tab already open in the user's real Chrome (requires options.tabId from `tabs`)."),
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 IS_DEV = process.env.IFRAMER_URL?.includes("localhost") || process.env.IFRAMER_URL?.includes("127.0.0.1");
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. In docker-headful mode, use the "solve-captcha" step.
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(
@@ -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
- then restart the browser. That installs a Chrome **native-messaging host**
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
- token from `cat ~/.iframer/secret` → **Save & connect**.
95
+ output of `iframer extension token` → **Save & connect**.
95
96
 
96
- Either way the dot turns green when it finds the running iframer server (it
97
- scans ports 3022–3042 on `127.0.0.1`; the server allocates its port from the
98
- same window). The token is the same machine-local secret the iframer CLI/MCP
99
- already use. It is only ever sent to `127.0.0.1`.
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
 
@@ -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 scanning = false;
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
- async function setStatus(patch) {
115
- const prev = (await chrome.storage.local.get("status")).status || {};
116
- await chrome.storage.local.set({ status: { ...prev, ...patch } });
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, SCAN_BACKOFF_MAX_MS);
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
- 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
- }
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
- 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();
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 ok = await tryConnect(port, token);
212
- if (ok) {
213
- resetScanBackoff();
214
- return;
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(false);
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
- // 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);
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
- clearTimeout(timer);
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(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);
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 restart the browser.",
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
- resetScanBackoff();
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
- resetScanBackoff();
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 {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "iframer",
4
- "version": "0.10.8",
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": [