Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67ca9cbb76 | ||
|
|
6c8b82940a | ||
|
|
aebfa26308 | ||
|
|
328b24d5a6 | ||
|
|
ced074a3a0 | ||
|
|
44b4737a20 | ||
|
|
ea2ca08efd | ||
|
|
173a9799d0 | ||
|
|
bb758580d7 | ||
|
|
b1586c5e25 | ||
|
|
cabf6400e7 | ||
|
|
f3cd2274a6 | ||
|
|
9afe49c378 |
5 changed files with 107 additions and 32 deletions
|
|
@ -21,6 +21,11 @@ linux:
|
||||||
target:
|
target:
|
||||||
- AppImage
|
- AppImage
|
||||||
- deb
|
- 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
|
# OS file associations: double-click / "Open with Folio" on Markdown and
|
||||||
# plain-text files imports them as books (see folio:openPathOrImport and
|
# plain-text files imports them as books (see folio:openPathOrImport and
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "folio",
|
"name": "folio",
|
||||||
"version": "0.1.8",
|
"version": "0.1.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "folio",
|
"name": "folio",
|
||||||
"version": "0.1.8",
|
"version": "0.1.17",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/core": "^2.27.2",
|
"@tiptap/core": "^2.27.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "folio",
|
"name": "folio",
|
||||||
"version": "0.1.10",
|
"version": "0.1.17",
|
||||||
"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",
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
|
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
|
||||||
"start": "npm run build && npm run electron",
|
"start": "npm run build && npm run electron",
|
||||||
"lint": "tsc --noEmit",
|
"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",
|
"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"
|
"package": "electron-builder"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1220,46 +1220,100 @@ handleIpc("folio:performUpdate", async () => {
|
||||||
app.getPath("temp"),
|
app.getPath("temp"),
|
||||||
`folio-update-${Date.now()}.tar.gz`
|
`folio-update-${Date.now()}.tar.gz`
|
||||||
);
|
);
|
||||||
// Downloads of the ~100MB asset have twice truncated near the end while
|
// Downloads go through curl, not Chromium's network stack: on flaky Wi-Fi
|
||||||
// the server copy stayed intact. The old 2-byte magic check could not
|
// the Electron net stack restarts requests mid-stream, producing a
|
||||||
// see that, so the detached installer failed after the app had quit and
|
// right-sized but corrupt gzip ("100% three times, then failed"). curl
|
||||||
// the update silently no-oped. Now: retry the download up to 3 times,
|
// resumes with HTTP Range (-C -) so a dropped connection continues where
|
||||||
// require the advertised byte count, and gunzip the whole file before
|
// it left off instead of restarting, and retries internally. We still
|
||||||
// handing off. A corrupt file is deleted and the user gets a real error.
|
// verify the exact byte count and gunzip the whole file before handoff.
|
||||||
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;
|
||||||
for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) {
|
const { spawn } = await import("child_process");
|
||||||
try {
|
const haveCurl = await new Promise<boolean>((resolve) => {
|
||||||
const res = await fetch(asset.browser_download_url, {
|
const p = spawn("curl", ["--version"], { stdio: "ignore" });
|
||||||
redirect: "follow",
|
p.on("error", () => resolve(false));
|
||||||
signal: AbortSignal.timeout(10 * 60 * 1000),
|
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) {
|
if (code !== 0) {
|
||||||
lastError = `Download failed (HTTP ${res.status}).`;
|
lastError = `Download failed (curl exit ${code}).`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let received = 0;
|
const size = fs.existsSync(tarball) ? fs.statSync(tarball).size : 0;
|
||||||
const source = Readable.fromWeb(res.body as never);
|
if (looksTruncated(size, total)) {
|
||||||
source.on("data", (chunk: Buffer) => {
|
lastError = `Download truncated (${size}/${total} bytes).`;
|
||||||
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) {
|
}
|
||||||
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) {
|
if (!downloaded) {
|
||||||
|
|
@ -1294,13 +1348,17 @@ 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");
|
||||||
execFileSync("systemd-run", [
|
execFileSync("systemd-run", [
|
||||||
"--user", "--quiet", "--collect",
|
"--user", "--quiet", "--collect",
|
||||||
`--description=Folio updater handoff`,
|
`--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,
|
"/bin/sh", script,
|
||||||
], { timeout: 10_000, stdio: "ignore" });
|
], { timeout: 10_000, stdio: "ignore" });
|
||||||
handedOff = true;
|
handedOff = true;
|
||||||
|
|
@ -1370,4 +1428,8 @@ if (app.requestSingleInstanceLock()) {
|
||||||
}
|
}
|
||||||
if (f) openFromArg(f);
|
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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,14 @@ export function buildInstallerScript(spec: InstallerSpec): string {
|
||||||
' rm -rf "$NEW"',
|
' rm -rf "$NEW"',
|
||||||
"fi",
|
"fi",
|
||||||
'say "relaunching"',
|
'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");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue