theseus/bundled-addons/pdf-editor/index.js
Local Dev acc433805e feat(pdf-editor): the dock opens the editor, and the editor offers more than one way in
Clicking the dock raised a native file browser, which was the right answer
while the editor had exactly one thing to offer an empty tab. It is the
wrong answer now: a file browser can only ask which PDF, and the answer is
sometimes none of them.

So the dock opens the editor, and the empty editor says what it can do.
The drop zone stays, and learns to read what it is given — pictures become
pages, several PDFs become one document. Beside it sit the three ways in
as buttons.

Not included: compress, which cannot be done honestly without re-encoding
the images, and split, which is the page rail plus Save a copy.

A document built from pictures or joins has never been on disk, so it is
marked unsaved from the moment it opens — otherwise closing the tab would
bin it without asking. An empty editor also stops claiming to hold a file
called document.pdf.
2026-09-23 00:51:45 +02:00

198 lines
9.5 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 → the file picker, straight away.
//
// The dock click opens the editor, and stops there.
//
// It briefly raised a native file browser instead, to save the clicks
// between the dock and a document. That was right while the editor had
// exactly one thing to offer an empty tab. It stopped being right once
// the start screen grew: a file browser can only answer "which PDF",
// and the answer is now sometimes "none of them — build one from these
// photos" or "weld these four together".
api.onMessage("menu-select", async (payload) => {
const item = String(payload && payload.id || "");
if (item !== "open") throw new Error(`unknown menu item: ${item}`);
api.openTab("editor.html");
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");
},
};