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..1dec0ea 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,77 @@ 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; + +/// 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, 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 { + if (!keepWarning) { + try { + await chrome.action.setBadgeText({ ...tabArg, text: "…" }); + await chrome.action.setTitle({ ...tabArg, title: "Starting Grab…" }); + } catch {} + } + updateTab(tabId, GRAB_WAKE_URL); + 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. + } + } + return false; + } finally { + if (!keepWarning) { + 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; + } +} + /// 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 +504,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(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); + } 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..397aa40 100644 --- a/test/background.test.js +++ b/test/background.test.js @@ -1650,6 +1650,219 @@ 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"); + 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( + 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(); + chrome.cookies.getAll = async () => [{ + name: "session", value: "abc", domain: "example.com", + hostOnly: true, path: "/", secure: true, + }]; + 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"); + }); + + 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("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; + 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]) {