PyStudio 不可能把 Python、pip、debug 工具、tree-sitter、proot、Alpine rootfs、C/C++ 工具链全塞进 APK。App 本体要轻,运行时能力又要能按需安装和更新,这中间必须有一层清单。

这篇记录我最后采用的模型:runtime-packages.json 负责发现能力,Packages.xz 负责包级依赖,planner 负责算安装计划,installer 把计划变成用户能看见的终端脚本。

清单不是另一个 apt。它负责告诉 App 去哪里找能力,真正的依赖关系仍然交给包索引。

为什么不用一堆写死链接

最早的想法很简单:App 里放一个按钮,点了就下载某个包。问题也很快出现:

  • 不同 ABI 的包不同:aarch64armi686x86_64
  • 有些能力不是一个文件,而是一组 .deb 依赖;
  • 同一个 profile 会不断重建,比如 proot 从 r40 到 r100;
  • bootstrap 也需要灵活更新,不能永远内置在 APK;
  • 下载源会变:GitHub、Gitee、ModelScope、Cloudflare Pages、自定义域名;
  • 安装时用户需要看到 URL、进度、速度、错误,而不是一个转圈。

所以清单必须成为唯一事实来源。App 不推导旧 URL,也不猜包名,而是从清单读取 entry、repository、commands、架构、包池和校验信息。

schema 5 的基本模型

现在的清单是 schema 5,大体分三层:

1
2
3
4
5
6
7
runtime-packages.json
-> entries
描述用户看到的能力,例如 PRoot Runtime、Python LSP、Debug Tools
-> repositories
描述某个 profile 在某个 ABI 下的 Packages.xz 和 package pools
-> package pools / mirrors
描述真正下载 .deb 的 baseUrl

一个 package-set entry 不直接等于一个下载链接,而是引用某个架构仓库:

1
2
3
4
5
6
7
8
{
"id": "proot",
"type": "package-set",
"commands": ["proot", "termux-chroot"],
"architectureRepositories": {
"x86_64": "repo:proot:primary:x86_64:pystudio-toolchains-r100:pystudio-proot-toolchain-primary"
}
}

这样 UI 可以展示“这个包会提供哪些命令”,安装器可以从 Packages.xz 解析真正的依赖。

App 里的三层实现

planner 不碰文件系统,只产出安装计划;installer 才负责写脚本和进入终端会话。
Kotlin 侧分成三层。

第一层是 catalog:

1
2
3
4
5
RuntimePackageCatalog
-> fetch runtime-packages.json
-> parse schemaVersion = 5
-> select ABI repository
-> expose entries / repositories / package pools

第二层是 planner:

1
2
3
4
5
6
RuntimePackagePlanner
-> download Packages.xz
-> parse Debian stanza
-> resolve Depends / Pre-Depends
-> skip exact installed packages
-> produce ordered install plan

第三层才是 installer:

1
2
3
4
RuntimePackageInstaller
-> write install-runtime-packages-*.sh
-> open a terminal conversation
-> run the script in the same prefix

这个分层很重要:planner 不改文件系统,适合以后写测试;installer 只负责把计划变成用户看得见的终端输出。

为什么要在终端里跑安装脚本

用户安装依赖时,最关心的是“到底卡在哪”。如果 App 只是显示一个常驻 installing 状态条,失败时体验很差。

后来我把安装过程改成在终端会话里滚动显示,脚本会打印:

1
2
3
4
5
6
7
8
Package: proot
URL: https://example.invalid/pool/x86_64/proot_5.1.107.81-3_x86_64.deb
HTTP: 200
Downloaded: 103732 bytes
Speed: 512 KiB/s
Elapsed: 0.20s
Install: dpkg --force-depends -i ...
Verify: proot --version

这比单纯弹 Toast 有用得多。用户能看到哪个 URL 失败、是不是 404、是不是 dpkg 配置失败、是不是命令没出现在 $PREFIX/bin

一个小坑是 shell heredoc 不能随便缩进。下载脚本里如果 URL 或 heredoc 结束标记前多了空格,shell 可能把后续安装命令都当成输入内容,最后表现成“下载完了但 dpkg 没跑”。这种错误在 UI 上很像安装器卡住,实际是脚本生成格式错了。

真实踩坑:索引发布了,deb 没发布

r100 proot 的清单和 Packages.xz 已经能拉到,元数据也显示:

1
2
proot 5.1.107.81-3
runtime-fork-to-clone-seccomp

但安装仍然可能失败,因为真正的 .deb URL 返回 404。这个坑非常典型:

1
2
3
Packages.xz: OK
runtime metadata JSON: OK
proot_5.1.107.81-3_x86_64.deb: 404

也就是说,清单“能列出来”不等于包“能安装”。发布流程至少要检查三类文件:

  • runtime-packages.json
  • 每个 ABI 的 Packages.xz 和 metadata JSON;
  • package pool 里的实际 .deb 文件。

我后来在文档里把这个写成发布验收项:App 安装失败时,不要只看 JSON,要抽查最终 .deb 直链。

下载源迁移的经验

清单地址也换过几次,从 Gitee/raw GitHub,到 Cloudflare Pages,再到自定义域名:

1
https://pystudio.yourba.top/runtime-packages.json

App 侧这里需要补两件事:默认入口改到新域名,已经保存过旧地址的用户也要自动迁移。否则老用户设置里的 downloadSource 会一直指向旧 URL,即使 APK 已经更新也没用。

类似这样的归一化很实用:

1
2
3
4
5
6
7
8
private fun normalizeCatalogUrl(url: String?): String? {
val normalized = url?.trim()
return when {
normalized.isNullOrBlank() -> normalized
normalized in LEGACY_CATALOG_URLS -> DEFAULT_CATALOG_URL
else -> normalized
}
}

校验开关不要绑死

开发阶段我一度想把 sha256 验证做成强制项。后来发现更合理的是:清单保留 sha256 字段,但 App 设置里提供“下载校验”开关。

原因很简单:早期包还在频繁重建,清单、镜像、pool 同步可能有时间差。强校验能保证安全,但也会在调试阶段放大同步成本。更好的做法是:

  • 默认给普通用户稳定策略;
  • 设置里允许打开严格校验;
  • 终端脚本把实际 URL、大小、耗时、错误都打印出来;
  • 发布流程在 CI 或本地脚本里做完整校验。

不要把调试期的发布不稳定,伪装成 App 安装器的神秘错误。

我现在会坚持的规则

清单只是入口,不是安装结果。发布时必须抽查最终 .deb 文件能不能下载,App 也不应该再去拼旧世界的 URL;包在哪里,由 manifest 和 package pool 说了算。

安装过程要在终端里透明运行。尤其是开发者工具类 App,用户宁愿看到真实错误,也不想看一个不会消失的状态条。bootstrap 也应该进清单,这样 APK 可以保持轻量,rootfs 和 prefix 初始化包可以独立迭代。

命令可用性要来自清单里的 commands 字段和安装后的 verify command。点击运行 Python、tree-sitter、clangd 之前,先检查命令是否存在;不存在就引导安装,不要直接报一屏 shell 错误。

这套方案真正解决的不是“有了一个 JSON”,而是把运行时能力从 APK 里解耦出来,让包、清单、终端输出、设置页和诊断路径连成一条能维护的链路。