最近做了几个 Magisk / KSU 模块,自己用下来感觉挺好用,放出来顺便写写实现。

后两个模块都带管理器更新检测,支持稳定版和预发布版渠道。应用模块也适配了 APatch,不过我这里主要按 Magisk / KernelSU 的用法写。

后台进程交给 runit

在 Android 上跑这些东西,开机执行一次启动命令很容易。麻烦的是后面的事:进程退出以后谁来拉起,要停用时怎么保证它不会又启动,日志又往哪里放。

这几个模块里,我把这些工作交给了 runit。runsvdir-magisk 用的是 Termux 的预编译二进制,构建时去掉原来的 RUNPATH。模块本身负责安装 runsvdir、runsv、sv、svlogd、chpst 等工具,开机等到 sys.boot_completed=1,再启动 runsvdir。它扫描的服务目录是:

/data/adb/runsvdir/service/

sing-box 和 OpenList 各占一个子目录,里面的 run 是启动脚本,conf 放启动参数,log/run 则启动日志子服务。runsvdir 为每个服务启动 runsv,由后者监管具体进程;服务退出后会重新执行 run。

这里有个很实际的要求:被监管的程序要保持前台运行,启动脚本最后用 exec 替换自身。以 OpenList 的 nomount 版为例,省略存储检查后,启动部分就是:

#!/system/bin/sh
exec 2>&1
cd "$(dirname "$0")" || exit 1
. ./conf
exec ./bin/openlist server --data "$OPENLIST_DATA"

如果在最后加个 &,shell 启动完后台进程就退出了,runsv 会把它当成服务退出,再启动一份。exec 2>&1 把标准错误并到标准输出,接着由 log/run 里的 svlogd -tt 收日志、加时间戳并轮转。看日志时直接读 current:

tail -f /data/adb/runsvdir/log/sv/sing-box/current
tail -f /data/adb/runsvdir/log/sv/openlist/current

以后再接别的程序,沿用这个目录结构就行,服务的启停命令也不用换一套。

启停和开机自启分开处理

先装 runsvdir-magisk 并重启,再刷应用模块,ZIP 要选对应的 ABI。大部分手机用 arm64-v8a,仓库里也打了 armeabi-v7a、x86_64 和 x86 的包。应用模块的安装脚本会检查前置模块,缺了就中止安装。

sing-box 和 OpenList 的安装包都带一个 down 文件,首次安装默认不启动。尤其是 sing-box,还没放配置就拉起来只会反复报错。它默认读取的配置在:

/data/adb/runsvdir/service/sing-box/workdir/config.json

准备好配置以后,可以点管理器里的模块「操作」按钮,音量下切换选项,音量上执行,选「启动并启用」。我也保留了直接用命令的方式,下面这些在 root shell 里执行:

export SVDIR=/data/adb/runsvdir/service

sv-enable sing-box
sv-enable openlist
sv status "$SVDIR/sing-box" "$SVDIR/openlist"

# 这次停止,保留原来的开机自启设置
sv down "$SVDIR/sing-box"

# 停止,并取消开机自启
sv-disable sing-box

# 重新启动服务,读取当前的程序和配置
sv restart "$SVDIR/openlist"

sv down 改的是当前监管进程的状态;sv-disable 还会创建 down 文件,下次开机依然保持停用。反过来,sv-enable 会删除这个文件并启动服务。手动停一次和长期禁用有各自的命令,用起来比较省心。

KernelSU Next 中的 sing-box 操作菜单,显示 nomount 安装方式、服务运行状态和音量键操作提示

手机上的 sing-box 操作菜单。用的是 nomount,核心为 1.15.0-alpha.12,主服务和日志服务都在运行。

为什么分 mount 和 nomount

两个应用模块都做了这两种安装方式,区别先从核心放在哪里说起。

nomountmount
核心位置服务目录里的 bin/模块的 system/bin/,挂载到 /system/bin/
应用模块是否挂载核心否是
更新核心后生效方式重启对应服务重启手机

nomount 的程序直接放在 /data/adb/runsvdir/service/<服务名>/bin/。替换文件以后,下一次启动服务就会执行新的核心,自用时更新很方便。不过 /data/adb 普通用户无权访问,这种方式下应用就以 root 运行。

mount 把核心挂到 /system/bin,普通用户也能读取和执行,于是可以用 chpst 切换运行身份。OpenList 的 mount 版默认配置是:

