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
1 change: 1 addition & 0 deletions app/electron/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }> = [
Expand Down
28 changes: 27 additions & 1 deletion app/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Telemetry, 'status' | 'setEnabled' | 'completeOnboarding' | 'track'>
Expand All @@ -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 {
Expand All @@ -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] : []
}
Expand Down Expand Up @@ -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<UpdateStatus>
}

type Handler = (...args: any[]) => Promise<Envelope>
Expand All @@ -145,7 +160,7 @@ type Handler = (...args: any[]) => Promise<Envelope>
* 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<string, Handler> {
export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota, emitProgress: broadcastProgress, telemetry: telemetryInstance, getUpdateStatus: () => updateChecker ? updateChecker.getStatus() : Promise.resolve(NO_UPDATE_STATUS) }): Record<string, Handler> {
const emitProgress = deps.emitProgress ?? (() => {})
const telemetry = deps.telemetry ?? null
// Flips true after the first overview fetch succeeds. Until then, every
Expand Down Expand Up @@ -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 }),
}
}

Expand Down Expand Up @@ -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', () => {
Expand Down
7 changes: 7 additions & 0 deletions app/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
129 changes: 129 additions & 0 deletions app/electron/updates.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
133 changes: 133 additions & 0 deletions app/electron/updates.ts
Original file line number Diff line number Diff line change
@@ -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<semver>` 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<major>.<minor>.<patch>` (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<semver>` 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<GitHubRelease[]> {
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<UpdateStatus>
/** Force a fresh check now (launch + the 24h timer call this). */
check(): Promise<UpdateStatus>
}

export function createUpdateChecker(opts: {
currentVersion: string
/** Injected in tests; defaults to the real GitHub read. */
fetchReleasesImpl?: (signal: AbortSignal) => Promise<GitHubRelease[]>
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<UpdateStatus> | null = null

const check = (): Promise<UpdateStatus> => {
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<UpdateStatus> => {
if (lastCheckedAt !== 0 && now() - lastCheckedAt < intervalMs) return Promise.resolve(cached)
return check()
}

return { getStatus, check }
}
2 changes: 2 additions & 0 deletions app/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -407,6 +408,7 @@ function AppMain() {
{onboardingStatus && <Onboarding defaultEnabled={onboardingStatus.defaultEnabled} onDone={finishOnboarding} />}
<div className="ct">
<div className={overview.switching ? 'switch-line on' : 'switch-line'} aria-hidden="true" />
<UpdateBanner />
<DailyBudgetBanner payload={overview.data ?? null} provider={provider} />
<ErrorBoundary key={section}>
{section === 'plans' ? (
Expand Down
Loading