Compare commits

..

16 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
5 changed files with 140 additions and 37 deletions

View file

@ -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

4
package-lock.json generated
View file

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

View file

@ -1,6 +1,6 @@
{
"name": "folio",
"version": "0.1.8",
"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"
},

View file

@ -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<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 (!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) {
@ -1286,12 +1340,44 @@ handleIpc("folio:performUpdate", async () => {
}),
{ mode: 0o700 }
);
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();
// 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." };
}
// Quit so the swap script can replace the app directory.
setTimeout(() => app.quit(), 300);
return { ok: true, version: rel.version };
@ -1342,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();
}

View file

@ -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");
}