feat: spellcheck menu, typewriter mode, file associations, updater hardening

English spellcheck suggestions with Add-to-dictionary in the native context menu plus a Settings toggle. Typewriter caret-centering mode. OS file associations for .md/.txt and Open File wording. Updater: release URL surfaced, single progress listener, two-byte tarball check, installer wait guard. Search/installer test suites registered; chapter-list layout polish; ignore KDE .directory files.
This commit is contained in:
avi 2026-09-19 16:38:17 -05:00
commit 542a545c0d
17 changed files with 445 additions and 73 deletions

41
tests/run-search-test.mjs Normal file
View file

@ -0,0 +1,41 @@
// Standalone test for the whole-book search helpers (src/renderer/book/ui.ts).
// These pure functions power the Search modal: Markdown flattening, literal
// matching (phrases included), and result snippets.
// Run with: node tests/run-search-test.mjs
import { bundleForTest } from "./helpers/bundle.mjs";
const { mod: ui, cleanupBundle } = await bundleForTest({
name: "folio-search-ui",
entryPoints: ["src/renderer/book/ui.ts"],
});
const { findMatches, markdownToPlain, makeSnippet, moveInList, escapeHtml } = ui;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else { failures++; console.error("FAIL -", msg); }
}
// markdown -> searchable plain text
ok(markdownToPlain("# Title\nSome **bold** text") === "Title\nSome bold text", "headings/emphasis flattened");
ok(markdownToPlain("[link](http://x)") === "link", "links flattened to text");
ok(markdownToPlain("`code`") === "code", "inline code flattened");
// literal matching incl. phrases, case handling
ok(JSON.stringify(findMatches("hello world hello", "hello", true)) === "[0,12]", "finds all occurrences");
ok(findMatches("Hello", "hello", true).length === 0, "match-case respected");
ok(findMatches("Hello", "hello", false).length === 1, "case-insensitive match");
ok(JSON.stringify(findMatches("the quick brown fox", "quick brown", false)) === "[4]", "phrase search works");
ok(findMatches("a.b", "a.b", false).length === 1, "regex chars treated literally");
// snippets highlight the match and escape HTML
const snip = makeSnippet("say <hi> & bye", 4, 4);
ok(snip.includes("<mark>") && snip.includes("&lt;hi&gt;"), "snippet highlights + escapes");
// shared reorder math used by drag + keyboard reorder
ok(JSON.stringify(moveInList([1, 2, 3], 0, 2)) === "[2,3,1]", "moveInList moves item");
ok(escapeHtml("<a>&") === "&lt;a&gt;&amp;", "escapeHtml works");
cleanupBundle();
if (failures) { console.error(`${failures} search check(s) FAILED`); process.exit(1); }
console.log("All search checks passed.");

View file

