From ce65926c2e663841eebb2661247756e3d953f918 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 1 Oct 2026 07:56:23 -0500 Subject: [PATCH 1/8] Release(cli): bundle the verified 0.6.1 terminal release Desktop-managed zn installations still receive CLI 0.6.0. Pin the released 0.6.1 archives and their source commit (4c4038e) for all four supported desktop targets, keeping integration protocol 1, and bring the in-app manual's terminal entry up to date with what 0.6.1 changes for desktop users: `zn vault list` now says which entries this app saved and which zn saved, so `zn vault remove` stops being trial and error; every command starts without waiting on a terminal that never answers a background-color query; and the help output lines up. The four digests were read from the release's asset digests and matched against its checksums.txt. Each archive was staged through `npm run terminal:stage`, the same download, checksum and native architecture validation packaging runs, and the native darwin-arm64 integration probe answered protocol 1, version 0.6.1. --- apps/desktop/terminal-release.json | 20 ++++++++++---------- packages/app-core/src/lib/help.ts | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index 9a64982c..e3bd2307 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.6.0", - "commit": "5d18fc86b58f94d7ffe90cbf9767904a20f33e9e", + "version": "0.6.1", + "commit": "4c4038e7a0ff204868729f64c3e372e54fb50307", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.0/zn_0.6.0_darwin_arm64.tar.gz", - "sha256": "5102fce1a2ee2cf9c7358dd0eb8d929023a5675ceb693a96ad2fa50fc6936795" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.1/zn_0.6.1_darwin_arm64.tar.gz", + "sha256": "7223eea660a270cf01d2e1015940c131014b8497d0db6b24a0acbb9c546263d7" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.0/zn_0.6.0_darwin_amd64.tar.gz", - "sha256": "514a642c820bbbba9f93b6e29626757ba165bc8f69ee967b6304054ad65633b2" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.1/zn_0.6.1_darwin_amd64.tar.gz", + "sha256": "8f3b3190b243d18e84f45a8c4b03eff9c9fa63ea3f815df7a757d674a1f8c652" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.0/zn_0.6.0_linux_arm64.tar.gz", - "sha256": "9c7bfb76ad248585cccd61fedea9a0e6547afd1f14cc96191799996478118142" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.1/zn_0.6.1_linux_arm64.tar.gz", + "sha256": "f17202e930ac2916cee113d0c7dcacc5bfcbf04db601306a13cc8f757b7c576a" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.0/zn_0.6.0_linux_amd64.tar.gz", - "sha256": "7e41439f78cceeb80e10a7d0caa68c7ae80986e821cca4d0f5787fdd597c426b" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.1/zn_0.6.1_linux_amd64.tar.gz", + "sha256": "9b082f58c2db052f44ede345539491f07578745971c847471e5cf1b1786ed256" } } } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index a7a11230..0252ede7 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1227,9 +1227,9 @@ export const HELP_CLI: HelpCard[] = [ 'Builds with the Go terminal tool include `zn tui`. Update and open ZenNotes once to upgrade an existing desktop-managed CLI; keep using the same `zn` commands. Settings shows the installed terminal version and offers Repair if an upgrade needs attention. Desktop-installed commands keep following the desktop vault, while the TUI remembers its own selection. Explicit `--vault` and `--server` flags still win. Set `ZENNOTES_WORKSPACE_SOURCE=terminal` to use the terminal default for a command, or `ZENNOTES_CLI_ENGINE=legacy` to run the previous CLI during the transition. Homebrew and manual installations stay managed by their own installer. A shortcut left behind by a moved Mac app or an old AppImage can be repaired from Settings: review the old target, replacement and backup path before choosing Repair shortcut. Note saves preserve creation dates in small files under `.zennotes/note-metadata` without changing Markdown; keep the `.zennotes` folder with vault backups. Explicit legacy rollback needs the original app resources to remain available.' }, { - title: 'CLI 0.6.0 in desktop 2.59.0', + title: 'CLI 0.6.1 in desktop 2.60.0', body: - 'On macOS and Linux, desktop 2.59.0 bundles CLI 0.6.0. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Run `zn update --check` to check the CLI release; a desktop-managed CLI updates with the desktop app. The full command reference is at https://zennotes.org/tui/docs.' + 'On macOS and Linux, desktop 2.60.0 bundles CLI 0.6.1. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. In 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. Run `zn update --check` to check the CLI release; a desktop-managed CLI updates with the desktop app. The full command reference is at https://zennotes.org/tui/docs.' }, { title: 'No app required', From 3e9934546dbf749d04d52f017606d40b7483024d Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 1 Oct 2026 07:56:42 -0500 Subject: [PATCH 2/8] Release: align desktop and shared packages at 2.60.0 --- apps/desktop/package.json | 2 +- apps/share-viewer/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 61eb1a00..103e913a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.59.0", + "version": "2.60.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index 8295db01..8e58c4fd 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.59.0", + "version": "2.60.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index 4b483438..feddbcb3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.59.0", + "version": "2.60.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index b8d43667..eab73b62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.59.0", + "version": "2.60.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.59.0", + "version": "2.60.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.59.0", + "version": "2.60.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,7 +874,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.59.0", + "version": "2.60.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -945,7 +945,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.59.0", + "version": "2.60.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16382,7 +16382,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.59.0", + "version": "2.60.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16469,14 +16469,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.59.0", + "version": "2.60.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.59.0", + "version": "2.60.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16488,7 +16488,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.59.0" + "version": "2.60.0" } } } diff --git a/package.json b/package.json index f2e870ab..8b26aff7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.59.0", + "version": "2.60.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 5e373e8f..17d93cd5 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.59.0", + "version": "2.60.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 9c99e96f..57e50114 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.59.0", + "version": "2.60.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index e9ed7825..ea984473 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.59.0", + "version": "2.60.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 853dd11a..a4167bff 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.59.0", + "version": "2.60.0", "type": "module", "exports": { ".": "./src/index.ts" From a0fef504ad6e209d17b51423772ed655c9ce02c9 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 1 Oct 2026 08:38:51 -0500 Subject: [PATCH 3/8] Fix(perf): open notes without recounting every backlink in the vault (#880) Switching notes in a large vault froze the window for seconds. The status bar shows how many notes link to the open one, and it counted them by resolving every wikilink in the vault, while each resolution filtered and searched the whole notes list. That is links x notes on every switch, even for a note nobody links to and for one already open in a tab. Yago Fernandes measured 4.8 s per switch in a 6,577-note vault with 13,672 links and traced 94% of the CPU time to that count. Resolution now reads lookup tables built once per notes array: first note per normalized title, first note and count per normalized path, and the same per `/`-led path tail, which is every string `endsWith` could match. backlinksForNote resolves the vault's links once into an incoming map, so a note switch is a lookup. Store updates replace the notes array, so its identity is the revision; length and both ends are rechecked so a caller growing an array in place cannot read a stale table. Matching does not change: `first` is the note the old find returned, `count` is the length the old filter saw, trash is still skipped and anchors are still stripped. A differential run over 35,411 targets (every link plus each note's title and path in 19 spellings) and 224 edge cases returned identical notes, and identical backlink lists for all 6,577 notes. Atlas, wikilink rendering, link clicks and the Connections panel's wikilink matching share the tables. In the built app, a vault shaped like the report (6,542 notes, 12,973 links) went from 1,077 ms to 77 ms click to paint per switch, and from 1,077 ms to 61 ms to reopen a note, with the same backlink counts. A test counts title reads across 200 switches: the old code made 1.2 billion, the new one at most 2,000. This leaves the Connections panel's disk scan and Markdown-link matching as they were, and adds no exclusions (#431). --- .../app-core/src/components/StatusBar.tsx | 9 +- packages/app-core/src/lib/wikilinks.test.ts | 65 ++++++++ packages/app-core/src/lib/wikilinks.ts | 140 +++++++++++++++--- 3 files changed, 186 insertions(+), 28 deletions(-) diff --git a/packages/app-core/src/components/StatusBar.tsx b/packages/app-core/src/components/StatusBar.tsx index 94cf43c5..c095d0e3 100644 --- a/packages/app-core/src/components/StatusBar.tsx +++ b/packages/app-core/src/components/StatusBar.tsx @@ -39,10 +39,11 @@ export function StatusBar({ note }: { note: NoteContent | null }): JSX.Element { }, [note?.body]); // Backlinks depend only on the active note's *path* and the vault's - // wikilink metadata — never on the note body. Keying the memo on - // `note.path` (instead of the whole `note` object, which changes on every - // keystroke) keeps this O(n) scan off the typing hot path while producing - // an identical count. + // wikilink metadata, never on the note body. backlinksForNote resolves the + // vault's links once per notes array, so a note switch is a map lookup + // (#880). Keying the memo on `note.path` (instead of the whole `note` + // object, which changes on every keystroke) keeps even that lookup off the + // typing hot path while producing an identical count. const backlinks = useMemo(() => { if (!note) return 0; return backlinksForNote(notes as NoteMeta[], note).length; diff --git a/packages/app-core/src/lib/wikilinks.test.ts b/packages/app-core/src/lib/wikilinks.test.ts index acc6a666..b1f7cc9a 100644 --- a/packages/app-core/src/lib/wikilinks.test.ts +++ b/packages/app-core/src/lib/wikilinks.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + backlinksForNote, blockAnchorsTargeting, extractWikilinkTargets, extractMarkdownLinkHrefs, @@ -261,3 +262,67 @@ describe('resolveWikilinkTarget trims slash runs without regex backtracking', () expect(resolveWikilinkTarget(notes, '/'.repeat(20000))).toBeNull() }) }) + +describe('backlinksForNote (#880)', () => { + const vault = [ + { path: 'inbox/Hub.md', title: 'Hub', folder: 'inbox' as const, wikilinks: ['Hub', 'Leaf'] }, + { path: 'inbox/Leaf.md', title: 'Leaf', folder: 'inbox' as const, wikilinks: ['Hub#Intro', 'hub', 'Hub^b1'] }, + { path: 'trash/Old.md', title: 'Old', folder: 'trash' as const, wikilinks: ['Hub'] }, + { path: 'inbox/a/Twin.md', title: 'Twin', folder: 'inbox' as const, wikilinks: [] }, + { path: 'archive/a/Twin.md', title: 'Twin', folder: 'archive' as const, wikilinks: ['inbox/Hub'] }, + { path: 'inbox/Ref.md', title: 'Ref', folder: 'inbox' as const, wikilinks: ['a/Twin', 'Twin', 'Missing'] } + ] + + it('lists linking notes once each, in notes order, skipping trash and self-links', () => { + expect(backlinksForNote(vault, { path: 'inbox/Hub.md' }).map((n) => n.path)).toEqual([ + 'inbox/Leaf.md', + 'archive/a/Twin.md' + ]) + }) + + it('keeps ambiguity rules: a shared path tail resolves nowhere, a shared title to the first note', () => { + expect(backlinksForNote(vault, { path: 'inbox/a/Twin.md' }).map((n) => n.path)).toEqual([ + 'inbox/Ref.md' + ]) + expect(backlinksForNote(vault, { path: 'archive/a/Twin.md' })).toEqual([]) + }) + + it('returns a copy, so a caller cannot change the next answer', () => { + backlinksForNote(vault, { path: 'inbox/Hub.md' }).length = 0 + expect(backlinksForNote(vault, { path: 'inbox/Hub.md' })).toHaveLength(2) + }) + + it('sees notes added to the same array after an earlier call', () => { + const live = [{ path: 'inbox/A.md', title: 'A', folder: 'inbox' as const, wikilinks: ['B'] }] + expect(resolveWikilinkTarget(live, 'B')).toBeNull() + expect(backlinksForNote(live, { path: 'inbox/B.md' })).toEqual([]) + live.push({ path: 'inbox/B.md', title: 'B', folder: 'inbox' as const, wikilinks: [] }) + expect(resolveWikilinkTarget(live, 'B')?.path).toBe('inbox/B.md') + expect(backlinksForNote(live, { path: 'inbox/B.md' }).map((n) => n.path)).toEqual(['inbox/A.md']) + }) + + // Counting backlinks used to resolve every link against every note, so each + // note switch cost (links x notes) in a large vault. Count the reads instead + // of timing them: switching between many notes must not rescan the vault. + it('resolves the vault once, not once per note switch', () => { + let reads = 0 + const size = 2000 + const big = Array.from({ length: size }, (_, i) => { + const note = { + path: `inbox/n${i}.md`, + folder: 'inbox' as const, + wikilinks: [`n${(i + 1) % size}`, `missing ${i}`, `x/n${i}`] + } + return Object.defineProperty(note, 'title', { + get: () => { + reads++ + return `n${i}` + } + }) as typeof note & { title: string } + }) + for (let i = 0; i < 200; i++) { + expect(backlinksForNote(big, big[i])).toEqual([big[(i + size - 1) % size]]) + } + expect(reads).toBeLessThanOrEqual(size) + }) +}) diff --git a/packages/app-core/src/lib/wikilinks.ts b/packages/app-core/src/lib/wikilinks.ts index b21cad79..66ac4a48 100644 --- a/packages/app-core/src/lib/wikilinks.ts +++ b/packages/app-core/src/lib/wikilinks.ts @@ -154,7 +154,85 @@ export function isSameFileHeadingLink(target: string): boolean { return stripWikilinkAnchor(target).trim() === '' && wikilinkHeadingAnchor(target) != null } -function resolveExplicitPath(notes: NoteRef[], target: string): NoteRef | null { +/** The first note with a key, and how many notes share it. */ +interface KeyedNotes { + first: T + count: number +} + +/** + * Lookup tables for one notes array, so a target costs a few map reads + * instead of a scan of the vault. Resolution used to filter and search every + * note for every target, and callers resolve targets in bulk: the status bar + * counted backlinks by resolving every link in the vault on each note switch, + * about five seconds in a 6,577-note vault with 13,672 links (#880). + * + * Every table covers non-trash notes in array order, so `first` is the note + * the old `find` returned and `count` is the length the old `filter` saw. + */ +interface ResolverIndex { + byTitle: Map + byPath: Map> + /** Keyed on every `/`-led tail of a path, the strings `endsWith` could match. */ + byPathTail: Map> +} + +/** + * Per-array memo. Store updates replace the notes array, so identity is the + * revision. Length and both ends are rechecked because a caller that grows + * or splices an array in place would otherwise read a stale index. + */ +interface ArrayMemo { + length: number + head: unknown + tail: unknown + value: V +} + +function memoForArray( + cache: WeakMap>, + notes: K & readonly unknown[], + build: () => V +): V { + const hit = cache.get(notes) + const head = notes[0] + const tail = notes[notes.length - 1] + if (hit && hit.length === notes.length && hit.head === head && hit.tail === tail) { + return hit.value + } + const value = build() + cache.set(notes, { length: notes.length, head, tail, value }) + return value +} + +function addKeyed(map: Map>, key: string, note: T): void { + const entry = map.get(key) + if (entry) entry.count++ + else map.set(key, { first: note, count: 1 }) +} + +const resolverIndexes = new WeakMap>>() + +function resolverIndexFor(notes: readonly T[]): ResolverIndex { + return memoForArray(resolverIndexes, notes, () => { + const byTitle = new Map() + const byPath = new Map>() + const byPathTail = new Map>() + for (const note of notes) { + if (note.folder === 'trash') continue + const title = normalizeForCompare(note.title) + if (!byTitle.has(title)) byTitle.set(title, note) + const path = normalizeForCompare(note.path) + addKeyed(byPath, path, note) + for (let slash = path.indexOf('/'); slash !== -1; slash = path.indexOf('/', slash + 1)) { + addKeyed(byPathTail, path.slice(slash), note) + } + } + return { byTitle, byPath, byPathTail } + }) as ResolverIndex +} + +function resolveExplicitPath(index: ResolverIndex, target: string): T | null { const normalized = normalizeSlashes(target.trim()) if (!normalized) return null @@ -169,36 +247,33 @@ function resolveExplicitPath(notes: NoteRef[], target: string): NoteRef | null { } if (!relPath) return null - const needle = normalizeForCompare(relPath) - return notes.find((note) => normalizeForCompare(note.path) === needle) ?? null + return index.byPath.get(normalizeForCompare(relPath))?.first ?? null } -function resolvePathSuffix(notes: NoteRef[], target: string): NoteRef | null { +function resolvePathSuffix(index: ResolverIndex, target: string): T | null { const trimmed = trimSlashes(stripMdExtension(normalizeSlashes(target.trim()))) if (!trimmed) return null - const suffix = normalizeForCompare(`/${trimmed}.md`) - const exact = normalizeForCompare(`${trimmed}.md`) - const matches = notes.filter((note) => { - const path = normalizeForCompare(note.path) - return path === exact || path.endsWith(suffix) - }) - return matches.length === 1 ? matches[0] : null + // A path equal to `exact` can never also end with `/` + `trimmed`, which is + // longer, so the two groups never share a note and their counts add. + const exact = index.byPath.get(normalizeForCompare(`${trimmed}.md`)) + const suffix = index.byPathTail.get(normalizeForCompare(`/${trimmed}.md`)) + if ((exact?.count ?? 0) + (suffix?.count ?? 0) !== 1) return null + return (exact ?? suffix)!.first } export function resolveWikilinkTarget(notes: T[], target: string): T | null { // `[[Doc#Heading]]` / `[[Doc^block]]` point at a spot inside Doc — resolve the // document, ignoring the anchor. (#196) const doc = stripWikilinkAnchor(target) - const visible = notes.filter((note) => note.folder !== 'trash') + const index = resolverIndexFor(notes) if (isPathLikeWikilinkTarget(doc)) { - return (resolveExplicitPath(visible, doc) ?? - resolvePathSuffix(visible, doc)) as T | null + return resolveExplicitPath(index, doc) ?? resolvePathSuffix(index, doc) } const needle = normalizeForCompare(stripMdExtension(doc)) if (!needle) return null - return visible.find((note) => normalizeForCompare(note.title) === needle) ?? null + return index.byTitle.get(needle) ?? null } /** @@ -220,19 +295,36 @@ export function resolveWikilinkPath( return null } -export function backlinksForNote>( +type LinkingNote = NoteRef & Pick + +const backlinkIndexes = new WeakMap>>() + +/** + * Notes whose wikilinks resolve to `current`, in notes order. Every link in the + * vault is resolved once per notes array into an incoming map, so asking about + * another note (a note switch) is a lookup, not another pass over the vault. + */ +export function backlinksForNote( notes: T[], current: Pick ): T[] { - const out: T[] = [] - for (const note of notes) { - if (note.folder === 'trash' || note.path === current.path) continue - if (!note.wikilinks?.length) continue - if (note.wikilinks.some((target) => resolveWikilinkTarget(notes, target)?.path === current.path)) { - out.push(note) + const incoming = memoForArray(backlinkIndexes, notes, () => { + const map = new Map() + for (const note of notes) { + if (note.folder === 'trash' || !note.wikilinks?.length) continue + const linked = new Set() + for (const target of note.wikilinks) { + const path = resolveWikilinkTarget(notes, target)?.path + if (path === undefined || path === note.path || linked.has(path)) continue + linked.add(path) + const sources = map.get(path) + if (sources) sources.push(note) + else map.set(path, [note]) + } } - } - return out + return map + }) + return (incoming.get(current.path) ?? []).slice() as T[] } /** From cf888a2674dd17d2f62093a55e2deb5198cc2d65 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 1 Oct 2026 08:39:01 -0500 Subject: [PATCH 4/8] Perf(harness): seed wikilinks and time note switches to paint (#880) The desktop runtime benchmark could not see #880. Its synthetic vault had no wikilinks, so resolving every link against every note cost nothing, and its note-open budget reads the store's note.open.* sample, which finishes before React renders the note. On the 2.59.0 code with links seeded, that sample stayed at 30.6 ms while the window froze for 1.5 s per switch. ZEN_PERF_WIKILINKS_PER_NOTE seeds links into every synthetic note with a fixed seed and the report's mix: about half resolve (titles, #Details and ^block anchors, inbox/ and / paths), the rest name notes that do not exist, which were the expensive ones. It defaults to 0, and a 0 run seeds a vault byte-identical to before (checked on 5,000 notes), so older numbers stay comparable. ZEN_PERF_DESKTOP_NOTES=6577 with 2 per note is roughly the reported vault. Every run now also opens 5 notes and reopens 2, timed from click until the editor's doc sync for that path, the matching active tab and two frames. That finish line lands after the commit that renders the note and the status bar, and reads no seeded content, so it works on an external vault. The step runs after the heap, DOM and long-task numbers are taken, and `note switch wall p50` gets a 150 ms budget (enforced only under ZEN_PERF_ENFORCE, like the rest, and in perf:runtime-repeat). Same harness, 6,577 notes, 13,154 links: 1,513.6 ms on the 2.59.0 code, 33.7 ms now. The search step sends Ctrl+P instead of Meta+P off macOS; the reporter had to patch that to run the harness on Linux. The web harness is unchanged. --- tooling/scripts/perf-desktop-runtime.mjs | 150 ++++++++++++++++++++++- tooling/scripts/perf-runtime-repeat.mjs | 10 ++ 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/tooling/scripts/perf-desktop-runtime.mjs b/tooling/scripts/perf-desktop-runtime.mjs index cf7e5840..cd28aa14 100644 --- a/tooling/scripts/perf-desktop-runtime.mjs +++ b/tooling/scripts/perf-desktop-runtime.mjs @@ -35,6 +35,19 @@ const cpuThrottleRate = parsePositiveFloat( ) const largeNoteLines = parseNonNegativeInt(process.env.ZEN_PERF_LARGE_NOTE_LINES, 0) const largeNoteIndex = parseNonNegativeInt(process.env.ZEN_PERF_LARGE_NOTE_INDEX, 159) +// Wikilinks seeded into each synthetic note. Zero keeps the notes +// byte-identical to older runs, so their numbers stay comparable. The seeded +// vault used to have no links at all, which is how #880 got past this +// harness: the status bar resolved every link in the vault against every +// note on each note switch, ~5 s per switch in a 6,577-note vault with 13,672 +// links, and a vault without links made that cost invisible. +// `ZEN_PERF_DESKTOP_NOTES=6577 ZEN_PERF_WIKILINKS_PER_NOTE=2` is roughly the +// reported vault. +const wikilinksPerNote = parseNonNegativeInt(process.env.ZEN_PERF_WIKILINKS_PER_NOTE, 0) +// Note switches, timed from click to paint. They run after the heap, DOM and +// long-task numbers are taken, so those stay comparable with older runs. +const noteSwitchCount = parseNonNegativeInt(process.env.ZEN_PERF_NOTE_SWITCHES, 5) +const noteReopenCount = parseNonNegativeInt(process.env.ZEN_PERF_NOTE_REOPENS, 2) const configuredSearchQuery = process.env.ZEN_PERF_DESKTOP_SEARCH_QUERY?.trim() || process.env.ZEN_PERF_SEARCH_QUERY?.trim() || null @@ -43,6 +56,7 @@ const budgets = { rendererReadyMs: parsePositiveInt(process.env.ZEN_PERF_DESKTOP_BUDGET_READY_MS, 1800), expansionMs: parsePositiveInt(process.env.ZEN_PERF_DESKTOP_BUDGET_EXPAND_MS, 80), noteOpenMs: parsePositiveInt(process.env.ZEN_PERF_DESKTOP_BUDGET_OPEN_MS, 120), + noteSwitchMs: parsePositiveInt(process.env.ZEN_PERF_DESKTOP_BUDGET_SWITCH_MS, 150), searchInputMs: parsePositiveInt(process.env.ZEN_PERF_DESKTOP_BUDGET_SEARCH_MS, 120), scrollMs: parsePositiveInt(process.env.ZEN_PERF_DESKTOP_BUDGET_SCROLL_MS, 80), maxLongTaskMs: parsePositiveInt(process.env.ZEN_PERF_DESKTOP_BUDGET_LONG_TASK_MS, 180), @@ -211,6 +225,42 @@ function largeNoteBody(lines, id) { return `\n## Large note stress section\n\n${blocks.join('\n')}\n` } +// mulberry32: a fixed seed gives every run the same link graph. +function seededRandom(seed) { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function seededNoteStem(index) { + return `${formatIndex(index)} - topic-${index % 20}` +} + +// The #880 mix: about half the links resolve (titles, `#Details` and `^block` +// anchors, `inbox/` and `/` paths), the rest name notes that do not exist, by +// title or by path, each one unique. Unresolved targets were the expensive +// ones, since nothing short of the whole vault could rule them out. +function seededWikilinks(count, random, missing) { + const links = [] + for (let link = 0; link < wikilinksPerNote; link += 1) { + const stem = seededNoteStem(Math.floor(random() * count)) + const roll = random() + if (roll < 0.35) links.push(stem) + else if (roll < 0.45) links.push(`${stem}#Details`) + else if (roll < 0.5) links.push(`${stem}^block-${link}`) + else if (roll < 0.6) links.push(`inbox/${stem}`) + else if (roll < 0.65) links.push(`/${stem}`) + else if (roll < 0.85) links.push(`Missing note ${missing.next++}`) + else links.push(`projects/missing-${missing.next++}`) + } + return links +} + async function writeFilesInBatches(files, batchSize = 96) { for (let index = 0; index < files.length; index += batchSize) { await Promise.all(files.slice(index, index + batchSize).map(([path, body]) => writeFile(path, body))) @@ -230,12 +280,15 @@ async function seedVault(vaultRoot, count) { ]) const files = [] + const random = seededRandom(880) + const missing = { next: 0 } + let wikilinks = 0 for (let index = 0; index < count; index += 1) { const id = formatIndex(index) const topic = index % 20 const sprint = index % 13 const title = `Perf Note ${id} Topic ${topic}` - const body = `# ${title} + let body = `# ${title} This is a synthetic desktop runtime benchmark note for ZenNotes. It contains searchable token desktop-runtime-benchmark-${id} and shared topic-${topic}. @@ -251,9 +304,15 @@ The body is intentionally modest so note-open timing measures app overhead more #perf #topic-${topic} #sprint-${sprint} ${largeNoteLines > 0 && index === Math.min(count - 1, largeNoteIndex) ? largeNoteBody(largeNoteLines, id) : ''}` - files.push([join(inbox, `${id} - topic-${topic}.md`), body]) + if (wikilinksPerNote > 0) { + const links = seededWikilinks(count, random, missing) + wikilinks += links.length + body += `\n## Links\n\n${links.map((target) => `- [[${target}]]`).join('\n')}\n` + } + files.push([join(inbox, `${seededNoteStem(index)}.md`), body]) } await writeFilesInBatches(files) + return { wikilinks } } async function seedUserData(userDataRoot, vaultRoot) { @@ -598,9 +657,10 @@ async function main() { try { const seedStartedAt = performance.now() + let seededWikilinkCount = 0 if (!externalVaultRoot) { if (skipSyntheticSeed) await access(vaultRoot, constants.R_OK) - else await seedVault(vaultRoot, noteCount) + else seededWikilinkCount = (await seedVault(vaultRoot, noteCount)).wikilinks } await seedUserData(userDataRoot, vaultRoot) const seedMs = round(performance.now() - seedStartedAt) @@ -832,10 +892,12 @@ async function main() { const search = await evaluate( client, `(async () => { + // Mod+P: Ctrl on Linux and Windows, where Meta+P opens nothing. window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', code: 'KeyP', - metaKey: true, + metaKey: ${process.platform === 'darwin'}, + ctrlKey: ${process.platform !== 'darwin'}, bubbles: true, cancelable: true })); @@ -920,6 +982,68 @@ async function main() { await evaluate(client, `(() => { globalThis.gc?.(); globalThis.gc?.(); return true })()`) const runtimeMetrics = summarizePerformanceMetrics(await client.send('Performance.getMetrics')) + // Click to paint, per note: the `note.open.*` samples above finish when the + // store has the note, before React renders it, so they stayed fast while + // #880 froze every switch for seconds in that render. The finish line here + // is the editor's doc sync for the clicked path, which lands after the + // commit that renders the note, its tab and the status bar, plus two + // frames. It reads no seeded content, so it works on an external vault. + const noteSwitches = noteSwitchCount > 0 + ? await evaluate( + client, + `(async () => { + const nextFrames = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const rowSelector = '[data-notelist-path], [data-sidebar-type="note"]'; + const pathOf = (row) => row.getAttribute('data-notelist-path') ?? row.getAttribute('data-sidebar-path'); + const rowFor = (path) => [...document.querySelectorAll(rowSelector)].find((row) => pathOf(row) === path) ?? null; + const activeTabPath = () => document.querySelector('[data-tab-active="true"][data-tab-path]')?.getAttribute('data-tab-path') ?? null; + // pathChanged is only ruled out when false: an older packaged app + // (ZEN_PERF_DESKTOP_APP_PATH) may record the sync without it, and the + // tab check still pins the path. + const editorShows = (path, since) => { + const synced = (window.__ZEN_PERF__?.getSamples?.() ?? []).some((sample) => + sample.at >= since && + ((sample.name === 'editor.doc.sync' && sample.detail?.pathChanged !== false) || sample.name === 'editor.mount.view') + ); + const tab = activeTabPath(); + return synced && (tab === null || tab === path); + }; + const open = async (path) => { + rowFor(path)?.scrollIntoView({ block: 'nearest' }); + await nextFrames(); + const row = rowFor(path); + if (!row) throw new Error('No row for ' + path); + const startedAt = performance.now(); + row.click(); + while (!editorShows(path, startedAt)) { + if (performance.now() - startedAt > 30000) throw new Error('Timed out switching to ' + path); + await new Promise((resolve) => setTimeout(resolve, 4)); + } + await nextFrames(); + return { path, wallMs: Math.round((performance.now() - startedAt) * 100) / 100 }; + }; + + // In sidebar order, whether on screen or not: the scroll step leaves + // the list past its last row, and open() scrolls each row into view + // before its clock starts. + const current = activeTabPath(); + const targets = [...new Set( + [...document.querySelectorAll(rowSelector)] + .map(pathOf) + .filter((path) => path && path !== current && /\\.md$/i.test(path)) + )].slice(0, ${noteSwitchCount}); + if (targets.length === 0) throw new Error('No note rows to switch between'); + const switches = []; + for (const path of targets) switches.push(await open(path)); + const reopens = []; + for (const path of targets.slice(0, ${noteReopenCount})) reopens.push(await open(path)); + return { switches, reopens }; + })()` + ) + : { switches: [], reopens: [] } + const switchWalls = noteSwitches.switches.map((entry) => entry.wallMs) + const reopenWalls = noteSwitches.reopens.map((entry) => entry.wallMs) + const mainPerfSamples = parseMainPerfSamples(electron.log()) const mainReady = mainPerfSamples.find((sample) => sample.name === 'main.window.ready-to-show') ?? null const mainFinish = mainPerfSamples.find((sample) => sample.name === 'main.window.did-finish-load') ?? null @@ -932,6 +1056,9 @@ async function main() { budgetStatus('renderer workspace ready', startup.ready.durationMs, budgets.rendererReadyMs), budgetStatus('inbox expansion', inboxExpansion.wallMs, budgets.expansionMs), budgetStatus('note open sample', noteOpen.sample.durationMs, budgets.noteOpenMs), + ...(switchWalls.length > 0 + ? [budgetStatus('note switch wall p50', round(percentile(switchWalls, 50)), budgets.noteSwitchMs)] + : []), budgetStatus('search input', search.wallMs, budgets.searchInputMs), budgetStatus('virtual scroll', scroll.wallMs, budgets.scrollMs), budgetStatus('max long task', longTaskSummary.maxMs, budgets.maxLongTaskMs), @@ -947,6 +1074,8 @@ async function main() { printMetric('cpu throttle rate', cpuThrottleRate, '') printMetric('large note lines', largeNoteLines, '') printMetric('large note index', largeNoteLines > 0 ? Math.min(noteCount - 1, largeNoteIndex) : 0, '') + printMetric('wikilinks per note', externalVaultRoot ? 0 : wikilinksPerNote, '') + printMetric('seeded wikilinks', seededWikilinkCount, '') printMetric('search query length', searchQuery.length, '') printMetric('seed vault + config', seedMs) printMetric('main ready-to-show', mainReady?.durationMs ?? 0) @@ -979,6 +1108,19 @@ async function main() { printMetric(sample.name, sample.durationMs) } } + printMetric('note switch count', switchWalls.length, '') + if (switchWalls.length > 0) { + printMetric('note switch wall p50', round(percentile(switchWalls, 50))) + printMetric('note switch wall max', round(Math.max(...switchWalls))) + } + if (reopenWalls.length > 0) { + printMetric('note reopen wall p50', round(percentile(reopenWalls, 50))) + } + if (switchWalls.length > 0 || reopenWalls.length > 0) { + console.log('\nNote switches (click to paint)') + for (const entry of noteSwitches.switches) console.log(`- ${entry.path} ${entry.wallMs}ms`) + for (const entry of noteSwitches.reopens) console.log(`- reopen ${entry.path} ${entry.wallMs}ms`) + } printMetric('search input wall', search.wallMs) printMetric('search results', search.resultCount, '') printMetric('virtual scroll wall', scroll.wallMs) diff --git a/tooling/scripts/perf-runtime-repeat.mjs b/tooling/scripts/perf-runtime-repeat.mjs index ba08906d..5d705e30 100644 --- a/tooling/scripts/perf-runtime-repeat.mjs +++ b/tooling/scripts/perf-runtime-repeat.mjs @@ -60,6 +60,8 @@ const metricNames = { 'cpu throttle rate', 'large note lines', 'large note index', + 'wikilinks per note', + 'seeded wikilinks', 'main ready-to-show', 'main did-finish-load', 'main list notes', @@ -74,6 +76,10 @@ const metricNames = { 'note open sample', 'note open wall', 'editor ready wall', + 'note switch count', + 'note switch wall p50', + 'note switch wall max', + 'note reopen wall p50', 'search input wall', 'virtual scroll wall', 'visible rows after scroll', @@ -159,6 +165,10 @@ const repeatBudgets = { label: 'editor ready wall', budget: parsePositiveInt(process.env.ZEN_PERF_DESKTOP_BUDGET_EDITOR_READY_WALL_MS, 700) }, + { + label: 'note switch wall p50', + budget: parsePositiveInt(process.env.ZEN_PERF_DESKTOP_BUDGET_SWITCH_MS, 150) + }, { label: 'search input wall', budget: parsePositiveInt(process.env.ZEN_PERF_DESKTOP_BUDGET_SEARCH_MS, 120) From 17e45dee20f7ea2678acc070cb26e534558db918 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 1 Oct 2026 10:02:57 -0500 Subject: [PATCH 5/8] Fix(settings): open CLI Settings on the CLI page Open CLI Settings in the command palette only opened Settings, and Settings reopens on whatever page it showed last (Appearance on a fresh launch), so the command never reached the page its name promises. It now asks for the CLI page through the same settings-navigation target the status bar uses for Cloud and :unbind uses for Keymaps. Found while driving the CLI update flow over CDP, where the command landed on Appearance every time. --- packages/app-core/src/lib/commands.ts | 7 ++++++- packages/app-core/src/lib/settings-navigation.ts | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 2ff18ec9..83f47b03 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -8,6 +8,7 @@ */ import { isTagsViewActive, isTasksViewActive, isTrashViewActive, useStore } from '../store' import { confirmApp } from './confirm-requests' +import { requestSettingsTarget } from './settings-navigation' import { promptApp } from './prompt-requests' import { captureNavigationContext } from './navigation-context' import { buildMoveNotePrompt, moveNoteVocabulary, parseMoveNoteTarget } from './move-note' @@ -2074,7 +2075,11 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma when: () => window.zen.getAppInfo().runtime === 'desktop' && window.zen.getCapabilities().supportsCliInstall, - run: () => getState().setSettingsOpen(true) + run: () => { + // Without a target Settings reopens on whatever page it showed last. + requestSettingsTarget('cli') + getState().setSettingsOpen(true) + } } ) diff --git a/packages/app-core/src/lib/settings-navigation.ts b/packages/app-core/src/lib/settings-navigation.ts index 25252575..d034b11a 100644 --- a/packages/app-core/src/lib/settings-navigation.ts +++ b/packages/app-core/src/lib/settings-navigation.ts @@ -1,7 +1,7 @@ /** Settings pages other surfaces can open directly: the Cloud page from the * status bar, the Keymaps page from `:unbind` when the action id is missing - * or unknown. */ -export type SettingsNavigationTarget = "cloud" | "keymaps" | "external-links" | "about"; + * or unknown, the CLI page from Open CLI Settings. */ +export type SettingsNavigationTarget = "cloud" | "keymaps" | "external-links" | "about" | "cli"; let pendingSettingsTarget: SettingsNavigationTarget | null = null; From e07e5f5a9076fe87e1b3918aadd807aff785adde Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 1 Oct 2026 10:03:11 -0500 Subject: [PATCH 6/8] Feat(cli): keep the desktop-managed zn up to date from signed releases Every CLI fix needed a desktop release: the app copied its bundled zn back over the managed one whenever the bytes differed, and zn update refused desktop-owned installs. 2.59.0 existed only to ship CLI 0.6.0, and the pin moved five times in two weeks. For a zn that ZenNotes put on PATH, the app now checks the latest ZenNotes/tui release 90 seconds after launch and then daily. Each release carries terminal-release.json (the same schema as this repo's pin) and an Ed25519 signature over its exact bytes; RELEASE_KEYS holds the public key for zn-release-1. The manifest is verified before it is parsed, its download URL must be the release asset its version names, the archive must match the signed sha256, and the extracted zn must answer the integration probe with that version before `current` moves. A failed or tampered download never replaces a working CLI, and the version it replaced stays on disk. The bundle becomes the floor: a newer verified copy survives restarts, a tie goes to the bundle (whose copy carries this build's signature), and a desktop update that ships something newer takes over. A release on a protocol this build does not speak waits for a ZenNotes update. Settings > CLI > Updates shows the running and bundled versions, Check for updates, and Update zn automatically; off still checks and offers the install. State is pushed to open windows, so a scheduled check that lands while Settings is open shows up. The palette gains Check for zn Updates. The switch lives in the per-machine zennotes.config.json rather than config.toml: it governs this machine's copy under userData, like the install location. Homebrew, Go and manual installs are untouched, and nothing is downloaded without a ZenNotes-installed zn, the same condition MCP setups use to pick the managed binary. Verified against the real v0.6.1 release: a build bundling 0.6.0 installed it on its own, kept it across a relaunch, offered it with automatic updates off, refused a manifest edited after signing, and skipped the download with no managed zn, with PATH isolated through a stand-in login shell. The packaged, Developer ID signed, hardened app ran the ad-hoc signed download fine. 14 new tests. --- apps/desktop/src/main/cli-install.ts | 17 +- apps/desktop/src/main/cli-update-core.test.ts | 321 ++++++++++++++++++ apps/desktop/src/main/cli-update-core.ts | 220 ++++++++++++ apps/desktop/src/main/cli-update.ts | 241 +++++++++++++ apps/desktop/src/main/index.ts | 19 ++ apps/desktop/src/main/terminal-runtime.ts | 172 +++++++++- apps/desktop/src/main/vault-config.test.ts | 27 +- apps/desktop/src/main/vault.ts | 12 +- apps/desktop/src/preload/index.ts | 12 + .../app-core/src/components/SettingsModal.tsx | 132 +++++++ packages/app-core/src/lib/commands.ts | 44 +++ packages/app-core/src/lib/help.ts | 9 +- packages/bridge-contract/src/bridge.ts | 10 + packages/bridge-contract/src/ipc.ts | 39 +++ 14 files changed, 1248 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/main/cli-update-core.test.ts create mode 100644 apps/desktop/src/main/cli-update-core.ts create mode 100644 apps/desktop/src/main/cli-update.ts diff --git a/apps/desktop/src/main/cli-install.ts b/apps/desktop/src/main/cli-install.ts index 617fed13..74af2885 100644 --- a/apps/desktop/src/main/cli-install.ts +++ b/apps/desktop/src/main/cli-install.ts @@ -245,6 +245,18 @@ async function reviewRepair( return offer } +/** Where this build's own `zn` and its manifest live. */ +export function terminalBundleDir(): string { + // Named in build/after-pack.js, which says why it is not `terminal` (#869). + return app.isPackaged + ? path.join(process.resourcesPath, 'zn-cli') + : path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../build/terminal', + `${process.platform}-${process.arch}` + ) +} + async function locateWrapper(): Promise { const candidates: WrapperLocation[] = [] @@ -275,10 +287,7 @@ async function locateWrapper(): Promise { let terminal try { terminal = await prepareTerminalRuntime({ - // Named in build/after-pack.js, which says why it is not `terminal` (#869). - bundleDir: app.isPackaged - ? path.join(process.resourcesPath, 'zn-cli') - : path.resolve(here, '../../build/terminal', `${process.platform}-${process.arch}`), + bundleDir: terminalBundleDir(), userData: app.getPath('userData'), platform: process.platform, arch: process.arch, diff --git a/apps/desktop/src/main/cli-update-core.test.ts b/apps/desktop/src/main/cli-update-core.test.ts new file mode 100644 index 00000000..0a98cfce --- /dev/null +++ b/apps/desktop/src/main/cli-update-core.test.ts @@ -0,0 +1,321 @@ +import { execFile } from 'node:child_process' +import { createHash, generateKeyPairSync, sign } from 'node:crypto' +import { mkdir, mkdtemp, readFile, readdir, readlink, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' +import { + extractBinary, + parseReleaseManifest, + runCliUpdate, + verifyReleaseManifest, + type CliUpdateDeps, + type ReleaseKey, +} from './cli-update-core' +import { compareTerminalVersions, prepareTerminalRuntime } from './terminal-runtime' + +const exec = promisify(execFile) +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +const platform = process.platform +const arch = process.arch +const goArch = arch === 'x64' ? 'amd64' : arch +const commit = 'a'.repeat(40) + +function keyPair(id = 'test-key'): { key: ReleaseKey; signer: (bytes: Buffer) => Buffer } { + const { privateKey, publicKey } = generateKeyPairSync('ed25519') + const raw = Buffer.from(publicKey.export({ format: 'jwk' }).x as string, 'base64url') + return { + key: { id, publicKey: raw.toString('base64') }, + signer: (bytes) => + Buffer.from( + JSON.stringify( + { keyId: id, algorithm: 'ed25519', signature: sign(null, bytes, privateKey).toString('base64') }, + null, + 2, + ) + '\n', + ), + } +} + +const releaseUrl = (version: string) => + `https://github.com/ZenNotes/tui/releases/download/v${version}/zn_${version}_${platform}_${goArch}.tar.gz` + +function manifest(version: string, sha256: string, protocol = 1, url = releaseUrl(version)): Buffer { + return Buffer.from( + JSON.stringify( + { + schemaVersion: 1, + release: { + repository: 'ZenNotes/tui', + protocol, + version, + commit, + artifacts: { [`${platform}-${arch}`]: { url, sha256 } }, + }, + }, + null, + 2, + ) + '\n', + ) +} + +function fakeZn(version: string, protocol = 1): string { + return `#!/bin/sh\nif [ "$1" = --desktop-integration ]; then\n echo '{"protocol":${protocol},"version":"${version}"}'\n exit\nfi\necho "zn ${version}"\n` +} + +async function archiveWith(root: string, script: string): Promise { + const dir = await mkdtemp(path.join(root, 'archive-')) + await writeFile(path.join(dir, 'zn'), script, { mode: 0o755 }) + await writeFile(path.join(dir, 'LICENSE'), 'MIT\n') + const out = path.join(root, `release-${path.basename(dir)}.tar.gz`) + await exec('tar', ['-czf', out, '-C', dir, 'zn', 'LICENSE']) + return await readFile(out) +} + +/** A managed runtime staged from a bundle, as the app does at startup. */ +async function managedRuntime(bundleVersion = '1.0.0') { + const root = await mkdtemp(path.join(os.tmpdir(), "zn update ' ")) + roots.push(root) + const bundleDir = path.join(root, 'resources', 'zn-cli') + await mkdir(bundleDir, { recursive: true }) + await writeBundle(bundleDir, bundleVersion) + const legacy = path.join(root, 'legacy') + await writeFile(legacy, '#!/bin/sh\necho legacy\n', { mode: 0o755 }) + const options = { + bundleDir, + userData: path.join(root, 'user data'), + platform, + arch, + legacyCommand: [legacy], + } + return { root, options } +} + +async function writeBundle(bundleDir: string, version: string): Promise { + await writeFile(path.join(bundleDir, 'zn'), fakeZn(version), { mode: 0o755 }) + await writeFile( + path.join(bundleDir, 'manifest.json'), + JSON.stringify({ schemaVersion: 1, protocol: 1, version, platform, arch }), + ) +} + +function deps( + userData: string, + key: ReleaseKey, + files: Record, + fetched: string[] = [], +): CliUpdateDeps { + return { + userData, + platform, + arch, + manifestUrl: 'https://example.test/terminal-release.json', + signatureUrl: 'https://example.test/terminal-release.json.sig', + keys: [key], + fetchBytes: async (url) => { + fetched.push(url) + if (!(url in files)) throw new Error(`unexpected fetch ${url}`) + return files[url] ?? null + }, + extractBinary, + } +} + +const sha = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex') +const currentTarget = (userData: string) => + readlink(path.join(userData, 'cli', 'terminal', 'current')) + +describe('compareTerminalVersions', () => { + it('orders by semver precedence and refuses what it cannot read', () => { + expect(compareTerminalVersions('0.6.2', '0.6.1')).toBe(1) + expect(compareTerminalVersions('0.10.0', '0.9.9')).toBe(1) + expect(compareTerminalVersions('1.0.0', '1.0.0')).toBe(0) + expect(compareTerminalVersions('1.0.0-rc.1', '1.0.0')).toBe(-1) + expect(compareTerminalVersions('1.0.0-rc.10', '1.0.0-rc.2')).toBe(1) + expect(compareTerminalVersions('1.0.0-alpha', '1.0.0-beta')).toBe(-1) + expect(compareTerminalVersions('1.0.0-alpha', '1.0.0-alpha.1')).toBe(-1) + expect(compareTerminalVersions('1.0.0+build.5', '1.0.0')).toBe(0) + expect(compareTerminalVersions('dev', '1.0.0')).toBeNull() + expect(compareTerminalVersions('1.0', '1.0.0')).toBeNull() + }) +}) + +describe('verifyReleaseManifest', () => { + const { key, signer } = keyPair() + const bytes = manifest('1.1.0', 'b'.repeat(64)) + + it('accepts a signature by a trusted key over the exact bytes', () => { + expect(() => verifyReleaseManifest(bytes, signer(bytes), [key])).not.toThrow() + }) + + it('rejects a manifest changed after signing', () => { + const tampered = Buffer.from(bytes.toString().replace('1.1.0', '1.1.1')) + expect(() => verifyReleaseManifest(tampered, signer(bytes), [key])).toThrow(/does not match/) + }) + + it('rejects a key it does not know, even with a valid signature', () => { + const other = keyPair('other-key') + expect(() => verifyReleaseManifest(bytes, other.signer(bytes), [key])).toThrow(/does not trust/) + }) + + it('rejects a signature from another key under a trusted id', () => { + const impostor = keyPair('test-key') + expect(() => verifyReleaseManifest(bytes, impostor.signer(bytes), [key])).toThrow(/does not match/) + }) + + it('rejects other algorithms and malformed envelopes', () => { + const envelope = JSON.parse(signer(bytes).toString()) + const rsa = Buffer.from(JSON.stringify({ ...envelope, algorithm: 'rsa' })) + expect(() => verifyReleaseManifest(bytes, rsa, [key])).toThrow(/Ed25519/) + expect(() => verifyReleaseManifest(bytes, Buffer.from('nope'), [key])).toThrow(/unreadable/) + const short = Buffer.from(JSON.stringify({ ...envelope, signature: 'AAAA' })) + expect(() => verifyReleaseManifest(bytes, short, [key])).toThrow(/malformed/) + }) +}) + +describe('parseReleaseManifest', () => { + it('reads this machine’s artifact', () => { + const release = parseReleaseManifest(manifest('1.1.0', 'b'.repeat(64)), platform, arch) + expect(release).toEqual({ + version: '1.1.0', + protocol: 1, + commit, + url: releaseUrl('1.1.0'), + sha256: 'b'.repeat(64), + }) + }) + + it('refuses a download URL other than the release asset its version names', () => { + const elsewhere = manifest('1.1.0', 'b'.repeat(64), 1, 'https://example.com/zn.tar.gz') + expect(() => parseReleaseManifest(elsewhere, platform, arch)).toThrow(/format/) + const otherVersion = manifest('1.1.0', 'b'.repeat(64), 1, releaseUrl('1.0.9')) + expect(() => parseReleaseManifest(otherVersion, platform, arch)).toThrow(/format/) + }) + + it('refuses malformed checksums, schemas and platforms', () => { + expect(() => parseReleaseManifest(manifest('1.1.0', 'XYZ'), platform, arch)).toThrow(/format/) + expect(() => parseReleaseManifest(Buffer.from('{"schemaVersion":2}'), platform, arch)).toThrow(/format/) + expect(() => parseReleaseManifest(manifest('1.1.0', 'b'.repeat(64)), 'win32', 'x64')).toThrow(/platform/) + }) +}) + +describe.skipIf(process.platform === 'win32')('runCliUpdate', () => { + it('reports nothing to update before ZenNotes has staged its own zn', async () => { + const { options } = await managedRuntime() + const { key } = keyPair() + expect(await runCliUpdate(deps(options.userData, key, {}), { install: true })).toEqual({ + kind: 'not-installed', + }) + }) + + it('treats a release without a manifest as nothing to do', async () => { + const { options } = await managedRuntime() + await prepareTerminalRuntime(options) + const { key } = keyPair() + const files = { 'https://example.test/terminal-release.json': null } + expect(await runCliUpdate(deps(options.userData, key, files), { install: true })).toEqual({ + kind: 'no-release-info', + }) + }) + + it('downloads nothing when the release is not newer, or speaks another protocol, or install is off', async () => { + const { options } = await managedRuntime('1.0.0') + await prepareTerminalRuntime(options) + const { key, signer } = keyPair() + const run = async (bytes: Buffer, install: boolean) => { + const fetched: string[] = [] + const files = { + 'https://example.test/terminal-release.json': bytes, + 'https://example.test/terminal-release.json.sig': signer(bytes), + } + const outcome = await runCliUpdate(deps(options.userData, key, files, fetched), { install }) + return { outcome, fetched } + } + const same = await run(manifest('1.0.0', 'b'.repeat(64)), true) + expect(same.outcome).toEqual({ kind: 'up-to-date', installed: '1.0.0', latest: '1.0.0' }) + const older = await run(manifest('0.9.0', 'b'.repeat(64)), true) + expect(older.outcome.kind).toBe('up-to-date') + const protocol2 = await run(manifest('2.0.0', 'b'.repeat(64), 2), true) + expect(protocol2.outcome).toEqual({ kind: 'incompatible', installed: '1.0.0', latest: '2.0.0', protocol: 2 }) + const reportOnly = await run(manifest('1.1.0', 'b'.repeat(64)), false) + expect(reportOnly.outcome).toEqual({ kind: 'available', installed: '1.0.0', latest: '1.1.0' }) + for (const { fetched } of [same, older, protocol2, reportOnly]) + expect(fetched.some((url) => url.endsWith('.tar.gz'))).toBe(false) + }) + + it('installs a newer signed release, keeps it over an older bundle, and yields to a newer one', async () => { + const { root, options } = await managedRuntime('1.0.0') + const bundled = await prepareTerminalRuntime(options) + const bundledTarget = await currentTarget(options.userData) + const { key, signer } = keyPair() + const archive = await archiveWith(root, fakeZn('1.1.0')) + const bytes = manifest('1.1.0', sha(archive)) + const files = { + 'https://example.test/terminal-release.json': bytes, + 'https://example.test/terminal-release.json.sig': signer(bytes), + [releaseUrl('1.1.0')]: archive, + } + expect(await runCliUpdate(deps(options.userData, key, files), { install: true })).toEqual({ + kind: 'updated', + from: '1.0.0', + to: '1.1.0', + }) + // The command on PATH runs the new release through the same launcher. + const { stdout } = await exec(bundled!.launcherPath, ['--version']) + expect(stdout).toBe('zn 1.1.0\n') + const installed = JSON.parse( + await readFile(path.join(options.userData, 'cli', 'terminal', 'current', 'installed.json'), 'utf8'), + ) + expect(installed).toMatchObject({ version: '1.1.0', protocol: 1, installedFrom: 'update' }) + + // Next launch: the older bundle is a floor, not a reset. + const relaunch = await prepareTerminalRuntime(options) + expect(relaunch?.version).toBe('1.1.0') + const versions = await readdir(path.join(options.userData, 'cli', 'terminal', 'versions')) + expect(versions.sort()).toEqual([path.basename(bundledTarget), path.basename(await currentTarget(options.userData))].sort()) + + // A ZenNotes update that bundles something newer takes over again. + await writeBundle(options.bundleDir, '1.2.0') + const upgraded = await prepareTerminalRuntime(options) + expect(upgraded?.version).toBe('1.2.0') + expect((await exec(bundled!.launcherPath, ['--version'])).stdout).toBe('zn 1.2.0\n') + }) + + it('leaves the active zn alone when a download fails its checks', async () => { + const { root, options } = await managedRuntime('1.0.0') + const bundled = await prepareTerminalRuntime(options) + const before = await currentTarget(options.userData) + const { key, signer } = keyPair() + const attempt = async (archive: Buffer, declaredSha: string, signature?: Buffer | null) => { + const bytes = manifest('1.1.0', declaredSha) + const files = { + 'https://example.test/terminal-release.json': bytes, + 'https://example.test/terminal-release.json.sig': signature === undefined ? signer(bytes) : signature, + [releaseUrl('1.1.0')]: archive, + } + return runCliUpdate(deps(options.userData, key, files), { install: true }) + } + const good = await archiveWith(root, fakeZn('1.1.0')) + await expect(attempt(good, 'c'.repeat(64))).rejects.toThrow(/signed checksum/) + await expect(attempt(good, sha(good), null)).rejects.toThrow(/no signature/) + const unsigned = keyPair('test-key') + await expect( + attempt(good, sha(good), unsigned.signer(manifest('1.1.0', sha(good)))), + ).rejects.toThrow(/does not match/) + const lies = await archiveWith(root, fakeZn('1.0.5')) + await expect(attempt(lies, sha(lies))).rejects.toThrow(/integration version/) + const broken = await archiveWith(root, '#!/bin/sh\nexit 3\n') + await expect(attempt(broken, sha(broken))).rejects.toThrow(/integration probe/) + + expect(await currentTarget(options.userData)).toBe(before) + expect((await exec(bundled!.launcherPath, ['--version'])).stdout).toBe('zn 1.0.0\n') + expect(await readdir(path.join(options.userData, 'cli', 'terminal', 'versions'))).toEqual([ + path.basename(before), + ]) + }) +}) diff --git a/apps/desktop/src/main/cli-update-core.ts b/apps/desktop/src/main/cli-update-core.ts new file mode 100644 index 00000000..0d6598ba --- /dev/null +++ b/apps/desktop/src/main/cli-update-core.ts @@ -0,0 +1,220 @@ +import { execFile } from 'node:child_process' +import { createHash, createPublicKey, verify } from 'node:crypto' +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' +import { + compareTerminalVersions, + installTerminalUpdate, + readActiveTerminalRuntime, + SUPPORTED_TERMINAL_PROTOCOLS, +} from './terminal-runtime' + +/** + * Keys whose signature makes a ZenNotes/tui release manifest installable. + * The private half signs `terminal-release.json` in that repo's release + * workflow (secret ZN_RELEASE_SIGNING_KEY). Rotating means shipping the new + * public key here in a desktop release before any CLI release signs with it; + * until then, builds that only know the old key keep their current CLI. + */ +export const RELEASE_KEYS: readonly ReleaseKey[] = [ + { id: 'zn-release-1', publicKey: 'vNI+cTgFGgg/CKam4WX6jdHisaEbiIjEf3KDjT81+uE=' }, +] + +export interface ReleaseKey { + id: string + /** Raw 32-byte Ed25519 public key, base64. */ + publicKey: string +} + +export interface CliRelease { + version: string + protocol: number + commit: string + url: string + sha256: string +} + +export type CliUpdateOutcome = + | { kind: 'not-installed' } + | { kind: 'no-release-info' } + | { kind: 'up-to-date'; installed: string; latest: string } + | { kind: 'incompatible'; installed: string; latest: string; protocol: number } + | { kind: 'available'; installed: string; latest: string } + | { kind: 'updated'; from: string; to: string } + +export interface CliUpdateDeps { + userData: string + platform: string + arch: string + manifestUrl: string + signatureUrl: string + keys: readonly ReleaseKey[] + /** Resolves null for a 404, which means the release carries no manifest. */ + fetchBytes(url: string, limits: { maxBytes: number; timeoutMs: number }): Promise + /** Returns the bytes of the archive's top-level `zn` entry. */ + extractBinary(archive: Buffer): Promise +} + +const exec = promisify(execFile) +const MANIFEST_MAX_BYTES = 64 * 1024 +const SIGNATURE_MAX_BYTES = 4 * 1024 +const ARCHIVE_MAX_BYTES = 128 * 1024 * 1024 +const GO_ARCH: Record = { x64: 'amd64', arm64: 'arm64' } +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') + +/** + * Throws unless `signatureBytes` is an Ed25519 signature, by one of `keys`, + * over exactly `manifestBytes`. The manifest is never parsed before this + * passes. + */ +export function verifyReleaseManifest( + manifestBytes: Buffer, + signatureBytes: Buffer, + keys: readonly ReleaseKey[], +): void { + let envelope: { keyId?: unknown; algorithm?: unknown; signature?: unknown } + try { + envelope = JSON.parse(signatureBytes.toString('utf8')) + } catch { + throw new Error('The CLI release signature is unreadable.') + } + if (envelope.algorithm !== 'ed25519' || typeof envelope.signature !== 'string') + throw new Error('The CLI release signature is not an Ed25519 signature.') + const key = keys.find((candidate) => candidate.id === envelope.keyId) + if (!key) throw new Error('The CLI release is signed with a key this version of ZenNotes does not trust.') + const raw = Buffer.from(key.publicKey, 'base64') + const signature = Buffer.from(envelope.signature, 'base64') + if (raw.length !== 32 || signature.length !== 64) + throw new Error('The CLI release signature is malformed.') + const publicKey = createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), + format: 'der', + type: 'spki', + }) + if (!verify(null, manifestBytes, publicKey, signature)) + throw new Error('The CLI release signature does not match its manifest.') +} + +/** + * Reads a verified `terminal-release.json` (the same schema as this repo's + * apps/desktop/terminal-release.json) down to the one artifact this machine + * would install. The download URL must be the GitHub release asset the + * version names, so even a signed manifest cannot point elsewhere. + */ +export function parseReleaseManifest(bytes: Buffer, platform: string, arch: string): CliRelease { + let value: unknown + try { + value = JSON.parse(bytes.toString('utf8')) + } catch { + throw new Error('The CLI release manifest is not valid JSON.') + } + const fail = (): never => { + throw new Error('The CLI release manifest is not in a format this version of ZenNotes reads.') + } + const root = value as { schemaVersion?: unknown; release?: Record } + if (!root || root.schemaVersion !== 1 || !root.release || typeof root.release !== 'object') fail() + const release = root.release as Record + const { repository, protocol, version, commit, artifacts } = release + if (repository !== 'ZenNotes/tui') fail() + if (typeof protocol !== 'number' || !Number.isInteger(protocol) || protocol < 1) fail() + if (typeof version !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9.+-]{0,99}$/.test(version)) fail() + if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) fail() + if (!artifacts || typeof artifacts !== 'object') fail() + const goArch = GO_ARCH[arch] + if ((platform !== 'darwin' && platform !== 'linux') || !goArch) + throw new Error('ZenNotes does not manage a CLI on this platform.') + const artifact = (artifacts as Record)[`${platform}-${arch}`] as + | { url?: unknown; sha256?: unknown } + | undefined + if (!artifact || typeof artifact !== 'object') fail() + const v = version as string + const expectedUrl = `https://github.com/ZenNotes/tui/releases/download/v${v}/zn_${v}_${platform}_${goArch}.tar.gz` + if (artifact!.url !== expectedUrl) fail() + if (typeof artifact!.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(artifact!.sha256)) fail() + return { + version: v, + protocol: protocol as number, + commit: commit as string, + url: expectedUrl, + sha256: artifact!.sha256 as string, + } +} + +/** + * Reads the archive's top-level `zn` entry to stdout. Archive paths are never + * extracted to disk, the same rule the packaging stager follows. + */ +export async function extractBinary(archive: Buffer): Promise { + const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'zennotes-cli-update-')) + try { + const archivePath = path.join(scratch, 'release.tar.gz') + await fs.writeFile(archivePath, archive, { mode: 0o600 }) + const { stdout } = await exec('tar', ['-xOzf', archivePath, 'zn'], { + encoding: 'buffer', + maxBuffer: 256 * 1024 * 1024, + }) + if (stdout.length === 0) throw new Error('The CLI release archive has no zn executable.') + return stdout + } finally { + await fs.rm(scratch, { recursive: true, force: true }) + } +} + +/** + * One update pass: read the latest signed release, and when it is newer than + * the managed CLI and speaks a protocol this build supports, either report it + * (`install: false`) or download, verify and activate it. Network and + * verification failures throw; "nothing to do" is an outcome, not an error. + */ +export async function runCliUpdate( + deps: CliUpdateDeps, + { install }: { install: boolean }, +): Promise { + const active = await readActiveTerminalRuntime(deps.userData) + if (!active) return { kind: 'not-installed' } + + const manifestBytes = await deps.fetchBytes(deps.manifestUrl, { + maxBytes: MANIFEST_MAX_BYTES, + timeoutMs: 20_000, + }) + if (!manifestBytes) return { kind: 'no-release-info' } + const signatureBytes = await deps.fetchBytes(deps.signatureUrl, { + maxBytes: SIGNATURE_MAX_BYTES, + timeoutMs: 20_000, + }) + if (!signatureBytes) throw new Error('The latest CLI release has a manifest but no signature.') + verifyReleaseManifest(manifestBytes, signatureBytes, deps.keys) + const release = parseReleaseManifest(manifestBytes, deps.platform, deps.arch) + + const order = compareTerminalVersions(release.version, active.version) + if (order === null || order <= 0) + return { kind: 'up-to-date', installed: active.version, latest: release.version } + if (!SUPPORTED_TERMINAL_PROTOCOLS.includes(release.protocol)) + return { + kind: 'incompatible', + installed: active.version, + latest: release.version, + protocol: release.protocol, + } + if (!install) return { kind: 'available', installed: active.version, latest: release.version } + + const archive = await deps.fetchBytes(release.url, { + maxBytes: ARCHIVE_MAX_BYTES, + timeoutMs: 5 * 60_000, + }) + if (!archive) throw new Error(`The zn ${release.version} download is missing from its release.`) + if (createHash('sha256').update(archive).digest('hex') !== release.sha256) + throw new Error(`The zn ${release.version} download does not match its signed checksum.`) + const bytes = await deps.extractBinary(archive) + const updated = await installTerminalUpdate({ + userData: deps.userData, + bytes, + version: release.version, + protocol: release.protocol, + platform: deps.platform, + arch: deps.arch, + }) + return { kind: 'updated', from: active.version, to: updated.version } +} diff --git a/apps/desktop/src/main/cli-update.ts b/apps/desktop/src/main/cli-update.ts new file mode 100644 index 00000000..145a55c0 --- /dev/null +++ b/apps/desktop/src/main/cli-update.ts @@ -0,0 +1,241 @@ +import { app, BrowserWindow, net } from 'electron' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { IPC, type CliUpdateCheckRequest, type CliUpdateState } from '@shared/ipc' +import { findManagedCliBinary, terminalBundleDir } from './cli-install' +import { + extractBinary, + RELEASE_KEYS, + runCliUpdate, + type CliUpdateOutcome, + type ReleaseKey, +} from './cli-update-core' +import { readActiveTerminalRuntime } from './terminal-runtime' +import { loadConfig, updateConfig } from './vault' + +const LATEST_MANIFEST_URL = + 'https://github.com/ZenNotes/tui/releases/latest/download/terminal-release.json' +const FIRST_CHECK_DELAY_MS = 90_000 +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 + +const supported = process.platform === 'darwin' || process.platform === 'linux' +// A development or perf run can point the check at a served manifest signed +// by a throwaway key, the same gate the app updater uses for its test feed. +// Whoever can set these variables can already write the managed CLI folder, +// so they open nothing new. +const overridesAllowed = !app.isPackaged || process.env.ZEN_PERF === '1' +const manifestOverride = overridesAllowed + ? process.env.ZENNOTES_CLI_UPDATE_MANIFEST_URL?.trim() || null + : null + +let state: CliUpdateState = { + supported, + autoUpdate: true, + phase: supported ? 'idle' : 'not-installed', + installedVersion: null, + bundledVersion: null, + availableVersion: null, + lastCheckedAt: null, + message: null, +} +let inFlight: Promise | null = null +let scheduled = false + +function trustedKeys(): readonly ReleaseKey[] { + const extra = overridesAllowed ? process.env.ZENNOTES_CLI_UPDATE_TRUSTED_KEY?.trim() : undefined + if (!extra) return RELEASE_KEYS + const split = extra.indexOf(':') + if (split <= 0) return RELEASE_KEYS + return [...RELEASE_KEYS, { id: extra.slice(0, split), publicKey: extra.slice(split + 1) }] +} + +async function fetchBytes( + url: string, + { maxBytes, timeoutMs }: { maxBytes: number; timeoutMs: number }, +): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + // net.fetch goes through Chromium's network stack, so the system proxy + // and certificate settings that let the app updater reach GitHub apply. + const response = await net.fetch(url, { + signal: controller.signal, + headers: { 'Cache-Control': 'no-cache' }, + }) + if (response.status === 404) return null + if (!response.ok) throw new Error(`GitHub answered ${response.status} for ${url}`) + if (Number(response.headers.get('content-length')) > maxBytes) + throw new Error(`${url} is larger than expected.`) + const chunks: Buffer[] = [] + let total = 0 + const reader = response.body?.getReader() + if (!reader) return Buffer.alloc(0) + for (;;) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > maxBytes) { + controller.abort() + throw new Error(`${url} is larger than expected.`) + } + chunks.push(Buffer.from(value)) + } + return Buffer.concat(chunks) + } finally { + clearTimeout(timer) + } +} + +async function readBundledVersion(): Promise { + try { + const manifest = JSON.parse( + await fs.readFile(path.join(terminalBundleDir(), 'manifest.json'), 'utf8'), + ) + return typeof manifest.version === 'string' ? manifest.version : null + } catch { + return null + } +} + +async function refreshVersions(): Promise { + const [active, bundledVersion, config] = await Promise.all([ + readActiveTerminalRuntime(app.getPath('userData')), + readBundledVersion(), + loadConfig(), + ]) + state = { + ...state, + autoUpdate: config.cliAutoUpdate, + installedVersion: active?.version ?? null, + bundledVersion, + } +} + +function describe(outcome: CliUpdateOutcome): Partial { + switch (outcome.kind) { + case 'not-installed': + return { + phase: 'not-installed', + availableVersion: null, + message: 'ZenNotes has not set up its zn yet. Open this page again in a moment.', + } + case 'no-release-info': + return { phase: 'up-to-date', availableVersion: null, message: 'No newer zn release found.' } + case 'up-to-date': + return { + phase: 'up-to-date', + availableVersion: null, + message: `zn ${outcome.installed} is the latest release.`, + } + case 'incompatible': + return { + phase: 'incompatible', + availableVersion: outcome.latest, + message: `zn ${outcome.latest} needs a newer version of ZenNotes. Update ZenNotes to get it.`, + } + case 'available': + return { + phase: 'available', + availableVersion: outcome.latest, + message: `zn ${outcome.latest} is available.`, + } + case 'updated': + return { + phase: 'updated', + availableVersion: outcome.to, + message: `Updated zn from ${outcome.from} to ${outcome.to}.`, + } + } +} + +function humanize(error: unknown): string { + const text = error instanceof Error ? error.message : String(error) + if (/ERR_INTERNET_DISCONNECTED|ERR_NAME_NOT_RESOLVED|ERR_NETWORK|ERR_CONNECTION|ENOTFOUND|aborted/i.test(text)) + return 'Could not reach GitHub to check for zn updates. Try again when you are online.' + return text +} + +// Settings may be open before a scheduled check starts or after it ends; +// every change reaches it here rather than on its next open. +function broadcast(): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(IPC.CLI_UPDATE_ON_STATE, { ...state }) + } +} + +export async function getCliUpdateState(): Promise { + if (supported) await refreshVersions() + return { ...state } +} + +/** + * One check, shared by the schedule and Settings: a second caller while one + * runs gets the same result instead of a second download. + */ +export function checkForCliUpdate(request: CliUpdateCheckRequest): Promise { + if (!supported) return Promise.resolve({ ...state }) + if (inFlight) return inFlight + state = { ...state, phase: 'checking', message: 'Checking for zn updates…' } + broadcast() + inFlight = (async () => { + try { + const outcome = await runCliUpdate( + { + userData: app.getPath('userData'), + platform: process.platform, + arch: process.arch, + manifestUrl: manifestOverride ?? LATEST_MANIFEST_URL, + signatureUrl: `${manifestOverride ?? LATEST_MANIFEST_URL}.sig`, + keys: trustedKeys(), + fetchBytes, + extractBinary, + }, + { install: request.install === true }, + ) + state = { ...state, ...describe(outcome), lastCheckedAt: Date.now() } + } catch (error) { + state = { ...state, phase: 'error', message: humanize(error), lastCheckedAt: Date.now() } + } + await refreshVersions().catch(() => {}) + broadcast() + return { ...state } + })().finally(() => { + inFlight = null + }) + return inFlight +} + +export async function setCliAutoUpdate(enabled: boolean): Promise { + await updateConfig((config) => ({ ...config, cliAutoUpdate: enabled === true })) + const next = await getCliUpdateState() + broadcast() + return next +} + +/** + * Checks shortly after launch and then daily. Packaged builds only, unless a + * test manifest is configured, so development and perf runs stay off the + * network. + */ +export function scheduleCliUpdateChecks(): void { + if (scheduled || !supported) return + if (!manifestOverride && (!app.isPackaged || process.env.ZEN_PERF === '1')) return + scheduled = true + const delay = Number(process.env.ZENNOTES_CLI_UPDATE_FIRST_CHECK_MS) + const run = async (): Promise => { + // Only a zn that ZenNotes put on PATH runs this copy (MCP setups use it + // under the same condition). Without one there is nothing to keep + // current, and no reason to download every release for someone who + // never uses the CLI or uses Homebrew's. + if (!(await findManagedCliBinary().catch(() => null))) return + const { cliAutoUpdate } = await loadConfig() + await checkForCliUpdate({ install: cliAutoUpdate }) + } + setTimeout( + () => { + void run() + setInterval(() => void run(), CHECK_INTERVAL_MS) + }, + overridesAllowed && Number.isFinite(delay) && delay >= 0 ? delay : FIRST_CHECK_DELAY_MS, + ) +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index cfa3b98a..d0f32718 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -242,6 +242,12 @@ import { migrateLegacyCliLink, uninstallCli, } from "./cli-install"; +import { + checkForCliUpdate, + getCliUpdateState, + scheduleCliUpdateChecks, + setCliAutoUpdate, +} from "./cli-update"; import { getRaycastExtensionStatus, installRaycastExtension, @@ -4718,6 +4724,18 @@ function registerIpc(): void { await installCli(request), ); handle(IPC.CLI_UNINSTALL, async () => await uninstallCli()); + handle(IPC.CLI_UPDATE_GET_STATE, async () => await getCliUpdateState()); + handle(IPC.CLI_UPDATE_CHECK, async (_event, request: unknown) => + await checkForCliUpdate({ + install: + typeof request === "object" && + request !== null && + (request as { install?: unknown }).install === true, + }), + ); + handle(IPC.CLI_SET_AUTO_UPDATE, async (_event, enabled: unknown) => + await setCliAutoUpdate(enabled === true), + ); handle(IPC.RAYCAST_GET_STATUS, async () => await getRaycastExtensionStatus()); handle(IPC.RAYCAST_INSTALL, async () => await installRaycastExtension()); @@ -5677,6 +5695,7 @@ app.whenReady().then(async () => { } void flushPendingFloatingNoteRequests(); scheduleBackgroundAppUpdateCheck(); + scheduleCliUpdateChecks(); try { const cfg = await loadConfig(); diff --git a/apps/desktop/src/main/terminal-runtime.ts b/apps/desktop/src/main/terminal-runtime.ts index 74db4bf2..66d91ead 100644 --- a/apps/desktop/src/main/terminal-runtime.ts +++ b/apps/desktop/src/main/terminal-runtime.ts @@ -28,9 +28,70 @@ interface Manifest { platform: string arch: string } +/** + * Integration protocols this build speaks. A CLI on any other protocol stays + * out until a ZenNotes release that speaks it, whoever offers it. + */ +export const SUPPORTED_TERMINAL_PROTOCOLS: readonly number[] = [1] + +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/ + +/** + * Orders two CLI versions by semver precedence (build metadata ignored). + * Null when either is not a version this can order, so an unorderable pair + * never replaces anything. + */ +export function compareTerminalVersions(a: string, b: string): number | null { + const left = VERSION_PATTERN.exec(a) + const right = VERSION_PATTERN.exec(b) + if (!left || !right) return null + for (let part = 1; part <= 3; part++) { + const diff = Number(left[part]) - Number(right[part]) + if (diff !== 0) return Math.sign(diff) + } + const leftPre = left[4] + const rightPre = right[4] + if (leftPre === rightPre) return 0 + if (leftPre === undefined) return 1 + if (rightPre === undefined) return -1 + const leftIds = leftPre.split('.') + const rightIds = rightPre.split('.') + for (let index = 0; index < Math.max(leftIds.length, rightIds.length); index++) { + const l = leftIds[index] + const r = rightIds[index] + if (l === undefined) return -1 + if (r === undefined) return 1 + if (l === r) continue + const lNum = /^\d+$/.test(l) + const rNum = /^\d+$/.test(r) + if (lNum && rNum) return Math.sign(Number(l) - Number(r)) + if (lNum) return -1 + if (rNum) return 1 + return l < r ? -1 : 1 + } + return 0 +} + const pending = new Map>() +const locks = new Map>() const digest = (data: Buffer): string => createHash('sha256').update(data).digest('hex') + +/** + * Bundle staging and downloaded updates both move `current`; one at a time + * per userData, or an update could land between a stage's checks and its + * swap. + */ +function withRuntimeLock(userData: string, run: () => Promise): Promise { + const previous = locks.get(userData) ?? Promise.resolve() + const next = previous.catch(() => {}).then(run) + const settled = next.catch(() => {}) + locks.set(userData, settled) + void settled.then(() => { + if (locks.get(userData) === settled) locks.delete(userData) + }) + return next +} const quote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'` async function atomicWrite( @@ -75,7 +136,7 @@ export function prepareTerminalRuntime( const key = `${options.bundleDir}\0${options.userData}` const existing = pending.get(key) if (existing) return existing - const operation = prepare(options).finally(() => { + const operation = withRuntimeLock(options.userData, () => prepare(options)).finally(() => { pending.delete(key) }) pending.set(key, operation) @@ -107,7 +168,7 @@ async function prepare( } if ( manifest.schemaVersion !== 1 || - manifest.protocol !== 1 || + !SUPPORTED_TERMINAL_PROTOCOLS.includes(manifest.protocol) || typeof manifest.version !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9.+-]{0,99}$/.test(manifest.version) ) { @@ -163,7 +224,54 @@ async function prepare( /* Missing or damaged copy is replaced through a fresh stage. */ } - const stage = await fs.mkdtemp(path.join(versions, `${manifest.version}-`)) + // The bundle is the floor this build guarantees, not the only version it + // allows: a newer CLI that installTerminalUpdate verified and activated + // stays. Only an intact copy on a protocol this build speaks counts, and a + // tie goes to the bundle, whose copy carries this build's signature. + const active = await readActiveTerminalRuntime(options.userData) + if (active && (compareTerminalVersions(active.version, manifest.version) ?? 0) > 0) { + await atomicWrite(launcherPath, launcher(options, current), 0o755) + return active + } + + const binaryPath = await stageAndActivate({ + runtimeRoot, + bytes, + sha256, + installed: manifest, + beforeActivate: () => atomicWrite(launcherPath, launcher(options, current), 0o755), + }) + return { launcherPath, binaryPath, version: manifest.version, sha256 } +} + +/** + * Writes a verified copy of `bytes` as a new version, proves it answers the + * integration probe with the expected protocol and version, then swaps + * `current` to it in one rename. Anything that fails before the swap leaves + * the active version untouched. Afterwards only the new version and the one + * it replaced stay on disk, the latter so a bad release can be rolled back by + * hand; a running `zn` keeps its open file either way. + */ +async function stageAndActivate({ + runtimeRoot, + bytes, + sha256, + installed, + beforeActivate, +}: { + runtimeRoot: string + bytes: Buffer + sha256: string + installed: Manifest & { installedFrom?: 'update' } + beforeActivate?: () => Promise +}): Promise { + const versions = path.join(runtimeRoot, 'versions') + const current = path.join(runtimeRoot, 'current') + const previous = await fs + .readlink(current) + .then((target) => path.resolve(runtimeRoot, target)) + .catch(() => null) + const stage = await fs.mkdtemp(path.join(versions, `${installed.version}-`)) const binaryPath = path.join(stage, 'zn') let activated = false const next = path.join(runtimeRoot, `current.${randomUUID()}.tmp`) @@ -182,26 +290,70 @@ async function prepare( throw new Error('Terminal integration probe failed.', { cause: error }) } if ( - integration.protocol !== 1 || - integration.version !== manifest.version + integration.protocol !== installed.protocol || + integration.version !== installed.version ) { throw new Error( - 'Terminal integration version does not match the bundled manifest.', + `Terminal integration version does not match ${installed.installedFrom === 'update' ? 'the release manifest' : 'the bundled manifest'}.`, ) } await atomicWrite( path.join(stage, 'installed.json'), - JSON.stringify({ ...manifest, sha256 }) + '\n', + JSON.stringify({ ...installed, sha256 }) + '\n', ) - await atomicWrite(launcherPath, launcher(options, current), 0o755) + await beforeActivate?.() await fs.symlink(path.relative(runtimeRoot, stage), next) await fs.rename(next, current) activated = true - return { launcherPath, binaryPath, version: manifest.version, sha256 } } finally { await fs.rm(next, { force: true }) if (!activated) await fs.rm(stage, { recursive: true, force: true }) } + const keep = new Set([stage, previous].filter((dir): dir is string => dir !== null)) + for (const entry of await fs.readdir(versions).catch(() => [] as string[])) { + const dir = path.join(versions, entry) + if (!keep.has(dir)) await fs.rm(dir, { recursive: true, force: true }).catch(() => {}) + } + return binaryPath +} + +/** + * Activates a CLI release that cli-update.ts downloaded and verified against + * its signed manifest. It only ever moves forward: there must be a managed + * runtime already (the bundle staged at startup), and the release must be + * newer than it and speak a protocol this build supports. + */ +export function installTerminalUpdate(options: { + userData: string + bytes: Buffer + version: string + protocol: number + platform: string + arch: string +}): Promise { + return withRuntimeLock(options.userData, async () => { + if (!SUPPORTED_TERMINAL_PROTOCOLS.includes(options.protocol)) + throw new Error(`This version of ZenNotes cannot run zn ${options.version}.`) + const active = await readActiveTerminalRuntime(options.userData) + if (!active) throw new Error('There is no managed zn to update yet.') + if ((compareTerminalVersions(options.version, active.version) ?? 0) <= 0) + throw new Error(`zn ${active.version} is already as new as ${options.version}.`) + const sha256 = digest(options.bytes) + const binaryPath = await stageAndActivate({ + runtimeRoot: path.join(options.userData, 'cli', 'terminal'), + bytes: options.bytes, + sha256, + installed: { + schemaVersion: 1, + protocol: options.protocol, + version: options.version, + platform: options.platform, + arch: options.arch, + installedFrom: 'update', + }, + }) + return { launcherPath: active.launcherPath, binaryPath, version: options.version, sha256 } + }) } /** @@ -255,7 +407,7 @@ export async function readActiveTerminalRuntime( await binary.close() } if ( - installed.protocol !== 1 || + !SUPPORTED_TERMINAL_PROTOCOLS.includes(installed.protocol) || typeof installed.version !== 'string' || !executable || digest(bytes) !== installed.sha256 || diff --git a/apps/desktop/src/main/vault-config.test.ts b/apps/desktop/src/main/vault-config.test.ts index 77e86e07..41420459 100644 --- a/apps/desktop/src/main/vault-config.test.ts +++ b/apps/desktop/src/main/vault-config.test.ts @@ -49,7 +49,8 @@ describe('config persistence', () => { windowState: null, zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', - quickCapturePinned: false + quickCapturePinned: false, + cliAutoUpdate: true }) const cfg = await loadConfig() @@ -75,7 +76,8 @@ describe('config persistence', () => { windowState: null, zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', - quickCapturePinned: false + quickCapturePinned: false, + cliAutoUpdate: true }) // Second save triggers the rotation: the first config is copied to .bak, // then the new payload replaces the primary. @@ -89,7 +91,8 @@ describe('config persistence', () => { windowState: null, zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', - quickCapturePinned: false + quickCapturePinned: false, + cliAutoUpdate: true }) // Backup now holds the first config; corrupt the primary. await writeFile(configFile(), '{ not valid json', 'utf8') @@ -128,7 +131,8 @@ describe('config persistence', () => { windowState: null, zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', - quickCapturePinned: false + quickCapturePinned: false, + cliAutoUpdate: true }) for (let i = 0; i < 5; i += 1) { @@ -158,7 +162,8 @@ describe('config persistence', () => { windowState: null, zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', - quickCapturePinned: false + quickCapturePinned: false, + cliAutoUpdate: true }) const raw = await readFile(configFile(), 'utf8') expect(() => JSON.parse(raw)).not.toThrow() @@ -175,7 +180,8 @@ describe('config persistence', () => { windowState: null, zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', - quickCapturePinned: false + quickCapturePinned: false, + cliAutoUpdate: true }) await saveConfig({ workspaceMode: 'local', @@ -187,7 +193,8 @@ describe('config persistence', () => { windowState: null, zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', - quickCapturePinned: false + quickCapturePinned: false, + cliAutoUpdate: true }) // Delete the primary — but the backup from the previous save survives. await rm(configFile()) @@ -210,7 +217,8 @@ describe('config persistence', () => { windowState: null, zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', - quickCapturePinned: false + quickCapturePinned: false, + cliAutoUpdate: true }) await chmod(configFile(), 0o000) try { @@ -234,7 +242,8 @@ describe('config persistence', () => { windowState: null, zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', - quickCapturePinned: false + quickCapturePinned: false, + cliAutoUpdate: true }) // Corrupt the primary AND delete the backup so loadConfigSafely() must // return `readable: false` — the dangerous state where the old code diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 1e11d0cb..e400f0a6 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -309,6 +309,11 @@ export interface PersistedConfig { /** When true, the quick-capture window stays pinned on top of all windows * and does not auto-hide when it loses focus. */ quickCapturePinned: boolean + /** Install newer `zn` releases into the managed CLI as they appear. Off + * still checks and reports one in Settings, installing only on request. + * Kept here, not in the portable config.toml, because it governs this + * machine's managed copy under userData, like the install location. */ + cliAutoUpdate: boolean } export const DEFAULT_QUICK_CAPTURE_HOTKEY = 'CommandOrControl+Shift+Space' @@ -323,7 +328,8 @@ const DEFAULT_CONFIG: PersistedConfig = { windowState: null, zoomFactor: 1, quickCaptureHotkey: DEFAULT_QUICK_CAPTURE_HOTKEY, - quickCapturePinned: false + quickCapturePinned: false, + cliAutoUpdate: true } let configWriteQueue = Promise.resolve() @@ -455,6 +461,7 @@ function normalizePersistedConfig(value: unknown): PersistedConfig { ? candidate.quickCaptureHotkey.trim() : DEFAULT_QUICK_CAPTURE_HOTKEY const quickCapturePinned = candidate.quickCapturePinned === true + const cliAutoUpdate = candidate.cliAutoUpdate !== false return { workspaceMode: candidate.workspaceMode === 'remote' ? 'remote' : 'local', vaultRoot: typeof candidate.vaultRoot === 'string' ? candidate.vaultRoot : null, @@ -471,7 +478,8 @@ function normalizePersistedConfig(value: unknown): PersistedConfig { windowState: normalizeWindowState(candidate.windowState), zoomFactor, quickCaptureHotkey, - quickCapturePinned + quickCapturePinned, + cliAutoUpdate } } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 37f40b6e..8e02d70a 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -59,6 +59,8 @@ import type { AppUpdateState, AssetMeta, CliInstallStatus, + CliUpdateCheckRequest, + CliUpdateState, CliInstallRequest, DeletedAsset, DirectoryBrowseResult, @@ -732,6 +734,16 @@ const api: ZenBridge = { cliInstall: (request?: CliInstallRequest): Promise => ipcRenderer.invoke(IPC.CLI_INSTALL, request), cliUninstall: (): Promise => ipcRenderer.invoke(IPC.CLI_UNINSTALL), + cliUpdateGetState: (): Promise => ipcRenderer.invoke(IPC.CLI_UPDATE_GET_STATE), + cliUpdateCheck: (request: CliUpdateCheckRequest): Promise => + ipcRenderer.invoke(IPC.CLI_UPDATE_CHECK, request), + cliSetAutoUpdate: (enabled: boolean): Promise => + ipcRenderer.invoke(IPC.CLI_SET_AUTO_UPDATE, enabled), + onCliUpdateState: (cb: (state: CliUpdateState) => void): (() => void) => { + const listener = (_: unknown, state: CliUpdateState): void => cb(state) + ipcRenderer.on(IPC.CLI_UPDATE_ON_STATE, listener) + return () => ipcRenderer.removeListener(IPC.CLI_UPDATE_ON_STATE, listener) + }, raycastGetStatus: (): Promise => ipcRenderer.invoke(IPC.RAYCAST_GET_STATUS), raycastInstall: (): Promise => diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index fdcbd3c0..6392282c 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -24,6 +24,7 @@ import { harperSupported } from "../lib/harper-runtime"; import type { AppUpdateState, CliInstallStatus, + CliUpdateState, NoteFolder, RaycastExtensionStatus, RemoteWorkspaceProfile, @@ -5521,6 +5522,20 @@ export function SettingsModal(): JSX.Element { "path", ], }, + { + id: "cli-updates", + title: "zn updates", + description: + "The installed `zn` version, the version this ZenNotes ships, and Check for updates.", + keywords: ["cli", "zn", "update", "upgrade", "version", "release", "check"], + }, + { + id: "cli-auto-update", + title: "Update zn automatically", + description: + "ZenNotes installs new `zn` releases itself after checking their signature.", + keywords: ["cli", "zn", "update", "upgrade", "version", "release", "automatic"], + }, { id: "cli-quick-reference", title: "CLI quick reference", @@ -8366,6 +8381,8 @@ function CliSettings(): JSX.Element { + {status.runtime === "go" && installed && ours && } + (null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + void window.zen + .cliUpdateGetState?.() + .then((next) => { + if (!cancelled) setUpdate(next); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + // A scheduled check can start or finish while this page is open. + useEffect(() => window.zen.onCliUpdateState?.((next) => setUpdate(next)), []); + + if (!update?.supported || !window.zen.cliUpdateCheck) return null; + + const check = async (install: boolean): Promise => { + setBusy(true); + try { + const next = await window.zen.cliUpdateCheck?.({ install }); + if (next) setUpdate(next); + } finally { + setBusy(false); + } + }; + + const setAutoUpdate = async (enabled: boolean): Promise => { + const next = await window.zen.cliSetAutoUpdate?.(enabled); + if (next) setUpdate(next); + }; + + const checking = busy || update.phase === "checking"; + const ahead = + update.bundledVersion != null && + update.installedVersion != null && + update.bundledVersion !== update.installedVersion; + + return ( +
+ void setAutoUpdate(next)} + /> +
+
+
+ zn {update.installedVersion ?? "not set up yet"} +
+ {ahead && ( +
This copy of ZenNotes ships zn {update.bundledVersion}.
+ )} + {update.message && ( +
+ {update.message} +
+ )} + {update.lastCheckedAt != null && ( +
+ Last checked{" "} + {new Date(update.lastCheckedAt).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + })} +
+ )} +
+
+ {update.phase === "available" && !update.autoUpdate && ( + + )} + +
+
+
+ ); +} + function RaycastExtensionSettings({ cliInstalled, copyToClipboard, diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 83f47b03..b5217e80 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -1928,6 +1928,50 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma window.zen.getAppInfo().runtime === 'desktop', run: () => getState().revealAssetsDir() }, + { + id: 'cli.check-updates', + title: 'Check for zn Updates…', + category: 'CLI', + keywords: 'cli zn terminal command line update upgrade version release', + when: () => + window.zen.getAppInfo().runtime === 'desktop' && + window.zen.getCapabilities().supportsCliInstall && + typeof window.zen.cliUpdateCheck === 'function', + run: async () => { + const before = await window.zen.cliUpdateGetState?.() + if (!before?.supported) return + const install = await window.zen.cliGetStatus() + if (!install.installedAt || !install.installedByThisApp) { + await confirmApp({ + title: 'zn is not managed by ZenNotes', + description: install.installedAt + ? `The zn at ${install.installedAt} came from another installer. Update it with that installer (\`zn update\` tells you how).` + : 'Install zn from Settings → CLI first; ZenNotes then keeps it up to date.', + confirmLabel: 'OK', + cancelLabel: 'Close' + }) + return + } + let state = await window.zen.cliUpdateCheck?.({ install: before.autoUpdate }) + if (state?.phase === 'available') { + const proceed = await confirmApp({ + title: `zn ${state.availableVersion ?? ''} is available`, + description: + 'Install it now? The zn you have keeps working until the new one passes its checks.', + confirmLabel: 'Install', + cancelLabel: 'Later' + }) + if (!proceed) return + state = await window.zen.cliUpdateCheck?.({ install: true }) + } + await confirmApp({ + title: 'zn updates', + description: state?.message ?? 'Checked for zn updates.', + confirmLabel: 'OK', + cancelLabel: 'Close' + }) + } + }, { id: 'cli.install', title: 'Install Command-Line Tool (zn)', diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 0252ede7..a1693c2a 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1224,12 +1224,17 @@ export const HELP_CLI: HelpCard[] = [ { title: 'The terminal app and existing installations', body: - 'Builds with the Go terminal tool include `zn tui`. Update and open ZenNotes once to upgrade an existing desktop-managed CLI; keep using the same `zn` commands. Settings shows the installed terminal version and offers Repair if an upgrade needs attention. Desktop-installed commands keep following the desktop vault, while the TUI remembers its own selection. Explicit `--vault` and `--server` flags still win. Set `ZENNOTES_WORKSPACE_SOURCE=terminal` to use the terminal default for a command, or `ZENNOTES_CLI_ENGINE=legacy` to run the previous CLI during the transition. Homebrew and manual installations stay managed by their own installer. A shortcut left behind by a moved Mac app or an old AppImage can be repaired from Settings: review the old target, replacement and backup path before choosing Repair shortcut. Note saves preserve creation dates in small files under `.zennotes/note-metadata` without changing Markdown; keep the `.zennotes` folder with vault backups. Explicit legacy rollback needs the original app resources to remain available.' + 'Builds with the Go terminal tool include `zn tui`. An existing desktop-managed CLI keeps itself up to date (see Updates arrive without a ZenNotes update); keep using the same `zn` commands. Settings shows the installed terminal version and offers Repair if an upgrade needs attention. Desktop-installed commands keep following the desktop vault, while the TUI remembers its own selection. Explicit `--vault` and `--server` flags still win. Set `ZENNOTES_WORKSPACE_SOURCE=terminal` to use the terminal default for a command, or `ZENNOTES_CLI_ENGINE=legacy` to run the previous CLI during the transition. Homebrew and manual installations stay managed by their own installer. A shortcut left behind by a moved Mac app or an old AppImage can be repaired from Settings: review the old target, replacement and backup path before choosing Repair shortcut. Note saves preserve creation dates in small files under `.zennotes/note-metadata` without changing Markdown; keep the `.zennotes` folder with vault backups. Explicit legacy rollback needs the original app resources to remain available.' }, { title: 'CLI 0.6.1 in desktop 2.60.0', body: - 'On macOS and Linux, desktop 2.60.0 bundles CLI 0.6.1. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. In 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. Run `zn update --check` to check the CLI release; a desktop-managed CLI updates with the desktop app. The full command reference is at https://zennotes.org/tui/docs.' + 'On macOS and Linux, desktop 2.60.0 bundles CLI 0.6.1. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. In 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' + }, + { + title: 'Updates arrive without a ZenNotes update', + body: + 'For a zn installed from Settings → CLI, ZenNotes checks the latest zn release once a day and installs it when it is newer than the zn you have, so a CLI fix no longer waits for a ZenNotes release. Every release carries a manifest signed with the ZenNotes release key: ZenNotes checks that signature and the download\'s checksum, then runs the new zn once to confirm it answers before switching to it, so a failed or tampered download never replaces a working CLI. The zn a ZenNotes build ships is its floor: a newer release stays installed across restarts, and a ZenNotes update that ships something newer takes over. A release that needs a newer ZenNotes waits until you update ZenNotes. Turn off Update zn automatically in Settings → CLI → Updates to be told instead; Check for updates there, or Check for zn Updates… in the command palette, checks right away. A zn installed with Homebrew, Go or by hand keeps updating through its own installer (`zn update`).' }, { title: 'No app required', diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index 7eb2cc85..40ab313b 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -4,6 +4,8 @@ import type { AssetMeta, CliInstallStatus, CliInstallRequest, + CliUpdateCheckRequest, + CliUpdateState, DeletedAsset, ExternalFileContent, ExternalFileLink, @@ -471,6 +473,14 @@ export interface ZenBridge { cliGetStatus(): Promise cliInstall(request?: CliInstallRequest): Promise cliUninstall(): Promise + /** Desktop only: the managed `zn` version and its update state. */ + cliUpdateGetState?(): Promise + /** Desktop only: check the latest signed CLI release now. */ + cliUpdateCheck?(request: CliUpdateCheckRequest): Promise + /** Desktop only: install newer CLI releases automatically or only report them. */ + cliSetAutoUpdate?(enabled: boolean): Promise + /** Desktop only: every change to the CLI update state, including scheduled checks. */ + onCliUpdateState?(cb: (state: CliUpdateState) => void): () => void raycastGetStatus(): Promise raycastInstall(): Promise clipboardWriteText(text: string): void diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 82e19f4e..ffdb017d 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -177,6 +177,10 @@ export const IPC = { CLI_GET_STATUS: 'cli:get-status', CLI_INSTALL: 'cli:install', CLI_UNINSTALL: 'cli:uninstall', + CLI_UPDATE_GET_STATE: 'cli:update-get-state', + CLI_UPDATE_CHECK: 'cli:update-check', + CLI_SET_AUTO_UPDATE: 'cli:set-auto-update', + CLI_UPDATE_ON_STATE: 'cli:update-on-state', RAYCAST_GET_STATUS: 'raycast:get-status', RAYCAST_INSTALL: 'raycast:install', CONFIG_GET_SYNC: 'config:get-sync', @@ -242,6 +246,41 @@ export interface CliInstallRequest { } /** Where on disk the `zn` shim is currently installed (or could be). */ +/** + * The desktop-managed `zn` and its updates. ZenNotes installs newer CLI + * releases itself from a signed manifest on the latest ZenNotes/tui release, + * so a CLI fix no longer waits for a desktop release. + */ +export interface CliUpdateState { + /** False on platforms without a managed CLI (Windows). */ + supported: boolean + /** Install newer releases as they appear, or only report them. */ + autoUpdate: boolean + phase: + | 'idle' + | 'checking' + | 'up-to-date' + | 'available' + | 'updated' + | 'incompatible' + | 'not-installed' + | 'error' + /** The version `zn` runs now, from this build's bundle or a later update. */ + installedVersion: string | null + /** The version this desktop build ships, the floor it always keeps. */ + bundledVersion: string | null + /** Set when a newer release was found, installed or not. */ + availableVersion: string | null + /** Epoch ms of the last finished check. */ + lastCheckedAt: number | null + message: string | null +} + +export interface CliUpdateCheckRequest { + /** Install a newer compatible release instead of only reporting it. */ + install: boolean +} + export interface CliInstallStatus { /** Runtime supplied by this desktop build or its retained managed install. */ runtime?: 'go' | 'node' From 2d5c3ea222dce5935cd5c0981f607d93520aee9e Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 1 Oct 2026 10:24:54 -0500 Subject: [PATCH 7/8] Release(cli): bundle the verified 0.6.2 terminal release The bundled CLI is the floor a desktop build guarantees, and 0.6.2 is the newest release. It is also the first one signed by the ZenNotes/tui release workflow itself, so the pin is that release's terminal-release.json copied byte for byte after it verified against packaging/release-signing/zn-release-1.pub there and against RELEASE_KEYS here (source commit 140a38f, integration protocol 1, all four archives matching checksums.txt). Staged through `npm run terminal:stage`; the native darwin-arm64 binary reports zn v0.6.2. The in-app manual's CLI card says 0.6.2 and that it is the first release this app can update its managed zn from. --- apps/desktop/terminal-release.json | 20 ++++++++++---------- packages/app-core/src/lib/help.ts | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index e3bd2307..f45b6df2 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.6.1", - "commit": "4c4038e7a0ff204868729f64c3e372e54fb50307", + "version": "0.6.2", + "commit": "140a38f02367ee640c88f1b25ddada807a1afada", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.1/zn_0.6.1_darwin_arm64.tar.gz", - "sha256": "7223eea660a270cf01d2e1015940c131014b8497d0db6b24a0acbb9c546263d7" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.2/zn_0.6.2_darwin_arm64.tar.gz", + "sha256": "1048e148ceba340ba4c5119fc8446566f4c522dacfe0855fe769b01820995d69" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.1/zn_0.6.1_darwin_amd64.tar.gz", - "sha256": "8f3b3190b243d18e84f45a8c4b03eff9c9fa63ea3f815df7a757d674a1f8c652" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.2/zn_0.6.2_darwin_amd64.tar.gz", + "sha256": "0aa804da788e58d4b75267e93100ef9beec9b787bc3d07c9d0bb94f00bdcbd22" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.1/zn_0.6.1_linux_arm64.tar.gz", - "sha256": "f17202e930ac2916cee113d0c7dcacc5bfcbf04db601306a13cc8f757b7c576a" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.2/zn_0.6.2_linux_arm64.tar.gz", + "sha256": "315f28c54084c3cfb8b7079c90a715584bef5baed5d4376e788673c15d0a4d06" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.1/zn_0.6.1_linux_amd64.tar.gz", - "sha256": "9b082f58c2db052f44ede345539491f07578745971c847471e5cf1b1786ed256" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.2/zn_0.6.2_linux_amd64.tar.gz", + "sha256": "7ce88ea2d0081159a3c971d3dfa5f708decf58ed734b225139be5bb5f46856dd" } } } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index a1693c2a..25c87cbf 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1227,9 +1227,9 @@ export const HELP_CLI: HelpCard[] = [ 'Builds with the Go terminal tool include `zn tui`. An existing desktop-managed CLI keeps itself up to date (see Updates arrive without a ZenNotes update); keep using the same `zn` commands. Settings shows the installed terminal version and offers Repair if an upgrade needs attention. Desktop-installed commands keep following the desktop vault, while the TUI remembers its own selection. Explicit `--vault` and `--server` flags still win. Set `ZENNOTES_WORKSPACE_SOURCE=terminal` to use the terminal default for a command, or `ZENNOTES_CLI_ENGINE=legacy` to run the previous CLI during the transition. Homebrew and manual installations stay managed by their own installer. A shortcut left behind by a moved Mac app or an old AppImage can be repaired from Settings: review the old target, replacement and backup path before choosing Repair shortcut. Note saves preserve creation dates in small files under `.zennotes/note-metadata` without changing Markdown; keep the `.zennotes` folder with vault backups. Explicit legacy rollback needs the original app resources to remain available.' }, { - title: 'CLI 0.6.1 in desktop 2.60.0', + title: 'CLI 0.6.2 in desktop 2.60.0', body: - 'On macOS and Linux, desktop 2.60.0 bundles CLI 0.6.1. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. In 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' + 'On macOS and Linux, desktop 2.60.0 bundles CLI 0.6.2. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' }, { title: 'Updates arrive without a ZenNotes update', From 158293947e451f2a88011ddf647517c847cc0a76 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 1 Oct 2026 10:43:44 -0500 Subject: [PATCH 8/8] Fix(ci): run the CLI manifest parse tests on Windows too Windows CI failed three parseReleaseManifest tests in the release PR. They built their manifest for the machine running the test, and on Windows that is win32, a platform ZenNotes deliberately manages no CLI on, so parsing threw "does not manage a CLI on this platform" before the assertion it was meant to reach. The install tests in the same file, which execute a real zn, were already skipped on Windows. The manifest and parsing tests now name a platform ZenNotes manages (linux on Windows, this machine's own elsewhere). App code is unchanged. Checked by forcing process.platform to win32 around the file: the old version fails exactly the three CI tests, this one passes 9 with the 5 install tests skipped, and the native run passes all 14. --- apps/desktop/src/main/cli-update-core.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/cli-update-core.test.ts b/apps/desktop/src/main/cli-update-core.test.ts index 0a98cfce..bdd35e4e 100644 --- a/apps/desktop/src/main/cli-update-core.test.ts +++ b/apps/desktop/src/main/cli-update-core.test.ts @@ -21,8 +21,11 @@ afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) -const platform = process.platform -const arch = process.arch +// The manifest and parsing tests run everywhere, Windows CI included, so they +// name a platform ZenNotes manages a CLI on; the install tests that execute a +// real `zn` are skipped on Windows and use this machine's own. +const platform = process.platform === 'win32' ? 'linux' : process.platform +const arch = process.arch === 'arm64' ? 'arm64' : 'x64' const goArch = arch === 'x64' ? 'amd64' : arch const commit = 'a'.repeat(40)