From 1b791cc1cf110b44d9b898f6417485879eef9871 Mon Sep 17 00:00:00 2001
From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com>
Date: Sat, 24 Oct 2026 19:26:14 +0100
Subject: [PATCH 01/12] Show pairing as verified only after a successful
handshake
---
options.js | 140 ++++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 122 insertions(+), 18 deletions(-)
diff --git a/options.js b/options.js
index ee46e02..b847d2c 100644
--- a/options.js
+++ b/options.js
@@ -7,53 +7,157 @@ 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 {
+ // Permission API unavailable, or the browser refused the query.
+ return false;
+ }
+}
+
+/// 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") {
+ showPairingStatus(
+ cookieAccessGranted
+ ? "Verified — Grab recognizes this code; cookie access is granted."
+ : "Verified — cookie access is not granted, so handoffs are URL-only.",
+ );
+ } 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");
+ const code = normalizePairingCode(await loadPairingCode());
+ pairingEl.value = code;
+ const revision = invalidatePairingStatus();
+
+ 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();
+ 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);
From 894df882973b6a28bfcb5734ba2f2fb59d0d715d Mon Sep 17 00:00:00 2001
From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com>
Date: Sat, 24 Oct 2026 19:26:23 +0100
Subject: [PATCH 02/12] Label pairing action as save and verify
---
options.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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.
-
+
From e0dfec0d7d90dc035f5477a1abe5e84dad836b30 Mon Sep 17 00:00:00 2001
From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com>
Date: Sat, 24 Oct 2026 19:27:03 +0100
Subject: [PATCH 03/12] Test pairing verification status in Options UI
---
test/options.test.js | 180 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 180 insertions(+)
create mode 100644 test/options.test.js
diff --git a/test/options.test.js b/test/options.test.js
new file mode 100644
index 0000000..4993e39
--- /dev/null
+++ b/test/options.test.js
@@ -0,0 +1,180 @@
+// Focused tests for the Options page's pairing-status state machine.
+// Run with: node --test test/*.test.js
+const { test } = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const vm = require("node:vm");
+
+const OPTIONS_SOURCE = fs.readFileSync(
+ path.join(__dirname, "..", "options.js"),
+ "utf8",
+);
+const VALID_CODE = "ab".repeat(32);
+
+function makeElement(id) {
+ return {
+ id,
+ value: "",
+ textContent: "",
+ attributes: new Map(),
+ listeners: new Map(),
+ addEventListener(type, callback) {
+ this.listeners.set(type, callback);
+ },
+ removeAttribute(name) {
+ this.attributes.delete(name);
+ },
+ setAttribute(name, value) {
+ this.attributes.set(name, value);
+ },
+ };
+}
+
+function makeOptions({ storedCode = VALID_CODE, cookiePermission = true } = {}) {
+ const elements = new Map();
+ const pendingMessages = [];
+ let savedCode = storedCode;
+
+ const document = {
+ getElementById(id) {
+ if (!elements.has(id)) elements.set(id, makeElement(id));
+ return elements.get(id);
+ },
+ };
+
+ const chrome = {
+ runtime: {
+ sendMessage(message) {
+ return new Promise((resolve, reject) => {
+ pendingMessages.push({ message, resolve, reject });
+ });
+ },
+ },
+ permissions: {
+ request: async () => cookiePermission,
+ contains: async () => cookiePermission,
+ remove: async () => true,
+ },
+ };
+
+ const normalizePairingCode = (raw) =>
+ String(raw || "").replace(/\\s+/g, "").toLowerCase();
+ const pairingCodePattern = /^[0-9a-f]{64}$/;
+ const context = vm.createContext({
+ document,
+ chrome,
+ SHARED_PAIRING_CODE_RE: pairingCodePattern,
+ normalizePairingCode,
+ bindSettingsForm() {},
+ async loadPairingCode() {
+ return savedCode;
+ },
+ async savePairingCode(raw) {
+ const code = normalizePairingCode(raw);
+ if (code !== "" && !pairingCodePattern.test(code)) return false;
+ savedCode = code;
+ return true;
+ },
+ });
+
+ vm.runInContext(OPTIONS_SOURCE, context, { filename: "options.js" });
+
+ return {
+ elements,
+ pendingMessages,
+ get savedCode() {
+ return savedCode;
+ },
+ };
+}
+
+async function waitFor(predicate, message) {
+ for (let i = 0; i < 100; i += 1) {
+ if (predicate()) return;
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+ assert.fail(message || "Timed out waiting for Options page state");
+}
+
+test("saved code stays pending until the background worker verifies it", async () => {
+ const ui = makeOptions();
+ const status = ui.elements.get("pairingStatus");
+
+ await waitFor(() => ui.pendingMessages.length === 1, "handshake message was not sent");
+ assert.equal(ui.pendingMessages[0].message.type, "grab-status");
+ assert.equal(status.textContent, "Saved; verifying with Grab…");
+ assert.notEqual(status.textContent, "Verified");
+
+ ui.pendingMessages[0].resolve({ state: "verified" });
+ await waitFor(() => status.textContent.startsWith("Verified"));
+
+ assert.equal(
+ status.textContent,
+ "Verified — Grab recognizes this code; cookie access is granted.",
+ );
+});
+
+test("a failed proof never labels the code Verified", async () => {
+ const ui = makeOptions();
+ const status = ui.elements.get("pairingStatus");
+
+ await waitFor(() => ui.pendingMessages.length === 1);
+ ui.pendingMessages[0].resolve({ state: "unverified" });
+ await waitFor(() => status.textContent.startsWith("Saved, but not verified"));
+
+ assert.match(status.textContent, /could not prove this pairing code/i);
+ assert.equal(status.textContent.startsWith("Verified"), false);
+});
+
+test("an unreachable Grab remains Saved but not Verified", async () => {
+ const ui = makeOptions();
+ const status = ui.elements.get("pairingStatus");
+
+ await waitFor(() => ui.pendingMessages.length === 1);
+ ui.pendingMessages[0].resolve({ state: "down" });
+ await waitFor(() => status.textContent.includes("not reachable"));
+
+ assert.equal(status.textContent.startsWith("Verified"), false);
+});
+
+test("editing the code invalidates a pending successful handshake", async () => {
+ const ui = makeOptions();
+ const codeInput = ui.elements.get("pairingCode");
+ const status = ui.elements.get("pairingStatus");
+
+ await waitFor(() => ui.pendingMessages.length === 1);
+ codeInput.value = "cd".repeat(32);
+ codeInput.listeners.get("input")();
+ assert.equal(status.textContent, "Unsaved changes — save to verify or unpair.");
+
+ ui.pendingMessages[0].resolve({ state: "verified" });
+ await new Promise((resolve) => setImmediate(resolve));
+
+ assert.equal(status.textContent, "Unsaved changes — save to verify or unpair.");
+ assert.equal(status.textContent.startsWith("Verified"), false);
+});
+
+test("saving a new code shows Saved first and then the verification result", async () => {
+ const ui = makeOptions({ storedCode: "", cookiePermission: false });
+ const codeInput = ui.elements.get("pairingCode");
+ const saveButton = ui.elements.get("savePairing");
+ const status = ui.elements.get("pairingStatus");
+
+ await waitFor(() => status.textContent === "Not paired: links only");
+ codeInput.value = VALID_CODE.toUpperCase();
+ codeInput.listeners.get("input")();
+
+ const savePromise = saveButton.listeners.get("click")();
+ await waitFor(() => ui.pendingMessages.length === 1, "saved code was not checked");
+ assert.equal(ui.savedCode, VALID_CODE);
+ assert.equal(status.textContent, "Saved; verifying with Grab…");
+
+ ui.pendingMessages[0].resolve({ state: "verified" });
+ await savePromise;
+
+ assert.equal(
+ status.textContent,
+ "Verified — cookie access is not granted, so handoffs are URL-only.",
+ );
+});
From 213ccc659356acf60dbe3c7b471a8961d558256e Mon Sep 17 00:00:00 2001
From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com>
Date: Sat, 24 Oct 2026 19:27:57 +0100
Subject: [PATCH 04/12] Move compact Grab status into the popup header
---
popup.html | 56 +++++++++++++++++++++++++++++++++++-------------------
1 file changed, 36 insertions(+), 20 deletions(-)
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…
-
+