RUN_AS="shell:shell:sdcard_r:sdcard_rw:media_rw:external_storage"
OPENLIST_DATA="/storage/emulated/0/Android/openlist"
WAIT_DECRYPT=1

这里让 OpenList 以 shell 用户运行,附带访问存储用的组,数据放在共享存储上。nomount 则默认用服务目录内的 ./data。两种方式连数据位置都不同,切换安装方式时也要处理自己的数据路径,不能把刷入另一版当成数据迁移。

共享存储还涉及开机后的解密。WAIT_DECRYPT=1 时,run 检查 sys.user.0.ce_available;没就绪就等十秒后退出,让 runsv 再试,避免程序在存储尚不可用时直接启动。

sing-box 的 mount 版也有 RUN_AS,默认是 root:net_admin。要用 TUN 就得考虑权限,所以不能因为核心放到了 /system/bin,就顺手把它改成普通用户。

mount 更新后需要重启手机,让新核心重新挂载;重启服务本身不会刷新模块挂载。这一点和 nomount 不一样,下载包的时候得看清楚。

更新检测按渠道、安装方式和架构分开

sing-box 和 OpenList 都会跟着上游更新,隔一阵子手动去 Releases 找包挺烦的。两个模块接了 module.prop 里的 updateJson,管理器从对应 JSON 读取版本号、下载地址和更新说明。

更新元数据按三个维度分开存放,例如:

update/stable/nomount/arm64-v8a.json
update/stable/mount/arm64-v8a.json
update/prerelease/nomount/arm64-v8a.json

拿 sing-box 稳定版的 arm64 nomount 包来说,安装后的 module.prop 会指向这个地址:

updateJson=https://raw.githubusercontent.com/sorubedo/sing-box-magisk-runsv/main/update/stable/nomount/arm64-v8a.json

JSON 里是 version、versionCode、zipUrl 和 changelog。这样管理器拿到的下载地址会对应当前架构和安装方式。模块操作菜单里的渠道切换也是改这个 URL,保留 variant 和 ABI,只把 stable 换成 prerelease,或者反过来。

KernelSU Next 中的 OpenList 操作菜单,显示 nomount 服务正在运行,更新渠道为预发布版

OpenList 也用同一套菜单,当前选择预发布渠道。这里还加了随机重置管理员密码的入口。

这只是切换后续检查更新的来源。如果预发布版的版本号比稳定版高,切回稳定渠道不会自动把已安装的核心降级,想回退仍然要手动刷包。

sing-box 的预发布版本还有个细节:alpha.11 和 alpha.12 必须有不同的 versionCode,否则管理器看不出更新。构建脚本把主版本、次版本、补丁号、阶段和阶段内序号编码成整数,同一组主次补丁号里按 alpha < beta < rc < stable 排序。展示给人的 version 仍然沿用上游版本号。

发布流程放在 GitHub Actions 里,定时检查上游,也能手动触发。发现新版本以后,下载核心、打出两种安装方式和四种 ABI 的 ZIP,发布 Release,再生成更新 JSON。设备端的更新检测和仓库端的自动打包各自完成自己的工作,不需要在手机后台另跑一个更新守护进程。

升级时保留哪些东西

服务目录放在 /data/adb/runsvdir/ 下,和模块安装目录分开。升级覆盖刷入时,sing-box 的 workdir、OpenList 的数据以及原来的自启状态都会保留。

不过 run、conf 和 log/run 也允许自己改,直接覆盖它们会把本地修改冲掉。在安装方式不变时,升级可以用音量键选:音量上只更新核心,音量下完整更新服务脚本和核心。完整更新会覆盖安装包里同名的文件,只有本地才有的文件会留下;自己改过 conf 的话,就要留意这个选择。切换 mount / nomount 会强制完整更新,让启动脚本和新的安装方式匹配。

nomount 的二进制替换也做了点处理:先复制到同目录的临时文件,再重命名覆盖目标。直接往正在执行的二进制里写可能遇到 Text file busy,这种做法可以替换路径,同时让旧进程继续运行。装完以后执行 sv restart,新进程才会用上新核心。

升级保留数据和卸载是两回事。sing-box 卸载会删除整个服务目录,配置也会一起删;OpenList nomount 的 ./data 同样在卸载范围里。OpenList mount 默认放在共享存储上的数据则会留下。卸载前该备份的还是要备份。