feat(desktop): dynamic About version, update manifest date, and a unified publish flow

The About section hardcoded its version; the main process now exposes
get-app-info so it reads app.getVersion() dynamically, and the update result
carries the manifest release date. The manifest generator orders versions
numerically (0.10.0 outranks 0.9.0). A new stage-release script copies
releases.json plus installers into the deepseek-harness-web site root's
updates/, which deploy.py publishes to OSS alongside the download page, so the
app and web share one update channel. The About section also gains links to the
release site and official site.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-14 23:01:36 +08:00
parent 768bcd4356
commit aaa434afbd
10 changed files with 292 additions and 34 deletions
+85 -13
View File
@@ -122,28 +122,100 @@ The bundled harness is multi-GB uncompressed (≈2 GB), dominated by
~1 GB. This is the inherent footprint of shipping the full harness runtime ~1 GB. This is the inherent footprint of shipping the full harness runtime
standalone, and is the accepted trade-off for a zero-external-dependency app. standalone, and is the accepted trade-off for a zero-external-dependency app.
## Updates (manual download) ## Release process
The app is **not code-signed**, so updates are a manual-download flow rather The app is **not code-signed**, so updates are a manual-download flow rather
than a silent swap. On startup (and hourly), and from the Settings → About than a silent swap. On startup (and hourly), and from the Settings → About
"check for updates" button (through a preload bridge), the main process fetches "check for updates" button (through a preload bridge), the main process fetches
the OSS manifest (`updates/releases.json`, `DSH_UPDATE_URL` overrides) and the manifest (`updates/releases.json`, `DSH_UPDATE_URL` overrides) and compares
compares the latest version against `app.getVersion()`. When a newer release the latest version against `app.getVersion()`. When a newer release exists it
exists it prompts the user to open the per-platform installer URL. prompts the user to open the per-platform installer URL. The web `download/`
page and the desktop update check read the same manifest, so **one deploy
publishes both the site and the update channel**.
To publish a release: ### 1. Bump the app version
The release version lives in **`apps/desktop/package.json` → `version`**. It
feeds three things: `app.getVersion()` (shown dynamically by the About section
and compared against the manifest), electron-builder's installer filenames
(`DeepSeek Harness-<version>-arm64.dmg`), and — derived from those filenames —
the manifest's `latest`. So bump this one field to the new version:
```sh ```sh
pnpm --filter @deepseek-ai/dsh-desktop run build:harness # e.g. 0.1.0-rc.5 → 0.1.0-rc.6
pnpm desktop:pack # produces .dmg (mac) / .nsis .exe (win)
DSH_RELEASE_NOTES="…" pnpm --filter @deepseek-ai/dsh-desktop run generate-release-json
``` ```
`scripts/generate-release-json.mjs` writes `dist/releases.json` from the built The scheme is `0.1.0-rc.N`; `generate-release-json` orders versions
installers (`DSH_UPDATE_BASE` defaults to `https://deepseek.pinesound.cn/updates/`). numerically, so `rc.6` correctly outranks `rc.5` (and `0.10.0` outranks
Upload `releases.json` plus the `.dmg`/`.exe` to the OSS `updates/` folder, and `0.9.0`). The root `package.json` version is the monorepo workspace version —
the `deepseek-harness-web` `download/` page renders the same manifest. Building bump it too if you keep them in sync, but only the desktop one is user-visible.
the Windows `.exe` on macOS needs wine (or build on a Windows host).
### 2. Build and package
```sh
pnpm --filter @deepseek-ai/dsh-desktop run build:harness # assemble the self-contained runtime
pnpm desktop:pack # electron-builder: .dmg (mac) / .nsis .exe (win)
```
Artifacts land in `apps/desktop/dist/`. Each target OS/arch needs its own
harness (`build/harness` bundles a platform Node + native addons), so regenerate
it on the target platform before packing that platform. Building the Windows
`.exe` on macOS needs wine (or build on a Windows host).
### 3. Generate the update manifest
```sh
DSH_RELEASE_NOTES="…本次更新的说明…" pnpm --filter @deepseek-ai/dsh-desktop run generate-release-json
```
`scripts/generate-release-json.mjs` scans `dist/` for installers and writes
`dist/releases.json` (single-latest structure): `latest.version`, `latest.date`,
`releaseNotes`, and per-platform URLs rooted at `DSH_UPDATE_BASE` (default
`https://deepseek.pinesound.cn/updates/`).
### 4. Stage into the web site (the upload location)
```sh
pnpm --filter @deepseek-ai/dsh-desktop run stage-release # --dry-run to preview
```
`scripts/stage-release.mjs` copies `dist/releases.json` and the installers
(`.dmg`/`.exe`) into **`deepseek-harness-web/updates/`** — the site root's
`updates/` folder — and prunes stale installers left from older releases. This
is the single upload location: everything the update check and download page
need lives under `updates/`.
### 5. Deploy the site
```sh
cd ../deepseek-harness-web && python3 deploy.py
```
`deploy.py` uploads the whole `deepseek-harness-web/` repo root (including
`download/` and `updates/`) to the OSS bucket root, served at
`https://deepseek.pinesound.cn/`. Because the desktop default
`DSH_UPDATE_URL` is `https://deepseek.pinesound.cn/updates/releases.json` and
the download page fetches `/updates/releases.json` (same origin), no further
configuration is needed. `deploy.py` wipes the bucket then uploads, so the repo
root is the source of truth.
### 6. The web pages
The release is installable from the web through the pages under
`deepseek-harness-web/`:
- **`download/index.html`** — the download page, **required** for a web
release. Reads `updates/releases.json` and renders the latest version, date,
release notes, and per-platform install buttons. Always include this in any
release.
- **`index.html`** — the site homepage (link to `download/`).
- **`privacy/`, `data-processing/`** — supporting pages (already part of the
site).
`deploy.py` serves every file under the repo root automatically (site root =
bucket root), so a new page is published by just adding it at the root and
re-deploying. If you add a page, link it from the homepage or the download page
so it is discoverable.
## Notes ## Notes
+1
View File
@@ -15,6 +15,7 @@
"build": "tsc -b tsconfig.json", "build": "tsc -b tsconfig.json",
"build:harness": "node scripts/build-harness.mjs", "build:harness": "node scripts/build-harness.mjs",
"generate-release-json": "node scripts/generate-release-json.mjs", "generate-release-json": "node scripts/generate-release-json.mjs",
"stage-release": "node scripts/stage-release.mjs",
"typecheck": "tsc -b tsconfig.json", "typecheck": "tsc -b tsconfig.json",
"test": "vitest run", "test": "vitest run",
"pack": "electron-builder" "pack": "electron-builder"
+18 -1
View File
@@ -26,6 +26,23 @@ function versionOf(filename) {
return match === null ? undefined : match[1] return match === null ? undefined : match[1]
} }
/** Order two versions: numeric segments first, then the pre-release suffix. */
function compareVersions(a, b) {
const num = (v) => v.split(/[-+]/)[0].split('.').map((n) => Number(n))
const pre = (v) => { const i = v.indexOf('-'); return i === -1 ? '' : v.slice(i + 1) }
const an = num(a), bn = num(b)
for (let i = 0; i < 3; i++) {
if (an[i] !== bn[i]) return an[i] - bn[i]
}
// Equal numeric core: a release with a pre-release suffix is OLDER than a
// stable one (e.g. `1.0.0-rc.1` < `1.0.0`); two suffixes compare by string.
const ap = pre(a), bp = pre(b)
if (ap === bp) return 0
if (ap === '') return 1
if (bp === '') return -1
return ap < bp ? -1 : 1
}
const platforms = {} const platforms = {}
let latestVersion let latestVersion
let latestDate = process.env.DSH_RELEASE_DATE ?? new Date().toISOString().slice(0, 10) let latestDate = process.env.DSH_RELEASE_DATE ?? new Date().toISOString().slice(0, 10)
@@ -38,7 +55,7 @@ for (const entry of readdirSync(dist)) {
? entry.includes('arm64') ? 'mac-arm64' : 'mac-x64' ? entry.includes('arm64') ? 'mac-arm64' : 'mac-x64'
: 'win-x64' : 'win-x64'
platforms[key] = { url: `${base}${encodeURIComponent(entry)}` } platforms[key] = { url: `${base}${encodeURIComponent(entry)}` }
if (latestVersion === undefined || version > latestVersion) { if (latestVersion === undefined || compareVersions(version, latestVersion) > 0) {
latestVersion = version latestVersion = version
latestDate = process.env.DSH_RELEASE_DATE ?? new Date().toISOString().slice(0, 10) latestDate = process.env.DSH_RELEASE_DATE ?? new Date().toISOString().slice(0, 10)
} }
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
/**
* Stage a built release into the `deepseek-harness-web` site so it can be
* published in one deploy. Copies `apps/desktop/dist`'s `releases.json` and the
* platform installers (.dmg/.exe) into `../deepseek-harness-web/updates/` — the
* site root's `updates/` folder (deploy.py uploads the repo root to the bucket
* root) that both the web download page and the desktop update check
* (`DSH_UPDATE_URL`) read from.
*
* Run after `desktop:pack` + `generate-release-json`, then deploy the site:
* cd ../deepseek-harness-web && python3 deploy.py
*
* Flags:
* --dry-run print what would be copied without touching the filesystem.
* --site <path> override the deepseek-harness-web repo path (default: sibling dir).
*/
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const dist = join(root, 'apps/desktop/dist')
const args = process.argv.slice(2)
const dryRun = args.includes('--dry-run')
const siteFlag = args.find((a) => a.startsWith('--site='))
const siteRoot = siteFlag === undefined
? resolve(root, '../deepseek-harness-web')
: resolve(root, siteFlag.slice('--site='.length))
/** Names the site root's update channel lives at (`updates/`). */
const updatesDir = join(siteRoot, 'updates')
/** Platform installer and manifest files staged to the site. */
const artifactNames = () => {
if (!existsSync(dist)) return []
return readdirSync(dist).filter((f) => f === 'releases.json' || f.endsWith('.dmg') || f.endsWith('.exe'))
}
const artifacts = artifactNames()
if (artifacts.length === 0) {
console.error(`stage-release: no releases.json or installers found in ${dist}`)
console.error('Run `desktop:pack` and `generate-release-json` first.')
process.exit(1)
}
console.log(`staging ${artifacts.length} artifact(s) from ${dist}`)
console.log(` → ${updatesDir}${dryRun ? ' (dry run)' : ''}`)
for (const name of artifacts) {
console.log(` ${dryRun ? 'would copy' : 'copying'} ${name}`)
if (dryRun) continue
mkdirSync(updatesDir, { recursive: true })
cpSync(join(dist, name), join(updatesDir, name))
}
// A stale installer left from a previous release should not linger on the site.
const stale = existsSync(updatesDir)
? readdirSync(updatesDir).filter((name) => !artifacts.includes(name) && (name.endsWith('.dmg') || name.endsWith('.exe')))
: []
for (const name of stale) {
console.log(` ${dryRun ? 'would remove stale' : 'removing stale'} ${name}`)
if (!dryRun) rmSync(join(updatesDir, name), { force: true })
}
if (dryRun) console.log('(dry run: no files were written)')
console.log('done. Deploy with `cd ../deepseek-harness-web && python3 deploy.py`.')
+12 -1
View File
@@ -34,9 +34,12 @@ const UPDATE_MANIFEST_URL = process.env.DSH_UPDATE_URL ?? 'https://deepseek.pine
/** The renderer's update-check IPC channel. */ /** The renderer's update-check IPC channel. */
const UPDATE_CHANNEL = 'check-update' const UPDATE_CHANNEL = 'check-update'
/** The renderer's static build-identity IPC channel. */
const APP_INFO_CHANNEL = 'get-app-info'
/** The release manifest published on OSS (`updates/releases.json`). */ /** The release manifest published on OSS (`updates/releases.json`). */
interface ReleaseManifest { interface ReleaseManifest {
readonly latest: { readonly version: string } readonly latest: { readonly version: string; readonly date?: string }
readonly releaseNotes?: string readonly releaseNotes?: string
readonly platforms: Partial<Record<'mac-arm64' | 'mac-x64' | 'win-x64', { readonly url: string }>> readonly platforms: Partial<Record<'mac-arm64' | 'mac-x64' | 'win-x64', { readonly url: string }>>
} }
@@ -180,6 +183,7 @@ async function checkForUpdate(): Promise<UpdateCheckResult> {
status: 'update-available', status: 'update-available',
current, current,
latest, latest,
date: manifest.latest.date,
notes: manifest.releaseNotes, notes: manifest.releaseNotes,
url: platformDownloadUrl(manifest), url: platformDownloadUrl(manifest),
} }
@@ -290,6 +294,13 @@ app.whenReady().then(() => {
installAppMenu() installAppMenu()
// The SPA's About "check for updates" button asks the main process. // The SPA's About "check for updates" button asks the main process.
ipcMain.handle(UPDATE_CHANNEL, () => checkForUpdate()) ipcMain.handle(UPDATE_CHANNEL, () => checkForUpdate())
// The SPA's About reads this build's version from the main process (no network).
ipcMain.handle(APP_INFO_CHANNEL, () => ({
version: app.getVersion(),
platform: process.platform,
arch: process.arch,
productName: PRODUCT_NAME,
}))
startSession() startSession()
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0 && session !== undefined) { if (BrowserWindow.getAllWindows().length === 0 && session !== undefined) {
+16
View File
@@ -14,11 +14,27 @@ export interface UpdateCheckResult {
readonly status: 'up-to-date' | 'update-available' | 'error' readonly status: 'up-to-date' | 'update-available' | 'error'
readonly current: string readonly current: string
readonly latest?: string | undefined readonly latest?: string | undefined
/** Release date of the newest release (YYYY-MM-DD), when the manifest supplies it. */
readonly date?: string | undefined
readonly notes?: string | undefined readonly notes?: string | undefined
readonly url?: string | undefined readonly url?: string | undefined
} }
/** Static identity of this build, read from the main process (no network). */
export interface AppInfo {
/** The packaged version (`app.getVersion()`). */
readonly version: string
/** Platform the app runs on (`process.platform`). */
readonly platform: NodeJS.Platform
/** CPU architecture (`process.arch`). */
readonly arch: string
/** User-facing product name. */
readonly productName: string
}
contextBridge.exposeInMainWorld('dshApp', { contextBridge.exposeInMainWorld('dshApp', {
/** Ask the main process for the latest release; resolves the check result. */ /** Ask the main process for the latest release; resolves the check result. */
checkUpdate: (): Promise<UpdateCheckResult> => ipcRenderer.invoke('check-update') as Promise<UpdateCheckResult>, checkUpdate: (): Promise<UpdateCheckResult> => ipcRenderer.invoke('check-update') as Promise<UpdateCheckResult>,
/** Ask the main process for this build's static identity (no network). */
getAppInfo: (): Promise<AppInfo> => ipcRenderer.invoke('get-app-info') as Promise<AppInfo>,
}) })
@@ -61,11 +61,6 @@
align-self: flex-start; align-self: flex-start;
} }
.check:disabled {
cursor: default;
opacity: 0.6;
}
.status { .status {
margin: 0; margin: 0;
opacity: 0.75; opacity: 0.75;
@@ -74,3 +69,36 @@
.download { .download {
align-self: flex-start; align-self: flex-start;
} }
.links {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.25rem;
}
/* External-site links styled as the design-system outline capsule button. */
.link {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 18px;
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 14px;
line-height: 22px;
text-decoration: none;
cursor: pointer;
}
.link:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.link:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
}
@@ -6,12 +6,18 @@
* on a plain-web surface without the bridge it reports up-to-date. * on a plain-web surface without the bridge it reports up-to-date.
*/ */
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import css from './AboutSection.module.css' import css from './AboutSection.module.css'
/** This build's version, mirrored from apps/desktop/package.json. */ /**
const APP_VERSION = '0.1.0-rc.5' * Plain-web fallback for the current version when the desktop bridge is absent:
* there is no packaged identity to report, so the version row shows this rather
* than a stale hardcoded value. On the desktop the real version comes from
* `getAppInfo` (`app.getVersion()`).
*/
const FALLBACK_VERSION = '—'
/** The update-check bridge the desktop preload injects (absent on plain web). */ /** The update-check bridge the desktop preload injects (absent on plain web). */
interface DshAppBridge { interface DshAppBridge {
@@ -19,16 +25,18 @@ interface DshAppBridge {
readonly status: 'up-to-date' | 'update-available' | 'error' readonly status: 'up-to-date' | 'update-available' | 'error'
readonly current: string readonly current: string
readonly latest?: string | undefined readonly latest?: string | undefined
readonly date?: string | undefined
readonly notes?: string | undefined readonly notes?: string | undefined
readonly url?: string | undefined readonly url?: string | undefined
}> }>
getAppInfo: () => Promise<{ readonly version: string }>
} }
type UpdateState = type UpdateState =
| { readonly status: 'idle' } | { readonly status: 'idle' }
| { readonly status: 'checking' } | { readonly status: 'checking' }
| { readonly status: 'up-to-date' } | { readonly status: 'up-to-date' }
| { readonly status: 'available'; readonly latest: string; readonly notes?: string | undefined; readonly url?: string | undefined } | { readonly status: 'available'; readonly latest: string; readonly date?: string | undefined; readonly notes?: string | undefined; readonly url?: string | undefined }
| { readonly status: 'error'; readonly detail?: string | undefined } | { readonly status: 'error'; readonly detail?: string | undefined }
/** Full component props for the About section. */ /** Full component props for the About section. */
@@ -42,7 +50,15 @@ export type AboutSectionComponentProps =
*/ */
export function AboutSection({ t }: AboutSectionComponentProps) { export function AboutSection({ t }: AboutSectionComponentProps) {
const [state, setState] = useState<UpdateState>({ status: 'idle' }) const [state, setState] = useState<UpdateState>({ status: 'idle' })
const [version, setVersion] = useState<string>(FALLBACK_VERSION)
const checking = state.status === 'checking' const checking = state.status === 'checking'
// Read this build's real version from the desktop main process; plain web has
// no packaged identity, so it keeps the fallback.
useEffect(() => {
const bridge = (window as { dshApp?: DshAppBridge }).dshApp
if (bridge === undefined) return
void bridge.getAppInfo().then((info) => { setVersion(info.version) }, () => {})
}, [])
const check = (): void => { const check = (): void => {
if (checking) return if (checking) return
const bridge = (window as { dshApp?: DshAppBridge }).dshApp const bridge = (window as { dshApp?: DshAppBridge }).dshApp
@@ -55,7 +71,7 @@ export function AboutSection({ t }: AboutSectionComponentProps) {
void bridge.checkUpdate().then( void bridge.checkUpdate().then(
(result) => { (result) => {
if (result.status === 'update-available') { if (result.status === 'update-available') {
setState({ status: 'available', latest: result.latest ?? '', notes: result.notes, url: result.url }) setState({ status: 'available', latest: result.latest ?? '', date: result.date, notes: result.notes, url: result.url })
} else if (result.status === 'up-to-date') { } else if (result.status === 'up-to-date') {
setState({ status: 'up-to-date' }) setState({ status: 'up-to-date' })
} else { } else {
@@ -76,24 +92,25 @@ export function AboutSection({ t }: AboutSectionComponentProps) {
<p className={css.productName}>{t('about.product')}</p> <p className={css.productName}>{t('about.product')}</p>
<dl className={css.version}> <dl className={css.version}>
<dt>{t('about.currentVersion')}</dt> <dt>{t('about.currentVersion')}</dt>
<dd data-version>{APP_VERSION}</dd> <dd data-version>{version}</dd>
</dl> </dl>
<button <Button
type="button"
className={css.check} className={css.check}
variant="primary"
disabled={checking} disabled={checking}
aria-busy={checking || undefined} aria-busy={checking || undefined}
onClick={check} onClick={check}
> >
{checking ? t('about.checking') : t('about.checkUpdates')} {checking ? t('about.checking') : t('about.checkUpdates')}
</button> </Button>
{state.status === 'up-to-date' && !checking {state.status === 'up-to-date' && !checking
? <p className={css.status} role="status">{t('about.upToDate')} · v{APP_VERSION}</p> ? <p className={css.status} role="status">{t('about.upToDate')} · v{version}</p>
: null} : null}
{state.status === 'available' {state.status === 'available'
? ( ? (
<p className={css.status} role="status"> <p className={css.status} role="status">
{t('about.updateAvailable')} · v{state.latest} {t('about.updateAvailable')} · v{state.latest}
{state.date === undefined || state.date.length === 0 ? '' : ` (${state.date})`}
{state.notes === undefined || state.notes.length === 0 ? '' : ` — ${state.notes}`} {state.notes === undefined || state.notes.length === 0 ? '' : ` — ${state.notes}`}
</p> </p>
) )
@@ -104,6 +121,10 @@ export function AboutSection({ t }: AboutSectionComponentProps) {
{state.status === 'error' {state.status === 'error'
? <p className={css.status} role="status">{t('about.updateCheckFailed')}{state.detail === undefined || state.detail.length === 0 ? '' : `: ${state.detail}`}</p> ? <p className={css.status} role="status">{t('about.updateCheckFailed')}{state.detail === undefined || state.detail.length === 0 ? '' : `: ${state.detail}`}</p>
: null} : null}
<div className={css.links}>
<a className={css.link} href="https://deepseek.pinesound.cn/" target="_blank" rel="noreferrer">{t('about.releaseSite')}</a>
<a className={css.link} href="https://www.deepseek.com/" target="_blank" rel="noreferrer">{t('about.officialSite')}</a>
</div>
</div> </div>
</div> </div>
) )
@@ -20,6 +20,8 @@ export const zh = {
'about.updateAvailable': '发现新版本', 'about.updateAvailable': '发现新版本',
'about.download': '去下载', 'about.download': '去下载',
'about.updateCheckFailed': '检查更新失败', 'about.updateCheckFailed': '检查更新失败',
'about.releaseSite': '发布站',
'about.officialSite': '官网',
} satisfies Record<string, string> } satisfies Record<string, string>
/** The settings namespace key union. */ /** The settings namespace key union. */
@@ -45,4 +47,6 @@ export const en = {
'about.updateAvailable': 'A new version is available', 'about.updateAvailable': 'A new version is available',
'about.download': 'Download', 'about.download': 'Download',
'about.updateCheckFailed': 'Could not check for updates', 'about.updateCheckFailed': 'Could not check for updates',
'about.releaseSite': 'Release site',
'about.officialSite': 'Official site',
} satisfies Record<SettingsKey, string> } satisfies Record<SettingsKey, string>
@@ -63,15 +63,26 @@ describe('GeneralSection', () => {
describe('AboutSection', () => { describe('AboutSection', () => {
const aboutProps: AboutSectionComponentProps = { close: vi.fn(), t } as never const aboutProps: AboutSectionComponentProps = { close: vi.fn(), t } as never
it('renders the company copy and the current version', () => { it('renders the company copy and a current-version row (fallback on plain web)', () => {
render(<AboutSection {...aboutProps} />) render(<AboutSection {...aboutProps} />)
expect(screen.getByText('PineSound')).toBeTruthy() expect(screen.getByText('PineSound')).toBeTruthy()
expect(screen.getByText('AI audio creation platform')).toBeTruthy() expect(screen.getByText('AI audio creation platform')).toBeTruthy()
expect(screen.getByText(en['about.currentVersion'])).toBeTruthy() expect(screen.getByText(en['about.currentVersion'])).toBeTruthy()
expect(screen.getByText('0.1.0-rc.5')).toBeTruthy() // No desktop bridge on a plain web surface: the version row falls back.
expect(screen.getByText('—')).toBeTruthy()
expect(screen.getByRole('button', { name: en['about.checkUpdates'] })).toBeTruthy() expect(screen.getByRole('button', { name: en['about.checkUpdates'] })).toBeTruthy()
}) })
it('shows the packaged version from the desktop bridge', async () => {
;(window as { dshApp?: unknown }).dshApp = { getAppInfo: vi.fn(async () => ({ version: '0.1.0-rc.5' })) }
try {
render(<AboutSection {...aboutProps} />)
await screen.findByText('0.1.0-rc.5')
} finally {
delete (window as { dshApp?: unknown }).dshApp
}
})
it('reports up-to-date on a web surface without the desktop bridge', async () => { it('reports up-to-date on a web surface without the desktop bridge', async () => {
render(<AboutSection {...aboutProps} />) render(<AboutSection {...aboutProps} />)
fireEvent.click(screen.getByRole('button', { name: en['about.checkUpdates'] })) fireEvent.click(screen.getByRole('button', { name: en['about.checkUpdates'] }))
@@ -83,20 +94,31 @@ describe('AboutSection', () => {
it('shows a download link when the bridge reports a newer version', async () => { it('shows a download link when the bridge reports a newer version', async () => {
const checkUpdate = vi.fn(async () => ({ const checkUpdate = vi.fn(async () => ({
status: 'update-available' as const, current: '0.1.0-rc.5', latest: '0.1.0-rc.6', 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', date: '2026-08-01', notes: 'New release', url: 'https://example.com/app.dmg',
})) }))
;(window as { dshApp?: unknown }).dshApp = { checkUpdate } ;(window as { dshApp?: unknown }).dshApp = {
checkUpdate,
getAppInfo: vi.fn(async () => ({ version: '0.1.0-rc.5' })),
}
try { try {
render(<AboutSection {...aboutProps} />) render(<AboutSection {...aboutProps} />)
fireEvent.click(screen.getByRole('button', { name: en['about.checkUpdates'] })) fireEvent.click(screen.getByRole('button', { name: en['about.checkUpdates'] }))
const status = await screen.findByRole('status') const status = await screen.findByRole('status')
expect(status.textContent).toContain(en['about.updateAvailable']) expect(status.textContent).toContain(en['about.updateAvailable'])
expect(status.textContent).toContain('0.1.0-rc.6') expect(status.textContent).toContain('0.1.0-rc.6')
expect(status.textContent).toContain('2026-08-01')
expect(status.textContent).toContain('New release')
expect(screen.getByRole('link', { name: en['about.download'] }).getAttribute('href')).toBe('https://example.com/app.dmg') expect(screen.getByRole('link', { name: en['about.download'] }).getAttribute('href')).toBe('https://example.com/app.dmg')
} finally { } finally {
delete (window as { dshApp?: unknown }).dshApp delete (window as { dshApp?: unknown }).dshApp
} }
}) })
it('links to the release site and the official site', () => {
render(<AboutSection {...aboutProps} />)
expect(screen.getByRole('link', { name: en['about.releaseSite'] }).getAttribute('href')).toBe('https://deepseek.pinesound.cn/')
expect(screen.getByRole('link', { name: en['about.officialSite'] }).getAttribute('href')).toBe('https://www.deepseek.com/')
})
}) })
describe('SettingsDocumentAction', () => { describe('SettingsDocumentAction', () => {