Harden IPC handlers: error protocol on exports/imports/settings, menu null-title crash fix, recents dedupe by path, no silent permanent book delete

This commit is contained in:
avi 2026-08-24 19:49:48 -05:00
commit ed3f3cd84b
4 changed files with 176 additions and 111 deletions

View file

@ -201,48 +201,59 @@ function sanitizeName(name: string): string {
ipcMain.handle("folio:newBookNamed", async (_e, name: string) => { ipcMain.handle("folio:newBookNamed", async (_e, name: string) => {
const title = (name || "").trim(); const title = (name || "").trim();
if (!title) return { error: "Please provide a book name." }; if (!title) return { error: "Please provide a book name." };
const base = app.getPath("documents"); try {
const folder = sanitizeName(title) || "Untitled"; const base = app.getPath("documents");
let target = path.join(base, folder); const folder = sanitizeName(title) || "Untitled";
let n = 2; let target = path.join(base, folder);
while (hasBook(target) || fs.existsSync(target)) { let n = 2;
target = path.join(base, `${folder} ${n}`); while (hasBook(target) || fs.existsSync(target)) {
n++; target = path.join(base, `${folder} ${n}`);
n++;
}
const meta = createBook(target, title);
if (process.env.FOLIO_SELF_TEST !== "1") {
addRecent({ path: target, title: meta.title, lastOpened: new Date().toISOString() });
}
bookPath = target;
mainWindow?.setTitle(`${meta.title} — Folio`);
refreshMenu();
getWindow()?.webContents.send("folio:bookOpened", { path: target, title: meta.title });
return { path: target, title: meta.title };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
} }
const meta = createBook(target, title);
if (process.env.FOLIO_SELF_TEST !== "1") {
addRecent({ path: target, title: meta.title, lastOpened: new Date().toISOString() });
}
bookPath = target;
mainWindow?.setTitle(`${meta.title} — Folio`);
refreshMenu();
getWindow()?.webContents.send("folio:bookOpened", { path: target, title: meta.title });
return { path: target, title: meta.title };
}); });
ipcMain.handle("folio:deleteBook", async (_e, p: string) => { ipcMain.handle("folio:deleteBook", async (_e, p: string) => {
try { try {
// Always drop the recent entry, even if the folder is gone or was moved,
// so stale/missing books can be cleared from the list.
removeRecent(p);
if (!hasBook(p)) { if (!hasBook(p)) {
// Stale recent entry: nothing to trash, just clear it from the list.
removeRecent(p);
if (bookPath === p) bookPath = null; if (bookPath === p) bookPath = null;
refreshMenu(); refreshMenu();
return { ok: true }; return { ok: true };
} }
// Move to the system trash rather than permanently erasing, so an // Move to the system trash rather than permanently erasing. If the
// accidental delete can be undone from the desktop trash. // trash refuses, STOP and report instead of silently destroying a whole
// book directory — an unrecoverable rmSync here used to masquerade as a
// safe "move to trash".
let trashed = false;
try { try {
await shell.trashItem(p); await shell.trashItem(p);
trashed = true;
} catch { } catch {
// Fall back to a permanent delete only when the system trash is return {
// unavailable (e.g. headless environments). On a normal desktop the error:
// book is moved to the trash and remains recoverable. "Could not move the book to the system trash, so it was left untouched. " +
fs.rmSync(p, { recursive: true, force: true }); "Delete it manually from your file manager if you are sure.",
};
} }
removeRecent(p);
if (bookPath === p) bookPath = null; if (bookPath === p) bookPath = null;
refreshMenu(); refreshMenu();
return { ok: true }; // In-app Undo reads the Linux home trash layout; on other platforms the
// OS trash is still recoverable by hand, so no false promise is made.
return { ok: true, trashed, canUndoRestore: process.platform === "linux" };
} catch (e) { } catch (e) {
return { error: (e as Error).message }; return { error: (e as Error).message };
} }
@ -289,8 +300,14 @@ ipcMain.handle("folio:getRecents", (): Recent[] => listRecents());
ipcMain.handle("folio:closeBook", () => { ipcMain.handle("folio:closeBook", () => {
bookPath = null; bookPath = null;
mainWindow?.setTitle("Folio"); mainWindow?.setTitle("Folio");
refreshMenu(); // Notify the renderer BEFORE rebuilding the menu: a refreshMenu failure
// must not leave main with no book while the renderer still shows one.
getWindow()?.webContents.send("folio:bookClosed"); getWindow()?.webContents.send("folio:bookClosed");
try {
refreshMenu();
} catch (err) {
console.error("[folio] refreshMenu after close failed:", err);
}
return true; return true;
}); });
@ -457,28 +474,36 @@ function currentBookTitle(): string {
} }
ipcMain.handle("folio:exportMarkdown", async () => { ipcMain.handle("folio:exportMarkdown", async () => {
const bp = requireBook(); try {
const { canceled, filePath } = await showSaveDialog({ const bp = requireBook();
title: "Export combined Markdown", const { canceled, filePath } = await showSaveDialog({
defaultPath: `${currentBookTitle()}.md`, title: "Export combined Markdown",
filters: [{ name: "Markdown", extensions: ["md"] }], defaultPath: `${currentBookTitle()}.md`,
}); filters: [{ name: "Markdown", extensions: ["md"] }],
if (canceled || !filePath) return { canceled: true } as const; });
fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8"); if (canceled || !filePath) return { canceled: true } as const;
return { ok: true, filePath }; fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8");
return { ok: true, filePath };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
ipcMain.handle("folio:exportZip", async () => { ipcMain.handle("folio:exportZip", async () => {
const bp = requireBook(); try {
const { canceled, filePath } = await showSaveDialog({ const bp = requireBook();
title: "Export book bundle (ZIP)", const { canceled, filePath } = await showSaveDialog({
defaultPath: `${currentBookTitle()}.zip`, title: "Export book bundle (ZIP)",
filters: [{ name: "ZIP Archive", extensions: ["zip"] }], defaultPath: `${currentBookTitle()}.zip`,
}); filters: [{ name: "ZIP Archive", extensions: ["zip"] }],
if (canceled || !filePath) return { canceled: true } as const; });
const buf = await buildZip(bp); if (canceled || !filePath) return { canceled: true } as const;
fs.writeFileSync(filePath, buf); const buf = await buildZip(bp);
return { ok: true, filePath }; fs.writeFileSync(filePath, buf);
return { ok: true, filePath };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
// Export the Full Book view as a PDF. The renderer shows only the assembled // Export the Full Book view as a PDF. The renderer shows only the assembled
@ -489,10 +514,13 @@ ipcMain.handle("folio:exportZip", async () => {
ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => { ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => {
try { try {
requireBook(); requireBook();
const selected = Array.isArray(chapterIds) && chapterIds.length > 0; // An explicitly empty selection is an error; undefined/null means the
if (selected && chapterIds!.length === 0) { // whole book. The old guard checked length>0 before comparing to zero,
// so it could never fire and empty arrays fell through to a full export.
if (Array.isArray(chapterIds) && chapterIds.length === 0) {
return { error: "Select at least one chapter to export." }; return { error: "Select at least one chapter to export." };
} }
const selected = Array.isArray(chapterIds) && chapterIds.length > 0;
const base = currentBookTitle(); const base = currentBookTitle();
const suffix = selected ? " - Selected Chapters" : ""; const suffix = selected ? " - Selected Chapters" : "";
let filePath: string; let filePath: string;
@ -529,10 +557,12 @@ ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => {
async function exportOffice(format: "docx" | "odt", chapterIds?: string[] | null) { async function exportOffice(format: "docx" | "odt", chapterIds?: string[] | null) {
try { try {
const bp = requireBook(); const bp = requireBook();
const selected = Array.isArray(chapterIds) && chapterIds.length > 0; // Same fix as the PDF path: an explicitly empty array is an error,
if (selected && chapterIds!.length === 0) { // undefined/null means the whole book.
if (Array.isArray(chapterIds) && chapterIds.length === 0) {
return { error: "Select at least one chapter to export." }; return { error: "Select at least one chapter to export." };
} }
const selected = Array.isArray(chapterIds) && chapterIds.length > 0;
const ext = format; const ext = format;
const envKey = format === "docx" ? "FOLIO_TEST_DOCX" : "FOLIO_TEST_ODT"; const envKey = format === "docx" ? "FOLIO_TEST_DOCX" : "FOLIO_TEST_ODT";
const base = currentBookTitle(); const base = currentBookTitle();
@ -622,30 +652,34 @@ ipcMain.handle("folio:saveChapterDoc", (_e, id: string, doc: unknown) => {
// Copy a user-chosen image into the book's assets/ directory and return a path // Copy a user-chosen image into the book's assets/ directory and return a path
// relative to the book root (e.g. "assets/photo.png") for embedding. // relative to the book root (e.g. "assets/photo.png") for embedding.
ipcMain.handle("folio:importImageAsset", async () => { ipcMain.handle("folio:importImageAsset", async () => {
const bp = requireBook(); try {
const { canceled, filePaths } = await showOpenDialog({ const bp = requireBook();
title: "Insert image", const { canceled, filePaths } = await showOpenDialog({
properties: ["openFile"], title: "Insert image",
filters: [ properties: ["openFile"],
{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] }, filters: [
], { name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] },
}); ],
if (canceled || !filePaths.length) return { canceled: true } as const; });
const src = filePaths[0]; if (canceled || !filePaths.length) return { canceled: true } as const;
const dir = path.join(bp, "assets"); const src = filePaths[0];
fs.mkdirSync(dir, { recursive: true }); const dir = path.join(bp, "assets");
const base = path.basename(src); fs.mkdirSync(dir, { recursive: true });
const ext = path.extname(base); const base = path.basename(src);
const stem = path.basename(base, ext); const ext = path.extname(base);
let name = base; const stem = path.basename(base, ext);
let counter = 2; let name = base;
while (fs.existsSync(path.join(dir, name))) { let counter = 2;
name = `${stem}-${counter}${ext}`; while (fs.existsSync(path.join(dir, name))) {
counter += 1; name = `${stem}-${counter}${ext}`;
counter += 1;
}
fs.copyFileSync(src, path.join(dir, name));
const relPath = `assets/${name}`;
return { ok: true, name, relPath };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
} }
fs.copyFileSync(src, path.join(dir, name));
const relPath = `assets/${name}`;
return { ok: true, name, relPath };
}); });
ipcMain.handle("folio:copyText", (_e, text: string) => { ipcMain.handle("folio:copyText", (_e, text: string) => {
@ -686,10 +720,14 @@ ipcMain.on("folio:context-menu", (event, x: number, y: number) => {
// "light" in the renderer, so it is normalized here too. // "light" in the renderer, so it is normalized here too.
ipcMain.handle("folio:getTheme", () => getSetting("theme", "light")); ipcMain.handle("folio:getTheme", () => getSetting("theme", "light"));
ipcMain.handle("folio:setTheme", (_e, id: unknown) => { ipcMain.handle("folio:setTheme", (_e, id: unknown) => {
const theme = try {
typeof id === "string" && id && id !== "default" ? id : "light"; const theme =
setSetting("theme", theme); typeof id === "string" && id && id !== "default" ? id : "light";
return { ok: true }; setSetting("theme", theme);
return { ok: true };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
// Editor zoom preference (percentage). Persisted app-wide, not per book, so the // Editor zoom preference (percentage). Persisted app-wide, not per book, so the
@ -699,9 +737,13 @@ ipcMain.handle("folio:getZoom", () => {
return Number.isFinite(z) && z > 0 ? Math.round(z) : 100; return Number.isFinite(z) && z > 0 ? Math.round(z) : 100;
}); });
ipcMain.handle("folio:setZoom", (_e, pct: unknown) => { ipcMain.handle("folio:setZoom", (_e, pct: unknown) => {
const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100; try {
setSetting("zoom", z); const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100;
return { ok: true }; setSetting("zoom", z);
return { ok: true };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
// Backup location preference. Backup reads/writes settings through the // Backup location preference. Backup reads/writes settings through the
@ -709,31 +751,39 @@ ipcMain.handle("folio:setZoom", (_e, pct: unknown) => {
bindBackupSettings({ get: getSetting, set: setSetting }); bindBackupSettings({ get: getSetting, set: setSetting });
ipcMain.handle("folio:getBackupLocation", () => getRecoveryRoot()); ipcMain.handle("folio:getBackupLocation", () => getRecoveryRoot());
ipcMain.handle("folio:setBackupLocation", (_e, loc: unknown) => { ipcMain.handle("folio:setBackupLocation", (_e, loc: unknown) => {
const s = typeof loc === "string" ? loc.trim() : ""; try {
setRecoveryRoot(s); const s = typeof loc === "string" ? loc.trim() : "";
return { ok: true }; setRecoveryRoot(s);
return { ok: true };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
ipcMain.handle("folio:importAttachment", async () => { ipcMain.handle("folio:importAttachment", async () => {
const bp = requireBook(); try {
const { canceled, filePaths } = await showOpenDialog({ const bp = requireBook();
title: "Attach a file", const { canceled, filePaths } = await showOpenDialog({
properties: ["openFile"], title: "Attach a file",
}); properties: ["openFile"],
if (canceled || !filePaths.length) return { canceled: true } as const; });
const src = filePaths[0]; if (canceled || !filePaths.length) return { canceled: true } as const;
const dir = path.join(bp, "attachments"); const src = filePaths[0];
fs.mkdirSync(dir, { recursive: true }); const dir = path.join(bp, "attachments");
const base = path.basename(src); fs.mkdirSync(dir, { recursive: true });
const ext = path.extname(base); const base = path.basename(src);
const stem = path.basename(base, ext); const ext = path.extname(base);
let name = base; const stem = path.basename(base, ext);
let counter = 2; let name = base;
while (fs.existsSync(path.join(dir, name))) { let counter = 2;
name = `${stem}-${counter}${ext}`; while (fs.existsSync(path.join(dir, name))) {
counter += 1; name = `${stem}-${counter}${ext}`;
counter += 1;
}
fs.copyFileSync(src, path.join(dir, name));
const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/");
return { ok: true, name, relPath };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
} }
fs.copyFileSync(src, path.join(dir, name));
const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/");
return { ok: true, name, relPath };
}); });

View file

@ -18,11 +18,19 @@ export function buildMenu(
const recents = listRecents(); const recents = listRecents();
const recentItems: MenuItem[] = recents.length const recentItems: MenuItem[] = recents.length
? recents.map((r: Recent) => ({ ? recents.map((r: Recent) => {
label: r.title.length ? `${r.title} — ${path.dirname(r.path)}` : path.basename(r.path), // A folio.json with a null/missing title used to throw here and,
tooltip: r.path, // at startup, take the whole app down before any window existed.
click: () => getWindow()?.webContents.send("folio:open-recent", r.path), const title = typeof r.title === "string" ? r.title.trim() : "";
})) const label = title
? `${title} — ${path.dirname(r.path)}`
: path.basename(r.path);
return {
label,
tooltip: r.path,
click: () => getWindow()?.webContents.send("folio:open-recent", r.path),
};
})
: [{ label: "(no recent books)", enabled: false }]; : [{ label: "(no recent books)", enabled: false }];
const fileMenu: MenuItem = { const fileMenu: MenuItem = {

View file

@ -61,10 +61,13 @@ export function listRecents(): (Recent & { missing?: boolean })[] {
// Keep every saved entry so a book never "disappears" from the list just // Keep every saved entry so a book never "disappears" from the list just
// because its folder moved or was renamed. Mark entries whose book can't be // because its folder moved or was renamed. Mark entries whose book can't be
// found so the UI can show them as missing instead of hiding them. // found so the UI can show them as missing instead of hiding them.
// Dedupe by PATH: keying on title used to hide genuinely distinct books
// that happened to share a display name ("Untitled"), making one of them
// unreachable from the recents menu.
const seen = new Set<string>(); const seen = new Set<string>();
const deduped: (Recent & { missing?: boolean })[] = []; const deduped: (Recent & { missing?: boolean })[] = [];
for (const r of loadRaw(recentsFilePath())) { for (const r of loadRaw(recentsFilePath())) {
const key = (r.title || r.path).toLowerCase(); const key = path.resolve(r.path);
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
deduped.push({ ...r, missing: !hasBook(r.path) }); deduped.push({ ...r, missing: !hasBook(r.path) });

View file

@ -1803,6 +1803,10 @@ const tmp = createTiptapEditor(host);
return; return;
} }
await renderRecents(); await renderRecents();
// Only promise an in-app Undo where restore actually works (Linux
// home trash). Elsewhere the book sits in the OS trash, which the
// user can restore manually — no false "Undo" button.
if (!(res as { canUndoRestore?: boolean }).canUndoRestore) return;
showUndoToast(`Deleted "${r.title || r.path}"`, async () => { showUndoToast(`Deleted "${r.title || r.path}"`, async () => {
const rr = await api.restoreBook(r.path); const rr = await api.restoreBook(r.path);
if (rr && "error" in rr) { if (rr && "error" in rr) {