theseus/bundled-addons/pdf-editor/index.js
Local Dev bd6aab02fe feat(theseus): profile at %APPDATA%\Theseus, extensions under extensions\
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.
2026-09-21 01:55:25 +02:00

191 lines
9.2 KiB
JavaScript

// PDF Editor — the Node half. Small on purpose: everything interesting
// happens in editor.html, which is a full Theseus tab (the "open-tab"
// capability) rendering with pdf.js and writing with pdf-lib.
//
// This file exists for the three things a file:// page cannot do itself:
//
// 1. Launch. The dock's "Open a PDF…" item opens the editor tab. Nothing
// is handed over; the editor shows its own drop zone.
// 2. Fetch a PDF the user right-clicked. A file:// page has an opaque
// origin, so its cross-origin fetch of https://host/doc.pdf is refused
// by CORS on essentially every real server. electron's `net.fetch`
// runs on the app session instead, so it follows whatever proxy the
// user configured, and the bytes land in a scratch file.
// 3. Serve those bytes back. The editor pulls them with
// invoke("getBytes", {id}).
//
// Handoff is a scratch FILE plus an id in the query string, not a
// storage.__pending entry. The screenshot add-on shipped __pending first
// and had to abandon it: the receiving page drains the entry on first
// paint, so a reload — or Theseus restoring the tab on next launch — finds
// an empty slot and an editor with no document. An id in the URL survives
// both, because the bytes are still on disk. It also keeps a 20 MB PDF out
// of a kv store whose every write rewrites the whole JSON file.
const fs = require("node:fs");
const path = require("node:path");
const SCRATCH_DIR = "pdf-editor-scratch";
const MAX_RECENT = 8; // scratch files kept before the oldest is reaped
const MAX_BYTES = 200 * 1024 * 1024; // refuse anything absurd before we buffer it
module.exports = {
activate(api) {
// Per-add-on scratch dir under <userData>/extensions-data/, a sibling of the
// kv JSON. We never write inside the add-on folder — a user reading the
// shipped source should see only what shipped.
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); }
// ---- scratch bookkeeping ------------------------------------------
// The ring lives in api.storage so it survives restarts, and every read
// prunes entries whose file has gone missing.
function readRing() {
const r = api.storage.get("recent", []);
return Array.isArray(r) ? r : [];
}
function pruneRing(ring) {
const alive = ring.filter((r) => { try { fs.accessSync(r.path); return true; } catch { return false; } });
const keep = alive.slice(0, MAX_RECENT);
for (const r of alive.slice(MAX_RECENT)) { try { fs.unlinkSync(r.path); } catch {} }
return keep;
}
function remember(entry) {
let ring = readRing();
ring.unshift(entry);
ring = pruneRing(ring);
api.storage.set("recent", ring);
}
function findEntry(id) {
return pruneRing(readRing()).find((r) => r.id === id) || null;
}
function newId() {
return "doc-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
}
// A filename we are willing to put on disk and later show as the
// document's name. Anything path-ish or non-printable is discarded, not
// escaped — the fallback name is always acceptable.
function safeName(raw) {
const base = String(raw || "").split(/[\\/]/).pop().trim();
const clean = base.replace(/[\x00-\x1f<>:"|?*]/g, "").slice(0, 120);
if (!clean || clean === "." || clean === "..") return "document.pdf";
return /\.pdf$/i.test(clean) ? clean : clean + ".pdf";
}
// The last path segment of a URL is the best filename guess we have.
function nameFromUrl(url) {
try {
const u = new URL(url);
const seg = decodeURIComponent(u.pathname.split("/").filter(Boolean).pop() || "");
return safeName(seg || u.hostname + ".pdf");
} catch { return "document.pdf"; }
}
function writeScratch(buf, name) {
const id = newId();
const file = path.join(scratchDir, id + ".pdf");
fs.writeFileSync(file, buf);
const entry = { id, name, path: file, size: buf.length, at: Date.now() };
remember(entry);
return entry;
}
// ---- launch paths -------------------------------------------------
// Dock → "Open a PDF…": a bare editor tab. The editor's own drop zone
// and File → Open take it from there, so no bytes cross IPC at all.
api.onMessage("menu-select", (payload) => {
const item = String(payload && payload.id || "");
if (item !== "open") throw new Error(`unknown menu item: ${item}`);
api.openTab("editor.html");
api.log("opened an empty editor tab");
return { ok: true };
});
// Right-click a link → fetch it here, hand the editor an id.
api.onMessage("context-menu", async (payload) => {
if (String(payload && payload.itemId || "") !== "open-link") return;
const url = String(payload && payload.linkURL || "").trim();
if (!url) { api.log("context-menu fired with no link"); return { ok: false }; }
let parsed;
try { parsed = new URL(url); } catch { throw new Error(`not a URL: ${url.slice(0, 80)}`); }
if (!/^https?:$/.test(parsed.protocol)) {
throw new Error(`refusing to fetch a ${parsed.protocol} link`);
}
const { net } = api.require("electron");
api.log(`fetching ${parsed.origin}${parsed.pathname.slice(0, 60)}`);
const res = await net.fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText || "fetch failed"}`);
const len = Number(res.headers.get("content-length") || 0);
if (len > MAX_BYTES) throw new Error(`refusing ${(len / 1048576).toFixed(0)} MB — over the ${MAX_BYTES / 1048576} MB cap`);
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length > MAX_BYTES) throw new Error(`refusing ${(buf.length / 1048576).toFixed(0)} MB — over the cap`);
// %PDF is the only header a PDF can start with. A server that answered
// an HTML error page with 200 would otherwise reach the editor and
// fail there with a much less useful message.
if (buf.subarray(0, 5).toString("latin1") !== "%PDF-") {
throw new Error("that link did not answer with a PDF");
}
const entry = writeScratch(buf, nameFromUrl(url));
api.openTab("editor.html", { query: { doc: entry.id } });
api.log(`fetched ${entry.name} (${entry.size} bytes) → ${entry.id}`);
return { ok: true, id: entry.id };
});
// ---- editor-facing handlers ---------------------------------------
// Bytes go across as a Buffer, which structured-clones into a
// Uint8Array on the page side. Base64 would inflate a 20 MB file to 27
// MB of string for no benefit.
api.onMessage("getBytes", (payload) => {
const id = String(payload && payload.id || "");
const hit = findEntry(id);
if (!hit) throw new Error(`no scratch document "${id}"`);
return { id: hit.id, name: hit.name, size: hit.size, at: hit.at, data: fs.readFileSync(hit.path) };
});
// The editor calls this when the user closes a document it had been
// handed, so a fetched PDF does not sit in scratch forever. Missing ids
// are fine — the ring may already have reaped it.
api.onMessage("release", (payload) => {
const id = String(payload && payload.id || "");
const hit = findEntry(id);
if (hit) { try { fs.unlinkSync(hit.path); } catch {} }
api.storage.set("recent", readRing().filter((r) => r.id !== id));
return { ok: true };
});
// A link inside a PDF, clicked. It must NOT navigate the editor's own tab —
// that would throw away an unsaved editing session — so the editor hands
// the URL here and we open it as a new tab instead.
api.onMessage("openExternal", (payload) => {
const url = String(payload && payload.url || "").trim();
let parsed;
try { parsed = new URL(url); } catch { throw new Error("not a URL"); }
if (!/^https?:$/.test(parsed.protocol)) throw new Error(`refusing to open a ${parsed.protocol} link`);
api.openTab(parsed.href);
return { ok: true };
});
// Reveal the scratch dir. Useful when a save went somewhere the user
// cannot find, and for support questions about what is cached.
api.onMessage("openScratch", () => {
const { shell } = api.require("electron");
shell.openPath(scratchDir);
return { ok: true, path: scratchDir };
});
// Reap on activation: scratch is a cache, and a crash mid-session can
// leave files with no ring entry pointing at them.
try {
const known = new Set(readRing().map((r) => path.basename(r.path)));
for (const f of fs.readdirSync(scratchDir)) {
if (!known.has(f)) fs.unlinkSync(path.join(scratchDir, f));
}
} catch (e) { api.log("scratch reap skipped:", e?.message); }
api.log("registered pdf-editor toolbar menu + link context-menu item");
},
};