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
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
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
the OSS manifest (`updates/releases.json`, `DSH_UPDATE_URL` overrides) and
compares the latest version against `app.getVersion()`. When a newer release
exists it prompts the user to open the per-platform installer URL.
the manifest (`updates/releases.json`, `DSH_UPDATE_URL` overrides) and compares
the latest version against `app.getVersion()`. When a newer release exists it
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
pnpm --filter @deepseek-ai/dsh-desktop run build:harness
pnpm desktop:pack # produces .dmg (mac) / .nsis .exe (win)
DSH_RELEASE_NOTES="…" pnpm --filter @deepseek-ai/dsh-desktop run generate-release-json
# e.g. 0.1.0-rc.5 → 0.1.0-rc.6
```
`scripts/generate-release-json.mjs` writes `dist/releases.json` from the built
installers (`DSH_UPDATE_BASE` defaults to `https://deepseek.pinesound.cn/updates/`).
Upload `releases.json` plus the `.dmg`/`.exe` to the OSS `updates/` folder, and
the `deepseek-harness-web` `download/` page renders the same manifest. Building
the Windows `.exe` on macOS needs wine (or build on a Windows host).
The scheme is `0.1.0-rc.N`; `generate-release-json` orders versions
numerically, so `rc.6` correctly outranks `rc.5` (and `0.10.0` outranks
`0.9.0`). The root `package.json` version is the monorepo workspace version —
bump it too if you keep them in sync, but only the desktop one is user-visible.
### 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
+1
View File
@@ -15,6 +15,7 @@
"build": "tsc -b tsconfig.json",
"build:harness": "node scripts/build-harness.mjs",
"generate-release-json": "node scripts/generate-release-json.mjs",
"stage-release": "node scripts/stage-release.mjs",
"typecheck": "tsc -b tsconfig.json",
"test": "vitest run",
"pack": "electron-builder"
+18 -1
View File
@@ -26,6 +26,23 @@ function versionOf(filename) {
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 = {}
let latestVersion
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'
: 'win-x64'
platforms[key] = { url: `${base}${encodeURIComponent(entry)}` }
if (latestVersion === undefined || version > latestVersion) {
if (latestVersion === undefined || compareVersions(version, latestVersion) > 0) {
latestVersion = version
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. */
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`). */
interface ReleaseManifest {
readonly latest: { readonly version: string }
readonly latest: { readonly version: string; readonly date?: string }
readonly releaseNotes?: 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',
current,
latest,
date: manifest.latest.date,
notes: manifest.releaseNotes,
url: platformDownloadUrl(manifest),
}
@@ -290,6 +294,13 @@ app.whenReady().then(() => {
installAppMenu()
// The SPA's About "check for updates" button asks the main process.
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()
app.on('activate', () => {
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 current: string
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 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', {
/** Ask the main process for the latest release; resolves the check result. */
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;
}
.check:disabled {
cursor: default;
opacity: 0.6;
}
.status {
margin: 0;
opacity: 0.75;
@@ -74,3 +69,36 @@
.download {
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.
*/
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 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). */
interface DshAppBridge {
@@ -19,16 +25,18 @@ interface DshAppBridge {
readonly status: 'up-to-date' | 'update-available' | 'error'
readonly current: string
readonly latest?: string | undefined
readonly date?: string | undefined
readonly notes?: string | undefined
readonly url?: string | undefined
}>
getAppInfo: () => Promise<{ readonly version: string }>
}
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: 'available'; readonly latest: string; readonly date?: string | undefined; readonly notes?: string | undefined; readonly url?: string | undefined }
| { readonly status: 'error'; readonly detail?: string | undefined }
/** Full component props for the About section. */
@@ -42,7 +50,15 @@ export type AboutSectionComponentProps =
*/
export function AboutSection({ t }: AboutSectionComponentProps) {
const [state, setState] = useState<UpdateState>({ status: 'idle' })
const [version, setVersion] = useState<string>(FALLBACK_VERSION)
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 => {
if (checking) return
const bridge = (window as { dshApp?: DshAppBridge }).dshApp
@@ -55,7 +71,7 @@ export function AboutSection({ t }: AboutSectionComponentProps) {
void bridge.checkUpdate().then(
(result) => {
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') {
setState({ status: 'up-to-date' })
} else {
@@ -76,24 +92,25 @@ export function AboutSection({ t }: AboutSectionComponentProps) {
<p className={css.productName}>{t('about.product')}</p>
<dl className={css.version}>
<dt>{t('about.currentVersion')}</dt>
<dd data-version>{APP_VERSION}</dd>
<dd data-version>{version}</dd>
</dl>
<button
type="button"
<Button
className={css.check}
variant="primary"
disabled={checking}
aria-busy={checking || undefined}
onClick={check}
>
{checking ? t('about.checking') : t('about.checkUpdates')}
</button>
</Button>
{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}
{state.status === 'available'
? (
<p className={css.status} role="status">
{t('about.updateAvailable')} · v{state.latest}
{state.date === undefined || state.date.length === 0 ? '' : ` (${state.date})`}
{state.notes === undefined || state.notes.length === 0 ? '' : `${state.notes}`}
</p>
)
@@ -104,6 +121,10 @@ export function AboutSection({ t }: AboutSectionComponentProps) {
{state.status === 'error'
? <p className={css.status} role="status">{t('about.updateCheckFailed')}{state.detail === undefined || state.detail.length === 0 ? '' : `: ${state.detail}`}</p>
: 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>
)
@@ -20,6 +20,8 @@ export const zh = {
'about.updateAvailable': '发现新版本',
'about.download': '去下载',
'about.updateCheckFailed': '检查更新失败',
'about.releaseSite': '发布站',
'about.officialSite': '官网',
} satisfies Record<string, string>
/** The settings namespace key union. */
@@ -45,4 +47,6 @@ export const en = {
'about.updateAvailable': 'A new version is available',
'about.download': 'Download',
'about.updateCheckFailed': 'Could not check for updates',
'about.releaseSite': 'Release site',
'about.officialSite': 'Official site',
} satisfies Record<SettingsKey, string>
@@ -63,15 +63,26 @@ describe('GeneralSection', () => {
describe('AboutSection', () => {
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} />)
expect(screen.getByText('PineSound')).toBeTruthy()
expect(screen.getByText('AI audio creation platform')).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()
})
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 () => {
render(<AboutSection {...aboutProps} />)
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 () => {
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',
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 {
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(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')
} finally {
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', () => {