diff --git a/.gitignore b/.gitignore index ff81fda..f85fa32 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/ *.asar releases/ .DS_Store +.directory tests/.tmp-folio-format-*.mjs .opencode/ diff --git a/electron-builder.yml b/electron-builder.yml index 029025c..d8f3b67 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -22,6 +22,25 @@ linux: - AppImage - deb +# OS file associations: double-click / "Open with Folio" on Markdown and +# plain-text files imports them as books (see folio:openPathOrImport and +# fileArgFrom in src/main/index.ts). Without this the desktop environment +# never offers Folio for .txt/.md files. +fileAssociations: + - ext: + - md + - markdown + name: Folio Markdown document + description: Markdown document opened as a Folio book + role: Editor + mimeType: text/markdown + - ext: + - txt + name: Folio text document + description: Plain-text document opened as a Folio book + role: Editor + mimeType: text/plain + # Debian package options live at the config root in electron-builder v26. # Setting `depends` replaces the auto-generated list, so keep its entries and # add what Electron links but the defaults omit — notably libasound2 (ALSA), diff --git a/package.json b/package.json index 163be13..50efb29 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "start": "npm run build && npm run electron", "lint": "tsc --noEmit", - "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", + "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" }, "devDependencies": { diff --git a/src/main/index.ts b/src/main/index.ts index 9db9721..8c565f2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from "electron"; +import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, session, shell } from "electron"; import type { OpenDialogOptions, SaveDialogOptions } from "electron"; import fs from "fs"; import path from "path"; @@ -86,8 +86,8 @@ function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 780, - minWidth: 960, - minHeight: 640, + minWidth: 560, + minHeight: 400, title: "Folio", icon: path.join(__dirname, "icons", "icon.png"), webPreferences: { @@ -97,6 +97,7 @@ function createWindow() { }, }); forwardEditShortcuts(mainWindow); + wireContextMenu(mainWindow); mainWindow.loadFile(path.join(__dirname, "index.html")); mainWindow.webContents.on("did-finish-load", () => { console.log("[folio] renderer loaded"); @@ -127,6 +128,15 @@ function createWindow() { app.whenReady().then(() => { migrateLegacyRecents(); + // English-only spellcheck dictionary + stored on/off preference. + try { + session.defaultSession.setSpellCheckerLanguages(["en-US"]); + if (getSetting("spellcheckEnabled", true) === false) { + session.defaultSession.setSpellCheckerEnabled(false); + } + } catch (err) { + console.error("[folio] spellchecker setup failed:", (err as Error).message); + } buildMenu( getWindow, getState, @@ -710,19 +720,57 @@ handleIpc("folio:toggleFullscreen", () => { return win.isFullScreen(); }); -// Right-click context menu (Electron has none by default), so Cut/Copy/Paste -// via the mouse work. The roles act on whatever element is focused. -ipcMain.on("folio:context-menu", (event, x: number, y: number) => { - const win = BrowserWindow.fromWebContents(event.sender); - if (!win) return; - const menu = Menu.buildFromTemplate([ - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { type: "separator" }, - { role: "selectAll" }, - ]); - menu.popup({ window: win, x, y }); +// Right-click menu with spelling suggestions (English-only dictionary). +// Electron builds no default context menu, so without this the red +// misspelling underlines had no way to reach a suggestion. Chromium reports +// the misspelled word + candidates via the webContents event; suggestions +// replace the word in place, and "Add to dictionary" persists to the +// session's custom words. +function wireContextMenu(win: BrowserWindow) { + win.webContents.on("context-menu", (_e, params) => { + const target = getWindow(); + if (!target) return; + const items: Electron.MenuItemConstructorOptions[] = []; + if (params.misspelledWord) { + const sugs = (params.dictionarySuggestions || []).slice(0, 5); + if (sugs.length) { + for (const s of sugs) { + items.push({ label: s, click: () => target.webContents.replaceMisspelling(s) }); + } + } else { + items.push({ label: "(no suggestions)", enabled: false }); + } + items.push({ + label: "Add to dictionary", + click: () => + target.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord), + }); + items.push({ type: "separator" }); + } + items.push( + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { type: "separator" }, + { role: "selectAll" } + ); + Menu.buildFromTemplate(items).popup({ window: target, x: params.x, y: params.y }); + }); +} + +// Spellcheck preference (English-only dictionary). Applied at startup and +// toggled live from Settings; the editor's spellcheck attribute stays on so +// Chromium underlines misspellings whenever the session checker is enabled. +handleIpc("folio:getSpellcheck", () => getSetting("spellcheckEnabled", true)); +handleIpc("folio:setSpellcheck", (_e, on: unknown) => { + const enabled = on !== false; + setSetting("spellcheckEnabled", enabled); + try { + session.defaultSession.setSpellCheckerEnabled(enabled); + } catch { + // applied on next launch if the session is not ready yet + } + return { ok: true, enabled }; }); // Theme preference. Stored as a plain string so future themes can be added @@ -901,9 +949,9 @@ handleIpc("folio:importAttachment", async () => { // never modified. Books land in Documents, same as "New". handleIpc("folio:importMarkdown", async () => { const { canceled, filePaths } = await showOpenDialog({ - title: "Open Markdown file(s)", + title: "Open Markdown or text file(s)", properties: ["openFile", "multiSelections"], - filters: [{ name: "Markdown", extensions: ["md", "markdown", "txt"] }], + filters: [{ name: "Markdown and text", extensions: ["md", "markdown", "txt"] }], }); if (canceled || !filePaths.length) return { canceled: true } as const; return importMarkdownFiles(filePaths, app.getPath("documents")); @@ -956,6 +1004,7 @@ interface UpdateCheckResult { updateAvailable?: boolean; assetName?: string; notes?: string; + url?: string; canInstall?: boolean; error?: string; } @@ -983,6 +1032,7 @@ handleIpc("folio:checkUpdate", async (): Promise => { updateAvailable: isNewer(rel.version, current), assetName: asset ? asset.name : undefined, notes: rel.notes, + url: rel.url, canInstall: !!asset && isTrustedDownloadUrl(asset.browser_download_url, updateConfig().base) && @@ -1035,7 +1085,21 @@ handleIpc("folio:performUpdate", async () => { getWindow()?.webContents.send("folio:update-progress", { received, total }); }); await pipeline(source, createWriteStream(tarball)); - const head = fs.readFileSync(tarball).subarray(0, 2); + // Verify the gzip magic (2 bytes) without loading a ~100MB tarball into + // memory. Anything else means a truncated download or an HTML error page. + let head = Buffer.alloc(0); + try { + const fd = fs.openSync(tarball, "r"); + try { + const buf = Buffer.alloc(2); + const n = fs.readSync(fd, buf, 0, 2, 0); + head = buf.subarray(0, n); + } finally { + fs.closeSync(fd); + } + } catch { + head = Buffer.alloc(0); + } if (head.length < 2 || head[0] !== 0x1f || head[1] !== 0x8b) { fs.rmSync(tarball, { force: true }); return { error: "Downloaded file is not a valid archive; update aborted." }; diff --git a/src/main/menu.ts b/src/main/menu.ts index 67b9399..7964a22 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,8 +1,7 @@ import path from "path"; -import { app, dialog, Menu, BrowserWindow } from "electron"; +import { app, Menu, BrowserWindow } from "electron"; import { listRecents } from "./recents.js"; import type { Recent } from "./recents.js"; -import { setRecoveryRoot } from "./backup.js"; type MenuItem = Electron.MenuItemConstructorOptions; type GetState = () => { bookPath: string | null }; @@ -38,10 +37,10 @@ export function buildMenu( submenu: [ { label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew }, { label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen }, - { - label: "Open Markdown File…", - click: () => getWindow()?.webContents.send("folio:open-markdown-requested"), - }, + { + label: "Open File…", + click: () => getWindow()?.webContents.send("folio:open-markdown-requested"), + }, { type: "separator" }, { label: "Save", diff --git a/src/main/preload.ts b/src/main/preload.ts index 3ee2b2d..5b36446 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -10,8 +10,10 @@ contextBridge.exposeInMainWorld("folio", { revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p), getRecents: () => ipcRenderer.invoke("folio:getRecents"), closeBook: () => ipcRenderer.invoke("folio:closeBook"), - showContextMenu: (x: number, y: number) => - ipcRenderer.send("folio:context-menu", x, y), + + // spelling (English dictionary, suggestions in the native context menu) + getSpellcheck: () => ipcRenderer.invoke("folio:getSpellcheck"), + setSpellcheck: (on: boolean) => ipcRenderer.invoke("folio:setSpellcheck", on), // chapters getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"), diff --git a/src/main/update.ts b/src/main/update.ts index 5774e22..0ea6423 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -146,7 +146,7 @@ export function buildInstallerScript(spec: InstallerSpec): string { `OLD="$INSTALL.old.$$"`, "# Wait (bounded to ~2 minutes) for the app process to exit.", "i=0", - 'while kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done', + 'while [ "$PID" -gt 0 ] 2>/dev/null && kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done', 'rm -rf "$NEW"', 'mkdir -p "$NEW"', 'if tar -xzf "$TARBALL" -C "$NEW"; then', diff --git a/src/renderer/book/chapter-list.ts b/src/renderer/book/chapter-list.ts index d070173..538791e 100644 --- a/src/renderer/book/chapter-list.ts +++ b/src/renderer/book/chapter-list.ts @@ -151,7 +151,6 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { li.draggable = false; li.tabIndex = -1; li.innerHTML = ` - ⋮ ${ch.title} diff --git a/src/renderer/book/editor.ts b/src/renderer/book/editor.ts index 0d57d87..3474d82 100644 --- a/src/renderer/book/editor.ts +++ b/src/renderer/book/editor.ts @@ -51,6 +51,45 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { }); const editorHost = $("editorHost"); + // ---- typewriter mode ---- + // Keeps the caret near 42% of the visible editor height by scrolling the + // ProseMirror surface (which is its own scroll container) as the selection + // moves. Renderer-local preference; nothing leaves the machine. + const TYPEWRITER_KEY = "folio:typewriter"; + let typewriterOn = false; + try { + typewriterOn = localStorage.getItem(TYPEWRITER_KEY) === "1"; + } catch { + typewriterOn = false; + } + function setTypewriterEnabled(on: boolean) { + typewriterOn = on !== false; + try { + localStorage.setItem(TYPEWRITER_KEY, typewriterOn ? "1" : "0"); + } catch { + // private-mode storage failure: mode still applies for the session + } + if (typewriterOn) centerCaret(); + } + function isTypewriterEnabled(): boolean { + return typewriterOn; + } + function centerCaret() { + if (!typewriterOn || !editor.isFocused) return; + if (!editor.state.selection.empty) return; + const scroller = editor.view.dom as HTMLElement; + const rect = scroller.getBoundingClientRect(); + if (rect.height <= 0) return; + let y = 0; + try { + y = editor.view.coordsAtPos(editor.state.selection.from).top; + } catch { + return; + } + const dy = y - (rect.top + rect.height * 0.42); + if (Math.abs(dy) > 2) scroller.scrollTop += dy; + } + // The ProseMirror surface is narrower than its host for readable line // lengths. Keep the surrounding editor area clickable so a new chapter can // be focused without requiring a click on the centered content column. @@ -239,6 +278,8 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { setActive("align-left", fmt.align === "left" || fmt.align === ""); setActive("align-center", fmt.align === "center"); setActive("align-right", fmt.align === "right"); + // Defer to the next frame so coords reflect the updated layout. + if (typewriterOn) requestAnimationFrame(centerCaret); } // Ctrl/Cmd+S saves the current chapter (in addition to the menu item). diff --git a/src/renderer/book/index.ts b/src/renderer/book/index.ts index 2980f3e..c35df40 100644 --- a/src/renderer/book/index.ts +++ b/src/renderer/book/index.ts @@ -144,6 +144,7 @@ export function initBook(api: FolioAPI) { // server, then offer a one-click install. The app downloads the tarball, // hands off to a detached swap script, and relaunches itself — no manual // reinstall. See src/main/update.ts for the safety properties. + let updateProgressWired = false; async function checkForUpdates() { showToast("Checking for updates…"); const res = await api.checkUpdate(); @@ -164,18 +165,23 @@ export function initBook(api: FolioAPI) { if (!res.canInstall) { await alertMessage( "Automatic install unavailable", - "This release has no installable Linux archive, or this Folio is not the user-local install. Download it manually from the releases page." + "This release has no installable Linux archive, or this Folio is not the user-local install." + + (res.url ? ` Download it manually from ${res.url}` : " Download it manually from the releases page.") ); return; } - let lastPct = -1; - api.onUpdateProgress(({ received, total }) => { - const pct = total ? Math.floor((received / total) * 100) : -1; - if (pct !== lastPct) { + // Registered once: checkForUpdates can run repeatedly per session and + // ipcRenderer.on would otherwise stack duplicate progress listeners. + if (!updateProgressWired) { + updateProgressWired = true; + let lastPct = -1; + api.onUpdateProgress(({ received, total }) => { + const pct = total ? Math.floor((received / total) * 100) : -1; + if (pct === lastPct) return; lastPct = pct; showToast(pct >= 0 ? `Downloading update… ${pct}%` : `Downloading update… ${(received / 1048576).toFixed(1)} MB`); - } - }); + }); + } const up = await api.performUpdate(); if (up && up.error) { await alertMessage("Update failed", up.error); diff --git a/src/renderer/book/toolbar.ts b/src/renderer/book/toolbar.ts index 1e7544d..49dbf5b 100644 --- a/src/renderer/book/toolbar.ts +++ b/src/renderer/book/toolbar.ts @@ -201,17 +201,10 @@ export function createToolbarFeature(ctx: BookCtx, editor: Editor): ToolbarFeatu // any of those closes the dropdowns. window.addEventListener("scroll", closeDropdowns, true); window.addEventListener("resize", closeDropdowns); - // Electron has no default right-click menu, so show one with Cut/Copy/Paste. - window.addEventListener("contextmenu", (e) => { - // Let Chromium show its native spelling suggestions when the target is a - // misspelled word. The custom menu is used for the rest of the editor. - const target = e.target as HTMLElement | null; - if (target?.closest(".ProseMirror") && window.getSelection()?.isCollapsed) { - return; - } - e.preventDefault(); - api.showContextMenu(e.clientX, e.clientY); - }); + // Note: right-click is handled by the main process (webContents + // context-menu with spelling suggestions + Cut/Copy/Paste), so no DOM + // contextmenu listener here — preventing the default would suppress the + // native suggestion menu for misspelled words. const headingSelect = $("headingSelect") as HTMLSelectElement; headingSelect.addEventListener("change", () => { diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index d60af22..93c9abc 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -39,7 +39,8 @@ export type FolioAPI = { revealInFolder: (p: string) => Promise; getRecents: () => Promise; closeBook: () => Promise; - showContextMenu: (x: number, y: number) => void; + getSpellcheck: () => Promise; + setSpellcheck: (on: boolean) => Promise; getBookMeta: () => Promise; setComments: (comments: Record) => Promise; getChapterList: () => Promise; diff --git a/src/renderer/index.css b/src/renderer/index.css index 1cb21dd..9495beb 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -44,23 +44,29 @@ html, body { color: var(--color-text); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + overflow: hidden; + max-width: 100vw; } #root { display: flex; height: 100vh; + width: 100vw; + max-width: 100vw; overflow: hidden; } .sidebar { flex: 0 0 280px; min-width: 240px; - max-width: 360px; + max-width: min(360px, 85vw); + flex-shrink: 0; background: var(--color-panel); border-right: 1px solid var(--color-border); display: flex; flex-direction: column; padding: var(--gap); + overflow-x: clip; } .sidebar-header { @@ -342,8 +348,10 @@ html, body { .editor { flex: 1; display: flex; + min-width: 0; + max-width: 100%; background: var(--color-bg); - overflow: visible; + overflow: hidden; } .editor-placeholder { @@ -716,23 +724,6 @@ body.dragging-active * { font-weight: 600; } -.drag-handle { - cursor: grab; - padding: 4px 6px; - font-size: 1.05rem; - line-height: 1; - color: var(--color-muted); - opacity: 0.6; - border-radius: 4px; - transition: opacity 0.12s ease, color 0.12s ease; - user-select: none; -} - -.chapter-row:hover .drag-handle { - opacity: 0.9; - color: var(--color-on-accent); -} - .row-title { flex: 1; overflow: hidden; @@ -909,6 +900,7 @@ body.dragging-active * { flex: 1; display: flex; flex-direction: column; + min-width: 0; padding: 0; overflow: hidden; } @@ -917,17 +909,22 @@ body.dragging-active * { flex: 1; display: flex; flex-direction: column; + min-width: 0; padding: 0; overflow: hidden; } .chapter-header { display: flex; - align-items: baseline; + align-items: center; justify-content: space-between; gap: 12px; padding: 14px 20px; border-bottom: 1px solid var(--color-border); + flex-wrap: wrap; + min-width: 0; + max-width: 100%; + overflow-x: clip; } .chapter-title { @@ -936,6 +933,11 @@ body.dragging-active * { font-weight: 600; margin: 0; color: var(--color-text); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; } .word-count { @@ -946,6 +948,11 @@ body.dragging-active * { .editor-actions { display: flex; align-items: center; + flex-wrap: wrap; + row-gap: 6px; + justify-content: flex-end; + min-width: 0; + max-width: 100%; gap: 10px; color: var(--color-muted); font-size: 0.72rem; @@ -1156,9 +1163,13 @@ body.dragging-active * { .editor-toolbar { display: flex; align-items: center; - flex-wrap: nowrap; - overflow: visible; + align-content: flex-start; + flex-wrap: wrap; + overflow-x: clip; + max-width: 100%; + min-width: 0; gap: 4px; + row-gap: 6px; padding: 6px 12px; border-bottom: 1px solid var(--color-border); background: var(--color-panel); @@ -1170,11 +1181,15 @@ body.dragging-active * { .toolbar-group { display: flex; align-items: center; + flex-wrap: wrap; + row-gap: 6px; gap: 2px; padding-right: 8px; margin-right: 8px; border-right: 1px solid var(--color-border); - flex: 0 0 auto; + flex: 0 1 auto; + min-width: 0; + max-width: 100%; } .toolbar-group:last-child { @@ -1991,3 +2006,40 @@ body:not(.dragging-active) .chapter-row { animation: none !important; } } + +/* ---- narrow / tiled windows (Hyprland split-screen etc.) ---- + Below ~900px the sidebar + full header + toolbar can't fit side by side, + so shrink the chrome instead of overflowing past the tile edge. */ +@media (max-width: 900px) { + .sidebar { + flex-basis: 220px; + min-width: 200px; + } + .brand-icon { + width: 48px; + height: 48px; + } + .chapter-header { + padding: 10px 12px; + } + .editor-toolbar { + padding: 6px 8px; + } + .toolbar-select { + max-width: 110px; + } + .toolbar-group { + padding-right: 6px; + margin-right: 6px; + } +} + +@media (max-width: 640px) { + .sidebar { + flex-basis: 200px; + min-width: 180px; + } + .chapter-title { + font-size: 1.15rem; + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 9e7a5e8..f935365 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -57,7 +57,7 @@
- +
diff --git a/tests/run-search-test.mjs b/tests/run-search-test.mjs new file mode 100644 index 0000000..2cc5d17 --- /dev/null +++ b/tests/run-search-test.mjs @@ -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 & bye", 4, 4); +ok(snip.includes("") && snip.includes("<hi>"), "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>&", "escapeHtml works"); + +cleanupBundle(); +if (failures) { console.error(`${failures} search check(s) FAILED`); process.exit(1); } +console.log("All search checks passed."); diff --git a/tests/run-update-import-test.mjs b/tests/run-update-import-test.mjs index d00700c..ac1ce8e 100644 --- a/tests/run-update-import-test.mjs +++ b/tests/run-update-import-test.mjs @@ -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"); diff --git a/tests/run-update-installer-test.mjs b/tests/run-update-installer-test.mjs new file mode 100644 index 0000000..c53d984 --- /dev/null +++ b/tests/run-update-installer-test.mjs @@ -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.");