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 @@
- Checking Grab…
-
+