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/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..bdd35e4e --- /dev/null +++ b/apps/desktop/src/main/cli-update-core.test.ts @@ -0,0 +1,324 @@ +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 }) +}) + +// 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) + +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/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index 9a64982c..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.0", - "commit": "5d18fc86b58f94d7ffe90cbf9767904a20f33e9e", + "version": "0.6.2", + "commit": "140a38f02367ee640c88f1b25ddada807a1afada", "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.2/zn_0.6.2_darwin_arm64.tar.gz", + "sha256": "1048e148ceba340ba4c5119fc8446566f4c522dacfe0855fe769b01820995d69" }, "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.2/zn_0.6.2_darwin_amd64.tar.gz", + "sha256": "0aa804da788e58d4b75267e93100ef9beec9b787bc3d07c9d0bb94f00bdcbd22" }, "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.2/zn_0.6.2_linux_arm64.tar.gz", + "sha256": "315f28c54084c3cfb8b7079c90a715584bef5baed5d4376e788673c15d0a4d06" }, "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.2/zn_0.6.2_linux_amd64.tar.gz", + "sha256": "7ce88ea2d0081159a3c971d3dfa5f708decf58ed734b225139be5bb5f46856dd" } } } 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/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/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/commands.ts b/packages/app-core/src/lib/commands.ts index 2ff18ec9..b5217e80 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' @@ -1927,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)', @@ -2074,7 +2119,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/help.ts b/packages/app-core/src/lib/help.ts index a7a11230..25c87cbf 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.0 in desktop 2.59.0', + title: 'CLI 0.6.2 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.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', + 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/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; 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[] } /** 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/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' 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" 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)