From 5cc5ccf42ef67dbfbb3d810343001bd4826e70ed Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 2 Sep 2026 13:36:08 -0500 Subject: [PATCH 1/2] Fix(updater): the AUR package finally hears about new versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a copy of ZenNotes installed from the AUR, or unpacked from the tarball, Check for Updates never came back. The About page showed "Checking GitHub releases for updates…" and stayed there, and the check that runs after launch never noticed a release either. unyanda reported it on Discord ("mine never does"), and it has been true for every AUR and tarball install since the updater shipped. Those installs were being handed to the AppImage updater: since 2.40.0 by linuxUpdaterFormat on purpose, before that by electron-updater's own default. That updater refuses to run without an APPIMAGE marker, and it refuses silently: checkForUpdates() resolves null with no event. The app had already set its state to "checking" and only ever left that state on an event, so it never did. A non-system, non-AppImage install is now its own case, "managed": no electron-updater instance at all, because nothing here may write into files a package manager owns. ZenNotes reads the same release feed the real updaters read (latest-linux.yml from the newest release, a two-line parse, a small numeric version compare), compares it with the running version, and says the answer. A newer version gives "ZenNotes X is available. This copy is managed by your package manager, so update it there", with the desktop notification once per version and the sidebar badge; being current gives "You're already on ZenNotes X"; a dead network gives the error. The retry policy is the one the real updaters use. No Download or Install button appears for these installs: AppUpdateState gained `installable`, and the renderer hides the action in the About page and the update notice when it is false. Official deb, rpm and pacman packages and the AppImage keep their full updater. Two development knobs, gated to unpackaged runs and ZEN_PERF=1, make the path driveable on any platform: ZENNOTES_UPDATER_FORMAT=managed and ZENNOTES_UPDATE_FEED_URL for a served feed. That is how every state was verified live on macOS: a feed above the running version lit the notice with no button, the sidebar Update badge, and an About page with Check for Updates and View Release and no Download Update; a current feed gave "You're already on"; a stopped server gave the error. How to test locally: on Linux, install from the AUR (or unpack the tarball), open Settings, About, and press Check for Updates. Before: "Checking…" forever. After: the answer within a second or two, and the sidebar badge when a version is out. --- apps/desktop/src/main/update-feed.test.ts | 70 +++++++++ apps/desktop/src/main/update-feed.ts | 76 +++++++++ apps/desktop/src/main/updater.test.ts | 60 +++++++- apps/desktop/src/main/updater.ts | 144 +++++++++++++++--- apps/web/src/bridge/http-bridge.ts | 1 + .../app-core/src/components/SettingsModal.tsx | 7 +- .../app-core/src/lib/app-update-state.test.ts | 9 ++ packages/app-core/src/lib/app-update-state.ts | 3 + packages/app-core/src/lib/help.ts | 4 +- packages/bridge-contract/src/ipc.ts | 7 + 10 files changed, 354 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/main/update-feed.test.ts create mode 100644 apps/desktop/src/main/update-feed.ts 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/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/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 ? (