Initial commit: Share-as-QR Firefox extension with bun build

MV3 WebExtension that turns the active tab's URL into a scannable QR
code via a toolbar popup. The popup runs locally — no network requests.

Build pipeline: `bun build` bundles popup.js + the vendored
kazuhikoarase/qrcode-generator (MIT, pinned to 83b7e8f) into a single
~23 KB minified ESM file under dist/. web-ext operates on dist/, so
the packaged zip contains only what actually ships (~28 KB).

Scripts:
- bun run build    — bundle + copy assets into dist/
- bun run lint     — build + web-ext lint (0 errors / 0 warnings)
- bun run package  — build + produce a signable .zip
- bun run start    — build + launch Firefox with the extension loaded

Tracks dogcat epic firefox-share-as-qr-35mw and its 5 child tasks
(scaffold, vendor lib, popup UI, icons, README).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-05-11 16:04:22 +02:00
commit 8fa1809d9d
16 changed files with 2915 additions and 0 deletions

104
popup/popup.js Normal file
View file

@ -0,0 +1,104 @@
import qrcode from "../vendor/qrcode.js";
// Schemes that either can't be QR-encoded usefully or are browser-internal.
const PRIVILEGED_PROTOCOLS = new Set([
"about:",
"moz-extension:",
"chrome:",
"view-source:",
"resource:",
"javascript:",
"data:",
]);
qrcode.stringToBytes = qrcode.stringToBytesFuncs["UTF-8"];
const qrEl = document.getElementById("qr");
const messageEl = document.getElementById("message");
const urlEl = document.getElementById("url-text");
const copyBtn = document.getElementById("copy");
let currentUrl = "";
let copyResetTimer = null;
function showMessage(text) {
qrEl.replaceChildren();
qrEl.dataset.empty = "true";
messageEl.textContent = text;
messageEl.hidden = false;
copyBtn.disabled = true;
}
function isPrivileged(url) {
try {
return PRIVILEGED_PROTOCOLS.has(new URL(url).protocol);
} catch {
return true;
}
}
function renderQr(url) {
// EC level 'M' (~15%) balances scan robustness against QR density for screen display.
const qr = qrcode(0, "M");
qr.addData(url, "Byte");
qr.make();
const svgString = qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true });
// Parse to a real DOM node rather than assigning innerHTML, to keep the
// string→DOM boundary explicit even though the bytes come from a trusted library.
const doc = new DOMParser().parseFromString(svgString, "image/svg+xml");
const svg = doc.documentElement;
if (svg.nodeName.toLowerCase() !== "svg") {
throw new Error("QR library did not return SVG");
}
qrEl.replaceChildren(svg);
qrEl.dataset.empty = "false";
messageEl.hidden = true;
}
async function init() {
let tabs;
try {
tabs = await browser.tabs.query({ active: true, currentWindow: true });
} catch (err) {
showMessage("Could not read the active tab.");
return;
}
const url = tabs?.[0]?.url;
if (!url) {
showMessage("No URL available for this tab.");
return;
}
currentUrl = url;
urlEl.textContent = url;
if (isPrivileged(url)) {
showMessage("This page can't be shared (browser-internal URL).");
return;
}
try {
renderQr(url);
} catch (err) {
// qrcode-generator throws when the data exceeds the largest QR version (~2,953 bytes for L).
showMessage("URL is too long to encode as a QR code.");
}
}
copyBtn.addEventListener("click", async () => {
if (!currentUrl) return;
try {
await navigator.clipboard.writeText(currentUrl);
copyBtn.textContent = "Copied!";
} catch (err) {
copyBtn.textContent = "Copy failed";
}
clearTimeout(copyResetTimer);
copyResetTimer = setTimeout(() => {
copyBtn.textContent = "Copy URL";
}, 1200);
});
init();