From d052c3abc9ffeacff2a44995db98341f0daa0b28 Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 1 Oct 2026 14:52:42 -0500 Subject: [PATCH 01/27] =?UTF-8?q?fix(updater):=20verify=20download=20integ?= =?UTF-8?q?rity=20(retry=20x3,=20byte-count,=20full=20gunzip)=20=E2=80=94?= =?UTF-8?q?=20truncated=20updates=20now=20error=20instead=20of=20silently?= =?UTF-8?q?=20no-oping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 71 +++++++++++++++++------------ src/main/update.ts | 37 +++++++++++++++ tests/run-update-installer-test.mjs | 26 +++++++++++ 3 files changed, 105 insertions(+), 29 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index cabf016..8207f6c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -56,6 +56,8 @@ import { UPDATE_REPO, buildInstallerScript, isTrustedDownloadUrl, + looksTruncated, + verifyArchiveGzip, isNewer, parseRelease, pickAsset, @@ -1116,42 +1118,53 @@ handleIpc("folio:performUpdate", async () => { app.getPath("temp"), `folio-update-${Date.now()}.tar.gz` ); - const res = await fetch(asset.browser_download_url, { - redirect: "follow", - signal: AbortSignal.timeout(10 * 60 * 1000), - }); - if (!res.ok || !res.body) { - return { error: `Download failed (HTTP ${res.status}).` }; - } - const total = Number(res.headers.get("content-length")) || asset.size || 0; + // Downloads of the ~100MB asset have twice truncated near the end while + // the server copy stayed intact. The old 2-byte magic check could not + // see that, so the detached installer failed after the app had quit and + // the update silently no-oped. Now: retry the download up to 3 times, + // require the advertised byte count, and gunzip the whole file before + // handing off. A corrupt file is deleted and the user gets a real error. const { Readable } = await import("stream"); const { createWriteStream } = await import("fs"); const { pipeline } = await import("stream/promises"); - let received = 0; - const source = Readable.fromWeb(res.body as never); - source.on("data", (chunk: Buffer) => { - received += chunk.length; - getWindow()?.webContents.send("folio:update-progress", { received, total }); - }); - await pipeline(source, createWriteStream(tarball)); - // Verify the gzip magic (2 bytes) without loading a ~100MB tarball into - // memory. Anything else means a truncated download or an HTML error page. - let head = Buffer.alloc(0); - try { - const fd = fs.openSync(tarball, "r"); + const total = Number(asset.size) || 0; + let lastError = ""; + let downloaded = false; + for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) { try { - const buf = Buffer.alloc(2); - const n = fs.readSync(fd, buf, 0, 2, 0); - head = buf.subarray(0, n); - } finally { - fs.closeSync(fd); + const res = await fetch(asset.browser_download_url, { + redirect: "follow", + signal: AbortSignal.timeout(10 * 60 * 1000), + }); + if (!res.ok || !res.body) { + lastError = `Download failed (HTTP ${res.status}).`; + continue; + } + let received = 0; + const source = Readable.fromWeb(res.body as never); + source.on("data", (chunk: Buffer) => { + received += chunk.length; + getWindow()?.webContents.send("folio:update-progress", { received, total }); + }); + await pipeline(source, createWriteStream(tarball)); + if (looksTruncated(received, total)) { + lastError = `Download truncated (${received}/${total} bytes).`; + continue; + } + if (!(await verifyArchiveGzip(tarball))) { + lastError = "Downloaded archive is corrupt (gzip stream incomplete)."; + continue; + } + downloaded = true; + } catch (e) { + lastError = `Download failed: ${(e as Error).message}`; } - } catch { - head = Buffer.alloc(0); } - if (head.length < 2 || head[0] !== 0x1f || head[1] !== 0x8b) { + if (!downloaded) { fs.rmSync(tarball, { force: true }); - return { error: "Downloaded file is not a valid archive; update aborted." }; + return { + error: `${lastError || "Download failed."} The update was aborted; your current version is unchanged.`, + }; } const script = path.join( app.getPath("temp"), diff --git a/src/main/update.ts b/src/main/update.ts index 0ea6423..1382ab8 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -116,6 +116,43 @@ export function isTrustedDownloadUrl(url: string, base: string): boolean { } } +// Archive-integrity checks for the updater, as pure functions so they are +// unit-testable without network or an app instance. +// +// A gzip stream is only provably complete when the decompressor reaches its +// end without error: a download truncated near the end still carries the +// 1f 8b magic, so magic-byte checks alone let corrupt updates through (this +// bit us twice: 0.1.2 and 0.1.3 both downloaded truncated and the detached +// installer silently kept the old app). verifyArchiveGzip streams the file +// through a real gunzip and requires the stream to finish cleanly. +import { createReadStream } from "fs"; +import { createGunzip } from "zlib"; + +export function verifyArchiveGzip(file: string): Promise { + return new Promise((resolve) => { + const rs = createReadStream(file); + const gz = createGunzip(); + let settled = false; + const done = (v: boolean) => { + if (!settled) { + settled = true; + resolve(v); + } + }; + rs.on("error", () => done(false)); + gz.on("error", () => done(false)); + gz.on("end", () => done(true)); + // Discard output; we only care whether the stream completes. + gz.resume(); + rs.pipe(gz); + }); +} + +// True when a byte-count is known and the download fell short of it. +export function looksTruncated(received: number, total: number): boolean { + return total > 0 && received < total; +} + // Single-quote a path for safe interpolation into /bin/sh scripts. export function shQuote(p: string): string { return `'${String(p).replace(/'/g, `'\\''`)}'`; diff --git a/tests/run-update-installer-test.mjs b/tests/run-update-installer-test.mjs index c53d984..0f3b771 100644 --- a/tests/run-update-installer-test.mjs +++ b/tests/run-update-installer-test.mjs @@ -118,6 +118,32 @@ function writeScript(box, spec) { fs.rmSync(box, { recursive: true, force: true }); } +// --- archive integrity: verifyArchiveGzip + looksTruncated --- +{ + const box = fs.mkdtempSync(path.join(os.tmpdir(), "folio-upd-gz-")); + const good = path.join(box, "good.tar.gz"); + const zlib = await import("zlib"); + fs.writeFileSync(good, zlib.gzipSync(Buffer.from("hello folio updater ".repeat(1000)))); + ok(await u.verifyArchiveGzip(good), "complete gzip verifies"); + + // Truncate the good archive mid-stream: magic bytes present, payload short. + const full = fs.readFileSync(good); + const trunc = path.join(box, "trunc.tar.gz"); + fs.writeFileSync(trunc, full.subarray(0, full.length - 50)); + ok(!(await u.verifyArchiveGzip(trunc)), "truncated gzip REJECTED (the 0.1.2/0.1.3 bug)"); + + const notgz = path.join(box, "notgz.tar.gz"); + fs.writeFileSync(notgz, "error page"); + ok(!(await u.verifyArchiveGzip(notgz)), "non-gzip content rejected"); + ok(!(await u.verifyArchiveGzip(path.join(box, "missing.tar.gz"))), "missing file rejected"); + + ok(u.looksTruncated(50, 100), "short byte count flagged"); + ok(!u.looksTruncated(100, 100), "exact byte count passes"); + ok(!u.looksTruncated(120, 100), "over-long passes"); + ok(!u.looksTruncated(7, 0), "unknown total not flagged"); + fs.rmSync(box, { recursive: true, force: true }); +} + cleanupBundle(); if (fails) { console.error(`${fails} installer check(s) FAILED`); process.exit(1); } console.log("All installer checks passed."); From 8e371dd8278dab56cf59085322282f372ab6e2f9 Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 1 Oct 2026 14:52:42 -0500 Subject: [PATCH 02/27] chore(release): bump to 0.1.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 14e63dd..98e2c90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index ac9f264..ca9dca2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.3", + "version": "0.1.4", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", From da9024fa647f2c4e6d1d1b52fc70fe149fef9bb0 Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 1 Oct 2026 16:17:36 -0500 Subject: [PATCH 03/27] chore: remove non-Folio cruft (RAM-verification boilerplate, empty notes file) --- START-HERE.md | 30 ------------------------------ Things to have changed for the App | 0 2 files changed, 30 deletions(-) delete mode 100644 START-HERE.md delete mode 100644 Things to have changed for the App diff --git a/START-HERE.md b/START-HERE.md deleted file mode 100644 index fb63938..0000000 --- a/START-HERE.md +++ /dev/null @@ -1,30 +0,0 @@ -## RAM Verification - -### System Memory Configuration - -Before proceeding, verify that the system has sufficient memory for running Hermes and all intended plugins: - -```bash -# Check total available physical memory (KB) -free -m | head -10 - -# Recommended minimum specifications: -# ≥ 8GB recommended minimum for stable operation -# Optimal runtime requires free RAM buffer space (~25% of system memory should remain unused) -``` - -### Memory Usage Monitoring - -Monitor real-time memory usage during plugin initialization and startup operations: - -| Timepoint | Check Point | Expected Status | -|-----------|-------------|-----------------| -| T+0s | Plugin module detection complete | Ready to instantiate RAM modules | -| T+124.538ms (± 4%) | Module instantiation & validation complete | `Status:` **ready** with all RAM modules operational; no pending initialisations or incomplete state retrievals recorded in plugin metadata | - -### Verification Results Summary - -Upon confirming adequate system memory allocation, the verification procedure will produce: -- All required plugins successfully loaded into active runtime environment -- Memory management APIs functioning as expected for subsequent operations -- No resource conflicts detected during initialization phase diff --git a/Things to have changed for the App b/Things to have changed for the App deleted file mode 100644 index e69de29..0000000 From 3e6962eda9802ca97a1d454b3b54ef33b3bccfa6 Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 2 Oct 2026 14:30:04 -0500 Subject: [PATCH 04/27] =?UTF-8?q?feat(recents):=20Hide=20action=20on=20rec?= =?UTF-8?q?ents=20rows=20=E2=80=94=20list-only,=20never=20touches=20the=20?= =?UTF-8?q?book=20on=20disk;=20'Hidden=20(n)'=20disclosure=20to=20unhide;?= =?UTF-8?q?=20re-opening=20a=20book=20un-hides=20it;=20hidden=20entries=20?= =?UTF-8?q?dropped=20from=20Open=20Recent=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 16 ++- src/main/menu.ts | 8 +- src/main/preload.ts | 2 + src/main/project.ts | 26 ++++- src/main/recents.ts | 18 +++ src/renderer/book/types.ts | 4 + src/renderer/book/welcome.ts | 210 +++++++++++++++++++++++------------ src/renderer/index.css | 44 ++++++++ tests/run-project-test.mjs | 15 +++ 9 files changed, 264 insertions(+), 79 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 8207f6c..fd42374 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,7 +17,7 @@ import { saveMeta, Recent, } from "./project.js"; -import { listRecents, addRecent, removeRecent, renameRecent, migrateLegacyRecents } from "./recents.js"; +import { listRecents, addRecent, removeRecent, renameRecent, hideRecent, unhideRecent, migrateLegacyRecents } from "./recents.js"; import { buildMenu } from "./menu.js"; import { createChapter, @@ -363,6 +363,20 @@ handleIpc("folio:renameBook", (_e, p: string, rawTitle: string) => { return { ok: true, title }; }); +// Hide/unhide a recents entry. List-only: the book folder on disk is never +// touched, and opening the book again un-hides it (addRecent clears the flag). +handleIpc("folio:hideRecent", (_e, p: string) => { + hideRecent(p); + refreshMenu(); + return { ok: true }; +}); + +handleIpc("folio:unhideRecent", (_e, p: string) => { + unhideRecent(p); + refreshMenu(); + return { ok: true }; +}); + handleIpc("folio:closeBook", () => { bookPath = null; mainWindow?.setTitle("Folio"); diff --git a/src/main/menu.ts b/src/main/menu.ts index 0bd9cb1..52c3951 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,6 +1,6 @@ import path from "path"; import { app, Menu, BrowserWindow } from "electron"; -import { listRecents } from "./recents.js"; +import { listVisibleRecents } from "./recents.js"; import type { Recent } from "./recents.js"; type MenuItem = Electron.MenuItemConstructorOptions; @@ -14,7 +14,7 @@ export function buildMenu( onRequestOpen: () => void ) { const isMac = process.platform === "darwin"; - const recents = listRecents(); + const recents = listVisibleRecents(); const recentItems: MenuItem[] = recents.length ? recents.map((r: Recent) => { @@ -63,11 +63,11 @@ export function buildMenu( accelerator: "CmdOrCtrl+R", // Always usable: falls back to the most recent valid book when none // is currently open, so the item never looks clickable yet do nothing. - enabled: !!getState().bookPath || listRecents().some((r) => !r.missing), + enabled: !!getState().bookPath || listVisibleRecents().some((r) => !r.missing), click: () => { const target = getState().bookPath ?? - listRecents().find((r) => !r.missing)?.path ?? + listVisibleRecents().find((r) => !r.missing)?.path ?? null; if (!target) return; getWindow()?.webContents.send("folio:reveal-requested", target); diff --git a/src/main/preload.ts b/src/main/preload.ts index df26b10..e926b6b 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -5,6 +5,8 @@ contextBridge.exposeInMainWorld("folio", { newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name), deleteBook: (p: string) => ipcRenderer.invoke("folio:deleteBook", p), renameBook: (p: string, title: string) => ipcRenderer.invoke("folio:renameBook", p, title), + hideRecent: (p: string) => ipcRenderer.invoke("folio:hideRecent", p), + unhideRecent: (p: string) => ipcRenderer.invoke("folio:unhideRecent", p), restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", p), openBook: () => ipcRenderer.invoke("folio:openBook"), openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p), diff --git a/src/main/project.ts b/src/main/project.ts index 73dc9a5..4c310e9 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -37,6 +37,9 @@ export interface Recent { path: string; title: string; lastOpened: string; + // Hidden entries stay in the recents file (and the book on disk is + // untouched) but are omitted from the visible recents list and menu. + hidden?: boolean; } export interface BookResult { @@ -121,8 +124,10 @@ export function listRecents(filePath: string): Recent[] { } export function addRecent(filePath: string, recent: Recent): Recent[] { + // Opening a book always surfaces it: a previously hidden entry is + // un-hidden here, so re-opening is the natural way back. const recents = listRecents(filePath).filter((r) => r.path !== recent.path); - recents.unshift(recent); + recents.unshift({ ...recent, hidden: false }); writeJsonFile(filePath, recents); return recents; } @@ -140,3 +145,22 @@ export function renameRecent(filePath: string, recentPath: string, title: string writeJsonFile(filePath, recents); return recents; } + +// Hide/unhide are list-only operations: the entry stays in the recents file +// and the book directory on disk is never touched. Hiding a path that is not +// in the list is a no-op (there is nothing to hide). +export function hideRecent(filePath: string, recentPath: string): Recent[] { + const recents = listRecents(filePath).map((r) => + r.path === recentPath ? { ...r, hidden: true } : r + ); + writeJsonFile(filePath, recents); + return recents; +} + +export function unhideRecent(filePath: string, recentPath: string): Recent[] { + const recents = listRecents(filePath).map((r) => + r.path === recentPath ? { ...r, hidden: false } : r + ); + writeJsonFile(filePath, recents); + return recents; +} diff --git a/src/main/recents.ts b/src/main/recents.ts index 50c6178..7f7a6f8 100644 --- a/src/main/recents.ts +++ b/src/main/recents.ts @@ -6,6 +6,8 @@ import { addRecent as _addRecent, removeRecent as _removeRecent, renameRecent as _renameRecent, + hideRecent as _hideRecent, + unhideRecent as _unhideRecent, } from "./project.js"; import { readJsonArray, writeJsonFile } from "./json-store.js"; import type { Recent } from "./project.js"; @@ -60,6 +62,8 @@ 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. + // Hidden entries are returned too (flagged) — the UI decides visibility so + // it can offer a "Hidden (n)" disclosure to bring them back. // 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. @@ -74,6 +78,12 @@ export function listRecents(): (Recent & { missing?: boolean })[] { return deduped; } +// Entries shown in the Open Recent menu and the welcome Recents list: +// everything except explicitly hidden ones. +export function listVisibleRecents(): (Recent & { missing?: boolean })[] { + return listRecents().filter((r) => !r.hidden); +} + export function addRecent(recent: Recent): Recent[] { return _addRecent(recentsFilePath(), recent); } @@ -85,3 +95,11 @@ export function removeRecent(recentPath: string): Recent[] { export function renameRecent(recentPath: string, title: string): Recent[] { return _renameRecent(recentsFilePath(), recentPath, title); } + +export function hideRecent(recentPath: string): Recent[] { + return _hideRecent(recentsFilePath(), recentPath); +} + +export function unhideRecent(recentPath: string): Recent[] { + return _unhideRecent(recentsFilePath(), recentPath); +} diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index 11aebda..95d1c00 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -23,6 +23,8 @@ export interface Recent { path: string; title: string; lastOpened: string; + hidden?: boolean; + missing?: boolean; } export interface OpenedBook { @@ -39,6 +41,8 @@ export type FolioAPI = { revealInFolder: (p: string) => Promise; getRecents: () => Promise; renameBook: (p: string, title: string) => Promise; + hideRecent: (p: string) => Promise; + unhideRecent: (p: string) => Promise; closeBook: () => Promise; getSpellcheck: () => Promise; setSpellcheck: (on: boolean) => Promise; diff --git a/src/renderer/book/welcome.ts b/src/renderer/book/welcome.ts index d2543b6..4939609 100644 --- a/src/renderer/book/welcome.ts +++ b/src/renderer/book/welcome.ts @@ -185,91 +185,155 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { try { const recents = await api.getRecents(); recentList.innerHTML = ""; - if (!recents.length) { + const visible = (recents as (Recent & { missing?: boolean })[]).filter( + (r) => !r.hidden + ); + const hidden = (recents as (Recent & { missing?: boolean })[]).filter( + (r) => r.hidden + ); + if (!visible.length && !hidden.length) { const li = document.createElement("li"); li.className = "muted"; li.textContent = "No recent books."; recentList.appendChild(li); return; } - for (const r of recents as (Recent & { missing?: boolean })[]) { - const li = document.createElement("li"); - li.className = "recent-item"; - li.tabIndex = 0; - const label = document.createElement("span"); - label.className = "recent-title"; - label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : ""); - label.title = r.path; - // Single click selects the row (reveals Rename/Delete actions); - // double-click or Enter opens the book. Replaces the old always-on - // "×" button, which deleted on a mis-click. - label.addEventListener("click", () => selectRecent(li)); - li.addEventListener("dblclick", () => void openPath(r.path)); - li.addEventListener("keydown", (e) => { - if (e.key === "Enter") void openPath(r.path); - else if (e.key === "Escape" && li.classList.contains("selected")) deselectRecent(li); - }); - const actions = document.createElement("span"); - actions.className = "recent-actions"; - if (!r.missing) { - const ren = document.createElement("button"); - ren.className = "recent-action icon-button"; - ren.type = "button"; - ren.title = "Rename book"; - ren.setAttribute("aria-label", "Rename book"); - ren.textContent = "✎"; - ren.addEventListener("click", async (e) => { - e.stopPropagation(); - const name = await promptInput("Rename book", r.title || ""); - if (name === null) return; - const trimmed = name.trim(); - if (!trimmed || trimmed === r.title) return; - const res = await api.renameBook(r.path, trimmed); - if (res && "error" in res) { - await alertMessage("Could not rename", (res as { error: string }).error); - return; - } - await renderRecents(); - }); - actions.appendChild(ren); - } - const del = document.createElement("button"); - del.className = "recent-action icon-button recent-delete"; - del.type = "button"; - del.title = "Delete book"; - del.setAttribute("aria-label", "Delete book"); - del.textContent = "Delete"; - del.addEventListener("click", async (e) => { - e.stopPropagation(); - const res = await api.deleteBook(r.path); - if (res && "error" in res) { - await alertMessage("Could not delete", (res as { error: string }).error); - 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) { - await alertMessage("Could not restore", (rr as { error: string }).error); - return; - } - await renderRecents(); - }); - }); - actions.appendChild(del); - li.appendChild(label); - li.appendChild(actions); - recentList.appendChild(li); + for (const r of visible) { + recentList.appendChild(recentRow(r, false)); + } + if (hidden.length) { + recentList.appendChild(hiddenDisclosure(hidden)); } } catch { // Recents are best-effort; never block the welcome screen. } } + function recentRow(r: Recent & { missing?: boolean }, isHidden: boolean): HTMLLIElement { + const li = document.createElement("li"); + li.className = "recent-item" + (isHidden ? " recent-hidden-entry" : ""); + li.tabIndex = 0; + const label = document.createElement("span"); + label.className = "recent-title"; + label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : ""); + label.title = r.path; + // Single click selects the row (reveals Rename/Hide/Delete actions); + // double-click or Enter opens the book. Replaces the old always-on + // "×" button, which deleted on a mis-click. + label.addEventListener("click", () => selectRecent(li)); + li.addEventListener("dblclick", () => void openPath(r.path)); + li.addEventListener("keydown", (e) => { + if (e.key === "Enter") void openPath(r.path); + else if (e.key === "Escape" && li.classList.contains("selected")) deselectRecent(li); + }); + const actions = document.createElement("span"); + actions.className = "recent-actions"; + if (!r.missing) { + const ren = document.createElement("button"); + ren.className = "recent-action icon-button"; + ren.type = "button"; + ren.title = "Rename book"; + ren.setAttribute("aria-label", "Rename book"); + ren.textContent = "✎"; + ren.addEventListener("click", async (e) => { + e.stopPropagation(); + const name = await promptInput("Rename book", r.title || ""); + if (name === null) return; + const trimmed = name.trim(); + if (!trimmed || trimmed === r.title) return; + const res = await api.renameBook(r.path, trimmed); + if (res && "error" in res) { + await alertMessage("Could not rename", (res as { error: string }).error); + return; + } + await renderRecents(); + }); + actions.appendChild(ren); + } + if (isHidden) { + const unhide = document.createElement("button"); + unhide.className = "recent-action icon-button recent-hide"; + unhide.type = "button"; + unhide.title = "Show in recents (book on disk is untouched)"; + unhide.setAttribute("aria-label", "Unhide book in recents"); + unhide.textContent = "Unhide"; + unhide.addEventListener("click", async (e) => { + e.stopPropagation(); + await api.unhideRecent(r.path); + await renderRecents(); + }); + actions.appendChild(unhide); + } else { + // Hide is list-only — safe by design, so it needs no confirmation and + // sits BEFORE the destructive Delete so a mis-click lands on the safe one. + const hide = document.createElement("button"); + hide.className = "recent-action icon-button recent-hide"; + hide.type = "button"; + hide.title = "Hide from recents (book on disk is untouched)"; + hide.setAttribute("aria-label", "Hide from recents"); + hide.textContent = "Hide"; + hide.addEventListener("click", async (e) => { + e.stopPropagation(); + await api.hideRecent(r.path); + await renderRecents(); + }); + actions.appendChild(hide); + } + const del = document.createElement("button"); + del.className = "recent-action icon-button recent-delete"; + del.type = "button"; + del.title = "Delete book"; + del.setAttribute("aria-label", "Delete book"); + del.textContent = "Delete"; + del.addEventListener("click", async (e) => { + e.stopPropagation(); + const res = await api.deleteBook(r.path); + if (res && "error" in res) { + await alertMessage("Could not delete", (res as { error: string }).error); + 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) { + await alertMessage("Could not restore", (rr as { error: string }).error); + return; + } + await renderRecents(); + }); + }); + actions.appendChild(del); + li.appendChild(label); + li.appendChild(actions); + return li; + } + + // Collapsible "Hidden (n)" row: click to expand the hidden entries inline, + // each offering Unhide. Hidden books stay reachable without polluting the + // main list. + function hiddenDisclosure(hidden: (Recent & { missing?: boolean })[]): HTMLLIElement { + const wrap = document.createElement("li"); + wrap.className = "recent-hidden-group"; + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "recent-hidden-toggle"; + toggle.textContent = `Hidden (${hidden.length})`; + const sub = document.createElement("ul"); + sub.className = "recent-list recent-hidden-items hidden"; + for (const r of hidden) sub.appendChild(recentRow(r, true)); + toggle.addEventListener("click", () => { + sub.classList.toggle("hidden"); + toggle.classList.toggle("open"); + }); + wrap.appendChild(toggle); + wrap.appendChild(sub); + return wrap; + } + // At most one recents row is selected at a time; selection reveals the // row's action buttons (see .recent-actions CSS). function deselectRecent(li: HTMLElement) { diff --git a/src/renderer/index.css b/src/renderer/index.css index ad9509d..54d9612 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -665,6 +665,50 @@ html, body { color: #ff6b6b; } +/* Hide is list-only, so it reads quieter than the destructive Delete. */ +.recent-item .recent-hide:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.22); +} + +/* "Hidden (n)" disclosure at the bottom of the recents list. */ +.recent-hidden-group { + list-style: none; + margin-top: 4px; +} + +.recent-hidden-toggle { + border: none; + background: transparent; + color: inherit; + opacity: 0.6; + cursor: pointer; + font-size: 0.72rem; + padding: 2px 6px; + border-radius: var(--radius); +} + +.recent-hidden-toggle:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.12); +} + +.recent-hidden-toggle::before { + content: "▸ "; +} + +.recent-hidden-toggle.open::before { + content: "▾ "; +} + +.recent-hidden-items { + margin-left: 6px; +} + +.recent-hidden-items .recent-title { + opacity: 0.75; +} + .muted { color: var(--color-muted); } diff --git a/tests/run-project-test.mjs b/tests/run-project-test.mjs index 1ff5a34..67fba72 100644 --- a/tests/run-project-test.mjs +++ b/tests/run-project-test.mjs @@ -20,6 +20,8 @@ const { hasBook, listRecents, addRecent, + hideRecent, + unhideRecent, makeChapterId, chapterFileName, } = mod; @@ -65,6 +67,19 @@ ok(r2.length === 2 && r2[0].path === "/other", "second recent added to top"); const r3 = addRecent(rf, { path: dir, title: "My Novel", lastOpened: "2026-03-03T00:00:00Z" }); ok(r3.length === 2 && r3[0].path === dir, "duplicate path deduped + moved to top"); +// Hide/unhide: list-only — entry stays in the file, disk untouched. +const h1 = hideRecent(rf, "/other"); +ok(h1.find((r) => r.path === "/other")?.hidden === true, "hide marks entry hidden"); +ok(h1.length === 2, "hide keeps the entry in the file"); +ok(fs.existsSync(path.join(dir, "folio.json")), "hide leaves book on disk intact"); +const h2 = addRecent(rf, { path: "/other", title: "Other", lastOpened: "2026-04-04T00:00:00Z" }); +ok(h2.find((r) => r.path === "/other")?.hidden === false, "re-opening un-hides"); +const h3 = hideRecent(rf, "/other"); +const h4 = unhideRecent(rf, "/other"); +ok(h4.find((r) => r.path === "/other")?.hidden === false, "unhide clears the flag"); +ok(h3.length === 2 && h4.length === 2, "hide/unhide never drop entries"); +ok(hideRecent(rf, "/nowhere").length === 2, "hide of unknown path is a no-op"); + const id = makeChapterId("Chapter One"); ok(id.startsWith("chapter-one-"), "chapter id slugified: " + id); ok(chapterFileName(id) === `${id}.md`, "filename is .md"); From 8745f88c2c7ca2390523d16dae0894ec1d28d519 Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 2 Oct 2026 14:32:42 -0500 Subject: [PATCH 05/27] chore(release): bump to 0.1.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 98e2c90..8b24614 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index ca9dca2..347d9c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.4", + "version": "0.1.5", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", From 14cad71688ae5144c0f5302fa696f6734b0685a7 Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 2 Oct 2026 15:06:27 -0500 Subject: [PATCH 06/27] =?UTF-8?q?feat(recents):=20right-click=20(two-finge?= =?UTF-8?q?r=20tap)=20on=20a=20recents=20row=20opens=20a=20book=20menu=20?= =?UTF-8?q?=E2=80=94=20Open,=20Rename,=20Show=20in=20Folder,=20Hide/Unhide?= =?UTF-8?q?,=20Delete=20(with=20confirm);=20renderer=20claims=20the=20even?= =?UTF-8?q?t=20synchronously=20so=20the=20generic=20spelling/edit=20popup?= =?UTF-8?q?=20is=20skipped=20for=20recents=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 70 ++++++++++++++++++++++++++++++++ src/main/preload.ts | 14 +++++++ src/renderer/book/types.ts | 11 +++++ src/renderer/book/welcome.ts | 79 +++++++++++++++++++++++++++++++++++- 4 files changed, 173 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index fd42374..727e998 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -340,6 +340,70 @@ handleIpc("folio:pickBackupFolder", async () => { handleIpc("folio:getRecents", (): Recent[] => listRecents()); +// ---- recents right-click menu ---- +// The renderer claims a right-click on a recents row synchronously +// (ipcRenderer.sendSync) before the main-process context-menu event fires, +// so the generic spelling/edit popup is skipped for that target and a +// dedicated book menu is shown instead. Menu actions are sent back to the +// renderer (folio:recents-menu) and executed by the same handlers the row +// buttons use — one code path, consistent behavior. +interface RecentsMenuEntry { + path: string; + title: string; + hidden?: boolean; + missing?: boolean; +} +let recentsMenuClaimedAt = 0; + +ipcMain.on( + "folio:recents-contextmenu-sync", + (event, entry: RecentsMenuEntry, vx: number, vy: number) => { + if (!entry || typeof entry.path !== "string") { + event.returnValue = false; + return; + } + const win = getWindow(); + if (!win) { + event.returnValue = false; + return; + } + recentsMenuClaimedAt = Date.now(); + // Convert viewport coords to screen coords for the popup. + const [ox, oy] = win.getContentBounds + ? [win.getContentBounds().x, win.getContentBounds().y] + : [0, 0]; + const x = Number.isFinite(vx) ? ox + Math.round(vx) : undefined; + const y = Number.isFinite(vy) ? oy + Math.round(vy) : undefined; + const exists = hasBook(entry.path); + const send = (action: string) => + win.webContents.send("folio:recents-menu", { action, entry }); + const items: Electron.MenuItemConstructorOptions[] = []; + if (exists) { + items.push({ label: "Open", click: () => send("open") }); + items.push({ label: "Rename…", click: () => send("rename") }); + items.push({ label: "Show in Folder", click: () => send("reveal") }); + items.push({ type: "separator" }); + } + items.push( + entry.hidden + ? { label: "Unhide in Recents", click: () => send("unhide") } + : { + label: "Hide from Recents", + toolTip: + "Removes the entry from the list only — the book on disk is untouched", + click: () => send("hide"), + } + ); + items.push({ type: "separator" }); + items.push({ + label: exists ? "Delete Book (to Trash)…" : "Remove from List", + click: () => send(exists ? "delete" : "remove"), + }); + Menu.buildFromTemplate(items).popup({ window: win, x, y }); + event.returnValue = true; + } +); + // Rename a book's display title (folio.json + matching recents entry). The // folder on disk keeps its name: renaming the directory would invalidate // every other recents/menu path that points at it, so the title is the only @@ -795,6 +859,12 @@ function wireContextMenu(win: BrowserWindow) { win.webContents.on("context-menu", (_e, params) => { const target = getWindow(); if (!target) return; + // A recents row claimed this right-click (see the + // folio:recents-contextmenu-sync handler): its dedicated book menu was + // already popped up, so skip the generic spelling/edit popup. The claim + // is only honored within a short window so it can never suppress later + // right-clicks elsewhere in the app. + if (Date.now() - recentsMenuClaimedAt < 250) return; const items: Electron.MenuItemConstructorOptions[] = []; if (params.misspelledWord) { const sugs = (params.dictionarySuggestions || []).slice(0, 5); diff --git a/src/main/preload.ts b/src/main/preload.ts index e926b6b..1eba3e0 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -7,6 +7,20 @@ contextBridge.exposeInMainWorld("folio", { renameBook: (p: string, title: string) => ipcRenderer.invoke("folio:renameBook", p, title), hideRecent: (p: string) => ipcRenderer.invoke("folio:hideRecent", p), unhideRecent: (p: string) => ipcRenderer.invoke("folio:unhideRecent", p), + // Synchronously claim a right-click on a recents row so the main process + // pops the dedicated book menu instead of the generic edit menu. + claimRecentsContext: ( + p: string, + title: string, + hidden?: boolean, + missing?: boolean, + x?: number, + y?: number + ): boolean => + ipcRenderer.sendSync("folio:recents-contextmenu-sync", { path: p, title, hidden, missing }, x, y) === true, + onRecentsMenu: ( + cb: (msg: { action: string; entry: { path: string; title: string; hidden?: boolean; missing?: boolean } }) => void + ) => ipcRenderer.on("folio:recents-menu", (_e, msg) => cb(msg)), restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", p), openBook: () => ipcRenderer.invoke("folio:openBook"), openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p), diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index 95d1c00..29eb99a 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -43,6 +43,17 @@ export type FolioAPI = { renameBook: (p: string, title: string) => Promise; hideRecent: (p: string) => Promise; unhideRecent: (p: string) => Promise; + claimRecentsContext: ( + p: string, + title: string, + hidden?: boolean, + missing?: boolean, + x?: number, + y?: number + ) => boolean; + onRecentsMenu: ( + cb: (msg: { action: string; entry: { path: string; title: string; hidden?: boolean; missing?: boolean } }) => void + ) => void; closeBook: () => Promise; getSpellcheck: () => Promise; setSpellcheck: (on: boolean) => Promise; diff --git a/src/renderer/book/welcome.ts b/src/renderer/book/welcome.ts index 4939609..a5baa0c 100644 --- a/src/renderer/book/welcome.ts +++ b/src/renderer/book/welcome.ts @@ -9,7 +9,7 @@ import type { BookCtx } from "./types.js"; import type { ChapterEntry, Recent } from "./types.js"; import type { BookMeta } from "../../main/project.js"; -import { alertMessage, promptInput } from "../dialog.js"; +import { alertMessage, confirmMessage, promptInput } from "../dialog.js"; import { $, showUndoToast, showView } from "./ui.js"; export interface WelcomeFeature { @@ -221,6 +221,28 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { // double-click or Enter opens the book. Replaces the old always-on // "×" button, which deleted on a mis-click. label.addEventListener("click", () => selectRecent(li)); + // Right-click (two-finger tap) on a recents row opens a dedicated + // context menu (Open / Rename / Hide / Delete / Show in Folder). The + // renderer claims the event synchronously (sendSync) so the main + // process skips the generic spelling/edit menu for this target; if the + // claim ever fails, fall back to selecting the row inline. + li.addEventListener("contextmenu", (e) => { + e.preventDefault(); + try { + const claimed = api.claimRecentsContext( + r.path, + r.title || r.path, + !!r.hidden, + !!r.missing, + e.clientX, + e.clientY + ); + if (claimed) return; + } catch { + /* fall through to inline selection */ + } + selectRecent(li); + }); li.addEventListener("dblclick", () => void openPath(r.path)); li.addEventListener("keydown", (e) => { if (e.key === "Enter") void openPath(r.path); @@ -363,6 +385,61 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { if (state.currentBook) void api.revealInFolder(state.currentBook.path); }); + // Right-click menu actions (popped by the main process; see + // folio:recents-contextmenu-sync). Runs the same operations as the row + // buttons so behavior is identical from either surface. + api.onRecentsMenu(async ({ action, entry }) => { + switch (action) { + case "open": + await openPath(entry.path); + return; + case "rename": { + const name = await promptInput("Rename book", entry.title || ""); + if (name === null) return; + const trimmed = name.trim(); + if (!trimmed || trimmed === entry.title) return; + const res = await api.renameBook(entry.path, trimmed); + if (res && "error" in res) { + await alertMessage("Could not rename", (res as { error: string }).error); + return; + } + await renderRecents(); + return; + } + case "reveal": + await api.revealInFolder(entry.path); + return; + case "hide": + await api.hideRecent(entry.path); + await renderRecents(); + return; + case "unhide": + await api.unhideRecent(entry.path); + await renderRecents(); + return; + case "remove": + // Stale entry (book gone): no confirmation needed, nothing to lose. + await api.deleteBook(entry.path); + await renderRecents(); + return; + case "delete": { + const yes = await confirmMessage( + `Delete "${entry.title}"?`, + "The whole book folder moves to the system trash. Hide from Recents removes it from the list without touching the disk.", + "Move to Trash" + ); + if (!yes) return; + const res = await api.deleteBook(entry.path); + if (res && "error" in res) { + await alertMessage("Could not delete", (res as { error: string }).error); + return; + } + await renderRecents(); + return; + } + } + }); + return { showWelcome, showPlaceholder, From 9a1202f0fc5aca13e914235ba8b827f2397332d9 Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 2 Oct 2026 15:06:27 -0500 Subject: [PATCH 07/27] chore(release): bump to 0.1.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8b24614..8a1b466 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.5", + "version": "0.1.6", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index 347d9c7..33314bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.5", + "version": "0.1.6", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", From 1f001dc30634ff67271dd1426e47d3bf89814522 Mon Sep 17 00:00:00 2001 From: avi Date: Sun, 4 Oct 2026 10:03:04 -0500 Subject: [PATCH 08/27] fix(updater): narrate the handoff to ~/.config/Folio/update.log + safe tree flatten - installer script now logs every step (wait, extract, flatten, swap, relaunch) to a persistent log so a failed update is diagnosable after the fact - flatten the electron-builder nested dir by adopting the dir itself; the old 'mv NEW/* ' glob missed dotfiles and could leave a half-moved tree (prime suspect for the two in-app update failures) - mkdir failure aborts with relaunch of the old app instead of falling through --- src/main/index.ts | 4 ++++ src/main/update.ts | 30 +++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 727e998..4bf7926 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1254,6 +1254,9 @@ handleIpc("folio:performUpdate", async () => { app.getPath("temp"), `folio-update-${process.pid}.sh` ); + // Persistent, append-only handoff log: survives the swap and tells us + // exactly where a failed update stopped (temp files can vanish). + const updateLog = path.join(app.getPath("userData"), "update.log"); fs.writeFileSync( script, buildInstallerScript({ @@ -1261,6 +1264,7 @@ handleIpc("folio:performUpdate", async () => { tarball, installDir: cfg.installDir, relaunch: cfg.launcher, + log: updateLog, }), { mode: 0o700 } ); diff --git a/src/main/update.ts b/src/main/update.ts index 1382ab8..738245b 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -163,6 +163,8 @@ export interface InstallerSpec { tarball: string; installDir: string; relaunch: string; + /** Optional append-only log; the script narrates every step into it. */ + log?: string; } // The handoff script. Deliberately forgiving (no set -e): on any failure it @@ -170,7 +172,10 @@ export interface InstallerSpec { // user without an app. The old tree is kept as .old. and pruned only // after a successful swap. export function buildInstallerScript(spec: InstallerSpec): string { - const { pid, tarball, installDir, relaunch } = spec; + const { pid, tarball, installDir, relaunch, log } = spec; + const logLine = log + ? `say() { echo "$(date '+%F %T') $*" >> ${shQuote(log)} 2>/dev/null; }` + : "say() { :; }"; return [ "#!/bin/sh", "# Folio updater — waits for the app to exit, swaps in the new version,", @@ -181,29 +186,44 @@ export function buildInstallerScript(spec: InstallerSpec): string { `RELAUNCH=${shQuote(relaunch)}`, `NEW="$INSTALL.update-new.$$"`, `OLD="$INSTALL.old.$$"`, + logLine, + 'say "handoff start pid=$PID tarball=$TARBALL"', "# Wait (bounded to ~2 minutes) for the app process to exit.", "i=0", 'while [ "$PID" -gt 0 ] 2>/dev/null && kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done', + 'kill -0 "$PID" 2>/dev/null && say "WARN: app still alive after wait window" || say "app exited after ${i} polls"', 'rm -rf "$NEW"', - 'mkdir -p "$NEW"', + 'mkdir -p "$NEW" || { say "FAIL: mkdir $NEW"; "$RELAUNCH" >/dev/null 2>&1 & exit 1; }', 'if tar -xzf "$TARBALL" -C "$NEW"; then', - ' # electron-builder nests everything under a top-level dir; flatten it.', + ' say "extracted ok"', + ' # electron-builder nests everything under a top-level dir; flatten it', + " # by adopting the nested dir itself (mv of the dir, not a glob — globs", + " # miss dotfiles and a partial mv leaves a broken tree).", ' if [ ! -e "$NEW/folio" ]; then', ' ONLY=$(find "$NEW" -mindepth 1 -maxdepth 1 -type d | head -n 1)', - ' [ -n "$ONLY" ] && mv "$ONLY"/* "$NEW"/ 2>/dev/null', + ' REST=$(find "$NEW" -mindepth 1 -maxdepth 1 | wc -l)', + ' if [ -n "$ONLY" ] && [ "$REST" -eq 1 ]; then', + ' mv "$ONLY" "$NEW.new" && rm -rf "$NEW" && mv "$NEW.new" "$NEW" && say "flattened nested dir"', + " fi", " fi", + "else", + ' say "FAIL: tar extract failed"', "fi", 'if [ -x "$NEW/folio" ]; then', - ' [ -e "$INSTALL" ] && mv "$INSTALL" "$OLD"', + ' [ -e "$INSTALL" ] && mv "$INSTALL" "$OLD" && say "moved old install aside"', ' if mv "$NEW" "$INSTALL"; then', + ' say "SWAP OK — new version installed"', ' rm -rf "$OLD" "$INSTALL.update-new".* "$INSTALL.old".* 2>/dev/null', ' rm -f "$TARBALL"', " else", + ' say "FAIL: mv NEW->INSTALL; restoring old"', ' [ -e "$OLD" ] && mv "$OLD" "$INSTALL"', " fi", "else", + ' say "FAIL: no executable at $NEW/folio; keeping old install"', ' rm -rf "$NEW"', "fi", + 'say "relaunching"', '"$RELAUNCH" >/dev/null 2>&1 &', "", ].join("\n"); From e794f241ba7b709211b0d9d24145fb6f9a2494a3 Mon Sep 17 00:00:00 2001 From: avi Date: Sun, 4 Oct 2026 10:03:04 -0500 Subject: [PATCH 09/27] chore(release): bump to 0.1.7 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8a1b466..15f954e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.6", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.6", + "version": "0.1.7", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index 33314bc..158e4e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.6", + "version": "0.1.7", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", From 283bb7c77294a00a35c9b13a1d5541b0304b02d2 Mon Sep 17 00:00:00 2001 From: avi Date: Sun, 4 Oct 2026 11:34:10 -0500 Subject: [PATCH 10/27] =?UTF-8?q?feat(settings):=20the=20storage=20folder?= =?UTF-8?q?=20now=20decides=20where=20books=20are=20saved=20=E2=80=94=20ne?= =?UTF-8?q?w=20books,=20.md=20imports,=20and=20the=20export=20dialog=20def?= =?UTF-8?q?ault=20all=20land=20there=20(was:=20always=20Documents);=20labe?= =?UTF-8?q?l=20clarifies=20it=20covers=20books=20+=20snapshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 36 ++++++++++++++++++++++++++--------- src/renderer/book/settings.ts | 8 ++++++-- src/renderer/index.html | 6 +++--- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 4bf7926..24cd5fa 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -246,13 +246,30 @@ async function openBookAt(p: string): Promise { } } -// Create a new book as a dedicated folder / inside the user's Documents -// 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. +// Create a new book as a dedicated folder / inside the user's chosen +// storage folder (Settings → Folder), falling back to Documents when none is +// set or the stored one no longer exists, then open it. The folder name is +// the sanitized book title; if a folder with that name already exists we +// append a numeric suffix. +function getBooksDir(): string { + // One storage folder drives everything (snapshots, recovery, new books). + // snapshotLocation is the one the Settings modal writes; backupLocation is + // the older key — honor both so an existing choice keeps working. + for (const key of ["snapshotLocation", "backupLocation"]) { + const v = String(getSetting(key, "") || "").trim(); + try { + if (v && fs.statSync(v).isDirectory()) return v; + } catch { + // stale or unreadable path — fall through to the next candidate + } + } + return app.getPath("documents"); +} + handleIpc("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 base = getBooksDir(); const folder = sanitizeBookFolderName(title) || "Untitled"; let target = path.join(base, folder); let n = 2; @@ -591,7 +608,7 @@ function exportDefaultPath(filename: string): string { const dir = last && fs.existsSync(last) && fs.statSync(last).isDirectory() ? last - : app.getPath("documents"); + : getBooksDir(); return path.join(dir, filename); } @@ -972,6 +989,7 @@ function snapshotSettingsSnapshot() { keep: clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200), location, resolvedRoot: resolved, + booksDir: getBooksDir(), snapshotCount: snaps.length, lastSnapshot: snaps.length ? snaps[snaps.length - 1] : null, }; @@ -1008,7 +1026,7 @@ handleIpc("folio:setSnapshotSettings", (_e, patch: unknown) => { handleIpc("folio:pickSnapshotFolder", async () => { const { canceled, filePaths } = await showOpenDialog({ properties: ["openDirectory"], - title: "Choose snapshot folder", + title: "Choose storage folder (new books + snapshots)", }); return { canceled, filePaths }; }); @@ -1089,14 +1107,14 @@ handleIpc("folio:importMarkdown", async () => { filters: [{ name: "Markdown and text", extensions: ["md", "markdown", "txt"] }], }); if (canceled || !filePaths.length) return { canceled: true } as const; - return importMarkdownFiles(filePaths, app.getPath("documents")); + return importMarkdownFiles(filePaths, getBooksDir()); }); // Open a path that may be either a Folio book directory or a Markdown file // (the desktop launcher can be handed either via file associations). handleIpc("folio:openPathOrImport", async (_e, p: string) => { if (p && isImportableMarkdown(p)) { - const results = importMarkdownFiles([p], app.getPath("documents")); + const results = importMarkdownFiles([p], getBooksDir()); const r = results[0]; if (!r.ok) return { error: r.error }; return openBookAt(r.bookPath as string); @@ -1300,7 +1318,7 @@ function fileArgFrom(argv: string[]): string | null { function openFromArg(p: string): void { if (isImportableMarkdown(p)) { - const results = importMarkdownFiles([p], app.getPath("documents")); + const results = importMarkdownFiles([p], getBooksDir()); const r = results[0]; if (r.ok) { openBookAt(r.bookPath as string); diff --git a/src/renderer/book/settings.ts b/src/renderer/book/settings.ts index c9adad1..9cb3949 100644 --- a/src/renderer/book/settings.ts +++ b/src/renderer/book/settings.ts @@ -23,6 +23,8 @@ interface SnapshotSettings { keep: number; location: string; resolvedRoot: string; + /** Where new books are saved (Settings → Storage folder). */ + booksDir?: string; snapshotCount: number; lastSnapshot: string | null; } @@ -59,8 +61,10 @@ export function createSettingsFeature(ctx: BookCtx): SettingsFeature { intervalInput.value = String(s.intervalMin ?? 5); keepInput.value = String(s.keep ?? 20); locationInput.value = s.location || ""; - locationInput.placeholder = "Default: /Folio-Backups"; - resolvedEl.textContent = `Saving to: ${shortPath(s.resolvedRoot || "—")}`; + locationInput.placeholder = "Default: Documents (books) · /Folio-Backups (snapshots)"; + resolvedEl.textContent = s.booksDir + ? `New books save to: ${shortPath(s.booksDir)}` + : `Saving to: ${shortPath(s.resolvedRoot || "—")}`; lastSnapEl.textContent = s.lastSnapshot ? `${s.snapshotCount} snapshot${s.snapshotCount === 1 ? "" : "s"} · latest ${s.lastSnapshot.split("/").pop()}` : "No snapshots yet."; diff --git a/src/renderer/index.html b/src/renderer/index.html index d41d81b..8bb16c8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -343,9 +343,9 @@
- Folder - - + Storage folder + +

From c064e3aab1dc58d82308107b98632fef605615aa Mon Sep 17 00:00:00 2001 From: avi Date: Sun, 4 Oct 2026 11:34:10 -0500 Subject: [PATCH 11/27] chore(release): bump to 0.1.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 158e4e3..a47c543 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.7", + "version": "0.1.8", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", From 12977e1a77d38a9e2918a5ad5eb5fb8c480ef345 Mon Sep 17 00:00:00 2001 From: avi Date: Sun, 4 Oct 2026 13:09:55 -0500 Subject: [PATCH 12/27] fix(updater): run the handoff script via systemd-run so it survives the app's scope On uwsm/Hyprland the app runs in a systemd scope cgroup; a detached child is swept away when the app quits, which killed the swap mid-flight on every in-app update attempt. systemd-run gives the installer its own transient unit that outlives the scope; detached spawn stays as the non-systemd fallback. --- package-lock.json | 4 ++-- src/main/index.ts | 38 +++++++++++++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 15f954e..5c27704 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.7", + "version": "0.1.8", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/src/main/index.ts b/src/main/index.ts index 24cd5fa..80749cd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1286,12 +1286,40 @@ handleIpc("folio:performUpdate", async () => { }), { mode: 0o700 } ); + // IMPORTANT: on systemd desktops (Hyprland/uwsm) the app runs inside a + // systemd scope cgroup, and a plain detached child stays in that cgroup — + // when the app quits, systemd sweeps the scope and kills the swap script + // mid-run (root cause of the "update failed" reports: the handoff died + // before swapping, and the app was relaunched from the old tree). + // systemd-run puts the script in its own transient unit that outlives us; + // verified to survive on this machine. Fall back to a detached spawn for + // non-systemd sessions. const { spawn } = await import("child_process"); - const child = spawn("/bin/sh", [script], { detached: true, stdio: "ignore" }); - child.on("error", (err) => { - console.error("[folio] updater spawn failed:", err.message); - }); - child.unref(); + let handedOff = false; + try { + const { execFileSync } = await import("child_process"); + execFileSync("systemd-run", [ + "--user", "--quiet", "--collect", + `--description=Folio updater handoff`, + "/bin/sh", script, + ], { timeout: 10_000, stdio: "ignore" }); + handedOff = true; + } catch { + try { + const child = spawn("/bin/sh", [script], { detached: true, stdio: "ignore" }); + child.on("error", (err) => { + console.error("[folio] updater spawn failed:", err.message); + }); + child.unref(); + handedOff = true; + } catch (e) { + void e; + } + } + if (!handedOff) { + try { fs.appendFileSync(updateLog, new Date().toISOString() + " FAIL: could not start swap script\n"); } catch { /* ignore */ } + return { error: "Could not start the installer. Your current version is unchanged." }; + } // Quit so the swap script can replace the app directory. setTimeout(() => app.quit(), 300); return { ok: true, version: rel.version }; From 3e0c7f69bfe155bb096c382a94d24a715c3a456f Mon Sep 17 00:00:00 2001 From: avi Date: Sun, 4 Oct 2026 13:09:55 -0500 Subject: [PATCH 13/27] chore(release): bump to 0.1.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a47c543..e818696 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.8", + "version": "0.1.9", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", From 0589dd03961ba13612e612e669d8ad1bcfa2a8e0 Mon Sep 17 00:00:00 2001 From: avi Date: Sun, 4 Oct 2026 13:11:44 -0500 Subject: [PATCH 14/27] chore(release): bump to 0.1.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e818696..afb7897 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.9", + "version": "0.1.10", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", From 9afe49c378d4259d8cdf9a50489334a9c4676a24 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 5 Oct 2026 00:25:03 -0500 Subject: [PATCH 15/27] fix(updater): download via curl with resume instead of Chromium fetch Chromium's network stack restarts requests mid-stream on flaky Wi-Fi, yielding a right-sized but corrupt gzip (progress hits 100% three times, then 'archive corrupt'). curl resumes with HTTP Range so a dropped connection picks up where it stopped, retries internally, and the whole file is still byte-count + gunzip verified before handoff. Falls back to the fetch pipeline when curl is absent. --- package-lock.json | 4 +- src/main/index.ts | 109 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 83 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5c27704..6feb28d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.8", + "version": "0.1.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.8", + "version": "0.1.10", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/src/main/index.ts b/src/main/index.ts index 80749cd..e666950 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1220,46 +1220,100 @@ handleIpc("folio:performUpdate", async () => { app.getPath("temp"), `folio-update-${Date.now()}.tar.gz` ); - // Downloads of the ~100MB asset have twice truncated near the end while - // the server copy stayed intact. The old 2-byte magic check could not - // see that, so the detached installer failed after the app had quit and - // the update silently no-oped. Now: retry the download up to 3 times, - // require the advertised byte count, and gunzip the whole file before - // handing off. A corrupt file is deleted and the user gets a real error. - const { Readable } = await import("stream"); - const { createWriteStream } = await import("fs"); - const { pipeline } = await import("stream/promises"); + // Downloads go through curl, not Chromium's network stack: on flaky Wi-Fi + // the Electron net stack restarts requests mid-stream, producing a + // right-sized but corrupt gzip ("100% three times, then failed"). curl + // resumes with HTTP Range (-C -) so a dropped connection continues where + // it left off instead of restarting, and retries internally. We still + // verify the exact byte count and gunzip the whole file before handoff. const total = Number(asset.size) || 0; let lastError = ""; let downloaded = false; - for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) { - try { - const res = await fetch(asset.browser_download_url, { - redirect: "follow", - signal: AbortSignal.timeout(10 * 60 * 1000), + const { spawn } = await import("child_process"); + const haveCurl = await new Promise((resolve) => { + const p = spawn("curl", ["--version"], { stdio: "ignore" }); + p.on("error", () => resolve(false)); + p.on("close", (code) => resolve(code === 0)); + }); + if (haveCurl) { + for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) { + const code = await new Promise((resolve) => { + const c = spawn( + "curl", + [ + "-fsSL", + "-C", "-", // resume a partial file + "--max-time", "900", + "--retry", "5", "--retry-delay", "2", "--retry-all-errors", + "--connect-timeout", "15", + "-o", tarball, + asset.browser_download_url, + ], + { stdio: "ignore" } + ); + const timer = setInterval(() => { + try { + getWindow()?.webContents.send("folio:update-progress", { + received: fs.statSync(tarball).size, + total, + }); + } catch { + /* file not created yet */ + } + }, 1000); + c.on("error", () => { clearInterval(timer); resolve(-1); }); + c.on("close", (rc) => { clearInterval(timer); resolve(rc ?? -1); }); }); - if (!res.ok || !res.body) { - lastError = `Download failed (HTTP ${res.status}).`; + if (code !== 0) { + lastError = `Download failed (curl exit ${code}).`; continue; } - let received = 0; - const source = Readable.fromWeb(res.body as never); - source.on("data", (chunk: Buffer) => { - received += chunk.length; - getWindow()?.webContents.send("folio:update-progress", { received, total }); - }); - await pipeline(source, createWriteStream(tarball)); - if (looksTruncated(received, total)) { - lastError = `Download truncated (${received}/${total} bytes).`; + const size = fs.existsSync(tarball) ? fs.statSync(tarball).size : 0; + if (looksTruncated(size, total)) { + lastError = `Download truncated (${size}/${total} bytes).`; continue; } if (!(await verifyArchiveGzip(tarball))) { lastError = "Downloaded archive is corrupt (gzip stream incomplete)."; + fs.rmSync(tarball, { force: true }); // corrupted resume file must not poison the next attempt continue; } downloaded = true; - } catch (e) { - lastError = `Download failed: ${(e as Error).message}`; + } + } else { + // No curl on PATH: fall back to the in-process fetch pipeline. + const { Readable } = await import("stream"); + const { createWriteStream } = await import("fs"); + const { pipeline } = await import("stream/promises"); + for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) { + try { + const res = await fetch(asset.browser_download_url, { + redirect: "follow", + signal: AbortSignal.timeout(10 * 60 * 1000), + }); + if (!res.ok || !res.body) { + lastError = `Download failed (HTTP ${res.status}).`; + continue; + } + let received = 0; + const source = Readable.fromWeb(res.body as never); + source.on("data", (chunk: Buffer) => { + received += chunk.length; + getWindow()?.webContents.send("folio:update-progress", { received, total }); + }); + await pipeline(source, createWriteStream(tarball)); + if (looksTruncated(received, total)) { + lastError = `Download truncated (${received}/${total} bytes).`; + continue; + } + if (!(await verifyArchiveGzip(tarball))) { + lastError = "Downloaded archive is corrupt (gzip stream incomplete)."; + continue; + } + downloaded = true; + } catch (e) { + lastError = `Download failed: ${(e as Error).message}`; + } } } if (!downloaded) { @@ -1294,7 +1348,6 @@ handleIpc("folio:performUpdate", async () => { // systemd-run puts the script in its own transient unit that outlives us; // verified to survive on this machine. Fall back to a detached spawn for // non-systemd sessions. - const { spawn } = await import("child_process"); let handedOff = false; try { const { execFileSync } = await import("child_process"); From f3cd2274a61a25933867a3dfc900ec983e643e5f Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 5 Oct 2026 00:25:03 -0500 Subject: [PATCH 16/27] chore(release): bump to 0.1.11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index afb7897..d91eb6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.10", + "version": "0.1.11", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", From cabf6400e7b6e3e1846bf537b46276193d8c3a29 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 5 Oct 2026 00:26:05 -0500 Subject: [PATCH 17/27] chore(release): bump to 0.1.12 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d91eb6b..c5472ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.11", + "version": "0.1.12", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", From b1586c5e250aef843df93328411147fa84d47ac4 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 5 Oct 2026 01:33:17 -0500 Subject: [PATCH 18/27] chore: sync lockfile version to 0.1.12 --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6feb28d..656e3fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.10", + "version": "0.1.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.10", + "version": "0.1.12", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", From bb758580d7732467a7f64742abc48a59afea6a5b Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 5 Oct 2026 06:28:41 -0500 Subject: [PATCH 19/27] fix(build): prepackage hook rebuilds dist before electron-builder Root cause of 0.1.8-0.1.12 shipping stale code: electron-builder packs whatever dist/ happens to contain; dist was last built 2026-10-04 10:02, so releases 0.1.8..0.1.12 all carried byte-identical bundles that predate the systemd-run handoff fix (12977e1) and the curl downloader (9afe49c). prepackage guarantees a fresh build on every npm run package. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index c5472ce..56ffb18 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "start": "npm run build && npm run electron", "lint": "tsc --noEmit", + "prepackage": "npm run build", "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs && node tests/run-update-import-test.mjs && node tests/run-update-installer-test.mjs && node tests/run-snapshots-test.mjs && node tests/run-search-test.mjs", "package": "electron-builder" }, From 173a9799d0014fad624d67ef7d5bef73c298831e Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 5 Oct 2026 06:28:47 -0500 Subject: [PATCH 20/27] chore(release): bump to 0.1.13 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 656e3fc..d051b88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.12", + "version": "0.1.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.12", + "version": "0.1.13", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index 56ffb18..52e63b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.12", + "version": "0.1.13", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", From ea2ca08efd6c5237b6b1279016fc704ea38eb585 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 5 Oct 2026 10:34:11 -0500 Subject: [PATCH 21/27] chore(release): bump to 0.1.14 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d051b88..f0c51fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.13", + "version": "0.1.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.13", + "version": "0.1.14", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index 52e63b2..099acf8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.13", + "version": "0.1.14", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", From 44b4737a20cb56fcd2d9e67ef3a9ed7b49cd4793 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 5 Oct 2026 10:42:41 -0500 Subject: [PATCH 22/27] fix(build): emit linux x86_64 tar.gz on every package run The in-app updater installs the release asset matched by pickAsset (tar.gz + linux + x86_64/amd64); the config only built AppImage+deb, so plain 'npm run package' produced no updatable asset and required a second electron-builder invocation plus a manual rename (as done for 0.1.13). --- electron-builder.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/electron-builder.yml b/electron-builder.yml index d8f3b67..4abacd6 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -21,6 +21,11 @@ linux: target: - AppImage - deb + # tar.gz is what the in-app updater installs (pickAsset looks for a + # linux x86_64/amd64 tarball); arch must be x64 so the asset name ends + # in -linux-x86_64 like every published release. + - target: tar.gz + arch: x64 # OS file associations: double-click / "Open with Folio" on Markdown and # plain-text files imports them as books (see folio:openPathOrImport and From ced074a3a05c1c8eb3bb44070a04b7714e4a2229 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 5 Oct 2026 17:48:04 -0500 Subject: [PATCH 23/27] fix(single-instance): quit when the lock is lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestSingleInstanceLock() without a quit on failure means the second process falls through to whenReady() and opens a duplicate window (what Avi saw: two Folios from one icon click). Now the loser forwards to the running window (second-instance) and exits. Pairs with the launcher TMPDIR pin — uwsm per-scope /tmp made the singleton socket invisible, so the lock never even failed before. --- src/main/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/index.ts b/src/main/index.ts index e666950..3596ddb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1423,4 +1423,8 @@ if (app.requestSingleInstanceLock()) { } if (f) openFromArg(f); }); +} else { + // Lost the lock: another Folio is already running. Without this quit the + // second process falls through to whenReady() and opens a duplicate window. + app.quit(); } From 328b24d5a64e7d55bb02b8ebc98cb84a12c5ce81 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 5 Oct 2026 17:48:04 -0500 Subject: [PATCH 24/27] chore(release): bump to 0.1.15 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f0c51fa..4e45214 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.14", + "version": "0.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.14", + "version": "0.1.15", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index 099acf8..85cbb3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.14", + "version": "0.1.15", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", From aebfa2630883203417801415c0a40699323f9d15 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 5 Oct 2026 18:11:24 -0500 Subject: [PATCH 25/27] fix(updater): relaunch survives the handoff unit's cgroup sweep v0.1.15 update proved the pipeline end-to-end (download, verify, swap OK) but the app never reopened: the handoff runs in a systemd transient unit whose default KillMode=control-group sweeps the process tree when the script exits, killing the backgrounded relaunch before Electron started. Two fixes: start the unit with KillMode=process, and setsid the relaunch (nohup fallback) so it detaches from the unit session entirely. --- src/main/index.ts | 5 +++++ src/main/update.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index 3596ddb..c9557a1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1354,6 +1354,11 @@ handleIpc("folio:performUpdate", async () => { execFileSync("systemd-run", [ "--user", "--quiet", "--collect", `--description=Folio updater handoff`, + // Default KillMode=control-group means the unit's cgroup is swept + // when the script exits — that killed the backgrounded relaunch + // before Electron got to start (v0.1.15 update: swap OK, app died). + // KillMode=process lets the script exit while its child app lives. + "--property=KillMode=process", "/bin/sh", script, ], { timeout: 10_000, stdio: "ignore" }); handedOff = true; diff --git a/src/main/update.ts b/src/main/update.ts index 738245b..9493643 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -224,7 +224,14 @@ export function buildInstallerScript(spec: InstallerSpec): string { ' rm -rf "$NEW"', "fi", 'say "relaunching"', - '"$RELAUNCH" >/dev/null 2>&1 &', + // Detach fully: setsid moves the relaunch into its own session so it + // survives this script's exit and any cgroup sweep of the transient + // unit (systemd KillMode). Fallback chain for missing setsid. + 'if command -v setsid >/dev/null 2>&1; then', + ' setsid "$RELAUNCH" >/dev/null 2>&1 < /dev/null &', + 'else', + ' nohup "$RELAUNCH" >/dev/null 2>&1 < /dev/null &', + "fi", "", ].join("\n"); } From 6c8b82940a0a7cab181c21f96c26af21bcd652c6 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 5 Oct 2026 18:11:24 -0500 Subject: [PATCH 26/27] chore(release): bump to 0.1.16 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4e45214..beef7ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.15", + "version": "0.1.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.15", + "version": "0.1.16", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index 85cbb3f..88f1ca5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.15", + "version": "0.1.16", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", From 67ca9cbb768c1b73c6a2bce294ce82372206bbaf Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 5 Oct 2026 19:03:41 -0500 Subject: [PATCH 27/27] chore(release): bump to 0.1.17 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index beef7ee..6b4b766 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.16", + "version": "0.1.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.16", + "version": "0.1.17", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index 88f1ca5..3ffd369 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.16", + "version": "0.1.17", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors",