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
+23
View File
@@ -122,6 +122,29 @@ 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)
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.
To publish a release:
```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
```
`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).
## Notes
- `asar: false` and `npmRebuild: false` are deliberate (see
+1
View File
@@ -14,6 +14,7 @@
"scripts": {
"build": "tsc -b tsconfig.json",
"build:harness": "node scripts/build-harness.mjs",
"generate-release-json": "node scripts/generate-release-json.mjs",
"typecheck": "tsc -b tsconfig.json",
"test": "vitest run",
"pack": "electron-builder"
@@ -0,0 +1,59 @@
#!/usr/bin/env node
/**
* Generate the OSS update manifest (`releases.json`) from the packaged
* installers in `apps/desktop/dist`. The manifest drives both the desktop app's
* manual "check for updates" (main-process fetch + version compare) and the
* web download page. Run after `desktop:pack`, then upload `releases.json`
* together with the installers to the OSS `updates/` folder.
*
* The installer URLs are derived from the `DSH_UPDATE_BASE` (default
* `https://deepseek.pinesound.cn/updates/`) plus the artifact filename.
* `DSH_RELEASE_NOTES` optionally supplies the release-notes text.
*/
import { readdirSync, writeFileSync } 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 base = process.env.DSH_UPDATE_BASE ?? 'https://deepseek.pinesound.cn/updates/'
/** Extract a semver-ish version from a filename like `…0.1.0-rc.5-arm64.dmg`. */
function versionOf(filename) {
const base = filename.replace(/\.(dmg|exe)$/, '').replace(/-(arm64|x64)$/, '')
const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)$/.exec(base)
return match === null ? undefined : match[1]
}
const platforms = {}
let latestVersion
let latestDate = process.env.DSH_RELEASE_DATE ?? new Date().toISOString().slice(0, 10)
for (const entry of readdirSync(dist)) {
if (!entry.endsWith('.dmg') && !entry.endsWith('.exe')) continue
const version = versionOf(entry)
if (version === undefined) continue
const key = entry.endsWith('.dmg')
? entry.includes('arm64') ? 'mac-arm64' : 'mac-x64'
: 'win-x64'
platforms[key] = { url: `${base}${encodeURIComponent(entry)}` }
if (latestVersion === undefined || version > latestVersion) {
latestVersion = version
latestDate = process.env.DSH_RELEASE_DATE ?? new Date().toISOString().slice(0, 10)
}
}
if (latestVersion === undefined) {
console.error(`generate-release-json: no installers found in ${dist}`)
process.exit(1)
}
const manifest = {
latest: { version: latestVersion, date: latestDate },
releaseNotes: process.env.DSH_RELEASE_NOTES ?? '',
platforms,
}
writeFileSync(join(dist, 'releases.json'), `${JSON.stringify(manifest, null, 2)}\n`)
console.log(`wrote ${join(dist, 'releases.json')} for ${latestVersion}`)
+98 -2
View File
@@ -17,18 +17,39 @@ import { existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { app, dialog, BrowserWindow, Menu, type MenuItemConstructorOptions } from 'electron'
import { app, dialog, BrowserWindow, ipcMain, Menu, shell, type MenuItemConstructorOptions } from 'electron'
import { LOOPBACK_HOST, parseReadyPort } from './ready-port.ts'
import type { UpdateCheckResult } from './preload.ts'
const require = createRequire(import.meta.url)
/**
* URL of the release manifest hosted on OSS. Override per deployment with
* DSH_UPDATE_URL. The manifest (releases.json) carries the latest version,
* release notes, and per-platform installer URLs.
*/
const UPDATE_MANIFEST_URL = process.env.DSH_UPDATE_URL ?? 'https://deepseek.pinesound.cn/updates/releases.json'
/** The renderer's update-check IPC channel. */
const UPDATE_CHANNEL = 'check-update'
/** The release manifest published on OSS (`updates/releases.json`). */
interface ReleaseManifest {
readonly latest: { readonly version: string }
readonly releaseNotes?: string
readonly platforms: Partial<Record<'mac-arm64' | 'mac-x64' | 'win-x64', { readonly url: string }>>
}
/** User-facing product name (the npm name is the scoped package id). */
const PRODUCT_NAME = 'DeepSeek Harness'
/** Window icon: the branded PNG in build/, resolved from this ES module's URL. */
const APP_ICON = fileURLToPath(new URL('../../build/icon.png', import.meta.url))
/** Renderer preload (compiled to lib/types/preload.js), resolved from this ES module's URL. */
const PRELOAD = fileURLToPath(new URL('./preload.js', import.meta.url))
/**
* The self-contained harness runtime bundled into the packaged app by
* electron-builder's `extraResources` (see electron-builder.yml): a copy of the
@@ -118,6 +139,55 @@ function installAppMenu(): void {
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
/**
* Compare two version strings for "newer". Versions are `0.1.0-rc.N`; the
* numeric `rc` suffix carries the order, so compare the trailing integer and
* fall back to a plain string compare for non-rc builds.
*/
function isNewer(latest: string, current: string): boolean {
const rc = (value: string): number => {
const match = /rc\.(\d+)$/.exec(value)
return match === null ? Number.NaN : Number(match[1])
}
const lrc = rc(latest)
const crc = rc(current)
if (Number.isFinite(lrc) && Number.isFinite(crc)) return lrc > crc
return latest !== current
}
/** The per-platform download URL from a release manifest. */
function platformDownloadUrl(manifest: ReleaseManifest): string | undefined {
const key = process.platform === 'win32' ? 'win-x64' : process.arch === 'arm64' ? 'mac-arm64' : 'mac-x64'
return manifest.platforms[key]?.url
}
/**
* Fetch the release manifest and compare the latest version against the
* running app. Returns an update-available result (with the download URL) when
* a newer release exists; errors surface as `{ status: 'error' }` so the UI
* never crashes on a transient network miss.
* @returns the update-check result for the SPA and the startup prompt.
*/
async function checkForUpdate(): Promise<UpdateCheckResult> {
const current = app.getVersion()
try {
const response = await fetch(UPDATE_MANIFEST_URL)
if (!response.ok) throw new Error(`update manifest HTTP ${response.status}`)
const manifest = (await response.json()) as ReleaseManifest
const latest = manifest.latest.version
if (!isNewer(latest, current)) return { status: 'up-to-date', current }
return {
status: 'update-available',
current,
latest,
notes: manifest.releaseNotes,
url: platformDownloadUrl(manifest),
}
} catch (error) {
return { status: 'error', current, notes: error instanceof Error ? error.message : String(error) }
}
}
/** Spawn's node executable: a bundled one when the app is packaged, else PATH. */
function nodeExecutable(): string {
const harness = harnessRoot()
@@ -144,10 +214,12 @@ function openWindow(url: string): BrowserWindow {
// use the packaged .app icon from electron-builder's build/icon.png.
...(existsSync(APP_ICON) ? { icon: APP_ICON } : {}),
webPreferences: {
// The UI is a remote SPA served by the harness; keep Node out of it.
// The UI is a remote SPA served by the harness; keep Node out of it. The
// preload exposes only the update-check bridge (no direct Node access).
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
preload: PRELOAD,
},
})
void win.loadURL(url)
@@ -216,12 +288,36 @@ function startSession(): void {
app.whenReady().then(() => {
installAppMenu()
// The SPA's About "check for updates" button asks the main process.
ipcMain.handle(UPDATE_CHANNEL, () => checkForUpdate())
startSession()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0 && session !== undefined) {
session.window = openWindow(session.url)
}
})
// Manual-download update flow: check on startup (and hourly), and when a newer
// release exists prompt to open the installer URL. No signing, so the user
// downloads and installs by hand rather than an automatic swap.
const promptUpdate = async (): Promise<void> => {
const result = await checkForUpdate()
if (result.status !== 'update-available' || result.url === undefined) return
const { response } = await dialog.showMessageBox({
type: 'info',
message: `发现新版本 v${result.latest}`,
detail: result.notes ?? '点击「去下载」获取最新安装包。',
buttons: ['去下载', '稍后'],
defaultId: 0,
cancelId: 1,
})
if (response === 0) shell.openExternal(result.url).catch(() => {})
}
const checkAt = (delayMs: number): void => {
setTimeout(() => { promptUpdate().catch(() => {}) }, delayMs)
}
checkAt(4000)
const checkTimer = setInterval(() => { promptUpdate().catch(() => {}) }, 60 * 60 * 1000)
app.on('will-quit', () => { clearInterval(checkTimer) })
})
app.on('window-all-closed', () => {
+24
View File
@@ -0,0 +1,24 @@
/**
* Renderer preload bridge for the desktop shell. Exposes a narrow, type-safe
* surface to the loopback SPA: a manual "check for updates" that runs in the
* main process (Node fetch, no CORS) and returns the latest release info plus a
* download URL. The renderer stays sandboxed and remote; only this bridge and
* the update check cross the process boundary.
* @module @deepseek-ai/dsh-desktop/preload
*/
import { contextBridge, ipcRenderer } from 'electron'
/** The update-check result the SPA's About section renders. */
export interface UpdateCheckResult {
readonly status: 'up-to-date' | 'update-available' | 'error'
readonly current: string
readonly latest?: string | undefined
readonly notes?: string | undefined
readonly url?: string | undefined
}
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>,
})
@@ -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', () => {