From 0cdbbfbf1a5a28fff15987c3e804c5e559f6ec29 Mon Sep 17 00:00:00 2001 From: Pine Date: Fri, 14 Aug 2026 10:23:22 +0800 Subject: [PATCH] feat(desktop): add Electron desktop shell over the web profile Spawn the real dsh CLI running the web profile on loopback (OS-assigned port), parse the printed readiness URL, and open a native window at it. The harness and its native addons stay on the system Node ABI; the main imports only electron and node builtins. Co-Authored-By: Claude --- THIRD_PARTY_NOTICES.md | 2 + apps/desktop/README.md | 31 +++++++ apps/desktop/electron-builder.yml | 31 +++++++ apps/desktop/package.json | 29 ++++++ apps/desktop/src/main.ts | 125 ++++++++++++++++++++++++++ apps/desktop/src/ready-port.ts | 22 +++++ apps/desktop/tests/ready-port.spec.ts | 17 ++++ apps/desktop/tsconfig.json | 9 ++ 8 files changed, 266 insertions(+) create mode 100644 apps/desktop/README.md create mode 100644 apps/desktop/electron-builder.yml create mode 100644 apps/desktop/package.json create mode 100644 apps/desktop/src/main.ts create mode 100644 apps/desktop/src/ready-port.ts create mode 100644 apps/desktop/tests/ready-port.spec.ts create mode 100644 apps/desktop/tsconfig.json diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 92b218ff33..10666a6142 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -143,6 +143,8 @@ External packages **directly declared** only by repository tooling, test infrast | [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT | | [`dayjs`](https://github.com/iamkun/dayjs) | MIT | | [`debug`](https://github.com/debug-js/debug) | MIT | +| [`electron`](https://github.com/electron/electron) | MIT | +| [`electron-builder`](https://github.com/electron-userland/electron-builder) | MIT | | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | | [`execa`](https://github.com/sindresorhus/execa) | MIT | diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000000..caca637b65 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,31 @@ +# @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 +(`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. + +## Run from a checkout + +```sh +pnpm install +pnpm run build # builds lib + the apps/web frontend dist +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`. + +## Package + +```sh +pnpm desktop:pack # electron-builder: mac .dmg / win .nsis in apps/desktop/dist +``` + +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`. diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml new file mode 100644 index 0000000000..a86233c0c8 --- /dev/null +++ b/apps/desktop/electron-builder.yml @@ -0,0 +1,31 @@ +# electron-builder packaging for the DeepSeek Harness desktop shell. +# +# The harness runs as a system-Node child process (see src/main.ts), so the +# packaged app must ship a Node runtime for the platform it targets. Point +# DSH_NODE at a bundled Node executable at runtime (an electron-builder +# `extraResource` plus a small resolver in the main process), or keep spawning +# PATH `node`. No signing is configured; distribute the unsigned artifacts +# only for local evaluation until a certificate is provided. +appId: ai.deepseek.harness +productName: DeepSeek Harness +directories: + output: dist +files: + - lib/types/main.js + - package.json +mac: + target: + - dmg + category: public.app-category.developer-tools +win: + target: + - nsis +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 new file mode 100644 index 0000000000..a074264bfc --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,29 @@ +{ + "name": "@deepseek-ai/dsh-desktop", + "description": "Electron desktop shell over the dsh harness: spawns the `web` profile on loopback and opens the GUI in a native window.", + "version": "0.1.0-rc.5", + "private": true, + "type": "module", + "main": "lib/types/main.js", + "files": [ + "lib/types/*.js", + "lib/types/*.d.ts", + "electron-builder.yml" + ], + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json", + "test": "vitest run", + "pack": "electron-builder" + }, + "dependencies": { + "@deepseek-ai/dsh": "workspace:^" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "electron": "^42.4.1", + "electron-builder": "^25.1.8", + "typescript": "^6.0.3", + "vitest": "^4.1.8" + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts new file mode 100644 index 0000000000..b601868e84 --- /dev/null +++ b/apps/desktop/src/main.ts @@ -0,0 +1,125 @@ +/** + * DeepSeek Harness desktop shell. The Electron main process is deliberately 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, and opens a native window at that address. The harness — its Cordis + * plugins, webserver, static frontend dist, and WebSocket transport — is + * untouched and runs exactly as `dsh web` would, as a separate Node process. + * + * Keeping the harness in its own system-Node child also keeps native addons + * (e.g. the PTY used by the terminal capability) on the system Node ABI; the + * Electron main and renderer never load them. + * @module @deepseek-ai/dsh-desktop/main + */ + +import { spawn, type ChildProcess } from 'node:child_process' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import { app, dialog, BrowserWindow } from 'electron' + +import { LOOPBACK_HOST, parseReadyPort } from './ready-port.ts' + +const require = createRequire(import.meta.url) + +/** Spawn's node executable: a bundled one when the app is packaged, else PATH. */ +function nodeExecutable(): string { + 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. */ +function dshEntry(): string { + if (process.env.DSH_ENTRY) return resolve(process.env.DSH_ENTRY) + return require.resolve('@deepseek-ai/dsh/lib/bin.js') +} + +/** Open the harness window, wired to a live loopback port. */ +function openWindow(url: string): BrowserWindow { + const win = new BrowserWindow({ + width: 1280, + height: 840, + title: 'DeepSeek Harness', + webPreferences: { + // The UI is a remote SPA served by the harness; keep Node out of it. + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }) + void win.loadURL(url) + return win +} + +/** A spawned harness and the window bound to it. */ +interface Session { + child: ChildProcess + url: string + window: BrowserWindow +} + +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)) + app.exit(1) +} + +/** Spawn the web profile and open a window once its URL is known. */ +function startSession(): void { + const entry = dshEntry() + const child = spawn(nodeExecutable(), [entry, '--profile', 'web', '--host', LOOPBACK_HOST, '--port', '0'], { + stdio: ['ignore', 'pipe', 'inherit'], + env: { ...process.env, DSH_TELEMETRY_DISABLED: '1' }, + }) + let settled = false + let buffered = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + buffered += chunk + // Only a COMPLETE line is trustworthy: the readiness port is not matched + // until its newline arrives, so a chunk ending mid-number cannot open a + // window at a truncated port. + let newline: number + while ((newline = buffered.indexOf('\n')) !== -1) { + const line = buffered.slice(0, newline).trimEnd() + buffered = buffered.slice(newline + 1) + if (settled) return + const port = parseReadyPort(line) + if (port === undefined) continue + settled = true + const url = `http://${LOOPBACK_HOST}:${String(port)}` + session = { child, url, window: openWindow(url) } + return + } + }) + child.on('error', (error) => { + if (!settled) reportFatal(`Could not start the harness:\n${error.message}`) + }) + child.on('exit', (code) => { + if (!settled) { + settled = true + reportFatal(`The harness exited before serving (code ${String(code)}). Is the frontend dist built?`) + } else if (app.isReady()) { + app.quit() + } + }) +} + +app.whenReady().then(() => { + startSession() + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0 && session !== undefined) { + session.window = openWindow(session.url) + } + }) +}) + +app.on('window-all-closed', () => { + // The harness owns the window lifecycle; on macOS keep the app until the + // user quits, elsewhere exit when every window is gone. + if (process.platform !== 'darwin') { + session?.child.kill() + app.quit() + } +}) diff --git a/apps/desktop/src/ready-port.ts b/apps/desktop/src/ready-port.ts new file mode 100644 index 0000000000..014069f355 --- /dev/null +++ b/apps/desktop/src/ready-port.ts @@ -0,0 +1,22 @@ +/** + * Readiness-URL parsing for the desktop shell. Kept as a dependency-free module + * so it is unit-testable under plain Node: the Electron main process otherwise + * owns every other responsibility. + * @module @deepseek-ai/dsh-desktop/ready-port + */ + +/** The bind host the desktop always requests: loopback only, never the LAN. */ +export const LOOPBACK_HOST = '127.0.0.1' as const + +/** Readiness marker the web profile prints once its server is listening. */ +export const READY_RE = /http:\/\/127\.0\.0\.1:(\d+)/ + +/** + * Extract the listening port from a web-profile readiness line. + * @param line - a line of child stdout (may already include earlier chunks). + * @returns the port, or `undefined` when the line carries no readiness URL. + */ +export function parseReadyPort(line: string): number | undefined { + const match = READY_RE.exec(line) + return match === null ? undefined : Number(match[1]) +} diff --git a/apps/desktop/tests/ready-port.spec.ts b/apps/desktop/tests/ready-port.spec.ts new file mode 100644 index 0000000000..a30d402197 --- /dev/null +++ b/apps/desktop/tests/ready-port.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { parseReadyPort } from '../src/ready-port.ts' + +describe('parseReadyPort', () => { + it('extracts the port from a complete readiness line', () => { + expect(parseReadyPort('dsh web: http://127.0.0.1:38291')).toBe(38291) + }) + + it('ignores lines without a readiness URL', () => { + expect(parseReadyPort('loading plugins…')).toBeUndefined() + expect(parseReadyPort('http://192.168.1.5:8080')).toBeUndefined() + }) + + it('matches the loopback URL before a LAN suffix on the same line', () => { + expect(parseReadyPort('dsh web: http://127.0.0.1:38291 (LAN: http://192.168.1.5:8080)')).toBe(38291) + }) +}) diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000000..473959f345 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "types": ["node"] + }, + "include": ["src"] +}