diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b0661e43..fd38ddb2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.42.0", + "version": "2.43.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/main/update-feed.test.ts b/apps/desktop/src/main/update-feed.test.ts new file mode 100644 index 00000000..848a5578 --- /dev/null +++ b/apps/desktop/src/main/update-feed.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import { + compareVersions, + fetchLatestRelease, + isNewerVersion, + parseLatestFeed +} from './update-feed' + +const FEED = `version: 2.42.0 +files: + - url: ZenNotes-2.42.0-linux-x86_64.AppImage + sha512: abc + size: 251757960 +path: ZenNotes-2.42.0-linux-x86_64.AppImage +sha512: abc +releaseDate: '2026-09-02T15:28:11.000Z' +` + +describe('parseLatestFeed', () => { + it('reads the version and release date electron-builder writes', () => { + expect(parseLatestFeed(FEED)).toEqual({ + version: '2.42.0', + releaseDate: '2026-09-02T15:28:11.000Z' + }) + }) + + it('tolerates a quoted version and a missing date', () => { + expect(parseLatestFeed('version: "2.43.0"\nfiles: []\n')).toEqual({ + version: '2.43.0', + releaseDate: null + }) + }) + + it('refuses a document with no version line', () => { + expect(() => parseLatestFeed('files: []\n')).toThrow(/no version/) + }) +}) + +describe('compareVersions / isNewerVersion', () => { + it('compares numerically, not lexically', () => { + expect(isNewerVersion('2.10.0', '2.9.0')).toBe(true) + expect(isNewerVersion('2.42.0', '2.41.0')).toBe(true) + expect(isNewerVersion('2.42.0', '2.42.0')).toBe(false) + expect(isNewerVersion('2.41.9', '2.42.0')).toBe(false) + expect(isNewerVersion('3.0.0', '2.99.99')).toBe(true) + }) + + it('treats a prerelease as older than its release and ignores a leading v', () => { + expect(compareVersions('2.43.0-beta.1', '2.43.0')).toBe(-1) + expect(compareVersions('2.43.0', '2.43.0-beta.1')).toBe(1) + expect(compareVersions('v2.42.0', '2.42.0')).toBe(0) + expect(compareVersions('2.42', '2.42.0')).toBe(0) + }) +}) + +describe('fetchLatestRelease', () => { + it('fetches the feed and parses it', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => FEED }) + await expect(fetchLatestRelease(fetchImpl, 'https://example.test/latest-linux.yml')).resolves.toEqual({ + version: '2.42.0', + releaseDate: '2026-09-02T15:28:11.000Z' + }) + expect(fetchImpl).toHaveBeenCalledWith('https://example.test/latest-linux.yml') + }) + + it('turns a bad status into an error the updater can show', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 503, text: async () => '' }) + await expect(fetchLatestRelease(fetchImpl)).rejects.toThrow(/503/) + }) +}) diff --git a/apps/desktop/src/main/update-feed.ts b/apps/desktop/src/main/update-feed.ts new file mode 100644 index 00000000..081fec21 --- /dev/null +++ b/apps/desktop/src/main/update-feed.ts @@ -0,0 +1,76 @@ +/** + * The published release feed, read directly for installs the app must not + * touch. + * + * electron-updater reads `latest-linux.yml` from the newest GitHub release to + * decide whether an update exists, then downloads and installs it. An AUR or + * tarball install has no updater that can do the second half (pacman owns + * those files), and the AppImage updater those installs used to fall into + * refused to run without an AppImage marker and never reported back: the + * About page sat on "Checking…" for good. This reads the same feed and stops + * at the first half: is there a newer version, and which one. + * + * The feed is a small YAML document written by electron-builder. Only two + * lines matter here (`version:` and `releaseDate:`), so they are read with a + * line match rather than a YAML parser the main process does not otherwise + * carry. + */ + +export const LATEST_LINUX_FEED_URL = + 'https://github.com/ZenNotes/zennotes/releases/latest/download/latest-linux.yml' + +export interface LatestRelease { + version: string + releaseDate: string | null +} + +/** `version:` and `releaseDate:` out of an electron-builder feed. Throws when + * there is no version line, since a feed without one is not the feed. */ +export function parseLatestFeed(feed: string): LatestRelease { + const version = /^version:\s*['"]?([^'"\s]+)['"]?\s*$/m.exec(feed)?.[1] + if (!version) throw new Error('The release feed carried no version.') + const releaseDate = /^releaseDate:\s*['"]?([^'"\s]+)['"]?\s*$/m.exec(feed)?.[1] ?? null + return { version, releaseDate } +} + +/** Dotted numeric parts compared left to right; a prerelease suffix + * (`2.43.0-beta.1`) sorts below its release. Enough for this project's + * `MAJOR.MINOR.PATCH` tags without carrying a semver library into main. */ +export function compareVersions(a: string, b: string): number { + const split = (v: string): { parts: number[]; pre: string } => { + const [core, ...rest] = v.replace(/^v/, '').split('-') + return { + parts: core.split('.').map((p) => Number.parseInt(p, 10) || 0), + pre: rest.join('-') + } + } + const x = split(a) + const y = split(b) + const length = Math.max(x.parts.length, y.parts.length) + for (let i = 0; i < length; i += 1) { + const d = (x.parts[i] ?? 0) - (y.parts[i] ?? 0) + if (d !== 0) return d < 0 ? -1 : 1 + } + if (x.pre === y.pre) return 0 + if (!x.pre) return 1 + if (!y.pre) return -1 + return x.pre < y.pre ? -1 : 1 +} + +export function isNewerVersion(candidate: string, current: string): boolean { + return compareVersions(candidate, current) > 0 +} + +export type FeedFetch = (url: string) => Promise<{ ok: boolean; status: number; text(): Promise }> + +/** The newest published release, from the feed at `url`. */ +export async function fetchLatestRelease( + fetchImpl: FeedFetch = (url) => fetch(url, { headers: { 'user-agent': 'ZenNotes update check' } }), + url: string = LATEST_LINUX_FEED_URL +): Promise { + const response = await fetchImpl(url) + if (!response.ok) { + throw new Error(`GitHub answered ${response.status} for the release feed.`) + } + return parseLatestFeed(await response.text()) +} diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 422bb424..448ea078 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -186,14 +186,16 @@ describe('linuxUpdaterFormat', () => { ).toBe('appimage') }) - it('forces the safe AppImage updater for AUR and tar installs even if the stamp leaked', () => { + it('marks AUR and tar installs as managed, even if the stamp leaked: report only, never install', () => { + // These used to get the AppImage updater, which refuses to run without an + // APPIMAGE marker and never says so; the About page sat on "Checking…". expect( linuxUpdaterFormat({ isAppImage: false, isOfficialSystemPackage: false, osRelease: arch }) - ).toBe('appimage') + ).toBe('managed') }) it('stays with the default updater when the distro cannot be identified', () => { @@ -248,3 +250,57 @@ describe('Linux updater build support', () => { expect(supportsAutoUpdate.call(Object.create(FpmTarget.prototype), 'pacman')).toBe(true) }) }) + +describe('checkForAppUpdates on a package-manager install', () => { + const FEED = (version: string) => `version: ${version}\nfiles: []\nreleaseDate: '2026-09-02T15:28:11.000Z'\n` + const original = { format: process.env.ZENNOTES_UPDATER_FORMAT, feed: process.env.ZENNOTES_UPDATE_FEED_URL } + + async function loadManagedUpdater(feedBody: string | Error) { + vi.resetModules() + process.env.ZENNOTES_UPDATER_FORMAT = 'managed' + process.env.ZENNOTES_UPDATE_FEED_URL = 'http://127.0.0.1:1/latest-linux.yml' + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + if (feedBody instanceof Error) throw feedBody + return { ok: true, status: 200, text: async () => feedBody } + }) + ) + return await import('./updater') + } + + afterEach(() => { + vi.unstubAllGlobals() + if (original.format === undefined) delete process.env.ZENNOTES_UPDATER_FORMAT + else process.env.ZENNOTES_UPDATER_FORMAT = original.format + if (original.feed === undefined) delete process.env.ZENNOTES_UPDATE_FEED_URL + else process.env.ZENNOTES_UPDATE_FEED_URL = original.feed + }) + + it('reports a newer version without offering to install it', async () => { + const mod = await loadManagedUpdater(FEED('2.0.3')) + const state = await mod.checkForAppUpdates() + expect(state.phase).toBe('available') + expect(state.availableVersion).toBe('2.0.3') + expect(state.installable).toBe(false) + expect(state.message).toMatch(/managed by your package manager/) + // Nothing to download: the install belongs to the package manager. + expect((await mod.downloadAppUpdate()).phase).toBe('available') + }) + + it('says so when the running version is the newest', async () => { + const mod = await loadManagedUpdater(FEED('2.0.2')) + const state = await mod.checkForAppUpdates() + expect(state.phase).toBe('not-available') + expect(state.message).toBe("You're already on ZenNotes 2.0.2.") + expect(state.installable).toBe(false) + }) + + it('surfaces a feed failure instead of staying on checking', async () => { + const mod = await loadManagedUpdater(new Error('getaddrinfo EAI_FAIL github.com')) + const state = await mod.checkForAppUpdates() + expect(state.phase).toBe('error') + expect(state.message).toMatch(/EAI_FAIL/) + }) +}) + diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 0474e433..5f6c7ea9 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, Notification, shell } from 'electron' import { execFile } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' +import { fetchLatestRelease, isNewerVersion } from './update-feed' import { join, posix } from 'node:path' import { promisify } from 'node:util' import electronUpdater, { @@ -18,6 +19,9 @@ const BACKGROUND_UPDATE_CHECK_DELAY_MS = 8000 let initialized = false let updater: AppUpdater | null = null +/** True when this install belongs to a package manager (AUR, a tarball): the + * app checks the release feed itself and only reports, never installs. */ +let managedInstall = false let lastInfo: UpdateInfo | null = null let startupCheckTimer: NodeJS.Timeout | null = null let backgroundCheckScheduled = false @@ -32,6 +36,7 @@ let updateState: AppUpdateState = makeState({ function makeState(overrides: Partial = {}): AppUpdateState { return { phase: 'idle', + installable: !managedInstall, currentVersion: app.getVersion(), availableVersion: null, releaseName: null, @@ -167,7 +172,13 @@ export function initAppUpdater(): void { if (initialized) return initialized = true - if (!app.isPackaged) { + // A development or perf run may force the package-manager path so the + // notify-only check can be driven on any platform (with + // ZENNOTES_UPDATE_FEED_URL pointing at a served feed). + const forcedManaged = + process.env.ZENNOTES_UPDATER_FORMAT === 'managed' && + (!app.isPackaged || process.env.ZEN_PERF === '1') + if (!app.isPackaged && !forcedManaged) { setUpdateState( makeState({ phase: 'unsupported', @@ -177,7 +188,26 @@ export function initAppUpdater(): void { return } - updater = process.platform === 'linux' ? linuxUpdater() : autoUpdater + const chosen = forcedManaged + ? 'managed' + : process.platform === 'linux' + ? linuxUpdater() + : autoUpdater + if (chosen === 'managed') { + // No updater can install here, and the one these installs used to fall + // into (AppImage) refused to run without an APPIMAGE marker and never + // said so: the About page sat on "Checking…" for good. Report only. + managedInstall = true + updater = null + setUpdateState( + makeState({ + message: + 'This copy of ZenNotes was installed by a package manager. Check GitHub for a newer version here and install it the way you installed ZenNotes.' + }) + ) + return + } + updater = chosen updater.autoDownload = false updater.autoInstallOnAppQuit = true @@ -250,6 +280,7 @@ export function initAppUpdater(): void { export async function checkForAppUpdates(): Promise { initAppUpdater() + if (managedInstall) return await checkManagedInstallForUpdates() if (!updater) return getAppUpdateState() if (updateState.phase === 'checking') return getAppUpdateState() @@ -286,11 +317,80 @@ export async function checkForAppUpdates(): Promise { return getAppUpdateState() } +/** + * The notify-only check for a package-manager install: read the release feed, + * compare, say the answer. The same retry policy as the real updaters, the + * same native notification once per version, and never a download. + */ +async function checkManagedInstallForUpdates(): Promise { + if (updateState.phase === 'checking') return getAppUpdateState() + const current = app.getVersion() + setUpdateState( + makeState({ + phase: 'checking', + availableVersion: updateState.availableVersion, + message: 'Checking GitHub releases for updates…' + }) + ) + for (let attempt = 1; attempt <= UPDATE_CHECK_MAX_ATTEMPTS; attempt += 1) { + try { + const latest = await fetchLatestRelease(undefined, managedFeedUrl()) + if (isNewerVersion(latest.version, current)) { + setUpdateState( + makeState({ + phase: 'available', + availableVersion: latest.version, + releaseDate: latest.releaseDate, + message: `ZenNotes ${latest.version} is available. This copy is managed by your package manager, so update it there (the AUR package updates with yay -Syu or paru -Syu).` + }) + ) + if (notifiedAvailableVersion !== latest.version) { + notifiedAvailableVersion = latest.version + showNativeUpdateNotification( + 'ZenNotes Update Available', + `ZenNotes ${latest.version} is available. Update it with your package manager.` + ) + } + } else { + setUpdateState( + makeState({ + phase: 'not-available', + message: `You're already on ZenNotes ${current}.` + }) + ) + } + return getAppUpdateState() + } catch (error) { + if (isRetryableUpdateError(error) && attempt < UPDATE_CHECK_MAX_ATTEMPTS) { + setUpdateState( + makeState({ + phase: 'checking', + message: `GitHub update check hit a temporary server error. Retrying (${attempt + 1}/${UPDATE_CHECK_MAX_ATTEMPTS})…` + }) + ) + await sleep(UPDATE_CHECK_RETRY_DELAY_MS) + continue + } + setUpdateState(makeState({ phase: 'error', message: humanizeUpdateError(error) })) + return getAppUpdateState() + } + } + return getAppUpdateState() +} + +/** Development and perf runs may point the notify-only check at a local feed + * (a served `latest-linux.yml`) to exercise every state without a release. */ +function managedFeedUrl(): string | undefined { + const override = process.env.ZENNOTES_UPDATE_FEED_URL?.trim() + if (override && (!app.isPackaged || process.env.ZEN_PERF === '1')) return override + return undefined +} + export function scheduleBackgroundAppUpdateCheck( delayMs: number = BACKGROUND_UPDATE_CHECK_DELAY_MS ): void { initAppUpdater() - if (!updater || backgroundCheckScheduled) return + if ((!updater && !managedInstall) || backgroundCheckScheduled) return backgroundCheckScheduled = true startupCheckTimer = setTimeout(() => { startupCheckTimer = null @@ -339,7 +439,7 @@ export function installAppUpdate(): void { updater.quitAndInstall() } -export type LinuxPackageFormat = 'appimage' | 'deb' | 'rpm' | 'pacman' | 'unknown' +export type LinuxPackageFormat = 'appimage' | 'deb' | 'rpm' | 'pacman' | 'managed' | 'unknown' export function linuxPackageFormat(file: string | null): LinuxPackageFormat { if (!file) return 'unknown' @@ -447,10 +547,12 @@ function defaultReadOsRelease(): string { * AUR repackages under `/opt/zennotes-bin`. The path check keeps those installs * owned by their package manager even when that race occurs. * - * The AppImage updater is selected explicitly for non-system installs instead - * of returning electron-updater's `autoUpdater`: that singleton has already - * read the racing stamp by the time this module loads and may itself be a deb - * or rpm updater. + * A non-system install that is not an AppImage (the AUR package, a tarball + * unpacked by hand) is `managed`: nothing here may write into it, so the app + * only reads the release feed and reports. Those installs used to be handed + * the AppImage updater, which refuses to run without an APPIMAGE marker and + * never reports that it refused, so their update check sat on "Checking…" + * forever (reported on Discord by unyanda, on the AUR package). */ export function linuxUpdaterFormat(input: { isAppImage: boolean @@ -458,7 +560,7 @@ export function linuxUpdaterFormat(input: { osRelease: string | null }): LinuxPackageFormat { if (input.isAppImage) return 'appimage' - if (!input.isOfficialSystemPackage) return 'appimage' + if (!input.isOfficialSystemPackage) return 'managed' return input.osRelease === null ? 'unknown' : linuxFormatFromOsRelease(input.osRelease) } @@ -505,18 +607,18 @@ export function mismatchedUpdateMessage( /** The updater matching what this machine actually runs, rather than the one * electron-updater chose from the stamp when the module was loaded. */ -function linuxUpdater(): AppUpdater { +function linuxUpdater(): AppUpdater | 'managed' { try { - return linuxUpdaterForFormat( - linuxUpdaterFormat({ - isAppImage: Boolean(process.env.APPIMAGE), - isOfficialSystemPackage: isOfficialLinuxSystemPackage( - process.resourcesPath, - existsSync(join(process.resourcesPath, 'package-type')) - ), - osRelease: readOsReleaseOrNull() - }) - ) + const format = linuxUpdaterFormat({ + isAppImage: Boolean(process.env.APPIMAGE), + isOfficialSystemPackage: isOfficialLinuxSystemPackage( + process.resourcesPath, + existsSync(join(process.resourcesPath, 'package-type')) + ), + osRelease: readOsReleaseOrNull() + }) + if (format === 'managed') return 'managed' + return linuxUpdaterForFormat(format) } catch { // Any surprise here means we know nothing extra; electron-updater's own // choice is no worse than it was before. @@ -524,7 +626,7 @@ function linuxUpdater(): AppUpdater { } } -export function linuxUpdaterForFormat(format: LinuxPackageFormat): AppUpdater { +export function linuxUpdaterForFormat(format: Exclude): AppUpdater { switch (format) { case 'appimage': return new electronUpdater.AppImageUpdater() diff --git a/apps/server/package.json b/apps/server/package.json index d229f14a..d5c157e6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.42.0", + "version": "2.43.0", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index 31925e4f..764baca2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.42.0", + "version": "2.43.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index e037dd19..468e998e 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -1140,6 +1140,7 @@ const unsupportedUpdateState: AppUpdateState = { transferredBytes: null, totalBytes: null, bytesPerSecond: null, + installable: false, message: 'The web build updates automatically when you reload.' } diff --git a/package-lock.json b/package-lock.json index 7892168e..d6a02d33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.42.0", + "version": "2.43.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.42.0", + "version": "2.43.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.42.0", + "version": "2.43.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -861,11 +861,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.42.0" + "version": "2.43.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.42.0", + "version": "2.43.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16242,7 +16242,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.42.0", + "version": "2.43.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16306,11 +16306,11 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.42.0" + "version": "2.43.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.42.0", + "version": "2.43.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16321,7 +16321,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.42.0" + "version": "2.43.0" } } } diff --git a/package.json b/package.json index 1265aa3b..ec42ecca 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.42.0", + "version": "2.43.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 754bee87..c8ee9a42 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.42.0", + "version": "2.43.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 d29553ec..29fe455e 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -757,7 +757,9 @@ export function SettingsModal(): JSX.Element { (state) => { if (state.phase === "available") { window.alert( - `ZenNotes ${state.availableVersion ?? ""} is available. Use “Download Update” to fetch it.`, + state.installable + ? `ZenNotes ${state.availableVersion ?? ""} is available. Use “Download Update” to fetch it.` + : state.message, ); return; } @@ -4885,7 +4887,8 @@ export function SettingsModal(): JSX.Element {
- {appUpdateState?.phase === "available" ? ( + {appUpdateState?.phase === "available" && + appUpdateState.installable ? (