Moving it out of the build left it with no way in. Settings can only install from the community catalogue, so a first-party extension that isn't bundled has a working update channel and no first copy for anyone to update — the mechanism was all there and the front door was missing. So it goes back beside screenshot, aegis and pdf-editor: seeded into every profile by the build, listed under "Built into Theseus", and kept current between releases by the operator-signed channel at theseus.x/extensions/docx-editor/. That is the arrangement docs/ADDON-UPDATES.md describes, and the one the signing script was written for. About 400 KB compressed in the installer, most of it the vendored editor libraries — next to the ~4 MB of pdf.js that pdf-editor already ships, the weight argument for keeping it out didn't survive contact with the numbers. The end-to-end driver goes back to checking that a fresh profile seeds it, which is the property that actually matters now.
323 lines
14 KiB
JavaScript
323 lines
14 KiB
JavaScript
// Word editor — the add-on half. All the document work happens in the
|
|
// editor tab; this side owns the sidebar panel, the scratch folder and the
|
|
// ring of recently-opened documents.
|
|
//
|
|
// Flow:
|
|
// panel picks a file (<input type=file>, or a drop) → invokes "stash" with
|
|
// the bytes → we write them to the scratch dir and push them onto the
|
|
// recent ring → panel invokes "openEditor" → we park a pointer under
|
|
// storage.__pending and openTab("editor.html") → the editor drains
|
|
// __pending on its first paint and pulls the bytes with "getBytes".
|
|
//
|
|
// Only a pointer goes through storage, never the document: add-on storage is
|
|
// a single JSON file rewritten in full on every set, and a few megabytes of
|
|
// base64 in there would make every unrelated write expensive.
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const MAX_RECENT = 8; // documents kept in the ring
|
|
const SCRATCH_DIR = "docx-scratch";
|
|
const MAX_BYTES = 64 * 1024 * 1024; // refuse absurd inputs early
|
|
|
|
module.exports = {
|
|
activate(api) {
|
|
// The dock button takes the panel's icon, the catalogue card takes the
|
|
// manifest's. Reading it back out of addon.json keeps those the same
|
|
// drawing — and addon.json's copy is itself generated from icon.svg by
|
|
// addon-build/docx-editor/make-icons.mjs, so there is exactly one.
|
|
let icon = "";
|
|
try { icon = JSON.parse(fs.readFileSync(path.join(api.folder, "addon.json"), "utf8")).icon || ""; }
|
|
catch (e) { api.log("couldn't read the icon from addon.json:", e?.message); }
|
|
|
|
api.registerSidebarPanel({
|
|
id: "main",
|
|
title: "Word editor",
|
|
icon,
|
|
page: "panel.html",
|
|
});
|
|
|
|
// Per-extension scratch dir under <userData>/extensions-data/, same
|
|
// arrangement as the screenshot add-on: never write inside the extension
|
|
// folder itself, where it would confuse anyone reading the shipped source.
|
|
// api.dataDir is the host-provided folder; the fallback rebuilds it for
|
|
// hosts that predate that field.
|
|
const dataParent = path.dirname(path.join(api.folder, ".."));
|
|
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); }
|
|
|
|
function stamp() {
|
|
const d = new Date();
|
|
const p = (n) => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
}
|
|
|
|
// A scratch filename that can't escape the folder however the original
|
|
// was named. The display name is kept separately in the ring.
|
|
function scratchName(displayName) {
|
|
const base = String(displayName || "document")
|
|
.replace(/\.docx$/i, "")
|
|
.replace(/[^\w.\- ]+/g, "_")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, 60) || "document";
|
|
return `${stamp()}-${base}.docx`;
|
|
}
|
|
|
|
function pruneRecent(recent) {
|
|
const alive = recent.filter((r) => { try { fs.accessSync(r.path); return true; } catch { return false; } });
|
|
const keep = alive.slice(0, MAX_RECENT);
|
|
for (const gone of alive.slice(MAX_RECENT)) { try { fs.unlinkSync(gone.path); } catch {} }
|
|
return keep;
|
|
}
|
|
|
|
function readRecent() {
|
|
let recent = api.storage.get("recent", []);
|
|
return Array.isArray(recent) ? recent : [];
|
|
}
|
|
|
|
function findRecent(id) {
|
|
return readRecent().find((r) => r.id === id) || null;
|
|
}
|
|
|
|
function pathOf(id) {
|
|
// Belt and braces: the id came from a renderer, so re-derive the path
|
|
// from the ring rather than joining whatever string arrived.
|
|
const hit = findRecent(id);
|
|
if (!hit) throw new Error(`no document "${id}" in the recent list`);
|
|
const abs = path.resolve(hit.path);
|
|
if (path.dirname(abs) !== path.resolve(scratchDir)) {
|
|
throw new Error("document path escapes the scratch folder");
|
|
}
|
|
return abs;
|
|
}
|
|
|
|
// Write bytes to the scratch folder and put them at the head of the ring.
|
|
// `kind` distinguishes what the user opened from what the editor saved
|
|
// back, so the panel can say which is which.
|
|
function stash({ name, base64, kind, replaces }) {
|
|
const buf = Buffer.from(String(base64 || ""), "base64");
|
|
if (!buf.length) throw new Error("no document bytes");
|
|
if (buf.length > MAX_BYTES) throw new Error(`document is too large (${(buf.length / 1048576).toFixed(0)} MB)`);
|
|
// Every .docx is a zip; catching this here beats a confusing parse
|
|
// error three layers deeper in the editor.
|
|
if (!(buf[0] === 0x50 && buf[1] === 0x4b)) {
|
|
throw new Error("that doesn't look like a .docx file");
|
|
}
|
|
const id = scratchName(name);
|
|
const file = path.join(scratchDir, id);
|
|
fs.writeFileSync(file, buf);
|
|
|
|
let recent = readRecent();
|
|
if (replaces) recent = recent.filter((r) => r.id !== replaces);
|
|
recent.unshift({
|
|
id,
|
|
name: String(name || "document.docx"),
|
|
path: file,
|
|
bytes: buf.length,
|
|
at: Date.now(),
|
|
kind: kind === "saved" ? "saved" : "opened",
|
|
});
|
|
recent = pruneRecent(recent);
|
|
api.storage.set("recent", recent);
|
|
api.log(`stashed ${id} (${buf.length} bytes, ${kind || "opened"})`);
|
|
return { id, name: String(name || "document.docx"), bytes: buf.length };
|
|
}
|
|
|
|
api.onMessage("stash", (payload) => stash(payload || {}));
|
|
|
|
// Hand a document to the editor tab. The pointer under __pending is what
|
|
// the editor drains on its first paint; the query string carries the same
|
|
// id so a reload of the tab still finds its document.
|
|
api.onMessage("openEditor", (payload) => {
|
|
const id = payload && payload.id ? String(payload.id) : "";
|
|
if (id) {
|
|
const hit = findRecent(id);
|
|
if (!hit) throw new Error(`no document "${id}" in the recent list`);
|
|
api.storage.set("__pending", { id, name: hit.name, at: Date.now() });
|
|
} else {
|
|
api.storage.set("__pending", null);
|
|
}
|
|
api.openTab("editor.html", id ? { query: { doc: id } } : undefined);
|
|
api.log(id ? `opening editor for ${id}` : "opening editor with a blank document");
|
|
return { ok: true, id };
|
|
});
|
|
|
|
api.onMessage("listRecent", () => {
|
|
const recent = pruneRecent(readRecent());
|
|
api.storage.set("recent", recent);
|
|
return recent.map((r) => ({ id: r.id, name: r.name, bytes: r.bytes, at: r.at, kind: r.kind }));
|
|
});
|
|
|
|
api.onMessage("getBytes", (payload) => {
|
|
const id = String(payload && payload.id || "");
|
|
const hit = findRecent(id);
|
|
if (!hit) throw new Error(`no document "${id}" in the recent list`);
|
|
const buf = fs.readFileSync(pathOf(id));
|
|
return { id, name: hit.name, base64: buf.toString("base64"), bytes: buf.length, at: hit.at, kind: hit.kind };
|
|
});
|
|
|
|
// Autosave. The editor calls this as the user works; each document keeps
|
|
// one autosave entry rather than filling the ring with its own history.
|
|
api.onMessage("autosave", (payload) => {
|
|
const p = payload || {};
|
|
const res = stash({
|
|
name: String(p.name || "document.docx"),
|
|
base64: p.base64,
|
|
kind: "saved",
|
|
replaces: p.replaces ? String(p.replaces) : "",
|
|
});
|
|
return res;
|
|
});
|
|
|
|
api.onMessage("clearRecent", (payload) => {
|
|
const id = payload && payload.id ? String(payload.id) : "";
|
|
let recent = readRecent();
|
|
if (id) {
|
|
const hit = recent.find((r) => r.id === id);
|
|
if (hit) { try { fs.unlinkSync(hit.path); } catch {} }
|
|
recent = recent.filter((r) => r.id !== id);
|
|
} else {
|
|
for (const r of recent) { try { fs.unlinkSync(r.path); } catch {} }
|
|
recent = [];
|
|
}
|
|
api.storage.set("recent", recent);
|
|
return { ok: true };
|
|
});
|
|
|
|
// --- Save as… -------------------------------------------------------
|
|
//
|
|
// Two calls rather than one, because the format depends on the extension
|
|
// the user types into the dialog, and we'd rather not build a .docx and
|
|
// a .pdf speculatively just to throw one away.
|
|
//
|
|
// The renderer never gets to name a path. `pickSavePath` hands back an
|
|
// opaque token for the path the USER chose in a native dialog, and
|
|
// `writeChosen` will only write to a path that came out of that dialog.
|
|
// An extension page is the least trusted thing in this add-on, and
|
|
// "write these bytes to any path you like" is not a capability it needs.
|
|
const chosenPaths = new Map(); // token -> absolute path
|
|
const everChosen = new Set(); // every path a dialog has ever returned
|
|
let tokenSeq = 0;
|
|
|
|
api.onMessage("pickSavePath", async (payload) => {
|
|
const { dialog } = api.require("electron");
|
|
const p = payload || {};
|
|
const suggested = String(p.name || "document.docx").replace(/[/\\]/g, "_");
|
|
const res = await dialog.showSaveDialog({
|
|
title: "Save as",
|
|
defaultPath: path.join(app_downloads(), suggested),
|
|
filters: [
|
|
{ name: "Word document", extensions: ["docx"] },
|
|
{ name: "PDF document", extensions: ["pdf"] },
|
|
],
|
|
properties: ["createDirectory", "showOverwriteConfirmation"],
|
|
});
|
|
if (res.canceled || !res.filePath) return { cancelled: true };
|
|
const token = `t${++tokenSeq}-${Date.now()}`;
|
|
chosenPaths.set(token, res.filePath);
|
|
everChosen.add(res.filePath);
|
|
// One token, one write; nothing accumulates across a session.
|
|
if (chosenPaths.size > 8) chosenPaths.delete(chosenPaths.keys().next().value);
|
|
const ext = path.extname(res.filePath).toLowerCase().replace(".", "") || "docx";
|
|
api.log(`save-as target chosen (.${ext})`);
|
|
return { token, format: ext === "pdf" ? "pdf" : "docx", name: path.basename(res.filePath) };
|
|
});
|
|
|
|
// Re-reserve a path the user already picked, so a second Ctrl+S can write
|
|
// to the same file without a second dialog. Only a path this add-on has
|
|
// already handed out can be re-reserved — it is not a way to name a new
|
|
// one from the renderer.
|
|
api.onMessage("reserveSavePath", (payload) => {
|
|
const target = String(payload && payload.path || "");
|
|
if (!everChosen.has(target)) throw new Error("that path was never chosen in a dialog");
|
|
const token = `t${++tokenSeq}-${Date.now()}`;
|
|
chosenPaths.set(token, target);
|
|
return { token, name: path.basename(target) };
|
|
});
|
|
|
|
api.onMessage("writeChosen", (payload) => {
|
|
const p = payload || {};
|
|
const target = chosenPaths.get(String(p.token || ""));
|
|
if (!target) throw new Error("no such save target — pick a location first");
|
|
chosenPaths.delete(String(p.token));
|
|
const buf = Buffer.from(String(p.base64 || ""), "base64");
|
|
if (!buf.length) throw new Error("nothing to write");
|
|
fs.writeFileSync(target, buf);
|
|
api.log(`wrote ${buf.length} bytes to a user-chosen path`);
|
|
return { ok: true, path: target, name: path.basename(target), bytes: buf.length };
|
|
});
|
|
|
|
function app_downloads() {
|
|
try { return api.require("electron").app.getPath("downloads"); }
|
|
catch { return scratchDir; }
|
|
}
|
|
|
|
// --- PDF ------------------------------------------------------------
|
|
//
|
|
// The editor hands over a self-contained HTML page (its own document
|
|
// CSS, images already inlined as data URLs) and the page geometry it
|
|
// read out of the .docx. We load that in a hidden window and let
|
|
// Chromium's own print pipeline make the PDF — the same engine behind
|
|
// Ctrl+P, so what the user previewed is what they get.
|
|
//
|
|
// A temp file rather than a data: URL: a document with a few photographs
|
|
// in it runs to megabytes, and long data: URLs get truncated.
|
|
api.onMessage("renderPdf", async (payload) => {
|
|
const { BrowserWindow } = api.require("electron");
|
|
const p = payload || {};
|
|
const html = String(p.html || "");
|
|
if (!html) throw new Error("no document to render");
|
|
|
|
const tmp = path.join(scratchDir, `print-${Date.now()}.html`);
|
|
fs.writeFileSync(tmp, html, "utf8");
|
|
let win = null;
|
|
try {
|
|
win = new BrowserWindow({
|
|
show: false,
|
|
webPreferences: {
|
|
// Nothing in the print page needs to run, reach the network or
|
|
// talk to the add-on — it is markup and inline styles.
|
|
javascript: false,
|
|
sandbox: true,
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
webSecurity: true,
|
|
},
|
|
});
|
|
await win.loadFile(tmp);
|
|
// loadFile resolves on did-finish-load; give layout a beat to settle
|
|
// before asking for the PDF, or the first page can come out short.
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
const opts = {
|
|
printBackground: true,
|
|
// The page box comes from the @page rule the editor wrote out of
|
|
// the document's own sectPr, so Chromium uses the real paper size
|
|
// rather than defaulting to Letter.
|
|
preferCSSPageSize: true,
|
|
generateTaggedPDF: true,
|
|
};
|
|
const pdf = await win.webContents.printToPDF(opts);
|
|
api.log(`rendered a ${(pdf.length / 1024).toFixed(0)} KB PDF`);
|
|
return { ok: true, base64: Buffer.from(pdf).toString("base64"), bytes: pdf.length };
|
|
} finally {
|
|
try { if (win && !win.isDestroyed()) win.destroy(); } catch {}
|
|
try { fs.unlinkSync(tmp); } catch {}
|
|
}
|
|
});
|
|
|
|
api.onMessage("openFolder", (payload) => {
|
|
const { shell } = api.require("electron");
|
|
const id = payload && payload.id ? String(payload.id) : "";
|
|
if (id) {
|
|
const hit = findRecent(id);
|
|
if (hit) { shell.showItemInFolder(hit.path); return { ok: true, path: hit.path }; }
|
|
}
|
|
shell.openPath(scratchDir);
|
|
return { ok: true, path: scratchDir };
|
|
});
|
|
|
|
api.log("registered docx-editor sidebar panel");
|
|
},
|
|
};
|