surf-cli 2.7.2 → 2.9.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.
Files changed (45) hide show
  1. package/README.md +208 -13
  2. package/dist/content/index.js +116 -0
  3. package/dist/content/index.js.map +1 -0
  4. package/dist/manifest.json +2 -11
  5. package/dist/options/options.js +3 -3
  6. package/dist/options/options.js.map +1 -1
  7. package/dist/service-worker/index.js +261 -61
  8. package/dist/service-worker/index.js.map +1 -1
  9. package/native/abort.cjs +65 -0
  10. package/native/ai-queue.cjs +64 -0
  11. package/native/aistudio-build.cjs +21 -13
  12. package/native/aistudio-client.cjs +40 -20
  13. package/native/browser-lock.cjs +169 -0
  14. package/native/chatgpt-client.cjs +63 -30
  15. package/native/cli.cjs +947 -460
  16. package/native/client-transport.cjs +168 -0
  17. package/native/config.cjs +2 -2
  18. package/native/do-executor.cjs +25 -51
  19. package/native/do-parser.cjs +12 -0
  20. package/native/doctor.cjs +633 -0
  21. package/native/endpoint.cjs +174 -0
  22. package/native/file-transfer.cjs +734 -0
  23. package/native/gemini-client.cjs +244 -88
  24. package/native/grok-client.cjs +321 -212
  25. package/native/host-helpers.cjs +88 -16
  26. package/native/host-sessions.cjs +283 -0
  27. package/native/host.cjs +811 -616
  28. package/native/listener.cjs +20 -0
  29. package/native/mcp-server.cjs +60 -62
  30. package/native/network-export.cjs +113 -0
  31. package/native/perplexity-client.cjs +46 -17
  32. package/native/remote-auth.cjs +279 -0
  33. package/native/remote-transport.cjs +337 -0
  34. package/native/request-pending.cjs +148 -0
  35. package/native/socket-path.cjs +46 -0
  36. package/package.json +11 -9
  37. package/scripts/install-native-host.cjs +184 -51
  38. package/scripts/uninstall-native-host.cjs +93 -15
  39. package/skills/README.md +11 -5
  40. package/skills/deep-x-research/SKILL.md +106 -0
  41. package/skills/surf/SKILL.md +77 -22
  42. package/dist/content/accessibility-tree.js +0 -11
  43. package/dist/content/accessibility-tree.js.map +0 -1
  44. package/dist/content/visual-indicator.js +0 -111
  45. package/dist/content/visual-indicator.js.map +0 -1
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  const https = require("https");
9
+ const { abortError, abortableDelay, raceAbort, throwIfAborted } = require("./abort.cjs");
9
10
  const fs = require("fs");
10
11
  const path = require("path");
11
12
 
@@ -22,9 +23,9 @@ const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
22
23
 
23
24
  const MODEL_HEADER_NAME = "x-goog-ext-525001261-jspb";
24
25
  const MODEL_HEADERS = {
25
- "gemini-3-pro": '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]',
26
- "gemini-2.5-pro": '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]',
27
- "gemini-2.5-flash": '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]',
26
+ "gemini-3.1-pro": '[1,null,null,null,"e6fa609c3fa255c0",null,null,0,[4]]',
27
+ "gemini-3.5-flash": '[1,null,null,null,"56fdd199312815e2",null,null,0,[4]]',
28
+ "gemini-3.1-flash-lite": '[1,null,null,null,"8c46e95b1a07cecc",null,null,0,[4]]',
28
29
  };
29
30
 
30
31
  const REQUIRED_COOKIES = ["__Secure-1PSID", "__Secure-1PSIDTS"];
