feat(desktop): manual-download update check and OSS release manifest

Adds an unsigned, manual-download update flow. The main process fetches the OSS
updates/releases.json manifest (DSH_UPDATE_URL overrides), compares the latest
version, and on startup and hourly prompts to open the per-platform installer
URL; a preload bridge exposes the same check to the SPA's About "check for
updates" button, which renders the version, notes, and a download link.
scripts/generate-release-json.mjs builds the manifest from the packaged
.dmg/.exe. Windows nsis packaging is configured.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-14 20:45:21 +08:00
parent e18cfbfb93
commit e87a3084ea
9 changed files with 290 additions and 16 deletions
@@ -70,3 +70,7 @@
margin: 0;
opacity: 0.75;
}
.download {
align-self: flex-start;
}
@@ -1,9 +1,9 @@
/**
* The About section: PineSound company introduction and a software-update check.
*
* The update check is a client-side placeholder: it compares the build version
* against a fixed known-latest constant and reports up-to-date, pending a real
* update channel. The two constants are product metadata, not tunables.
* The "check for updates" button asks the desktop main process (via the preload
* bridge) for the latest release and shows a download link when one is newer;
* on a plain-web surface without the bridge it reports up-to-date.
*/
import { useState } from 'react'
@@ -13,10 +13,23 @@ import css from './AboutSection.module.css'
/** This build's version, mirrored from apps/desktop/package.json. */
const APP_VERSION = '0.1.0-rc.5'
/** Latest version known to this build; the update check compares against it. */
const LATEST_VERSION = APP_VERSION
/** The update-check bridge the desktop preload injects (absent on plain web). */
interface DshAppBridge {
checkUpdate: () => Promise<{
readonly status: 'up-to-date' | 'update-available' | 'error'
readonly current: string
readonly latest?: string | undefined
readonly notes?: string | undefined
readonly url?: string | undefined
}>
}
type UpdateStatus = 'idle' | 'checking' | 'upToDate'
type UpdateState =
| { readonly status: 'idle' }
| { readonly status: 'checking' }
| { readonly status: 'up-to-date' }
| { readonly status: 'available'; readonly latest: string; readonly notes?: string | undefined; readonly url?: string | undefined }
| { readonly status: 'error'; readonly detail?: string | undefined }
/** Full component props for the About section. */
export type AboutSectionComponentProps =
@@ -28,12 +41,29 @@ export type AboutSectionComponentProps =
* @returns the About section element tree.
*/
export function AboutSection({ t }: AboutSectionComponentProps) {
const [status, setStatus] = useState<UpdateStatus>('idle')
const checking = status === 'checking'
const [state, setState] = useState<UpdateState>({ status: 'idle' })
const checking = state.status === 'checking'
const check = (): void => {
if (checking) return
setStatus('checking')
window.setTimeout(() => { setStatus('upToDate') }, 600)
const bridge = (window as { dshApp?: DshAppBridge }).dshApp
if (bridge === undefined) {
// No desktop bridge (plain web): nothing newer is known here.
setState({ status: 'up-to-date' })
return
}
setState({ status: 'checking' })
void bridge.checkUpdate().then(
(result) => {
if (result.status === 'update-available') {
setState({ status: 'available', latest: result.latest ?? '', notes: result.notes, url: result.url })
} else if (result.status === 'up-to-date') {
setState({ status: 'up-to-date' })
} else {
setState({ status: 'error', detail: result.notes })
}
},
() => { setState({ status: 'error' }) },
)
}
return (
<div className={css.section}>
@@ -57,8 +87,22 @@ export function AboutSection({ t }: AboutSectionComponentProps) {
>
{checking ? t('about.checking') : t('about.checkUpdates')}
</button>
{status === 'upToDate' && !checking
? <p className={css.status} role="status">{t('about.upToDate')} · v{LATEST_VERSION}</p>
{state.status === 'up-to-date' && !checking
? <p className={css.status} role="status">{t('about.upToDate')} · v{APP_VERSION}</p>
: null}
{state.status === 'available'
? (
<p className={css.status} role="status">
{t('about.updateAvailable')} · v{state.latest}
{state.notes === undefined || state.notes.length === 0 ? '' : ` — ${state.notes}`}
</p>
)
: null}
{state.status === 'available' && state.url !== undefined
? <a className={css.download} href={state.url} target="_blank" rel="noreferrer">{t('about.download')}</a>
: null}
{state.status === 'error'
? <p className={css.status} role="status">{t('about.updateCheckFailed')}{state.detail === undefined || state.detail.length === 0 ? '' : `: ${state.detail}`}</p>
: null}
</div>
</div>
@@ -17,6 +17,9 @@ export const zh = {
'about.checkUpdates': '检查更新',
'about.checking': '正在检查更新…',
'about.upToDate': '已是最新版本',
'about.updateAvailable': '发现新版本',
'about.download': '去下载',
'about.updateCheckFailed': '检查更新失败',
} satisfies Record<string, string>
/** The settings namespace key union. */
@@ -39,4 +42,7 @@ export const en = {
'about.checkUpdates': 'Check for updates',
'about.checking': 'Checking for updates…',
'about.upToDate': 'You are on the latest version',
'about.updateAvailable': 'A new version is available',
'about.download': 'Download',
'about.updateCheckFailed': 'Could not check for updates',
} satisfies Record<SettingsKey, string>
@@ -72,14 +72,31 @@ describe('AboutSection', () => {
expect(screen.getByRole('button', { name: en['about.checkUpdates'] })).toBeTruthy()
})
it('checks for updates and reports the latest version', async () => {
it('reports up-to-date on a web surface without the desktop bridge', async () => {
render(<AboutSection {...aboutProps} />)
fireEvent.click(screen.getByRole('button', { name: en['about.checkUpdates'] }))
expect(screen.getByText(en['about.checking'])).toBeTruthy()
await waitFor(() => {
expect(screen.getByRole('status').textContent).toContain(en['about.upToDate'])
})
})
it('shows a download link when the bridge reports a newer version', async () => {
const checkUpdate = vi.fn(async () => ({
status: 'update-available' as const, current: '0.1.0-rc.5', latest: '0.1.0-rc.6',
notes: 'New release', url: 'https://example.com/app.dmg',
}))
;(window as { dshApp?: unknown }).dshApp = { checkUpdate }
try {
render(<AboutSection {...aboutProps} />)
fireEvent.click(screen.getByRole('button', { name: en['about.checkUpdates'] }))
const status = await screen.findByRole('status')
expect(status.textContent).toContain(en['about.updateAvailable'])
expect(status.textContent).toContain('0.1.0-rc.6')
expect(screen.getByRole('link', { name: en['about.download'] }).getAttribute('href')).toBe('https://example.com/app.dmg')
} finally {
delete (window as { dshApp?: unknown }).dshApp
}
})
})
describe('SettingsDocumentAction', () => {