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.
138 lines
7.1 KiB
JavaScript
138 lines
7.1 KiB
JavaScript
// 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);
|
|
// 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)`);
|
|
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)");
|
|
},
|
|
};
|