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
2 changes: 1 addition & 1 deletion options.html
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ <h1>Grab Options</h1>
need them. Without one, only links are sent. Leave empty to unpair.
</p>
</div>
<button type="button" id="savePairing">Pair</button>
<button type="button" id="savePairing">Save &amp; verify</button>
<span id="pairingStatus" class="hint" role="status" aria-live="polite"></span>
</fieldset>

Expand Down
154 changes: 136 additions & 18 deletions options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
56 changes: 36 additions & 20 deletions popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -20,6 +24,10 @@
--input-bg: #2d2d2d;
--saved: #4caf50;
--accent: #8ab4f8;
--status-ok: #4caf50;
--status-warning: #e5c07b;
--status-down: #f28b82;
--status-neutral: #b0b0b0;
}
}
body {
Expand All @@ -35,7 +43,7 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 10px;
margin-bottom: 12px;
}
h2 {
Expand Down Expand Up @@ -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 {
Expand All @@ -163,6 +179,7 @@
<body>
<header class="header">
<h2><img src="icons/icon32.png" alt="" />Grab</h2>
<span id="grabStatus" class="status neutral" role="status" aria-live="polite" aria-atomic="true">Checking…</span>
<button
type="button"
id="openOptions"
Expand All @@ -186,8 +203,7 @@ <h2><img src="icons/icon32.png" alt="" />Grab</h2>
</svg>
</button>
</header>
<div id="grabStatus" class="status" role="status" aria-live="polite" aria-atomic="true">Checking Grab…</div>


<label class="checkbox">
<input type="checkbox" id="interceptDownloads" />
<span>Automatically send browser downloads to Grab</span>
Expand Down
4 changes: 2 additions & 2 deletions popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ bindSettingsForm(document);
// The worker does the check: it knows the pairing code and the port.
const STATUS_TEXT = {
verified: ["Connected", "status ok"],
unverified: ["Unverified", "status down"],
unpaired: ["Not paired", "status down"],
unverified: ["Unverified", "status warning"],
unpaired: ["Not paired", "status neutral"],
down: ["Not running", "status down"],
};

Expand Down
Loading
Loading