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