diff --git a/options.html b/options.html index 87064d2..612191e 100644 --- a/options.html +++ b/options.html @@ -228,7 +228,7 @@

Grab Options

need them. Without one, only links are sent. Leave empty to unpair.

- + diff --git a/options.js b/options.js index ee46e02..d2d5fb9 100644 --- a/options.js +++ b/options.js @@ -7,53 +7,171 @@ const pairingEl = document.getElementById("pairingCode"); const pairingBtn = document.getElementById("savePairing"); const pairingStatusEl = document.getElementById("pairingStatus"); +let pairingStatusRevision = 0; + function showPairingStatus(text) { pairingStatusEl.textContent = text; } +function invalidatePairingStatus() { + pairingStatusRevision += 1; + return pairingStatusRevision; +} + +function isCurrentPairingOperation(revision, code) { + return ( + revision === pairingStatusRevision && + normalizePairingCode(pairingEl.value) === code + ); +} + +async function hasCookiePermission() { + try { + return await chrome.permissions.contains({ permissions: ["cookies"] }); + } catch { + // The permission state is unknown, not necessarily denied. + return null; + } +} + +/// The background worker runs the same nonce/HMAC handshake used before a +/// paired handoff. A saved or well-formed code alone is never "Verified". +async function verifySavedPairing(code, cookieAccessGranted, revision) { + let reply; + try { + reply = await chrome.runtime.sendMessage({ type: "grab-status" }); + } catch { + // The worker may be restarting or unavailable. + } + + if (!isCurrentPairingOperation(revision, code)) return; + + // Protect against the code being changed from another Options page while + // the handshake was in flight. + let storedCode; + try { + storedCode = normalizePairingCode(await loadPairingCode()); + } catch { + if (isCurrentPairingOperation(revision, code)) { + showPairingStatus("Saved, but verification status is unavailable."); + } + return; + } + + if (!isCurrentPairingOperation(revision, code)) return; + if (storedCode !== code) { + showPairingStatus("Pairing code changed elsewhere; reload before verifying."); + return; + } + + if (reply && reply.state === "verified") { + const cookieStatus = + cookieAccessGranted === true + ? "Cookie access is granted." + : cookieAccessGranted === false + ? "Cookie access is not granted, so handoffs are URL-only." + : "Cookie permission status is unavailable."; + showPairingStatus(`Verified — Grab recognizes this code. ${cookieStatus}`); + } else if (reply && reply.state === "unverified") { + showPairingStatus( + "Saved, but not verified — Grab could not prove this pairing code.", + ); + } else if (reply && reply.state === "down") { + showPairingStatus( + "Saved, but Grab is not reachable; verification is pending.", + ); + } else if (reply && reply.state === "unpaired") { + showPairingStatus( + "Saved, but the background worker found no valid pairing code.", + ); + } else { + showPairingStatus("Saved, but verification status is unavailable."); + } +} + async function loadPairing() { - pairingEl.value = await loadPairingCode(); - showPairingStatus(pairingEl.value ? "Paired" : "Not paired: links only"); + // Capture the revision before storage access. If the user starts editing + // before the asynchronous read completes, don't overwrite their input. + const revision = invalidatePairingStatus(); + let code; + try { + code = normalizePairingCode(await loadPairingCode()); + } catch { + if (revision === pairingStatusRevision) { + showPairingStatus("Could not load the saved pairing code."); + } + return; + } + if (revision !== pairingStatusRevision) return; + + pairingEl.value = code; + if (!code) { + showPairingStatus("Not paired: links only"); + return; + } + if (!SHARED_PAIRING_CODE_RE.test(code)) { + showPairingStatus("Saved code has an invalid format; enter it again."); + return; + } + + showPairingStatus("Saved; verifying with Grab…"); + const cookieAccessGranted = await hasCookiePermission(); + if (!isCurrentPairingOperation(revision, code)) return; + await verifySavedPairing(code, cookieAccessGranted, revision); } -/// Pair (or unpair, with an empty field). The click is the user gesture the -/// browser requires to grant the optional cookies permission, which is asked -/// for only when a code is being stored. +/// Save a code (or unpair when empty), request optional cookie access, then +/// ask the background worker to cryptographically verify the saved code. async function savePairing() { + const revision = invalidatePairingStatus(); pairingEl.removeAttribute("aria-invalid"); const code = normalizePairingCode(pairingEl.value); + if (code === "") { await savePairingCode(""); - // Unpairing also gives back the cookies permission granted with the code. + // Always revoke cookie access when unpairing, even if the input is edited + // again while the storage operation is in flight. try { await chrome.permissions.remove({ permissions: ["cookies"] }); } catch { // Permissions API unavailable, or already removed. } - showPairingStatus("Not paired: links only"); + if (isCurrentPairingOperation(revision, "")) { + pairingEl.value = ""; + showPairingStatus("Not paired: links only"); + } return; } - if (!(await savePairingCode(code))) { - pairingEl.setAttribute("aria-invalid", "true"); - showPairingStatus("That isn't a pairing code (64 letters and digits)."); + + if (!SHARED_PAIRING_CODE_RE.test(code) || !(await savePairingCode(code))) { + if (revision === pairingStatusRevision) { + pairingEl.setAttribute("aria-invalid", "true"); + showPairingStatus("That isn't a pairing code (64 hexadecimal characters)."); + } return; } + + if (!isCurrentPairingOperation(revision, code)) return; pairingEl.value = code; - let granted = false; + showPairingStatus("Saved; verifying with Grab…"); + + // The click is the user gesture the browser requires to grant optional + // cookie access. Verification is separate from this permission decision. try { - granted = await chrome.permissions.request({ permissions: ["cookies"] }); + await chrome.permissions.request({ permissions: ["cookies"] }); } catch { - // Permission API unavailable: pair anyway; handoffs stay URL-only. + // The code can still be verified; handoffs remain URL-only without cookies. } - showPairingStatus( - granted - ? "Paired: cookies are shared with Grab" - : "Paired, but cookie access was not granted: links only", - ); + if (!isCurrentPairingOperation(revision, code)) return; + + const cookieAccessGranted = await hasCookiePermission(); + await verifySavedPairing(code, cookieAccessGranted, revision); } pairingEl.addEventListener("input", () => { + invalidatePairingStatus(); pairingEl.removeAttribute("aria-invalid"); + showPairingStatus("Unsaved changes — save to verify or unpair."); }); pairingBtn.addEventListener("click", savePairing); diff --git a/popup.html b/popup.html index 6afaaa7..6223d5e 100644 --- a/popup.html +++ b/popup.html @@ -11,6 +11,10 @@ --input-bg: #ffffff; --saved: #1a7f37; --accent: #1a73e8; + --status-ok: #188038; + --status-warning: #9a6700; + --status-down: #b3261e; + --status-neutral: #6b7280; } @media (prefers-color-scheme: dark) { :root { @@ -20,6 +24,10 @@ --input-bg: #2d2d2d; --saved: #4caf50; --accent: #8ab4f8; + --status-ok: #4caf50; + --status-warning: #e5c07b; + --status-down: #f28b82; + --status-neutral: #b0b0b0; } } body { @@ -35,7 +43,7 @@ display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: 10px; margin-bottom: 12px; } h2 { @@ -130,28 +138,36 @@ margin: 12px 0 0; } .status { - font-size: 12px; - padding: 6px 10px; - border-radius: 6px; - margin: 0 0 12px; + display: inline-flex; + align-items: center; + gap: 5px; + flex: 0 1 auto; + min-width: 0; + margin: 0 0 0 auto; + color: var(--status-neutral); + font-size: 11px; + line-height: 1.2; + white-space: nowrap; + } + .status::before { + width: 6px; + height: 6px; + flex: 0 0 6px; + border-radius: 50%; + background: currentColor; + content: ""; } .status.ok { - background: #e6f4ea; - color: #1a7f37; + color: var(--status-ok); + } + .status.warning { + color: var(--status-warning); } .status.down { - background: #fce8e6; - color: #c5221f; + color: var(--status-down); } - @media (prefers-color-scheme: dark) { - .status.ok { - background: #1a3a24; - color: #4caf50; - } - .status.down { - background: #3a1a1a; - color: #f28b82; - } + .status.neutral { + color: var(--status-neutral); } @media (prefers-reduced-motion: reduce) { #saved { @@ -163,6 +179,7 @@

Grab

+ Checking…
-
Checking Grab…
- +