@ -44,6 +44,37 @@ assert(sh.includes(`'/tmp/it'\\''s.tar.gz'`), "shQuote escapes embedded single q
assert(sh.includes("kill -0"), "installer waits for app pid");
assert(sh.includes("-xzf"), "installer extracts gz");
// Real Gitea release-JSON shape (tag_name/html_url/body/assets with sizes).
const gitea = u.parseRelease({
id: 5,
tag_name: "v0.2.0",
html_url: "https://git.atitlan.io/avi/Folio/releases/tag/v0.2.0",
body: "Bug fixes",
assets: [
{ name: "Folio-0.2.0-linux-x86_64.tar.gz", browser_download_url: "https://git.atitlan.io/avi/Folio/releases/download/v0.2.0/Folio-0.2.0-linux-x86_64.tar.gz", size: 123456 },
{ name: "Folio-0.2.0-linux-amd64.deb", browser_download_url: "https://git.atitlan.io/avi/Folio/releases/download/v0.2.0/Folio-0.2.0-linux-amd64.deb", size: 111 },
{ name: "Folio-0.2.0.AppImage", browser_download_url: "https://git.atitlan.io/avi/Folio/releases/download/v0.2.0/Folio-0.2.0.AppImage", size: 222 },
],
});
assert(gitea.version === "0.2.0", "gitea tag parsed");
assert(gitea.url === "https://git.atitlan.io/avi/Folio/releases/tag/v0.2.0", "gitea release url kept");
assert(gitea.notes === "Bug fixes", "gitea notes kept");
assert(u.pickAsset(gitea.assets).name.endsWith(".tar.gz"), "gitea: tar.gz picked over deb/AppImage");
assert(u.isTrustedDownloadUrl(gitea.assets[0].browser_download_url, "https://git.atitlan.io"), "gitea asset url trusted");
// No installable archive cases.
assert(u.pickAsset([]) === null, "empty assets -> no pick");
const debOnly = u.parseRelease({ tag_name: "v0.3.0", assets: [{ name: "Folio.deb", browser_download_url: "https://git.atitlan.io/x/Folio.deb" }] });
assert(u.pickAsset(debOnly.assets) === null, "deb-only release -> no pick (tar.gz required)");
// Version edge cases.
assert(!u.isNewer("0.1.0", "0.1.0"), "same version not an update");
assert(!u.isNewer("0.1.0", "0.2.0"), "older release not an update");
assert(u.compareVersions("0.1.2-rc1", "0.1.2") === 0, "rc suffix ignored in compare");
assert(u.parseVersion("nope") === null, "unparseable version -> null");
assert(!u.isTrustedDownloadUrl("http://git.atitlan.io/a.tar.gz", "https://git.atitlan.io"), "http vs https rejected");
assert(!u.isTrustedDownloadUrl("not a url", "https://git.atitlan.io"), "malformed url rejected");
assert(m.mdTitle("# My Heading\nbody", "file") === "My Heading", "title from H1");
assert(m.mdTitle("no heading", "file") === "file", "title falls back to filename stem");

View file

@ -0,0 +1,123 @@
// Sandbox execution test for the updater handoff script (buildInstallerScript).
// Runs the generated /bin/sh script against fake install dirs in os.tmpdir() —
// never the real ~/.local/share/Folio — proving the swap works and failures
// always relaunch the OLD version.
// Run with: node tests/run-update-installer-test.mjs
import { execFileSync, spawn } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
import { bundleForTest } from "./helpers/bundle.mjs";
const { mod: u, cleanupBundle } = await bundleForTest({
name: "folio-update-script",
entryPoints: ["src/main/update.ts"],
});
let fails = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else { fails++; console.error("FAIL -", msg); }
}
function sleepPid(ms) {
// Short-lived stand-in for the app process the script waits on.
const p = spawn("sleep", [String(ms / 1000)], { stdio: "ignore" });
return p.pid;
}
// A pid that is dead AND reaped (kill -0 fails), so the script proceeds
// without waiting. NOTE: a Node-spawned child that just exited stays a
// zombie while our event loop is blocked inside execFileSync — and kill -0
// succeeds on zombies — so the pid must be observed dead before running the
// script, not merely spawned with a short sleep.
async function deadPid() {
const p = spawn("sleep", ["0.01"], { stdio: "ignore" });
const pid = p.pid;
p.on("error", () => {});
for (let i = 0; i < 200; i++) {
try {
process.kill(pid, 0);
} catch {
return pid;
}
await new Promise((r) => setTimeout(r, 10));
}
throw new Error("helper process would not die");
}
function runScript(script, timeoutMs = 30000) {
execFileSync("/bin/sh", [script], { timeout: timeoutMs, stdio: "ignore" });
}
// Fake "installed" app tree: an executable `folio` stub + a data file.
function makeInstall(box, versionMarker) {
const dir = path.join(box, "install");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "folio"), `#!/bin/sh\necho ${versionMarker}\n`, { mode: 0o755 });
fs.writeFileSync(path.join(dir, "other.txt"), "keepme");
return dir;
}
// Fake release tarball with an electron-builder-style nested top-level dir.
function makeTarball(box, versionMarker) {
const staged = path.join(box, "staged", "Folio-linux-x64");
fs.mkdirSync(staged, { recursive: true });
fs.writeFileSync(path.join(staged, "folio"), `#!/bin/sh\necho ${versionMarker}\n`, { mode: 0o755 });
fs.writeFileSync(path.join(staged, "extra.txt"), "newfile");
const tarball = path.join(box, "release.tar.gz");
execFileSync("tar", ["-czf", tarball, "-C", path.join(box, "staged"), "Folio-linux-x64"]);
return tarball;
}
// Relaunch stub records that it was invoked.
function makeRelaunch(box) {
const marker = path.join(box, "relaunched");
const launcher = path.join(box, "relaunch.sh");
fs.writeFileSync(launcher, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`, { mode: 0o755 });
return { launcher, marker };
}
function writeScript(box, spec) {
const script = path.join(box, "installer.sh");
fs.writeFileSync(script, u.buildInstallerScript(spec), { mode: 0o700 });
return script;
}
// --- success path: valid tarball replaces the install, relaunches ---
{
const box = fs.mkdtempSync(path.join(os.tmpdir(), "folio-upd-ok-"));
const install = makeInstall(box, "OLD");
const tarball = makeTarball(box, "NEW");
const { launcher, marker } = makeRelaunch(box);
const pid = await deadPid();
runScript(writeScript(box, { pid, tarball, installDir: install, relaunch: launcher }));
await new Promise((r) => setTimeout(r, 800)); // relaunched in background (&)
ok(fs.readFileSync(path.join(install, "folio"), "utf-8").includes("NEW"), "install swapped to new version");
ok(fs.readFileSync(path.join(install, "extra.txt"), "utf-8") === "newfile", "new files land in install");
ok(fs.existsSync(marker), "relaunch invoked after swap");
ok(!fs.existsSync(tarball), "tarball cleaned up after success");
ok(!fs.readdirSync(path.dirname(install)).some((e) => e.includes(".old.")), "old tree pruned after success");
fs.rmSync(box, { recursive: true, force: true });
}
// --- failure path: corrupt tarball keeps the old install, still relaunches ---
{
const box = fs.mkdtempSync(path.join(os.tmpdir(), "folio-upd-bad-"));
const install = makeInstall(box, "OLD");
const tarball = path.join(box, "release.tar.gz");
fs.writeFileSync(tarball, "this is not a gzip stream");
const { launcher, marker } = makeRelaunch(box);
const pid = await deadPid();
runScript(writeScript(box, { pid, tarball, installDir: install, relaunch: launcher }));
await new Promise((r) => setTimeout(r, 800));
ok(fs.readFileSync(path.join(install, "folio"), "utf-8").includes("OLD"), "old install intact after bad tarball");
ok(fs.existsSync(marker), "relaunch still invoked after failure");
ok(!fs.readdirSync(path.dirname(install)).some((e) => e.includes(".old.")), "no .old residue after failure");
fs.rmSync(box, { recursive: true, force: true });
}
cleanupBundle();
if (fails) { console.error(`${fails} installer check(s) FAILED`); process.exit(1); }
console.log("All installer checks passed.");