Slint Visual Editor 的 Linux Flatpak 分发管道:构建、发布、签名与自更新
本文是一份围绕 Slint 开源仓库中 Linux 版 Slint Visual Editor(应用 ID dev.slint.VisualEditor)Flatpak 分发体系的深度技术指南,涵盖 CI 双架构构建入口、无历史 OSTree 仓库的发布策略、Cloudflare R2 缓存规则、GPG 签名方案、应用内自更新机制以及本地可复现的测试与验证方法。读完本文,你将掌握 Slint 团队如何把一款依赖 Skia 的桌面编辑器以 Flatpak 形式交付给 nightly 用户,并理解每一处设计取舍背后的工程依据。
本文主体基于 docs/development/linux-visual-editor-flatpak.md(仓库内文档明确标注其将来会并入正式文档,目前作为占位说明存在),并交叉引用了仓库内对应的工作流、脚本与 Rust 源码作为实现佐证。
整体架构:一条从 CI 到用户桌面的发布链路
Linux 版 Slint Visual Editor 的分发与自更新链路可以分为四个环节,本文按此展开:
- CI 构建:在 GitHub Actions 上为 x86_64 与 aarch64 两个架构构建 Flatpak,产出单文件 bundle 与 OSTree 仓库;
- 合并与发布:把两个架构的仓库合并、写 summary、按三阶段顺序上传到 Cloudflare R2,并生成
.flatpakref供用户安装; - 缓存与签名:按"内容寻址对象不可变、索引可变"的原则设置
Cache-Control,并准备一套"公私钥配对校验"的签名方案(当前尚未启用); - 应用内自更新:编辑器通过
org.freedesktop.portal.Flatpak门户安装更新、在用户确认后重启到新部署,整个逻辑集中在 tools/editor/flatpak.rs 一个文件中。
Flatpak 清单本身位于 tools/editor/dev.slint.VisualEditor.yml:基于 org.freedesktop.Platform 25.08 运行时,通过 org.freedesktop.Sdk.Extension.rust-stable 与 org.freedesktop.Sdk.Extension.llvm20 两个 SDK 扩展提供 Rust 工具链和 Skia 所需的 clang/libclang,并在沙箱内从源码构建 Skia(gn 作为独立 module 先构建,随后通过 SKIA_GN_COMMAND=/app/bin/gn 交给 skia-bindings 的离线构建路径使用)。finish-args 中除常规的 --device=dri、--share=ipc、--socket=wayland、--socket=fallback-x11 外,还显式开放了 --filesystem=host(因为 .slint 文件通常通过相对路径引用同目录的图片、字体和其他 .slint 文件,仅靠门户文件对话框打不开工程)与 --share=network(live-preview 需要连接到沙箱外的应用)。
CI 入口:双架构构建与 nightly 调度
构建由两个 GitHub Actions 工作流驱动:
- .github/workflows/visual_editor_linux_flatpak.yaml 负责把应用构建为 Flatpak,目标架构为 x86_64 与 aarch64。触发条件有三:向
visual-editor分支提交的 pull request、手动 dispatch、以及被其他工作流以 reusable workflow 方式调用。工作流通过matrix在两个 runner 上并行构建(fail-fast: false,避免一个架构失败掩盖另一个的报错),其中 aarch64 使用ubuntu-24.04-armrunner。整个构建跑在ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08容器中,并缓存.flatpak-builder状态以加速增量构建。 - .github/workflows/visual_editor_nightly.yaml 每天一次(cron 为
40 3 * * *)调用 Linux 构建工作流,同时并行的还有 macOS 构建(visual_editor_macos_dmg.yaml)与 Windows 构建(visual_editor_windows_msix.yaml,走 Store preview 渠道)。Linux 与 macOS 的发布是两个独立的 job,一个平台失败不会拖住另一个。Windows 交给 Store 签名分发,因此没有自托管的发布步骤。
每个 Linux 构建会产出两样东西:
- 一个单文件
.flatpakbundle,给只想一次性安装的用户; - 该 bundle 导出时所在的 OSTree 仓库,它是
flatpak update能工作的基础。
仓库在上传前会被打成 tar:archive 模式的仓库包含数万个小文件,而 GitHub artifact 按文件数计费,先 tar -cf repo-${{ matrix.arch }}.tar 再上传(见工作流中的 Pack the repository 步骤),发布侧再解包,速度会快数分钟。
构建前还需要生成两份源码清单:scripts/generate_visual_editor_flatpak_sources.bash 会下载固定 commit 的 flatpak-cargo-generator.py,基于 Cargo.lock 生成 tools/editor/cargo-sources.json,并用 scripts/flatpak-skia-generator.py 生成 Skia 及其全部第三方 DEPS 的清单。因为 Flatpak 沙箱没有网络,每个 crate 和 Skia 源码树的每个部分都必须预先声明;这两份生成文件不检入仓库,Cargo.lock 变化后需要重新运行。
发布产物布局与用户安装方式
发布目标是 visual-editor-updates 这个 R2 bucket,对外由 visual-editor.slint.dev 提供服务。nightly 渠道的布局如下:
nightly/flatpak/ OSTree repository
nightly/slint-visual-editor.flatpakref installs the app and adds the remote
nightly/slint-visual-editor-x86_64.flatpak one-off download, updates from the repository above
nightly/slint-visual-editor-aarch64.flatpak
stable/ 前缀被保留,渠道逻辑也接受它,但在有打 tag 的正式发布之前不会有任何东西发布到那里。用户安装 nightly 的方式只有一条命令:
flatpak install https://visual-editor.slint.dev/nightly/slint-visual-editor.flatpakref
渠道(channel)由环境变量 SLINT_EDITOR_CHANNEL 控制(默认 nightly,仅接受 nightly 或 stable),它同时决定发布前缀和 OSTree 分支名,因此 flatpak info 可以直接看出一个安装来自哪个渠道(见 scripts/publish_visual_editor_flatpak.bash 第 16-26 行)。
无历史(No History)发布策略
这套管道的核心设计是每一次运行都导出一个全新的仓库并整体发布:运行开始不需要向下同步旧数据,运行结束也没有任何需要清理的旧提交。
文档强调,这是实测而非假设得出的结论:
- 一个客户端从"与已安装版本毫无共同历史"的新提交更新,可以干净成功:无警告,
ostree fsck结果干净,因为客户端只保留自己安装的那个 commit,根本用不到祖先链; - OSTree 是内容寻址的,无论历史如何,未变化的文件都会被跳过。测试中 30 MB 未变化文件加上一个 5 MB 部分重写的文件,无论"有历史"还是"无历史"的客户端,都不会重新拉取那 30 MB。
付出的代价是静态 delta(static deltas):delta 需要一个祖先来 diff,而无历史渠道的客户端只能拉取整个变更对象——同样的变更,无历史客户端要拉 4.8 MB,而有历史时只需 0.1 MB。这是为了让管道大幅简化而接受的取舍。
因此发布脚本刻意不传 --generate-static-deltas。测试观察到:即使仓库里存在 delta 对象,无历史客户端也一个都不会拉取;生成它们只会浪费构建时间和仓库空间。对应实现见 update_repo() 函数(scripts/publish_visual_editor_flatpak.bash 第 198-224 行)的注释。
发布流程:五个可独立执行的阶段
scripts/publish_visual_editor_flatpak.bash 驱动整个发布,各阶段可以单独运行:
./scripts/publish_visual_editor_flatpak.bash merge-repos
./scripts/publish_visual_editor_flatpak.bash update-repo
./scripts/publish_visual_editor_flatpak.bash write-flatpakref
./scripts/publish_visual_editor_flatpak.bash publish
./scripts/publish_visual_editor_flatpak.bash publish-flatpakref
除此之外脚本还提供 write-bundle、publish-bundles、generate-key、repo-url 等子命令,full(默认)依次执行前五个阶段。
merge-repos:两个架构第一次汇合
merge-repos 把构建 job 产生的各架构仓库合并为一个。构建运行在相互独立的 runner 上,因此这是所有架构第一次同时存在的地方,而 summary 只有在它们齐全之后才能写。实现上先 ostree init --mode=archive(archive 模式每个对象存一个压缩文件,这是通过普通 HTTP 提供服务的仓库必须的形态),再对每个 repo-<arch> 执行 ostree pull-local,且不指定 ref——这样除了应用本身,flatpak-builder 一同产出的 .Debug、.Locale ref 也会被一并合并(脚本第 134-160 行)。
publish:三阶段上传,顺序就是全部要点
publish 分三遍上传,顺序本身是设计的核心(脚本第 259-296 行):
aws s3 sync对象,不带--delete,仅添加:仓库在整个过程中始终可以从旧的 summary 提供服务。用--size-only,因为解包(untarring)会重置所有修改时间,而对象路径就是内容哈希——同名即同字节,大小一致就足以跳过上传。aws s3 cp --recursivesummary 和 refs:新 commit 在这一刻上线。刻意不用sync --size-only:重写的 summary 长度可能和旧的一模一样,跳过它就会让新 commit 孤零零地躺在 bucket 里、没有任何指针指向它。aws s3 sync --delete对象:所有仍被引用的对象保留了内容哈希,在新仓库里同样存在,因此能存活;只有真正死掉的对象被删除。第一遍已上传了全部内容,所以这一遍只做删除。
--delete 在这里是正确的工具而非隐患,正因为无历史仓库按构造就是完整且权威的。如果把它指向一个残缺或半构建的本地仓库,它会清空已发布的仓库——这是必须理解的风险边界。
第三遍存在一个"存活窗口":刚刚读过旧 summary 的客户端可能请求一个恰好被删除的对象。它会表现为可重试的 404,下一次尝试就会成功。文档的建议是:如果这个窗口变得明显,把这一遍延后一天,而不是试图去压缩窗口。
顺带一提,wrangler(macOS 发布用的 R2 CLI 工具)无法胜任这个任务:它一次只能上传一个对象,而且没有列出对象的能力,第三遍删除根本写不出来。
缓存策略:内容寻址对象不可变,索引永不缓存
缓存必须在上传时设置,因为从缓存提供服务的仓库会"谎报"当前 commit。具体规则如下表:
| path | Cache-Control |
|---|---|
flatpak/objects/** | public, max-age=31536000, immutable |
everything else under flatpak/ | no-cache |
slint-visual-editor.flatpakref | no-cache |
两处实现要点:
- Cloudflare 的坑:当 zone 的 browser TTL 被设为固定值时,Cloudflare 会把裸的
no-cache替换掉,所以visual-editor.slint.dev的 Cache Rule 必须设为"尊重源站 TTL"(respect origin TTLs),否则 summary 可能被陈旧地缓存数小时; - 禁止给 flatpak 前缀加 R2 lifecycle 规则:对象跨 commit 共享,按年龄过期会删掉当前 commit 仍引用的对象。清理是 OSTree 的职责,且只能是 OSTree 的职责。
nightly/builds/上针对 macOS DMG 的 lifecycle 规则是正确的,而 flatpak 前缀看起来和它很像——这正是文档特意写下来的原因。
脚本中对应常量为 IMMUTABLE_CACHE="public, max-age=31536000, immutable" 与 MUTABLE_CACHE="no-cache"(脚本第 106-110 行),三遍上传分别把它们套在对象和 summary 上。
签名方案:尚未启用,但机制已完整就位
当前仓库以未签名状态发布:客户端安装时不验证任何东西,bucket 就是信任边界——HTTPS 保护传输,但任何能写入 visual-editor-updates 的人都可以向每个 nightly 用户投递任意代码。文档明确这是为了让渠道先跑起来而做的刻意取舍,不应该延续到 stable 渠道。
签名的启用条件是公私钥两半同时存在,脚本(signing_enabled(),第 42-56 行)在只有一半时会直接报错退出,因为半对密钥永远是错误:已签名仓库的密钥无人持有则无法安装;flatpakref 里写了一个仓库没有用其签名的密钥,则会被每个客户端拒绝。
启用步骤如下(generate-key 子命令生成 ed25519 密钥对,sign 能力、无过期时间):
./scripts/publish_visual_editor_flatpak.bash generate-key
然后按此顺序操作:
- 把私钥放进团队密码管理器。GitHub secrets 无法读回,只存在于其中的密钥将来无法备份;
gh secret set EDITOR_FLATPAK_GPG_PRIVATE_KEY --repo slint-ui/slint < the-private-key- 把公钥检入仓库,路径为 tools/editor/packaging/linux/slint-visual-editor.gpg;
- 删除本地副本。
其中第 2、3 步必须同时落地——单独任一步都会让构建失败,这正是设计意图。
几个值得注意的设计决策:
- 公钥检入仓库的原因和
SUPublicEDKey一样:它是用户 remote 钉住的信任锚点,任何替换都必须通过 diff 暴露出来。它通过.flatpakref的GPGKey=字段(base64 编码)到达用户;省略该字段正是告诉客户端"此 remote 未签名"的方式; - 启用后有两样东西被签名:
build-sign给每个 commit 附上objects/<hash>.commitmeta签名,证明内容;summary 签名证明哪个 commit 是当前的——没有它,能写 bucket 的人可以放一个旧 summary,把用户冻结在旧构建上; update-repo会拒绝在"secret 不是检入公钥的私钥一半"时运行。这个不匹配是值得防御的失败模式:它构建全绿,只在用户机器上失败;- 密钥刻意不设过期时间。过期的签名密钥会让所有既有安装在某个人人都不记得的日期开始拒绝更新,而且不会有什么构建失败来提醒你。
CI secrets:最小化凭据与 R2 派生 S3 凭据
Linux 发布需要的最少 secret 只有两个:
VISUAL_EDITOR_R2_API_TOKEN与CLOUDFLARE_ACCOUNT_ID——和 macOS job 交给 wrangler 的完全一样,不需要额外凭据;EDITOR_FLATPAK_GPG_PRIVATE_KEY:可选,目前未设置。设置它会开启签名,并要求同时检入公钥。
没有单独的 S3 凭据,因为 R2 可以从普通 API token 派生 S3 凭据:Access Key ID 就是 token 的 id,Secret Access Key 是 token 值的 SHA-256。derive_s3_credentials()(脚本第 69-95 行)自己计算哈希,并通过 /user/tokens/verify 查询 id。
需要注意:/user/tokens/verify 只对用户所有的 token 有效。对于账户所有的 token,需要把 id 放进 VISUAL_EDITOR_R2_TOKEN_ID 仓库变量——它是标识符而非机密,因此不需要作为 secret 保管。此外,aws_s3() 包装器还显式设置了 AWS_REQUEST_CHECKSUM_CALCULATION=when_required 与 AWS_RESPONSE_CHECKSUM_VALIDATION=when_required,因为 R2 会拒绝 AWS CLI 默认发送的 flexible checksums。
应用内自更新:门户、启动检查与重启语义
编辑器通过 org.freedesktop.portal.Flatpak 门户自更新,这是沙箱应用获知并安装自己更新的正途。它不需要任何 finish-args——flatpak 的 session bus proxy 已经允许每个应用与门户通信。全部实现就是 tools/editor/flatpak.rs 一个文件。
门户替我们决定了两件事:
① 无法请求"立刻检查"。 CreateUpdateMonitor 只会装一个定时器——默认 30 分钟一次,创建时不检查——所以夜里发布的更新在会话的前半小时内不会被告知,更短的会话则永远听不到。因此编辑器在启动时通过 HTTPS 读取 OSTree ref 作为替代:整个过程生命周期一次 65 字节的请求,其余会话时间交给 monitor。失败就是沉默:离线、强制门户、404 都与编辑器无关(remote_commit() 中任何错误只记日志返回 None)。
② Update 只部署、不重启。 运行中的进程仍挂载着旧部署,所以一次完成的更新止步于"Restart to update"。点击后调用 Spawn 并携带 FLATPAK_SPAWN_FLAGS_LATEST_VERSION(源码中常量 SPAWN_LATEST_VERSION: u32 = 2),从新部署启动应用并传递同样的 argv——这样编辑器打开的文件不会丢失。没有任何东西自动重启:失去正在运行的预览的时机属于正在使用编辑器的人。
更新检查的 URL 完全来自 /.flatpak-info(parse_instance() 读取 [Application] 段的 name、[Instance] 段的 arch/branch/app-commit),因此同一个二进制对两个渠道、两个架构都是对的:
https://visual-editor.slint.dev/<branch>/flatpak/refs/heads/app/<app id>/<arch>/<branch>
SLINT_FLATPAK_BASE_URL 环境变量可以覆盖到 /flatpak 为止的部分,本地测试正是靠它指向本机仓库。connect() 在 /.flatpak-info 不存在时(比如 cargo run 开发模式)返回 None,更新器静默不激活——这也顺带充当了"是否以 Flatpak 打包"的检测。
用户可能遇到的两件事:
- 门户只问一次。"The application wants to update itself" 来自门户而非编辑器,答案记在 permission store 里,可用
flatpak permission-show dev.slint.VisualEditor查看; - bundle 安装能更新,但不验证。bundle 点名了渠道的仓库,所以它的 origin remote 有地方可更新;但它带不了密钥——
flatpak build-bundle会把一个全新 commit 写进 bundle,不带仓库的签名,因此点名密钥的 bundle 会拒绝安装。从 flatpakref 安装才是验证路径。
Rust 端的状态机也很值得读:Event 枚举(UpdateAvailable / RestartRequired / UpToDate / Downloading / Installing / Failed)跨线程边界进入 UI;update_available() 用 running/local/remote 三个 commit 区分"远端有待下载的更新"与"别人已经帮你部署好了、只差重启"两种情况;progress() 依据门户 Progress 信号的 status 值(0=Running、1=Empty、2=Done、3=Failed)推进 UI 状态。文件末尾还有三个单元测试,用一份从 nightly 实际运行实例裁剪的 FLATPAK_INFO 片段验证解析逻辑。
本地测试更新:不发布也能走完整个流程
scripts/local_flatpak_update_test.sh 可以在本机对一个仓库跑完整更新路径——启动横幅、门户安装、重启进新部署——而不发布任何东西:
scripts/local_flatpak_update_test.sh --bundle slint-visual-editor-x86_64.flatpak
它的工作方式:把 bundle 导入一个仓库,按 visual-editor.slint.dev 同样的布局在 localhost 上提供(python3 -m http.server,默认端口 8766,路径按 ref 的分支组织),从 flatpakref 以 --user 安装使 origin remote 指向本地服务器,然后用 flatpak build-commit-from --force 从相同内容制造第二个 commit——这样应用有"等待中的更新"却不必构建两次。支持参数包括:
--bundle FILE:单文件 bundle(来自本地构建脚本或 nightly 下载);--repo DIR:直接使用一个已导出的 OSTree 仓库,替代 bundle;--port PORT、--work-dir DIR:自定义本地服务器端口与工作目录;--clean:卸载测试应用、删除 remote、停掉服务器、清理 permission store,全部还原。
--user 安装在测试运行期间优先于同 ID 的系统级安装。运行后脚本会给出预期行为清单:启动约 1 秒内出现 "Update" 横幅 → 点击后门户问一次是否允许自更新 → 横幅依次经过 Downloading、Installing → "Restart to update" → 点击后以新 commit 启动。如需跳过门户提问,可用 flatpak permission-set flatpak updates dev.slint.VisualEditor yes。
本地验证发布流程:MinIO 秒级演练
不构建真实应用(耗时约两小时)也能演练发布路径:把脚本指向本地 rclone 远程和一个 dummy 应用,merge、密钥守卫、flatpakref、三遍上传顺序都能在一分钟内于容器中验证:
export AWS_ENDPOINT_URL=http://localhost:9000 AWS_DEFAULT_REGION=auto
export AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin
同时设置两个密钥会短路凭据派生逻辑,因此任何 S3 兼容端点都可用,MinIO 就在其中。需要说明的边界是:这条路径无法验证 flatpak-builder 的调用本身——它只覆盖发布侧的合并、签名、ref 生成与上传顺序,真实构建验证仍需走完整 CI。
小结
Slint Visual Editor 的 Linux 分发管道是一套"以简化换可靠性"的工程样板:无历史 OSTree 仓库让发布变成纯增量的三阶段上传;内容寻址让不可变缓存与按需删除同时成立;门户驱动的自更新把"部署"与"重启"严格分离;而签名机制虽然当前未启用,其公私钥配对守卫、summary 签名与无过期设计已经为 stable 渠道备好了完整的信任模型。对于任何想在 Flatpak 上做自更新分发的项目,scripts/publish_visual_editor_flatpak.bash、tools/editor/flatpak.rs 与 scripts/local_flatpak_update_test.sh 三份文件构成了可以直接参照的完整闭环。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



