Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ if (typeof module !== "undefined" && module.exports) {
resolveStoreId,
requestRescan,
postToGrabWithRetry,
explicitSend,
wakeGrabAndWait,
_setWakeTimeout,
verifyGrab,
grabStatus,
SHARED_EXCLUDED_HOSTS,
Expand Down
95 changes: 93 additions & 2 deletions handoff.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
213 changes: 213 additions & 0 deletions test/background.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
Loading