From 932a13d4c1c3a1c99fbb8afca4b472bc6b3c2afd Mon Sep 17 00:00:00 2001 From: reviewer Date: Mon, 20 Jul 2026 19:02:03 +0200 Subject: [PATCH] app: Download button direct-downloads the update asset The update Download button opened the GitHub release page; it now downloads the right artifact for the running platform directly: arm64 or x64 dmg on macOS (preload newly exposes process.arch), the Setup exe on Windows. Linux keeps the release page since it ships three formats and the user picks. Unknown platforms and preloads without arch fall back to the page. URL mapping is unit-tested per platform. --- app/electron/preload.ts | 1 + app/renderer/components/AboutModal.tsx | 4 +- app/renderer/components/UpdateBanner.tsx | 4 +- app/renderer/hooks/useUpdateStatus.test.ts | 44 ++++++++++++++++++++++ app/renderer/hooks/useUpdateStatus.ts | 31 ++++++++++++++- app/renderer/lib/types.ts | 3 ++ app/renderer/sections/Settings.tsx | 4 +- 7 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 app/renderer/hooks/useUpdateStatus.test.ts diff --git a/app/electron/preload.ts b/app/electron/preload.ts index c0dc464..39426eb 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -69,6 +69,7 @@ const bridge = { return () => { ipcRenderer.removeListener('codeburn:update', listener) } }, platform: process.platform, + arch: process.arch, } contextBridge.exposeInMainWorld('codeburn', bridge) diff --git a/app/renderer/components/AboutModal.tsx b/app/renderer/components/AboutModal.tsx index 174cf87..d1f7563 100644 --- a/app/renderer/components/AboutModal.tsx +++ b/app/renderer/components/AboutModal.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, type MouseEvent, type ReactNode } from 'react' import { version } from '../../package.json' import { FlameMark } from './FlameMark' import { BUILD_STAMP } from '../lib/build' -import { releasePageUrl, useUpdateStatus } from '../hooks/useUpdateStatus' +import { updateDownloadUrl, useUpdateStatus } from '../hooks/useUpdateStatus' import { codeburn } from '../lib/ipc' export type SocialLink = { @@ -81,7 +81,7 @@ export function AboutModal({ socials, onClose }: { socials: SocialLink[]; onClos diff --git a/app/renderer/components/UpdateBanner.tsx b/app/renderer/components/UpdateBanner.tsx index 0ac4d32..81683c0 100644 --- a/app/renderer/components/UpdateBanner.tsx +++ b/app/renderer/components/UpdateBanner.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' -import { releasePageUrl, useUpdateStatus } from '../hooks/useUpdateStatus' +import { updateDownloadUrl, useUpdateStatus } from '../hooks/useUpdateStatus' import { codeburn } from '../lib/ipc' const DISMISS_KEY = 'codeburn.updateDismissed' @@ -32,7 +32,7 @@ export function UpdateBanner() {
Update available: CodeBurn {status.latestVersion} ·{' '} - +
diff --git a/app/renderer/hooks/useUpdateStatus.test.ts b/app/renderer/hooks/useUpdateStatus.test.ts new file mode 100644 index 0000000..c20d426 --- /dev/null +++ b/app/renderer/hooks/useUpdateStatus.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../lib/ipc', () => ({ + codeburn: { platform: 'linux', arch: 'x64' }, +})) + +import { directDownloadUrl, releasePageUrl, updateDownloadUrl } from './useUpdateStatus' + +const TAG = 'desktop-v0.9.19' +const BASE = 'https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.19' + +describe('directDownloadUrl', () => { + it('maps macOS arm64 to the arm64 dmg', () => { + expect(directDownloadUrl(TAG, 'darwin', 'arm64')).toBe(`${BASE}/CodeBurn-0.9.19-arm64.dmg`) + }) + + it('maps macOS x64 to the plain dmg', () => { + expect(directDownloadUrl(TAG, 'darwin', 'x64')).toBe(`${BASE}/CodeBurn-0.9.19.dmg`) + }) + + it('maps Windows to the Setup exe regardless of arch', () => { + expect(directDownloadUrl(TAG, 'win32', 'x64')).toBe(`${BASE}/CodeBurn-Setup-0.9.19.exe`) + expect(directDownloadUrl(TAG, 'win32', undefined)).toBe(`${BASE}/CodeBurn-Setup-0.9.19.exe`) + }) + + it('returns null for Linux (three formats, the user picks on the page)', () => { + expect(directDownloadUrl(TAG, 'linux', 'x64')).toBeNull() + }) + + it('returns null for unknown platforms, missing mac arch, and foreign tags', () => { + expect(directDownloadUrl(TAG, 'freebsd', 'x64')).toBeNull() + expect(directDownloadUrl(TAG, 'darwin', undefined)).toBeNull() + expect(directDownloadUrl('mac-v0.9.19', 'darwin', 'arm64')).toBeNull() + }) +}) + +describe('updateDownloadUrl fallback', () => { + it('falls back to the release page when no direct asset fits', () => { + // The mocked bridge reports linux, where no single asset fits, so the + // click target is the release page. + expect(releasePageUrl(TAG)).toBe('https://github.com/getagentseal/codeburn/releases/tag/desktop-v0.9.19') + expect(updateDownloadUrl(TAG)).toBe(releasePageUrl(TAG)) + }) +}) diff --git a/app/renderer/hooks/useUpdateStatus.ts b/app/renderer/hooks/useUpdateStatus.ts index 414c0e3..dd90c9d 100644 --- a/app/renderer/hooks/useUpdateStatus.ts +++ b/app/renderer/hooks/useUpdateStatus.ts @@ -23,8 +23,35 @@ export function useUpdateStatus(): UpdateStatus | null { return status } -/** GitHub release page for a desktop tag — the Download target (no site #get - * anchor exists). https-only, so it passes the openExternal allowlist. */ +/** GitHub release page for a desktop tag — the fallback Download target when + * no single direct asset fits (Linux ships three formats) or the platform is + * unknown. https-only, so it passes the openExternal allowlist. */ export function releasePageUrl(tag: string): string { return `https://github.com/getagentseal/codeburn/releases/tag/${tag}` } + +/** + * Direct asset download for the running platform, so Download saves the file + * instead of landing on a GitHub page. Filenames mirror the electron-builder + * output (app/DISTRIBUTION.md) and are version-derived from the tag. Returns + * null (callers fall back to the release page) for Linux — three formats, the + * user picks — and for unknown platforms or a preload without `arch`. + */ +export function directDownloadUrl(tag: string, platform: string | undefined, arch: string | undefined): string | null { + const version = tag.startsWith('desktop-v') ? tag.slice('desktop-v'.length) : null + if (!version) return null + let file: string | null = null + if (platform === 'darwin') { + if (!arch) return null + file = arch === 'arm64' ? `CodeBurn-${version}-arm64.dmg` : `CodeBurn-${version}.dmg` + } else if (platform === 'win32') { + file = `CodeBurn-Setup-${version}.exe` + } + if (!file) return null + return `https://github.com/getagentseal/codeburn/releases/download/${tag}/${file}` +} + +/** The Download click target: direct asset when determinable, else the page. */ +export function updateDownloadUrl(tag: string): string { + return directDownloadUrl(tag, codeburn.platform, codeburn.arch) ?? releasePageUrl(tag) +} diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 94d766d..9a87f25 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -592,6 +592,9 @@ export interface CodeburnBridge { getPlans(period: Period): Promise getActReport(): Promise readonly platform: string + /** Node process.arch of the host ('arm64', 'x64', ...). Absent on preloads + * that predate the direct-download update link. */ + readonly arch?: string getModels(period: Period, provider: string, byTask: boolean, range?: DateRange): Promise getSessions(period: Period, provider: string, range?: DateRange): Promise getCompareModels(period: Period, provider: string): Promise diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx index f1b2110..17666f2 100644 --- a/app/renderer/sections/Settings.tsx +++ b/app/renderer/sections/Settings.tsx @@ -8,7 +8,7 @@ import { Panel } from '../components/Panel' import { ProviderLogo } from '../components/ProviderLogo' import type { Section } from '../components/Sidebar' import { usePolled } from '../hooks/usePolled' -import { releasePageUrl, useUpdateStatus } from '../hooks/useUpdateStatus' +import { updateDownloadUrl, useUpdateStatus } from '../hooks/useUpdateStatus' import { version as appVersion } from '../../package.json' import { readDailyBudget } from '../lib/budget' import { formatConverted, formatUsd } from '../lib/format' @@ -208,7 +208,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
About
-
Version {version}{updateNote && {updateNote}}{update?.updateAvailable && update.tag ? : null}
+
Version {version}{updateNote && {updateNote}}{update?.updateAvailable && update.tag ? : null}