refactor(ipc): unify 41 handlers into handleIpc wrapper; improve error logging and non-Error handling

This commit is contained in:
avi 2026-08-24 22:45:59 -05:00
commit b5db582538

View file

@ -138,6 +138,23 @@ function notifyChange() {
getWindow()?.webContents.send("folio:chapters-changed"); getWindow()?.webContents.send("folio:chapters-changed");
} }
// Standard IPC registration. Every handler gets the same crash safety net:
// a thrown error is logged (with its channel, for diagnosability) and
// returned to the renderer as `{ error }` instead of surfacing as an
// unhandled promise rejection that the UI would silently swallow.
/* eslint-disable @typescript-eslint/no-explicit-any */
function handleIpc(channel: string, fn: (...args: any[]) => unknown): void {
ipcMain.handle(channel, async (_e, ...args: any[]) => {
try {
return await fn(...args);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[folio] ${channel} failed: ${msg}`);
return { error: msg };
}
});
}
// Route undo/redo/select-all shortcuts to the renderer so the active editor's // Route undo/redo/select-all shortcuts to the renderer so the active editor's
// own history is used when it has focus, instead of Electron's native // own history is used when it has focus, instead of Electron's native
// (menu-role) undo/redo which does not understand the editor's history. // (menu-role) undo/redo which does not understand the editor's history.
@ -191,10 +208,9 @@ async function openBookAt(p: string): Promise<BookResult | ErrResult> {
// Create a new book as a dedicated folder <name>/ inside the user's Documents // Create a new book as a dedicated folder <name>/ inside the user's Documents
// directory, then open it. The folder name is the sanitized book title; if a // directory, then open it. The folder name is the sanitized book title; if a
// folder with that name already exists we append a numeric suffix. // folder with that name already exists we append a numeric suffix.
ipcMain.handle("folio:newBookNamed", async (_e, name: string) => { handleIpc("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." };
try {
const base = app.getPath("documents"); const base = app.getPath("documents");
const folder = sanitizeBookFolderName(title) || "Untitled"; const folder = sanitizeBookFolderName(title) || "Untitled";
let target = path.join(base, folder); let target = path.join(base, folder);
@ -212,13 +228,9 @@ ipcMain.handle("folio:newBookNamed", async (_e, name: string) => {
refreshMenu(); refreshMenu();
getWindow()?.webContents.send("folio:bookOpened", { path: target, title: meta.title }); getWindow()?.webContents.send("folio:bookOpened", { path: target, title: meta.title });
return { path: target, title: meta.title }; return { path: target, title: meta.title };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
ipcMain.handle("folio:deleteBook", async (_e, p: string) => { handleIpc("folio:deleteBook", async (_e, p: string) => {
try {
if (!hasBook(p)) { if (!hasBook(p)) {
// Stale recent entry: nothing to trash, just clear it from the list. // Stale recent entry: nothing to trash, just clear it from the list.
removeRecent(p); removeRecent(p);
@ -247,12 +259,9 @@ ipcMain.handle("folio:deleteBook", async (_e, p: string) => {
// In-app Undo reads the Linux home trash layout; on other platforms the // 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. // OS trash is still recoverable by hand, so no false promise is made.
return { ok: true, trashed, canUndoRestore: process.platform === "linux" }; return { ok: true, trashed, canUndoRestore: process.platform === "linux" };
} catch (e) {
return { error: (e as Error).message };
}
}); });
ipcMain.handle("folio:openBook", async () => { handleIpc("folio:openBook", async () => {
const { canceled, filePaths } = await showOpenDialog({ const { canceled, filePaths } = await showOpenDialog({
properties: ["openDirectory"], properties: ["openDirectory"],
title: "Open a Folio book", title: "Open a Folio book",
@ -265,9 +274,9 @@ ipcMain.handle("folio:openBook", async () => {
return openBookAt(p); return openBookAt(p);
}); });
ipcMain.handle("folio:openPath", async (_e, p: string) => openBookAt(p)); handleIpc("folio:openPath", async (_e, p: string) => openBookAt(p));
ipcMain.handle("folio:reveal", async (_e, p: string) => { handleIpc("folio:reveal", async (_e, p: string) => {
if (!p) return false; if (!p) return false;
// True "reveal": show the book's folio.json selected in the file manager. // True "reveal": show the book's folio.json selected in the file manager.
// Fall back to opening the folder itself if the meta file is missing. // Fall back to opening the folder itself if the meta file is missing.
@ -280,7 +289,7 @@ ipcMain.handle("folio:reveal", async (_e, p: string) => {
return !err; return !err;
}); });
ipcMain.handle("folio:pickBackupFolder", async () => { handleIpc("folio:pickBackupFolder", async () => {
const { canceled, filePaths } = await showOpenDialog({ const { canceled, filePaths } = await showOpenDialog({
properties: ["openDirectory"], properties: ["openDirectory"],
title: "Choose recovery backup folder", title: "Choose recovery backup folder",
@ -288,9 +297,9 @@ ipcMain.handle("folio:pickBackupFolder", async () => {
return { canceled, filePaths }; return { canceled, filePaths };
}); });
ipcMain.handle("folio:getRecents", (): Recent[] => listRecents()); handleIpc("folio:getRecents", (): Recent[] => listRecents());
ipcMain.handle("folio:closeBook", () => { handleIpc("folio:closeBook", () => {
bookPath = null; bookPath = null;
mainWindow?.setTitle("Folio"); mainWindow?.setTitle("Folio");
// Notify the renderer BEFORE rebuilding the menu: a refreshMenu failure // Notify the renderer BEFORE rebuilding the menu: a refreshMenu failure
@ -309,71 +318,41 @@ function requireBook(): string {
return bookPath; return bookPath;
} }
ipcMain.handle("folio:getBookMeta", () => { handleIpc("folio:getBookMeta", () => loadBook(requireBook()));
try {
return loadBook(requireBook());
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:setComments", (_e, comments: Record<string, unknown>) => { handleIpc("folio:setComments", (_e, comments: Record<string, unknown>) => {
try {
const meta = loadBook(requireBook()); const meta = loadBook(requireBook());
meta.comments = comments as never; meta.comments = comments as never;
saveMeta(requireBook(), meta); saveMeta(requireBook(), meta);
return { ok: true }; return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
}); });
ipcMain.handle("folio:getChapterContent", (_e, id: string) => { handleIpc("folio:getChapterContent", (_e, id: string) =>
try { getChapterContent(requireBook(), id)
return getChapterContent(requireBook(), id); );
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:createChapter", (_e, title: string) => { handleIpc("folio:createChapter", (_e, title: string) => {
try {
const entry = createChapter(requireBook(), title); const entry = createChapter(requireBook(), title);
notifyChange(); notifyChange();
return { id: entry.id, title: entry.title, file: entry.file }; return { id: entry.id, title: entry.title, file: entry.file };
} catch (e) {
return { error: (e as Error).message };
}
}); });
ipcMain.handle("folio:renameChapter", (_e, id: string, title: string) => { handleIpc("folio:renameChapter", (_e, id: string, title: string) => {
try {
renameChapter(requireBook(), id, title); renameChapter(requireBook(), id, title);
notifyChange(); notifyChange();
return { ok: true }; return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
}); });
ipcMain.handle("folio:deleteChapter", (_e, id: string) => { handleIpc("folio:deleteChapter", (_e, id: string) => {
try {
deleteChapter(requireBook(), id); deleteChapter(requireBook(), id);
notifyChange(); notifyChange();
return { ok: true }; return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
}); });
ipcMain.handle("folio:restoreChapter", (_e, snap: unknown) => { handleIpc("folio:restoreChapter", (_e, snap: unknown) => {
try {
restoreChapter(requireBook(), snap as never); restoreChapter(requireBook(), snap as never);
notifyChange(); notifyChange();
return { ok: true }; return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
}); });
// Restore a book the user just trashed (Undo for book deletion). The desktop // Restore a book the user just trashed (Undo for book deletion). The desktop
@ -416,47 +395,28 @@ function restoreBookFromTrash(p: string): { ok?: boolean; error?: string } {
} }
} }
ipcMain.handle("folio:restoreBook", (_e, p: string) => restoreBookFromTrash(p)); handleIpc("folio:restoreBook", (_e, p: string) => restoreBookFromTrash(p));
ipcMain.handle("folio:duplicateChapter", (_e, id: string, title?: string) => { handleIpc("folio:duplicateChapter", (_e, id: string, title?: string) => {
try {
const entry = duplicateChapter(requireBook(), id, title); const entry = duplicateChapter(requireBook(), id, title);
notifyChange(); notifyChange();
return { id: entry.id, title: entry.title }; return { id: entry.id, title: entry.title };
} catch (e) {
return { error: (e as Error).message };
}
}); });
ipcMain.handle("folio:setChapterColor", (_e, id: string, color: string | null) => { handleIpc("folio:setChapterColor", (_e, id: string, color: string | null) => {
try {
setChapterColor(requireBook(), id, color); setChapterColor(requireBook(), id, color);
notifyChange(); notifyChange();
return { ok: true }; return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
}); });
ipcMain.handle("folio:reorderChapters", (_e, newOrder: string[]) => { handleIpc("folio:reorderChapters", (_e, newOrder: string[]) => {
try {
reorderChapters(requireBook(), newOrder); reorderChapters(requireBook(), newOrder);
notifyChange(); notifyChange();
return { ok: true }; return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
}); });
// expose ordered chapter list for the renderer // expose ordered chapter list for the renderer
ipcMain.handle("folio:getChapterList", () => { handleIpc("folio:getChapterList", () => listChaptersInOrder(loadBook(requireBook())));
try {
const meta = loadBook(requireBook());
return listChaptersInOrder(meta);
} catch (e) {
return { error: (e as Error).message };
}
});
function currentBookTitle(): string { function currentBookTitle(): string {
try { try {
@ -466,8 +426,7 @@ function currentBookTitle(): string {
} }
} }
ipcMain.handle("folio:exportMarkdown", async () => { handleIpc("folio:exportMarkdown", async () => {
try {
const bp = requireBook(); const bp = requireBook();
const { canceled, filePath } = await showSaveDialog({ const { canceled, filePath } = await showSaveDialog({
title: "Export combined Markdown", title: "Export combined Markdown",
@ -477,13 +436,9 @@ ipcMain.handle("folio:exportMarkdown", async () => {
if (canceled || !filePath) return { canceled: true } as const; if (canceled || !filePath) return { canceled: true } as const;
fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8"); fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8");
return { ok: true, filePath }; return { ok: true, filePath };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
ipcMain.handle("folio:exportZip", async () => { handleIpc("folio:exportZip", async () => {
try {
const bp = requireBook(); const bp = requireBook();
const { canceled, filePath } = await showSaveDialog({ const { canceled, filePath } = await showSaveDialog({
title: "Export book bundle (ZIP)", title: "Export book bundle (ZIP)",
@ -494,9 +449,6 @@ ipcMain.handle("folio:exportZip", async () => {
const buf = await buildZip(bp); const buf = await buildZip(bp);
fs.writeFileSync(filePath, buf); fs.writeFileSync(filePath, buf);
return { ok: true, filePath }; 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
@ -504,9 +456,8 @@ ipcMain.handle("folio:exportZip", async () => {
// chrome, so printing the window produces a clean, book-only PDF with an // chrome, so printing the window produces a clean, book-only PDF with an
// automatic page break before each chapter after the first. Chapter files and // automatic page break before each chapter after the first. Chapter files and
// the assembled Markdown are never touched. // the assembled Markdown are never touched.
ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => { handleIpc("folio:exportPdf", async (_e, chapterIds?: string[] | null) => {
try { requireBook();
requireBook();
// An explicitly empty selection is an error; undefined/null means the // An explicitly empty selection is an error; undefined/null means the
// whole book. The old guard checked length>0 before comparing to zero, // 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. // so it could never fire and empty arrays fell through to a full export.
@ -538,9 +489,6 @@ ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => {
fs.writeFileSync(filePath, buf); fs.writeFileSync(filePath, buf);
const pdfOk = buf.subarray(0, 4).toString("utf-8") === "%PDF"; const pdfOk = buf.subarray(0, 4).toString("utf-8") === "%PDF";
return { ok: true, filePath, bytes: buf.length, pdfOk }; return { ok: true, filePath, bytes: buf.length, pdfOk };
} catch (e) {
return { error: (e as Error).message };
}
}); });
// Export the manuscript as a DOCX or ODT. Both formats share one pipeline: the // Export the manuscript as a DOCX or ODT. Both formats share one pipeline: the
@ -590,62 +538,39 @@ async function exportOffice(format: "docx" | "odt", chapterIds?: string[] | null
} }
} }
ipcMain.handle("folio:exportDocx", (_e, chapterIds) => exportOffice("docx", chapterIds)); handleIpc("folio:exportDocx", (_e, chapterIds) => exportOffice("docx", chapterIds));
ipcMain.handle("folio:exportOdt", (_e, chapterIds) => exportOffice("odt", chapterIds)); handleIpc("folio:exportOdt", (_e, chapterIds) => exportOffice("odt", chapterIds));
ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => { handleIpc("folio:saveChapter", (_e, id: string, content: string) => {
try {
setChapterContent(requireBook(), id, content); setChapterContent(requireBook(), id, content);
return { ok: true }; return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
}); });
// Explicit, opt-in migration of a single chapter (or the whole book) to the // Explicit, opt-in migration of a single chapter (or the whole book) to the
// native Folio document format. Never automatic — the UI triggers this. The // native Folio document format. Never automatic — the UI triggers this. The
// migration pipeline makes a recovery backup and keeps the original .md files. // migration pipeline makes a recovery backup and keeps the original .md files.
ipcMain.handle("folio:migrateChapter", (_e, id: string) => { handleIpc("folio:migrateChapter", (_e, id: string) =>
try { migrateChapterToNative(requireBook(), id)
return migrateChapterToNative(requireBook(), id); );
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:migrateBook", () => { handleIpc("folio:migrateBook", () => migrateBookToNative(requireBook()));
try {
return migrateBookToNative(requireBook());
} catch (e) {
return { error: (e as Error).message };
}
});
// Load a chapter as a native document for the Tiptap editor. Legacy Markdown // Load a chapter as a native document for the Tiptap editor. Legacy Markdown
// chapters are converted on the fly (see chapters.loadChapterDoc). // chapters are converted on the fly (see chapters.loadChapterDoc).
ipcMain.handle("folio:loadChapterDoc", (_e, id: string) => { handleIpc("folio:loadChapterDoc", (_e, id: string) =>
try { loadChapterDoc(requireBook(), id)
return loadChapterDoc(requireBook(), id); );
} catch (e) {
return { error: (e as Error).message };
}
});
// Persist a native document from the editor. Autosave writes directly to the // Persist a native document from the editor. Autosave writes directly to the
// native JSON model (no Markdown round-trip). // native JSON model (no Markdown round-trip).
ipcMain.handle("folio:saveChapterDoc", (_e, id: string, doc: unknown) => { handleIpc("folio:saveChapterDoc", (_e, id: string, doc: unknown) => {
try {
saveChapterDoc(requireBook(), id, doc as never); saveChapterDoc(requireBook(), id, doc as never);
return { ok: true }; return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
}); });
// 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 () => { handleIpc("folio:importImageAsset", async () => {
try {
const bp = requireBook(); const bp = requireBook();
const { canceled, filePaths } = await showOpenDialog({ const { canceled, filePaths } = await showOpenDialog({
title: "Insert image", title: "Insert image",
@ -670,23 +595,20 @@ ipcMain.handle("folio:importImageAsset", async () => {
fs.copyFileSync(src, path.join(dir, name)); fs.copyFileSync(src, path.join(dir, name));
const relPath = `assets/${name}`; const relPath = `assets/${name}`;
return { ok: true, name, relPath }; return { ok: true, name, relPath };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
ipcMain.handle("folio:copyText", (_e, text: string) => { handleIpc("folio:copyText", (_e, text: string) => {
clipboard.writeText(typeof text === "string" ? text : ""); clipboard.writeText(typeof text === "string" ? text : "");
return { ok: true }; return { ok: true };
}); });
ipcMain.handle("folio:getClipboardText", () => { handleIpc("folio:getClipboardText", () => {
return clipboard.readText(); return clipboard.readText();
}); });
// Full screen is driven by the Electron window (not the DOM Fullscreen API, // Full screen is driven by the Electron window (not the DOM Fullscreen API,
// which is unreliable inside Electron and fails silently here). // which is unreliable inside Electron and fails silently here).
ipcMain.handle("folio:toggleFullscreen", () => { handleIpc("folio:toggleFullscreen", () => {
const win = getWindow(); const win = getWindow();
if (!win) return false; if (!win) return false;
win.setFullScreen(!win.isFullScreen()); win.setFullScreen(!win.isFullScreen());
@ -711,50 +633,37 @@ ipcMain.on("folio:context-menu", (event, x: number, y: number) => {
// Theme preference. Stored as a plain string so future themes can be added // Theme preference. Stored as a plain string so future themes can be added
// without touching the main process. "default" from older builds maps to // without touching the main process. "default" from older builds maps to
// "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")); handleIpc("folio:getTheme", () => getSetting("theme", "light"));
ipcMain.handle("folio:setTheme", (_e, id: unknown) => { handleIpc("folio:setTheme", (_e, id: unknown) => {
try {
const theme = const theme =
typeof id === "string" && id && id !== "default" ? id : "light"; typeof id === "string" && id && id !== "default" ? id : "light";
setSetting("theme", theme); setSetting("theme", theme);
return { ok: true }; 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
// writing view stays at the user's chosen size across books and restarts. // writing view stays at the user's chosen size across books and restarts.
ipcMain.handle("folio:getZoom", () => { handleIpc("folio:getZoom", () => {
const z = getSetting<number>("zoom", 100); const z = getSetting<number>("zoom", 100);
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) => { handleIpc("folio:setZoom", (_e, pct: unknown) => {
try {
const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100; const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100;
setSetting("zoom", z); setSetting("zoom", z);
return { ok: true }; 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
// injected store so the module itself stays Electron-free for tests. // injected store so the module itself stays Electron-free for tests.
bindBackupSettings({ get: getSetting, set: setSetting }); bindBackupSettings({ get: getSetting, set: setSetting });
ipcMain.handle("folio:getBackupLocation", () => getRecoveryRoot()); handleIpc("folio:getBackupLocation", () => getRecoveryRoot());
ipcMain.handle("folio:setBackupLocation", (_e, loc: unknown) => { handleIpc("folio:setBackupLocation", (_e, loc: unknown) => {
try {
const s = typeof loc === "string" ? loc.trim() : ""; const s = typeof loc === "string" ? loc.trim() : "";
setRecoveryRoot(s); setRecoveryRoot(s);
return { ok: true }; return { ok: true };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
ipcMain.handle("folio:importAttachment", async () => { handleIpc("folio:importAttachment", async () => {
try {
const bp = requireBook(); const bp = requireBook();
const { canceled, filePaths } = await showOpenDialog({ const { canceled, filePaths } = await showOpenDialog({
title: "Attach a file", title: "Attach a file",
@ -776,7 +685,4 @@ ipcMain.handle("folio:importAttachment", async () => {
fs.copyFileSync(src, path.join(dir, name)); fs.copyFileSync(src, path.join(dir, name));
const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/"); const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/");
return { ok: true, name, relPath }; return { ok: true, name, relPath };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });