Deepseek-harness增加桌面版端序列:第 3 讲 · Electron 主进程:这个进程就是 harness

第 3 讲 · Electron 主进程:这个进程就是 harness

系列 :我在Deepseek harness中增加了桌面版,将逐行代码解析我是怎么增加的,希望你也能创建属于自己的桌面版Agent 本讲目标 :逐行吃透 apps/desktop/src/main.ts(85 行全行)------Electron 主进程如何调用 runProfile({ profile: 'desktop' }) 真正 boot harness、如何通过 IPC 桥接渲染进程、以及桌面端"无 TCP、纯 IPC"的架构差异。配套精讲 argv.ts(37 行)与 ipc.ts(31 行)。 逐行文件apps/desktop/src/main.ts(85 行,全行)+ argv.ts + ipc.ts + preload.ts + renderer.ts(精讲)
代码仓库github.com/tslcarmack/... Desktop App 运行效果图


🎯 本讲地图

scss 复制代码
Electron 主进程(main.ts)
   ├─► 检查构建产物(preload.cjs / dist/index.html)
   ├─► parseDesktopArgv(process.argv) → { patches, args }
   ├─► await app.whenReady()
   ├─► runProfile({ environment, profile: 'desktop', patchFiles, args })
   │        └─► ← 这就是第 4 讲的 boot 流程!返回 ctx
   ├─► ctx.get('desktopRuntime') → runtime
   ├─► new BrowserWindow(...) + IPC 三通道
   ├─► runtime.subscribeMux / subscribeHost → 推送渲染进程
   └─► window.loadFile(dist/index.html) → 窗口打开

📁 0. 前置认知:桌面端的架构定位

在逐行之前,先理解这个文件的关键设计(第 2 行注释点破):

