Compare commits

...

22 commits

Author SHA1 Message Date
avi
67ca9cbb76 chore(release): bump to 0.1.17 2026-10-05 19:03:41 -05:00
avi
6c8b82940a chore(release): bump to 0.1.16 2026-10-05 18:11:24 -05:00
avi
aebfa26308 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.
2026-10-05 18:11:24 -05:00
avi
328b24d5a6 chore(release): bump to 0.1.15 2026-10-05 17:48:04 -05:00
avi
ced074a3a0 fix(single-instance): quit when the lock is lost
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.
2026-10-05 17:48:04 -05:00
avi
44b4737a20 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).
2026-10-05 10:42:41 -05:00
avi
ea2ca08efd chore(release): bump to 0.1.14 2026-10-05 10:34:11 -05:00
avi
173a9799d0 chore(release): bump to 0.1.13 2026-10-05 06:28:47 -05:00
avi
bb758580d7 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.
2026-10-05 06:28:41 -05:00
avi
b1586c5e25 chore: sync lockfile version to 0.1.12 2026-10-05 01:33:17 -05:00
avi
cabf6400e7 chore(release): bump to 0.1.12 2026-10-05 00:26:05 -05:00
avi
f3cd2274a6 chore(release): bump to 0.1.11 2026-10-05 00:25:03 -05:00
avi
9afe49c378 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.
2026-10-05 00:25:03 -05:00
avi
0589dd0396 chore(release): bump to 0.1.10 2026-10-04 13:11:44 -05:00
avi
3e0c7f69bf chore(release): bump to 0.1.9 2026-10-04 13:09:55 -05:00
avi
12977e1a77 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.
2026-10-04 13:09:55 -05:00
avi
c064e3aab1 chore(release): bump to 0.1.8 2026-10-04 11:34:10 -05:00
avi
283bb7c772 feat(settings): the storage folder now decides where books are saved — new books, .md imports, and the export dialog default all land there (was: always Documents); label clarifies it covers books + snapshots 2026-10-04 11:34:10 -05:00
avi
e794f241ba chore(release): bump to 0.1.7 2026-10-04 10:03:04 -05:00
avi
1f001dc306 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
2026-10-04 10:03:04 -05:00
avi
9a1202f0fc chore(release): bump to 0.1.6 2026-10-02 15:06:27 -05:00
avi
14cad71688 feat(recents): right-click (two-finger tap) on a recents row opens a book menu — Open, Rename, Show in Folder, Hide/Unhide, Delete (with confirm); renderer claims the event synchronously so the generic spelling/edit popup is skipped for recents rows 2026-10-02 15:06:27 -05:00
10 changed files with 378 additions and 57 deletions

View file

@ -21,6 +21,11 @@ linux:
target: target:
- AppImage - AppImage
- deb - 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 # OS file associations: double-click / "Open with Folio" on Markdown and
# plain-text files imports them as books (see folio:openPathOrImport and # plain-text files imports them as books (see folio:openPathOrImport and

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "folio", "name": "folio",
"version": "0.1.5", "version": "0.1.17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "folio", "name": "folio",
"version": "0.1.5", "version": "0.1.17",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tiptap/core": "^2.27.2", "@tiptap/core": "^2.27.2",

View file

@ -1,6 +1,6 @@
{ {
"name": "folio", "name": "folio",
"version": "0.1.5", "version": "0.1.17",
"description": "A simple, local, open-source desktop writing app.", "description": "A simple, local, open-source desktop writing app.",
"productName": "Folio", "productName": "Folio",
"author": "Folio Contributors", "author": "Folio Contributors",
@ -15,6 +15,7 @@
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
"start": "npm run build && npm run electron", "start": "npm run build && npm run electron",
"lint": "tsc --noEmit", "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", "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" "package": "electron-builder"
}, },

View file

@ -246,13 +246,30 @@ async function openBookAt(p: string): Promise<BookResult | ErrResult> {
} }
} }
// Create a new book as a dedicated folder <name>/ inside the user's Documents // Create a new book as a dedicated folder <name>/ inside the user's chosen
// directory, then open it. The folder name is the sanitized book title; if a // storage folder (Settings → Folder), falling back to Documents when none is
// folder with that name already exists we append a numeric suffix. // 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<string>(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) => { handleIpc("folio:newBookNamed", async (_e, name: string) => {
const title = (name || "").trim(); const title = (name || "").trim();
if (!title) return { error: "Please provide a book name." }; if (!title) return { error: "Please provide a book name." };
const base = app.getPath("documents"); const base = getBooksDir();
const folder = sanitizeBookFolderName(title) || "Untitled"; const folder = sanitizeBookFolderName(title) || "Untitled";
let target = path.join(base, folder); let target = path.join(base, folder);
let n = 2; let n = 2;
@ -340,6 +357,70 @@ handleIpc("folio:pickBackupFolder", async () => {
handleIpc("folio:getRecents", (): Recent[] => listRecents()); 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 // Rename a book's display title (folio.json + matching recents entry). The
// folder on disk keeps its name: renaming the directory would invalidate // 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 // every other recents/menu path that points at it, so the title is the only
@ -527,7 +608,7 @@ function exportDefaultPath(filename: string): string {
const dir = const dir =
last && fs.existsSync(last) && fs.statSync(last).isDirectory() last && fs.existsSync(last) && fs.statSync(last).isDirectory()
? last ? last
: app.getPath("documents"); : getBooksDir();
return path.join(dir, filename); return path.join(dir, filename);
} }
@ -795,6 +876,12 @@ function wireContextMenu(win: BrowserWindow) {
win.webContents.on("context-menu", (_e, params) => { win.webContents.on("context-menu", (_e, params) => {
const target = getWindow(); const target = getWindow();
if (!target) return; 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[] = []; const items: Electron.MenuItemConstructorOptions[] = [];
if (params.misspelledWord) { if (params.misspelledWord) {
const sugs = (params.dictionarySuggestions || []).slice(0, 5); const sugs = (params.dictionarySuggestions || []).slice(0, 5);
@ -902,6 +989,7 @@ function snapshotSettingsSnapshot() {
keep: clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200), keep: clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200),
location, location,
resolvedRoot: resolved, resolvedRoot: resolved,
booksDir: getBooksDir(),
snapshotCount: snaps.length, snapshotCount: snaps.length,
lastSnapshot: snaps.length ? snaps[snaps.length - 1] : null, lastSnapshot: snaps.length ? snaps[snaps.length - 1] : null,
}; };
@ -938,7 +1026,7 @@ handleIpc("folio:setSnapshotSettings", (_e, patch: unknown) => {
handleIpc("folio:pickSnapshotFolder", async () => { handleIpc("folio:pickSnapshotFolder", async () => {
const { canceled, filePaths } = await showOpenDialog({ const { canceled, filePaths } = await showOpenDialog({
properties: ["openDirectory"], properties: ["openDirectory"],
title: "Choose snapshot folder", title: "Choose storage folder (new books + snapshots)",
}); });
return { canceled, filePaths }; return { canceled, filePaths };
}); });
@ -1019,14 +1107,14 @@ handleIpc("folio:importMarkdown", async () => {
filters: [{ name: "Markdown and text", extensions: ["md", "markdown", "txt"] }], filters: [{ name: "Markdown and text", extensions: ["md", "markdown", "txt"] }],
}); });
if (canceled || !filePaths.length) return { canceled: true } as const; 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 // 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). // (the desktop launcher can be handed either via file associations).
handleIpc("folio:openPathOrImport", async (_e, p: string) => { handleIpc("folio:openPathOrImport", async (_e, p: string) => {
if (p && isImportableMarkdown(p)) { if (p && isImportableMarkdown(p)) {
const results = importMarkdownFiles([p], app.getPath("documents")); const results = importMarkdownFiles([p], getBooksDir());
const r = results[0]; const r = results[0];
if (!r.ok) return { error: r.error }; if (!r.ok) return { error: r.error };
return openBookAt(r.bookPath as string); return openBookAt(r.bookPath as string);
@ -1132,18 +1220,71 @@ handleIpc("folio:performUpdate", async () => {
app.getPath("temp"), app.getPath("temp"),
`folio-update-${Date.now()}.tar.gz` `folio-update-${Date.now()}.tar.gz`
); );
// Downloads of the ~100MB asset have twice truncated near the end while // Downloads go through curl, not Chromium's network stack: on flaky Wi-Fi
// the server copy stayed intact. The old 2-byte magic check could not // the Electron net stack restarts requests mid-stream, producing a
// see that, so the detached installer failed after the app had quit and // right-sized but corrupt gzip ("100% three times, then failed"). curl
// the update silently no-oped. Now: retry the download up to 3 times, // resumes with HTTP Range (-C -) so a dropped connection continues where
// require the advertised byte count, and gunzip the whole file before // it left off instead of restarting, and retries internally. We still
// handing off. A corrupt file is deleted and the user gets a real error. // verify the exact byte count and gunzip the whole file before handoff.
const { Readable } = await import("stream");
const { createWriteStream } = await import("fs");
const { pipeline } = await import("stream/promises");
const total = Number(asset.size) || 0; const total = Number(asset.size) || 0;
let lastError = ""; let lastError = "";
let downloaded = false; let downloaded = false;
const { spawn } = await import("child_process");
const haveCurl = await new Promise<boolean>((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<number>((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 (code !== 0) {
lastError = `Download failed (curl exit ${code}).`;
continue;
}
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;
}
} 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++) { for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) {
try { try {
const res = await fetch(asset.browser_download_url, { const res = await fetch(asset.browser_download_url, {
@ -1174,6 +1315,7 @@ handleIpc("folio:performUpdate", async () => {
lastError = `Download failed: ${(e as Error).message}`; lastError = `Download failed: ${(e as Error).message}`;
} }
} }
}
if (!downloaded) { if (!downloaded) {
fs.rmSync(tarball, { force: true }); fs.rmSync(tarball, { force: true });
return { return {
@ -1184,6 +1326,9 @@ handleIpc("folio:performUpdate", async () => {
app.getPath("temp"), app.getPath("temp"),
`folio-update-${process.pid}.sh` `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( fs.writeFileSync(
script, script,
buildInstallerScript({ buildInstallerScript({
@ -1191,15 +1336,48 @@ handleIpc("folio:performUpdate", async () => {
tarball, tarball,
installDir: cfg.installDir, installDir: cfg.installDir,
relaunch: cfg.launcher, relaunch: cfg.launcher,
log: updateLog,
}), }),
{ mode: 0o700 } { mode: 0o700 }
); );
const { spawn } = await import("child_process"); // 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.
let handedOff = false;
try {
const { execFileSync } = await import("child_process");
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;
} catch {
try {
const child = spawn("/bin/sh", [script], { detached: true, stdio: "ignore" }); const child = spawn("/bin/sh", [script], { detached: true, stdio: "ignore" });
child.on("error", (err) => { child.on("error", (err) => {
console.error("[folio] updater spawn failed:", err.message); console.error("[folio] updater spawn failed:", err.message);
}); });
child.unref(); 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. // Quit so the swap script can replace the app directory.
setTimeout(() => app.quit(), 300); setTimeout(() => app.quit(), 300);
return { ok: true, version: rel.version }; return { ok: true, version: rel.version };
@ -1226,7 +1404,7 @@ function fileArgFrom(argv: string[]): string | null {
function openFromArg(p: string): void { function openFromArg(p: string): void {
if (isImportableMarkdown(p)) { if (isImportableMarkdown(p)) {
const results = importMarkdownFiles([p], app.getPath("documents")); const results = importMarkdownFiles([p], getBooksDir());
const r = results[0]; const r = results[0];
if (r.ok) { if (r.ok) {
openBookAt(r.bookPath as string); openBookAt(r.bookPath as string);
@ -1250,4 +1428,8 @@ if (app.requestSingleInstanceLock()) {
} }
if (f) openFromArg(f); 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();
} }

View file

@ -7,6 +7,20 @@ contextBridge.exposeInMainWorld("folio", {
renameBook: (p: string, title: string) => ipcRenderer.invoke("folio:renameBook", p, title), renameBook: (p: string, title: string) => ipcRenderer.invoke("folio:renameBook", p, title),
hideRecent: (p: string) => ipcRenderer.invoke("folio:hideRecent", p), hideRecent: (p: string) => ipcRenderer.invoke("folio:hideRecent", p),
unhideRecent: (p: string) => ipcRenderer.invoke("folio:unhideRecent", 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), restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", p),
openBook: () => ipcRenderer.invoke("folio:openBook"), openBook: () => ipcRenderer.invoke("folio:openBook"),
openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p), openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p),

View file

@ -163,6 +163,8 @@ export interface InstallerSpec {
tarball: string; tarball: string;
installDir: string; installDir: string;
relaunch: 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 // 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.<pid> and pruned only // user without an app. The old tree is kept as .old.<pid> and pruned only
// after a successful swap. // after a successful swap.
export function buildInstallerScript(spec: InstallerSpec): string { 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 [ return [
"#!/bin/sh", "#!/bin/sh",
"# Folio updater — waits for the app to exit, swaps in the new version,", "# Folio updater — waits for the app to exit, swaps in the new version,",
@ -181,30 +186,52 @@ export function buildInstallerScript(spec: InstallerSpec): string {
`RELAUNCH=${shQuote(relaunch)}`, `RELAUNCH=${shQuote(relaunch)}`,
`NEW="$INSTALL.update-new.$$"`, `NEW="$INSTALL.update-new.$$"`,
`OLD="$INSTALL.old.$$"`, `OLD="$INSTALL.old.$$"`,
logLine,
'say "handoff start pid=$PID tarball=$TARBALL"',
"# Wait (bounded to ~2 minutes) for the app process to exit.", "# Wait (bounded to ~2 minutes) for the app process to exit.",
"i=0", "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', '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"', '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', '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', ' if [ ! -e "$NEW/folio" ]; then',
' ONLY=$(find "$NEW" -mindepth 1 -maxdepth 1 -type d | head -n 1)', ' 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",
" fi",
"else",
' say "FAIL: tar extract failed"',
"fi", "fi",
'if [ -x "$NEW/folio" ]; then', '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', ' if mv "$NEW" "$INSTALL"; then',
' say "SWAP OK — new version installed"',
' rm -rf "$OLD" "$INSTALL.update-new".* "$INSTALL.old".* 2>/dev/null', ' rm -rf "$OLD" "$INSTALL.update-new".* "$INSTALL.old".* 2>/dev/null',
' rm -f "$TARBALL"', ' rm -f "$TARBALL"',
" else", " else",
' say "FAIL: mv NEW->INSTALL; restoring old"',
' [ -e "$OLD" ] && mv "$OLD" "$INSTALL"', ' [ -e "$OLD" ] && mv "$OLD" "$INSTALL"',
" fi", " fi",
"else", "else",
' say "FAIL: no executable at $NEW/folio; keeping old install"',
' rm -rf "$NEW"', ' rm -rf "$NEW"',
"fi", "fi",
'"$RELAUNCH" >/dev/null 2>&1 &', 'say "relaunching"',
// 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"); ].join("\n");
} }

View file

@ -23,6 +23,8 @@ interface SnapshotSettings {
keep: number; keep: number;
location: string; location: string;
resolvedRoot: string; resolvedRoot: string;
/** Where new books are saved (Settings → Storage folder). */
booksDir?: string;
snapshotCount: number; snapshotCount: number;
lastSnapshot: string | null; lastSnapshot: string | null;
} }
@ -59,8 +61,10 @@ export function createSettingsFeature(ctx: BookCtx): SettingsFeature {
intervalInput.value = String(s.intervalMin ?? 5); intervalInput.value = String(s.intervalMin ?? 5);
keepInput.value = String(s.keep ?? 20); keepInput.value = String(s.keep ?? 20);
locationInput.value = s.location || ""; locationInput.value = s.location || "";
locationInput.placeholder = "Default: <book>/Folio-Backups"; locationInput.placeholder = "Default: Documents (books) · <book>/Folio-Backups (snapshots)";
resolvedEl.textContent = `Saving to: ${shortPath(s.resolvedRoot || "—")}`; resolvedEl.textContent = s.booksDir
? `New books save to: ${shortPath(s.booksDir)}`
: `Saving to: ${shortPath(s.resolvedRoot || "—")}`;
lastSnapEl.textContent = s.lastSnapshot lastSnapEl.textContent = s.lastSnapshot
? `${s.snapshotCount} snapshot${s.snapshotCount === 1 ? "" : "s"} · latest ${s.lastSnapshot.split("/").pop()}` ? `${s.snapshotCount} snapshot${s.snapshotCount === 1 ? "" : "s"} · latest ${s.lastSnapshot.split("/").pop()}`
: "No snapshots yet."; : "No snapshots yet.";

View file

@ -43,6 +43,17 @@ export type FolioAPI = {
renameBook: (p: string, title: string) => Promise<any>; renameBook: (p: string, title: string) => Promise<any>;
hideRecent: (p: string) => Promise<any>; hideRecent: (p: string) => Promise<any>;
unhideRecent: (p: string) => Promise<any>; unhideRecent: (p: string) => Promise<any>;
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<boolean>; closeBook: () => Promise<boolean>;
getSpellcheck: () => Promise<boolean>; getSpellcheck: () => Promise<boolean>;
setSpellcheck: (on: boolean) => Promise<any>; setSpellcheck: (on: boolean) => Promise<any>;

View file

@ -9,7 +9,7 @@
import type { BookCtx } from "./types.js"; import type { BookCtx } from "./types.js";
import type { ChapterEntry, Recent } from "./types.js"; import type { ChapterEntry, Recent } from "./types.js";
import type { BookMeta } from "../../main/project.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"; import { $, showUndoToast, showView } from "./ui.js";
export interface WelcomeFeature { 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 // double-click or Enter opens the book. Replaces the old always-on
// "×" button, which deleted on a mis-click. // "×" button, which deleted on a mis-click.
label.addEventListener("click", () => selectRecent(li)); 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("dblclick", () => void openPath(r.path));
li.addEventListener("keydown", (e) => { li.addEventListener("keydown", (e) => {
if (e.key === "Enter") void openPath(r.path); 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); 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 { return {
showWelcome, showWelcome,
showPlaceholder, showPlaceholder,

View file

@ -343,9 +343,9 @@
<label>Keep last <input type="number" id="settingKeep" class="modal-input setting-num" min="0" max="200" value="20" /></label> <label>Keep last <input type="number" id="settingKeep" class="modal-input setting-num" min="0" max="200" value="20" /></label>
</div> </div>
<div class="backup-control"> <div class="backup-control">
<span class="backup-label">Folder</span> <span class="backup-label">Storage folder</span>
<input type="text" id="settingLocation" class="backup-input" readonly placeholder="Default: <book>/Folio-Backups" title="Snapshot folder (empty = default)" /> <input type="text" id="settingLocation" class="backup-input" readonly placeholder="Default: Documents" title="Where new books and snapshots are saved (empty = Documents / &lt;book&gt;/Folio-Backups)" />
<button type="button" class="button button-ghost backup-browse" id="settingBrowse" title="Choose snapshot folder">…</button> <button type="button" class="button button-ghost backup-browse" id="settingBrowse" title="Choose storage folder">…</button>
<button type="button" class="button button-ghost" id="settingResetLoc" title="Back to the default folder">Reset</button> <button type="button" class="button button-ghost" id="settingResetLoc" title="Back to the default folder">Reset</button>
</div> </div>
<p class="setting-meta" id="settingResolved"></p> <p class="setting-meta" id="settingResolved"></p>