Compare commits

..

No commits in common. "cabf6400e7b6e3e1846bf537b46276193d8c3a29" and "0589dd03961ba13612e612e669d8ad1bcfa2a8e0" have entirely different histories.

3 changed files with 31 additions and 84 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "folio", "name": "folio",
"version": "0.1.10", "version": "0.1.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "folio", "name": "folio",
"version": "0.1.10", "version": "0.1.8",
"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.12", "version": "0.1.10",
"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",

View file

@ -1220,100 +1220,46 @@ handleIpc("folio:performUpdate", async () => {
app.getPath("temp"), app.getPath("temp"),
`folio-update-${Date.now()}.tar.gz` `folio-update-${Date.now()}.tar.gz`
); );
// Downloads go through curl, not Chromium's network stack: on flaky Wi-Fi // Downloads of the ~100MB asset have twice truncated near the end while
// the Electron net stack restarts requests mid-stream, producing a // the server copy stayed intact. The old 2-byte magic check could not
// right-sized but corrupt gzip ("100% three times, then failed"). curl // see that, so the detached installer failed after the app had quit and
// resumes with HTTP Range (-C -) so a dropped connection continues where // the update silently no-oped. Now: retry the download up to 3 times,
// it left off instead of restarting, and retries internally. We still // require the advertised byte count, and gunzip the whole file before
// verify the exact byte count and gunzip the whole file before handoff. // 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; const total = Number(asset.size) || 0;
let lastError = ""; let lastError = "";
let downloaded = false; let downloaded = false;
const { spawn } = await import("child_process"); for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) {
const haveCurl = await new Promise<boolean>((resolve) => { try {
const p = spawn("curl", ["--version"], { stdio: "ignore" }); const res = await fetch(asset.browser_download_url, {
p.on("error", () => resolve(false)); redirect: "follow",
p.on("close", (code) => resolve(code === 0)); signal: AbortSignal.timeout(10 * 60 * 1000),
});
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) { if (!res.ok || !res.body) {
lastError = `Download failed (curl exit ${code}).`; lastError = `Download failed (HTTP ${res.status}).`;
continue; continue;
} }
const size = fs.existsSync(tarball) ? fs.statSync(tarball).size : 0; let received = 0;
if (looksTruncated(size, total)) { const source = Readable.fromWeb(res.body as never);
lastError = `Download truncated (${size}/${total} bytes).`; 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; continue;
} }
if (!(await verifyArchiveGzip(tarball))) { if (!(await verifyArchiveGzip(tarball))) {
lastError = "Downloaded archive is corrupt (gzip stream incomplete)."; lastError = "Downloaded archive is corrupt (gzip stream incomplete).";
fs.rmSync(tarball, { force: true }); // corrupted resume file must not poison the next attempt
continue; continue;
} }
downloaded = true; downloaded = true;
} } catch (e) {
} else { lastError = `Download failed: ${(e as Error).message}`;
// 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) { if (!downloaded) {
@ -1348,6 +1294,7 @@ handleIpc("folio:performUpdate", async () => {
// systemd-run puts the script in its own transient unit that outlives us; // 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 // verified to survive on this machine. Fall back to a detached spawn for
// non-systemd sessions. // non-systemd sessions.
const { spawn } = await import("child_process");
let handedOff = false; let handedOff = false;
try { try {
const { execFileSync } = await import("child_process"); const { execFileSync } = await import("child_process");