diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 4d1235ec8a..bb442806d1 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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--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 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e41246c1ab..65484da5b0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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" diff --git a/apps/desktop/scripts/generate-release-json.mjs b/apps/desktop/scripts/generate-release-json.mjs index 30aa442369..4086ae0353 100644 --- a/apps/desktop/scripts/generate-release-json.mjs +++ b/apps/desktop/scripts/generate-release-json.mjs @@ -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) } diff --git a/apps/desktop/scripts/stage-release.mjs b/apps/desktop/scripts/stage-release.mjs new file mode 100644 index 0000000000..2eca71b759 --- /dev/null +++ b/apps/desktop/scripts/stage-release.mjs @@ -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 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`.') diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 63ed093302..7d781501c2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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> } @@ -180,6 +183,7 @@ async function checkForUpdate(): Promise { 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) { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 36a8b1da71..d4a455b3c3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -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 => ipcRenderer.invoke('check-update') as Promise, + /** Ask the main process for this build's static identity (no network). */ + getAppInfo: (): Promise => ipcRenderer.invoke('get-app-info') 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 f886ff4af6..a6bc57bca2 100644 --- a/packages/client/ui-settings-general/src/client/AboutSection.module.css +++ b/packages/client/ui-settings-general/src/client/AboutSection.module.css @@ -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; +} diff --git a/packages/client/ui-settings-general/src/client/AboutSection.tsx b/packages/client/ui-settings-general/src/client/AboutSection.tsx index 92eccbed68..626797330f 100644 --- a/packages/client/ui-settings-general/src/client/AboutSection.tsx +++ b/packages/client/ui-settings-general/src/client/AboutSection.tsx @@ -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({ status: 'idle' }) + const [version, setVersion] = useState(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) {

{t('about.product')}

{t('about.currentVersion')}
-
{APP_VERSION}
+
{version}
- + {state.status === 'up-to-date' && !checking - ?

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

+ ?

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

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

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

) @@ -104,6 +121,10 @@ export function AboutSection({ t }: AboutSectionComponentProps) { {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 dadfa2b3f1..8102174aca 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -20,6 +20,8 @@ export const zh = { 'about.updateAvailable': '发现新版本', 'about.download': '去下载', 'about.updateCheckFailed': '检查更新失败', + 'about.releaseSite': '发布站', + 'about.officialSite': '官网', } satisfies Record /** 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 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 9366f55738..e0d1f6b7e6 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -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() 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() + 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() 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() 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() + 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', () => {