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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
| [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT |
|
||||||
| [`dayjs`](https://github.com/iamkun/dayjs) | MIT |
|
| [`dayjs`](https://github.com/iamkun/dayjs) | MIT |
|
||||||
| [`debug`](https://github.com/debug-js/debug) | 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 |
|
| [`esbuild`](https://github.com/evanw/esbuild) | MIT |
|
||||||
| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
|
| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
|
||||||
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
|
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
|
||||||
|
|||||||
@@ -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:<port>`), 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`.
|
||||||
@@ -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/**'
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "lib/types",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user