Windows 客户端有两种做法。
一种是把前端打进安装包,装完之后本地有一份完整页面。另一种是桌面端只做壳,窗口加载远端地址。这条线选了后者。
选择的结果是:7 月 7 日一天发了两个版本(1.0.19、1.0.20),两次都只动了 apps/desktop 自己的代码。
一、壳里没有页面
最直接的证据是渲染进程的产物。
electron-vite 的渲染进程配置指向 src/renderer/index.html,而这个文件全部内容是:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>创玩猩球</title>
</head>
<body>
<div id="app">Loading...</div>
</body>
</html>
没有 <script>。
构建产物三个文件:out/main/index.js(884K)、out/preload/index.cjs(4K)、out/renderer/index.html(147 字节)。
全仓搜索 loadFile / file://,apps/desktop/src/ 下没有任何一处加载本地 renderer。主窗口创建完就一件事:
void win.loadURL(cfg.webUrl);
打包配置里的文件白名单也只列了三项:out/**/*、resources/**/*、package.json。前端产物不在其中——不是通过某个「排除 web」的配置项排除掉的,是从来没被放进来过。
二、换来的东西
web 发版不动桌面端。 页面改动走云端发版,用户下次打开就是新版本,不需要装任何东西。这也是 7 月 7 日能一天发两版的原因:两次改动都只涉及桌面端自己的代码。
更新包小。 OTA 只依赖 apps/desktop 源码加一个协议包。更新包的内容就是主进程、预加载、几个本地二进制。
两端的版本可以独立走。 没有「前端必须等桌面端一起发」这种依赖。
三、代价
离线完全不可用。 桌面端的 README 里,「暂未实现」清单下有一行:
- 离线模式与本地回放
没有本地缓存页、没有离线兜底。断网时窗口是空的。
首屏依赖网络。 主窗口的加载失败处理只做了一件事——标记这次启动是成功的:
win.webContents.on("did-fail-load", (_e, _code, _desc, _url, isMainFrame) => {
if (isMainFrame) launchGuard.markLaunchSucceeded();
});
这个设计的用意是区分「渲染进程起不来」和「网页加载失败」:网页加载失败说明沙箱正常,不该触发降级。但它也意味着主窗口没有错误页——加载失败时用户看到的是一块白窗口。
对比之下,agent 的内置浏览器有兜底页,注释写得很清楚:
// 加载失败/渲染崩溃时展示的兜底页:把失败原因和 URL 明明白白写出来,
// 取代过去那块「无提示纯白窗口」。
主窗口这一条还没补。
两端版本要各自维护兼容边界。 这条是最不确定的。目前桌面端和 web 之间只有三个地址契约(webUrl、apiBase、wsUrl),没有版本号交换,也没有「桌面端版本过低就拒绝服务」的门槛。
设备注册时会上报 appVersion,云端落库——但它的用途是记录,不是校验。兼容靠的是「能力清单 + 可选字段」:老版本不带某个字段时,新接口按缺省处理。协议包里有对应的测试:
it("没有工作目录时不带该字段,老桌面端行为不变", ...)
测试清单里有一条 P1 是「旧桌面端连新后端不崩」。这条列在那里本身就说明它还没有被系统验证过。
四、壳里真正装的是什么
如果桌面端什么都不装,它就没必要存在。装的是三类本地运行时。
Python 运行时。 打包一份 Python 3.12.7 的 embed 版本,让 AI 的 python xxx.py 在任何客户机器上都能跑,不需要客户自己装 Python。
它必须走 extraResources 而不是进 asar,配置里写了原因:
// 自带 Python 运行时:二进制必须走 extraResources(解压到 app/resources 下、不进 asar 才能执行)。
// 由 scripts/fetch-pyruntime.mjs 预先下载到 resources/pyruntime/<平台>/。
运行时目录从安装目录解析,也不可能从网络取:
const base = join(resourcesPath, "pyruntime", sub);
const exe = process.platform === "win32" ? join(base, "python.exe") : join(base, "bin", "python3");
if (!existsSync(exe)) return null;
VC++ 运行库。 安装期静默装,已装则跳过,避免多余的 UAC 弹窗。
Electron 自己的运行库。 有一个设计文档专门钉了一份「核心文件清单」:主程序 exe、ffmpeg.dll、chrome_elf.dll、resources/app.asar,检查发生在安装包解包完成之后、启动新程序之前。
这份清单来自一次真实故障:用户从旧版自动更新之后,启动时报「找不到 ffmpeg.dll」。安装包里其实有这个文件——错误发生在 Electron 主程序加载阶段,那时应用自己的错误处理还没机会运行,所以只能靠安装器检查。
这三类东西的共同点是它们不能从远端加载。Python 解释器、运行库 dll、Electron 自身,都必须在本地文件系统上。这才是壳存在的理由,不是为了渲染页面。
五、发版链路
四个步骤,脚本里编号就是四段:
# [1/4] 同步源码(只覆盖源码,保留 node_modules / pyruntime / dist)
# [2/4] 构建 dist:win(含 pyruntime、签名)
# [3/4] 校验 latest.yml 版本 == $VERSION
# [4/4] 上传 OTA
为什么是「scp 源码到构建机」而不是在构建机上 clone。 构建机无法非交互拉取私有仓库(HTTPS 没有 tty,SSH 公钥没授权),所以从工作机把源码推过去,在一个「依赖已装、运行时齐全、密钥在系统环境变量」的固定构建目录里出包。
为什么走镜像。 electron-builder 在国内拉 GitHub releases 会超时,报的是 Timeout awaiting 'request' for 600000ms。构建前设两个镜像环境变量绕开。
版本校验有两道。 第一道在脚本里,出包之后比对产物清单里的版本和目标版本,不符就中止,不上传:
[[ "$ONVER" == "$VERSION" ]] || { echo "产物版本($ONVER) != 目标($VERSION),中止上传。" >&2; exit 1; }
第二道在产物选择里:只允许当前版本的三件套(安装包、blockmap、清单)上传,缺任何一个直接抛错。这一道是为了处理「dist 目录不清、旧版安装包残留」的情况。
客户端侧。 更新走 electron-updater 的 generic provider,指向一个静态目录:
publish: [{ provider: "generic", url: updateUrl }],
启动时拉清单文件、比较版本、下载、静默安装。
六、版本号的唯一来源
版本号只在 apps/desktop/package.json 一处。产物名和清单里的版本都由它决定。
与之配套的一条纪律:绝不能复用旧版本号。客户端判断「要不要更新」靠的是版本比较,版本号相同就等于没有新版本,用户那边什么都不会发生——而这种失败是静默的,服务端看到的是上传成功。
脚本的这条判断还有一个前置的护栏:工作区有未提交的已跟踪改动时拒绝发版。因为发出去的是工作区源码,不是某个 commit。
七、一处没对齐的地方
签名。桌面端 README 写的是:
当前 Windows 安装器未接入 CA 代码签名,用户首次安装可能看到 SmartScreen 提示。
拿到证书后再通过 WIN_CSC_LINK / WIN_CSC_KEY_PASSWORD 接入签名流程。
而发版 skill 里写的是构建机已配 signtool、产出无 SmartScreen 强拦。两处说法不一致,仓库里也搜不到任何签名实现——那两个环境变量只出现在这一行文档里。
这两句话都写在 7 月上旬,中间没有改动过配置。真实状态是:安装包没有 CA 签名,首次安装会出现 SmartScreen 提示。文档里那句「无强拦」应该改掉。
八、这条路线适合什么
选它之前要接受三个前提:用户始终在线、接受首次打开有网络等待、接受两端各自演进。
换来的是发版节奏的自由:改一个页面不需要用户装新版本,改壳里的东西不需要等前端一起走。前者让迭代变快,后者让迭代变危险——OTA 是推给所有人的,很难回滚。
所以这条路线上的纪律集中在一点:发版前的校验要做足。两道版本门禁、一条工作区护栏、一个只构建不上传的选项。四道关卡守的都是同一件事:不要把一个身份不明的包推给全部用户。
有一处边界想清楚过:安装包必须包含哪些文件。这份清单不是设计出来的,是被一次「找不到 ffmpeg.dll」的故障逼出来的。清单上的文件缺任何一个,安装器就在启动新程序之前停下来——因为在那个时刻,应用自己的任何补救代码都还没机会运行。
■