@@ -96,7 +97,8 @@ function hasRequiredCookies(cookieMap) {
96
97
  // ============================================================================
97
98
 
98
99
  function httpsGet(url, headers, opts = {}) {
99
- const { binary = false, timeoutMs = 30000, log = null, label = "httpsGet" } = opts;
100
+ const { binary = false, timeoutMs = 30000, log = null, label = "httpsGet", signal } = opts;
101
+ throwIfAborted(signal);
100
102
  return new Promise((resolve, reject) => {
101
103
  const urlObj = new URL(url);
102
104
  const options = {
@@ -131,13 +133,17 @@ function httpsGet(url, headers, opts = {}) {
131
133
  });
132
134
  });
133
135
 
136
+ const onAbort = () => req.destroy(abortError(signal, "Request cancelled"));
137
+ signal?.addEventListener("abort", onAbort, { once: true });
134
138
  req.on("timeout", () => {
135
139
  req.destroy(new Error(`${label}: request timeout after ${timeoutMs}ms`));
136
140
  });
137
141
  req.on("error", (err) => {
142
+ signal?.removeEventListener("abort", onAbort);
138
143
  if (log) log(`${label}: request error ${err.message}`);
139
144
  reject(err);
140
145
  });
146
+ req.on("close", () => signal?.removeEventListener("abort", onAbort));
141
147
  req.end();
142
148
  });
143
149
  }
@@ -151,7 +157,8 @@ function httpsPut(url, headers, body, opts = {}) {
151
157
  }
152
158
 
153
159
  function httpsSend(method, url, headers, body, opts = {}) {
154
- const { timeoutMs = 30000, log = null, label = "httpsSend" } = opts;
160
+ const { timeoutMs = 30000, log = null, label = "httpsSend", signal } = opts;
161
+ throwIfAborted(signal);
155
162
  return new Promise((resolve, reject) => {
156
163
  const urlObj = new URL(url);
157
164
  const bodyBuffer = body == null
@@ -185,13 +192,17 @@ function httpsSend(method, url, headers, body, opts = {}) {
185
192
  });
186
193
  });
187
194
 
195
+ const onAbort = () => req.destroy(abortError(signal, "Request cancelled"));
196
+ signal?.addEventListener("abort", onAbort, { once: true });
188
197
  req.on("timeout", () => {
189
198
  req.destroy(new Error(`${label}: request timeout after ${timeoutMs}ms`));
190
199
  });
191
200
  req.on("error", (err) => {
201
+ signal?.removeEventListener("abort", onAbort);
192
202
  if (log) log(`${label}: request error ${err.message}`);
193
203
  reject(err);
194
204
  });
205
+ req.on("close", () => signal?.removeEventListener("abort", onAbort));
195
206
  if (bodyBuffer) req.write(bodyBuffer);
196
207
  req.end();
197
208
  });
@@ -292,14 +303,49 @@ function ensureFullSizeImageUrl(url) {
292
303
  return `${url}=s2048`;
293
304
  }
294
305
 
