📚 离职知识库

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.jssrc/prefs.js.github/workflows/build.yml。以下行号均可回查。

#1.1 版本发现:刻意绕开 electron-updater 的一条「纯查询」链路

quietDiscover()updater.js:783-808)只回答"有没有新版本",不碰任何 UI、不碰 electron-updater 的状态机

  • 主请求 fetchLatestReleaseFromApi():722-769):https.getapi.github.com/repos/<owner>/<repo>/releases/latest,带 If-None-Match ETag;命中 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 = falseautoInstallOnAppQuit = 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*_secretapi_key…)。最后 8KB 截断。因为这段文本是给用户「一键复制去反馈」的,比普通日志更狠地脱敏——这个思路值得抄。

日志落 userData/update-debug.loglog-rotate.js 超 1MB 从换行处截断保留后半段。写入策略是「只记诊断分支(失败 + 显式跳过)」,成功心跳不写——所以日志没新增 = 检查成功且无更新

#1.6 CI 发布流程(.github/workflows/build.yml

push tag v* → validate-release(npm run verify:release)
            → 三平台并行构建,都用 electron-builder --publish neverrelease 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

两个关键设计:

  1. 构建与发布挂载解耦--publish never + 独立 release job),避免 electron-builder 自带 publish 跟仓库的草稿流程打架。
  2. 先建草稿,人工点发布。所以 tag push ≠ 用户马上能更新;releases/latest 只认非 draft、非 prerelease 的版本。这是一道很便宜的人工闸门。

#1.7 它的七个缺陷(正好是我要避开的)

  1. 直连 api.github.comhttps.get 没传任何 agent,不读系统代理 → 墙内基本失效。这是「更新从来没成功过」的根因。
  2. 无镜像回退:兜底路径仍是 github.com 域名,被墙时两条一起死。
  3. 无差分下载:没产 blockmap。
  4. 无灰度:release 一转正式就全量可见。
  5. 无强制更新通道:安全修复也能被用户无限「Later」。
  6. mac 无静默安装(见 1.4)。
  7. 调度状态纯内存:重启即重置 12 小时计时,频繁重启的用户实际上一直停在「首次 2–5 分钟」阶段。

#2. 标准做法:electron-builder + electron-updater

#2.1 发布侧

package.jsonbuild.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.jsonversion 严格一致且单调递增。

构建产物里除了安装包,还会生成三份更新元数据,必须一起传到同一个 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-updateupdate-availabledownload-progressupdate-downloaded → (error)。

两个高频误用:

  1. update-downloaded 里直接 quitAndInstall()——用户没保存的东西就没了,先弹确认。
  2. 混淆 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 模块,不要用 Node https。这一条能省掉 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、校验后改指针 目录结构直接可见

从这五个样本能抽出的国内客户端通用套路:

  1. 几乎没人用 GitHub Releases 当更新源——全是自建 HTTP 端点。原因不只是墙,更是端点可控:能按用户 ID / 版本 / 渠道返回不同响应,天然就有灰度、强制更新、定向回滚的能力。这是静态文件方案(latest.yml)永远做不到的。
  2. 更新器独立成进程(WPS 最典型)。好处是主程序退出后它还能干活、能覆盖主程序自身文件、崩了也不影响主程序。Electron 系靠 Squirrel 达到同样效果(Squirrel 本身就是个独立的 helper)。
  3. 差分更新是标配(WPS 的 diffupdate)。国内用户网络差、包体大,全量下 200MB 的体验不可接受。
  4. 断点续传 + 重试 + 后台下载,把「网络不稳」当默认前提设计,而不是当异常处理。
  5. 原子切换(vibex 的 current 软链、Chrome 的版本化目录):新版本先落到临时目录,校验通过后再改指针/改名,永远不会出现「更新到一半程序坏了」。

给我自己的结论:如果做的是给别人用的正式产品,别学 Clawd 用 GitHub Releases 直连,学 WorkBuddy——包放国内对象存储/CDN,自己开一个返回 JSON 的 /v2/update 端点(甚至一个 Cloudflare Worker 就够),把灰度、强制更新、回滚这些字段握在自己手里。GitHub Releases 只留作开源分发和兜底源。


#5. 我自己的项目该怎么落地(行动清单)

  1. 建仓 → package.json 里配 build.publish GitHub provider,tag 规则定为 v{version}
  2. .github/workflows/release.yml:push tag → 矩阵构建 → electron-builder --publish always
  3. macOS 先把签名和公证跑通(没有这一步,mac 端自动更新等于没有)。
  4. 更新检查代码用 Electron net 模块,不用 Node https;启动后 2–5 分钟首查,之后 12h ± 抖动。
  5. 加一份自建 latest.json 到 R2/OSS,做成「国内源优先、GitHub 兜底」的双源;客户端独立校验 sha512。
  6. 元数据里预留 minSupportedVersion / rolloutPercentage / rollbackToVersion 三个字段,哪怕一开始不用。
  7. UI 上留三样:更新提示气泡(可忽略该版本)、手动检查入口、失败原因文案。
  8. 日志只记失败、并对 authorization / cookie 做脱敏。

来源:沉淀/SOP-桌面客户端追踪GitHub上游自动更新.md(整理于 2026-08-18)