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>,
})