190 lines
9 KiB
JavaScript
190 lines
9 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>/addons-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, ".."));
|
||
|
|
const scratchDir = path.join(dataParent, "addons-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");
|
||
|
|
},
|
||
|
|
};
|