把任意网页打包成 Android / iOS 原生 App。
很多网站只在浏览器里活着,不是它们不配有个 app,是没人给它们打包。
- 📱 点开就是它——天天用的站值得一个图标
- 🖥 没有浏览器打扰——地址栏、工具栏、多标签都不在了,屏幕全是内容
- 🔒 应用锁——手势图案锁住冷启动,手机递给别人也打不开你的追剧 app。
- 📤 分享的是 app,不是网址——发朋友一个「追剧 app」装上就能看。
按你的需求选一条路:
下面的 app 由 CI 签名和发布,下载在各自版本的 release 页。每个页面挂三个
ABI 的包,现役手机(arm64)装名字里带 arm64-v8a 的那个。
| App | 干什么的 | 下载 |
|---|---|---|
| 4KVM | 4K 高清影视,电影美剧日更,打开即看 | fourkvm-v0.2.0 |
| DADATU(哒哒兔) | 电影、综艺、动漫在线观看 | dadatu-v0.2.0 |
| YouTube | 网页版装成独立的 App,打开即用 | youtube-v0.2.0 |
底部悬浮工具栏上的 ⚙。工具条浮在网页之上,上滑隐藏、下滑显示,滑到页面 顶部或换页时必然出现(网页播放器进全屏时也让位)。同一条栏上还有后退、 刷新和「更多」(⋯)。
这条栏没有关闭开关,这是刻意设计:它和错误页上的按钮是仅剩的两个设置 入口,允许关掉就等于允许用户把自己锁在外面。
系统返回键被接管成网页后退,退无可退时才退出 app——壳里只有一个页面,返回 键的默认行为(直接杀掉 app)在网页场景里几乎总是误操作。
正式包(你下载到的就是)里的设置页是短版:注入脚本开关、App Lock、版本与 检查更新、清缓存。URL、User agent 那类调试项只在 debug 构建里显示。
设置页里可以开一道手势图案锁(3×3 九宫格,至少连 4 个点):冷启动时锁,
切后台超过 30 秒回来也锁。图案有方向,1-2-3 和 3-2-1 不是同一个。
存的是图案的 SHA-256(不加盐):翻一眼存储文件不会直接看到密码——不防拿到 设备的人枚举,全部合法图案不到 40 万种。忘了图案没有恢复路径:锁屏 盖住了工具栏,系统返回键也不响应,只能清应用数据或重装。这是刻意的: 留后门的锁不叫锁。
底部栏的 ⋯ → Share app,走系统分享面板。发出去的是这个 app 本身:名字、
一句介绍、本版本 release 页地址(<bundleId 末段>-v<version> 的 tag)。朋友
从 release 页按自己的设备挑 APK 装——直链只能钉死一个 ABI,发错设备就是
「应用未安装」。介绍文案来自打包时写进配置的 description。
冷启动时去主仓的 GitHub Releases 查有没有新版,一天最多一次;有就弹一次 提示,点「更新」跳系统浏览器下 APK。设置页里能手动查、能关自动检查。 iOS 不自动查——侧载的 IPA 点了链接也装不上。
两条已知边界:api.github.com 不可达(墙内常见)时一律静默——不弹错、
不重试;只给 arm64 设备推包,只剩 32 位的老设备拿到的包装不上。
给任意网站打个包,全程在 GitHub 上完成,本机不用装 Flutter SDK、Android SDK、JDK。
- Fork 这个仓库
——
workflow_dispatch要仓库的写权限,在别人的仓库里你看不到Run workflow那个按钮。 - 去你 fork 的 Actions 页 → 左边选 build → 右上 Run workflow
- 填表:URL 和 app 名必填,bundle id 默认
com.pake.app(打多个 app 要各不 相同,否则装在一起会互相覆盖);图标留空会自己从站点抓,抓不到就按 app 名 生成一个(见图标从哪来),版本留空是1.0.0 - 跑完在仓库的 Releases 里取包——不用去 Artifacts,那个 90 天就过期了
一次出三个包(--split-per-abi),现役手机基本都装
app-arm64-v8a-release.apk。有 Gradle 缓存时约 4~5 分钟(实测);fork 后
第一次跑是冷缓存,要明显更久。
fork 里没有签名密钥,构建会回落到 Flutter 的 debug key,而那个 key 每次 构建都不一样。后果不是装不上,是装得上但升不了级:第二次构建出的包想覆盖 第一次那个,会被系统以签名不符拒绝,只能卸载重装、丢掉 app 里的所有数据。 构建结果页(job summary)会标出本次是哪种签名。
自用的话忍一次卸载重装也行。要能持续更新,就在你自己的 fork 里配一次密钥, 设成三个 repo secret——生成方式和 secret 名字见 Android 发布签名。这个 keystore 丢了就没法再给同一个 app 发更新,备份它。
另外:这样打出来的包,app 里的「检查更新」不会有反应,不是坏了——壳里的 更新源写死指向主仓,而你的 release 发在自己的 fork 里,两边对不上。自己 构建的包靠自己重新构建来更新。
--icon 给了就用给的。没给就从站点找,按这个顺序:页面 <link rel=icon> →
manifest.json 的 icons → /favicon.ico → Google 的 favicon 服务。逐个下载
到能解码为止——后缀会骗人(x.com/apple-touch-icon.png 返回的是 287KB 的
首页 HTML),而且第一个能解码的未必最大,所以拿到 192px 以上才收工。
一个都抓不到时,按 app 名生成一个:纯色底 + 一个白色大字符,颜色由名字
哈希决定,同样的名字每次生成的都一样。字符取名字里第一个 ASCII 字母或数字
(4KVM → 4,4K影视 → 4);名字全是中文就退到 bundleId 末段
(com.pake.dadatu → D)——内置的是 Arial 位图字体,画不了汉字,而
bundleId 永远是 ASCII。
这一步替掉的是模板里那个默认地球仪:抓不到图标的站点不算少(4kvm.site 就
声明了一个 404 的 /ico.png),装一屏地球仪根本分不出谁是谁。构建结果里的
icon 字段会写明是 generated 还是某个 URL。
这一节写给要在本机改代码、跑构建、发版本的人。
pakem(packages/pake_cli)负责打包,壳(packages/pake_shell)负责运行,
配置分两层:
构建期 pake.json |
运行期(设置页) | |
|---|---|---|
| 内容 | app 名、bundle id、图标、版本号、初始 URL、系统权限 | 当前 URL、UA、注入脚本开关、缓存策略、更新检查 |
| 谁写 | CLI | 设置页 |
改 UA 不需要重新构建。运行期层为空时回落构建期默认;「重置」= 清空运行期层。
正式包里运行期这层只露出一半:URL、User agent、Capture network、View logs、 View requests、Reset to build defaults 只在 debug 构建里显示。藏 URL 和 UA 不只是嫌乱:这两项改错了壳就变成一个打不开的空白页,而唯一的退路(Reset) 恰好也在被藏起来的那一组里。
pakem build 出的包永远是 release,所以默认就是短版。包已经装在真机上、
要现场看日志或抓包时,加一个开关重新出一版——它仍然是 release 构建(签名、
体积、性能都不变),只是把那几项放回设置页:
pakem build https://example.com --debug-ui壳是单站壳:一个包只装一个站点,所以「别的 app 分享链接进来用这个壳打开」 不做——单站壳收到任意链接没地方放。
cd packages/pake_config && dart test
cd packages/pake_cli && dart test # smoke test 默认跳过
cd packages/pake_shell && flutter test打了 smoke tag 的两个用例默认跳过,要显式跑:
# 两个一起跑:真实构建一次 APK + 图标发现(数分钟,冷 Gradle)
cd packages/pake_cli && dart test --tags smoke --run-skipped
# 只跑真打网络的图标发现验证(几秒)
cd packages/pake_cli && dart test test/icon_discovery_live_test.dart --run-skipped--tags smoke 只选中用例,不解除 dart_test.yaml 里的 skip:;
必须带 --run-skipped 才会真正执行。
CI 不跑这两个。 build.yml 里那个叫 smoke 的 job 跟测试标签没有关系
——它验的是构建出来的 APK 里 pake.json 对不对,一行 dart test 都不跑。
所以图标那个用例哪天因为 x.com 改版而变红,只会红在你手上,不会把 CI 搞红。
发版前跑 手动回归清单。
想做还没做的功能点记在 docs/roadmap.md,落地过程记在
docs/develop_log.md。
不配密钥也能出包,但会用 Flutter 的 debug key 签——那种 APK 换台机器
或换一次 CI 运行,签名指纹就变了,装不到已有安装之上,也无法升级。
pakem build 的结果里 androidSigning 字段写明本次到底用了哪种。
预设 app 走 CI 那条通道,密钥只在 GitHub secrets 里,本机不需要配。下面这套
是给自己在本地打、用 pakem release 发的 app 用的。
先生成一个 keystore:
keytool -genkeypair -v -keystore ~/.pake/pake-release.p12 -storetype PKCS12 \
-keyalg RSA -keysize 2048 -validity 10000 -alias pake \
-dname "CN=pake, O=pake_mobile, C=CN"
chmod 600 ~/.pake/pake-release.p12 ~/.pake/signing.properties再在 ~/.pake/signing.properties 放四行(storeFile 用绝对路径):
storeFile=/Users/you/.pake/pake-release.p12
storePassword=…
keyAlias=pake
keyPassword=…这个文件放在 workspace 之外是必须的:~/.pake/workspace 每次构建都会被
模板覆写,放里面会被冲掉;放仓库里则等于把私钥提交上去。
keystore 丢了就没法再给同一个 app 发更新,用户只能卸载重装。备份它。
CI 用同一套约定,密钥来自三个 repo secret:
| secret | 值 |
|---|---|
ANDROID_KEYSTORE_BASE64 |
base64 < keystore.p12 | tr -d '\n' |
ANDROID_KEYSTORE_PASSWORD |
keystore 密码 |
ANDROID_KEY_ALIAS |
key alias |
build.yml 没设这些 secret 时照常出包,但会在 job summary 里标出 debug 签名
并给一条 warning——fork 出来走打包自己的网站就是这种情况,
后果那节写了。build-presets.yml 不一样,它查到不是 release 签名会直接失败,
因为那批包是发给用户装的。
app 冷启动检查的是 release 里 <bundleId 末段>-v<version> 的 tag。发一个
能被检测到的版本:
# 1. 改 pake.json 的 version,2. 构建,3. 装到真机上验过,4. 再发
pakem build https://www.4kvm.site
pakem release --notes "修了 X"version 是唯一要改的字段:Android 的 versionCode 从它推导
(1.2.3 → 10203),不用再单独维护 buildNumber——系统只认 versionCode,
它不跟着走的话 bump 完版本号两个包在系统眼里一模一样。同一个版本要重发一次
包时,在 pake.json 里显式写 buildNumber 钉死它。
推导值不是装机的最终值:--split-per-abi 会再加一个 ABI 偏移(arm64 +2000,
所以 1.0.0 的 arm64 包实际是 12000)。什么时候动哪一位、四处版本号分别归谁
管,见 docs/versioning.md。
tag 由 CLI 拼成 <bundleId 末段>-v<version>(com.pake.fourkvm + 1.2.0
→ fourkvm-v1.2.0),app 端按同样的规则筛——手动建 release 时 tag 写错
就是静默失效,这正是 pakem release 存在的理由。它需要 gh 并且已登录,
pakem doctor 会报它在不在。
pakem release 不构建:它只发 ~/.pake/out/<app>/ 里已经躺着的那份,
逼你先把包装到真机上验过。想灰度就加 --prerelease(或事后在 GitHub 上勾)
——app 端跳过 prerelease,验完再取消勾选。取消后最长要等一分钟才生效:
未登录的 api.github.com 响应有约 60 秒 CDN 缓存。
四条已知边界,都是刻意接受的:
pakem release不拦 debug key 签的包。 发出去了用户覆盖安装只会看到 「应用未安装」,排查成本很高。发布前自己确认pakem build输出里的androidSigning。(预设那条 CI 通道会拦,见预设站点)- 只拉一页 100 条 release。 所有 app 共用主仓,某个 app 的最新版被挤出 这 100 条就再也检测不到
- 墙内基本查不到。
api.github.com不可达时一律静默——不弹错、不重试 - 只给 arm64 设备推包。 构建是
--split-per-abi的三个 APK,app 端认arm64-v8a;只剩 32 位的老设备拿到的包装不上
presets/ 下一个 json 一个 app,手动触发 build-presets.yml 会把它们并成
矩阵一次性全构建。加一个站点 = 加一个 json 文件。
预设 app 只由 CI 签名和发布,release key 不放在笔记本上。 这不是洁癖: 本地和 CI 各存一张证书的话,没有任何东西能保证它们是同一张,而一旦不是, 先从 Releases 页面下过包的用户再装另一条路径出的版本就是「应用未安装」, 他也不会知道为什么。收敛到一处,这个问题就不需要靠核对来维持。
每个 preset 各发一条自己的 release,tag 和 pakem release 同一个规则
(fourkvm-v1.2.0)——workflow 直接调 pakem release,不在 YAML 里重写一遍
tag 格式。发出来一律是 pre-release,所以已装的 app 不会看见它:
改 presets/4kvm.json 的 version → 跑 build-presets.yml
→ fourkvm-v1.2.0 (pre-release)
→ 下载 APK 装到真机上验
→ 在 GitHub 上取消 pre-release 勾选 → 用户收到更新提示
版本号没动过的 preset 会被跳过(job summary 里会写明是哪个、为什么)——一次
跑构建全部三个 app,「这个版本已经发过了」是常态,不是错误。所以 json 里的
version 是唯一要动的字段:漏写会回落 CLI 的默认值 1.0.0,而不 bump 就
什么都不会发出去。
README 首页用现成的 app那张表格链接到具体版本 tag,bump 版本后记得跟着更新。
签名不对就不发。 没配 ANDROID_KEYSTORE_* secrets 时构建会回落 debug
key,那种包装不到任何别的构建之上;workflow 查到 androidSigning 不是
release 就直接失败,产物仍然留在 Actions artifacts 里供排查。
域名被墙时的处置是换域名,不是加代理:GFW 按域名封锁,同一站点的备用
域名往往直连可达——4kvm.site、dadatuys.com 都是这么换过来的。
1 配置错误 · 2 环境缺失 · 3 构建失败。--json 模式下错误同样是 JSON,
这套分级是给脚本和 agent 分支用的。




