Opening a PDF cost four clicks across two screens: the dock icon, a dropdown with a single entry in it, an editor with an empty drop zone, and finally the Open button that produced a file browser. Three of those were the program asking the user to confirm what they had already said by clicking a PDF icon. The dock item now raises the file browser itself. The dialog is native and 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 none to spend. The chosen file goes through the same scratch handoff a right-clicked link already used, so the editor opens with the document in it rather than with an invitation to find one. Cancelling still lands in the empty editor, which is where drag-and-drop works and is probably still where someone who changed their mind about the file wants to be. Second click removed in the chrome: a toolbar menu holding exactly one item is not a menu, it is a button wearing a dropdown, so it now dispatches directly. Falls through to the popup if the renderer has no addonMenuSelect. Not yet exercised end to end — TheseusNavigator/node_modules is empty while another session reinstalls it, so the CDP suites cannot boot Electron. Both files parse; run scratchpad/verify-pdf-editor/drive.mjs once the tree is back.
232 lines
11 KiB
JavaScript
232 lines
11 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.
|
|
//
|
|
// 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");
|
|
},
|
|
};
|