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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user