diff --git a/.gitignore b/.gitignore index 3d0fd8e322..3dac26c8ab 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/ python/**/__pycache__/ python/**/.pytest_cache/ apps/web/dist/ +# Packaged desktop app output and the generated self-contained harness runtime +# (build/harness is a multi-GB copy of node_modules + vendor + native + Node). +apps/desktop/dist/ +apps/desktop/build/harness/ .artifacts/ .playwright-mcp/ .orig diff --git a/apps/desktop/README.md b/apps/desktop/README.md index caca637b65..5a4e92bf9e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,13 +1,51 @@ # @deepseek-ai/dsh-desktop -Electron desktop shell over the DeepSeek Harness. The Electron main process is a -thin wrapper: it spawns the real `dsh` CLI running the `web` profile on loopback -(an OS-assigned port), parses the readiness URL line the profile prints +Electron desktop shell for DeepSeek Harness. The Electron main process is a thin +wrapper: it spawns the real `dsh` CLI running the `web` profile on loopback (an +OS-assigned port), parses the readiness URL line the profile prints (`dsh web: http://127.0.0.1:`), and opens a native window at that address. - The harness — its Cordis plugins, webserver, static frontend dist, and WebSocket transport — runs exactly as `dsh web` would, as a separate system-Node process, -so native addons keep their system Node ABI. +so native addons keep their system Node ABI and are never loaded into Electron. + +## Why a system-Node child + +Electron's bundled Node ABI differs from the system Node the harness's native +addons (e.g. `node-pty` for the terminal capability) are built against. Running +the harness in its own `node` child keeps those addons on the system Node ABI; +the Electron main and renderer never load them. This is why the desktop is a +launcher, not a bundler: it delegates the whole harness runtime to a child +process and only opens a window at the served URL. + +## Architecture + +``` +Electron main (this package) + │ spawns node /apps/cli/lib/bin.js --profile web --host 127.0.0.1 --port 0 + ▼ +system-Node child — the dsh harness + │ prints "dsh web: http://127.0.0.1:" on stdout + ▼ +Electron opens a BrowserWindow at that loopback URL +``` + +The main process keeps the window bound to the child: it reads the child's +stdout for the readiness line, opens the window once the port is known, and +quits when the child exits. A fatal child failure is shown in a native error +box. + +## Feature surface + +- **Chinese application menu** — replaces Electron's English default with + 文件/编辑/视图/窗口/帮助 (plus the macOS app menu: 关于/服务/隐藏/退出). Menu + items are Electron *roles* with localized labels, so accelerators stay the + system defaults (e.g. `Cmd+R` reload, `Cmd+Alt+I` DevTools). +- **Branded icon** — `build/icon.png` (1024px) is used for the window icon and, + through `electron-builder.yml`, derived into the per-target `.icns`/`.ico`. + Replace it with final artwork anytime. +- **Self-contained harness** — the packaged app ships the full harness runtime + under `Contents/Resources/harness` (see below), so it runs on any target + machine with no external Node or repository install. ## Run from a checkout @@ -18,14 +56,77 @@ pnpm desktop:dev # opens the desktop window ``` `desktop:dev` builds the frontend dist and the `dsh` CLI, then launches -Electron. Override the spawned Node or `dsh` entry with `DSH_NODE` / `DSH_ENTRY`. +Electron. In development the harness resolves from the workspace, and you can +override the spawned Node or `dsh` entry with `DSH_NODE` / `DSH_ENTRY`. -## Package +## Packaging ```sh -pnpm desktop:pack # electron-builder: mac .dmg / win .nsis in apps/desktop/dist +pnpm --filter @deepseek-ai/dsh-desktop run build:harness # (re)assemble the bundled runtime +pnpm desktop:pack # electron-builder: mac .dmg / win .nsis ``` -Packaging is unsigned by default; provide a certificate to distribute. The -harness child needs a Node runtime on the target platform — see -`electron-builder.yml` and the notes in `src/main.ts`. +Artifacts land in `apps/desktop/dist`. + +### The self-contained harness (`build/harness`) + +The harness's pnpm workspace does **not** cleanly materialize into a portable +node_modules: its packages are linked through per-package symlinks to vendored +sources at the repository root (`vendor/`, `packages/`, `native/`), it has +native addons built for a specific Node ABI, and the web profile serves a +separately built frontend dist. `pnpm deploy` and electron-builder's dependency +resolution both produce incomplete or broken copies of this runtime. + +The only known-good runtime is the repository's own working tree. So +`scripts/build-harness.mjs` assembles a self-contained copy into +`apps/desktop/build/harness`, preserving the relative layout the symlinks +depend on: + +``` +build/harness/ + node_modules/ the full dependency store + link network + vendor/ vendored Cordis packages the symlinks resolve to + packages/ workspace packages the symlinks resolve to + native/ native addon sources (landlock-run) + apps/cli/ the dsh CLI (the harness entry) + apps/web/ the frontend + built dist + bin/node the platform Node binary the child runs on +``` + +electron-builder ships this as an `extraResource` at +`Contents/Resources/harness`, and `src/main.ts` spawns +`Resources/harness/bin/node` + `Resources/harness/apps/cli/lib/bin.js` with the +harness root as the child working directory. Because every symlink is relative +to that tree, the bundled runtime resolves identically on any machine of the +same OS/arch. + +### Platform matrix + +Each target platform needs its own harness: the bundled `bin/node` and the +native addons are OS/arch-specific. Regenerate `build/harness` on each target +platform (or per-target in CI) before packaging that platform. The current +configuration targets **macOS arm64** (`mac.target: dmg`); `win.target: nsis` +is declared but needs a Windows-built harness. + +### Signing + +No signing is configured (`electron-builder.yml` has no `mac.sign` identity). +Artifacts are unsigned: on first open, macOS Gatekeeper may block them (open +via right-click → Open, or add a `Developer ID Application` certificate to +distribute). Provide a certificate before public distribution. + +### Size + +The bundled harness is multi-GB uncompressed (≈2 GB), dominated by +`node_modules`; the compressed `.dmg` is on the order of a few hundred MB to +~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. + +## Notes + +- `asar: false` and `npmRebuild: false` are deliberate (see + `electron-builder.yml` comments): the harness stays as real on-disk files for + the child and its native addons, and native addons are not rebuilt for the + Electron ABI because they never load under Electron. +- The profile data (`~/.dsh/profiles/web`) is created and read on the host the + app runs on; it is not part of the packaged artifact. diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png new file mode 100644 index 0000000000..0629179215 Binary files /dev/null and b/apps/desktop/build/icon.png differ diff --git a/apps/desktop/build/icon.svg b/apps/desktop/build/icon.svg new file mode 100644 index 0000000000..6f325e14b5 --- /dev/null +++ b/apps/desktop/build/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index a86233c0c8..8207ee4588 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -8,24 +8,41 @@ # only for local evaluation until a certificate is provided. appId: ai.deepseek.harness productName: DeepSeek Harness +# Native addons (node-pty, …) run under the system-Node child process, never +# under Electron, so skip @electron/rebuild: rebuilding them for the Electron +# ABI is wasted work and needs a headers download the local proxy rejects. +npmRebuild: false +# The harness runs as an external system-Node child process (see src/main.ts). +# The fully self-contained runtime lives in build/harness — a copy of the +# repository's working node_modules plus vendor/packages/native/apps and a +# bundled Node binary — and is shipped as extraResources so the packaged app +# runs on any machine with no external install. The Electron shell itself is +# unpacked (`asar: false`); the harness stays on disk as real files for the +# child and for the native addons it dlopens. +asar: false directories: output: dist files: - - lib/types/main.js + - lib/types/**/*.js - package.json +# The self-contained harness (build/harness) lands at Contents/Resources/harness +# in the packaged app; src/main.ts spawns the bundled node + dsh CLI from there. +extraResources: + - from: build/harness + to: harness + filter: + - "**/*" +# One 1024px build/icon.png; electron-builder derives the per-target .icns/.ico. +icon: build/icon.png mac: target: - dmg + icon: build/icon.png category: public.app-category.developer-tools win: target: - nsis + icon: build/icon.png nsis: oneClick: false allowToChangeInstallationDirectory: true -# Native addons the harness loads (e.g. the PTY node-pty) must live outside the -# asar archive so the spawned system-Node process can dlopen them by path. -asarUnpack: - - '**/*.node' - - 'node_modules/@deepseek-ai/dsh/node_modules/**/*.node' - - 'node_modules/node-pty/**' diff --git a/apps/desktop/package.json b/apps/desktop/package.json index aaabb288d5..b5ab586665 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -4,6 +4,7 @@ "version": "0.1.0-rc.5", "private": true, "type": "module", + "author": "PineSound", "main": "lib/types/main.js", "files": [ "lib/types/*.js", @@ -12,6 +13,7 @@ ], "scripts": { "build": "tsc -b tsconfig.json", + "build:harness": "node scripts/build-harness.mjs", "typecheck": "tsc -b tsconfig.json", "test": "vitest run", "pack": "electron-builder" diff --git a/apps/desktop/scripts/build-harness.mjs b/apps/desktop/scripts/build-harness.mjs new file mode 100644 index 0000000000..3da63d92c7 --- /dev/null +++ b/apps/desktop/scripts/build-harness.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Assemble the self-contained harness runtime for the packaged app. + * + * The harness's pnpm workspace does not cleanly materialize via `pnpm deploy` or + * electron-builder's dependency resolution (per-package symlinks to vendored + * packages, native addons, a separate frontend dist). The only known-good + * runtime is the repository's own working tree, so this copies the parts the + * harness needs at the same relative layout — node_modules, vendor, packages, + * native, apps/cli, apps/web — plus the platform Node binary, into + * `build/harness`, which electron-builder ships as `extraResources`. + * + * Run from the repository root before `desktop:pack`. + */ + +import { cpSync, chmodSync, existsSync, mkdirSync, rmSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { execFileSync } from 'node:child_process' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..') +const out = join(root, 'apps/desktop/build/harness') + +// Dirs whose relative layout must be preserved so node_modules symlinks resolve. +const harnessDirs = ['node_modules', 'vendor', 'packages', 'native'] +const extraDirs = [['apps/cli', 'apps/cli'], ['apps/web', 'apps/web']] + +rmSync(out, { recursive: true, force: true }) +mkdirSync(join(out, 'apps'), { recursive: true }) +mkdirSync(join(out, 'bin'), { recursive: true }) + +for (const d of harnessDirs) { + const src = join(root, d) + if (existsSync(src)) cpSync(src, join(out, d), { recursive: true }) +} +for (const [src, dst] of extraDirs) { + cpSync(join(root, src), join(out, dst), { recursive: true }) +} + +// Bundle the platform Node binary for the harness child. +const nodeBin = execFileSync('node', ['-e', 'process.stdout.write(process.execPath)']).toString() +if (!existsSync(nodeBin)) throw new Error(`node executable not found: ${nodeBin}`) +cpSync(nodeBin, join(out, 'bin/node')) +chmodSync(join(out, 'bin/node'), 0o755) + +console.log(`assembled self-contained harness at ${out}`) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b601868e84..3bd219dc57 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -13,22 +13,123 @@ */ import { spawn, type ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' import { createRequire } from 'node:module' -import { resolve } from 'node:path' -import { app, dialog, BrowserWindow } from 'electron' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { app, dialog, BrowserWindow, Menu, type MenuItemConstructorOptions } from 'electron' import { LOOPBACK_HOST, parseReadyPort } from './ready-port.ts' const require = createRequire(import.meta.url) +/** 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)) + +/** + * The self-contained harness runtime bundled into the packaged app by + * electron-builder's `extraResources` (see electron-builder.yml): a copy of the + * repository's working node_modules plus vendor/packages/native/apps and a + * bundled Node binary, so the harness child runs on any machine with no + * external install. In development the harness resolves from the workspace. + */ +function harnessRoot(): string | undefined { + return app.isPackaged ? join(process.resourcesPath, 'harness') : undefined +} + +/** + * Install a Chinese application menu, replacing Electron's English default. + * Roles carry the platform behavior (close, quit, zoom, DevTools, …); only the + * visible labels are localized, so the accelerators stay the system defaults. + */ +function installAppMenu(): void { + const isMac = process.platform === 'darwin' + const template: MenuItemConstructorOptions[] = [ + ...(isMac + ? [{ + label: PRODUCT_NAME, + submenu: [ + { role: 'about', label: `关于 ${PRODUCT_NAME}` }, + { type: 'separator' }, + { role: 'services', label: '服务' }, + { type: 'separator' }, + { role: 'hide', label: `隐藏 ${PRODUCT_NAME}` }, + { role: 'hideOthers', label: '隐藏其他' }, + { role: 'unhide', label: '全部显示' }, + { type: 'separator' }, + { role: 'quit', label: `退出 ${PRODUCT_NAME}` }, + ], + }] satisfies MenuItemConstructorOptions[] + : []), + { + label: '文件', + submenu: [ + isMac + ? { role: 'close', label: '关闭窗口' } + : { role: 'quit', label: '退出' }, + ], + }, + { + label: '编辑', + submenu: [ + { role: 'undo', label: '撤销' }, + { role: 'redo', label: '重做' }, + { type: 'separator' }, + { role: 'cut', label: '剪切' }, + { role: 'copy', label: '复制' }, + { role: 'paste', label: '粘贴' }, + { role: 'selectAll', label: '全选' }, + ], + }, + { + label: '视图', + submenu: [ + { role: 'reload', label: '重新加载' }, + { role: 'forceReload', label: '强制重新加载' }, + { role: 'toggleDevTools', label: '开发者工具' }, + { type: 'separator' }, + { role: 'resetZoom', label: '实际大小' }, + { role: 'zoomIn', label: '放大' }, + { role: 'zoomOut', label: '缩小' }, + { type: 'separator' }, + { role: 'togglefullscreen', label: '切换全屏' }, + ], + }, + { + label: '窗口', + submenu: [ + { role: 'minimize', label: '最小化' }, + { role: 'zoom', label: '缩放' }, + ...(isMac + ? [{ type: 'separator' }, { role: 'front', label: '前置全部窗口' }] satisfies MenuItemConstructorOptions[] + : []), + ], + }, + { + label: '帮助', + submenu: [ + { role: 'about', label: `关于 ${PRODUCT_NAME}` }, + ], + }, + ] + Menu.setApplicationMenu(Menu.buildFromTemplate(template)) +} + /** Spawn's node executable: a bundled one when the app is packaged, else PATH. */ function nodeExecutable(): string { + const harness = harnessRoot() + if (harness !== undefined) return join(harness, 'bin/node') if (process.env.DSH_NODE) return resolve(process.env.DSH_NODE) return 'node' } -/** The dsh CLI entry to spawn: an explicit dev override, else the built bin. */ +/** The dsh CLI entry to spawn: the bundled bin when packaged, else the built bin. */ function dshEntry(): string { + const harness = harnessRoot() + if (harness !== undefined) return join(harness, 'apps/cli/lib/bin.js') if (process.env.DSH_ENTRY) return resolve(process.env.DSH_ENTRY) return require.resolve('@deepseek-ai/dsh/lib/bin.js') } @@ -38,7 +139,10 @@ function openWindow(url: string): BrowserWindow { const win = new BrowserWindow({ width: 1280, height: 840, - title: 'DeepSeek Harness', + title: PRODUCT_NAME, + // The window icon matters on Linux/Windows; on macOS the dock and menu bar + // 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. nodeIntegration: false, @@ -61,14 +165,17 @@ let session: Session | undefined /** Fail loudly in the shell when the harness cannot start. */ function reportFatal(error: unknown): void { - void dialog.showErrorBox('DeepSeek Harness failed to start', String(error)) + void dialog.showErrorBox(`${PRODUCT_NAME} 启动失败`, String(error)) app.exit(1) } /** Spawn the web profile and open a window once its URL is known. */ function startSession(): void { const entry = dshEntry() + const harness = harnessRoot() const child = spawn(nodeExecutable(), [entry, '--profile', 'web', '--host', LOOPBACK_HOST, '--port', '0'], { + // Run the harness from its own root so relative path resolution is stable. + cwd: harness, stdio: ['ignore', 'pipe', 'inherit'], env: { ...process.env, DSH_TELEMETRY_DISABLED: '1' }, }) @@ -107,6 +214,7 @@ function startSession(): void { } app.whenReady().then(() => { + installAppMenu() startSession() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0 && session !== undefined) {