"Electron main: this process IS the harness"

  • web 端 :CLI 进程 boot harness + HTTP 服务器,浏览器通过 TCP(http://127.0.0.1:3080)访问。
  • desktop 端 :Electron 主进程自己就是 harness ,渲染进程通过 IPC(进程间通信) 访问------不监听任何 TCP 端口(第 5 行注释:"nothing listens on TCP")。

这是安全与部署上的重要差异:桌面应用不需要开放本地端口,也就少了一个攻击面。


📁 1. main.ts 全行逐行(85 行)

文件:apps/desktop/src/main.ts

ts 复制代码
1  /**
  2   * Electron main: this process IS the harness (`runProfile({ profile: 'desktop' })`).
  3   * The renderer talks IPC through preload; nothing listens on TCP.
  4   * @module @deepseek-ai/dsh-desktop/main
  5   */
  6
  7  import { existsSync } from 'node:fs'
  8  import { fileURLToPath } from 'node:url'
  9  import { dirname, join } from 'node:path'
 10  import { app, BrowserWindow, ipcMain } from 'electron'
 11  import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot'
 12  import type { DesktopRuntimeImpl } from '@deepseek-ai/dsh-desktop-app'
 13  import { runProfile } from '../../cli/src/profile-boot.ts'
 14  import { parseDesktopArgv } from './argv.ts'
 15  import { DSH_IPC, FOREIGN_WEB_CONTENTS } from './ipc.ts'
 16
 17  const here = dirname(fileURLToPath(import.meta.url))
 18  const preloadPath = join(here, '..', 'lib', 'preload.cjs')
 19  const indexHtml = join(here, '..', 'dist', 'index.html')
 20
 21  function requireFile(path: string, hint: string): void {
 22    if (!existsSync(path)) throw new Error(`desktop: missing ${path}; ${hint}`)
 23  }
 24
 25  function sendJson(contents: Electron.WebContents, channel: string, json: string): void {
 26    if (contents.isDestroyed()) return
 27    try {
 28      contents.send(channel, json)
 29    } catch {
 30      // The window closed between isDestroyed and send.
 31    }
 32  }
 33
 34  async function main(): Promise {
 35    requireFile(preloadPath, 'run `pnpm --filter @deepseek-ai/dsh-desktop build`')
 36    requireFile(indexHtml, 'run `pnpm --filter @deepseek-ai/dsh-desktop build`')
 37    const { patches, args } = parseDesktopArgv(process.argv, fileURLToPath(import.meta.url))
 38    await app.whenReady()
 39    process.stderr.write('dsh: booting desktop profile\n')
 40    const { ctx } = await runProfile({
 41      environment: loadLayeredEnv('dsh'),
 42      profile: 'desktop',
 43      patchFiles: patches,
 44      args,
 45    })
 46    const runtime = ctx.get('desktopRuntime') as DesktopRuntimeImpl | undefined
 47    if (runtime === undefined) throw new Error('desktop: desktopRuntime is not mounted')
 48
 49    const window = new BrowserWindow({
 50      webPreferences: {
 51        preload: preloadPath,
 52        contextIsolation: true,
 53        nodeIntegration: false,
 54        sandbox: false,
 55      },
 56    })
 57
 58    const fromThisWindow = (sender: Electron.WebContents): boolean => sender === window.webContents
 59
 60    ipcMain.on(DSH_IPC.bootGraph, (event) => {
 61      if (!fromThisWindow(event.sender)) return
 62      event.returnValue = runtime.graph()
 63    })
 64    ipcMain.handle(DSH_IPC.invoke, async (event, request) => {
 65      if (!fromThisWindow(event.sender)) throw new Error(FOREIGN_WEB_CONTENTS)
 66      return runtime.fetchFromPreload(request)
 67    })
 68    ipcMain.handle(DSH_IPC.loadBundle, async (event, url: string) => {
 69      if (!fromThisWindow(event.sender)) throw new Error(FOREIGN_WEB_CONTENTS)
 70      return runtime.loadBundleSource(url)
 71    })
 72    runtime.subscribeMux((json) => { sendJson(window.webContents, DSH_IPC.mux, json) })
 73    runtime.subscribeHost((json) => { sendJson(window.webContents, DSH_IPC.host, json) })
 74
 75    window.on('closed', () => {
 76      void ctx.fiber.dispose().finally(() => { app.quit() })
 77    })
 78    await window.loadFile(indexHtml)
 79  }
 80
 81  void main().catch((error: unknown) => {
 82    const message = error instanceof Error ? error.stack ?? error.message : String(error)
 83    process.stderr.write(`dsh: desktop main failed\n${message}\n`)
 84    app.exit(1)
 85  })

逐段讲解

第 7-15 行 · 导入(重点看两处)

ts 复制代码
import { app, BrowserWindow, ipcMain } from 'electron'
import { runProfile } from '../../cli/src/profile-boot.ts'
  • runProfile 直接跨目录 importapps/desktop/src/main.ts import apps/cli/src/profile-boot.ts!这是 tsx 源码启动才能做到的事(ESM + tsx 让跨应用源码互引成为可能)。
  • 关键含义 :desktop 主进程复用了 CLI 的 boot 逻辑 ------runProfile 正是第 1 讲里 web/headless 走的那条常规路径。所以"desktop 特殊"只特殊在如何拉起进程(第 2 讲),一旦进入 harness 世界,desktop 与 web 的 boot 流程完全一致。

第 17-19 行 · 构建产物路径

ts 复制代码
const here = dirname(fileURLToPath(import.meta.url))
const preloadPath = join(here, '..', 'lib', 'preload.cjs')
const indexHtml = join(here, '..', 'dist', 'index.html')
  • main.tsapps/desktop/src/,上溯一层到 apps/desktop/,再进 lib/(preload 编译产物)与 dist/(前端打包产物)。
  • preload 是 .cjs(CommonJS)------Electron preload 要求非 ESM,这与主进程的 ESM 形成对比(第 5 讲展开)。

第 21-23 行 · requireFile(fail-fast 检查)

ts 复制代码
function requireFile(path: string, hint: string): void {
  if (!existsSync(path)) throw new Error(`desktop: missing ${path}; ${hint}`)
}
  • 启动前检查两个构建产物是否存在,缺失时直接给出修复命令pnpm --filter @deepseek-ai/dsh-desktop build)------延续第 2 讲的"报错即操作指引"风格。

第 25-32 行 · sendJson(防销毁竞态)

