diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 5a4e92bf9e..4d1235ec8a 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b5ab586665..e41246c1ab 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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" diff --git a/apps/desktop/scripts/generate-release-json.mjs b/apps/desktop/scripts/generate-release-json.mjs new file mode 100644 index 0000000000..30aa442369 --- /dev/null +++ b/apps/desktop/scripts/generate-release-json.mjs @@ -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}`) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b2b7877446..63ed093302 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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> +} + /** 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 { + 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 => { + 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', () => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts new file mode 100644 index 0000000000..36a8b1da71 --- /dev/null +++ b/apps/desktop/src/preload.ts @@ -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 => ipcRenderer.invoke('check-update') as Promise, +}) diff --git a/packages/client/ui-settings-general/src/client/AboutSection.module.css b/packages/client/ui-settings-general/src/client/AboutSection.module.css index 1ab33971c3..f886ff4af6 100644 --- a/packages/client/ui-settings-general/src/client/AboutSection.module.css +++ b/packages/client/ui-settings-general/src/client/AboutSection.module.css @@ -70,3 +70,7 @@ margin: 0; opacity: 0.75; } + +.download { + align-self: flex-start; +} diff --git a/packages/client/ui-settings-general/src/client/AboutSection.tsx b/packages/client/ui-settings-general/src/client/AboutSection.tsx index 2a13bed0f0..92eccbed68 100644 --- a/packages/client/ui-settings-general/src/client/AboutSection.tsx +++ b/packages/client/ui-settings-general/src/client/AboutSection.tsx @@ -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('idle') - const checking = status === 'checking' + const [state, setState] = useState({ 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 (
@@ -57,8 +87,22 @@ export function AboutSection({ t }: AboutSectionComponentProps) { > {checking ? t('about.checking') : t('about.checkUpdates')} - {status === 'upToDate' && !checking - ?

{t('about.upToDate')} · v{LATEST_VERSION}

+ {state.status === 'up-to-date' && !checking + ?

{t('about.upToDate')} · v{APP_VERSION}

+ : null} + {state.status === 'available' + ? ( +

+ {t('about.updateAvailable')} · v{state.latest} + {state.notes === undefined || state.notes.length === 0 ? '' : ` — ${state.notes}`} +

+ ) + : null} + {state.status === 'available' && state.url !== undefined + ? {t('about.download')} + : null} + {state.status === 'error' + ?

{t('about.updateCheckFailed')}{state.detail === undefined || state.detail.length === 0 ? '' : `: ${state.detail}`}

: null}
diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index d252a87a3f..dadfa2b3f1 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -17,6 +17,9 @@ export const zh = { 'about.checkUpdates': '检查更新', 'about.checking': '正在检查更新…', 'about.upToDate': '已是最新版本', + 'about.updateAvailable': '发现新版本', + 'about.download': '去下载', + 'about.updateCheckFailed': '检查更新失败', } satisfies Record /** 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 diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 7c26557e97..9366f55738 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -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() 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() + 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', () => {