diff --git a/package-lock.json b/package-lock.json index 6feb28d..5c27704 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.10", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.10", + "version": "0.1.8", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index c5472ce..afb7897 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.12", + "version": "0.1.10", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", diff --git a/src/main/index.ts b/src/main/index.ts index e666950..80749cd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1220,100 +1220,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) { @@ -1348,6 +1294,7 @@ 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");