SOP:桌面客户端「追踪 GitHub 上游 + 自动更新」
用途:以后自己做桌面客户端(Electron / Tauri / macOS 原生)时,照这份文档搭一套能自动发现新版本、自动下载安装、在墙内也能用的更新链路。 来源:拆解开源项目 Clawd on Desk(rullerzhou-afk/clawd-on-desk,v0.14.0)的实现 + 横向调研业界方案。 成文日期:2026-08-03。 标注约定:〔已核实〕= 读代码或官方文档确认;〔待验证〕= 二手资料,用之前自己跑一遍。
#0. 一句话结论
个人/小团队项目,首选 electron-builder + electron-updater + GitHub Releases:零后端、零成本、CI 一条 tag 就发版。但必须自己补三件上游默认不给的东西——墙内可达性(双源回退)、独立的哈希/签名校验、灰度与强制更新的元数据。
#1. 参考实现拆解:Clawd on Desk 是怎么做的
对象:rullerzhou-afk/clawd-on-desk v0.14.0。核心在 src/updater.js(1581 行)、src/update-bubble.js、src/prefs.js、.github/workflows/build.yml。以下行号均可回查。
#1.1 版本发现:刻意绕开 electron-updater 的一条「纯查询」链路
quietDiscover()(updater.js:783-808)只回答"有没有新版本",不碰任何 UI、不碰 electron-updater 的状态机。
- 主请求
fetchLatestReleaseFromApi()(:722-769):https.get打api.github.com/repos/<owner>/<repo>/releases/latest,带If-None-MatchETag;命中 304 直接复用内存里的上一份 JSON。这一招把未认证请求(60 次/小时)的消耗压到几乎为零。10 秒超时主动 destroy。 - 兜底
fetchLatestReleaseViaRedirect()(:687-720):API 出错(带 statusCode)时改打网页版github.com/…/releases/latest,从 302 的Location正则抠出 tag。代价:只能拿到 tag,拿不到 assets 列表,代码里有注释明说。 - 为什么绕开 electron-updater:后台轮询若直接调
checkForUpdates(),会触发它内部的事件(update-available等)污染"谁发起了这次检查"的语境,还可能在用户操作中途弹窗。真要下载时再用checkForUpdates({trigger:'manual', intent:'download'})回头重建一次 electron-updater 上下文。
这是最值得抄的设计:把「发现」和「执行」拆成两条路。
#1.2 调度器(:1172-1242)
| 参数 | 值 |
|---|---|
| 首次延迟 | 2–5 分钟随机(FIRST_DELAY_MIN/MAX_MS) |
| 常规周期 | 12 小时 ± 30 分钟双向抖动 |
| 开关 | autoUpdateCheck(默认 true) |
三道跳过条件按序判定:未打包(!app.isPackaged)→ 旁边有 .git(走 git pull 更新路径而非安装包路径)→ 用户关了 autoUpdateCheck。
#1.3 去重与状态持久化(handlePendingVersion :830-888)
dismissedUpdateVersions:只有用户真的看见气泡并点了「Later」才写入。气泡因免打扰/自动关闭而消失不算拒绝,下次还会弹。这个区分很讲究,抄。pendingUpdateVersion:一发现新版本就落盘,驱动托盘菜单角标。- 免打扰 / mini 模式下不弹,把提示函数存进
pendingPromptDeferred,等退出静默模式再重放——不是丢弃。 - 开机对账
reconcilePendingOnStartup:用户带外手动装了新版本时清理陈旧的 pending,并pruneDismissedBelow(current)防止 dismissed 表无限膨胀。
#1.4 下载安装:这里有个大坑
- 代码里
autoDownload = false、autoInstallOnAppQuit = true(:907-908)。 Contents/Resources/app-update.yml由 electron-builder 按build.publish生成,内容只有 owner/repo/provider/updaterCacheDirName,electron-updater 靠它去找latest*.yml。- 但 macOS 上它根本不走 electron-updater 安装:上游
build.mac.target只打dmg、不打zip,而 Squirrel.Mac 静默更新需要 zip;代码里 mac 分支直接shell.openExternal(releases 页)(:1271-1289),downloadUpdate()只在非 mac 调用。所以 mac 用户的「自动更新」实际只是「自动提醒 + 打开下载页」,仍要手动拖进 Applications。 - 该项目 adhoc 签名、未做 Apple 公证,release 资产里也没有
.blockmap(无差分下载,Windows 每次全量下 120MB+)。
教训:只打 dmg = mac 端没有静默更新。要静默更新,mac 必须
target: ["dmg","zip"]+ Developer ID 签名 + 公证,三者缺一不可。
#1.5 错误分类与脱敏(:12-98、:135-197)
15 类错误码(NETWORK_OFFLINE / DNS_FAILED / CONNECTION_TIMEOUT / TLS_OR_PROXY / GITHUB_RATE_LIMIT / NO_COMPATIBLE_ASSET / INTEGRITY_FAILED / DISK_OR_PERMISSION / git 系列 …),每类带 {message, nextStep} 两句话,suffix 拼 i18n key,未翻译回落英文。
脱敏做了四层:通用 token 正则(ghp_/sk-/AKIA 等)→ URL 里的 user:pass@ 和整个 query string 砍掉 → authorization/cookie 头行整行替换 → 一条更严格的自定义键名正则(*_token、*_secret、api_key…)。最后 8KB 截断。因为这段文本是给用户「一键复制去反馈」的,比普通日志更狠地脱敏——这个思路值得抄。
日志落 userData/update-debug.log,log-rotate.js 超 1MB 从换行处截断保留后半段。写入策略是「只记诊断分支(失败 + 显式跳过)」,成功心跳不写——所以日志没新增 = 检查成功且无更新。
#1.6 CI 发布流程(.github/workflows/build.yml)
push tag v* → validate-release(npm run verify:release)
→ 三平台并行构建,都用 electron-builder --publish never
→ release job:合并全部产物,softprops/action-gh-release@v2
一次性挂 dmg(arm64/x64) + exe(x64/arm64) + AppImage + deb + 三份 latest*.yml
body_path 指向 docs/releases/release-<tag>.md(强制先写发布说明)
draft: true;tag 名含「-」自动标 prerelease两个关键设计:
- 构建与发布挂载解耦(
--publish never+ 独立 release job),避免 electron-builder 自带 publish 跟仓库的草稿流程打架。 - 先建草稿,人工点发布。所以 tag push ≠ 用户马上能更新;
releases/latest只认非 draft、非 prerelease 的版本。这是一道很便宜的人工闸门。
#1.7 它的七个缺陷(正好是我要避开的)
- 直连
api.github.com,https.get没传任何 agent,不读系统代理 → 墙内基本失效。这是「更新从来没成功过」的根因。 - 无镜像回退:兜底路径仍是 github.com 域名,被墙时两条一起死。
- 无差分下载:没产 blockmap。
- 无灰度:release 一转正式就全量可见。
- 无强制更新通道:安全修复也能被用户无限「Later」。
- mac 无静默安装(见 1.4)。
- 调度状态纯内存:重启即重置 12 小时计时,频繁重启的用户实际上一直停在「首次 2–5 分钟」阶段。
#2. 标准做法:electron-builder + electron-updater
#2.1 发布侧
package.json 的 build.publish:
"publish": [{ "provider": "github", "owner": "your-name", "repo": "your-repo" }]- CI 里设了
GH_TOKEN/GITHUB_TOKEN时会自动启用 GitHub provider。〔已核实〕 draft: true发草稿、prerelease: true发预发布;也可用环境变量EP_DRAFT/EP_PRE_RELEASE控制。〔已核实〕- Git tag 用
v{version},必须与package.json的version严格一致且单调递增。
构建产物里除了安装包,还会生成三份更新元数据,必须一起传到同一个 Release:
| 文件 | 平台 | 内容 |
|---|---|---|
latest.yml |
Windows | version / path / sha512 / stagingPercentage |
latest-mac.yml |
macOS | 同上(mac 必须同时产出 zip,只出 dmg 生成不了) |
latest-linux.yml |
Linux | 同上 |
.blockmap 是差分更新的基础:构建时把安装包切块算哈希,客户端只下载变化的块。支持 Windows NSIS、macOS dmg、Linux AppImage。〔已核实:官方文档〕
#2.2 客户端侧
| 调用 | 行为 | 用在哪 |
|---|---|---|
checkForUpdates() |
只检查,返回 Promise | 自己控制节奏和 UI |
checkForUpdatesAndNotify() |
检查→下载→系统通知 | 最省事,但 UI 不可控 |
autoDownload(默认 true) |
发现即下载 | 想让用户先确认就设 false |
autoInstallOnAppQuit(推荐 true) |
退出时静默装 | 体验最无感 |
quitAndInstall() |
立刻退出并安装 | 用户点「立即更新」才调 |
事件流:checking-for-update → update-available → download-progress → update-downloaded → (error)。
两个高频误用:
update-downloaded里直接quitAndInstall()——用户没保存的东西就没了,先弹确认。- 混淆
autoDownload: false(不自动下)和autoInstallOnAppQuit: false(不自动装)。
#2.3 平台差异(决定成败的部分)
- macOS:必须代码签名 + 公证,未签名的应用自动更新会直接失败(Gatekeeper 拦)。需要
hardenedRuntime: true和 entitlements。dmg 与 zip 都要产出——Squirrel.Mac 静默更新依赖 zip,只打 dmg 的项目(如 Clawd on Desk)mac 端就退化成「打开浏览器下载页」,见 1.4。〔已核实:Clawd 实测 + 官方文档〕 - Windows:用 NSIS,Squirrel.Windows 已不推荐。
- Linux:AppImage 可自更新,但文件名带版本号,更新后快捷方式会失效。
- arm64 / x64:分架构各出一份资产,客户端按运行时架构匹配。〔待验证:跨架构匹配细节自己测一遍〕
#2.4 坑清单
| 坑 | 原因 | 对策 |
|---|---|---|
| 检查到 404 | latest*.yml 没传上去,或多架构并行构建互相覆盖 |
CI 用 --publish always,串行或分别命名 |
| 版本比较出错 | semver 下 1.0.0-alpha < 1.0.0 |
tag 与 package.json 严格同步递增 |
| 私有仓库下不动 | 缺 token | 用户侧 GH_TOKEN,或 autoUpdater.requestHeaders 带 Authorization |
| 缓存残留导致版本错乱 | 旧包没清 | 查 macOS ~/Library/Caches/<updaterCacheDirName>、Windows %APPDATA% |
| 想降级 | semver 不允许回退 | 官方不支持,要自己实现「允许降级」开关 |
| 企业代理 / 自签证书 | TLS 校验失败 | 见第 4 章代理方案 |
| 多实例同时装 | 文件锁冲突 | app.requestSingleInstanceLock() |
#2.5 最小骨架
// package.json
"build": {
"publish": [{ "provider": "github" }],
"mac": { "target": ["dmg", "zip"], "hardenedRuntime": true },
"win": { "target": ["nsis"] },
"linux": { "target": ["AppImage"] }
}// 主进程
const { autoUpdater } = require('electron-updater');
autoUpdater.autoDownload = false; // 先问用户
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('update-available', (info) => showBubble(info.version));
autoUpdater.on('update-downloaded', () => notifyReadyOnQuit());
autoUpdater.on('error', (err) => logQuietly(err)); // 失败不打扰用户
setTimeout(() => autoUpdater.checkForUpdates(), 3 * 60 * 1000); // 启动后延迟首查
setInterval(() => autoUpdater.checkForUpdates(), 12 * 60 * 60 * 1000); // 之后 12h 一次# .github/workflows/release.yml
on: { push: { tags: ['v*'] } }
jobs:
build:
runs-on: ${{ matrix.os }}
strategy: { matrix: { os: [macos-latest, windows-latest, ubuntu-latest] } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm run build
- run: npx electron-builder --publish always
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}#3. 横向对照:别人是怎么做的
| 方案 | 更新源 | 灰度/回滚 | 签名校验 | 适合谁 |
|---|---|---|---|---|
| Chrome / VS Code 式 | 自建更新服务(Omaha 协议 XML manifest) | 原生支持按百分比分阶段发布 | 平台签名 + manifest 校验 | 大厂,要养后端 |
| Electron 商业客户端(Obsidian、Cursor 等) | GitHub Releases / S3 / 自建 CDN | 公开资料未见完整灰度实现 | 平台代码签名 | 中型项目 |
| Sparkle(macOS 原生) | 静态 appcast.xml(RSS) |
可按 OS 版本等条件分批 | EdDSA(Ed25519) + Apple 签名,generate_appcast 自动生成 |
只做 macOS,运维成本最低 |
| Tauri updater | HTTP endpoint 或静态 latest.json |
endpoint 可动态返回不同版本 → 天然可灰度 | 公私钥签名,platforms.<target>.signature 必填 |
中小型跨平台 |
| 纯 GitHub Releases | Releases API + latest*.yml |
靠 tag 管理,无原生灰度 | 需自己加 | 开源项目、个人产品 |
关键取舍:静态元数据文件(Sparkle/Tauri/electron-updater)省事但灰度弱;HTTP 端点(Chrome/Tauri endpoint)能灰度但要养服务。 折中做法是把 latest.json 放在自己的 CDN 上,用一个能改的静态文件承载 rolloutPercentage——不用后端也能灰度。
#3.1 灰度 + 强制更新 + 回滚的最小元数据设计
{
"version": "1.2.3",
"pubDate": "2026-08-03T10:00:00Z",
"releaseNotes": "……",
"minSupportedVersion": "1.0.0", // 低于此版本 → 强制更新,不给「稍后」按钮
"rolloutPercentage": 10, // 客户端按稳定哈希(设备ID)自判是否命中
"rollbackToVersion": null, // 出事故时填上,客户端主动回退
"platforms": {
"darwin-arm64": { "url": "…", "sha512": "…", "signature": "…" },
"darwin-x64": { "url": "…", "sha512": "…", "signature": "…" },
"win32-x64": { "url": "…", "sha512": "…", "signature": "…" }
}
}灰度命中要用设备稳定哈希(如 hash(machineId + version) % 100 < rolloutPercentage),不能用随机数——否则同一台机器每次检查结果都在跳。
#4. 中国大陆网络:这套东西真正的难点
#4.1 失败形态与识别
| 现象 | 错误码 | 含义 |
|---|---|---|
| DNS 污染 | ENOTFOUND api.github.com |
域名解析不到 |
| TLS 被重置 | ECONNRESET / ERR_CONNECTION_CLOSED |
握手被打断 |
| 资产下载中断/限速 | ETIMEDOUT / socket hang up |
objects.githubusercontent.com 不稳 |
建议超时:连接 10s、读取 30s;元数据文件保持在几十 KB 以内,首字节超 15s 就切下一个源。
#4.2 代理:Electron 里最容易踩的一脚
Node 原生
http/https模块不读HTTP_PROXY/HTTPS_PROXY〔已核实,这正是 Clawd 墙内失效的根因〕。要么显式传 agent:const { HttpsProxyAgent } = require('https-proxy-agent'); const agent = process.env.HTTPS_PROXY ? new HttpsProxyAgent(process.env.HTTPS_PROXY) : undefined; https.get(url, { agent }, cb);Node 新版
fetch()可用NODE_USE_ENV_PROXY=1打开环境变量代理〔待验证:只影响 undici/fetch,不覆盖 http/https 模块〕。Electron 主进程的正确姿势:走 Chromium 网络栈(
net模块 /session),它自动继承系统代理;或app.commandLine.appendSwitch('proxy-server', '…')显式指定。〔已核实:Electron 官方文档〕结论:更新检查一律走 Electron
net模块,不要用 Nodehttps。这一条能省掉 90% 的「用户说更新坏了」。
#4.3 加速与镜像层
- 反代类(gh-proxy 等自建 Cloudflare Workers 版):URL 前缀改写即可,公共实例寿命不稳,要用就自建。〔待验证:公共实例可用性随时变〕
- 自建 CDN:Cloudflare R2(无出站流量费)或阿里 OSS / 腾讯 COS(国内 BGP 好)托管安装包 + 一份
latest.json。这是最稳的方案。 - jsDelivr:不支持 Releases 资产,别指望。〔待验证〕
- 铁律:镜像只负责传输,校验必须客户端独立做。 sha512 或 Ed25519 签名由客户端内置公钥验证,任何一个源下下来的包都要过同一道校验。
#4.4 双源回退骨架
// 元数据也要双源:先国内 CDN,再 GitHub
const META_SOURCES = [
'https://cdn.example.com/myapp/latest.json',
'https://api.github.com/repos/owner/repo/releases/latest',
];
async function fetchMeta() {
for (const url of META_SOURCES) {
try { return await getJson(url, { timeoutMs: 10_000 }); }
catch (e) { log(`meta source failed: ${url} — ${e.code || e.message}`); }
}
throw new Error('all metadata sources failed');
}
async function downloadVerified(asset) {
for (const url of [asset.cdnUrl, asset.githubUrl]) { // 国内源优先
try {
const buf = await download(url, { timeoutMs: 30_000 });
const sha = crypto.createHash('sha512').update(buf).digest('base64');
if (sha !== asset.sha512) throw new Error('checksum mismatch'); // 镜像被污染 → 换源
return buf;
} catch (e) { log(`asset source failed: ${url} — ${e.message}`); }
}
throw new Error('all asset sources failed or failed verification');
}#4.5 运营层面的规矩
- 后台检查失败一律静默,只写日志,不弹窗——用户不需要知道你的更新服务器抽风了。
- 菜单里必须留一个「检查更新」手动入口,手动触发时失败要给出人话原因(DNS 失败 / 超时 / 校验不通过)+ 重试按钮 + 可复制的直链兜底。
- 检查频率带抖动(±10~30 分钟),避免所有客户端在整点雷群打你的 CDN。
- 版本提示要能「忽略这个版本」,并把忽略记录持久化,否则每 12 小时骚扰一次必被卸载。
#4.6 国内商业客户端实际是怎么做的(本机实证)
在本机对已安装应用做静态分析(ls 结构 + strings 抓端点,只读不触发下载)得到的结论,比二手文章可靠:
| 应用 | 底座 | 更新机制 | 关键证据 |
|---|---|---|---|
| WorkBuddy 5.3.5 | Electron | Electron 原生 autoUpdater + Squirrel.framework,feed 指向自建端点 https://copilot.tencent.com/v2/update |
Contents/Frameworks/Squirrel.framework;asar 里 autoUpdater/checkForUpdates/quitAndInstall 共 119 处命中 |
| WPS Office 12.1 | 自研(Qt/C++) | 独立的进程外更新器 SharedSupport/WPS Office UpdateTool.app,bundle id com.kingsoft.wpsoffice.mac.slientupdatetool(silent 拼错了)。支持差分补丁、断点续传、后台下载、失败 sleep 60s 重试、从 dmg 里提取 app 覆盖 |
二进制里 diffupdate: apply diff patch failed. / resume task from file / extractAppFromDmg 等一整套日志串 |
| 网易云音乐 3.0.19 | CEF(不是 Electron) | 无独立更新器,升级模块编译进主程序;符号里有 upgradePostDataURL/upgradeReason,走自家 interface3.music.163.com 系接口的可能性大〔待验证:未抓到确证 URL,字符串疑似运行时拼接〕 |
Frameworks/Chromium Embedded Framework.framework;主二进制 upgrade 符号 |
| Google Chrome | — | 常驻守护 com.google.GoogleUpdater.wake LaunchAgent 定时唤醒(Omaha) |
~/Library/LaunchAgents/com.google.GoogleUpdater.wake.plist |
| vibex(CLI) | Go/Node | symlink 原子切换:~/.vibex/updates/ 下分 releases/ staging/ 和一个 current -> 软链,新版本下到 staging、校验后改指针 |
目录结构直接可见 |
从这五个样本能抽出的国内客户端通用套路:
- 几乎没人用 GitHub Releases 当更新源——全是自建 HTTP 端点。原因不只是墙,更是端点可控:能按用户 ID / 版本 / 渠道返回不同响应,天然就有灰度、强制更新、定向回滚的能力。这是静态文件方案(
latest.yml)永远做不到的。 - 更新器独立成进程(WPS 最典型)。好处是主程序退出后它还能干活、能覆盖主程序自身文件、崩了也不影响主程序。Electron 系靠 Squirrel 达到同样效果(Squirrel 本身就是个独立的 helper)。
- 差分更新是标配(WPS 的
diffupdate)。国内用户网络差、包体大,全量下 200MB 的体验不可接受。 - 断点续传 + 重试 + 后台下载,把「网络不稳」当默认前提设计,而不是当异常处理。
- 原子切换(vibex 的
current软链、Chrome 的版本化目录):新版本先落到临时目录,校验通过后再改指针/改名,永远不会出现「更新到一半程序坏了」。
给我自己的结论:如果做的是给别人用的正式产品,别学 Clawd 用 GitHub Releases 直连,学 WorkBuddy——包放国内对象存储/CDN,自己开一个返回 JSON 的 /v2/update 端点(甚至一个 Cloudflare Worker 就够),把灰度、强制更新、回滚这些字段握在自己手里。GitHub Releases 只留作开源分发和兜底源。
#5. 我自己的项目该怎么落地(行动清单)
- 建仓 →
package.json里配build.publishGitHub provider,tag 规则定为v{version}。 - 写
.github/workflows/release.yml:push tag → 矩阵构建 →electron-builder --publish always。 - macOS 先把签名和公证跑通(没有这一步,mac 端自动更新等于没有)。
- 更新检查代码用 Electron
net模块,不用 Nodehttps;启动后 2–5 分钟首查,之后 12h ± 抖动。 - 加一份自建
latest.json到 R2/OSS,做成「国内源优先、GitHub 兜底」的双源;客户端独立校验 sha512。 - 元数据里预留
minSupportedVersion/rolloutPercentage/rollbackToVersion三个字段,哪怕一开始不用。 - UI 上留三样:更新提示气泡(可忽略该版本)、手动检查入口、失败原因文案。
- 日志只记失败、并对
authorization/cookie做脱敏。
来源:沉淀/SOP-桌面客户端追踪GitHub上游自动更新.md(整理于 2026-08-18)