From 283bb7c77294a00a35c9b13a1d5541b0304b02d2 Mon Sep 17 00:00:00 2001 From: avi Date: Sun, 4 Oct 2026 11:34:10 -0500 Subject: [PATCH 01/18] =?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 02/18] 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 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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 08/18] 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 09/18] 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 10/18] 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 11/18] 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 12/18] 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 13/18] 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 14/18] 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 15/18] 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 16/18] 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 17/18] 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 18/18] 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",