Folo 桌面端与移动端 OTA 服务统一设计:从 mainHash 到 runtimeVersion 的更新架构演进
Folo(AI RSS Reader)在apps/ota中维护着一个基于 Cloudflare Workers + KV + R2 的移动端 OTA 更新服务。本文以仓库设计文档 docs/superpowers/specs/2026-04-11-desktop-ota-unification-design.md 为骨架,讲解如何把该服务扩展为移动端与新桌面端共用的"单一更新事实源":引入文件驱动的release-plan.json/release.json发布流程、以显式runtimeVersion取代mainHash的兼容性判定、面向direct/mas/mss三种分发的二进制策略(/policy),以及同时承载 renderer OTA 与直连安装包的/manifest协议。读完本文,你将掌握这套跨端更新系统在路由契约、元数据模型、KV 存储键设计与发布编排上的完整设计思路,并可在仓库源码中找到对应的落地实现。
背景:从移动端专属 OTA 到多产品统一更新源
apps/ota最初是一个移动端专用的 OTA 服务。仓库中的早期设计文档 docs/superpowers/specs/2026-04-10-ota-design.md 明确了其定位:基于 Expo Updates 的自定义后端,运行在 Cloudflare Workers 之上,以 GitHub Releases 作为发布事实源、Cloudflare R2 作为交付层,仅通过x.y.z纯版本号驱动,并依赖runtimeVersion表达原生兼容边界。
桌面端 OTA 统一设计文档(即本文主题)正是在这一基础之上的第二阶段演进。它要解决的核心问题是:把apps/ota从一个"仅服务移动端"的服务,扩展为移动端与新版桌面端的唯一更新事实源,同时做到:
- 桌面端 OTA 与直接二进制更新策略都由
apps/ota承载; follow-server中既有的桌面更新路由保持不动,继续服务旧版桌面客户端;- 把兼容性判定从
mainHash迁移到显式runtimeVersion; - 桌面端发布编排对齐移动端已经建立的
release-plan.json+release.json文件驱动工作流。
从仓库当前文件状态看,这套设计并非停留在纸面:apps/desktop/下已存在 release-plan.json(当前默认mode: "build")与 release.json,apps/ota/src/lib/下已出现 request.ts、desktop.ts 等桌面端解析模块,apps/ota/src/__tests__/也配套了 manifest.test.ts、policy.test.ts、sync.test.ts 等测试。配套的实施计划文档见 docs/superpowers/plans/2026-04-11-desktop-ota-unification.md。
设计目标与非目标
Goals
- 让
apps/ota成为移动端与新版桌面客户端唯一的更新事实源; follow-server保持不变,旧版桌面客户端完全兼容;- 桌面端 OTA 兼容性判定中移除
mainHash; - 桌面发布编排与移动端新的
release-plan.json/release.json工作流对齐; - 支持
direct、Mac App Store(mas)、Microsoft Store(mss)三类分发,且三者的二进制策略生效时机可以不同(商店审核完成时间不同步); - 一次桌面 OTA 发布可以同时发布 renderer OTA 数据与直连安装包数据;
- 保留一种简单、可审计、仓库原生的发布流程,由入库 JSON 配置驱动。
Non-Goals
- 不迁移旧版桌面客户端离开
follow-server; - 不替换
follow-server既有的桌面 YAML 路由; - 不通过外部 API 自动探测 App Store / Microsoft Store 审核完成状态;
- 不通过 OTA payload 下发原生代码;
- 首个版本不做分阶段灰度百分比(staged rollout)。
Constraints
follow-server不允许被修改;- 新版桌面客户端访问
apps/ota时只使用X-App-*请求头; - 桌面分发渠道只能从既有的
X-App-Platform取值推断,这些值定义在 packages/internal/utils/src/headers.ts 的DesktopPlatform枚举中; - 桌面 OTA 兼容性必须使用显式
runtimeVersion; - 若桌面客户端省略
X-App-Runtime-Version,服务端须把X-App-Version当作 runtimeVersion 处理。
六个关键设计决策
1. 服务归属:apps/ota拥有更新事实源
apps/ota拥有移动端与新版桌面客户端的更新事实源;follow-server退化为仅服务旧桌面客户端的兼容层。这意味着新功能的迭代不再牵动旧的follow-server部署面。
2. 桌面兼容模型:用 runtimeVersion 取代 mainHash
桌面端不再以mainHash作为 OTA 兼容性键,而改用三个明确语义的版本维度:
installedBinaryVersion:当前已安装的桌面应用版本;runtimeVersion:renderer OTA 的兼容线;rendererVersion:当前已安装的 renderer 版本。
默认规则是:
runtimeVersion = installedBinaryVersion这一模型与移动端的心智完全一致,消除了mainHash隐藏的兼容语义。
3. 文件驱动的发布意图
桌面端采用与移动端完全相同的工作流形态:
- apps/desktop/release-plan.json 表达下一次的发布意图;
- apps/desktop/release.json 记录实际打 tag 发布时被"锁定"的解析配置。
发布自动化在决定发布内容时必须读取release.json,而不是读取临时手工输入的工作流参数。当前仓库中桌面 release.json 的实态为version: "1.12.0"、mode: "build"。
4. 桌面发布三种模式
桌面端支持build、ota、binary-policy三种模式:
| 模式 | 含义 |
|---|---|
build | 只发布直连安装包资源 |
ota | 同时发布 renderer OTA 资源与直连安装包资源 |
binary-policy | 只发布二进制升级策略元数据,不重新构建或上传安装包 |
5. 分发策略粒度:per-distribution 时机
由于 MAS 与 MSS 的商店审核存在延迟且完成时间不同,桌面二进制策略必须支持按分发渠道独立设置生效时机。受支持的分发渠道为:
direct mas mss策略查找须优先匹配分发特定策略,找不到再回退到产品级策略(product-level policy)。
6. 发布类型命名:store → binary
OTA 元数据模型中的发布类型从ota/store迁移为ota/binary。兼容规则是:Worker 继续把遗留的store元数据当作binary的别名接受。这样既能保证旧移动端元数据继续工作,又给桌面端一个比store更准确的命名。
发布配置设计:release-plan.json 与 release.json
桌面release-plan.json
建议的默认形态:
{ "mode": "build", "runtimeVersion": null, "channel": null, "distributions": [], "required": false, "message": null }规则约束:
mode只能是build、ota、binary-policy三者之一;ota模式必须提供runtimeVersion;build与binary-policy模式下runtimeVersion必须为null;ota与binary-policy必须提供channel;binary-policy必须提供distributions;required与message只影响二进制策略的发布行为。
允许的桌面渠道为stable、beta、alpha,development不是发布渠道。
桌面release.json
建议形态:
{ "version": "1.6.1", "mode": "ota", "runtimeVersion": "1.6.0", "channel": "stable", "distributions": ["direct"], "required": false, "message": null }release.json是 CI 打 tag 发布时消费的最终事实源。
工作流解析
桌面端镜像移动端的工作流模式:
- 解析脚本读取 apps/desktop/release.json;
- 解析器决定要触发的 workflow 动作;
tag.yml依据解析输出分派构建与 OTA 发布。
据此,日常发布执行不再需要手工输入发布参数。实施计划中对应新增的解析器为.github/scripts/resolve-desktop-release-config.mjs,其职责可概括为:校验release.json中的version与 tag 版本一致,并按模式输出triggerDirectBuild/triggerStoreBuilds/triggerMetadataPublish/releaseKind/runtimeVersion/channel等输出项(build与ota模式触发构建、binary-policy只触发元数据发布)。
OTA 元数据模型:schemaVersion 2 的 ota-release.json
桌面发布必须产出单一、机器可读的元数据文件供apps/ota使用。文件继续命名为ota-release.json,使 Worker 的同步路径在所有产品上保持一致。
一份桌面ota-release.json需要能够描述三种内容:
- renderer OTA payload;
- direct 直连安装包 payload;
- 仅 binary-policy 的发布。
设计文档给出的建议完整形态:
{ "schemaVersion": 2, "product": "desktop", "channel": "stable", "releaseVersion": "1.6.1", "releaseKind": "ota", "runtimeVersion": "1.6.0", "publishedAt": "2026-04-11T10:00:00Z", "git": { "tag": "desktop/v1.6.1", "commit": "abcdef123456" }, "policy": { "required": false, "minSupportedBinaryVersion": "1.6.0", "message": null, "distributions": { "direct": { "downloadUrl": "https://example.com/Folo-1.6.1.dmg" } } }, "desktop": { "renderer": { "version": "1.6.1", "commit": "abcdef123456", "launchAsset": { "path": "renderer/render-asset.tar.gz", "sha256": "0123...", "contentType": "application/gzip" }, "assets": [] }, "app": { "platforms": { "macos": { "platform": "macos-x64", "releaseDate": "2026-04-11T10:00:00Z", "manifest": { "name": "latest-mac.yml", "downloadUrl": "https://example.com/latest-mac.yml" }, "files": [ { "filename": "Folo-1.6.1-macos-x64.zip", "sha512": "base64sha512", "size": 123456789, "downloadUrl": "https://example.com/Folo-1.6.1-macos-x64.zip" } ] } } } } }语义要点:
schemaVersion: 2用于与既有移动端专属形态区隔,避免歧义;- 移动端可以继续沿用现有 schema,也可以在未来选择迁移;
releaseKind: "ota"表示该发布可从/manifest下发 renderer OTA 数据;releaseKind: "binary"表示/manifest不服务任何 OTA payload,但元数据仍可更新/policy;runtimeVersion仅在releaseKind: "ota"时必填,binary时应为null或省略;direct二进制 payload 数据在build与ota两种模式下都可以存在。
仓库中的落地实现位于 apps/ota/src/lib/schema.ts:desktopReleaseInputSchema将schemaVersion约束为字面量2、product约束为字面量desktop,releaseKind使用z.enum(["ota", "binary", "store"])接受store遗留值,policy.distributions通过z.partialRecord(desktopDistributionSchema, ...)表达direct/mas/mss的分发级策略,runtimeVersion为可空 semver。
请求契约:X-App-* 请求头体系
新版桌面客户端请求apps/ota时只使用X-App-*请求头:
必填头:
X-App-PlatformX-App-VersionX-App-Channel
可选头:
X-App-Runtime-VersionX-App-Renderer-Version
X-App-Platform的实际取值可在 packages/internal/utils/src/headers.ts 的DesktopPlatform枚举中看到,包含desktop、desktop/web、desktop/macos、desktop/macos/dmg、desktop/macos/mas、desktop/windows/exe、desktop/windows/ms、desktop/linux。其中分发相关的建构建函数createDesktopAPIHeaders会根据运行平台(darwin/win32/linux)以及是否为process.mas、Microsoft Store 构建,自动选择对应的 platform 头值。
X-App-Platform到路由维度的映射
| 头值 | platform | distribution |
|---|---|---|
desktop/macos/dmg | macos | direct |
desktop/macos/mas | macos | mas |
desktop/windows/exe | windows | direct |
desktop/windows/ms | windows | mss |
desktop/linux | linux | direct |
desktop/web | 不参与桌面 OTA 与桌面二进制策略 | — |
仓库中的实际解析实现位于 apps/ota/src/lib/request.ts 的parseDesktopRequest:它用DESKTOP_PLATFORM_MAP把上述头值映射为platform + distribution,并实现X-App-Runtime-Version缺省时回退到X-App-Version的逻辑,与设计约束完全一致。
桌面版本语义
X-App-Version永远是已安装的二进制版本(installed binary version);X-App-Runtime-Version是 OTA 兼容性键;- 若
X-App-Runtime-Version缺失,使用X-App-Version; X-App-Renderer-Version仅用于判断某个 renderer payload 是否比已安装 renderer 更新。
GET /manifest:下发兼容 payload
/manifest只回答一个问题:当前客户端可用的兼容更新 payload 有哪些。桌面端manifest可以包含 renderer OTA payload 或 direct 渠道的完整应用 payload,但不能代替/policy去做商店升级拦截的最终 UX 决策。
建议响应形态
{ "id": "uuid", "createdAt": "2026-04-11T10:00:00.000Z", "product": "desktop", "channel": "stable", "runtimeVersion": "1.6.0", "renderer": { "releaseVersion": "1.6.1", "version": "1.6.1", "commit": "abcdef1234", "launchAsset": { "key": "render-asset", "hash": "sha256-base64url", "fileExtension": ".tar.gz", "contentType": "application/gzip", "url": "https://ota.folo.is/assets/desktop/stable/1.6.0/1.6.1/windows/render-asset.tar.gz" }, "assets": [] }, "app": { "releaseVersion": "1.6.1", "version": "1.6.1", "platform": "windows-x64", "releaseDate": "2026-04-11T10:00:00.000Z", "manifest": { "name": "latest.yml", "downloadUrl": "https://example.com/latest.yml" }, "files": [ { "filename": "Folo-1.6.1-windows-x64.exe", "sha512": "base64sha512", "size": 123456789, "downloadUrl": "https://example.com/Folo-1.6.1-windows-x64.exe" } ] } }桌面 Manifest 规则
renderer仅在同时满足以下条件时返回:- 存在
product + channel + runtimeVersion + platform匹配的兼容桌面 OTA 发布; - renderer payload 版本比
X-App-Renderer-Version更新;
- 存在
app仅在满足以下条件时返回:- 客户端分发渠道为
direct; - 存在请求平台下更新的兼容直连安装包;
- 客户端分发渠道为
mas与mss渠道绝不能从/manifest拿到 direct 二进制 payload;- 若
renderer与app都为空,返回204。
客户端决策优先级
- 有
renderer就优先应用 renderer; - 否则有
app就提供app(direct 全量更新); - 二进制升级的引导与强制则单独走
/policy。
实施计划给出的对应测试期望包括:direct 客户端两者皆有时返回renderer + app、只有 renderer 时只返回 renderer、store 渠道请求永远拿不到app、无兼容 payload 时返回204。
GET /policy:显式发布的二进制升级策略
/policy只回答三个问题:
- 当前已安装二进制是否应继续可用;
- 是否应提示用户升级;
- 用户应去哪里拿到正确的二进制。
建议响应形态(无动作)
{ "action": "none", "targetVersion": null, "message": null, "distribution": "direct", "downloadUrl": null, "storeUrl": null, "publishedAt": null }有直连升级可用时
{ "action": "prompt", "targetVersion": "1.6.1", "message": "A newer desktop version is available.", "distribution": "direct", "downloadUrl": "https://example.com/Folo-1.6.1.dmg", "storeUrl": null, "publishedAt": "2026-04-11T10:00:00.000Z" }策略选择规则
对桌面端:
- 从
X-App-Platform推断distribution; - 查询
product + channel + distribution的策略; - 若不存在,回退到
product + channel; - 若仍无策略,返回
none。
动作语义:
none:不提示也不阻塞;prompt:建议二进制升级,但允许继续使用;block:继续使用前必须先完成二进制升级。
URL 语义:
direct返回downloadUrl;mas与mss返回storeUrl。
为什么策略必须显式发布
服务端不能从 GitHub Release 的发布时间推断策略生效时机。原因在于 MAS / MSS 的审核时机是异步且不可知的,GitHub Release 可能早于商店二进制真正可安装就发布。因此二进制策略只有在针对相关分发渠道执行了一次显式binary-policy发布后才生效(例如 App Store 审核通过后发布 MAS 策略,Microsoft Store 审核完成后另行发布 MSS 策略)。
仓库的 KV 键实现(apps/ota/src/lib/constants.ts)已把 policy 键设计为可选分发维度:
policy: ( product, channel, distribution?, // "direct" | "mas" | "mss" ) => distribution ? `policy:${product}:${channel}:${distribution}` : `policy:${product}:${channel}`,存储模型:KV 键设计
Release 记录
按 release-version 键存储解析后的桌面与移动发布元数据:
release:desktop:1.6.1 release:mobile:0.4.3最新 OTA 指针
桌面 OTA 最新指针继续以 product、channel、runtimeVersion、platform 为维度:
latest:desktop:stable:1.6.0:windows二进制策略键
新增分发感知键:
policy:desktop:stable:direct policy:desktop:stable:mas policy:desktop:stable:mss移动端初期可继续使用 product 级键(policy:mobile:production等),未来再平滑迁移到分发感知键。
发布流程:三种模式的差异
Desktopbuild
- 构建并上传直连安装包资源;
- 发布桌面二进制元数据;
- 不发布 renderer OTA payload。
Desktopota
- 构建并上传 renderer OTA payload;
- 构建并上传直连安装包资源;
- 发布一份同时包含 renderer OTA 与 direct 二进制 payload 数据的元数据文件。
这样做保证了:既有用户有资格接收 renderer OTA,新用户也能立即下载最新直连安装包——"一次 OTA 发布仍要发布最新完整安装包"这一桌面端硬性需求由此得到满足。
Desktopbinary-policy
- 只发布策略元数据;
- 不重建安装包;
- 不上传 renderer OTA payload;
- 显式指定一个或多个目标分发渠道。
典型场景:App Store 审核通过后发布 MAS 策略,Microsoft Store 审核完成后再发布 MSS 策略。
迁移与验证方案
Migration Plan
- 引入桌面发布配置文件与解析逻辑;
- 让桌面发布流程产出新的桌面
ota-release.json; - 扩展
apps/ota的 sync、存储与选择逻辑以理解桌面元数据; - 为
apps/ota增加桌面感知的manifest与policy路由; - 让新版桌面客户端改用
apps/ota的manifest + policy; - 旧版桌面客户端继续使用
follow-server,不做改动。
测试矩阵
单元测试覆盖:桌面 release config 校验与解析器行为、桌面元数据解析、X-App-Version到 runtimeVersion 的回退、X-App-Platform到 platform/distribution 的映射、策略选择与回退、renderer payload 选择、direct 二进制 payload 选择。仓库中的对应测试文件包括 schema.test.ts、sync.test.ts 等。
Worker 路由测试覆盖:direct 请求在两者可用时返回renderer + app;只有 renderer 时返回 renderer;MAS / MSS 请求永不从/manifest拿到app;policy 优先分发特定记录;direct返回downloadUrl、mas/mss返回storeUrl;无兼容 payload 时manifest返回204。对应文件为 manifest.test.ts 与 policy.test.ts。
客户端验证:新版桌面客户端优先应用 renderer OTA;renderer 不可用时回退到 direct 全量更新;MAS / MSS 客户端尊重prompt并执行block。
开放风险
- 桌面元数据文件要同时为未来的移动端收敛预留向后兼容,需要谨慎处理 schema 版本演进;
- 桌面客户端不得假定 store 分发渠道一定存在
apppayload; - 发布自动化必须避免对商店渠道过早发布
binary-policy。
最终建议:一套可理解、可落地的更新模型
采纳apps/ota作为移动端与新版桌面客户端的统一更新事实源,follow-server保持原样为旧桌面客户端服务,从兼容模型中移除mainHash,并让桌面发布编排与移动端文件驱动发布流对齐。整套体系收敛为四层清晰的概念:
release-plan.json定义意图;release.json锁定打 tag 发布的配置;/manifest暴露兼容 payload 事实;/policy暴露二进制升级策略。
同时保留桌面端直接分发的要求:一次 OTA 发布依然会发布最新完整安装包。对于正在阅读源码的开发者,建议按 request.ts → schema.ts → constants.ts →desktop.ts/sync.ts→ 路由与测试的顺序阅读,即可把本文的设计契约与代码逐一对上。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考