// Screenshot — capture the active tab, then hand the raw PNG to a full-tab // editor page (editor.html) for annotate / redact / crop / save. There is // no sidebar panel; the whole flow is toolbar dropdown → capture → editor tab. // // Flow: chrome dispatches "menu-select" with {id: "visible"|"full"|"region"} // → we call api.captureTab({mode, ...}) → write the raw PNG to a per-add-on // scratch dir → record it in api.storage under "recent" (small ring buffer) // → open editor.html?src=&name=…&tool=… as a full Theseus tab. // The editor loads the PNG onto a and, when the user hits Save, // downloads the modified image through Chromium's normal path // (caught by Theseus's will-download tracker — no download capability needed). const fs = require("node:fs"); const path = require("node:path"); const MAX_RECENT = 6; // keep the last N in the ring; older files pruned const SCRATCH_DIR = "screenshot-scratch"; // sibling of the add-on's storage json module.exports = { activate(api) { // Per-add-on scratch dir under /addons-data/. We don't touch // the add-on folder itself — that would confuse users editing their own // copy in the file browser. api.folder is /addons/screenshot/, // so dirname twice + "addons-data" gets us to the sibling of the storage // json api.storage writes. const dataParent = path.dirname(path.join(api.folder, "..")); const scratchDir = path.join(dataParent, "addons-data", SCRATCH_DIR); try { fs.mkdirSync(scratchDir, { recursive: true }); } catch (e) { api.log("scratch mkdir failed:", e?.message); } // Region-select overlay source, loaded once at activation. Region mode // triggers this; main injects it into the tab and awaits the rect it // resolves with. Keeping the DOM code in a sibling file (not a JS string // in main) lets someone edit the overlay UX without touching browser core. let overlaySource = ""; try { overlaySource = fs.readFileSync(path.join(api.folder, "panel-preload.js"), "utf8"); } catch (e) { api.log("panel-preload.js not readable:", e?.message); } function fileUrl(abs) { return "file:///" + abs.replace(/\\/g, "/").replace(/^\/+/, "").replace(/#/g, "%23").replace(/\?/g, "%3F"); } function nowStamp() { const d = new Date(); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } function pruneRecent(recent) { // Drop entries whose scratch file no longer exists so the ring doesn't // reference broken paths after a manual cleanup. const alive = recent.filter((r) => { try { fs.accessSync(r.path); return true; } catch { return false; } }); // Cap size and delete the files we're about to forget. const trimmed = alive.slice(0, MAX_RECENT); const dropped = alive.slice(MAX_RECENT); for (const r of dropped) { try { fs.unlinkSync(r.path); } catch {} } return trimmed; } function writeScratch(dataUrl, name) { const m = /^data:image\/png;base64,(.+)$/.exec(String(dataUrl)); if (!m) throw new Error("expected image/png data URL from capture"); const buf = Buffer.from(m[1], "base64"); const file = path.join(scratchDir, name); fs.writeFileSync(file, buf); return { path: file, bytes: buf.length }; } // Toolbar dropdown item click: chrome sends {id: "visible"|"full"|"region"}. api.onMessage("menu-select", async (payload) => { const id = String(payload && payload.id || "visible"); if (id !== "visible" && id !== "full" && id !== "region") { throw new Error(`unknown menu item: ${id}`); } // capture-tab uses the same mode strings as our menu ids. Region mode // needs the overlay source so the user can draw the rect in-page. const opts = { mode: id, format: "png" }; if (id === "region") opts.overlaySource = overlaySource; const cap = await api.captureTab(opts); if (cap.cancelled) { api.log(`region capture cancelled`); return { ok: false, cancelled: true }; } const name = `screenshot-${nowStamp()}${id === "full" ? "-fullpage" : id === "region" ? "-region" : ""}.png`; const written = writeScratch(cap.dataUrl, name); // Recent-captures ring buffer. let recent = api.storage.get("recent", []); if (!Array.isArray(recent)) recent = []; recent.unshift({ id: name, name, path: written.path, url: fileUrl(written.path), bytes: written.bytes, at: Date.now(), mode: id }); recent = pruneRecent(recent); api.storage.set("recent", recent); // Hand the raw data URL to the editor tab through addon storage. We // used to pass `?src=file://…` and let the editor load the scratch // file, but Chromium's file:// origin policy blocks from // cross-directory file loads: editor.html lives under // /addons/screenshot/ and scratch PNGs land under // /addons-data/screenshot-scratch/, which is a sibling of // the addon dir, and the load quietly fails — the editor initializes // but the image never draws, so every subsequent tool (draw, save, // discard) operates on a still-empty canvas. Storage isn't // origin-scoped, so the editor can pull the data URL out and paint // it locally. api.storage.set("__pending", { name, dataUrl: cap.dataUrl, tool: id === "region" ? "crop" : "", at: Date.now() }); api.log(`captured ${id} → ${name} (${written.bytes} bytes)`); api.openTab("editor.html", { query: { name } }); return { ok: true, name }; }); // Read the ring for a future "recent captures" surface — the editor may // grow a "past captures" strip, and Settings can display them too. Not // wired to any UI in 0.2.0; kept so the ring is inspectable. api.onMessage("listRecent", () => { let recent = api.storage.get("recent", []); if (!Array.isArray(recent)) recent = []; recent = pruneRecent(recent); api.storage.set("recent", recent); return recent.map((r) => ({ id: r.id, name: r.name, url: r.url, bytes: r.bytes, at: r.at, mode: r.mode })); }); api.onMessage("openRecent", ({ id } = {}) => { const recent = api.storage.get("recent", []) || []; const hit = recent.find((r) => r.id === id); if (!hit) throw new Error(`no recent capture "${id}"`); api.openTab("editor.html", { query: { src: hit.url, name: hit.name, tool: "" } }); return { ok: true }; }); api.onMessage("clearRecent", ({ id } = {}) => { let recent = api.storage.get("recent", []) || []; if (id) { const hit = recent.find((r) => r.id === id); if (hit) { try { fs.unlinkSync(hit.path); } catch {} } recent = recent.filter((r) => r.id !== id); } else { for (const r of recent) { try { fs.unlinkSync(r.path); } catch {} } recent = []; } api.storage.set("recent", recent); return { ok: true }; }); api.log("registered screenshot toolbar-menu (visible / full / region)"); }, };