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:
commit
8fa1809d9d
16 changed files with 2915 additions and 0 deletions
98
popup/popup.css
Normal file
98
popup/popup.css
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--fg: #1f2937;
|
||||
--muted: #6b7280;
|
||||
--accent: #2563eb;
|
||||
--accent-fg: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111827;
|
||||
--fg: #f9fafb;
|
||||
--muted: #9ca3af;
|
||||
--accent: #3b82f6;
|
||||
--accent-fg: #ffffff;
|
||||
--border: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 13px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
width: 320px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qr {
|
||||
width: 288px;
|
||||
height: 288px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.qr svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qr[data-empty="true"] {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.url {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
max-height: 3.6em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
17
popup/popup.html
Normal file
17
popup/popup.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Share as QR</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div id="qr" class="qr"></div>
|
||||
<p id="message" class="message" role="status" hidden></p>
|
||||
<p id="url-text" class="url"></p>
|
||||
<button id="copy" type="button">Copy URL</button>
|
||||
</main>
|
||||
<script type="module" src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
104
popup/popup.js
Normal file
104
popup/popup.js
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue