From 9c3a8e8bda7cfc460f3d96ca678033690ba4cecc Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 08:22:29 +0200 Subject: [PATCH 01/10] feat(update): add GitHub release version-check module Co-Authored-By: Claude Fable 5 --- src/main/update/checkForUpdate.test.ts | 61 +++++++++++++++++++++++++ src/main/update/checkForUpdate.ts | 63 ++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 src/main/update/checkForUpdate.test.ts create mode 100644 src/main/update/checkForUpdate.ts diff --git a/src/main/update/checkForUpdate.test.ts b/src/main/update/checkForUpdate.test.ts new file mode 100644 index 0000000..f1821af --- /dev/null +++ b/src/main/update/checkForUpdate.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; +import { compareVersions, checkForUpdate } from './checkForUpdate'; + +function fakeFetch(impl: { status: number; body?: unknown; throwError?: string }) { + return vi.fn().mockImplementation(async () => { + if (impl.throwError) throw new Error(impl.throwError); + return { + status: impl.status, + ok: impl.status >= 200 && impl.status < 300, + json: async () => impl.body, + } as Response; + }) as unknown as typeof fetch; +} + +describe('compareVersions', () => { + it('compares numerically, not lexically', () => { + expect(compareVersions('1.10.0', '1.9.0')).toBeGreaterThan(0); + }); + it('treats equal versions as 0 and strips a leading v', () => { + expect(compareVersions('v1.2.3', '1.2.3')).toBe(0); + }); + it('ignores a pre-release suffix on the core comparison', () => { + expect(compareVersions('1.2.3-beta.1', '1.2.3')).toBe(0); + }); + it('reports an older version as negative', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBeLessThan(0); + }); +}); + +describe('checkForUpdate', () => { + it('reports an available update from a newer tag', async () => { + const res = await checkForUpdate({ + fetchImpl: fakeFetch({ status: 200, body: { tag_name: 'v2.0.0', html_url: 'https://example/r' } }), + currentVersion: '1.0.0', + }); + expect(res).toEqual({ ok: true, data: { currentVersion: '1.0.0', latestVersion: '2.0.0', updateAvailable: true, releaseUrl: 'https://example/r' } }); + }); + + it('reports up to date when the tag matches', async () => { + const res = await checkForUpdate({ + fetchImpl: fakeFetch({ status: 200, body: { tag_name: 'v1.0.0', html_url: 'https://example/r' } }), + currentVersion: '1.0.0', + }); + expect(res.ok && res.data.updateAvailable).toBe(false); + }); + + it('treats a 404 (no releases) as up to date with the releases page url', async () => { + const res = await checkForUpdate({ fetchImpl: fakeFetch({ status: 404 }), currentVersion: '1.0.0' }); + expect(res).toEqual({ ok: true, data: { currentVersion: '1.0.0', latestVersion: null, updateAvailable: false, releaseUrl: 'https://github.com/NoiXdev/s3Manager/releases' } }); + }); + + it('returns an error on a non-OK response', async () => { + const res = await checkForUpdate({ fetchImpl: fakeFetch({ status: 403 }), currentVersion: '1.0.0' }); + expect(res.ok).toBe(false); + }); + + it('returns an error when the request throws', async () => { + const res = await checkForUpdate({ fetchImpl: fakeFetch({ status: 0, throwError: 'offline' }), currentVersion: '1.0.0' }); + expect(res.ok).toBe(false); + }); +}); diff --git a/src/main/update/checkForUpdate.ts b/src/main/update/checkForUpdate.ts new file mode 100644 index 0000000..45a0802 --- /dev/null +++ b/src/main/update/checkForUpdate.ts @@ -0,0 +1,63 @@ +import { ok, err, type Result } from '../shared/result'; + +export const GITHUB_REPO = 'NoiXdev/s3Manager'; +const RELEASES_PAGE = `https://github.com/${GITHUB_REPO}/releases`; +const LATEST_RELEASE_API = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`; + +export interface UpdateInfo { + currentVersion: string; + latestVersion: string | null; + updateAvailable: boolean; + releaseUrl: string; +} + +/** Parse "v1.2.3" / "1.2.3-beta.1" into [1,2,3]; ignores a leading v and any -prerelease suffix. */ +function parseVersion(v: string): number[] { + const core = v.replace(/^v/i, '').split('-')[0]; + return core.split('.').map((p) => Number.parseInt(p, 10) || 0); +} + +/** >0 if a is newer than b, 0 if equal, <0 if older. Compares major.minor.patch numerically. */ +export function compareVersions(a: string, b: string): number { + const pa = parseVersion(a); + const pb = parseVersion(b); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0); + if (d !== 0) return d > 0 ? 1 : -1; + } + return 0; +} + +export async function checkForUpdate({ + fetchImpl, + currentVersion, +}: { + fetchImpl: typeof fetch; + currentVersion: string; +}): Promise> { + let res: Response; + try { + res = await fetchImpl(LATEST_RELEASE_API, { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': 's3Manager-update-check' }, + }); + } catch (e) { + return err('UpdateCheckFailed', (e as Error).message); + } + if (res.status === 404) { + return ok({ currentVersion, latestVersion: null, updateAvailable: false, releaseUrl: RELEASES_PAGE }); + } + if (!res.ok) { + return err('UpdateCheckFailed', `GitHub responded ${res.status}`); + } + let body: { tag_name?: string; html_url?: string }; + try { + body = (await res.json()) as { tag_name?: string; html_url?: string }; + } catch (e) { + return err('UpdateCheckFailed', (e as Error).message); + } + const tag = body.tag_name ?? ''; + const latestVersion = tag.replace(/^v/i, '') || null; + const updateAvailable = tag !== '' && compareVersions(tag, currentVersion) > 0; + return ok({ currentVersion, latestVersion, updateAvailable, releaseUrl: body.html_url ?? RELEASES_PAGE }); +} From 08a083171c454c834c4a4ca4fe0c446d76e86938 Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 08:26:32 +0200 Subject: [PATCH 02/10] test(update): cover empty-tag and json-error paths; gate release url Co-Authored-By: Claude Fable 5 --- src/main/update/checkForUpdate.test.ts | 23 +++++++++++++++++++++-- src/main/update/checkForUpdate.ts | 8 +++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/main/update/checkForUpdate.test.ts b/src/main/update/checkForUpdate.test.ts index f1821af..ae2499e 100644 --- a/src/main/update/checkForUpdate.test.ts +++ b/src/main/update/checkForUpdate.test.ts @@ -1,13 +1,16 @@ import { describe, it, expect, vi } from 'vitest'; import { compareVersions, checkForUpdate } from './checkForUpdate'; -function fakeFetch(impl: { status: number; body?: unknown; throwError?: string }) { +function fakeFetch(impl: { status: number; body?: unknown; throwError?: string; jsonThrows?: boolean }) { return vi.fn().mockImplementation(async () => { if (impl.throwError) throw new Error(impl.throwError); return { status: impl.status, ok: impl.status >= 200 && impl.status < 300, - json: async () => impl.body, + json: async () => { + if (impl.jsonThrows) throw new Error('bad json'); + return impl.body; + }, } as Response; }) as unknown as typeof fetch; } @@ -25,6 +28,12 @@ describe('compareVersions', () => { it('reports an older version as negative', () => { expect(compareVersions('1.0.0', '2.0.0')).toBeLessThan(0); }); + it('treats 1.2 and 1.2.0 as equal (segment padding)', () => { + expect(compareVersions('1.2', '1.2.0')).toBe(0); + }); + it('does not crash on a non-numeric segment', () => { + expect(compareVersions('1.x.0', '1.0.0')).toBe(0); + }); }); describe('checkForUpdate', () => { @@ -58,4 +67,14 @@ describe('checkForUpdate', () => { const res = await checkForUpdate({ fetchImpl: fakeFetch({ status: 0, throwError: 'offline' }), currentVersion: '1.0.0' }); expect(res.ok).toBe(false); }); + + it('returns an error when the body cannot be parsed', async () => { + const res = await checkForUpdate({ fetchImpl: fakeFetch({ status: 200, jsonThrows: true }), currentVersion: '1.0.0' }); + expect(res.ok).toBe(false); + }); + + it('treats a 200 response with no tag as up to date', async () => { + const res = await checkForUpdate({ fetchImpl: fakeFetch({ status: 200, body: {} }), currentVersion: '1.0.0' }); + expect(res).toEqual({ ok: true, data: { currentVersion: '1.0.0', latestVersion: null, updateAvailable: false, releaseUrl: 'https://github.com/NoiXdev/s3Manager/releases' } }); + }); }); diff --git a/src/main/update/checkForUpdate.ts b/src/main/update/checkForUpdate.ts index 45a0802..9e2a5b4 100644 --- a/src/main/update/checkForUpdate.ts +++ b/src/main/update/checkForUpdate.ts @@ -12,6 +12,7 @@ export interface UpdateInfo { } /** Parse "v1.2.3" / "1.2.3-beta.1" into [1,2,3]; ignores a leading v and any -prerelease suffix. */ +// Pre-release suffixes are dropped: this app does not ship pre-releases, so 1.2.3-beta == 1.2.3 for comparison. function parseVersion(v: string): number[] { const core = v.replace(/^v/i, '').split('-')[0]; return core.split('.').map((p) => Number.parseInt(p, 10) || 0); @@ -59,5 +60,10 @@ export async function checkForUpdate({ const tag = body.tag_name ?? ''; const latestVersion = tag.replace(/^v/i, '') || null; const updateAvailable = tag !== '' && compareVersions(tag, currentVersion) > 0; - return ok({ currentVersion, latestVersion, updateAvailable, releaseUrl: body.html_url ?? RELEASES_PAGE }); + return ok({ + currentVersion, + latestVersion, + updateAvailable, + releaseUrl: updateAvailable ? (body.html_url ?? RELEASES_PAGE) : RELEASES_PAGE, + }); } From fcff4aa0ffb38603bedd62d79f01a3cd9cc15af9 Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 08:28:41 +0200 Subject: [PATCH 03/10] feat(settings): add autoCheckUpdates and lastUpdateCheckAt Co-Authored-By: Claude Fable 5 --- src/main/settings/appSettings.test.ts | 34 +++++++++++++++++++++++---- src/main/settings/appSettings.ts | 20 +++++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/main/settings/appSettings.test.ts b/src/main/settings/appSettings.test.ts index bb6084a..b963c0c 100644 --- a/src/main/settings/appSettings.test.ts +++ b/src/main/settings/appSettings.test.ts @@ -8,13 +8,13 @@ function fakeRepo() { describe('readSettings', () => { it('returns the default expiry when unset', () => { - expect(readSettings(fakeRepo())).toEqual({ presignExpirySeconds: 3600, theme: 'system', language: 'system' }); + expect(readSettings(fakeRepo())).toEqual({ presignExpirySeconds: 3600, theme: 'system', language: 'system', autoCheckUpdates: true, lastUpdateCheckAt: null }); }); it('returns a valid stored value', () => { const repo = fakeRepo(); repo.set('presignExpirySeconds', '86400'); - expect(readSettings(repo)).toEqual({ presignExpirySeconds: 86400, theme: 'system', language: 'system' }); + expect(readSettings(repo)).toEqual({ presignExpirySeconds: 86400, theme: 'system', language: 'system', autoCheckUpdates: true, lastUpdateCheckAt: null }); }); it('falls back to the default for a non-numeric or out-of-range stored value', () => { @@ -30,8 +30,8 @@ describe('writeSettings', () => { it('persists a value and returns the merged settings', () => { const repo = fakeRepo(); const out = writeSettings(repo, { presignExpirySeconds: 86400 }); - expect(out).toEqual({ presignExpirySeconds: 86400, theme: 'system', language: 'system' }); - expect(readSettings(repo)).toEqual({ presignExpirySeconds: 86400, theme: 'system', language: 'system' }); + expect(out).toEqual({ presignExpirySeconds: 86400, theme: 'system', language: 'system', autoCheckUpdates: true, lastUpdateCheckAt: null }); + expect(readSettings(repo)).toEqual({ presignExpirySeconds: 86400, theme: 'system', language: 'system', autoCheckUpdates: true, lastUpdateCheckAt: null }); }); it('clamps to the [1, 604800] range', () => { @@ -88,3 +88,29 @@ describe('language', () => { expect(writeSettings(repo, { language: 'bogus' as never }).language).toBe('fr'); }); }); + +describe('update-check settings', () => { + function fresh() { + const m = new Map(); + return { get: (k: string) => m.get(k), set: (k: string, v: string) => { m.set(k, v); } }; + } + + it('defaults autoCheckUpdates to true and lastUpdateCheckAt to null', () => { + const s = readSettings(fresh()); + expect(s.autoCheckUpdates).toBe(true); + expect(s.lastUpdateCheckAt).toBeNull(); + }); + + it('persists autoCheckUpdates=false', () => { + const repo = fresh(); + expect(writeSettings(repo, { autoCheckUpdates: false }).autoCheckUpdates).toBe(false); + expect(readSettings(repo).autoCheckUpdates).toBe(false); + }); + + it('persists a numeric lastUpdateCheckAt and ignores invalid values', () => { + const repo = fresh(); + expect(writeSettings(repo, { lastUpdateCheckAt: 1700000000000 }).lastUpdateCheckAt).toBe(1700000000000); + repo.set('lastUpdateCheckAt', 'nonsense'); + expect(readSettings(repo).lastUpdateCheckAt).toBeNull(); + }); +}); diff --git a/src/main/settings/appSettings.ts b/src/main/settings/appSettings.ts index f7d3f44..56a342e 100644 --- a/src/main/settings/appSettings.ts +++ b/src/main/settings/appSettings.ts @@ -7,6 +7,8 @@ export interface AppSettings { presignExpirySeconds: number; theme: ThemePreference; language: LanguagePreference; + autoCheckUpdates: boolean; + lastUpdateCheckAt: number | null; } export interface AppInfo { version: string; @@ -35,7 +37,12 @@ export function readSettings(repo: SettingsRepo): AppSettings { const theme: ThemePreference = isTheme(storedTheme) ? storedTheme : 'system'; const storedLanguage = repo.get('language'); const language: LanguagePreference = isLanguage(storedLanguage) ? storedLanguage : 'system'; - return { presignExpirySeconds, theme, language }; + const storedAuto = repo.get('autoCheckUpdates'); + const autoCheckUpdates = storedAuto === undefined ? true : storedAuto === 'true'; + const storedLast = repo.get('lastUpdateCheckAt'); + const lastN = storedLast !== undefined ? Number(storedLast) : NaN; + const lastUpdateCheckAt = Number.isFinite(lastN) && lastN >= 0 ? lastN : null; + return { presignExpirySeconds, theme, language, autoCheckUpdates, lastUpdateCheckAt }; } export function writeSettings(repo: SettingsRepo, patch: Partial): AppSettings { @@ -49,5 +56,16 @@ export function writeSettings(repo: SettingsRepo, patch: Partial): if (patch.language !== undefined && isLanguage(patch.language)) { repo.set('language', patch.language); } + if (patch.autoCheckUpdates !== undefined) { + repo.set('autoCheckUpdates', String(Boolean(patch.autoCheckUpdates))); + } + if ( + patch.lastUpdateCheckAt !== undefined && + patch.lastUpdateCheckAt !== null && + Number.isFinite(patch.lastUpdateCheckAt) && + patch.lastUpdateCheckAt >= 0 + ) { + repo.set('lastUpdateCheckAt', String(Math.round(patch.lastUpdateCheckAt))); + } return readSettings(repo); } From 0a43d61a3016b5c2bb189f9e47a226a860cd0c6a Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 08:31:11 +0200 Subject: [PATCH 04/10] test(ipc): update settings expectation for new update-check fields Co-Authored-By: Claude Fable 5 --- src/main/ipc/register.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/ipc/register.test.ts b/src/main/ipc/register.test.ts index e8e70fc..b6bcb33 100644 --- a/src/main/ipc/register.test.ts +++ b/src/main/ipc/register.test.ts @@ -479,7 +479,7 @@ describe('settings & app info handlers', () => { it('settings:get returns the default and settings:set persists a new value', async () => { const { handlers } = buildHarness(); const before = (await handlers.get(CH.getSettings)!()) as { ok: boolean; data: { presignExpirySeconds: number } }; - expect(before).toEqual({ ok: true, data: { presignExpirySeconds: 3600, theme: 'system', language: 'system' } }); + expect(before).toEqual({ ok: true, data: { presignExpirySeconds: 3600, theme: 'system', language: 'system', autoCheckUpdates: true, lastUpdateCheckAt: null } }); const saved = (await handlers.get(CH.setSettings)!({ presignExpirySeconds: 86400 })) as { ok: boolean; data: { presignExpirySeconds: number } }; expect(saved.data.presignExpirySeconds).toBe(86400); From 4fdc00d6bf76536f1e9e6c3f3da509e93642b5bc Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 08:33:32 +0200 Subject: [PATCH 05/10] feat(ipc): expose app:checkForUpdate channel Co-Authored-By: Claude Fable 5 --- src/main/ipc/channels.ts | 3 +++ src/main/ipc/register.test.ts | 19 ++++++++++++++++++- src/main/ipc/register.ts | 6 ++++++ src/preload.ts | 1 + 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index 4f4cfd0..6d361eb 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -9,6 +9,7 @@ import type { ObjectRetention, LegalHoldStatus } from '../s3/objectRetention'; import type { Endpoint, SyncPlan, SyncResult } from '../s3/sync'; import type { LocalSyncArgs } from '../s3/localSync'; import type { AppSettings, AppInfo } from '../settings/appSettings'; +import type { UpdateInfo } from '../update/checkForUpdate'; import type { ObjectAcl } from '../s3/objectAcl'; import type { EditableMetadata } from '../s3/objectMetadata'; @@ -53,6 +54,7 @@ export const CH = { setSettings: 'settings:set', getAppInfo: 'app:getInfo', openExternal: 'shell:openExternal', + checkForUpdate: 'app:checkForUpdate', getObjectAcl: 's3:getObjectAcl', putObjectAcl: 's3:putObjectAcl', getEditableMetadata: 's3:getEditableMetadata', @@ -137,6 +139,7 @@ export interface ApiMap { [CH.setSettings]: { args: [Partial]; res: Result }; [CH.getAppInfo]: { args: []; res: Result }; [CH.openExternal]: { args: [string]; res: Result }; + [CH.checkForUpdate]: { args: []; res: Result }; [CH.getObjectAcl]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result }; [CH.putObjectAcl]: { args: [{ accountId: string; bucket: string; key: string; acl: ObjectAcl }]; res: Result }; [CH.getEditableMetadata]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result }; diff --git a/src/main/ipc/register.test.ts b/src/main/ipc/register.test.ts index b6bcb33..64aa969 100644 --- a/src/main/ipc/register.test.ts +++ b/src/main/ipc/register.test.ts @@ -23,7 +23,7 @@ const fakeCrypto: Crypto = { decryptString: (b) => b.toString('utf8'), }; -function buildHarness() { +function buildHarness(overrides: Record = {}) { const handlers = new Map unknown>(); const progressEvents: { channel: string; payload: unknown }[] = []; const ipcMain: IpcMainLike = { @@ -43,6 +43,7 @@ function buildHarness() { selectDirectory: vi.fn().mockResolvedValue('/picked/dir'), appVersion: '1.2.3', openExternal: vi.fn().mockResolvedValue(undefined), + ...overrides, }; registerIpc(ipcMain, deps); return { handlers, deps, progressEvents }; @@ -496,6 +497,22 @@ describe('settings & app info handlers', () => { expect(res.ok).toBe(true); expect(res.data).toEqual({ version: '1.2.3', encryptionAvailable: true, accountCount: 0 }); }); + + it('app:checkForUpdate reports a newer release as available', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + status: 200, + ok: true, + json: async () => ({ tag_name: 'v9.9.9', html_url: 'https://example/release' }), + }); + const { handlers } = buildHarness({ fetchImpl }); + const res = (await handlers.get(CH.checkForUpdate)!()) as { + ok: boolean; + data: { updateAvailable: boolean; latestVersion: string }; + }; + expect(res.ok).toBe(true); + expect(res.data.updateAvailable).toBe(true); + expect(res.data.latestVersion).toBe('9.9.9'); + }); }); describe('object ACL handlers', () => { diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index b999614..06c5e42 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -27,6 +27,7 @@ import type { ObjectAcl } from '../s3/objectAcl'; import { getEditableMetadata, updateObjectMetadata } from '../s3/objectMetadata'; import { createFolder, moveObject, moveFolder } from '../s3/transfer'; import { createBucket } from '../s3/buckets'; +import { checkForUpdate } from '../update/checkForUpdate'; import { planSync, runSync, type Endpoint } from '../s3/sync'; import { planLocalSync, runLocalSync } from '../s3/localSync'; import type { LocalSyncArgs } from '../s3/localSync'; @@ -56,6 +57,8 @@ export interface RegisterDeps { appVersion: string; /** Opens a URL in the user's default browser (Electron shell.openExternal), injected by main.ts. */ openExternal: (url: string) => Promise; + /** Fetch implementation for the update check; defaults to globalThis.fetch. Injectable for tests. */ + fetchImpl?: typeof fetch; /** Applies the chosen theme to native chrome (nativeTheme.themeSource), injected by main.ts. Optional so tests/headless can omit it. */ applyTheme?: (theme: AppSettings['theme']) => void; } @@ -387,4 +390,7 @@ export function registerIpc(ipcMain: IpcMainLike, deps: RegisterDeps): void { accountCount: deps.accounts.list().length, }), ); + h(CH.checkForUpdate, () => + checkForUpdate({ fetchImpl: deps.fetchImpl ?? globalThis.fetch, currentVersion: deps.appVersion }), + ); } diff --git a/src/preload.ts b/src/preload.ts index 4717db8..d971be8 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -50,6 +50,7 @@ const api = { getSettings: () => invoke(CH.getSettings), setSettings: (a: ApiMap[typeof CH.setSettings]['args'][0]) => invoke(CH.setSettings, a), getAppInfo: () => invoke(CH.getAppInfo), + checkForUpdate: () => invoke(CH.checkForUpdate), getObjectAcl: (a: ApiMap[typeof CH.getObjectAcl]['args'][0]) => invoke(CH.getObjectAcl, a), putObjectAcl: (a: ApiMap[typeof CH.putObjectAcl]['args'][0]) => invoke(CH.putObjectAcl, a), getEditableMetadata: (a: ApiMap[typeof CH.getEditableMetadata]['args'][0]) => invoke(CH.getEditableMetadata, a), From f57193d938eb4d263f537b46869194ad0bd608a2 Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 08:37:24 +0200 Subject: [PATCH 06/10] feat(update): add daily auto-check throttle helper Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/lib/updateThrottle.test.ts | 22 ++++++++++++++++++++++ src/renderer/lib/updateThrottle.ts | 17 +++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 src/renderer/lib/updateThrottle.test.ts create mode 100644 src/renderer/lib/updateThrottle.ts diff --git a/src/renderer/lib/updateThrottle.test.ts b/src/renderer/lib/updateThrottle.test.ts new file mode 100644 index 0000000..6964460 --- /dev/null +++ b/src/renderer/lib/updateThrottle.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { shouldAutoCheck, UPDATE_CHECK_INTERVAL_MS } from './updateThrottle'; + +describe('shouldAutoCheck', () => { + const now = 1_000_000_000_000; + + it('is true when auto-check is on and it was never checked', () => { + expect(shouldAutoCheck({ autoCheckUpdates: true, lastUpdateCheckAt: null, now })).toBe(true); + }); + + it('is true when the last check was at least the interval ago', () => { + expect(shouldAutoCheck({ autoCheckUpdates: true, lastUpdateCheckAt: now - UPDATE_CHECK_INTERVAL_MS, now })).toBe(true); + }); + + it('is false when the last check was within the interval', () => { + expect(shouldAutoCheck({ autoCheckUpdates: true, lastUpdateCheckAt: now - 1000, now })).toBe(false); + }); + + it('is false when auto-check is disabled', () => { + expect(shouldAutoCheck({ autoCheckUpdates: false, lastUpdateCheckAt: null, now })).toBe(false); + }); +}); diff --git a/src/renderer/lib/updateThrottle.ts b/src/renderer/lib/updateThrottle.ts new file mode 100644 index 0000000..3524c87 --- /dev/null +++ b/src/renderer/lib/updateThrottle.ts @@ -0,0 +1,17 @@ +export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +export function shouldAutoCheck({ + autoCheckUpdates, + lastUpdateCheckAt, + now, + intervalMs = UPDATE_CHECK_INTERVAL_MS, +}: { + autoCheckUpdates: boolean; + lastUpdateCheckAt: number | null; + now: number; + intervalMs?: number; +}): boolean { + if (!autoCheckUpdates) return false; + if (lastUpdateCheckAt === null) return true; + return now - lastUpdateCheckAt >= intervalMs; +} From 5cc4854e0e281d30a6a91a5a0f1a4859de5ddf4f Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 08:39:36 +0200 Subject: [PATCH 07/10] feat(update): add useUpdateCheck hook Co-Authored-By: Claude Fable 5 --- src/renderer/hooks/useUpdateCheck.test.tsx | 31 ++++++++++++++++++++++ src/renderer/hooks/useUpdateCheck.ts | 11 ++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/renderer/hooks/useUpdateCheck.test.tsx create mode 100644 src/renderer/hooks/useUpdateCheck.ts diff --git a/src/renderer/hooks/useUpdateCheck.test.tsx b/src/renderer/hooks/useUpdateCheck.test.tsx new file mode 100644 index 0000000..eb77068 --- /dev/null +++ b/src/renderer/hooks/useUpdateCheck.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useUpdateCheck } from './useUpdateCheck'; + +function wrapper() { + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +const info = { currentVersion: '1.0.0', latestVersion: '2.0.0', updateAvailable: true, releaseUrl: 'https://example/r' }; + +beforeEach(() => { + (window as unknown as { s3: unknown }).s3 = { + checkForUpdate: vi.fn().mockResolvedValue({ ok: true, data: info }), + setSettings: vi.fn().mockResolvedValue({ ok: true, data: {} }), + }; +}); + +describe('useUpdateCheck', () => { + it('returns the update info and records the check time', async () => { + const { result } = renderHook(() => useUpdateCheck(), { wrapper: wrapper() }); + result.current.mutate(); + await waitFor(() => expect(result.current.data).toEqual(info)); + const setSettings = (window.s3 as unknown as { setSettings: ReturnType }).setSettings; + expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ lastUpdateCheckAt: expect.any(Number) })); + }); +}); diff --git a/src/renderer/hooks/useUpdateCheck.ts b/src/renderer/hooks/useUpdateCheck.ts new file mode 100644 index 0000000..05bf97d --- /dev/null +++ b/src/renderer/hooks/useUpdateCheck.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query'; +import { unwrap } from '../lib/result'; + +export function useUpdateCheck() { + return useMutation({ + mutationFn: async () => unwrap(await window.s3.checkForUpdate()), + onSuccess: () => { + void window.s3.setSettings({ lastUpdateCheckAt: Date.now() }); + }, + }); +} From 77056dfdd90a9fa5cb107d1b27bf34ff7c79d729 Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 08:44:09 +0200 Subject: [PATCH 08/10] feat(settings): check-for-updates UI with download link and auto-check toggle Co-Authored-By: Claude Fable 5 --- .../settings/SettingsScreen.test.tsx | 29 ++++++++++ .../components/settings/SettingsScreen.tsx | 53 +++++++++++++++++++ src/renderer/i18n/locales/de.json | 13 ++++- src/renderer/i18n/locales/en.json | 13 ++++- src/renderer/i18n/locales/fr.json | 13 ++++- src/renderer/i18n/locales/nl.json | 13 ++++- src/renderer/i18n/locales/pl.json | 13 ++++- src/renderer/i18n/locales/ro.json | 13 ++++- 8 files changed, 154 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/settings/SettingsScreen.test.tsx b/src/renderer/components/settings/SettingsScreen.test.tsx index 79c2a9a..20e852b 100644 --- a/src/renderer/components/settings/SettingsScreen.test.tsx +++ b/src/renderer/components/settings/SettingsScreen.test.tsx @@ -75,4 +75,33 @@ describe('SettingsScreen', () => { await userEvent.selectOptions(select, 'de'); await waitFor(() => expect(window.s3.setSettings).toHaveBeenCalledWith({ language: 'de' })); }); + + it('checks for updates and offers a download when one is available', async () => { + (window as unknown as { s3: unknown }).s3 = { + getSettings: vi.fn().mockResolvedValue({ ok: true, data: { presignExpirySeconds: 3600, autoCheckUpdates: true } }), + setSettings: vi.fn().mockResolvedValue({ ok: true, data: { presignExpirySeconds: 3600 } }), + getAppInfo: vi.fn().mockResolvedValue({ ok: true, data: { version: '1.0.0', encryptionAvailable: true, accountCount: 0 } }), + openExternal: vi.fn().mockResolvedValue({ ok: true, data: true }), + checkForUpdate: vi.fn().mockResolvedValue({ ok: true, data: { currentVersion: '1.0.0', latestVersion: '2.0.0', updateAvailable: true, releaseUrl: 'https://example/r' } }), + }; + wrap(); + await userEvent.click(await screen.findByRole('button', { name: 'Check for updates' })); + expect(await screen.findByText('Version 2.0.0 available')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Download' })); + expect(window.s3.openExternal).toHaveBeenCalledWith('https://example/r'); + }); + + it('persists the auto-check toggle', async () => { + (window as unknown as { s3: unknown }).s3 = { + getSettings: vi.fn().mockResolvedValue({ ok: true, data: { presignExpirySeconds: 3600, autoCheckUpdates: true } }), + setSettings: vi.fn().mockResolvedValue({ ok: true, data: { presignExpirySeconds: 3600, autoCheckUpdates: false } }), + getAppInfo: vi.fn().mockResolvedValue({ ok: true, data: { version: '1.0.0', encryptionAvailable: true, accountCount: 0 } }), + openExternal: vi.fn().mockResolvedValue({ ok: true, data: true }), + checkForUpdate: vi.fn(), + }; + wrap(); + const toggle = await screen.findByLabelText('Check for updates on startup'); + await userEvent.click(toggle); + await waitFor(() => expect(window.s3.setSettings).toHaveBeenCalledWith({ autoCheckUpdates: false })); + }); }); diff --git a/src/renderer/components/settings/SettingsScreen.tsx b/src/renderer/components/settings/SettingsScreen.tsx index c3c16ff..e671899 100644 --- a/src/renderer/components/settings/SettingsScreen.tsx +++ b/src/renderer/components/settings/SettingsScreen.tsx @@ -1,4 +1,5 @@ import { useSettings } from '../../hooks/useSettings'; +import { useUpdateCheck } from '../../hooks/useUpdateCheck'; import { useToast } from '../ui/ToastProvider'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -43,6 +44,18 @@ export function SettingsScreen() { const language = settings.data?.language ?? 'system'; const [showLicenses, setShowLicenses] = useState(false); + const autoCheck = settings.data?.autoCheckUpdates ?? true; + const check = useUpdateCheck(); + + const onToggleAutoCheck = async (value: boolean) => { + try { + await save.mutateAsync({ autoCheckUpdates: value }); + show(t('common.settingsSaved')); + } catch (e) { + show((e as Error).message, 'error'); + } + }; + const onChangeExpiry = async (value: number) => { try { await save.mutateAsync({ presignExpirySeconds: value }); @@ -135,6 +148,46 @@ export function SettingsScreen() { )} +
+ +
+ {check.isPending && {t('settings.checkingUpdates')}} + {check.isError && {t('settings.updateCheckFailed')}} + {check.isSuccess && !check.data.updateAvailable && ( + {t('settings.upToDate')} + )} + {check.isSuccess && check.data.updateAvailable && ( + + {t('settings.updateAvailable', { version: check.data.latestVersion })}{' '} + + + )} +
+ +

{t('settings.autoCheckHelp')}

+
+
+
+ {check.isPending && {t('settings.checkingUpdates')}} + {check.isError && {t('settings.updateCheckFailed')}} + {check.isSuccess && !check.data.updateAvailable && ( + {t('settings.upToDate')} + )} + {check.isSuccess && check.data.updateAvailable && ( + + {t('settings.updateAvailable', { version: check.data.latestVersion })}{' '} + + + )} +
+ +

{t('settings.autoCheckHelp')}

+
+``` + +- [ ] **Step 5: Run to verify pass** + +Run: `npx vitest run src/renderer/components/settings/SettingsScreen.test.tsx` +Expected: PASS (existing + 2 new tests). + +- [ ] **Step 6: Types + lint + commit** + +Run: `npx tsc --noEmit` (clean), `npm run lint` (0 errors), then: + +```bash +git add src/renderer/i18n/locales/*.json src/renderer/components/settings/SettingsScreen.tsx src/renderer/components/settings/SettingsScreen.test.tsx +git commit -m "feat(settings): check-for-updates UI with download link and auto-check toggle" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 7: Startup auto-check + toast + +**Files:** +- Create: `src/renderer/components/StartupUpdateCheck.tsx` +- Modify: `src/renderer/App.tsx` +- Modify: `src/renderer/App.test.tsx` + +Why a separate component: `useToast()` must run **inside** `ToastProvider`, but `App` renders that provider, so the auto-check/toast logic lives in a render-null child mounted inside it. + +- [ ] **Step 1: Write the failing App tests** + +In `src/renderer/App.test.tsx`, add two tests in a new describe block (place after the existing describes). They set their own `getSettings`/`checkForUpdate` before `renderApp()`: + +```tsx +describe('App — update check', () => { + it('shows a toast when a due startup check finds an update', async () => { + const s3 = window.s3 as unknown as Record>; + s3.getSettings = vi.fn().mockResolvedValue({ ok: true, data: { presignExpirySeconds: 3600, theme: 'system', autoCheckUpdates: true, lastUpdateCheckAt: null } }); + s3.setSettings = vi.fn().mockResolvedValue({ ok: true, data: {} }); + s3.checkForUpdate = vi.fn().mockResolvedValue({ ok: true, data: { currentVersion: '1.0.0', latestVersion: '2.0.0', updateAvailable: true, releaseUrl: 'https://example/r' } }); + renderApp(); + expect(await screen.findByText('Update available: 2.0.0')).toBeInTheDocument(); + }); + + it('does not auto-check when the last check was recent', async () => { + const s3 = window.s3 as unknown as Record>; + s3.getSettings = vi.fn().mockResolvedValue({ ok: true, data: { presignExpirySeconds: 3600, theme: 'system', autoCheckUpdates: true, lastUpdateCheckAt: Date.now() } }); + s3.checkForUpdate = vi.fn(); + renderApp(); + await screen.findByRole('button', { name: 'Files' }); + expect(s3.checkForUpdate).not.toHaveBeenCalled(); + }); +}); +``` + +Note: the existing shared `beforeEach` `getSettings` mock returns no `autoCheckUpdates`, so `shouldAutoCheck` returns false for all existing tests — they never call `checkForUpdate` and need no new mock. + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run src/renderer/App.test.tsx` +Expected: FAIL — no toast (StartupUpdateCheck not wired yet). + +- [ ] **Step 3: Implement the component** + +Create `src/renderer/components/StartupUpdateCheck.tsx`: + +```tsx +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSettings } from '../hooks/useSettings'; +import { useUpdateCheck } from '../hooks/useUpdateCheck'; +import { useToast } from './ui/ToastProvider'; +import { shouldAutoCheck } from '../lib/updateThrottle'; + +/** Renders nothing; fires a daily-throttled update check on startup and toasts when one is available. */ +export function StartupUpdateCheck() { + const { settings } = useSettings(); + const check = useUpdateCheck(); + const { show } = useToast(); + const { t } = useTranslation(); + const fired = useRef(false); + const toasted = useRef(false); + + useEffect(() => { + if (fired.current || !settings.isSuccess) return; + const due = shouldAutoCheck({ + autoCheckUpdates: settings.data.autoCheckUpdates, + lastUpdateCheckAt: settings.data.lastUpdateCheckAt, + now: Date.now(), + }); + if (due) { + fired.current = true; + check.mutate(); + } + }, [settings.isSuccess, settings.data, check]); + + useEffect(() => { + if (toasted.current) return; + if (check.data?.updateAvailable) { + toasted.current = true; + show(t('updates.available', { version: check.data.latestVersion })); + } + }, [check.data, show, t]); + + return null; +} +``` + +- [ ] **Step 4: Mount it inside ToastProvider in `App.tsx`** + +Add the import (after the `SettingsScreen` import): + +```tsx +import { StartupUpdateCheck } from './components/StartupUpdateCheck'; +``` + +In the returned JSX, the outermost element is ``. Insert `` as its first child, immediately before ``: + +```tsx + + + +``` + +(Match the existing indentation; only that one line is added.) + +- [ ] **Step 5: Run to verify pass** + +Run: `npx vitest run src/renderer/App.test.tsx` +Expected: PASS (existing 11 + 2 new). + +- [ ] **Step 6: Full suite + types + lint** + +Run: `npm test` (all green), `npx tsc --noEmit` (clean), `npm run lint` (0 errors). + +- [ ] **Step 7: Commit** + +```bash +git add src/renderer/components/StartupUpdateCheck.tsx src/renderer/App.tsx src/renderer/App.test.tsx +git commit -m "feat(update): daily startup update check with toast" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Self-review notes + +- **Spec coverage:** check module + compareVersions (Task 1); IPC channel/handler/preload (Task 3); `autoCheckUpdates` + `lastUpdateCheckAt` (Task 2); throttle helper (Task 4); hook + timestamp recording (Task 5); Settings button/status/download/toggle + i18n (Task 6); startup auto-check + toast (Task 7). All spec sections covered. +- **Type consistency:** `UpdateInfo` defined in Task 1, imported in channels (Task 3); `checkForUpdate({ fetchImpl, currentVersion })` signature consistent across Tasks 1/3; `shouldAutoCheck({ autoCheckUpdates, lastUpdateCheckAt, now })` consistent across Tasks 4/7; `useUpdateCheck()` mutation API (`mutate`/`data`/`isPending`/`isError`/`isSuccess`) used consistently in Tasks 6/7. +- **No main.ts change:** handler defaults `fetchImpl` to `globalThis.fetch`; called out in Task 3. +- **Existing-test impact:** Task 2 updates the exact-equality `appSettings` assertions; Task 7 notes existing App tests don't trigger the auto-check (shared mock lacks `autoCheckUpdates`). diff --git a/docs/superpowers/specs/2026-06-13-update-check-design.md b/docs/superpowers/specs/2026-06-13-update-check-design.md new file mode 100644 index 0000000..61ad6e7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-update-check-design.md @@ -0,0 +1,144 @@ +# GitHub update check — design + +## Summary + +Add a lightweight update checker that compares the running app version against +the latest GitHub release. It surfaces a **"Check for updates"** button in +Settings (with a Download link when a newer version exists) and a **once-per-day +automatic check on startup** that shows an informational toast when an update is +available. No auto-download/install — just notify and link. + +## Decisions made + +- **Version check only, not a native auto-updater.** Electron's built-in + `autoUpdater` (Squirrel) supports only macOS + Windows, requires macOS code + signing, and has no Linux support; it also fits poorly with the Electron Forge + build (no hosted feed). A GitHub Releases version check works identically on + all platforms, needs no signing, and adds no npm dependency (Node's global + `fetch` is available in the main process). +- **Trigger:** manual button in Settings **plus** an automatic startup check + throttled to **at most once per 24 hours**. +- **No new dependency.** Uses `globalThis.fetch`, the existing `app.getVersion()` + injection, and the existing `openExternal` IPC. +- **Toasts are text-only** (current `ToastProvider`), so the startup toast is + purely informational; the actionable Download link lives in Settings. + +## Data source + +`GET https://api.github.com/repos/NoiXdev/s3Manager/releases/latest` with headers +`Accept: application/vnd.github+json` and a `User-Agent` (GitHub requires one). +`/releases/latest` excludes drafts and pre-releases. Response of interest: +`tag_name` (e.g. `v1.2.0`) and `html_url` (the release page). A **404** means no +published release yet → treat as "up to date". Unauthenticated rate limit is +60 req/h — ample for occasional checks. + +## Components + +### Main — `src/main/update/checkForUpdate.ts` + +- `const GITHUB_REPO = 'NoiXdev/s3Manager'`. +- `compareVersions(a, b): number` — strips a leading `v` and any `-prerelease` + suffix, compares `major.minor.patch` numerically (so `1.10.0 > 1.9.0`). + Returns `>0` if `a` is newer, `0` if equal, `<0` if older. +- `interface UpdateInfo { currentVersion: string; latestVersion: string | null; updateAvailable: boolean; releaseUrl: string }`. +- `async function checkForUpdate({ fetchImpl, currentVersion }): Promise>`: + - Fetches the latest-release endpoint via `fetchImpl`. + - On HTTP 404 → `ok({ currentVersion, latestVersion: null, updateAvailable: false, releaseUrl: 'https://github.com/NoiXdev/s3Manager/releases' })`. + - On non-OK (e.g. 403 rate-limit, 5xx) → `err(...)` with a readable message. + - On OK → parse `tag_name`/`html_url`; `updateAvailable = compareVersions(tag, currentVersion) > 0`; `releaseUrl = html_url ?? `. + - On thrown fetch/parse error → `err(message)`. + +### Main — IPC wiring + +- New channel `checkForUpdate: 'app:checkForUpdate'`, `{ args: []; res: Result }`. `UpdateInfo` is imported from the update module into `channels.ts`. +- `RegisterDeps` gains `fetchImpl?: typeof fetch` (optional; defaults to `globalThis.fetch`). `main.ts` injects `fetchImpl: (...a) => globalThis.fetch(...a)` (or omits it to use the default). +- Handler: `h(CH.checkForUpdate, () => checkForUpdate({ fetchImpl: deps.fetchImpl ?? globalThis.fetch, currentVersion: deps.appVersion }))`. The handler is **pure** — it does not persist anything (the daily-throttle timestamp is owned by the renderer; see below). +- `preload.ts`: `checkForUpdate: () => invoke(CH.checkForUpdate)`. + +### Settings persistence — `src/main/settings/appSettings.ts` + +- `AppSettings` gains `autoCheckUpdates: boolean` (default **true**) and + `lastUpdateCheckAt: number | null` (default **null**). +- `readSettings`: parse `autoCheckUpdates` from `'true'`/`'false'` (default true); + parse `lastUpdateCheckAt` as a finite number ≥ 0 or null. +- `writeSettings`: handle `autoCheckUpdates` (store `String(boolean)`) and + `lastUpdateCheckAt` (store `String(number)` when a finite number ≥ 0). + +### Renderer — throttle helper `src/renderer/lib/updateThrottle.ts` + +- `const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000`. +- `shouldAutoCheck({ autoCheckUpdates, lastUpdateCheckAt, now, intervalMs = UPDATE_CHECK_INTERVAL_MS }): boolean` → + `autoCheckUpdates && (lastUpdateCheckAt == null || now - lastUpdateCheckAt >= intervalMs)`. + +### Renderer — hook `src/renderer/hooks/useUpdateCheck.ts` + +- A TanStack `useMutation` whose `mutationFn` calls `unwrap(window.s3.checkForUpdate())`. +- `onSuccess` records the check time for the daily throttle by calling + `window.s3.setSettings({ lastUpdateCheckAt: Date.now() })` (fire-and-forget; + no settings-query invalidation needed — the value is read fresh on next launch). +- Returns the mutation (`mutate`, `data`, `isPending`, `isError`, `error`). + +### Renderer — Settings UI (`SettingsScreen.tsx`, "About" area) + +- A **"Check for updates"** button → `check.mutate()`. Inline status from the + mutation state: + - pending → `settings.checkingUpdates` + - success + `!updateAvailable` → `settings.upToDate` + - success + `updateAvailable` → `settings.updateAvailable` (with version) and a + **Download** link/button → `window.s3.openExternal(data.releaseUrl)` + - error → `settings.updateCheckFailed` +- A checkbox toggle **"Check for updates on startup"** bound to + `autoCheckUpdates` (saved via the existing `save` mutation). + +### Renderer — startup auto-check (`App.tsx`) + +- Instantiate `const check = useUpdateCheck()`. +- One-shot `useEffect` guarded by a `useRef(false)`: when `settings.isSuccess` + and `shouldAutoCheck({ autoCheckUpdates, lastUpdateCheckAt, now: Date.now() })`, + call `check.mutate()` once. +- A second `useEffect`: when `check.data?.updateAvailable` is true, `show(t('updates.available', { version: check.data.latestVersion }))` once. Auto-check errors are ignored (no toast — don't nag offline users). + +## i18n (all six locales) + +`settings.checkUpdates`, `settings.checkingUpdates`, `settings.upToDate`, +`settings.updateAvailable` ("Version {{version}} available"), +`settings.updateDownload`, `settings.updateCheckFailed`, +`settings.autoCheck` (toggle label), `settings.autoCheckHelp`, +`updates.available` (toast, "Update available: {{version}}"). + +## Error handling & edge cases + +- **Offline / network error:** manual → inline `updateCheckFailed`; auto → silent. +- **No releases yet (404):** treated as up to date. +- **Rate limited (403):** `err` → manual shows failure; auto silent. +- **Pre-releases:** excluded by `/releases/latest`. +- **Dev build:** `app.getVersion()` returns the `package.json` version; comparison still works. + +## Testing (TDD) + +- `checkForUpdate.test.ts`: stubbed `fetchImpl` for update-available, up-to-date, + 404-no-release, non-OK (403/500), and thrown-error cases; `compareVersions` + unit cases incl. `1.10.0 > 1.9.0`, equal, `v`-prefix, pre-release suffix. +- `appSettings.test.ts`: `autoCheckUpdates` default true and persists false; + `lastUpdateCheckAt` default null and persists a number; ignores invalid values. +- `register.test.ts`: a `checkForUpdate` handler test with a stubbed `fetchImpl` + returning a newer tag → `updateAvailable: true`. +- `updateThrottle.test.ts`: `shouldAutoCheck` true when never checked, true when + ≥24h, false when <24h, false when `autoCheckUpdates` is false. +- `useUpdateCheck.test.tsx`: mocks `window.s3.checkForUpdate` + `setSettings`; + asserts data flows through and `setSettings` is called with `lastUpdateCheckAt`. +- `SettingsScreen.test.tsx`: button → up-to-date and update-available states; + Download calls `openExternal`; toggle persists `autoCheckUpdates`. +- `App.test.tsx`: with `checkForUpdate` returning `updateAvailable` and a + due/never `lastUpdateCheckAt`, a toast appears; with a recent `lastUpdateCheckAt`, + no auto-check fires (`checkForUpdate` not called). + +## Out of scope + +- Auto-download/install, delta updates, release-notes rendering in-app. +- Per-channel (beta) updates. +- Reminders/snooze beyond the 24h startup throttle. + +## Open questions + +None.