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) => {
const title = (name || "").trim();
if (!title) return { error: "Please provide a book name." };
const base = app.getPath("documents");
const folder = sanitizeName(title) || "Untitled";
let target = path.join(base, folder);
let n = 2;
while (hasBook(target) || fs.existsSync(target)) {
target = path.join(base, `${folder} ${n}`);
n++;
try {
const base = app.getPath("documents");
const folder = sanitizeName(title) || "Untitled";
let target = path.join(base, folder);
let n = 2;
while (hasBook(target) || fs.existsSync(target)) {
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) => {
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)) {
// Stale recent entry: nothing to trash, just clear it from the list.
removeRecent(p);
if (bookPath === p) bookPath = null;
refreshMenu();
return { ok: true };
}
// Move to the system trash rather than permanently erasing, so an
// accidental delete can be undone from the desktop trash.
// Move to the system trash rather than permanently erasing. If the
// 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 {
await shell.trashItem(p);
trashed = true;
} catch {
// Fall back to a permanent delete only when the system trash is
// unavailable (e.g. headless environments). On a normal desktop the
// book is moved to the trash and remains recoverable.
fs.rmSync(p, { recursive: true, force: true });
return {
error:
"Could not move the book to the system trash, so it was left untouched. " +
"Delete it manually from your file manager if you are sure.",
};
}
removeRecent(p);
if (bookPath === p) bookPath = null;
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) {
return { error: (e as Error).message };
}
@ -289,8 +300,14 @@ ipcMain.handle("folio:getRecents", (): Recent[] => listRecents());
ipcMain.handle("folio:closeBook", () => {
bookPath = null;
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");
try {
refreshMenu();
} catch (err) {
console.error("[folio] refreshMenu after close failed:", err);
}
return true;
});
@ -457,28 +474,36 @@ function currentBookTitle(): string {
}
ipcMain.handle("folio:exportMarkdown", async () => {
const bp = requireBook();
const { canceled, filePath } = await showSaveDialog({
title: "Export combined Markdown",
defaultPath: `${currentBookTitle()}.md`,
filters: [{ name: "Markdown", extensions: ["md"] }],
});
if (canceled || !filePath) return { canceled: true } as const;
fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8");
return { ok: true, filePath };
try {
const bp = requireBook();
const { canceled, filePath } = await showSaveDialog({
title: "Export combined Markdown",
defaultPath: `${currentBookTitle()}.md`,
filters: [{ name: "Markdown", extensions: ["md"] }],
});
if (canceled || !filePath) return { canceled: true } as const;
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 () => {
const bp = requireBook();
const { canceled, filePath } = await showSaveDialog({
title: "Export book bundle (ZIP)",
defaultPath: `${currentBookTitle()}.zip`,
filters: [{ name: "ZIP Archive", extensions: ["zip"] }],
});
if (canceled || !filePath) return { canceled: true } as const;
const buf = await buildZip(bp);
fs.writeFileSync(filePath, buf);
return { ok: true, filePath };
try {
const bp = requireBook();
const { canceled, filePath } = await showSaveDialog({
title: "Export book bundle (ZIP)",
defaultPath: `${currentBookTitle()}.zip`,
filters: [{ name: "ZIP Archive", extensions: ["zip"] }],
});
if (canceled || !filePath) return { canceled: true } as const;
const buf = await buildZip(bp);
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
@ -489,10 +514,13 @@ ipcMain.handle("folio:exportZip", async () => {
ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => {
try {
requireBook();
const selected = Array.isArray(chapterIds) && chapterIds.length > 0;
if (selected && chapterIds!.length === 0) {
// An explicitly empty selection is an error; undefined/null means the
// 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." };
}
const selected = Array.isArray(chapterIds) && chapterIds.length > 0;
const base = currentBookTitle();
const suffix = selected ? " - Selected Chapters" : "";
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) {
try {
const bp = requireBook();
const selected = Array.isArray(chapterIds) && chapterIds.length > 0;
if (selected && chapterIds!.length === 0) {
// Same fix as the PDF path: an explicitly empty array is an error,
// undefined/null means the whole book.
if (Array.isArray(chapterIds) && chapterIds.length === 0) {
return { error: "Select at least one chapter to export." };
}
const selected = Array.isArray(chapterIds) && chapterIds.length > 0;
const ext = format;
const envKey = format === "docx" ? "FOLIO_TEST_DOCX" : "FOLIO_TEST_ODT";
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
// relative to the book root (e.g. "assets/photo.png") for embedding.
ipcMain.handle("folio:importImageAsset", async () => {
const bp = requireBook();
const { canceled, filePaths } = await showOpenDialog({
title: "Insert image",
properties: ["openFile"],
filters: [
{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] },
],
});
if (canceled || !filePaths.length) return { canceled: true } as const;
const src = filePaths[0];
const dir = path.join(bp, "assets");
fs.mkdirSync(dir, { recursive: true });
const base = path.basename(src);
const ext = path.extname(base);
const stem = path.basename(base, ext);
let name = base;
let counter = 2;
while (fs.existsSync(path.join(dir, name))) {
name = `${stem}-${counter}${ext}`;
counter += 1;
try {
const bp = requireBook();
const { canceled, filePaths } = await showOpenDialog({
title: "Insert image",
properties: ["openFile"],
filters: [
{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] },
],
});
if (canceled || !filePaths.length) return { canceled: true } as const;
const src = filePaths[0];
const dir = path.join(bp, "assets");
fs.mkdirSync(dir, { recursive: true });
const base = path.basename(src);
const ext = path.extname(base);
const stem = path.basename(base, ext);
let name = base;
let counter = 2;
while (fs.existsSync(path.join(dir, name))) {
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) => {
@ -686,10 +720,14 @@ ipcMain.on("folio:context-menu", (event, x: number, y: number) => {
// "light" in the renderer, so it is normalized here too.
ipcMain.handle("folio:getTheme", () => getSetting("theme", "light"));
ipcMain.handle("folio:setTheme", (_e, id: unknown) => {
const theme =
typeof id === "string" && id && id !== "default" ? id : "light";
setSetting("theme", theme);
return { ok: true };
try {
const theme =
typeof id === "string" && id && id !== "default" ? id : "light";
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
@ -699,9 +737,13 @@ ipcMain.handle("folio:getZoom", () => {
return Number.isFinite(z) && z > 0 ? Math.round(z) : 100;
});
ipcMain.handle("folio:setZoom", (_e, pct: unknown) => {
const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100;
setSetting("zoom", z);
return { ok: true };
try {
const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100;
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
@ -709,31 +751,39 @@ ipcMain.handle("folio:setZoom", (_e, pct: unknown) => {
bindBackupSettings({ get: getSetting, set: setSetting });
ipcMain.handle("folio:getBackupLocation", () => getRecoveryRoot());
ipcMain.handle("folio:setBackupLocation", (_e, loc: unknown) => {
const s = typeof loc === "string" ? loc.trim() : "";
setRecoveryRoot(s);
return { ok: true };
try {
const s = typeof loc === "string" ? loc.trim() : "";
setRecoveryRoot(s);
return { ok: true };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
});
ipcMain.handle("folio:importAttachment", async () => {
const bp = requireBook();
const { canceled, filePaths } = await showOpenDialog({
title: "Attach a file",
properties: ["openFile"],
});
if (canceled || !filePaths.length) return { canceled: true } as const;
const src = filePaths[0];
const dir = path.join(bp, "attachments");
fs.mkdirSync(dir, { recursive: true });
const base = path.basename(src);
const ext = path.extname(base);
const stem = path.basename(base, ext);
let name = base;
let counter = 2;
while (fs.existsSync(path.join(dir, name))) {
name = `${stem}-${counter}${ext}`;
counter += 1;
try {
const bp = requireBook();
const { canceled, filePaths } = await showOpenDialog({
title: "Attach a file",
properties: ["openFile"],
});
if (canceled || !filePaths.length) return { canceled: true } as const;
const src = filePaths[0];
const dir = path.join(bp, "attachments");
fs.mkdirSync(dir, { recursive: true });
const base = path.basename(src);
const ext = path.extname(base);
const stem = path.basename(base, ext);
let name = base;
let counter = 2;
while (fs.existsSync(path.join(dir, name))) {
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 recentItems: MenuItem[] = recents.length
? recents.map((r: Recent) => ({
label: r.title.length ? `${r.title} — ${path.dirname(r.path)}` : path.basename(r.path),
tooltip: r.path,
click: () => getWindow()?.webContents.send("folio:open-recent", r.path),
}))
? recents.map((r: Recent) => {
// A folio.json with a null/missing title used to throw here and,
// at startup, take the whole app down before any window existed.
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 }];
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
// 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.
// 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 deduped: (Recent & { missing?: boolean })[] = [];
for (const r of loadRaw(recentsFilePath())) {
const key = (r.title || r.path).toLowerCase();
const key = path.resolve(r.path);
if (seen.has(key)) continue;
seen.add(key);
deduped.push({ ...r, missing: !hasBook(r.path) });

View file

@ -1803,6 +1803,10 @@ const tmp = createTiptapEditor(host);
return;
}
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 () => {
const rr = await api.restoreBook(r.path);
if (rr && "error" in rr) {