diff --git a/background.js b/background.js index b9f81c5..8552de2 100644 --- a/background.js +++ b/background.js @@ -1,4 +1,10 @@ -// Grab browser extension (MV3) — service worker. +// Grab browser extension (MV3) — background entry point. +// +// The worker's code is split by job: +// - handoff.js: pairing, cookies, HTTP/grab:// handoff, explicit sends. +// - intercept.js: automatic download interception. +// - video-menu.js: video detection and the right-click menu. +// This file holds the shared helpers and the runtime message dispatcher. // // Three jobs: // 1. Automatic interception: browser downloads are cancelled and POSTed to @@ -14,14 +20,14 @@ // All listeners are registered synchronously at the top level: the worker is // ephemeral and Chrome only wakes it for events it registered for on startup. -// shared.js loads via importScripts in the Chrome MV3 service worker. -// Firefox lists it as a background script before this file, so the -// globals already exist there (importScripts only exists in workers). +// The Chrome MV3 service worker loads its sibling scripts here. Firefox lists +// them in manifest.firefox.json before this file, so the globals already +// exist there (importScripts only exists in workers). if ( typeof SHARED_DEFAULTS === "undefined" && typeof importScripts === "function" ) { - importScripts("shared.js"); + importScripts("shared.js", "handoff.js", "intercept.js", "video-menu.js"); } const GRAB_SCHEME = "grab://"; @@ -82,494 +88,18 @@ function updateTab(tabId, url) { if (r && typeof r.catch === "function") r.catch(() => {}); } -/// Grab's local HTTP server. The extension POSTs URLs here instead of using -/// native messaging — no host manifest, no extension IDs, works from Flatpak. -const GRAB_BASE_URL = "http://127.0.0.1:9412"; -const GRAB_HTTP_URL = `${GRAB_BASE_URL}/add`; - -/// Delays before retrying when Grab answers 429 (its handoff queue is full). -const HANDOFF_RETRY_DELAYS_MS = [300, 900]; - -/// Grab holds a `?confirm=1` request until the user answers its dialog (it -/// gives up after 120 s); wait a little longer than that. -const CONFIRM_TIMEOUT_MS = 130_000; -/// A pairing handshake is a loopback round trip. -const HELLO_TIMEOUT_MS = 3_000; - /// Keep URL and body limits aligned with Grab's server-side validation. /// The body limit is measured as UTF-8 bytes over the exact JSON string sent. const MAX_GRAB_URL_BYTES = 2048; -const MAX_HANDOFF_COOKIES = 100; -const MAX_HANDOFF_BODY_BYTES = 32 * 1024; // Bound menu creation and per-tab DOM snapshots while retaining many videos. const MAX_VIDEO_ITEMS = 50; -/// How long to wait for a server to report the size before treating an -/// unknown size as big enough to intercept (streams rarely send one, and they -/// belong in Grab). -const SIZE_WAIT_MS = 10_000; /// Grab rejects longer URLs (HTTP 414), so they are never worth tracking. const fitsGrabUrl = (u) => typeof u === "string" && new TextEncoder().encode(u).length <= MAX_GRAB_URL_BYTES; -let sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - /// Tabs showing the "unverified Grab" warning badge. Warned tabs keep their /// "!" until Grab verifies successfully. const warnedTabs = new Set(); -let activeTabId = null; -/// The URLs behind the submenu items as last built (after filtering): a -/// click index resolves here, never against a fresh, differently-ordered list. -let menuUrls = []; -let detectVideosOn = SHARED_DEFAULTS.detectVideos; - -// Test hook: swap the sleep implementation to observe retry delays. -function _setSleep(fn) { - sleep = fn; -} - -// --- Pairing --------------------------------------------------------------- -// -// Any local process can listen on 127.0.0.1:9412 before Grab does. Once the -// user has pasted Grab's pairing code into the options page, the extension -// 1. makes Grab prove it knows the code (GET /hello) before anything else, -// 2. only then sends the page's cookies, and -// 3. signs the request body, so Grab can tell this extension from any other. -// Without a code it still hands URLs off, but never sends cookies. - -const SERVER_CONTEXT = "grab-server\u0000"; -const CLIENT_CONTEXT = "grab-client\u0000"; -// Shared pattern lives in shared.js. -const STATUS_UNVERIFIED = -1; -/// The POST was sent but the answer never arrived (timeout or connection -/// drop): Grab may have accepted it. Never fall back to grab:// here — -/// that would duplicate the download. -const STATUS_MAYBE_SENT = -2; - -/// The pairing code lives in local storage, not sync: it is a secret for this -/// machine's Grab and must not follow the profile to other devices. -async function getPairingCode() { - try { - const { pairingCode } = await chrome.storage.local.get({ pairingCode: "" }); - const code = normalizePairingCode(pairingCode); - return SHARED_PAIRING_CODE_RE.test(code) ? code : ""; - } catch { - return ""; - } -} - -function hexToBytes(hex) { - const out = new Uint8Array(hex.length / 2); - for (let i = 0; i < out.length; i++) { - out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - } - return out; -} - -function bytesToHex(bytes) { - return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); -} - -/// HMAC-SHA256(secret, context || message) as lowercase hex. -async function hmacHex(secretHex, context, message) { - const key = await crypto.subtle.importKey( - "raw", - hexToBytes(secretHex), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const data = new TextEncoder().encode(context + message); - return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, data))); -} - -function constantTimeEqual(a, b) { - if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) { - return false; - } - let diff = 0; - for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); - return diff === 0; -} - -/// fetch() with a deadline. -async function fetchWithTimeout(url, opts, ms) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), ms); - try { - return await fetch(url, { ...opts, signal: controller.signal }); - } finally { - clearTimeout(timer); - } -} - -/// True when the listener on Grab's port knows the pairing code. A squatter -/// without it cannot answer, and a Grab that regenerated its code fails too. -async function verifyGrab(secret) { - const nonce = bytesToHex(crypto.getRandomValues(new Uint8Array(16))); - const resp = await fetchWithTimeout( - `${GRAB_BASE_URL}/hello?n=${nonce}`, - { method: "GET" }, - HELLO_TIMEOUT_MS, - ); - if (!resp.ok) return false; - const body = await resp.json(); - const expected = await hmacHex(secret, SERVER_CONTEXT, nonce); - return constantTimeEqual(body && body.proof, expected); -} - -/// Prefer cookies most likely to carry authentication when the 100-cookie -/// server cap forces a choice. The original order breaks ties deterministically. -function cookieRetentionPriority(cookie) { - const name = typeof cookie?.name === "string" ? cookie.name : ""; - if (name.toLowerCase() === "cf_clearance") return 0; - if (name.startsWith("__Host-")) return 1; - if (name.startsWith("__Secure-")) return 2; - if ( - /(session|sessid|auth|token|csrf|xsrf|login|jwt|clearance)/i.test(name) || - /(?:^|[^a-z0-9])sid(?:$|[^a-z0-9])/i.test(name) - ) return 3; - return 4; -} - -function limitHandoffCookies(cookies) { - return cookies - .map((cookie, index) => ({ cookie, index })) - .sort((a, b) => - cookieRetentionPriority(a.cookie) - cookieRetentionPriority(b.cookie) || - a.index - b.index - ) - .slice(0, MAX_HANDOFF_COOKIES) - .sort((a, b) => a.index - b.index) - .map(({ cookie }) => cookie); -} - -function serializeHandoffBody(url, cookies, userAgent, requestMetadata) { - return JSON.stringify({ - url, - cookies, - user_agent: userAgent, - ...(requestMetadata || {}), - }); -} - -/// Keep a valid URL handoff even if a site exposes a very large cookie jar. -/// Drop lower-priority cookies first; among equal priorities, remove the -/// largest serialized cookies first. Critical auth cookies are retained unless -/// they alone prevent the body from fitting. -function fitHandoffBody(url, cookies, userAgent, requestMetadata) { - let body = serializeHandoffBody(url, cookies, userAgent, requestMetadata); - const byteLength = (value) => new TextEncoder().encode(value).length; - if (byteLength(body) <= MAX_HANDOFF_BODY_BYTES) return body; - - const dropOrder = cookies - .map((cookie, index) => ({ - index, - priority: cookieRetentionPriority(cookie), - size: byteLength(JSON.stringify(cookie)), - })) - .sort((a, b) => - b.priority - a.priority || b.size - a.size || b.index - a.index - ); - const retained = new Set(cookies.map((_, index) => index)); - for (const candidate of dropOrder) { - retained.delete(candidate.index); - body = serializeHandoffBody( - url, - cookies.filter((_, index) => retained.has(index)), - userAgent, - requestMetadata - ); - if (byteLength(body) <= MAX_HANDOFF_BODY_BYTES) break; - } - return byteLength(body) <= MAX_HANDOFF_BODY_BYTES ? body : null; -} - -/// The cookies permission is optional and can be declined or revoked. When the -/// Permissions API is unavailable, assume it is granted and let the read decide. -async function hasCookiePermission() { - if (typeof chrome.permissions?.contains !== "function") return true; - try { - return await chrome.permissions.contains({ permissions: ["cookies"] }); - } catch { - return false; - } -} - -/// Read cookies in the shape Grab expects. Partitioned cookies are detected but -/// deliberately excluded: Grab's Netscape cookie jar cannot preserve their -/// partition key, so exporting them as ordinary cookies would widen their scope. -/// Errors propagate so the URL-only fallback can be accompanied by a warning. -async function readCookies(url, storeId, ctx) { - const filter = storeId ? { url, storeId } : { url }; - let all; - try { - // With partitionKey={} modern browsers return partitioned and unpartitioned - // cookies. Older browser versions may reject this filter; fall back to the - // supported unpartitioned-only query. - all = await chrome.cookies.getAll({ ...filter, partitionKey: {} }); - } catch { - all = await chrome.cookies.getAll(filter); - } - - const partitioned = all.filter((cookie) => cookie.partitionKey != null); - if (partitioned.length > 0 && ctx && typeof ctx === "object" && !ctx.cookieWarning) { - ctx.cookieWarning = - `${partitioned.length} partitioned cookie(s) were not forwarded because Grab's cookie-file format cannot preserve their partition isolation.`; - } - const cookies = all.filter((cookie) => cookie.partitionKey == null).map((c) => ({ - name: c.name, - value: c.value, - domain: c.domain, - host_only: c.hostOnly, - path: c.path, - secure: c.secure, - expiration_date: c.expirationDate, - })); - return limitHandoffCookies(cookies); -} - -/// Resolve the cookie store for a handoff context. Returns a store ID string, -/// undefined for the default store, or null when cookies must not be sent. -/// Incognito cookie forwarding is intentionally disabled. Firefox download items -/// carry cookieStoreId, preserving non-private container isolation without a -/// tab ID; Chrome tabs can still be resolved through their store list. -async function resolveStoreId(ctx) { - if (ctx && ctx.incognito === true) return null; - - const explicitStoreId = ctx && ctx.cookieStoreId; - if (typeof explicitStoreId === "string" && explicitStoreId) { - return explicitStoreId; - } - - const tabId = ctx && ctx.tabId; - if (Number.isInteger(tabId) && tabId >= 0) { - let tab; - try { - tab = await chrome.tabs.get(tabId); - } catch { - tab = undefined; - } - // A tab lookup can reveal a private context even when the caller lacked - // incognito metadata; never read that store's cookies. - if (tab && tab.incognito === true) return null; - if (tab && tab.cookieStoreId) return tab.cookieStoreId; // Firefox containers. - - // Chrome: match the tab against the stores' tabIds. - try { - const stores = await chrome.cookies.getAllCookieStores(); - if (Array.isArray(stores)) { - const match = stores.find( - (s) => Array.isArray(s.tabIds) && s.tabIds.includes(tabId) - ); - if (match) return match.id; - } - } catch {} - } - return undefined; // Default store. -} - -/// POST one URL to Grab and wait for its answer to the confirm dialog. -/// Resolves to the HTTP status, 0 when Grab can't be reached (not running), -/// STATUS_UNVERIFIED when the listener could not prove it is Grab, or -/// STATUS_MAYBE_SENT when the POST may have reached Grab but the answer -/// was lost (timeout). The caller must not fall back to grab:// on -/// STATUS_MAYBE_SENT — that would risk a duplicate download. -async function postToGrab(url, ctx) { - if (ctx && typeof ctx === "object") delete ctx.cookieWarning; - // Match Grab's Rust String::len() check (UTF-8 bytes), avoiding a network - // round trip for URLs that Grab will reject with HTTP 414. - if (typeof url !== "string" || new TextEncoder().encode(url).length > MAX_GRAB_URL_BYTES) { - return 414; - } - let secret; - try { - secret = await getPairingCode(); - } catch { - return 0; // Storage unreachable; treat as Grab not running. - } - // Explicit sends may remain URL-only, but automatic interception must never - // accept an unsigned POST if the user unpairs between the outer guard and here. - if (ctx && ctx.requirePairing === true && !secret) return STATUS_UNVERIFIED; - let cookies = []; - if (secret) { - let verified; - try { - verified = await verifyGrab(secret); - } catch { - return 0; // Grab not reachable. - } - if (!verified) return STATUS_UNVERIFIED; - // Cookies let Grab's yt-dlp pass bot challenges (Cloudflare etc.). - // They go only to a Grab that has proven it holds the pairing code. - // Never export incognito cookies; retain Firefox container scoping for - // ordinary downloads using DownloadItem.cookieStoreId. - if (!(await hasCookiePermission())) { - // Declined or revoked: URL only, and not an error, so no warning. - cookies = []; - } else try { - const storeId = await resolveStoreId(ctx); - if (storeId === null) { - cookies = []; - if (ctx && typeof ctx === "object") { - ctx.cookieWarning = "Incognito cookie forwarding is disabled; Grab received the URL without cookies."; - } - } else { - cookies = await readCookies(url, storeId, ctx); - } - } catch { - cookies = []; - if (ctx && typeof ctx === "object") { - ctx.cookieWarning = "Grab could not read this site's cookies. Check the extension's optional Cookies permission or Firefox first-party isolation; the URL was sent without cookies."; - } - } - } - let body, headers; - try { - // userAgent travels with the cookies: cf_clearance is UA-bound, so Grab - // applies it to its HTTP client and yt-dlp alongside the jar. - const userAgent = - typeof navigator !== "undefined" && navigator.userAgent - ? navigator.userAgent - : undefined; - // Preserve the URL and retain the highest-value auth cookies within the - // payload budget; don't drop the whole jar as the first fallback. - // Every authenticated POST gets fresh signed metadata. Retrying postToGrab - // generates a new nonce, so a network retry can never replay an earlier body. - const requestMetadata = secret - ? { - timestamp_ms: Date.now(), - nonce: bytesToHex(crypto.getRandomValues(new Uint8Array(16))), - } - : undefined; - body = fitHandoffBody(url, cookies, userAgent, requestMetadata); - if (body === null) return 413; // Do not send a body Grab will reject. - headers = { "Content-Type": "application/json" }; - if (secret) headers["X-Grab-Token"] = await hmacHex(secret, CLIENT_CONTEXT, body); - } catch { - return 0; // Crypto failed; POST never sent, safe to fall back. - } - const start = Date.now(); - try { - // confirm=1: Grab answers only once the user has approved or declined, so - // the browser's own download is cancelled only when Grab really took over. - const resp = await fetchWithTimeout( - `${GRAB_HTTP_URL}?confirm=1`, - { method: "POST", headers, body }, - CONFIRM_TIMEOUT_MS, - ); - if (resp.status >= 200 && resp.status < 300 && ctx && typeof ctx === "object") { - const acceptedHeader = resp.headers?.get?.("x-grab-cookies-accepted"); - if (acceptedHeader !== null && acceptedHeader !== undefined) { - const accepted = Number(acceptedHeader); - const sentCount = JSON.parse(body).cookies.length; - if (Number.isSafeInteger(accepted) && accepted >= 0 && accepted < sentCount && !ctx.cookieWarning) { - ctx.cookieWarning = `Grab accepted the URL but discarded ${sentCount - accepted} of ${sentCount} supplied cookies.`; - } - } - } - return resp.status; - } catch { - // Fast failure (connection refused): Grab isn't running, the POST never - // left. Slow failure (timeout): Grab may have accepted it. - // Heuristic: a fast failure is treated as "never sent". A connection reset - // after Grab accepted the POST looks the same and can duplicate a download; - // a grab:// fallback is not attempted for STATUS_MAYBE_SENT for that reason. - return Date.now() - start < 10000 ? 0 : STATUS_MAYBE_SENT; - } -} - -/// POST to Grab, retrying briefly on 429 (queue full). Resolves to the final -/// HTTP status, or 0 when Grab isn't reachable. -async function postToGrabWithRetry(url, ctx, retryDelays = HANDOFF_RETRY_DELAYS_MS) { - let status = await postToGrab(url, ctx); - for (const delay of retryDelays) { - if (status !== 429) break; - await sleep(delay); - status = await postToGrab(url, ctx); - } - return status; -} - -/// Send the URL to Grab via HTTP. Resolves to true only when Grab accepted it. -/// A 429 means Grab's queue is momentarily full, so retry briefly first. Any -/// other outcome (Grab not running or not verified, declined by the user (409), -/// no answer in time (408), 403, 414, …) is a failure: the caller must leave -/// the browser's own download alone. The caller validates the URL with -/// toGrabUrl first; the raw URL is posted as-is. -async function handOff(url, ctx, retryDelays = HANDOFF_RETRY_DELAYS_MS) { - const status = await postToGrabWithRetry(url, ctx, retryDelays); - return status >= 200 && status < 300; -} - -/// 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 -/// with 409 (card opened or declined) or 408 — it handled the URL; falling -/// back would duplicate the handoff. -async function explicitSend(url, tabId, incognito) { - if (!toGrabUrl(url)) return; - const ctx = { tabId, incognito }; - const 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. - sendToGrab(url, tabId); - } else if (status === STATUS_UNVERIFIED) { - // Something else answers on Grab's port. Don't hand the URL to a - // stranger; flag it on the toolbar instead. - try { - await chrome.action.setBadgeText({ ...tabArg, text: "!" }); - await chrome.action.setBadgeBackgroundColor({ ...tabArg, color: "#cc0000" }); - await chrome.action.setTitle({ ...tabArg, title: "Grab not recognised. Pair again in Options." }); - if (Number.isInteger(tabId) && tabId >= 0) warnedTabs.add(tabId); - } catch {} - } else if (status >= 200 && status < 300) { - if (ctx.cookieWarning) { - await showCookieWarning(ctx); - } else { - // Success: clear any earlier warning. - try { - await chrome.action.setBadgeText({ ...tabArg, text: "" }); - await chrome.action.setTitle({ ...tabArg, title: "" }); - if (Number.isInteger(tabId) && tabId >= 0) warnedTabs.delete(tabId); - } catch {} - } - } else if (status !== STATUS_MAYBE_SENT) { - // Concrete HTTP rejections (especially exhausted 429 retries, 408, or - // 409) need visible feedback. Never fall back to grab:// after a response. - const title = status === 408 - ? "Grab did not respond in time (HTTP 408). Check Grab before retrying." - : status === 409 - ? "Grab did not queue this URL (HTTP 409). Check the Grab dialog." - : status === 414 - ? "URL exceeds Grab's 2048-byte handoff limit; the original download was not handed off." - : status === 429 - ? "Grab's queue is full after retries (HTTP 429). Try again after a download finishes." - : `Grab could not accept this URL (HTTP ${status}).`; - try { - await chrome.action.setBadgeText({ ...tabArg, text: "!" }); - await chrome.action.setBadgeBackgroundColor({ ...tabArg, color: "#cc0000" }); - await chrome.action.setTitle({ ...tabArg, title }); - } catch {} - } - // STATUS_MAYBE_SENT may have been accepted; never fall back because that - // risks a duplicate download. -} - -/// Show why a handoff was accepted without a fully usable cookie jar. -async function showCookieWarning(ctx) { - const tabId = ctx && ctx.tabId; - const tabArg = Number.isInteger(tabId) && tabId >= 0 ? { tabId } : {}; - const title = ctx && ctx.cookieWarning; - if (!title) return; - try { - await chrome.action.setBadgeText({ ...tabArg, text: "!" }); - await chrome.action.setBadgeBackgroundColor({ ...tabArg, color: "#cc0000" }); - await chrome.action.setTitle({ ...tabArg, title }); - if (Number.isInteger(tabId) && tabId >= 0) warnedTabs.add(tabId); - } catch {} -} /// True when the URL's file extension is on the user's skip list. function isSkipped(url, skipTypes) { @@ -592,1142 +122,10 @@ async function getSettings() { return chrome.storage.sync.get(SHARED_DEFAULTS); } -/// Per-download claim keys in session storage: one key per download id, so two -/// downloads claimed at the same moment can't clobber each other's record the -/// way a shared read-modify-write set would. Session storage survives service -/// worker restarts and clears when the browser closes. -const claimKey = (id) => `claimedDownload:${id}`; - -async function claimDownload(id) { - await chrome.storage.session.set({ [claimKey(id)]: true }); -} - -async function unclaimDownload(id) { - await chrome.storage.session.remove(claimKey(id)); -} - -async function isClaimed(id) { - const key = claimKey(id); - const result = await chrome.storage.session.get(key); - return result[key] === true; -} - -// --- Automatic interception ---------------------------------------------- - -/// Remove the browser's own copy of a download Grab has taken over. -async function removeBrowserCopy(id) { - try { - await chrome.downloads.removeFile(id); - } catch { - // Already gone. - } - try { - await chrome.downloads.erase({ id }); - } catch { - // Already gone. - } -} - -async function interceptDownload(item) { - // Cancelling a browser download is only safe when a verified Grab took it. - // Unpaired, any process listening on the port could answer 200 and make the - // file disappear, so automatic interception requires pairing. - if (!(await getPairingCode())) return; - // Prefer the post-redirect URL only when Grab can accept its length. - // Otherwise preserve the original URL so Grab can follow the redirect. - const finalUrl = toGrabUrl(item.finalUrl) ? item.finalUrl : null; - const url = finalUrl && - new TextEncoder().encode(finalUrl).length <= MAX_GRAB_URL_BYTES - ? finalUrl - : item.url; - // Non-http(s) URLs (blob:, data:) and URLs with credentials can't be - // handed over. - if (!toGrabUrl(url)) return; - // Hand off over Grab's local HTTP endpoint. If Grab isn't running or - // refuses the request, the download is left to the browser. - // Only cancel the browser download on HTTP 200 (queued). HTTP 202 means - // Grab opened the New Download card (nothing queued yet) — the user may - // not complete it, so the browser copy must stay. - const ctx = { - incognito: item.incognito === true, - cookieStoreId: item.cookieStoreId, - requirePairing: true, - }; - const status = await postToGrabWithRetry(url, ctx); - if (status !== 200) return; - if (ctx.cookieWarning) await showCookieWarning(ctx); - - try { - await claimDownload(item.id); - } catch { - // Continue: the cancel below still stops the browser copy. Only the - // onChanged cleanup for a tiny finished download may be missed. - } - // A tiny download can finish while the handoff is in flight, before the - // claim exists, so onChanged skipped it: clean up here instead. - const [current] = await chrome.downloads.search({ id: item.id }); - if (current && current.state === "complete") { - await removeBrowserCopy(item.id); - await unclaimDownload(item.id); - return; - } - try { - await chrome.downloads.cancel(item.id); - } catch { - // Already gone; the onChanged guard below still cleans up. - } - try { - await chrome.downloads.removeFile(item.id); - } catch { - // Nothing written yet or already removed. - } -} - -// Downloads parked while waiting for their size to become known, when the -// user set a minimum intercept size. MV3 service workers are ephemeral: an -// in-memory Map + setTimeout can die mid-wait and lose the interception, so -// the pending set lives in session storage (survives worker restarts, clears -// with the browser) and the deadline is a chrome.alarms one-shot (alarms -// wake the worker). After a restart, the next onChanged or alarm re-reads -// the pending set and decides. -const pendingKey = (id) => `pendingSize:${id}`; -const alarmName = (id) => `size-wait:${id}`; - -// Best-effort guard against deciding the same download twice when an alarm -// and a size event interleave within one worker lifetime. Cross-restart -// dedup comes from dropPending's session-storage removal. -const deciding = new Set(); - -async function isPending(id) { - const key = pendingKey(id); - const result = await chrome.storage.session.get(key); - return result[key] !== undefined; -} - -async function parkForSize(item, deadline = Date.now() + SIZE_WAIT_MS) { - // The alarm is only the worker-wakeup: the stored deadline is the real - // timer. Packaged Chrome fires alarms no earlier than ~30s out, so the - // effective wait in packaged builds is >= 30s even though the deadline is - // sooner; the deadline still decides, so unpacked builds keep the short - // wait for development. - await chrome.storage.session.set({ [pendingKey(item.id)]: deadline }); - await chrome.alarms.create(alarmName(item.id), { when: deadline }); -} - -async function dropPending(id) { - await chrome.alarms.clear(alarmName(id)).catch(() => {}); - await chrome.storage.session.remove(pendingKey(id)); -} - -const minSizeBytes = (settings) => - Math.max(0, settings.minSizeMB | 0) * 1024 * 1024; - -async function decidePending(id) { - // Acquire the guard before the first await: two interleaved calls (an alarm - // and a size event arriving together) would otherwise both pass the check - // above before either reaches the add below, and both would intercept. - if (deciding.has(id)) return; - deciding.add(id); - try { - const stored = await chrome.storage.session.get(pendingKey(id)); - const deadline = stored[pendingKey(id)]; - if (deadline === undefined) return; - - const settings = await getSettings(); - const items = await chrome.downloads.search({ id }); - const item = items[0]; - if (!item) { - await dropPending(id); - return; - } - // The download finished or failed while parked (or in the async gap - // between deciding to park and persisting the pending state): too late - // to hand over — leave the browser copy alone. - if (item.state === "complete" || item.state === "interrupted") { - await dropPending(id); - return; - } - // The user may have changed their mind while we waited. - if ( - !settings.interceptDownloads || - isSkipped(item.url, settings.skipTypes) || - isSkipped(item.finalUrl, settings.skipTypes) || - !toGrabUrl(item.url) - ) { - await dropPending(id); - return; - } - if (item.fileSize >= 0) { - // Size is known: small enough stays in the browser, big enough is - // intercepted now. - if (item.fileSize < minSizeBytes(settings)) { - await dropPending(id); - return; - } - } else if (Date.now() < deadline) { - // Size still unknown but the deadline hasn't passed: this call came - // too early (the alarm is only the wakeup). Re-park and wait for the - // real deadline instead of intercepting early. Don't drop: parkForSize - // refreshes the pending entry. - await parkForSize(item, deadline); - return; - } - // Unknown size past the deadline: streams rarely report one, and they - // belong in Grab (see SIZE_WAIT_MS). - // Drop the pending entry AFTER the handoff starts, so a worker death - // in between doesn't lose the download silently. - await interceptDownload(item); - await dropPending(id); - } finally { - deciding.delete(id); - } -} - -// Size-wait deadline: decide the parked download even if the worker was -// restarted while it was parked (the pending set survived in session storage). -chrome.alarms.onAlarm.addListener((alarm) => { - const m = /^size-wait:(\d+)$/.exec(alarm.name); - if (m) decidePending(Number(m[1])); -}); - -chrome.downloads.onCreated.addListener(async (item) => { - const settings = await getSettings(); - if (!settings.interceptDownloads) return; - // Ignore downloads from a previous browser session (Varia pattern): - // onCreated can re-fire on startup for old downloads. Missing startTime - // or a missing browserStartTime (no startup seen) is treated as new. - if (item.startTime) { - const { browserStartTime } = await chrome.storage.session.get( - "browserStartTime", - ); - if ( - browserStartTime !== undefined && - new Date(item.startTime).getTime() < browserStartTime - ) { - return; - } - } - // Extension installs/updates must never be intercepted. - if (item.mime === "application/x-chrome-extension") return; - if (isSkipped(item.url, settings.skipTypes) || isSkipped(item.finalUrl, settings.skipTypes)) return; - // Non-http(s) downloads (blob:, data:) have no server URL to hand over. - if (!toGrabUrl(item.url)) return; - - const cap = minSizeBytes(settings); - if (cap > 0) { - if (item.fileSize >= 0 && item.fileSize < cap) return; // Too small: browser keeps it. - if (item.fileSize < 0) { - // Size not known yet (headers haven't arrived): wait for it instead of - // guessing. The browser download proceeds normally meanwhile. - await parkForSize(item); - // The download may have finished while the pending state was being - // written (async gap between deciding to park and persisting it): - // re-check, and drop the parked entry if it's already too late to - // hand over. Anything finishing after this is caught by the onChanged - // guard or by decidePending's own state check. - const fresh = await chrome.downloads.search({ id: item.id }); - const current = fresh[0]; - if ( - !current || - current.state === "complete" || - current.state === "interrupted" - ) { - await dropPending(item.id); - } - return; - } - } - - await interceptDownload(item); -}); - -chrome.downloads.onChanged.addListener(async (delta) => { - if (await isPending(delta.id)) { - const state = delta.state && delta.state.current; - if (state === "complete" || state === "interrupted") { - // Finished or failed before the size arrived: too late to hand over. - await dropPending(delta.id); - return; - } - if (delta.fileSize && delta.fileSize.current >= 0) { - await decidePending(delta.id); - return; - } - } - if (!(await isClaimed(delta.id))) return; - const state = delta.state && delta.state.current; - if (state === "complete") { - // Lost the race: the browser finished before our cancel landed. Remove - // the duplicate so only Grab's copy remains. - await removeBrowserCopy(delta.id); - } - if (state === "complete" || state === "interrupted") { - await unclaimDownload(delta.id); - } -}); - -// --- Explicit sends ------------------------------------------------------- - -function createContextMenu() { - chrome.contextMenus.create({ - id: "sendToGrab", - title: "Download with Grab", - contexts: ["link", "video", "audio", "image", "page"], - }); -} - -chrome.runtime.onInstalled.addListener(async () => { - const settings = await getSettings(); - // removeAll first: onInstalled also fires on extension updates, when the - // menu may already exist (create would throw on a duplicate id). - await removeAllContextMenus(); - if (settings.showContextMenu) createContextMenu(); -}); - -chrome.storage.onChanged.addListener(async (changes, area) => { - if (area !== "sync") return; - if (changes.showContextMenu) { - // removeAll first: rapid toggles would otherwise stack a duplicate-id - // create on top of an in-flight one. - await removeAllContextMenus(); - if (changes.showContextMenu.newValue) { - createContextMenu(); - // removeAll also dropped the video submenu; rebuild it. - if (Number.isInteger(activeTabId) && activeTabId >= 0) { - refreshVideoMenu(activeTabId); - } - } - } - if (changes.detectVideos) { - detectVideosOn = changes.detectVideos.newValue !== false; - if (!detectVideosOn) clearAllVideoUi(); - } -}); - -chrome.contextMenus.onClicked.addListener(async (info, tab) => { - const id = info.menuItemId; - if (typeof id === "string" && id.startsWith(VIDEO_ITEM_PREFIX)) { - // A detected video: same explicit-send channel as the main item (the - // user picked this tab), not the background-tab interception channel. - // Resolve against the list the menu was built from: it was filtered - // (master playlists) and may belong to another window's tab. - // menuUrls is in-memory; after a worker restart, read the persisted copy. - let urls = menuUrls; - if (urls.length === 0) { - try { - const stored = await chrome.storage.session.get("menuUrls"); - if (Array.isArray(stored.menuUrls)) urls = stored.menuUrls; - } catch { - // Fall through with the empty list. - } - } - const url = urls[Number(id.slice(VIDEO_ITEM_PREFIX.length))]; - // Try the paired HTTP handoff first (sends cookies, waits for Grab's - // real answer); fall back to the grab:// navigation only when Grab isn't - // reachable at all. - if (url) await explicitSend(url, tab && tab.id, tab && tab.incognito === true); - return; - } - if (id !== "sendToGrab") return; - // Prefer the top-level tab URL: some context-menu invocations report only - // a frame/origin URL in info.pageUrl, which is not enough for site extractors. - // Keep info.pageUrl as a fallback when the browser omits tab.url. - const pageUrl = tab && typeof tab.url === "string" ? tab.url : info.pageUrl; - const candidates = [info.linkUrl, info.srcUrl, pageUrl]; - // Magnet links already open in Grab through the OS handler; pass them - // through unwrapped. - const magnet = candidates.find( - (u) => typeof u === "string" && u.toLowerCase().startsWith("magnet:"), - ); - if (magnet) { - updateTab(tab && tab.id, magnet); - return; - } - // Right-clicked on a video element whose srcUrl is unusable (blob:/data:): - // try to find the detected video matching the clicked element's src. - // If the tab has exactly one detected video, that's the one the user - // meant — don't fall back to the generic page URL. - let target = candidates.find((u) => toGrabUrl(u)); - if ( - info.mediaType === "video" && - tab && - Number.isInteger(tab.id) && - target === pageUrl - ) { - const detected = mergedVideoUrls(tab.id); - // Try to match the clicked video's srcUrl against detected videos. - // info.srcUrl is the video element's src (may be blob:). - let matched = null; - if (info.srcUrl) { - const videos = domVideosFor(tab.id); - for (const v of videos) { - if (v && v.src === info.srcUrl) { - matched = v; - break; - } - } - } - if (matched && toGrabUrl(matched.src) && matched.src !== pageUrl) { - // Direct URL: use it. - target = matched.src; - } else if (detected.length === 1 && toGrabUrl(detected[0])) { - // Single detected video: use it. - target = detected[0]; - } else if (matched && matched.blob && detected.length > 0) { - // Blob video matched but no direct URL: use first detected (likely paired manifest). - const first = detected.find((u) => toGrabUrl(u)); - if (first) target = first; - } - } - // Prefer the media/link target, but a blob:/data: media URL can't leave - // the browser — fall back to the page URL so yt-dlp can extract it there. - // Try the paired HTTP handoff first (sends cookies); fall back to grab:// - // only when Grab isn't reachable at all. - if (target) await explicitSend(target, tab && tab.id, tab && tab.incognito === true); -}); - -// The toolbar button now opens popup.html (default_popup in the manifest), -// which hosts the settings UI directly. - -chrome.commands.onCommand.addListener((command) => { - if (command !== "send-tab-to-grab") return; - chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => { - const url = tabs[0] && tabs[0].url; - // Paired HTTP handoff first (sends cookies); grab:// fallback only when - // Grab isn't reachable at all. - if (url) await explicitSend(url, tabs[0].id, tabs[0].incognito === true); - }); -}); - -// --- Video detection ------------------------------------------------------ -// -// FDM-style video menu: the content script reports