Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
70 changes: 70 additions & 0 deletions apps/desktop/src/main/update-feed.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})
76 changes: 76 additions & 0 deletions apps/desktop/src/main/update-feed.ts
Original file line number Diff line number Diff line change
@@ -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<string> }>

/** 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<LatestRelease> {
const response = await fetchImpl(url)
if (!response.ok) {
throw new Error(`GitHub answered ${response.status} for the release feed.`)
}
return parseLatestFeed(await response.text())
}
60 changes: 58 additions & 2 deletions apps/desktop/src/main/updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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/)
})
})

Loading
Loading