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 diff --git a/package-lock.json b/package-lock.json index 5c27704..6b4b766 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.8", + "version": "0.1.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.8", + "version": "0.1.17", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index afb7897..3ffd369 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.10", + "version": "0.1.17", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", @@ -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" }, diff --git a/src/main/index.ts b/src/main/index.ts index 80749cd..c9557a1 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,13 +1348,17 @@ 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"); 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; @@ -1370,4 +1428,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(); } 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"); }