diff --git a/electron-builder.yml b/electron-builder.yml index 4abacd6..d8f3b67 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -21,11 +21,6 @@ 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 diff --git a/package-lock.json b/package-lock.json index 6b4b766..15f954e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.17", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.17", + "version": "0.1.7", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index 3ffd369..158e4e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.17", + "version": "0.1.7", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", @@ -15,7 +15,6 @@ "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" }, diff --git a/src/main/index.ts b/src/main/index.ts index c9557a1..4bf7926 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -246,30 +246,13 @@ async function openBookAt(p: string): Promise { } } -// 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"); -} - +// 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. handleIpc("folio:newBookNamed", async (_e, name: string) => { const title = (name || "").trim(); if (!title) return { error: "Please provide a book name." }; - const base = getBooksDir(); + const base = app.getPath("documents"); const folder = sanitizeBookFolderName(title) || "Untitled"; let target = path.join(base, folder); let n = 2; @@ -608,7 +591,7 @@ function exportDefaultPath(filename: string): string { const dir = last && fs.existsSync(last) && fs.statSync(last).isDirectory() ? last - : getBooksDir(); + : app.getPath("documents"); return path.join(dir, filename); } @@ -989,7 +972,6 @@ 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, }; @@ -1026,7 +1008,7 @@ handleIpc("folio:setSnapshotSettings", (_e, patch: unknown) => { handleIpc("folio:pickSnapshotFolder", async () => { const { canceled, filePaths } = await showOpenDialog({ properties: ["openDirectory"], - title: "Choose storage folder (new books + snapshots)", + title: "Choose snapshot folder", }); return { canceled, filePaths }; }); @@ -1107,14 +1089,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, getBooksDir()); + return importMarkdownFiles(filePaths, app.getPath("documents")); }); // 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], getBooksDir()); + const results = importMarkdownFiles([p], app.getPath("documents")); const r = results[0]; if (!r.ok) return { error: r.error }; return openBookAt(r.bookPath as string); @@ -1220,100 +1202,46 @@ handleIpc("folio:performUpdate", async () => { app.getPath("temp"), `folio-update-${Date.now()}.tar.gz` ); - // 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. + // 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"); const total = Number(asset.size) || 0; let lastError = ""; let downloaded = false; - 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); }); + 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 (code !== 0) { - lastError = `Download failed (curl exit ${code}).`; + if (!res.ok || !res.body) { + lastError = `Download failed (HTTP ${res.status}).`; continue; } - const size = fs.existsSync(tarball) ? fs.statSync(tarball).size : 0; - if (looksTruncated(size, total)) { - lastError = `Download truncated (${size}/${total} bytes).`; + 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)."; - 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++) { - 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}`; - } + } catch (e) { + lastError = `Download failed: ${(e as Error).message}`; } } if (!downloaded) { @@ -1340,44 +1268,12 @@ 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. - 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" }); - 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." }; - } + 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(); // Quit so the swap script can replace the app directory. setTimeout(() => app.quit(), 300); return { ok: true, version: rel.version }; @@ -1404,7 +1300,7 @@ function fileArgFrom(argv: string[]): string | null { function openFromArg(p: string): void { if (isImportableMarkdown(p)) { - const results = importMarkdownFiles([p], getBooksDir()); + const results = importMarkdownFiles([p], app.getPath("documents")); const r = results[0]; if (r.ok) { openBookAt(r.bookPath as string); @@ -1428,8 +1324,4 @@ 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(); } diff --git a/src/main/update.ts b/src/main/update.ts index 9493643..738245b 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -224,14 +224,7 @@ export function buildInstallerScript(spec: InstallerSpec): string { ' rm -rf "$NEW"', "fi", '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", + '"$RELAUNCH" >/dev/null 2>&1 &', "", ].join("\n"); } diff --git a/src/renderer/book/settings.ts b/src/renderer/book/settings.ts index 9cb3949..c9adad1 100644 --- a/src/renderer/book/settings.ts +++ b/src/renderer/book/settings.ts @@ -23,8 +23,6 @@ interface SnapshotSettings { keep: number; location: string; resolvedRoot: string; - /** Where new books are saved (Settings → Storage folder). */ - booksDir?: string; snapshotCount: number; lastSnapshot: string | null; } @@ -61,10 +59,8 @@ 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: Documents (books) · /Folio-Backups (snapshots)"; - resolvedEl.textContent = s.booksDir - ? `New books save to: ${shortPath(s.booksDir)}` - : `Saving to: ${shortPath(s.resolvedRoot || "—")}`; + locationInput.placeholder = "Default: /Folio-Backups"; + resolvedEl.textContent = `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 8bb16c8..d41d81b 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -343,9 +343,9 @@
- Storage folder - - + Folder + +