From 6c494a2f6bf1302c2afa0959201b188608a9b337 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:01:28 +0100 Subject: [PATCH 1/3] Carry browser cookies into a cold-started Grab When the local server refuses the handoff, navigate to a wake URL that launches Grab without a download, poll until it proves the pairing, then retry the POST so the cookies still travel. Fall back to the cookieless grab:// URL only when Grab never comes up. --- background.js | 3 ++ handoff.js | 53 +++++++++++++++++++++++++++++++++-- test/background.test.js | 62 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/background.js b/background.js index 8552de2..1640858 100644 --- a/background.js +++ b/background.js @@ -169,6 +169,9 @@ if (typeof module !== "undefined" && module.exports) { resolveStoreId, requestRescan, postToGrabWithRetry, + explicitSend, + wakeGrabAndWait, + _setWakeTimeout, verifyGrab, grabStatus, SHARED_EXCLUDED_HOSTS, diff --git a/handoff.js b/handoff.js index fe3ee97..272bd3e 100644 --- a/handoff.js +++ b/handoff.js @@ -16,6 +16,21 @@ const CONFIRM_TIMEOUT_MS = 130_000; /// A pairing handshake is a loopback round trip. const HELLO_TIMEOUT_MS = 3_000; +/// Launches Grab without handing it a download: the app restores this to the +/// invalid "https://", so it only presents its window. A cold start cannot +/// use the grab:// URL fallback when cookies must travel — that channel has +/// no way to carry them. +const GRAB_WAKE_URL = "grab://https/"; +/// How long to wait for a cold-started Grab to answer its local server. +const WAKE_TIMEOUT_MS = 12_000; +const WAKE_POLL_MS = 250; + +// Test hook: shorten the cold-start wait. +let wakeTimeoutMs = WAKE_TIMEOUT_MS; +function _setWakeTimeout(ms) { + wakeTimeoutMs = ms; +} + const MAX_HANDOFF_COOKIES = 100; const MAX_HANDOFF_BODY_BYTES = 32 * 1024; @@ -410,6 +425,35 @@ async function postToGrabWithRetry(url, ctx, retryDelays = HANDOFF_RETRY_DELAYS_ return status; } +/// Launch Grab without a URL, then wait until its local server answers. +/// Returns true when a retry can carry the cookies over HTTP; false when the +/// app never came up and the caller must use the cookieless URL fallback. +async function wakeGrabAndWait(tabId) { + updateTab(tabId, GRAB_WAKE_URL); + const secret = await getPairingCode(); + const deadline = Date.now() + wakeTimeoutMs; + while (Date.now() < deadline) { + await sleep(WAKE_POLL_MS); + try { + if (secret) { + if (await verifyGrab(secret)) return true; + } else { + // Unpaired: any answer means the endpoint is up. The POST that + // follows still refuses to trust an unverified listener with cookies. + const nonce = bytesToHex(crypto.getRandomValues(new Uint8Array(16))); + const { ok } = await fetchJsonWithTimeout( + `${GRAB_BASE_URL}/hello?n=${nonce}`, + HELLO_TIMEOUT_MS, + ); + if (ok) return true; + } + } catch { + // Not up yet. + } + } + return false; +} + /// Explicit user action (context menu, video menu, Alt+G): try the HTTP /// handoff first, but only fall back to the grab:// navigation when Grab /// isn't reachable at all (connection failure). If Grab responded — even @@ -418,11 +462,16 @@ async function postToGrabWithRetry(url, ctx, retryDelays = HANDOFF_RETRY_DELAYS_ async function explicitSend(url, tabId, incognito) { if (!toGrabUrl(url)) return; const ctx = { tabId, incognito }; - const status = await postToGrabWithRetry(url, ctx); + let status = await postToGrabWithRetry(url, ctx); + if (status === 0 && (await wakeGrabAndWait(tabId))) { + // Grab was closed and has just started: retry over HTTP so the browser + // cookies still ride along. The grab:// fallback cannot carry them. + status = await postToGrabWithRetry(url, ctx); + } const tabArg = Number.isInteger(tabId) && tabId >= 0 ? { tabId } : {}; if (status === 0) { - // Grab not running: the OS launches it with the grab:// URL. + // Grab never came up: the OS launches it with the grab:// URL. sendToGrab(url, tabId); } else if (status === STATUS_UNVERIFIED) { // Something else answers on Grab's port. Don't hand the URL to a diff --git a/test/background.test.js b/test/background.test.js index d469d82..a5028ec 100644 --- a/test/background.test.js +++ b/test/background.test.js @@ -1650,6 +1650,68 @@ describe("HTTP handoff to Grab", () => { assert.equal(fetchCalls[0].opts.headers["X-Grab-Token"], undefined); }); + // --- cold start -------------------------------------------------------- + + test("explicit send wakes a closed Grab, then retries so cookies travel", async () => { + await pair(); + let grabUp = false; + fetchCalls = []; + globalThis.fetch = async (url, opts = {}) => { + fetchCalls.push({ url, opts }); + if (url.includes("/hello")) { + if (!grabUp) throw new Error("connection refused"); + const nonce = new URL(url).searchParams.get("n"); + return { + status: 200, + ok: true, + json: async () => ({ proof: hmac("grab-server\u0000", nonce) }), + }; + } + return { status: 200, ok: true }; + }; + chrome.cookies.getAll = async () => [{ + name: "session", value: "abc", domain: "example.com", + hostOnly: true, path: "/", secure: true, + }]; + // Launching Grab is what makes its local server answer: flip the mock + // when the extension navigates to the wake URL. + const origUpdate = chrome.tabs.update; + chrome.tabs.update = (a, b) => { + const target = b !== undefined ? b : a; + const url = typeof target === "string" ? target : target && target.url; + if (url === "grab://https/") grabUp = true; + return origUpdate(a, b); + }; + + await bg.explicitSend("https://example.com/f.zip", 7, false); + + const updates = chrome._calls.filter(([name]) => name === "tabs.update"); + assert.equal(updates.length, 1, "the tab only visits the wake URL"); + assert.equal(updates[0][1].url, "grab://https/"); + assert.equal(updates[0][2], 7, "wake used the tab the user acted on"); + const post = posts().find((c) => c.opts.method === "POST"); + assert.ok(post, "the URL was retried over HTTP after the wake"); + assert.deepEqual( + JSON.parse(post.opts.body).cookies.map((cookie) => cookie.name), + ["session"], + "the cold-start retry carries the browser cookies" + ); + }); + + test("cold start that never comes up falls back to the grab:// URL", async () => { + await pair(); + bg._setWakeTimeout(0); + mockFetch("down"); + + await bg.explicitSend("https://example.com/f.zip", 7, false); + + const updates = chrome._calls.filter(([name]) => name === "tabs.update"); + assert.equal(updates.length, 2, "wake attempt, then the cookieless fallback"); + assert.equal(updates[0][1].url, "grab://https/"); + assert.equal(updates[1][1].url, "grab://https/example.com/f.zip"); + assert.equal(posts().length, 0, "no POST ever reached Grab"); + }); + // --- confirm=1 outcomes ------------------------------------------------ for (const status of [408, 409]) { From ff295e005cbed2fcaab872a175d8b4005ce1da52 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:13:08 +0100 Subject: [PATCH 2/3] Gate the cold-start wake and share one launch Only wake when the retry can carry cookies: a paired Grab and the Cookies permission are both required, so unpaired or revoked users fall back immediately as before. Show a starting badge while waiting, and let concurrent sends await one in-flight wake instead of launching repeatedly. --- handoff.js | 61 +++++++++++++++++--------- test/background.test.js | 95 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 20 deletions(-) diff --git a/handoff.js b/handoff.js index 272bd3e..9cc2553 100644 --- a/handoff.js +++ b/handoff.js @@ -425,33 +425,54 @@ async function postToGrabWithRetry(url, ctx, retryDelays = HANDOFF_RETRY_DELAYS_ return status; } +/// Set while a cold start is in progress: concurrent sends share one launch +/// instead of each navigating the tab to the wake URL. +let wakeInFlight = null; + /// Launch Grab without a URL, then wait until its local server answers. /// Returns true when a retry can carry the cookies over HTTP; false when the -/// app never came up and the caller must use the cookieless URL fallback. +/// app never came up, the user never granted the Cookies permission, or Grab +/// is unpaired — all cases where the wake buys nothing and the caller must +/// use the cookieless URL fallback immediately. async function wakeGrabAndWait(tabId) { - updateTab(tabId, GRAB_WAKE_URL); - const secret = await getPairingCode(); - const deadline = Date.now() + wakeTimeoutMs; - while (Date.now() < deadline) { - await sleep(WAKE_POLL_MS); + // The wake exists only to carry cookies over the retried POST. Unpaired + // Grab never accepts cookies, and a revoked Cookies permission means there + // are none to send, so both take main's immediate fallback. + if (!(await getPairingCode()) || !(await hasCookiePermission())) return false; + if (wakeInFlight) return wakeInFlight; + const tabArg = Number.isInteger(tabId) && tabId >= 0 ? { tabId } : {}; + const run = (async () => { try { - if (secret) { - if (await verifyGrab(secret)) return true; - } else { - // Unpaired: any answer means the endpoint is up. The POST that - // follows still refuses to trust an unverified listener with cookies. - const nonce = bytesToHex(crypto.getRandomValues(new Uint8Array(16))); - const { ok } = await fetchJsonWithTimeout( - `${GRAB_BASE_URL}/hello?n=${nonce}`, - HELLO_TIMEOUT_MS, - ); - if (ok) return true; + try { + await chrome.action.setBadgeText({ ...tabArg, text: "…" }); + await chrome.action.setTitle({ ...tabArg, title: "Starting Grab…" }); + } catch {} + updateTab(tabId, GRAB_WAKE_URL); + const secret = await getPairingCode(); + if (!secret) return false; + const deadline = Date.now() + wakeTimeoutMs; + while (Date.now() < deadline) { + await sleep(WAKE_POLL_MS); + try { + if (await verifyGrab(secret)) return true; + } catch { + // Not up yet. + } } - } catch { - // Not up yet. + return false; + } finally { + try { + await chrome.action.setBadgeText({ ...tabArg, text: "" }); + await chrome.action.setTitle({ ...tabArg, title: "" }); + } catch {} } + })(); + wakeInFlight = run; + try { + return await run; + } finally { + if (wakeInFlight === run) wakeInFlight = null; } - return false; } /// Explicit user action (context menu, video menu, Alt+G): try the HTTP diff --git a/test/background.test.js b/test/background.test.js index a5028ec..df2e9fb 100644 --- a/test/background.test.js +++ b/test/background.test.js @@ -1689,6 +1689,23 @@ describe("HTTP handoff to Grab", () => { assert.equal(updates.length, 1, "the tab only visits the wake URL"); assert.equal(updates[0][1].url, "grab://https/"); assert.equal(updates[0][2], 7, "wake used the tab the user acted on"); + assert.ok( + chrome._calls.some( + ([name, args]) => + name === "action.setBadgeText" && args.text === "…" && args.tabId === 7 + ), + "the wait is visible on the toolbar" + ); + assert.ok( + chrome._calls.some( + ([name, args]) => name === "action.setTitle" && args.title === "Starting Grab…" + ), + "the badge tooltip explains the wait" + ); + assert.ok( + chrome._calls.some(([name, args]) => name === "action.setBadgeText" && args.text === ""), + "the starting badge is cleared" + ); const post = posts().find((c) => c.opts.method === "POST"); assert.ok(post, "the URL was retried over HTTP after the wake"); assert.deepEqual( @@ -1712,6 +1729,84 @@ describe("HTTP handoff to Grab", () => { assert.equal(posts().length, 0, "no POST ever reached Grab"); }); + test("cold start without pairing skips the wake and falls back immediately", async () => { + bg._setWakeTimeout(1000); // would stall if the gate regressed + mockFetch("down"); + + await bg.explicitSend("https://example.com/f.zip", 7, false); + + const updates = chrome._calls.filter(([name]) => name === "tabs.update"); + assert.deepEqual( + updates.map(([, args]) => args.url), + ["grab://https/example.com/f.zip"], + "main's single fallback navigation, with no wake first" + ); + }); + + test("cold start with Cookies permission revoked skips the wake", async () => { + await pair(); + chrome.permissions = { contains: async () => false }; + bg._setWakeTimeout(1000); + mockFetch("down"); + + await bg.explicitSend("https://example.com/f.zip", 7, false); + + const updates = chrome._calls.filter(([name]) => name === "tabs.update"); + assert.deepEqual( + updates.map(([, args]) => args.url), + ["grab://https/example.com/f.zip"], + "nothing to carry, so no cold-start wait" + ); + }); + + test("concurrent cold sends share one wake launch", async () => { + await pair(); + let grabUp = false; + fetchCalls = []; + globalThis.fetch = async (url, opts = {}) => { + fetchCalls.push({ url, opts }); + if (url.includes("/hello")) { + if (!grabUp) throw new Error("connection refused"); + const nonce = new URL(url).searchParams.get("n"); + return { + status: 200, + ok: true, + json: async () => ({ proof: hmac("grab-server\u0000", nonce) }), + }; + } + return { status: 200, ok: true }; + }; + chrome.cookies.getAll = async () => [{ + name: "session", value: "abc", domain: "example.com", + hostOnly: true, path: "/", secure: true, + }]; + const origUpdate = chrome.tabs.update; + chrome.tabs.update = (a, b) => { + const target = b !== undefined ? b : a; + const url = typeof target === "string" ? target : target && target.url; + if (url === "grab://https/") grabUp = true; + return origUpdate(a, b); + }; + + await Promise.all([ + bg.explicitSend("https://example.com/a.zip", 7, false), + bg.explicitSend("https://example.com/b.zip", 7, false), + ]); + + const wakeUpdates = chrome._calls.filter( + ([name, args]) => name === "tabs.update" && args.url === "grab://https/" + ); + assert.equal(wakeUpdates.length, 1, "one launch for the whole burst"); + const posted = posts().map((c) => JSON.parse(c.opts.body).url).sort(); + assert.deepEqual(posted, [ + "https://example.com/a.zip", + "https://example.com/b.zip", + ]); + for (const call of posts()) { + assert.equal(JSON.parse(call.opts.body).cookies.length, 1); + } + }); + // --- confirm=1 outcomes ------------------------------------------------ for (const status of [408, 409]) { From babf946e9e77b31ce1e3e0a8031a9da0e109c898 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:17:44 +0100 Subject: [PATCH 3/3] Skip pointless wakes and preserve warning badges Only wake when the tab is not incognito, Grab is paired, the Cookies permission is granted, and the site actually has cookies to send. A warned tab keeps its existing badge instead of having it replaced and cleared. Reads the pairing code once. --- handoff.js | 59 ++++++++++++++++++++++++++++------------- test/background.test.js | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/handoff.js b/handoff.js index 9cc2553..1dec0ea 100644 --- a/handoff.js +++ b/handoff.js @@ -429,27 +429,46 @@ async function postToGrabWithRetry(url, ctx, retryDelays = HANDOFF_RETRY_DELAYS_ /// instead of each navigating the tab to the wake URL. let wakeInFlight = null; +/// True when the retried POST would carry at least one cookie. Incognito +/// stores and unreadable jars yield none, so the wake would buy nothing. +async function hasHandoffCookies(url, tabId) { + try { + const storeId = await resolveStoreId({ tabId }); + if (storeId === null) return false; + return (await readCookies(url, storeId)).length > 0; + } catch { + return false; + } +} + /// Launch Grab without a URL, then wait until its local server answers. /// Returns true when a retry can carry the cookies over HTTP; false when the -/// app never came up, the user never granted the Cookies permission, or Grab -/// is unpaired — all cases where the wake buys nothing and the caller must -/// use the cookieless URL fallback immediately. -async function wakeGrabAndWait(tabId) { - // The wake exists only to carry cookies over the retried POST. Unpaired - // Grab never accepts cookies, and a revoked Cookies permission means there - // are none to send, so both take main's immediate fallback. - if (!(await getPairingCode()) || !(await hasCookiePermission())) return false; +/// app never came up, the user never granted the Cookies permission, Grab is +/// unpaired, the send is incognito, or the site has no cookies — all cases +/// where the wake buys nothing and the caller must use the URL fallback now. +async function wakeGrabAndWait(url, tabId, incognito) { + // The wake exists only to carry cookies over the retried POST. + if (incognito === true) return false; + const secret = await getPairingCode(); + if (!secret || !(await hasCookiePermission())) return false; + if (!(await hasHandoffCookies(url, tabId))) return false; + // Check after the jar read: two sends that entered together both await it, + // and only the first may start a launch. if (wakeInFlight) return wakeInFlight; const tabArg = Number.isInteger(tabId) && tabId >= 0 ? { tabId } : {}; + // A warned tab keeps its "!" until a handoff verifies: overwriting it here + // would clear the visible warning without clearing warnedTabs. + const keepWarning = + Number.isInteger(tabId) && tabId >= 0 && warnedTabs.has(tabId); const run = (async () => { try { - try { - await chrome.action.setBadgeText({ ...tabArg, text: "…" }); - await chrome.action.setTitle({ ...tabArg, title: "Starting Grab…" }); - } catch {} + if (!keepWarning) { + try { + await chrome.action.setBadgeText({ ...tabArg, text: "…" }); + await chrome.action.setTitle({ ...tabArg, title: "Starting Grab…" }); + } catch {} + } updateTab(tabId, GRAB_WAKE_URL); - const secret = await getPairingCode(); - if (!secret) return false; const deadline = Date.now() + wakeTimeoutMs; while (Date.now() < deadline) { await sleep(WAKE_POLL_MS); @@ -461,10 +480,12 @@ async function wakeGrabAndWait(tabId) { } return false; } finally { - try { - await chrome.action.setBadgeText({ ...tabArg, text: "" }); - await chrome.action.setTitle({ ...tabArg, title: "" }); - } catch {} + if (!keepWarning) { + try { + await chrome.action.setBadgeText({ ...tabArg, text: "" }); + await chrome.action.setTitle({ ...tabArg, title: "" }); + } catch {} + } } })(); wakeInFlight = run; @@ -484,7 +505,7 @@ async function explicitSend(url, tabId, incognito) { if (!toGrabUrl(url)) return; const ctx = { tabId, incognito }; let status = await postToGrabWithRetry(url, ctx); - if (status === 0 && (await wakeGrabAndWait(tabId))) { + if (status === 0 && (await wakeGrabAndWait(url, tabId, incognito))) { // Grab was closed and has just started: retry over HTTP so the browser // cookies still ride along. The grab:// fallback cannot carry them. status = await postToGrabWithRetry(url, ctx); diff --git a/test/background.test.js b/test/background.test.js index df2e9fb..397aa40 100644 --- a/test/background.test.js +++ b/test/background.test.js @@ -1717,6 +1717,10 @@ describe("HTTP handoff to Grab", () => { test("cold start that never comes up falls back to the grab:// URL", async () => { await pair(); + chrome.cookies.getAll = async () => [{ + name: "session", value: "abc", domain: "example.com", + hostOnly: true, path: "/", secure: true, + }]; bg._setWakeTimeout(0); mockFetch("down"); @@ -1759,6 +1763,58 @@ describe("HTTP handoff to Grab", () => { ); }); + test("incognito sends skip the wake: no cookies are forwarded", async () => { + await pair(); + chrome.cookies.getAll = async () => [{ + name: "session", value: "abc", domain: "example.com", + hostOnly: true, path: "/", secure: true, + }]; + bg._setWakeTimeout(1000); + mockFetch("down"); + + await bg.explicitSend("https://example.com/f.zip", 7, true); + + const updates = chrome._calls.filter(([name]) => name === "tabs.update"); + assert.deepEqual( + updates.map(([, args]) => args.url), + ["grab://https/example.com/f.zip"] + ); + }); + + test("a site without cookies skips the wake", async () => { + await pair(); + bg._setWakeTimeout(1000); + mockFetch("down"); + + await bg.explicitSend("https://example.com/f.zip", 7, false); + + const updates = chrome._calls.filter(([name]) => name === "tabs.update"); + assert.deepEqual( + updates.map(([, args]) => args.url), + ["grab://https/example.com/f.zip"], + "nothing to carry, so no launch round trip" + ); + }); + + test("a warned tab keeps its badge through the wake", async () => { + await pair(); + chrome.cookies.getAll = async () => [{ + name: "session", value: "abc", domain: "example.com", + hostOnly: true, path: "/", secure: true, + }]; + bg._warnedTabs.add(7); + bg._setWakeTimeout(0); + mockFetch("down"); + + await bg.explicitSend("https://example.com/f.zip", 7, false); + + assert.equal( + chrome._calls.some(([name]) => name === "action.setBadgeText"), + false, + "the wake must neither replace nor clear the warning badge" + ); + }); + test("concurrent cold sends share one wake launch", async () => { await pair(); let grabUp = false;