2026-05-11 16:12:34 +02:00
|
|
|
// Popup entry point. Runs every time the user clicks the toolbar icon.
|
|
|
|
|
//
|
|
|
|
|
// Lifecycle:
|
|
|
|
|
// 1. `init()` reads the active tab's URL via the WebExtension `tabs` API
|
|
|
|
|
// (granted by the `activeTab` permission, which only activates on user
|
|
|
|
|
// gesture — no persistent host access).
|
|
|
|
|
// 2. Browser-internal schemes (about:, moz-extension:, ...) short-circuit
|
|
|
|
|
// to a friendly "can't share this page" message.
|
|
|
|
|
// 3. Otherwise `renderQr()` asks the vendored qrcode-generator for an SVG
|
|
|
|
|
// (smallest version that fits, error-correction level M ~15%) and
|
|
|
|
|
// mounts it into the popup.
|
|
|
|
|
// 4. The Copy URL button uses navigator.clipboard.writeText, gated by the
|
|
|
|
|
// `clipboardWrite` permission.
|
|
|
|
|
//
|
|
|
|
|
// The vendored library is imported here so `bun build` can inline it; there
|
|
|
|
|
// is no global `qrcode` at runtime.
|
|
|
|
|
|
2026-05-11 16:04:22 +02:00
|
|
|
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();
|