306
+ function getFirstStringAtPaths(value, paths) {
307
+ for (const pathParts of paths) {
308
+ const found = getNestedValue(value, pathParts, "");
309
+ if (typeof found === "string" && found.trim()) return found;
310
+ }
311
+ return "";
312
+ }
313
+
314
+ const GEMINI_TEXT_PATHS = [
315
+ [1, 0],
316
+ [1, 0, 0],
317
+ [1, 0, 1],
318
+ [1, 1, 0],
319
+ [2, 0],
320
+ [22, 0],
321
+ ];
322
+
323
+ const GEMINI_CARD_CONTENT_RE = /^http:\/\/googleusercontent\.com\/card_content\/\d+/;
324
+ const GEMINI_CARD_ALT_PATHS = [[22, 0], [1, 1, 0], [2, 0]];
325
+
326
+ function resolveCandidateText(candidate) {
327
+ const textRaw = getFirstStringAtPaths(candidate, GEMINI_TEXT_PATHS);
328
+ if (GEMINI_CARD_CONTENT_RE.test(textRaw)) {
329
+ return getFirstStringAtPaths(candidate, GEMINI_CARD_ALT_PATHS) || textRaw;
330
+ }
331
+ return textRaw;
332
+ }
333
+
334
+ // An unresolved card_content placeholder URL is not answer text; score it zero.
335
+ function candidateTextScore(candidate) {
336
+ const resolved = resolveCandidateText(candidate);
337
+ return GEMINI_CARD_CONTENT_RE.test(resolved) ? 0 : resolved.length;
338
+ }
339
+
295
340
  function parseGeminiStreamGenerateResponse(rawText) {
296
341
  const responseJson = JSON.parse(trimGeminiJsonEnvelope(rawText));
297
342
  const errorCode = extractErrorCode(responseJson);
298
343
 
299
344
  const parts = Array.isArray(responseJson) ? responseJson : [];
300
- let bodyIndex = 0;
301
345
  let body = null;
302
-
346
+ let bestTextLength = -1;
347
+
348
+ // Stream chunks are cumulative; the longest text is the most complete answer.
303
349
  for (let i = 0; i < parts.length; i++) {
304
350
  const partBody = getNestedValue(parts[i], [2], null);
305
351
  if (!partBody) continue;
@@ -307,9 +353,14 @@ function parseGeminiStreamGenerateResponse(rawText) {
307
353
  const parsed = JSON.parse(partBody);
308
354
  const candidateList = getNestedValue(parsed, [4], []);
309
355
  if (Array.isArray(candidateList) && candidateList.length > 0) {
310
- bodyIndex = i;
311
- body = parsed;
312
- break;
356
+ if (!body) {
357
+ body = parsed;
358
+ }
359
+ const score = candidateTextScore(candidateList[0]);
360
+ if (score > bestTextLength) {
361
+ bestTextLength = score;
362
+ body = parsed;
363
+ }
313
364
  }
314
365
  } catch {
315
366
  // ignore
@@ -318,11 +369,7 @@ function parseGeminiStreamGenerateResponse(rawText) {
318
369
 
319
370
  const candidateList = getNestedValue(body, [4], []);
320
371
  const firstCandidate = candidateList[0];
321
- const textRaw = getNestedValue(firstCandidate, [1, 0], "");
322
- const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw);
323
- const text = cardContent
324
- ? (getNestedValue(firstCandidate, [22, 0], null) ?? textRaw)
325
- : textRaw;
372
+ const text = resolveCandidateText(firstCandidate);
326
373
  const thoughts = getNestedValue(firstCandidate, [37, 0, 0], null);
327
374
  const metadata = getNestedValue(body, [1], []);
328
375
 
@@ -341,27 +388,23 @@ function parseGeminiStreamGenerateResponse(rawText) {
341
388
  });
342
389
  }
343
390
 
344
- // Generated images
345
- const hasGenerated = Boolean(getNestedValue(firstCandidate, [12, 7, 0], null));
346
- if (hasGenerated) {
347
- let imgBody = null;
348
- for (let i = bodyIndex; i < parts.length; i++) {
349
- const partBody = getNestedValue(parts[i], [2], null);
350
- if (!partBody) continue;
351
- try {
352
- const parsed = JSON.parse(partBody);
353
- const candidateImages = getNestedValue(parsed, [4, 0, 12, 7, 0], null);
354
- if (candidateImages != null) {
355
- imgBody = parsed;
356
- break;
357
- }
358
- } catch {
359
- // ignore
391
+ // Keep the last chunk with a non-empty image list; a trailing empty must not erase it.
392
+ let imgBody = null;
393
+ for (let i = 0; i < parts.length; i++) {
394
+ const partBody = getNestedValue(parts[i], [2], null);
395
+ if (!partBody) continue;
396
+ try {
397
+ const parsed = JSON.parse(partBody);
398
+ const imgs = getNestedValue(parsed, [4, 0, 12, 7, 0], null);
399
+ if (Array.isArray(imgs) && imgs.length > 0) {
400
+ imgBody = parsed;
360
401
  }
402
+ } catch {
403
+ // ignore
361
404
  }
362
-
363
- const imgCandidate = getNestedValue(imgBody ?? body, [4, 0], null);
364
- const generated = getNestedValue(imgCandidate, [12, 7, 0], []);
405
+ }
406
+ if (imgBody) {
407
+ const generated = getNestedValue(imgBody, [4, 0, 12, 7, 0], []);
365
408
  for (const genImage of generated) {
366
409
  const url = getNestedValue(genImage, [0, 3, 3], null);
367
410
  if (!url) continue;
@@ -517,16 +560,17 @@ function buildGeminiFReqPayload(prompt, uploaded, chatMetadata) {
517
560
  }
518
561
 
519
562
  async function runGeminiWebOnce(input) {
520
- const { prompt, files, model, cookieMap, chatMetadata, timeoutMs = 30000, log = null } = input;
563
+ const { prompt, files, model, cookieMap, chatMetadata, timeoutMs = 30000, log = null, signal } = input;
564
+ throwIfAborted(signal);
521
565
  const cookieHeader = buildCookieHeader(cookieMap);
522
566
 
523
567
  // 1. Get access token
524
- const at = await fetchGeminiAccessToken(cookieMap, { timeoutMs, log, label: "geminiAccessToken" });
568
+ const at = await fetchGeminiAccessToken(cookieMap, { timeoutMs, log, label: "geminiAccessToken", signal });
525
569
 
526
570
  // 2. Upload files
527
571
  const uploaded = [];
528
572
  for (const file of files ?? []) {
529
- uploaded.push(await uploadGeminiFile(file, cookieMap, { timeoutMs, log, label: "geminiUpload" }));
573
+ uploaded.push(await uploadGeminiFile(file, cookieMap, { timeoutMs, log, label: "geminiUpload", signal }));
530
574
  }
531
575
 
532
576
  // 3. Build request
@@ -543,8 +587,8 @@ async function runGeminiWebOnce(input) {
543
587
  "referer": "https://gemini.google.com/",
544
588
  "x-same-domain": "1",
545
589
  "cookie": cookieHeader,
546
- [MODEL_HEADER_NAME]: MODEL_HEADERS[model] || MODEL_HEADERS["gemini-3-pro"],
547
- }, params.toString(), { timeoutMs, log, label: "geminiStreamGenerate" });
590
+ [MODEL_HEADER_NAME]: MODEL_HEADERS[model] || MODEL_HEADERS["gemini-3.1-pro"],
591
+ }, params.toString(), { timeoutMs, log, label: "geminiStreamGenerate", signal });
548
592
 
549
593
  const rawResponseText = res.text;
550
594
 
@@ -591,12 +635,13 @@ async function runGeminiWebOnce(input) {
591
635
  }
592
636
 
593
637
  async function runGeminiWebWithFallback(input) {
638
+ throwIfAborted(input.signal);
594
639
  const attempt = await runGeminiWebOnce(input);
595
640
 
596
641
  // Auto-fallback to flash if model unavailable
597
- if (isModelUnavailable(attempt.errorCode) && input.model !== "gemini-2.5-flash") {
598
- const fallback = await runGeminiWebOnce({ ...input, model: "gemini-2.5-flash" });
599
- return { ...fallback, effectiveModel: "gemini-2.5-flash" };
642
+ if (isModelUnavailable(attempt.errorCode) && input.model !== "gemini-3.5-flash") {
643
+ const fallback = await runGeminiWebOnce({ ...input, model: "gemini-3.5-flash" });
644
+ return { ...fallback, effectiveModel: "gemini-3.5-flash" };
600
645
  }
601
646
 
602
647
  return { ...attempt, effectiveModel: input.model };
@@ -607,7 +652,14 @@ async function runGeminiWebWithFallback(input) {
607
652
  // ============================================================================
608
653
 
609
654
  async function runGeminiWebViaPage(input) {
610
- const { prompt, files, model, timeoutMs = 120000, log = null, createTab, closeTab, jsEval, fetchUrl, uploadFile } = input;
655
+ const { prompt, files, model, timeoutMs = 120000, log = null, createTab, closeTab, jsEval, fetchUrl, uploadFile, signal } = input;
656
+ throwIfAborted(signal);
657
+ const guardedUploadFile = uploadFile
658
+ ? (...args) => raceAbort(() => uploadFile(...args), signal)
659
+ : uploadFile;
660
+ const guardedFetchUrl = fetchUrl
661
+ ? (...args) => raceAbort(() => fetchUrl(...args), signal)
662
+ : fetchUrl;
611
663
 
612
664
  if (!createTab || !closeTab || !jsEval) {
613
665
  throw new Error("In-page execution requires createTab, closeTab, and jsEval callbacks");
@@ -616,19 +668,19 @@ async function runGeminiWebViaPage(input) {
616
668
  let tabId = null;
617
669
  try {
618
670
  if (log) log("Creating Gemini tab...");
619
- const tabResult = await createTab();
671
+ const tabResult = await raceAbort(createTab, signal);
620
672
  tabId = tabResult?.tabId;
621
673
  if (!tabId) throw new Error("Failed to create Gemini tab");
622
674
  if (log) log(`Gemini tab created: ${tabId}`);
623
- await new Promise(r => setTimeout(r, 12000));
675
+ await abortableDelay(12000, signal);
624
676
 
625
677
  if (files?.length && uploadFile) {
626
678
  const absFiles = files.map(f => path.resolve(process.cwd(), f));
627
679
  if (log) log(`Uploading ${absFiles.length} file(s) via file chooser...`);
628
- const result = await uploadFile(tabId, absFiles);
680
+ const result = await guardedUploadFile(tabId, absFiles);
629
681
  if (result?.error) throw new Error(`File upload failed: ${result.error}`);
630
682
  if (log) log("File uploaded, waiting for processing...");
631
- await new Promise(r => setTimeout(r, 3000));
683
+ await abortableDelay(3000, signal);
632
684
  }
633
685
 
634
686
  const checkJsResult = (result, context) => {
@@ -638,57 +690,103 @@ async function runGeminiWebViaPage(input) {
638
690
  };
639
691
 
640
692
  // Type prompt
641
- const fullPrompt = prompt.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r");
693
+ const promptJson = JSON.stringify(prompt);
642
694
  if (log) log("Typing prompt...");
643
- const typeResult = await jsEval(tabId, `
695
+ const typeResult = await jsEval(tabId, `(() => {
644
696
  const editor = document.querySelector('.ql-editor[contenteditable=true]');
645
697
  if (!editor) return JSON.stringify({ error: "No editor found on page" });
646
698
  editor.focus();
647
699
  document.execCommand('selectAll', false, null);
648
- document.execCommand('insertText', false, '${fullPrompt}');
700
+ document.execCommand('insertText', false, ${promptJson});
649
701
  return JSON.stringify({ ok: true, len: editor.textContent.length });
650
- `);
702
+ })()`);
651
703
  const typed = JSON.parse(JSON.parse(checkJsResult(typeResult, "Type prompt")));
652
704
  if (typed.error) throw new Error(typed.error);
653
705
 
654
- const beforeResult = await jsEval(tabId, `
655
- return String(Array.from(document.querySelectorAll('img[src*="gg-dl"]')).filter(i => i.naturalWidth >= 512).length);
656
- `);
657
- const imgCountBefore = parseInt(JSON.parse(checkJsResult(beforeResult, "Count images")) || "0", 10);
706
+ const beforeResult = await jsEval(tabId, `(() => {
707
+ const imageKey = (img) => {
708
+ const url = img.currentSrc || img.src || "";
709
+ return url + "|" + img.naturalWidth + "x" + img.naturalHeight;
710
+ };
711
+ const baselineKeys = Array.from(document.images)
712
+ .filter((img) => {
713
+ const url = img.currentSrc || img.src || "";
714
+ return img.naturalWidth >= 512
715
+ && img.naturalHeight >= 512
716
+ && (url.includes("gg-dl") || url.startsWith("blob:"));
717
+ })
718
+ .map(imageKey);
719
+ return JSON.stringify(baselineKeys);
720
+ })()`);
721
+ const baselineImageKeys = JSON.parse(JSON.parse(checkJsResult(beforeResult, "Count images")) || "[]");
658
722
 
659
723
  if (log) log("Submitting...");
660
- const sendResult = await jsEval(tabId, `
724
+ const sendResult = await jsEval(tabId, `(() => {
661
725
  const btn = document.querySelector('button[aria-label="Send message"]');
662
726
  if (!btn) return 'no-btn';
663
727
  btn.click();
664
728
  return 'sent';
665
- `);
729
+ })()`);
666
730
  const sendVal = JSON.parse(checkJsResult(sendResult, "Click send"));
667
731
  if (sendVal === "no-btn") throw new Error("Send button not found on Gemini page");
668
732
 
669
733
  // Poll for response
670
734
  if (log) log("Waiting for response...");
671
735
  const deadline = Date.now() + timeoutMs;
672
- let imageUrls = [];
736
+ let imageEntries = [];
673
737
  let responseText = "";
674
738
 
675
739
  while (Date.now() < deadline) {
676
- await new Promise(r => setTimeout(r, 2000));
677
- const pollResult = await jsEval(tabId, `
678
- const ggImgs = Array.from(document.querySelectorAll('img[src*="gg-dl"]'))
679
- .filter(i => i.naturalWidth >= 512)
680
- .map(i => i.src);
740
+ await abortableDelay(2000, signal);
741
+ const pollResult = await jsEval(tabId, `(async () => {
742
+ const baselineKeys = new Set(${JSON.stringify(baselineImageKeys)});
743
+ const imageKey = (img) => {
744
+ const url = img.currentSrc || img.src || "";
745
+ return url + "|" + img.naturalWidth + "x" + img.naturalHeight;
746
+ };
747
+ const generatedImgs = Array.from(document.images)
748
+ .filter((img) => {
749
+ const url = img.currentSrc || img.src || "";
750
+ return img.naturalWidth >= 512
751
+ && img.naturalHeight >= 512
752
+ && (url.includes("gg-dl") || url.startsWith("blob:"));
753
+ })
754
+ .filter((img) => !baselineKeys.has(imageKey(img)));
755
+ window.__surfGeminiBlobImages = window.__surfGeminiBlobImages || [];
756
+ window.__surfGeminiBlobImageIndexes = window.__surfGeminiBlobImageIndexes || Object.create(null);
757
+ const images = await Promise.all(generatedImgs.map(async (img) => {
758
+ const url = img.currentSrc || img.src || "";
759
+ if (!url.startsWith("blob:")) return { url };
760
+ const key = imageKey(img);
761
+ if (Number.isInteger(window.__surfGeminiBlobImageIndexes[key])) {
762
+ return { url, blobIndex: window.__surfGeminiBlobImageIndexes[key], type: "image/png" };
763
+ }
764
+ const canvas = document.createElement("canvas");
765
+ canvas.width = img.naturalWidth;
766
+ canvas.height = img.naturalHeight;
767
+ const ctx = canvas.getContext("2d");
768
+ if (!ctx) throw new Error("Canvas context unavailable");
769
+ ctx.drawImage(img, 0, 0);
770
+ const dataUrl = canvas.toDataURL("image/png");
771
+ const blobIndex = window.__surfGeminiBlobImages.push({
772
+ url,
773
+ b64: dataUrl.split(",")[1],
774
+ type: "image/png",
775
+ }) - 1;
776
+ window.__surfGeminiBlobImageIndexes[key] = blobIndex;
777
+ return { url, blobIndex, type: "image/png" };
778
+ }));
681
779
  const loading = !!document.querySelector('mat-progress-bar, .loading-indicator, message-loading');
682
780
  const turns = document.querySelectorAll('message-content');
683
781
  const lastTurn = turns.length ? turns[turns.length - 1] : null;
684
782
  const text = lastTurn ? lastTurn.textContent?.trim() : "";
685
- return JSON.stringify({ ggImgs, loading, text, turns: turns.length });
686
- `);
783
+ return JSON.stringify({ images, loading, text, turns: turns.length });
784
+ })()`);
687
785
  const poll = JSON.parse(JSON.parse(checkJsResult(pollResult, "Poll response")));
688
- const newImgs = poll.ggImgs.slice(imgCountBefore);
786
+ const newImgs = poll.images || [];
689
787
 
690
788
  if (newImgs.length > 0) {
691
- imageUrls = newImgs;
789
+ imageEntries = newImgs;
692
790
  responseText = poll.text || "";
693
791
  if (log) log(`Found ${newImgs.length} generated image(s)`);
694
792
  break;
@@ -699,23 +797,47 @@ async function runGeminiWebViaPage(input) {
699
797
  }
700
798
  }
701
799
 
702
- if (!imageUrls.length && !responseText) {
800
+ if (!imageEntries.length && !responseText) {
703
801
  throw new Error("Gemini response timed out");
704
802
  }
705
803
 
706
- // Download images via extension
804
+ // Download URL-backed images via extension; read blob images from the page in chunks.
707
805
  const images = [];
708
- if (fetchUrl) {
709
- for (const url of imageUrls) {
710
- if (log) log(`Downloading image (${url.slice(0, 60)}...)...`);
711
- const dlResult = await fetchUrl(url);
712
- if (dlResult?.b64) {
713
- images.push({ url, b64: dlResult.b64, type: dlResult.type || "image/png" });
806
+ for (const img of imageEntries) {
807
+ if (Number.isInteger(img?.blobIndex)) {
808
+ let b64 = "";
809
+ let offset = 0;
810
+ const chunkSize = 40000;
811
+ let type = img.type || "image/png";
812
+ while (true) {
813
+ const chunkResult = await jsEval(tabId, `(() => {
814
+ const item = window.__surfGeminiBlobImages?.[${img.blobIndex}];
815
+ if (!item) return JSON.stringify({ error: "Blob image not found" });
816
+ return JSON.stringify({
817
+ chunk: item.b64.slice(${offset}, ${offset + chunkSize}),
818
+ done: ${offset + chunkSize} >= item.b64.length,
819
+ type: item.type || "image/png",
820
+ url: item.url,
821
+ });
822
+ })()`);
823
+ const chunk = JSON.parse(JSON.parse(checkJsResult(chunkResult, "Read blob image chunk")));
824
+ if (chunk.error) throw new Error(chunk.error);
825
+ b64 += chunk.chunk || "";
826
+ type = chunk.type || type;
827
+ if (chunk.done) break;
828
+ offset += chunkSize;
714
829
  }
830
+ images.push({ url: img.url, b64, type });
831
+ continue;
715
832
  }
716
- } else {
717
- for (const url of imageUrls) {
718
- images.push({ url });
833
+ if (img?.url && fetchUrl) {
834
+ if (log) log(`Downloading image (${img.url.slice(0, 60)}...)...`);
835
+ const dlResult = await guardedFetchUrl(img.url);
836
+ if (dlResult?.b64) {
837
+ images.push({ url: img.url, b64: dlResult.b64, type: dlResult.type || "image/png" });
838
+ }
839
+ } else if (img?.url) {
840
+ images.push({ url: img.url });
719
841
  }
720
842
  }
721
843
 
@@ -728,7 +850,13 @@ async function runGeminiWebViaPage(input) {
728
850
  _pageTabId: tabId,
729
851
  };
730
852
  } catch (err) {
731
- if (tabId) { try { await closeTab(tabId); } catch {} }
853
+ if (tabId) {
854
+ try {
855
+ await closeTab(tabId);
856
+ } catch (closeError) {
857
+ log?.(`Failed to close Gemini tab ${tabId}: ${closeError?.message || closeError}`);
858
+ }
859
+ }
732
860
  throw err;
733
861
  }
734
862
  }
@@ -740,7 +868,7 @@ async function runGeminiWebViaPage(input) {
740
868
  async function query(options) {
741
869
  const {
742
870
  prompt,
743
- model = "gemini-3-pro",
871
+ model = "gemini-3.1-pro",
744
872
  file,
745
873
  generateImage,
746
874
  editImage,
@@ -755,14 +883,17 @@ async function query(options) {
755
883
  uploadFile,
756
884
  timeout = 300000,
757
885
  log = () => {},
886
+ signal,
758
887
  } = options;
888
+ throwIfAborted(signal);
759
889
  const hasPageCallbacks = !!(createTab && closeTab && jsEval);
890
+ const guardedJsEval = (...args) => raceAbort(() => jsEval(...args), signal);
760
891
 
761
892
  const startTime = Date.now();
762
893
  log("Starting Gemini query");
763
894
 
764
895
  // 1. Get cookies from Chrome
765
- const cookieResponse = await getCookies();
896
+ const cookieResponse = await raceAbort(getCookies, signal);
766
897
  const cookies = cookieResponse?.cookies;
767
898
  if (!Array.isArray(cookies)) {
768
899
  throw new Error("Failed to get cookies from Chrome. Make sure the extension is loaded and Chrome is running.");
@@ -776,7 +907,13 @@ async function query(options) {
776
907
  log(`Got ${Object.keys(cookieMap).length} Gemini cookies`);
777
908
 
778
909
  // 2. Resolve model
779
- const resolvedModel = MODEL_HEADERS[model] ? model : "gemini-3-pro";
910
+ let resolvedModel;
911
+ if (MODEL_HEADERS[model]) {
912
+ resolvedModel = model;
913
+ } else {
914
+ resolvedModel = "gemini-3.1-pro";
915
+ log(`Unknown Gemini model "${model}"; using "${resolvedModel}"`);
916
+ }
780
917
 
781
918
  // 3. Build prompt
782
919
  let fullPrompt = prompt || "";
@@ -813,16 +950,17 @@ async function query(options) {
813
950
  log,
814
951
  createTab,
815
952
  closeTab,
816
- jsEval,
953
+ jsEval: guardedJsEval,
817
954
  fetchUrl,
818
955
  uploadFile,
956
+ signal,
819
957
  });
820
958
 
821
959
  response = out;
822
960
 
823
961
  // Save output image
824
962
  const outputPath = output || generateImage || "edited.png";
825
- const saveOpts = { timeoutMs: timeout, log };
963
+ const saveOpts = { timeoutMs: timeout, log, signal };
826
964
  if (fetchUrl) saveOpts.fetchUrl = fetchUrl;
827
965
  try {
828
966
  const imageSave = await saveFirstGeminiImage(out, cookieMap, outputPath, saveOpts);
@@ -830,7 +968,13 @@ async function query(options) {
830
968
  throw new Error(`No images generated. Response: ${out.text?.slice(0, 200) || "(empty)"}`);
831
969
  }
832
970
  } finally {
833
- if (out._pageTabId && closeTab) { try { await closeTab(out._pageTabId); } catch {} }
971
+ if (out._pageTabId && closeTab) {
972
+ try {
973
+ await closeTab(out._pageTabId);
974
+ } catch (closeError) {
975
+ log(`Failed to close Gemini tab ${out._pageTabId}: ${closeError?.message || closeError}`);
976
+ }
977
+ }
834
978
  }
835
979
  imagePath = outputPath;
836
980
 
@@ -846,8 +990,9 @@ async function query(options) {
846
990
  log,
847
991
  createTab,
848
992
  closeTab,
849
- jsEval,
993
+ jsEval: guardedJsEval,
850
994
  fetchUrl,
995
+ signal,
851
996
  });
852
997
  } else {
853
998
  out = await runGeminiWebWithFallback({
@@ -858,13 +1003,14 @@ async function query(options) {
858
1003
  chatMetadata: null,
859
1004
  timeoutMs: timeout,
860
1005
  log,
1006
+ signal,
861
1007
  });
862
1008
  }
863
1009
 
864
1010
  response = out;
865
1011
 
866
1012
  // Save output image
867
- const saveOpts = { timeoutMs: timeout, log };
1013
+ const saveOpts = { timeoutMs: timeout, log, signal };
868
1014
  if (fetchUrl) saveOpts.fetchUrl = fetchUrl;
869
1015
  try {
870
1016
  const imageSave = await saveFirstGeminiImage(out, cookieMap, generateImage, saveOpts);
@@ -872,7 +1018,13 @@ async function query(options) {
872
1018
  throw new Error(`No images generated. Response: ${out.text?.slice(0, 200) || "(empty)"}`);
873
1019
  }
874
1020
  } finally {
875
- if (out._pageTabId && closeTab) { try { await closeTab(out._pageTabId); } catch {} }
1021
+ if (out._pageTabId && closeTab) {
1022
+ try {
1023
+ await closeTab(out._pageTabId);
1024
+ } catch (closeError) {
1025
+ log(`Failed to close Gemini tab ${out._pageTabId}: ${closeError?.message || closeError}`);
1026
+ }
1027
+ }
876
1028
  }
877
1029
  imagePath = generateImage;
878
1030
 
@@ -887,11 +1039,13 @@ async function query(options) {
887
1039
  chatMetadata: null,
888
1040
  timeoutMs: timeout,
889
1041
  log,
1042
+ signal,
890
1043
  });
891
1044
 
892
1045
  response = out;
893
1046
  }
894
1047
  } catch (error) {
1048
+ if (error?.code === "SURF_REQUEST_ABORTED") throw error;
895
1049
  throw new Error(`Gemini request failed: ${error.message}`);
896
1050
  }
897
1051
 
@@ -913,10 +1067,12 @@ async function query(options) {
913
1067
  // ============================================================================
914
1068
 
915
1069
  module.exports = {
1070
+ httpsGet,
916
1071
  query,
917
1072
  hasRequiredCookies,
918
1073
  buildCookieMap,
919
1074
  parseGeminiStreamGenerateResponse,
1075
+ runGeminiWebViaPage,
920
1076
  REQUIRED_COOKIES,
921
1077
  ALL_COOKIE_NAMES,
922
1078
  GEMINI_APP_URL,