diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index 6861a6a9..256d8311 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -64,6 +64,7 @@ const CHANNELS = [ 'codeburn:telemetrySetEnabled', 'codeburn:telemetryOnboarded', 'codeburn:telemetryTrack', + 'codeburn:getUpdateStatus', ] as const const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = [ diff --git a/app/electron/main.ts b/app/electron/main.ts index 7f870130..4aefa4b4 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -4,9 +4,12 @@ import path from 'node:path' import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, type ActionResult } from './cli' import { getQuota, sanitizeError } from './quota' import { Telemetry } from './telemetry' +import { createUpdateChecker, type UpdateChecker, type UpdateStatus } from './updates' // Initialized in bootstrap() once Electron paths exist; stays null under tests. let telemetryInstance: Telemetry | null = null +// The once-per-launch + 24h update-availability checker. Null under tests. +let updateChecker: UpdateChecker | null = null /** The slice of Telemetry the bridge handlers use — injectable for tests. */ export type TelemetryBridge = Pick @@ -26,6 +29,8 @@ const WARMUP_TIMEOUT_MS = 10 * 60_000 const PROGRESS_LINE_PREFIX = 'CODEBURN_PROGRESS ' // IPC channel carrying cold-start scan-progress events to the splash. export const PROGRESS_CHANNEL = 'codeburn:progress' +// IPC channel pushing update-availability status to open windows (launch + 24h). +export const UPDATE_CHANNEL = 'codeburn:update' /** Line-buffer a spawn's stderr and forward each parsed scan-progress event. */ export function makeProgressReader(emit: (event: unknown) => void): (chunk: string) => void { @@ -50,6 +55,14 @@ function broadcastProgress(event: unknown): void { } } +function broadcastUpdateStatus(status: UpdateStatus): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(UPDATE_CHANNEL, status) + } +} + +const NO_UPDATE_STATUS: UpdateStatus = { currentVersion: '', latestVersion: null, updateAvailable: false, tag: null } + function providerArgs(provider: string | undefined): string[] { return provider && provider !== 'all' ? ['--provider', provider] : [] } @@ -136,6 +149,8 @@ type Deps = { emitProgress?: (event: unknown) => void /** Consent-gated anonymous telemetry; absent under tests unless injected. */ telemetry?: TelemetryBridge | null + /** Cached update-availability status; absent under tests unless injected. */ + getUpdateStatus?: () => Promise } type Handler = (...args: any[]) => Promise @@ -145,7 +160,7 @@ type Handler = (...args: any[]) => Promise * shell) and returns a result envelope. Pure + injectable so the wiring is * unit-testable without launching Electron. */ -export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota, emitProgress: broadcastProgress, telemetry: telemetryInstance }): Record { +export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota, emitProgress: broadcastProgress, telemetry: telemetryInstance, getUpdateStatus: () => updateChecker ? updateChecker.getStatus() : Promise.resolve(NO_UPDATE_STATUS) }): Record { const emitProgress = deps.emitProgress ?? (() => {}) const telemetry = deps.telemetry ?? null // Flips true after the first overview fetch succeeds. Until then, every @@ -277,6 +292,9 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re telemetry?.track(String(name ?? ''), props) return { ok: true, value: true } }, + // One-shot read of the cached update-availability status. The check itself + // runs in the background (launch + 24h); this returns whatever is known. + 'codeburn:getUpdateStatus': async () => ({ ok: true, value: deps.getUpdateStatus ? await deps.getUpdateStatus() : NO_UPDATE_STATUS }), } } @@ -475,6 +493,14 @@ function bootstrap(): void { app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) + + // Update availability: check once at launch, then every 24h, pushing each + // result to any open window. Never downloads/installs (unsigned builds); + // errors are swallowed inside the checker as a silent no-op. + updateChecker = createUpdateChecker({ currentVersion: app.getVersion() }) + const runUpdateCheck = () => { void updateChecker?.check().then(broadcastUpdateStatus) } + runUpdateCheck() + setInterval(runUpdateCheck, 24 * 60 * 60 * 1000) }) app.on('window-all-closed', () => { diff --git a/app/electron/preload.ts b/app/electron/preload.ts index c6858462..71a22d45 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -61,6 +61,13 @@ const bridge = { ipcRenderer.on('codeburn:progress', listener) return () => { ipcRenderer.removeListener('codeburn:progress', listener) } }, + // Update availability: a one-shot read plus a push for the launch + 24h checks. + getUpdateStatus: () => invoke('codeburn:getUpdateStatus'), + onUpdateStatus: (cb: (status: unknown) => void) => { + const listener = (_e: unknown, status: unknown) => cb(status) + ipcRenderer.on('codeburn:update', listener) + return () => { ipcRenderer.removeListener('codeburn:update', listener) } + }, platform: process.platform, } diff --git a/app/electron/updates.test.ts b/app/electron/updates.test.ts new file mode 100644 index 00000000..92d92bd8 --- /dev/null +++ b/app/electron/updates.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest' + +import { compareSemver, createUpdateChecker, fetchReleases, pickLatestDesktopVersion } from './updates' + +const CURRENT = '0.9.16' + +function release(tag: string) { + return { tag_name: tag } +} + +describe('compareSemver', () => { + it.each([ + ['0.9.17', '0.9.16', 1], + ['0.10.0', '0.9.16', 1], + ['1.0.0', '0.9.16', 1], + ['0.9.16', '0.9.16', 0], + ['0.9.15', '0.9.16', -1], + ['0.9.9', '0.9.16', -1], // string sort would rank "9" > "16"; numeric must not + ])('compareSemver(%s, %s) === %d', (a, b, expected) => { + expect(Math.sign(compareSemver(a, b))).toBe(expected) + }) +}) + +describe('pickLatestDesktopVersion', () => { + it('picks the newest desktop-v tag by semver, ignoring non-desktop tags', () => { + const picked = pickLatestDesktopVersion([ + release('v0.9.20'), // CLI release — ignored + release('mac-v0.9.18'), // menubar release — ignored + release('desktop-v0.9.15'), + release('desktop-v0.9.17'), + release('desktop-v0.9.16'), + ]) + expect(picked).toEqual({ version: '0.9.17', tag: 'desktop-v0.9.17' }) + }) + + it('returns null when no desktop-v release is present', () => { + expect(pickLatestDesktopVersion([release('v1.2.3'), release('mac-v0.9.9')])).toBeNull() + }) + + it('rejects malformed / prerelease-shaped desktop tags', () => { + expect(pickLatestDesktopVersion([release('desktop-v0.9'), release('desktop-v1.0.0-rc1')])).toBeNull() + }) +}) + +describe('createUpdateChecker', () => { + const checker = (releases: unknown[]) => + createUpdateChecker({ currentVersion: CURRENT, fetchReleasesImpl: async () => releases as never }) + + it('flags an update when a newer desktop release exists', async () => { + const status = await checker([release('desktop-v0.9.17')]).getStatus() + expect(status).toEqual({ currentVersion: '0.9.16', latestVersion: '0.9.17', updateAvailable: true, tag: 'desktop-v0.9.17' }) + }) + + it.each(['0.10.0', '1.0.0'])('flags an update for a %s release', async version => { + const status = await checker([release(`desktop-v${version}`)]).getStatus() + expect(status).toMatchObject({ updateAvailable: true, latestVersion: version }) + }) + + it('reports no update for the same version (tag suppressed)', async () => { + const status = await checker([release('desktop-v0.9.16')]).getStatus() + expect(status).toEqual({ currentVersion: '0.9.16', latestVersion: '0.9.16', updateAvailable: false, tag: null }) + }) + + it('reports no update for an older release', async () => { + const status = await checker([release('desktop-v0.9.15')]).getStatus() + expect(status).toMatchObject({ updateAvailable: false, latestVersion: '0.9.15' }) + }) + + it('is a silent no-op on a fetch error and retries on the next cycle', async () => { + let calls = 0 + const check = createUpdateChecker({ + currentVersion: CURRENT, + fetchReleasesImpl: async () => { + calls += 1 + if (calls === 1) throw new Error('offline') + return [release('desktop-v0.9.17')] as never + }, + }) + const first = await check.getStatus() + expect(first).toMatchObject({ updateAvailable: false, latestVersion: null }) // error: no crash, no update + const second = await check.getStatus() // retries because the error did not stamp lastCheckedAt + expect(second).toMatchObject({ updateAvailable: true, latestVersion: '0.9.17' }) + expect(calls).toBe(2) + }) + + it('serves the cached status within the 24h interval, then re-checks after it', async () => { + let clock = 1_000 + let calls = 0 + const check = createUpdateChecker({ + currentVersion: CURRENT, + now: () => clock, + fetchReleasesImpl: async () => { + calls += 1 + return [release('desktop-v0.9.17')] as never + }, + }) + await check.getStatus() + expect(calls).toBe(1) + clock += 60_000 // <24h later + await check.getStatus() + expect(calls).toBe(1) // served from cache + clock += 24 * 60 * 60 * 1000 // past the interval + await check.getStatus() + expect(calls).toBe(2) + }) +}) + +describe('fetchReleases', () => { + it('reads the releases feed with no auth or app-identifying headers', async () => { + const fakeFetch = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify([{ tag_name: 'desktop-v0.9.17' }]), { status: 200 })) + const result = await fetchReleases(new AbortController().signal, fakeFetch as unknown as typeof fetch) + expect(result).toEqual([{ tag_name: 'desktop-v0.9.17' }]) + const [url, init] = fakeFetch.mock.calls[0]! + expect(String(url)).toContain('api.github.com/repos/getagentseal/codeburn/releases') + // No custom headers at all → no Authorization, no app-identifying UA override. + expect(init?.headers).toBeUndefined() + }) + + it('throws on a non-2xx response so the checker treats it as a no-op', async () => { + const fakeFetch = vi.fn(async () => new Response('rate limited', { status: 403 })) + await expect(fetchReleases(new AbortController().signal, fakeFetch as unknown as typeof fetch)).rejects.toThrow(/403/) + }) + + it('tolerates a non-array body', async () => { + const fakeFetch = vi.fn(async () => new Response(JSON.stringify({ message: 'oops' }), { status: 200 })) + expect(await fetchReleases(new AbortController().signal, fakeFetch as unknown as typeof fetch)).toEqual([]) + }) +}) diff --git a/app/electron/updates.ts b/app/electron/updates.ts new file mode 100644 index 00000000..4100200e --- /dev/null +++ b/app/electron/updates.ts @@ -0,0 +1,133 @@ +// "Update available" check for CodeBurn Desktop, mirroring the Swift menubar's +// UpdateChecker (mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift): it reads +// the public GitHub releases feed once per launch and every 24h, finds the newest +// `desktop-v` release, and semver-compares it to the running version. +// +// It NEVER downloads or installs. The desktop builds are unsigned, so an in-app +// auto-update can't run yet — that arrives with Developer ID signing. Offline or +// any error is a silent no-op that retries on the next cycle. +// +// Privacy: this is a plain, unauthenticated GitHub read that carries no +// identifiers. We deliberately send NO app-identifying headers — only the +// runtime's default User-Agent (Node/Electron's "node") goes out, and no auth +// token — so the request reveals nothing about the user or install. GitHub only +// requires *some* User-Agent, which the default satisfies. See fetchReleases. + +const RELEASES_URL = 'https://api.github.com/repos/getagentseal/codeburn/releases?per_page=15' +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 +const FETCH_TIMEOUT_MS = 15_000 +// Desktop releases are tagged `desktop-v..` (app/DISTRIBUTION.md). +const DESKTOP_TAG_RE = /^desktop-v(\d+\.\d+\.\d+)$/ + +export type UpdateStatus = { + currentVersion: string + /** Newest published desktop version, or null when unknown (offline / no match). */ + latestVersion: string | null + updateAvailable: boolean + /** The release tag to link when an update is available (else null). */ + tag: string | null +} + +type GitHubRelease = { tag_name?: string } + +function baselineStatus(currentVersion: string): UpdateStatus { + return { currentVersion, latestVersion: null, updateAvailable: false, tag: null } +} + +/** Numeric compare of two `major.minor.patch` strings: -1 / 0 / 1. Missing or + * non-numeric parts count as 0. */ +export function compareSemver(a: string, b: string): number { + const pa = a.split('.') + const pb = b.split('.') + for (let i = 0; i < 3; i++) { + const x = Number(pa[i] ?? 0) || 0 + const y = Number(pb[i] ?? 0) || 0 + if (x !== y) return x < y ? -1 : 1 + } + return 0 +} + +/** The newest `desktop-v` release among the feed, or null if none match. + * Picks by semver rather than trusting feed order. */ +export function pickLatestDesktopVersion(releases: GitHubRelease[]): { version: string; tag: string } | null { + let best: { version: string; tag: string } | null = null + for (const release of releases) { + const tag = typeof release?.tag_name === 'string' ? release.tag_name : '' + const match = DESKTOP_TAG_RE.exec(tag) + if (!match) continue + const version = match[1]! + if (!best || compareSemver(version, best.version) > 0) best = { version, tag } + } + return best +} + +/** Fetch + parse the releases feed. No auth, no app-identifying headers (see the + * file header). Aborts after 15s. Throws on a non-2xx response. */ +export async function fetchReleases(signal: AbortSignal, fetchImpl: typeof fetch = globalThis.fetch): Promise { + const response = await fetchImpl(RELEASES_URL, { signal }) + if (!response.ok) throw new Error(`GitHub HTTP ${response.status}`) + const data = await response.json() + return Array.isArray(data) ? (data as GitHubRelease[]) : [] +} + +export type UpdateChecker = { + /** Cached status, refreshed only when older than the 24h interval. */ + getStatus(): Promise + /** Force a fresh check now (launch + the 24h timer call this). */ + check(): Promise +} + +export function createUpdateChecker(opts: { + currentVersion: string + /** Injected in tests; defaults to the real GitHub read. */ + fetchReleasesImpl?: (signal: AbortSignal) => Promise + now?: () => number + intervalMs?: number +}): UpdateChecker { + const now = opts.now ?? (() => Date.now()) + const intervalMs = opts.intervalMs ?? CHECK_INTERVAL_MS + const fetchReleasesImpl = opts.fetchReleasesImpl ?? ((signal: AbortSignal) => fetchReleases(signal)) + + let cached = baselineStatus(opts.currentVersion) + let lastCheckedAt = 0 + let inflight: Promise | null = null + + const check = (): Promise => { + if (inflight) return inflight + inflight = (async () => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + try { + const releases = await fetchReleasesImpl(controller.signal) + const latest = pickLatestDesktopVersion(releases) + lastCheckedAt = now() + if (!latest) { + cached = baselineStatus(opts.currentVersion) + } else { + const updateAvailable = compareSemver(latest.version, opts.currentVersion) > 0 + cached = { + currentVersion: opts.currentVersion, + latestVersion: latest.version, + updateAvailable, + tag: updateAvailable ? latest.tag : null, + } + } + } catch { + // Offline / GitHub error / timeout: silent no-op. Keep the last known + // status and leave lastCheckedAt so the next cycle retries. + } finally { + clearTimeout(timer) + inflight = null + } + return cached + })() + return inflight + } + + const getStatus = (): Promise => { + if (lastCheckedAt !== 0 && now() - lastCheckedAt < intervalMs) return Promise.resolve(cached) + return check() + } + + return { getStatus, check } +} diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 94b1feb4..23e5f4c6 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -8,6 +8,7 @@ import { Panel } from './components/Panel' import { Sidebar, type Section } from './components/Sidebar' import { Splash } from './components/Splash' import { ToastHost } from './components/ToastHost' +import { UpdateBanner } from './components/UpdateBanner' import { rangeLabel, TopBar } from './components/TopBar' import { Window } from './components/Window' import { clearPolledMemo, hasPolledMemo, primePolledMemo, setPolledMemoMax, usePolled } from './hooks/usePolled' @@ -407,6 +408,7 @@ function AppMain() { {onboardingStatus && }
)} -
+
Display
{plans.data ? ({ value: code, label: code }))} onChange={value => void codeburn.setCurrency(value).then(finishCurrency)} width={92} /> : plans.error ? : Loading…} @@ -197,6 +206,10 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
{ const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}
{budgetError &&

{budgetError}

}
+
+
About
+
Version {version}{updateNote && {updateNote}}{update?.updateAvailable && update.tag ? : null}
+
) diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index fa8ddb29..3f1068fa 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -305,6 +305,9 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); } .budget-banner.exceeded { color: var(--bad); border-left-color: var(--bad); } .budget-banner > span { flex: 1; } .budget-banner .set-text-button { color: var(--mut); } +.update-banner { display: flex; align-items: center; gap: 10px; padding: 6px 16px; font-size: 12px; font-weight: 550; color: var(--ok); border-left: 3px solid var(--ok); border-bottom: 1px solid var(--line); line-height: 1.4; } +.update-banner > span { flex: 1; } +.update-banner > .set-text-button { color: var(--mut); } /* Settings */ .body.set-body { align-self: stretch; align-items: stretch; justify-content: flex-start; width: auto; height: 100%; min-height: 0; max-width: none; margin: 0; padding: 0; overflow: hidden; flex-direction: row; gap: 0; }