79ae82fa48
Localizes the Electron application menu (文件/编辑/视图/窗口/帮助 plus the macOS app menu), adds a branded 1024px app icon (window + per-target icns/ico), and makes the packaged app fully self-contained. Because the harness's pnpm workspace does not cleanly materialize via pnpm deploy or electron-builder's dependency resolution (per-package symlinks to vendored sources, native addons, a separate frontend dist), scripts/build-harness.mjs assembles the repository's working runtime — node_modules, vendor, packages, native, apps/cli, apps/web, and a bundled Node binary — into build/harness. electron-builder ships it as an extraResource at Resources/harness, and the main process spawns that bundled node + dsh entry when packaged (keeping the system-Node child during dev). Adds a comprehensive README and ignores the multi-GB harness/dist from git. Co-Authored-By: Claude <noreply@anthropic.com>
234 lines
8.0 KiB
TypeScript
234 lines
8.0 KiB
TypeScript
/**
|
|
* 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 { 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 { 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: 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')
|
|
}
|
|
|
|
/** Open the harness window, wired to a live loopback port. */
|
|
function openWindow(url: string): BrowserWindow {
|
|
const win = new BrowserWindow({
|
|
width: 1280,
|
|
height: 840,
|
|
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,
|
|
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(`${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' },
|
|
})
|
|
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(() => {
|
|
installAppMenu()
|
|
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()
|
|
}
|
|
})
|