theseus/bundled-addons/screenshot/index.js

130 lines
6.5 KiB
JavaScript
Raw Normal View History

// 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=<file url>&name=…&tool=… as a full Theseus tab.
// The editor loads the PNG onto a <canvas> and, when the user hits Save,
// downloads the modified image through Chromium's normal <a download> 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 <userData>/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 <userData>/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);
api.log(`captured ${id}${name} (${written.bytes} bytes)`);
// Region mode = land in the crop tool immediately so the user can trim
// further if the freehand rect was rough.
const tool = id === "region" ? "crop" : "";
api.openTab("editor.html", { query: { src: fileUrl(written.path), name, tool } });
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)");
},
};