codeburn/app/electron/updates.ts
iamtoruk 8fc9e303b9 feat(app): update-available notifications
Checks the GitHub releases for newer desktop-v tags once per launch
and every 24h (15s timeout, no identifying headers, offline = silent
retry). A dismissible banner and a Settings About row surface the
newer version and link the release page; dismissal persists per
version so each release nags at most once. No auto-install: unsigned
builds cannot, by platform constraint; the checker only notifies.

App 364/364 (+24), typechecks + build green.
2026-07-17 04:58:13 -07:00

133 lines
5.3 KiB
TypeScript

// "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 }
}