Compare commits
No commits in common. "main" and "v0.1.7" have entirely different histories.
7 changed files with 51 additions and 176 deletions
|
|
@ -21,11 +21,6 @@ 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.17",
|
"version": "0.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "folio",
|
"name": "folio",
|
||||||
"version": "0.1.17",
|
"version": "0.1.7",
|
||||||
"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.17",
|
"version": "0.1.7",
|
||||||
"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,7 +15,6 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -246,30 +246,13 @@ async function openBookAt(p: string): Promise<BookResult | ErrResult> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new book as a dedicated folder <name>/ inside the user's chosen
|
// Create a new book as a dedicated folder <name>/ inside the user's Documents
|
||||||
// storage folder (Settings → Folder), falling back to Documents when none is
|
// directory, then open it. The folder name is the sanitized book title; if a
|
||||||
// set or the stored one no longer exists, then open it. The folder name is
|
// folder with that name already exists we append a numeric suffix.
|
||||||
// the sanitized book title; if a folder with that name already exists we
|
|
||||||
// append a numeric suffix.
|
|
||||||
function getBooksDir(): string {
|
|
||||||
// One storage folder drives everything (snapshots, recovery, new books).
|
|
||||||
// snapshotLocation is the one the Settings modal writes; backupLocation is
|
|
||||||
// the older key — honor both so an existing choice keeps working.
|
|
||||||
for (const key of ["snapshotLocation", "backupLocation"]) {
|
|
||||||
const v = String(getSetting<string>(key, "") || "").trim();
|
|
||||||
try {
|
|
||||||
if (v && fs.statSync(v).isDirectory()) return v;
|
|
||||||
} catch {
|
|
||||||
// stale or unreadable path — fall through to the next candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return app.getPath("documents");
|
|
||||||
}
|
|
||||||
|
|
||||||
handleIpc("folio:newBookNamed", async (_e, name: string) => {
|
handleIpc("folio:newBookNamed", async (_e, name: string) => {
|
||||||
const title = (name || "").trim();
|
const title = (name || "").trim();
|
||||||
if (!title) return { error: "Please provide a book name." };
|
if (!title) return { error: "Please provide a book name." };
|
||||||
const base = getBooksDir();
|
const base = app.getPath("documents");
|
||||||
const folder = sanitizeBookFolderName(title) || "Untitled";
|
const folder = sanitizeBookFolderName(title) || "Untitled";
|
||||||
let target = path.join(base, folder);
|
let target = path.join(base, folder);
|
||||||
let n = 2;
|
let n = 2;
|
||||||
|
|
@ -608,7 +591,7 @@ function exportDefaultPath(filename: string): string {
|
||||||
const dir =
|
const dir =
|
||||||
last && fs.existsSync(last) && fs.statSync(last).isDirectory()
|
last && fs.existsSync(last) && fs.statSync(last).isDirectory()
|
||||||
? last
|
? last
|
||||||
: getBooksDir();
|
: app.getPath("documents");
|
||||||
return path.join(dir, filename);
|
return path.join(dir, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -989,7 +972,6 @@ function snapshotSettingsSnapshot() {
|
||||||
keep: clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200),
|
keep: clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200),
|
||||||
location,
|
location,
|
||||||
resolvedRoot: resolved,
|
resolvedRoot: resolved,
|
||||||
booksDir: getBooksDir(),
|
|
||||||
snapshotCount: snaps.length,
|
snapshotCount: snaps.length,
|
||||||
lastSnapshot: snaps.length ? snaps[snaps.length - 1] : null,
|
lastSnapshot: snaps.length ? snaps[snaps.length - 1] : null,
|
||||||
};
|
};
|
||||||
|
|
@ -1026,7 +1008,7 @@ handleIpc("folio:setSnapshotSettings", (_e, patch: unknown) => {
|
||||||
handleIpc("folio:pickSnapshotFolder", async () => {
|
handleIpc("folio:pickSnapshotFolder", async () => {
|
||||||
const { canceled, filePaths } = await showOpenDialog({
|
const { canceled, filePaths } = await showOpenDialog({
|
||||||
properties: ["openDirectory"],
|
properties: ["openDirectory"],
|
||||||
title: "Choose storage folder (new books + snapshots)",
|
title: "Choose snapshot folder",
|
||||||
});
|
});
|
||||||
return { canceled, filePaths };
|
return { canceled, filePaths };
|
||||||
});
|
});
|
||||||
|
|
@ -1107,14 +1089,14 @@ handleIpc("folio:importMarkdown", async () => {
|
||||||
filters: [{ name: "Markdown and text", extensions: ["md", "markdown", "txt"] }],
|
filters: [{ name: "Markdown and text", extensions: ["md", "markdown", "txt"] }],
|
||||||
});
|
});
|
||||||
if (canceled || !filePaths.length) return { canceled: true } as const;
|
if (canceled || !filePaths.length) return { canceled: true } as const;
|
||||||
return importMarkdownFiles(filePaths, getBooksDir());
|
return importMarkdownFiles(filePaths, app.getPath("documents"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Open a path that may be either a Folio book directory or a Markdown file
|
// Open a path that may be either a Folio book directory or a Markdown file
|
||||||
// (the desktop launcher can be handed either via file associations).
|
// (the desktop launcher can be handed either via file associations).
|
||||||
handleIpc("folio:openPathOrImport", async (_e, p: string) => {
|
handleIpc("folio:openPathOrImport", async (_e, p: string) => {
|
||||||
if (p && isImportableMarkdown(p)) {
|
if (p && isImportableMarkdown(p)) {
|
||||||
const results = importMarkdownFiles([p], getBooksDir());
|
const results = importMarkdownFiles([p], app.getPath("documents"));
|
||||||
const r = results[0];
|
const r = results[0];
|
||||||
if (!r.ok) return { error: r.error };
|
if (!r.ok) return { error: r.error };
|
||||||
return openBookAt(r.bookPath as string);
|
return openBookAt(r.bookPath as string);
|
||||||
|
|
@ -1220,71 +1202,18 @@ 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 total = Number(asset.size) || 0;
|
|
||||||
let lastError = "";
|
|
||||||
let downloaded = false;
|
|
||||||
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 (code !== 0) {
|
|
||||||
lastError = `Download failed (curl exit ${code}).`;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No curl on PATH: fall back to the in-process fetch pipeline.
|
|
||||||
const { Readable } = await import("stream");
|
const { Readable } = await import("stream");
|
||||||
const { createWriteStream } = await import("fs");
|
const { createWriteStream } = await import("fs");
|
||||||
const { pipeline } = await import("stream/promises");
|
const { pipeline } = await import("stream/promises");
|
||||||
|
const total = Number(asset.size) || 0;
|
||||||
|
let lastError = "";
|
||||||
|
let downloaded = false;
|
||||||
for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) {
|
for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(asset.browser_download_url, {
|
const res = await fetch(asset.browser_download_url, {
|
||||||
|
|
@ -1315,7 +1244,6 @@ handleIpc("folio:performUpdate", async () => {
|
||||||
lastError = `Download failed: ${(e as Error).message}`;
|
lastError = `Download failed: ${(e as Error).message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!downloaded) {
|
if (!downloaded) {
|
||||||
fs.rmSync(tarball, { force: true });
|
fs.rmSync(tarball, { force: true });
|
||||||
return {
|
return {
|
||||||
|
|
@ -1340,44 +1268,12 @@ handleIpc("folio:performUpdate", async () => {
|
||||||
}),
|
}),
|
||||||
{ mode: 0o700 }
|
{ mode: 0o700 }
|
||||||
);
|
);
|
||||||
// IMPORTANT: on systemd desktops (Hyprland/uwsm) the app runs inside a
|
const { spawn } = await import("child_process");
|
||||||
// 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" });
|
const child = spawn("/bin/sh", [script], { detached: true, stdio: "ignore" });
|
||||||
child.on("error", (err) => {
|
child.on("error", (err) => {
|
||||||
console.error("[folio] updater spawn failed:", err.message);
|
console.error("[folio] updater spawn failed:", err.message);
|
||||||
});
|
});
|
||||||
child.unref();
|
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.
|
// Quit so the swap script can replace the app directory.
|
||||||
setTimeout(() => app.quit(), 300);
|
setTimeout(() => app.quit(), 300);
|
||||||
return { ok: true, version: rel.version };
|
return { ok: true, version: rel.version };
|
||||||
|
|
@ -1404,7 +1300,7 @@ function fileArgFrom(argv: string[]): string | null {
|
||||||
|
|
||||||
function openFromArg(p: string): void {
|
function openFromArg(p: string): void {
|
||||||
if (isImportableMarkdown(p)) {
|
if (isImportableMarkdown(p)) {
|
||||||
const results = importMarkdownFiles([p], getBooksDir());
|
const results = importMarkdownFiles([p], app.getPath("documents"));
|
||||||
const r = results[0];
|
const r = results[0];
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
openBookAt(r.bookPath as string);
|
openBookAt(r.bookPath as string);
|
||||||
|
|
@ -1428,8 +1324,4 @@ 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,14 +224,7 @@ export function buildInstallerScript(spec: InstallerSpec): string {
|
||||||
' rm -rf "$NEW"',
|
' rm -rf "$NEW"',
|
||||||
"fi",
|
"fi",
|
||||||
'say "relaunching"',
|
'say "relaunching"',
|
||||||
// Detach fully: setsid moves the relaunch into its own session so it
|
'"$RELAUNCH" >/dev/null 2>&1 &',
|
||||||
// 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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,6 @@ interface SnapshotSettings {
|
||||||
keep: number;
|
keep: number;
|
||||||
location: string;
|
location: string;
|
||||||
resolvedRoot: string;
|
resolvedRoot: string;
|
||||||
/** Where new books are saved (Settings → Storage folder). */
|
|
||||||
booksDir?: string;
|
|
||||||
snapshotCount: number;
|
snapshotCount: number;
|
||||||
lastSnapshot: string | null;
|
lastSnapshot: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -61,10 +59,8 @@ export function createSettingsFeature(ctx: BookCtx): SettingsFeature {
|
||||||
intervalInput.value = String(s.intervalMin ?? 5);
|
intervalInput.value = String(s.intervalMin ?? 5);
|
||||||
keepInput.value = String(s.keep ?? 20);
|
keepInput.value = String(s.keep ?? 20);
|
||||||
locationInput.value = s.location || "";
|
locationInput.value = s.location || "";
|
||||||
locationInput.placeholder = "Default: Documents (books) · <book>/Folio-Backups (snapshots)";
|
locationInput.placeholder = "Default: <book>/Folio-Backups";
|
||||||
resolvedEl.textContent = s.booksDir
|
resolvedEl.textContent = `Saving to: ${shortPath(s.resolvedRoot || "—")}`;
|
||||||
? `New books save to: ${shortPath(s.booksDir)}`
|
|
||||||
: `Saving to: ${shortPath(s.resolvedRoot || "—")}`;
|
|
||||||
lastSnapEl.textContent = s.lastSnapshot
|
lastSnapEl.textContent = s.lastSnapshot
|
||||||
? `${s.snapshotCount} snapshot${s.snapshotCount === 1 ? "" : "s"} · latest ${s.lastSnapshot.split("/").pop()}`
|
? `${s.snapshotCount} snapshot${s.snapshotCount === 1 ? "" : "s"} · latest ${s.lastSnapshot.split("/").pop()}`
|
||||||
: "No snapshots yet.";
|
: "No snapshots yet.";
|
||||||
|
|
|
||||||
|
|
@ -343,9 +343,9 @@
|
||||||
<label>Keep last <input type="number" id="settingKeep" class="modal-input setting-num" min="0" max="200" value="20" /></label>
|
<label>Keep last <input type="number" id="settingKeep" class="modal-input setting-num" min="0" max="200" value="20" /></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="backup-control">
|
<div class="backup-control">
|
||||||
<span class="backup-label">Storage folder</span>
|
<span class="backup-label">Folder</span>
|
||||||
<input type="text" id="settingLocation" class="backup-input" readonly placeholder="Default: Documents" title="Where new books and snapshots are saved (empty = Documents / <book>/Folio-Backups)" />
|
<input type="text" id="settingLocation" class="backup-input" readonly placeholder="Default: <book>/Folio-Backups" title="Snapshot folder (empty = default)" />
|
||||||
<button type="button" class="button button-ghost backup-browse" id="settingBrowse" title="Choose storage folder">…</button>
|
<button type="button" class="button button-ghost backup-browse" id="settingBrowse" title="Choose snapshot folder">…</button>
|
||||||
<button type="button" class="button button-ghost" id="settingResetLoc" title="Back to the default folder">Reset</button>
|
<button type="button" class="button button-ghost" id="settingResetLoc" title="Back to the default folder">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="setting-meta" id="settingResolved"></p>
|
<p class="setting-meta" id="settingResolved"></p>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue