macOS 应用写完之后,怎么交到用户手里(三):让官网跟着版本一起更新
本文最后更新于 2026年10月14日 晚上
上一篇《从手动流程到一条命令》把本机发布串起来了:准备版本、测试、签名、公证、附加票据,最后生成安装包和更新清单。
应用能发出去了,另一个问题就冒出来了:官网还得跟着改。
发一个新构建,要修改中文首页、英文首页、两份下载页、两份更新日志。版本号改了,包大小可能没改;下载链接换了,搜索引擎读取的元数据还指着旧包。页面都能打开,里面的信息却对不上。
我这次做的事,就是让 SSHGuard 的官网和应用读取同一份发布数据。网站继续用简单的 HTML 和 CSS,Cloudflare 只负责补上容易漏改的版本信息。
这篇按实际项目讲清楚:Pages、R2 和 Pages Functions 分别做什么,为什么不用再加一份 JSON 清单,以及下一次发布到底还要手动做哪些事。
下文的部署域名、桶名、绑定名和项目专属路径均使用示例值,不代表实际后台配置。public、functions、_routes.json 等 Cloudflare 约定名称保留;使用时请将示例替换为自己的配置。
一、官网需要更新的,其实只有一小部分
先把内容分开看。
产品介绍、截图、安装指南和隐私说明,属于网站内容。它们并不会每次发版都变化,继续留在网站仓库里维护就好。
版本号、构建号、包大小、下载链接和更新日志,属于发布内容。应用打包时已经知道这些信息,再到官网手填一次,很容易出现偏差。
所以这次没有重新搭建网站,也没有引入后台管理系统。只让中文和英文的三个页面读取发布数据:
| 页面 | 自动同步什么 |
|---|---|
| 首页 | 版本、包大小、最低系统要求、下载链接和相关结构化元数据 |
| 下载页 | 版本、包大小、最低系统要求、ZIP 和 SHA-256 链接 |
| 更新日志 | 对应语言、对应构建的更新说明 |
其他页面、截图和 CSS 继续按原来的方式发布。
二、Cloudflare 的几个名字,到底各管什么
第一次接触这些服务时,很容易把它们当成同一件事。我实际用到的是下面三个:
| 服务 | 在这个项目里的用途 |
|---|---|
| Cloudflare Pages | 部署官网的 HTML、CSS、图片和脚本 |
| Cloudflare R2 | 保存安装包、校验文件、更新说明和 appcast |
| Pages Functions | 在用户访问指定页面时,读取发布数据并填进 HTML |
网站地址是:
1 | |
安装包和应用更新源在:
1 | |
两个域名分别给用户看页面和下载文件。官网不会把 ZIP 读进函数再转发一次,下载按钮直接指向 R2 的公开地址。
Pages Functions 运行在 Cloudflare 的 Workers 运行时上,这个项目不用再单独建立一个 Worker 服务。R2 绑定让函数可以直接访问桶里的对象。Cloudflare 的绑定说明
部署网站时,Pages 从 GitHub 拉取代码并编译函数。用户每次打开网页,运行的是已经部署好的函数,不会再去 GitHub 读取代码。
三、网站还是静态页面,版本信息在返回前填进去
以下载页为例,网站仓库里仍然有一份正常的 HTML。它里面有标题、安装说明和最后一次核实的版本信息。
用户访问页面时,函数先取得这份静态响应,再读取 R2 中的 appcast,找到最新正式版本,替换指定位置,最后返回完整页面。
sequenceDiagram
participant U as 用户浏览器
participant P as Pages 与 Functions
participant R as R2
U->>P: 打开 SSHGuard 下载页
P->>P: 取得静态 HTML
P->>R: 读取 appcast,或使用有效缓存
R-->>P: 版本、包大小、下载地址
P->>P: 校验数据,替换指定内容
P-->>U: 返回完整 HTML
U->>R: 点击下载,直接获取 ZIP
这里的“替换”用的是 Cloudflare 的 HTMLRewriter。它可以按选择器找到 HTML 中的元素,修改文字、属性或正文。HTMLRewriter 文档
页面里的标记很简单,例如:
1 | |
函数只找这些明确的标记,不靠“第三个链接”“第二段文字”这样的顺序去猜位置。后面调整页面布局,也不容易误改其他内容。
首页的 JSON-LD 也要一起更新。它是提供给搜索引擎的结构化信息;用户看不到,并不代表可以留着旧版本。版本、地址和大小会从同一份数据填入,避免正文和元数据各说各话。
浏览器拿到的已经是完整 HTML。这个版本同步功能不依赖浏览器 JavaScript,关闭 JavaScript 也能看到更新后的版本和下载链接。
四、为什么没有再加一个 releases.json
最开始考虑过给官网单独生成一份 releases.json。
它当然能工作:软件读取 appcast,官网读取 JSON。但两份文件都要记录版本、包大小和下载链接,多了一处生成、上传和核对的工作。对当前项目来说,收益不大。
SSHGuard 已经通过 Sparkle 检查更新,现有的 appcast.xml 本来就包含这些信息:
- 构建号和对外显示的版本号;
- 最低系统要求;
- 安装包的完整下载地址和字节数;
- 更新包签名;
- 更新说明地址。
Sparkle 的 appcast 使用具体 URL 指向安装包和说明,并不要求它们放在同一个目录。Sparkle 的发布说明
当前就共用这份清单。官网读取其中的元数据,再按说明链接取得正文。
不过,共用也有一个前提:不能随手裁剪 appcast 的历史条目。 官网用它列历史,删除条目后,官网也会少掉对应版本。
如果以后需要稳定版、测试版分别发通知,或者应用清单只保留少量可升级版本,官网却要保留完整历史,那时再拆一个专门的历史索引会更合适。现在先不增加这层维护。
五、更新说明改用双语 Markdown
之前发布脚本把中文 Markdown 转成 HTML,供软件显示更新说明。现在 Sparkle 2.10.0 已能显示基本 Markdown,而 SSHGuard 最低支持 macOS 14,满足其运行条件。Sparkle 从 2.9 开始提供这项支持,要求 macOS 12 及以上。Sparkle 文档
因此新版本的说明直接用:
1 | |
应用按语言读取说明,官网也读取同一份内容。官网把 Markdown 转成自己的 HTML,使用网站现有的排版。
这并不意味着英文会凭空出现。准备版本时,脚本会创建中英文两份草稿,发布前都需要补全和审校。这里只自动化文件生成和数据同步,函数运行时不会替用户机器翻译。
说明只使用基本标题、段落、列表、分隔线和行内代码。版本日志用不着完整的 Markdown 插件体系,也不应该把里面的任意 HTML 当作代码执行。
还有一个之前踩过的坑:UTF-8 文件本身正确,浏览器仍可能显示乱码。 上传 Markdown 时,把对象的响应类型设为:
1 | |
官网通过函数读取后会自行渲染,但直接打开说明地址、以及软件读取说明时,也应该给出正确的内容类型和编码。
六、每个版本放在自己的目录里
安装包、校验文件和双语说明按版本放在一起,发布时上传这一组文件即可。下面以构建 6 为例,不代表它已经发布:
1 | |
appcast.xml 的地址固定,每次发布更新它的内容。安装包和说明使用具体的版本地址,发布后不覆盖成另一份内容。
应用和官网都按清单中的完整 URL 找文件,不需要遍历桶,也不需要递归查找版本目录。
七、绑定 R2,不需要把访问密钥放进网页
在 Pages 项目中给 R2 配置一个绑定:
1 | |
函数中就可以这样读取:
1 | |
APP_RELEASES 是示例绑定名,demo-app-releases 是示例桶名。两者可以不同,但变量名大小写必须与代码一致。绑定修改后需要重新部署才能生效。R2 绑定步骤
这段代码在 Cloudflare 上运行,浏览器不会收到 R2 访问密钥,也不需要为了读取版本清单去配置浏览器跨域请求。
绑定给了函数访问桶的能力,不等于桶已经公开。函数读取清单和说明使用绑定;用户下载 ZIP 使用公开域名。它们是两条不同的访问路径,公开下载仍要单独验证。
配置后来放进官网仓库的 wrangler.jsonc,生产绑定真实更新桶,预览配置指向隔离测试桶。预览桶需要事先创建并填入测试数据,不能只写一个名字就认为资源已经存在。
八、只让六个页面运行函数
官网还承载其他应用,没必要让所有请求都绕一遍 SSHGuard 的版本逻辑。
中英文各三个页面由 middleware 接入共享处理函数,再通过 public/_routes.json 限定调用范围。目录结构大致是:
1 | |
路由明确包括首页、下载页、更新日志页,以及它们没有尾斜杠和带 index.html 的入口别名。其他应用、指南、隐私页面、图片和 CSS 保持静态服务。
不能因为只在某个目录里放了函数,就认为所有子资源都不会调用它。函数调用范围需要明确配置。Pages Functions 路由文档
这样后续调整版本同步逻辑,影响范围也容易检查:就这六页。
九、缓存与回退:网页能打开,不代表同步成功
如果每次打开下载页都读取 R2,每次打开日志页又读取一批说明,重复工作会越来越多。
当前采用两种缓存时间:
| 数据 | 缓存时间 | 原因 |
|---|---|---|
| appcast 索引 | 5 分钟 | 随正式发布变化,需要较快刷新 |
| 已发布版本说明 | 1 天 | 按版本固定,发布后原则上不覆盖 |
这是函数显式使用 Cache API 保存的数据缓存。R2 绑定的 get() 不会因为配置了一个 fetch 缓存参数就自动走相同机制。Cache API 的内容保存在各数据中心,不能保证全球同一时刻刷新。Cache API 文档
所以上传新清单后,某些地区的官网可能还短暂显示上一版。软件也在自己的检查时机读取更新源,不是清单上传成功后所有用户立即收到推送。
应用读取的公开 appcast 地址仍按更新源规则绕过 CDN 缓存;官网函数的五分钟内部缓存,是另一层缓存。两者不要混在一起看。
动态页面响应本身使用 no-store,避免生成的 HTML 又被长时间缓存。安装包使用独立的版本地址,适合采用较长缓存时间。
再说失败处理。读取不到清单、缺少英文说明、数据格式错误时,网站会保留整份静态页面,记录具体错误。不会只换新版标题,却留下旧版下载地址。
当前实现先把数据和替换后的完整 HTML 准备好,再返回响应,确保转换过程中出错时仍能使用原始页面。
判断是否真的同步成功,可以看响应头:
1 | |
如果看到:
1 | |
说明网站正在使用静态兜底。它能让下载页继续可用,但不保证显示最新版。静态兜底需要在网站维护时刷新,错误日志也要能查到原因。
十、这次真正踩到的坑:Functions 有依赖,构建命令不能继续留空
网站原来是纯静态 HTML,Pages 的构建命令可以留空。
加入 Functions 后,代码用了 fast-xml-parser 来解析 XML。本地已经安装依赖,所以本地编译和验证正常。云端第一次部署却报错:
1 | |
这两行放在一起,原因就很清楚了:这次云端没有执行依赖安装,编译函数时找不到解析器。
处理方式是在 Pages 的构建配置中设置:
| 设置 | 值 |
|---|---|
| 框架预设 | 无 |
| 构建命令 | npm ci && npm run check |
| 构建输出目录 | public |
| 根目录 | 仓库根目录,留空 |
npm ci 按锁文件安装依赖;npm run check 检查静态页面;之后 Pages 编译并发布 Functions。仅修改 README 不会自动改变控制台里原来留空的构建命令。Pages 构建配置
Node 版本也要明确。本项目锁定的本地工具要求 Node 22 及以上,可以在“变量和机密”中配置 NODE_VERSION,例如 22.16.0;如果选择已发布的 Node 24 版本,也应在云端实际验证一次。构建环境与版本设置
也不要按错误提示把 fast-xml-parser 标成 external 来绕过构建。函数运行时同样需要它,正确做法是安装并打包依赖。
另一个需要注意的地方是响应头:静态文件的 _headers 规则不会自动给 Functions 生成的响应补齐所有头。项目在函数响应中显式保留现有安全策略,并去掉转换后不再适用的长度和静态 ETag。Pages 响应头说明
十一、以后发布,网站和软件分别要做什么
可以把工作分成两条线。
网站代码发生变化时,例如调整布局、增加截图、修改函数逻辑,需要修改官网仓库,再通过 GitHub 与 Pages 部署。
普通软件发版时,网站函数已经部署,就只更新 R2 的发布数据,不需要为版本号再改六份 HTML。
flowchart TD
subgraph SITE[官网代码更新]
A[修改页面、截图或函数] --> B[提交并推送官网仓库]
B --> C[Pages 安装依赖、检查并部署]
end
subgraph RELEASE[普通软件发版]
D[准备并审校双语说明] --> E[本机测试、签名、公证和验证]
E --> F[生成 ZIP、校验文件、说明和候选清单]
F --> G[先上传本版文件到 R2]
G --> H[验证公开文件]
H --> I[最后发布 appcast]
I --> J[官网按缓存窗口读取新版]
I --> K[软件下次检查时发现新版]
J --> L[核对网页并登记正式发布]
K --> M[完成真实升级验收]
end
本机操作现在集中到一个入口。下面以示例脚本 scripts/release.sh 演示操作顺序,实际入口名称按自己的项目替换:
1 | |
之后仍然手动上传 R2。先上传本版目录中的 ZIP、校验文件和两份说明,然后运行:
1 | |
文件核对通过,再上传 appcast:
1 | |
--record 会更新仓库中的 docs/published-appcast.xml,需要审查后提交。它不是“上传成功就记一笔”,而是公开文件和完整清单与本地一致时才登记。
最后还要检查官网六页,以及用旧应用完成一次下载、安装、重启。函数响应正常、公开文件可下载、应用实际升级成功,是三件不同的事。
Shell 脚本负责串起工具和执行顺序,Python 处理版本与 XML。日常只使用统一入口,不需要逐个运行内部脚本,也没有必要为了语言一致把它们重写成一种语言。
十二、已经验证的和还需要真跑的
目前本地已经验证了:
- 中英文六页使用同一版本、下载地址和包大小,首页 JSON-LD 同步;
- 基本 Markdown 渲染和危险内容处理;
- 日志每页十版,读取有数量及大小上限;
- 说明缺失、清单损坏时保留完整静态页面;
- 入口别名重定向正常,其他应用和静态资源保持原行为。
本地使用 Wrangler 与模拟 R2 验证。普通 Python 静态服务器只会展示兜底 HTML,不能用来判断 Functions 是否工作;本地模拟桶也不会自动读取生产数据。
初次云端部署还暴露了上面的依赖安装问题。生产环境是否已经正确绑定、函数是否真的返回动态数据,应以部署结果和响应头为准,不能因为页面能打开就认为全部完成。
构建 6 的目录只是本文示例。下一版双语说明在 Sparkle 中的实际展示、公开文件核对、旧版真实升级,都需要用正式产物再验一次。
做到这里,重复手填的版本信息基本移交给发布数据了。每次发布仍然需要认真写说明、验证用户拿到的包和完成升级验收;网站里那些“版本改了,链接忘了改”的工作,则不必再靠记忆。