ts 复制代码
function sendJson(contents: Electron.WebContents, channel: string, json: string): void {
  if (contents.isDestroyed()) return
  try {
    contents.send(channel, json)
  } catch {
    // The window closed between isDestroyed and send.
  }
}
  • 双保险:先查 isDestroyed(),再 try/catch 包裹 send------注释点破竞态:"window 在 isDestroyed 检查与 send 之间关闭了"。

第 34-38 行 · boot 前置

ts 复制代码
async function main(): Promise {
  requireFile(preloadPath, ...)
  requireFile(indexHtml, ...)
  const { patches, args } = parseDesktopArgv(process.argv, fileURLToPath(import.meta.url))
  await app.whenReady()
  process.stderr.write('dsh: booting desktop profile\n')
  • 第 37 行parseDesktopArgv 解析 Electron argv(见 2.1 精讲)------把第 2 讲透传的 --patch-- 后的参数还原出来。
  • 第 38 行await app.whenReady()------Electron 生命周期:等 Chromium 就绪(此时才能创建窗口)。
  • 注意顺序:先 boot harness(第 40 行),后创建窗口(第 49 行)------窗口打开前 harness 必须已经跑起来。

第 40-47 行 · 真正 boot harness(核心)

ts 复制代码
const { ctx } = await runProfile({
  environment: loadLayeredEnv('dsh'),
  profile: 'desktop',
  patchFiles: patches,
  args,
})
const runtime = ctx.get('desktopRuntime') as DesktopRuntimeImpl | undefined
if (runtime === undefined) throw new Error('desktop: desktopRuntime is not mounted')
  • runProfile({ profile: 'desktop' }) :与 web/headless 同一条 boot 路径!desktop profile 的 bundles = [dsh-base, dsh-desktop-app](第 1 讲验证过)。
  • 第 46 行 ctx.get('desktopRuntime') :从已装载的插件树里取 desktopRuntime 服务------这是 desktop-app bundle 注入的服务(第 5 讲详解)。
  • 第 47 行:取不到就报错"desktopRuntime is not mounted"------fail-fast 保证后续 IPC 一定有 runtime 可用。

第 49-56 行 · 创建窗口(安全配置)

ts 复制代码
const window = new BrowserWindow({
  webPreferences: {
    preload: preloadPath,
    contextIsolation: true,
    nodeIntegration: false,
    sandbox: false,
  },
})
配置 含义
preload preload.cjs 预加载脚本(暴露 dshDesktop 桥,见 2.3)
contextIsolation true 安全关键:渲染进程与 preload 隔离上下文,防止 XSS 直接拿到 Node API
nodeIntegration false 安全关键:渲染进程没有 Node 能力
sandbox false 例外项:preload 需要 require 模块,所以关掉沙箱(但 contextIsolation + nodeIntegration:false 已提供主要防护)

第 58-71 行 · IPC 三通道

ts 复制代码
const fromThisWindow = (sender: Electron.WebContents): boolean => sender === window.webContents
  • 所有 IPC 处理前先校验 sender 是不是本窗口 的 webContents------防止任意窗口/页面调用主进程 API(FOREIGN_WEB_CONTENTS 错误)。
ts 复制代码
ipcMain.on(DSH_IPC.bootGraph, (event) => {
  if (!fromThisWindow(event.sender)) return
  event.returnValue = runtime.graph()
})
  • bootGraph同步 通道(on + returnValue)。渲染进程 preload 用 sendSync 获取启动图(__DSH_BOOT__,见 2.3)。
  • runtime.graph():desktop-app 提供的启动图(插件树快照之类)。
ts 复制代码
ipcMain.handle(DSH_IPC.invoke, async (event, request) => {
  if (!fromThisWindow(event.sender)) throw new Error(FOREIGN_WEB_CONTENTS)
  return runtime.fetchFromPreload(request)
})
  • invoke异步 通道(handle = invoke/handle 模式)。渲染进程发任意 JSON 请求,转发给 runtime.fetchFromPreload(request)------这是桌面端替代 HTTP 的 RPC 通道。
ts 复制代码
ipcMain.handle(DSH_IPC.loadBundle, async (event, url: string) => {
  if (!fromThisWindow(event.sender)) throw new Error(FOREIGN_WEB_CONTENTS)
  return runtime.loadBundleSource(url)
})
  • loadBundle:渲染进程按需加载插件 bundle 源码(第 5 讲 renderer.ts 会用到)。

第 72-78 行 · 订阅推送 + 生命周期

ts 复制代码
runtime.subscribeMux((json) => { sendJson(window.webContents, DSH_IPC.mux, json) })
runtime.subscribeHost((json) => { sendJson(window.webContents, DSH_IPC.host, json) })
  • 两个推送通道mux(多路复用的消息流)与 host(host 侧事件流)------主进程订阅 runtime 的推送,转发给渲染进程(与 web 端的 session/event 流对应,第 6 讲详述)。
ts 复制代码
window.on('closed', () => {
  void ctx.fiber.dispose().finally(() => { app.quit() })
})
await window.loadFile(indexHtml)
  • 第 75-77 行 :窗口关闭 → ctx.fiber.dispose() 拆掉整个插件树(一切皆插件的可逆注册在此刻体现)→ 然后 app.quit()
  • 第 78 行:加载前端页面,窗口正式出现。

第 81-85 行 · 兜底错误处理

ts 复制代码
void main().catch((error: unknown) => {
  const message = error instanceof Error ? error.stack ?? error.message : String(error)
  process.stderr.write(`dsh: desktop main failed\n${message}\n`)
  app.exit(1)
})
  • 主进程任何异步错误 → 打印堆栈到 stderr → app.exit(1)

📁 2. 配套文件精讲

2.1 argv.ts(37 行)------Electron 进程内 argv 解析

ts 复制代码
export function parseDesktopArgv(
  argv: readonly string[],
  scriptPath: string,
): { patches: string[]; args: string[] } {
  const from = argv.findIndex(part => part === scriptPath || isMainScript(part))
  const rest = from === -1 ? argv.slice(2) : argv.slice(from + 1)
  const patches: string[] = []
  const args: string[] = []
  for (let i = 0; i < rest.length; i++) {
    const token = rest[i]
    if (token === '--patch') {
      const next = rest[++i]
      if (next === undefined) throw new Error('dsh: --patch requires a path')
      patches.push(next)
      continue
    }
    if (token === '--') {
      args.push(...rest.slice(i + 1))
      break
    }
    if (token !== undefined) args.push(token)
  }
  return { patches, args }
}

function isMainScript(part: string): boolean {
  const name = basename(part)
  return name === 'main.ts' || name === 'main.js'
}

精讲

  • 第 13 行 argv.findIndex(...) :找到主脚本在 argv 中的位置(Electron 的 argv 形态与 Node 不同------第一个参数可能是 --inspect 之类,所以用"找到 main 脚本路径"来定位真正的参数起点)。
  • 第 14 行 :找不到就用 argv.slice(2) 兜底。
  • 第 17-30 行 :线性扫描--------patch 消费后一个 token;-- 之后全部归为 app args;其他 token 归为 args。
  • isMainScript :兼容 main.ts(源码)与 main.js(构建)两种形态。

2.2 ipc.ts(31 行)------IPC 通道名契约

ts 复制代码
export const DSH_IPC = {
  bootGraph: 'dsh:boot-graph',
  invoke: 'dsh:invoke',
  mux: 'dsh:mux',
  host: 'dsh:host',
  loadBundle: 'dsh:load-bundle',
} as const

export const FOREIGN_WEB_CONTENTS = 'desktop: foreign webContents'

export function attachJsonListener(register, unregister, channel, listener) {
  const handler = (_event, json) => { listener(json) }
  register(channel, handler)
  return () => { unregister(channel, handler) }
}
  • 5 个通道名 集中定义,主进程与 preload 共享(as const 保证字面量类型)。
  • attachJsonListener:通用 JSON 监听器包装,返回取消订阅函数------渲染进程侧复用。

2.3 preload.ts(21 行)------安全桥

ts 复制代码
const graph = ipcRenderer.sendSync(DSH_IPC.bootGraph)
contextBridge.exposeInMainWorld('__DSH_BOOT__', graph)
contextBridge.exposeInMainWorld('dshDesktop', {
  invoke: (request: unknown) => ipcRenderer.invoke(DSH_IPC.invoke, request),
  onMux: (listener) => attachJsonListener(...),
  onHost: (listener) => attachJsonListener(...),
  loadBundle: (url) => ipcRenderer.invoke(DSH_IPC.loadBundle, url),
})
  • contextBridge.exposeInMainWorld :把最小 API 安全地暴露给渲染进程------只暴露 invoke / onMux / onHost / loadBundle 四个方法 + __DSH_BOOT__ 启动图。
  • 渲染进程拿不到 ipcRenderer 本身,只能调用这 4 个白名单方法------这是 contextIsolation 的核心价值。

2.4 renderer.ts(27 行)------前端入口

ts 复制代码
void new AppWebEntry(el, {
  loadBundle: async (url) => {
    const source = await desktop.loadBundle(url)
    const blob = new Blob([source], { type: 'text/javascript' })
    const blobUrl = URL.createObjectURL(blob)
    ...动态创建  加载...
  },
}).run()
  • 复用 @deepseek-ai/dsh-client-webAppWebEntry------桌面端前端与 web 端共用同一套 client 框架(只是传输层从 HTTP 换成 IPC + Blob URL)。
  • loadBundle 的 Blob URL 技巧:主进程返回 bundle 源码字符串 → 渲染进程包装成 Blob URL → 动态 <script> 加载------这就是"浏览器端插件按需加载"在桌面的实现。

🖼️ 第 3 讲架构图

图 3-1 · 桌面端进程模型与 IPC 通道


⚙️ 机制小结

  1. 主进程 = harnessrunProfile({ profile: 'desktop' }) 与 web/headless 同一条 boot 路径,desktop 的"特殊"只体现在进程拉起方式。
  2. 无 TCP、纯 IPC:桌面端安全模型优于 web------不开放本地端口,渲染进程只能通过 preload 白名单方法通信。
  3. 三层安全防护contextIsolation: true + nodeIntegration: false + IPC 白名单 + fromThisWindow 校验。
  4. 先 boot 后开窗 :插件树就绪(拿到 desktopRuntime)才创建 BrowserWindow。
  5. 前端复用AppWebEntry 让桌面与 web 共用 client 框架,仅传输层不同(IPC+Blob vs HTTP)。

🧪 动手验证

sh 复制代码
# 1) 构建 desktop 前端产物(main.ts 会检查 preload.cjs 与 dist/index.html)
cd D:\code\deepseek-harness
pnpm --filter @deepseek-ai/dsh-desktop build

# 2) 在 Electron 里真正跑 desktop(若已装 Electron,会弹出窗口)
node apps/cli/lib/bin.js desktop

# 3) 观察 IPC 通道名(grep 共享契约)
grep -rn "dsh:boot-graph\|dsh:invoke" apps/desktop/src/

⚠️ 若 node apps/cli/lib/bin.js desktop 报"electron is not installed",按第 2 讲指引安装后再试。


📚 深入指引

文件 作用
apps/desktop/src/main.ts 本讲全行(85 行)
apps/desktop/src/argv.ts / ipc.ts / preload.ts / renderer.ts 配套精讲
packages/bundle/desktop-app/src/index.ts desktopRuntime 服务实现(第 5 讲)
apps/cli/src/profile-boot.ts runProfile 实现(第 4 讲)

下一讲预告runProfile 内部到底发生了什么------四层 patch 如何合并、boot() 如何创建 Context 并装载插件树、以及"config-only HMR"如何实现。→ 第 4 讲:组合与装载

相关推荐
修远客2 小时前
风格进化:让Agent越来越懂你 — 从"工具"到"助手"的关键跃迁
llm·agent
武子康2 小时前
Pi Extension 写完不等于可用:从类型检查到真实 Runtime 的证据阶梯
人工智能·llm·agent
苏灿烤鱼4 小时前
上下文写成文件系统,为什么向量还能静默丢?
python·github·agent
怕浪猫12 小时前
DeepSeek Harness 源码实战 第4章:Session 会话日志——单一事实源
agent·产品·资讯
用户479492835691514 小时前
字节Agent开发技术分享—旧会话越聊越笨,新会话又得重讲?我给 Matt Pocock Skill 炼了套《影分身之术》:本体想清楚,分身写代码,完事回来汇报
面试·agent
MomentYY15 小时前
RAG 索引维护:文档改了,知识库要不要重建?
人工智能·agent·ai编程