The profile folder was Electron's default from the product name
("Theseus Navigator") and add-ons lived in addons\ under it. Now:
%APPDATA%\Theseus\extensions\ installed extensions
%APPDATA%\Theseus\extensions-data\ per-extension storage + scratch
%APPDATA%\Theseus\extensions-backups\ replaced copies
%APPDATA%\Theseus\extensions-staged\ staged updates
Both moves are one-time migrations on the first start that finds the old
layout: the profile folder is renamed (same volume, instant) or copied
when a rename is refused, with the old folder left in place in that case;
the four sub-folders are renamed before the extension host first reads
them. Nothing is deleted. THESEUS_USER_DATA still overrides everything.
The host now hands each extension its data folder as api.dataDir; the
Screenshot and PDF editor add-ons used to rebuild the old path from their
own folder for scratch files (so they recreated addons-data\ after the
move) and now use the field, with versions bumped so the bundles reseed.
164 lines
7.8 KiB
JavaScript
164 lines
7.8 KiB
JavaScript
// Screenshot — capture the active tab, preview in the sidebar, then hand
|
|
// off to a full-tab editor only when the user asks for it. The old toolbar-
|
|
// dropdown flow auto-opened editor.html the instant a capture completed;
|
|
// that turned the editor tab into the ACTIVE tab, so any second click of
|
|
// the dropdown snapshotted the editor's still-blank canvas and every follow-
|
|
// up produced a white PNG. Sidebar-first breaks that loop entirely: the
|
|
// preview is served from a data URL inside the sidebar's own document, and
|
|
// the editor tab is opened only on an explicit "Open in editor" click.
|
|
//
|
|
// Flow:
|
|
// panel invokes "capture" → api.captureTab → write PNG to per-add-on
|
|
// scratch dir → return {name, dataUrl, width, height, host, bytes} to
|
|
// panel → panel renders <img src="data:…"> in a preview area.
|
|
// panel invokes "openInTab" → we stash the raw PNG in api.storage under
|
|
// "__pending" and openTab("editor.html") — the editor's preload lets it
|
|
// pull that entry out via silentmode.storage.get().
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const MAX_RECENT = 6; // ring size for the recent-captures list
|
|
const SCRATCH_DIR = "screenshot-scratch"; // sibling of the add-on's storage JSON
|
|
|
|
module.exports = {
|
|
activate(api) {
|
|
api.registerSidebarPanel({
|
|
id: "main",
|
|
title: "Screenshot",
|
|
icon: "📸",
|
|
page: "panel.html",
|
|
});
|
|
|
|
// Per-add-on scratch dir under <userData>/extensions-data/. We don't touch
|
|
// the add-on folder itself — editing it there would confuse users who
|
|
// are inspecting the shipped source. api.folder = <userData>/extensions/
|
|
// screenshot/, so dirname twice lands on <userData>.
|
|
const dataParent = path.dirname(path.join(api.folder, ".."));
|
|
// api.dataDir is the host-provided per-extension data folder (<userData>/extensions-data);
|
|
// the fallback rebuilds it for hosts that predate that field.
|
|
const scratchDir = path.join(api.dataDir || path.join(dataParent, "extensions-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
|
|
// hands this to capture-tab, which injects it into the target tab and
|
|
// awaits the {x,y,w,h} the overlay resolves with.
|
|
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) {
|
|
const alive = recent.filter((r) => { try { fs.accessSync(r.path); return true; } catch { return false; } });
|
|
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 };
|
|
}
|
|
function readAsDataUrl(absPath) {
|
|
const buf = fs.readFileSync(absPath);
|
|
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
}
|
|
|
|
// panel invokes "capture": take the shot, write to scratch, hand the
|
|
// data URL straight back so the sidebar can preview it immediately.
|
|
api.onMessage("capture", async (payload) => {
|
|
const mode = String(payload && payload.mode || "visible");
|
|
if (mode !== "visible" && mode !== "full" && mode !== "region") {
|
|
throw new Error(`unknown capture mode: ${mode}`);
|
|
}
|
|
const opts = { mode, format: "png" };
|
|
if (mode === "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()}${mode === "full" ? "-fullpage" : mode === "region" ? "-region" : ""}.png`;
|
|
const written = writeScratch(cap.dataUrl, name);
|
|
let recent = api.storage.get("recent", []);
|
|
if (!Array.isArray(recent)) recent = [];
|
|
recent.unshift({ id: name, name, path: written.path, bytes: written.bytes, at: Date.now(), mode });
|
|
recent = pruneRecent(recent);
|
|
api.storage.set("recent", recent);
|
|
api.log(`captured ${mode} → ${name} (${written.bytes} bytes)`);
|
|
return {
|
|
ok: true, name, mode,
|
|
dataUrl: cap.dataUrl,
|
|
width: cap.width, height: cap.height,
|
|
host: cap.host, bytes: written.bytes,
|
|
};
|
|
});
|
|
|
|
// (The old "arm" handler that pre-armed a __pending storage entry is
|
|
// gone: editor.html now pulls its bytes via getBytes on load, so there
|
|
// is nothing to hand off in advance. Idempotent — reopening the editor
|
|
// in the sidebar just re-fetches the bytes.)
|
|
|
|
// Read the ring for a "recent captures" strip in the sidebar. Bytes are
|
|
// reported; the actual images are pulled through "getBytes" on demand
|
|
// so we don't ship every thumbnail through IPC on every panel open.
|
|
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, bytes: r.bytes, at: r.at, mode: r.mode }));
|
|
});
|
|
|
|
// Panel asks for a specific past capture's bytes so it can preview it.
|
|
api.onMessage("getBytes", (payload) => {
|
|
const wantedName = String(payload && payload.name || "");
|
|
const recent = api.storage.get("recent", []) || [];
|
|
const hit = recent.find((r) => r.id === wantedName);
|
|
if (!hit) throw new Error(`no recent capture "${wantedName}"`);
|
|
return { name: hit.name, dataUrl: readAsDataUrl(hit.path), bytes: hit.bytes, at: hit.at, mode: hit.mode };
|
|
});
|
|
|
|
// Show the scratch dir (or a specific capture inside it) in the OS
|
|
// file explorer. `api.require("electron").shell.openPath` opens the
|
|
// folder; `showItemInFolder` opens the folder AND highlights the
|
|
// file — used when the panel/editor knows a specific capture.
|
|
api.onMessage("openFolder", (payload) => {
|
|
const { shell } = api.require("electron");
|
|
const wantedName = payload && payload.name ? String(payload.name) : "";
|
|
if (wantedName) {
|
|
const recent = api.storage.get("recent", []) || [];
|
|
const hit = recent.find((r) => r.id === wantedName);
|
|
if (hit) { shell.showItemInFolder(hit.path); return { ok: true, path: hit.path }; }
|
|
}
|
|
shell.openPath(scratchDir);
|
|
return { ok: true, path: scratchDir };
|
|
});
|
|
|
|
api.onMessage("clearRecent", (payload) => {
|
|
const wantedName = payload && payload.name ? String(payload.name) : "";
|
|
let recent = api.storage.get("recent", []) || [];
|
|
if (wantedName) {
|
|
const hit = recent.find((r) => r.id === wantedName);
|
|
if (hit) { try { fs.unlinkSync(hit.path); } catch {} }
|
|
recent = recent.filter((r) => r.id !== wantedName);
|
|
} 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 v0.5.0 sidebar panel");
|
|
},
|
|
};
|