theseus/bundled-addons/screenshot/index.js

139 lines
7.1 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);
fix(theseus/screenshot): 0.2.4 — deliver capture via addon storage, not a cross-origin file:// Blank editor + broken buttons root cause: index.js was writing the capture to <userData>/addons-data/screenshot-scratch/<name>.png and passing "?src=file://<that path>" to editor.html. The editor lives at file:///<userData>/addons/screenshot/editor.html — different directory tree under file://. Chromium's file:// origin policy treats those as different origins and quietly refuses the <img> load, so init()'s loadImage() rejects, the canvas never gets an image, and every tool after that operates on a still-empty 300×150 default canvas — the tools appear to work but produce no visible output because the base image never landed. The sidebar version we replaced set `previewImg.src = dataUrl` (a base64 data URL) directly, which has no origin and just worked; the tab version regressed by adding the file hop. Fix keeps the scratch file for the recent-captures ring but hands the raw capture through the add-on's per-add-on kv store (`__pending` key). Same store, same origin scoping, no cross-directory read: index.js writes via api.storage.set from main; editor.js reads via window.silentmode.storage.get through the tab preload (packaged since 0.3.27). Fallback path retained for "openRecent" callers still passing ?src=… — those will need their own fix in a follow-up. Bumped to 0.2.4 and signed for the OTA endpoint — first real independent add-on ship: no Theseus release needed to fix this, 0.3.27 installs pick up 0.2.4 via the boot-time signed-update poll.
2026-09-08 12:57:46 +02:00
// 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 <img> from
// cross-directory file loads: editor.html lives under
// <userData>/addons/screenshot/ and scratch PNGs land under
// <userData>/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)`);
fix(theseus/screenshot): 0.2.4 — deliver capture via addon storage, not a cross-origin file:// Blank editor + broken buttons root cause: index.js was writing the capture to <userData>/addons-data/screenshot-scratch/<name>.png and passing "?src=file://<that path>" to editor.html. The editor lives at file:///<userData>/addons/screenshot/editor.html — different directory tree under file://. Chromium's file:// origin policy treats those as different origins and quietly refuses the <img> load, so init()'s loadImage() rejects, the canvas never gets an image, and every tool after that operates on a still-empty 300×150 default canvas — the tools appear to work but produce no visible output because the base image never landed. The sidebar version we replaced set `previewImg.src = dataUrl` (a base64 data URL) directly, which has no origin and just worked; the tab version regressed by adding the file hop. Fix keeps the scratch file for the recent-captures ring but hands the raw capture through the add-on's per-add-on kv store (`__pending` key). Same store, same origin scoping, no cross-directory read: index.js writes via api.storage.set from main; editor.js reads via window.silentmode.storage.get through the tab preload (packaged since 0.3.27). Fallback path retained for "openRecent" callers still passing ?src=… — those will need their own fix in a follow-up. Bumped to 0.2.4 and signed for the OTA endpoint — first real independent add-on ship: no Theseus release needed to fix this, 0.3.27 installs pick up 0.2.4 via the boot-time signed-update poll.
2026-09-08 12:57:46 +02:00
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)");
},
};