// 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 /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 (/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. // // This used to open an empty editor whose own drop zone then asked for a // file, which meant three clicks and two screens before the user reached // their own documents: dock, menu item, Open, and only then the file // browser. Asking here collapses it — the dock click IS the "open a file" // gesture, so answer it with a file browser. // // The dialog is native, raised from the add-on's Node side, because a // file:// page cannot open one without a user gesture of its own and a // freshly-opened tab has no gesture to spend. // // Cancelling still lands you in the editor, empty. Someone who changed // their mind about which file probably still wants the editor, and it is // where drag-and-drop works. api.onMessage("menu-select", async (payload) => { const item = String(payload && payload.id || ""); if (item !== "open") throw new Error(`unknown menu item: ${item}`); const entry = await pickPdf(); if (!entry) { api.openTab("editor.html"); api.log("picker cancelled — opened an empty editor"); return { ok: true, cancelled: true }; } api.openTab("editor.html", { query: { doc: entry.id } }); api.log(`picked ${entry.name} (${entry.size} bytes) → ${entry.id}`); return { ok: true, id: entry.id }; }); /** * Native open dialog, then the bytes in scratch. Returns null when the * user cancels or picks something that is not a PDF. */ async function pickPdf() { const { dialog, BrowserWindow } = api.require("electron"); // Parent it to Theseus so the dialog is attached to the window rather // than floating loose behind it. const parent = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] || null; const opts = { title: "Open a PDF", properties: ["openFile"], filters: [{ name: "PDF documents", extensions: ["pdf"] }, { name: "All files", extensions: ["*"] }], }; const res = parent ? await dialog.showOpenDialog(parent, opts) : await dialog.showOpenDialog(opts); const file = !res.canceled && res.filePaths && res.filePaths[0]; if (!file) return null; const stat = await fs.promises.stat(file); if (stat.size > MAX_BYTES) throw new Error(`${path.basename(file)} is ${(stat.size / 1048576).toFixed(0)} MB — over the ${MAX_BYTES / 1048576} MB cap`); const buf = await fs.promises.readFile(file); if (buf.subarray(0, 5).toString("latin1") !== "%PDF-") { throw new Error(`${path.basename(file)} does not start with a PDF header`); } return writeScratch(buf, safeName(path.basename(file))); } // 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"); }, };