文章目录
-
- [1. 核心设计](#1. 核心设计)
-
- [1.1 三层技术栈,各管一摊](#1.1 三层技术栈,各管一摊)
- [1.2 装配流水线:两条独立支线 + 一次组装](#1.2 装配流水线:两条独立支线 + 一次组装)
- [1.3 实际打包命令(Windows)](#1.3 实际打包命令(Windows))
- [1.4 安装产物:程序本体与用户数据分两个目录(核心设计判断)](#1.4 安装产物:程序本体与用户数据分两个目录(核心设计判断))
- [2. 运行时:三个进程怎么配合](#2. 运行时:三个进程怎么配合)
- [3. Rust 壳层具体干了什么(进程编排,不碰业务)](#3. Rust 壳层具体干了什么(进程编排,不碰业务))
-
- [3.1 spawn sidecar 的关键细节](#3.1 spawn sidecar 的关键细节)
- [3.2 Rust 暴露给前端的 command](#3.2 Rust 暴露给前端的 command)
- [3.3 崩溃恢复:退避、兜底与"稳定即原谅"](#3.3 崩溃恢复:退避、兜底与"稳定即原谅")
- [4. 单实例、开机自启与自动更新](#4. 单实例、开机自启与自动更新)
-
- [4.1 单实例保护](#4.1 单实例保护)
- [4.2 开机自启:默认开启 + 用户选择三态记忆](#4.2 开机自启:默认开启 + 用户选择三态记忆)
- [4.3 自动更新:后台定时检查 + 手动检查 + 一条热更新链路](#4.3 自动更新:后台定时检查 + 手动检查 + 一条热更新链路)
- [5. 三进程通信机制](#5. 三进程通信机制)
- [6. Bun sidecar 内部装配(业务内核)](#6. Bun sidecar 内部装配(业务内核))
-
- [6.1 打包后与开发模式的区别](#6.1 打包后与开发模式的区别)
- [7. 生命周期总览(用户视角)](#7. 生命周期总览(用户视角))
- [8. 设计取舍与已知局限](#8. 设计取舍与已知局限)
- [9. 小结](#9. 小结)
P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看, 传送门https://blog.csdn.net/qq_74013365
先说个事儿。你写了个服务端程序,Bun 写的,TypeScript 写的,跑得好好的,忽然有一天领导飘过来说:"能不能做成双击就能用的桌面软件?"
你心里第一个念头是:我后端好不容易写完,你让我为桌面端重写一版?
不用。这就是今天要聊的事:怎么把一个 Bun 后端原样打包成 Windows 桌面软件,顺便把升级、卸载、崩溃恢复、自动更新这些"桌面软件该有的体面"一次配齐。
全程不涉及花哨算法,只涉及工程判断。什么叫工程判断?就是想清楚三个问题:为什么是三个进程而不是一个?为什么程序和数据要分两个目录?sidecar 崩了到底怎么办?
说白了,这不是造火箭,是当好三个进程的家长。
1. 核心设计
先给结论:Tauri 2 当外壳,Bun 后端原样编译成一个 sidecar 二进制塞进安装包,Vue 前端通过 localhost 的 HTTP/WebSocket 直连这个后端。
最核心的取舍是:不为桌面端重写业务。桌面和服务器跑的是同一份代码、同一个 gateway 入口。桌面端只是给它套了个窗口,外加一个"进程管家"。
好处是业务逻辑零分叉,桌面版和服务器版行为一致。坏处是运行时得协调三个进程,生老病死全归 Rust 壳层管。
给这三个进程排个角色:Rust 壳是房东,Vue 前端是装修,Bun 后端是大脑。房东不干活,只管房子和房客。
1.1 三层技术栈,各管一摊
前端(界面层) :Vue 3 + TypeScript + Vite,配 shadcn-vue/Tailwind 管 UI、Pinia 管状态、Vue Router 管路由。Vite 把它编译成 dist/ 里一坨纯静态 html/js/css。
为什么选 Vite?因为它的产物就是静态文件,任何 WebView 都能直接加载,不需要运行时。这一点很关键------你想想,如果前端产物还依赖 Node 环境,塞进桌面壳的那一刻你就想提交辞职信。
后端(业务内核) :TypeScript + Bun,用 @anthropic-ai/sdk 调 Claude,@modelcontextprotocol/sdk 做 MCP 客户端,Zod 校验、yaml 读配置。
关键动作是 bun build --compile:把 Bun 运行时 + 全部代码 + 依赖打成单个 exe(agenthub.exe),不依赖目标机器装 Bun 或 Node。
为什么后端要编译成独立 exe,而不是打包源码让壳去跑?因为终端用户机器上没有 Bun。你总不能安装的时候跟用户说:"亲,先装个 Bun 哦,装完记得配环境变量。"
--compile 把运行时一起塞进去,exe 自带解释器,双击即跑。这就是 Bun 相比"分发一堆 .js + 要求装 Node"的直接优势------相当于把厨房装进行李箱,到哪儿都能开火。
桌面壳(变桌面软件层):Tauri 2 + Rust。Tauri 不自带浏览器内核,而是复用系统的 WebView2(Windows)/ WKWebView(Mac)来渲染前端。
为什么选 Tauri 而不是 Electron?最直接的技术理由是产物体积和内存。Electron 每个 app 自带一整个 Chromium,上百 MB------相当于你出门背个厨房。Tauri 复用系统 WebView,壳是 Rust 编译的原生二进制,安装包能小一个数量级。
代价是 Windows 上依赖用户机器的 WebView2 Runtime。Win11 已内置,老系统需要预装------这是个真实的部署约束,不是纯优点。翻译一下:你在别人家做饭之前,得先确认人家厨房里有锅。
打包分发 :当前只出 Windows NSIS .exe 安装包,由配置 bundle.targets: "nsis" 单目标驱动,且 installMode: "currentUser" 按当前用户安装,不需要管理员权限;NSIS 还挂了一个自定义卸载钩子(installer-hooks.nsh,见后文自启一节)。
Mac 的 dmg 不再随打包产出------配置里仍留着 macOS.infoPlist 和 mac 版 sidecar 编译脚本,想打 Mac 包需手动把 targets 改回 "all"。嗯,Mac 用户:我们并没有忘记你们,我们只是暂时不想理你们。另外 createUpdaterArtifacts: true 会在打包时额外产出带签名的更新增量文件,供自动更新通道使用。
1.2 装配流水线:两条独立支线 + 一次组装
关键设计是前端和后端各自独立打包成"零件",最后由 Tauri 组装,而不是揉在一个构建步骤里:
┌──────────┐ ┌──────────┐ ┌───────────────────────┐ ┌────────────┐
│ Vue3 源码 │ ─▶ │ dist/ │ │ AgentHub.exe │ │ 安装包 │
│ + 配置 │ ① │ 静态网页 │ ─▶ │ = Rust 壳 + dist/ │ ④ │ .exe │
└──────────┘ Vite └──────────┘ │ + agenthub.exe │ NSIS └────────────┘
┌──────────┐ ┌──────────┐ │ + resources/ │
│ TS 后端 │ ─▶ │ agenthub │ ─▶ └───────────────────────┘
│ 源码 │ ② │ .exe │ ③ Tauri 组装
└──────────┘ Bun └──────────┘
三步组装由 Tauri 配置(desktop/src-tauri/tauri.conf.json)声明,它是"装配总指挥":
AgentHub.exe(最终成品)= Rust 壳程序
+ dist/ 前端网页(frontendDist: "../dist")
+ agenthub.exe(externalBin: ["binaries/agenthub"])
+ resources/(base.yaml、SOUL.md)
externalBin: ["binaries/agenthub"]------ 把 sidecar 二进制打进安装包(Tauri 按平台三元组自动匹配agenthub-x86_64-pc-windows-msvc.exe)resources: {...}------ 把config/base.yaml、config/SOUL.md作为默认模板一起打包frontendDist: "../dist"------ 指向前端产物目录
1.3 实际打包命令(Windows)
原理是 4 步,实操要敲的命令:
# 1. 根目录依赖(用 npm 不用 bun,见下方说明)
cd <项目根>
npm install
# 2. desktop 依赖
cd desktop
npm install
# 3. 编译 sidecar 二进制
# 产物:desktop/src-tauri/binaries/agenthub-x86_64-pc-windows-msvc.exe
cd ..
bun run build:sidecar:win
# 4. 打包(= tauri build:编 Rust 壳 + 出 NSIS 安装包)
cd desktop
npm run build
sidecar 编译脚本共四条分工(根 package.json):build:sidecar 按 os.platform() 自动分发(darwin 走 mac-arm,其余走 win),build:sidecar:mac-arm / build:sidecar:mac-x64 是 Mac 两个架构,build:sidecar:win 是 Windows;另有一键命令 build:desktop(= build:sidecar + desktop build),把第 3、4 步合成一条。
第 4 步内部发生了什么 :npm run build = tauri build。tauri build 启动后,按配置里的 beforeBuildCommand: "bun run build:vite" 自动跑前端打包 (vite build → dist/),再编 Rust 壳、出安装包。所以前端只打一次,不需要手动先 vite build------这是 Tauri 的 build hook 机制,懒人的福音。
注意一个看似矛盾的点:装依赖用 npm,但 tauri 的 hook 里仍用 bun 跑 vite。两条命令走不同运行时,这是刻意的、不冲突------原因见"设计取舍与已知局限"一节。
为什么装依赖用 npm install 而不是 bun install :Windows Defender 实时扫描会持有 bun 写入的临时文件句柄,导致 bun 报 EPERM: Operation not permitted。翻译一下:Defender 像个多管闲事的保安,bun 刚把文件放下,它就一把攥住说"你没权限",你明明有权限。规避方式两种:用 npm 装依赖(绕道走);或给 Defender 加排除项:
Add-MpPreference -ExclusionPath "C:\Users\<用户名>.bun"
Add-MpPreference -ExclusionPath "<项目目录>"
构建环境要求:
| 工具 | 版本 | 用途 |
|---|---|---|
| Bun | >= 1.3 | 运行时 + 编译 sidecar |
| Node.js | >= 18 | desktop 前端依赖 |
| Rust | >= 1.80 | 编译 Tauri 壳 |
| VS Build Tools | 2019/2022 | Rust Windows 编译链(勾 "Desktop development with C++") |
| WebView2 Runtime | 最新 | 打包机通常已有,终端机需预装 |
产物路径 :desktop/src-tauri/target/release/bundle/nsis/AgentHub_<版本>_x64-setup.exe,版本号跟随 tauri.conf.json 的 version 字段(当前为 1.0.6,即 AgentHub_1.0.6_x64-setup.exe),同时产出 .sig 更新签名文件。首次 Rust 全量编译约 5--15 分钟------这 15 分钟里你可以把一年份的咖啡喝完,然后发现还没编完。
1.4 安装产物:程序本体与用户数据分两个目录(核心设计判断)
这是这层最值得讲的设计取舍。装完后,**程序本体(只读)和用户数据(可写)**放在两个物理隔离的目录:
安装目录(程序本体,只读) ------ 如 D:\AgentHub:
D:\AgentHub\
├── AgentHub.exe ← Rust 主进程(Tauri Shell,外壳)
├── agenthub-x86_64-pc-windows-msvc.exe ← Bun 编译的 sidecar(业务内核,bundle 后带 target triple 后缀)
├── resources\
│ ├── base.yaml ← 默认配置模板
│ └── SOUL.md ← 人格定义模板
└── icons\ ← 窗口/托盘图标
工作目录(用户数据,可写) ------ %APPDATA%\com.agenthub.desk(由 tauri.conf.json 的 identifier 决定这层子目录名;换 Windows 用户登录,用户名变但这层不变,每个系统用户独立一份):
com.agenthub.desk\
├── .env ← 敏感配置(配置页保存后写这里,注意在工作目录根)
├── config\ ← 启动即有(init_workspace 从 resources 拷入)
│ ├── base.yaml ← 配置基线,运行时读这个(不是安装目录的模板)
│ └── SOUL.md ← 人格定义(同样从 resources 拷入)
├── data\ ← per-agent 会话与记忆(见下方说明)
│ └── <agentName>\
│ ├── sessions<session_key>.jsonl ← 该 agent 的会话历史
│ └── memory\MEMORY.md ← 该 agent 的 L3 记忆
├── auth\
│ └── USER.md ← 用户画像(全局共享,不分 agent)
├── agents\ ← 各 agent 的配置目录(agent.json + SOUL.md)
├── workspace\ ← Agent 工作区(default agent 用 workspace,其他用 workspace_<name>)
├── logs\
│ └── gateway.log ← sidecar 业务日志(入口处 setLogFile 指向 logs/)
├── init.log ← ★ 在根目录,不在 logs/。workspace 初始化记录
├── sidecar-debug.log ← ★ 在根目录。sidecar 的 stdout/stderr
└── tauri-debug.log ← ★ 在根目录。Rust 侧 spawn 诊断
关于 data/<agentName>/:AgentHub 是多 agent 架构 ------同一份程序里可以并存多个可配置的 agent 实例(某业务团队的审核助手、咨询助手各是一个),每个 agent 有独立的会话历史和记忆,所以会话/记忆按 data/<agentName>/ 分桶存放。
USER.md(用户画像)是例外,放 auth/ 下全局共享,因为所有 agent 服务的是同一个用户,画像不该分裂。你可以分裂人格,但别分裂画像。
按需生成(用到才出现):
| 文件/目录 | 何时生成 | 说明 |
|---|---|---|
config/base.local.yaml |
开发模式下手工建的本地覆盖 | 深度合并到 base.yaml 之上;仅开发模式加载,桌面端打包后跑 production 环境不读它 |
.env |
配置页保存了敏感字段(API key 等) | 真实值写 .env(工作目录根),config/base.yaml 里始终写回 ${VAR} 占位 |
data/<agent>/memory/MEMORY.md |
L3 记忆处理器成功跑完一次 | LLM 整理会话后写;调用失败则不生成 |
auth/USER.md |
L3 处理器整理用户画像后 | 全局一份 |
workspace/backups/ |
write_file 覆盖了授权目录下已存在文件 |
自动备份,写 workspace 不备份 |
workspace/email-attachments/ |
read_email 读了带附件的邮件 |
附件落 <uid>/ 子目录 |
cron/jobs.json |
用 manage_cron_jobs 建了定时任务 |
或手工编辑 |
为什么要分两个目录 ------ 答案是两类文件的生命周期完全不同:
| 关注点 | 安装目录 | 工作目录 |
|---|---|---|
| 谁写 | 安装器 | 程序运行时 |
| 可写性 | 只读 | 用户可写 |
| 升级时 | 整体替换 | 保留 |
| 卸载时 | 删除 | 保留 |
| 放什么 | 二进制 + 静态资源 | 用户数据 + 日志 |
核心结论:安装目录的 resources/base.yaml 是模板,升级会被覆盖;运行时真正读的是工作目录的 config/base.yaml(首次启动从模板拷来,之后用户改动都落这里)。
如果程序和数据混在一个目录,升级替换程序时就会连带冲掉用户配置和会话------分目录是为了让"升级/卸载安装目录"这个高频动作永远碰不到用户数据。就像分手之后,前任搬走了,但你的聊天记录还在,这就是体面。
配置页保存时也走这套边界:非敏感字段 read-merge-write 进工作目录的 config/base.yaml,敏感字段(API key)真实值写工作目录根的 .env,yaml 里只留 ${NAME}_API_KEY 占位符------密钥永远不落进可能被升级覆盖或随包分发的 yaml。简单说:钥匙不放在门垫底下,也不放在快递箱里。
首次启动由 Rust 的 init_workspace 从 resources 拷 base.yaml/SOUL.md 到工作目录 config/,已存在则跳过 (保护用户改动)。另含一个 macOS 专属逻辑:从旧 identifier 目录迁移 .env 到新工作目录,是 identifier 变更后给老用户留的数据衔接。Mac 用户感动了,虽然我们暂时不想理他们,但退路是留好的。
2. 运行时:三个进程怎么配合
用户双击 AgentHub.exe 后,实际跑起来的是三个进程:
用户双击 AgentHub.exe
│
▼
┌──────────────────────────────────────────────────┐
│ Rust 壳程序启动(AgentHub.exe) │
│ ① 开桌面窗口 │
│ ② 把 dist/ 网页塞进窗口(WebView 渲染)← 界面 │
│ ③ 悄悄启动后台 agenthub.exe(gateway 模式)← 大脑 │
└──────────────────────────────────────────────────┘
│ │
│ 界面点按钮 │ 后台开 127.0.0.1:8900
▼ ▼
┌─────────────┐ HTTP/WS 请求 ┌──────────────┐
│ Vue 前端 │ ──────────────▶ │ agenthub │
│ (WebView) │ ◄────────────── │ (Bun 后端) │
└─────────────┘ 返回数据 └──────────────┘
│
▼
调 Claude AI / 调工具(文件/Shell/邮件/Skill)/ 存会话/记忆(per-agent)
| 进程 | 角色 | 干什么 |
|---|---|---|
| Rust 壳(AgentHub.exe) | 房东 | 开窗口、管后台进程生死、托盘、SSO、自动更新 |
| Vue 前端(WebView) | 装修 | 用户看到的界面,点按钮发请求 |
| Bun 后端(agenthub.exe) | 大脑 | 干所有活:调 AI、存数据、接渠道、跑各 agent |
关键取舍:前端和后端不在一个进程,前端通过 HTTP/WebSocket 直连 127.0.0.1:8900,Rust 不转发业务数据。
为什么前端不经 Rust 转发、而是直连 sidecar 端口?因为后端本来就是个完整的 HTTP 服务(服务器部署时企微/飞书/HTTP API 都连它)。桌面端复用同一个 HTTP 接口,前端就是又一个 HTTP 客户端,Rust 不用为业务 API 再写一层转发胶水。Rust 只管进程级操作(重启/停止/SSO/开文件夹),这些才走 Tauri command。
职责边界清晰:业务走 HTTP,进程管理走 command。翻译一下:快递员送快递直接送到你家,不用先去物业办公室报到。
3. Rust 壳层具体干了什么(进程编排,不碰业务)
Rust 一行业务逻辑都不写,只做进程编排。理解它"只当进程管家"是理解整个架构的关键:
| 职责 | 实现 |
|---|---|
算工作目录 = app_data_dir() |
决定 sidecar 把数据写到哪 |
| 首次启动从 resources 拷 base.yaml/SOUL.md | init_workspace,已存在则跳过 |
spawn 前先 taskkill 清理残留 sidecar |
kill_sidecar_by_name,防上次异常退出留下孤儿进程占着 8900 |
spawn sidecar:agenthub.exe gateway |
spawn_with_monitor,传 AGENTHUB_WORKSPACE + AGENTHUB_ENV=production |
| sidecar 崩溃自动重启 | 监控线程 500ms 轮询,指数退避 + 60s 兜底 + 5 分钟稳定重置(见下) |
| Windows 托盘、关窗=隐藏到托盘 | 托盘菜单 + CloseRequested 事件拦截 |
| 单实例保护 | tauri_plugin_single_instance,第二个进程唤起已有窗口后退出 |
| 开机自启 | tauri_plugin_autostart + get/set_autostart command |
| 自动更新 | tauri_plugin_updater + updater 模块(见第四节) |
| SSO 子窗口 + ticket 拦截 | open_sso_login,子 WebView 导航拦截 ticket |
| 调 explorer/open 打开文件夹 | open_path |
| dev 模式下 8900 已被占则跳过 spawn | tauri::is_dev() && port_is_open(8900),不抢外部进程 |
| 启动窗口按显示器工作区自适应收缩居中 | 解决高 DPI 屏固定 1200x800 底部出屏:宽取 min(1200, 工作区宽 95%),高取 min(800, 工作区高 90%) |
3.1 spawn sidecar 的关键细节
rust
let child = cmd
.arg("gateway") // 跑完整网关模式
.env("AGENTHUB_WORKSPACE", &workspace) // 告诉后端数据写哪
.env("AGENTHUB_ENV", "production")
.stdin(Stdio::null())
.stdout(log_file) // 重定向到 sidecar-debug.log
.stderr(log_file2)
.spawn()?;
- sidecar 路径按 target triple 探测候选:优先
agenthub-<arch>-<triple>.exe(与 tauri bundle 对 externalBin 的命名一致),找不到回退裸名agenthub.exe,两个 exe 与主程序平级;全部候选都不存在时,把尝试过的路径写进 sidecar-debug.log 再报 NotFound------不再是静默拼单一文件名,排查 spawn 失败有迹可循 - 工作目录通过
AGENTHUB_WORKSPACE环境变量传给 sidecar ------ 这是 Rust 和 Bun 之间唯一的"数据写哪"约定 CREATE_NO_WINDOW(0x08000000)确保 sidecar 不弹黑色控制台窗------不然用户每次启动都以为电脑中了什么病毒,屏幕上多出一个来历不明的黑框框- 绕过
tauri_plugin_shell,改用原生std::process::Command------ 这是个踩过的坑:用 Tauri 官方 shell 插件 spawn Bun 二进制会让它挂起,换原生 Command 才正常。代码里保留着明确注释(bypasses tauri_plugin_shell which causes Bun to hang)。官方文档说"用这个插件",现实说"别用",你听现实的,注释里还留了句吐槽,很有职业道德
3.2 Rust 暴露给前端的 command
restart_gateway 重启 sidecar(kill → 重置计数 → 重新 spawn)
stop_gateway 停止 sidecar
is_managed 查询是否被 Rust 托管
get_autostart/set_autostart 查询/开关机自启(dev 构建禁止开启)
open_sso_login 打开 SSO 登录子窗口
clear_sso_session 清除 SSO 会话(隐藏 WebView 访问 logout URL,3 秒后自动关窗)
open_path 调系统资源管理器打开文件夹
get_app_version 当前版本号(编译期从 tauri.conf.json 注入)
check_for_update 手动检查更新(只查不装)
download_and_install_update 下载并安装更新(见第四节)
open_sso_login 打开的 SSO 登录 URL 带回调参数 &target=http://127.0.0.1:8900/login-callback:登录成功后 SSO 服务端把 ticket 拼到这个 target 上跳转,子窗口的导航拦截捕获 ticket、emit("sso-ticket") 给前端并关窗。像一个经过精密计算的暗号:票拿到了,暗门自己关上。
3.3 崩溃恢复:退避、兜底与"稳定即原谅"
监控线程每 500ms try_wait() 一次 sidecar 进程,状态机如下:
- 进程还活着 → 继续轮询
- 进程退出且
managed == true(不是主动停的)→ 记一条[terminated] uptime=<秒> restart_attempt=<N>到 sidecar-debug.log,然后按下面的策略决定何时拉起:- 本次运行时长 ≥ 300 秒 → 先把
restart_count清零。意思是"稳定跑了 5 分钟以上才死,属于偶发崩溃(端口冲突/OOM/被杀),快速重试额度视为已恢复"------能跑五分钟才死,说明不是启动的问题,是意外 - 额度内(第 1--3 次)→ 指数退避 1s / 10s / 30s 后重新 spawn
- 超过 3 次 → 降为每 60 秒低频兜底重试,不再永久放弃
- 本次运行时长 ≥ 300 秒 → 先把
- respawn 本身失败(比如二进制丢失这类硬错误)→ 写日志放弃,不再空转
managed 这个 AtomicBool 是关键:主动 stop_gateway/restart_gateway 时先置 managed=false,监控线程看到就知道"这是人为停的,别自动拉起来",避免和主动操作打架。相当于给门贴了张"人不在家,别敲门",快递小哥(监控线程)看一眼就懂了。
三个策略各有明确动机,都是从真实故障模式倒推的:
- 为什么退避而不是立刻连试:启动即崩的典型原因是 8900 端口被占,1 秒内连试三次只会烧光额度,等端口释放才能成功。就像追一个正在气头上的人,你隔一秒发一条"在吗",只会被拉黑
- 为什么 60s 兜底而不是放弃:sidecar 死亡对用户是静默的,界面上只是请求全部失败,没有兜底意味着用户必须手动重启整个应用。它不是不救,是改成慢节奏地救
- 为什么 5 分钟稳定就重置额度:如果不重置,一个白天偶发崩一次的进程会慢慢耗尽额度,最后落进 60s 慢速通道------而它本该享受快速恢复。这就叫"稳定即原谅",比某些公司的团建制度还通情达理
4. 单实例、开机自启与自动更新
这三件事是"桌面软件"区别于"网页"的标配,壳层一次配齐。网页不配,网页刷新就完事了。
4.1 单实例保护
tauri_plugin_single_instance:第二个进程启动时,唤起已有实例的主窗口(show + unminimize + set_focus)后自己退出,避免同时跑两个壳、两个 sidecar 抢 8900。翻译:新来的看到屋里有人,默默把门带上走了,绝不挤进去。
一个容易踩的实现细节:这个插件必须第一个注册。它靠初始化顺序抢占单实例锁,如果注册晚于其他插件,锁竞争失败时仍会走完整 setup 流程(杀 sidecar + spawn),保护就失效了------代码里有注释专门标注这一点。抢锁这种事,晚一步就是晚一步,跟抢演唱会门票一个道理。
4.2 开机自启:默认开启 + 用户选择三态记忆
自启用 tauri_plugin_autostart 实现(写注册表 Run 键),暴露 get_autostart/set_autostart 两个 command。两条规则:
- dev 构建禁止开启自启:dev 的 exe 路径注册进 Run 键,会让每次开机拉起开发构建。开发构建开机自启,那是给电脑上刑
- 用户选择三态记忆 (前端
desktop/src/lib/autostart.ts):null(用户从未碰过开关)= 首次启动自动开启;"on"/"off"(用户手动设置过)= 永不自动改写。main.ts启动时调ensureDefaultAutostart(),只有用户从没表达过意愿时才替他打开------尊重已有关闭选择,又不至于让"默认常驻"的功能没人发现。翻译:你不表态我就当你同意,你表态了我就不碰你的选择,比某些 APP 的"默认勾选同意协议"有良心多了
自启还牵出一个卸载清理问题 :Run 键是应用运行时写的(值名 = productName),NSIS 卸载器不认识它,不清理的话卸载后每次开机都会报"找不到 exe"。所以 installer-hooks.nsh 里挂了 NSIS_HOOK_POSTUNINSTALL 钩子,卸载时显式删掉这条注册表值。这是"装的时候顺手开的开关,卸的时候要负责关掉"的完整闭环。翻译:你把人请进门,分手的时候得把人家留下的钥匙拿走,别让前任的钥匙永远挂在你家门口。
4.3 自动更新:后台定时检查 + 手动检查 + 一条热更新链路
Tauri 的 updater 插件负责签名校验和下载安装,配置在 tauri.conf.json(createUpdaterArtifacts: true 打包时产出更新产物 + minisign 公钥 + 内网更新源)。业务逻辑在 desktop/src-tauri/src/updater.rs,分三条路径:
后台定时检查 (仅生产模式,dev 跳过避免开发期误触):启动后延迟 30 秒 首次检查,之后每 4 小时 一次。发现新版 emit("update-available"),前端弹更新对话框。
两个容易被忽略的细节:
- 为什么延迟 30 秒 :setup() 执行时 webview 还没加载,前端的
listen("update-available")要等 App onMounted 才注册,立即检查会在监听注册前 emit,通知丢失,用户得干等 4 小时------30 秒让前端先就绪。翻译:打电话之前得先等对方接起来再说,不然你对着空气说了半天"有新版本了",对面一句没听见 - 为什么用
std::thread + sleep而不是前端 setInterval:托盘隐藏/系统休眠时前端定时器不可靠,且 sleep 在休眠后顺延而非补偿触发,对"每 4h 复查"这个精度足够
手动检查 (check_for_update command,配置页按钮):只查不装,返回 UpdateInfo 结构。关键设计是 check_failed 字段------更新源不可达时返回"检查失败",与"确认无新版"区分开,避免把网络故障误报成"已是最新版本"。这个细节很良心:断网的时候它跟你说"检查失败",而不是像某些软件一样假装"已是最新"。dev 模式直接返回无更新,前端按钮不用按 dev 状态分支处理。
下载安装 (download_and_install_update command),是一条带失败恢复的链路:
- 置
updating = true(此后CloseRequested事件拦截关窗,emit("update-close-blocked")提示用户稍候) - 先停 sidecar ------Windows 上更新要替换安装目录的 exe,sidecar 二进制被占用会导致安装失败;杀完立即释放锁,不持锁跨下载
- 下载安装:
on_chunk回调节流 100msemit("update-progress")(逐 chunk emit 高频事件 + CSS transition 跟不上会让进度条抖动),总大小来自响应头 Content-Length;下载完成emit("update-installing"),让前端切到"正在安装"态而不是僵在 100% - Windows 上安装成功后,安装器会自动退出并重启应用(插件
restart_after_install默认开启),Rust 侧无需再调app.restart() - 失败路径 :复位
updating(避免卡 true 关不了窗)、把第 2 步杀掉的 sidecar 复活 ------不复活的话界面还在但 8900 已死,聊天/配置全挂只能重启应用;最后emit("update-error"),错误按是否签名问题分类(signature/other),前端给出不同的提示文案
这条链路最打动我的是第 5 步:更新失败不是躺平,是把刚才亲手掐死的同事再人工呼吸救回来。够意思。
5. 三进程通信机制
┌──────────────┐ invoke() ┌──────────────┐ HTTP/WS ┌──────────────┐
│ WebView │ ──────────▶ │ Rust 主进程 │ │ Bun sidecar │
│ (Vue3 前端) │ │ (Tauri 壳) │ ◄────────▶ │ (:8900) │
│ │ ◄────────── │ │ │ │
└──────────────┘ emit() └──────────────┘ └──────────────┘
│
│ HTTP/WS 直连 :8900(前端 fetch,绕过 Rust)
└────────────────────────────────────────────────────────────────┘
| 通信方向 | 走什么通道 | 管什么 |
|---|---|---|
| 前端 → Rust | Tauri command(invoke) |
进程级:restart/stop/自启/SSO/open_path/更新 |
| 前端 → sidecar | HTTP/WebSocket 直连 127.0.0.1:8900 |
业务数据:REST API + /ws/logs 日志流 |
| Rust → sidecar | 原生进程管理(spawn/kill) | 无业务数据通道 |
| Rust → 前端 | emit(sso-ticket/update-available/update-progress 等) |
推票据、更新进度等事件 |
三条通道各管一摊,互不越界------这种"进程管理走 command、业务走 HTTP、事件走 emit"的三分法,是判断某个新功能该走哪条通道的依据。通道之间的界限比很多公司的部门墙还清晰:业务不会去 command 通道串门,进程管理也不会去 HTTP 通道兼职。
6. Bun sidecar 内部装配(业务内核)
agenthub.exe gateway 启动后的装配顺序。这里体现了多 agent 架构------运行时对象不再是全局单例,而是每个 agent 一套:
loadConfig(workspace) 读工作目录 config/base.yaml + .env
↓
构建共享工具池 builtinToolsMap(内置)+ mcpToolsMap(MCP),全局构建一次
↓
GatewayCore + MessageBus 持有两个异步队列(渠道 ↔ Agent 解耦)
│ dispatch 内部:找 agent(agent_hint 直取 / routeAgent 两轮匹配)
│ → 标准化 → 命令路由(3 级:priority/exact/prefix)
│ → 并发控制 → Mid-turn Queue
↓
AgentRegistry.create(...) 加载所有 agent,每个 agent 一个 AgentEntry
│ AgentEntry 内含该 agent 专属的 loop / toolRegistry /
│ contextBuilder / consolidator / provider ------ 不再全局单例
↓
ChannelManager CLI/HTTP 全局单例;企微/飞书按 active agent 的
channel binding 多实例注册(register(channel, agentName))
↓
后台调度器 Heartbeat / Cron / MemoryProcessor / AutoCompact
(Heartbeat/MemoryProcessor 遍历 active agent;
Cron 按 job 自带 agent 字段;
AutoCompact 遍历 data/ 所有 agent 目录不区分 active)
启动后在 127.0.0.1:8900 监听 HTTP + WebSocket。
这层跟"打包"关系不大,但要点出装配出来的不是单个 agent,而是一个 AgentRegistry 管着多个 agent 。工具池(builtinToolsMap/mcpToolsMap)是全局构建一次共享的,每个 agent 的 ToolRegistry 只是从共享池按自己的白名单过滤挂载------不是每个 agent 复制一份工具代码。这个"共享池 + per-agent 过滤视图"的设计,是多 agent 内存开销可控的原因。翻译:一个厨房配一个公共灶台,每个厨师只拿自己菜谱上要用的那几把刀,而不是每人背一整套厨具上班。打包/进程编排层不感知这些,Rust 只管拉起 gateway 进程,进程内是单 agent 还是多 agent 是 Bun 侧的事。
6.1 打包后与开发模式的区别
打包后 sidecar 仍执行后端入口的 gateway 分支,与开发时 bun run gateway 完全一致。区别只有三点:
| 区别点 | 开发模式 | 打包后 |
|---|---|---|
| 入口 | 命令行 bun run gateway |
被 Rust 主进程 spawn |
| workspace | 项目根目录 | %APPDATA%\com.agenthub.desk(由 AGENTHUB_WORKSPACE 指定) |
| env | development | production(AGENTHUB_ENV=production) |
这个"三点差异之外完全一致"是整个桌面端设计的落脚点:开发时怎么跑,打包后就怎么跑,只是换了个数据目录和环境标记。翻译:同一个员工,平时在办公室上班,出差就在酒店上班,干的活一模一样,只是工位变了。
7. 生命周期总览(用户视角)
| 用户动作 | 发生的事 |
|---|---|
| 双击安装包 | NSIS 按当前用户解压到安装目录,sidecar.exe + resources 落地 |
| 启动 AgentHub | 单实例检查 → Rust spawn sidecar(gateway,workspace=APPDATA),sidecar 在 :8900 起服务,前端连上 |
| 关主窗口(Win) | 隐藏到托盘,sidecar 继续跑(服务不中断);更新进行中则拦截关窗并提示 |
| 托盘「退出」 | quitting=true → shutdown_sidecar(kill child + taskkill 兜底)→ app.exit(0) |
| sidecar 崩溃 | 监控线程 500ms 检测,1s/10s/30s 退避重启;稳定 5 分钟后额度重置;额度耗尽转 60s 低频兜底,不放弃 |
| 仪表盘点「重启」 | restart_gateway:kill → 重置计数 → 重新 spawn |
| 后台/手动检查到新版 | 弹窗 → 确认后停 sidecar → 下载(进度事件)→ 安装 → 安装器自动重启应用 |
| 升级/卸载 | 安装目录被替换,工作目录在 APPDATA,用户数据/会话/记忆/配置保留;卸载钩子顺带清掉自启 Run 键 |
Windows 上"关窗=隐藏到托盘"而非退出,是刻意的:后台 sidecar 还在跑定时任务和 AI 巡检,关窗就停服会让这些静默任务失效。只有托盘菜单「退出」才真正走 shutdown_sidecar。macOS 行为不同(关窗直接停 sidecar)。
翻译一下:Windows 上你以为它睡了,其实它还在被窝里刷手机;macOS 上你一关窗它当场断电。两个平台,两种人生。
8. 设计取舍与已知局限
最后把几个"为什么"和"哪里不完美"集中说清楚。好东西不怕亮底牌。
为什么选 Tauri 而不是 Electron? 用一点部署约束换体积和内存:Electron 每个 app 自带整个 Chromium(上百 MB、常驻内存高),Tauri 复用系统 WebView、壳是 Rust 原生二进制,安装包小一个数量级;代价是 Windows 上需要 WebView2 Runtime(Win11 内置,老系统要预装),等于把一部分环境依赖甩给了用户机器。这不是纯粹的"Tauri 更好",而是一笔明确的交换。
为什么装依赖用 npm,但跑 vite 又用 bun? 刻意分开的。装依赖用 npm 是为了规避 Windows Defender 的问题:Defender 实时扫描会持有 bun 写入的临时文件句柄,导致 bun install 报 EPERM。跑 vite 用 bun 则没这个问题(不涉及大量小文件的并发写入),而且 Tauri 的 beforeBuildCommand 里配的就是 bun run build:vite。两条命令各自选了没坑的那个运行时。另一个规避方式是给 Defender 加 .bun 目录和项目目录的排除项。
这套架构目前已知的不彻底之处,几个真实存在的:
- 两个平台生命周期语义不一致 。Windows 托盘和"关窗=隐藏"是
#[cfg(target_os = "windows")]门控的,macOS 走的是关窗直接停 sidecar,分平台维护两套语义。同一个产品,Windows 像住酒店,macOS 像住胶囊旅馆 - spawn sidecar 绕过了 Tauri 官方的
tauri_plugin_shell。官方插件会让 Bun 编译产物挂起,改用原生std::process::Command才正常------官方插件对 Bun 产物的兼容还不完善,代码注释里明确标注了这个规避 - 端口是写死的 8900。如果端口被占(比如本机跑着开发用的 gateway),桌面端会和它冲突,目前靠启动时 taskkill 残留进程兜底。更稳的做法是端口动态分配 + Rust 把实际端口 emit 给前端。写死端口就像门牌号写死 8900 号,隔壁搬来一个也住 8900 的,你们就得打一架
- 三个调试日志(init/sidecar-debug/tauri-debug)散在工作目录根 ,没有轮转,长期运行会无限增长,可以收敛到统一的
logs/下加轮转。日志不轮转,就像垃圾桶不收,时间久了家里全是垃圾
9. 小结
NSIS 把 Rust 壳 + Bun 核 + Vue 脸打包到安装目录(per-user 安装);Rust 启动后先清残留再 spawn Bun 核,用环境变量把数据指向 %APPDATA% 工作目录;Vue 脸直连 Bun 核的 :8900;三者靠 localhost HTTP/WS + Tauri command + emit 三条通道各管一摊。壳层除了管进程生死(退避重启 + 稳定重置),还配齐了桌面软件三件套------单实例、自启(带卸载清理)、自动更新(后台定时 + 手动检查 + 失败回滚)。后端在桌面和服务器上跑的是同一份 gateway 代码,只有数据目录和环境标记不同。
一句话总结:把 Bun 后端变成桌面软件,核心不是写桌面,是当好三个进程的家长------管住它们的生死,分清它们的家产,还得在它们崩溃的时候,装作什么都没发生地把它拉起来。
P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看,传送门https://blog.csdn.net/qq_74013365