把三张联通卡塞进一个 Egern 小组件:一次 fail-closed 的实现记录
在存储作用域没有文档、凭据绑定无法离线证明、上游没有开源授权的前提下,把三张联通卡的话费做进一个桌面小组件:fail-closed 闸门、显式卡槽、诚实的缓存状态。
工程笔记 · 2026-10-14 · 约 9 分钟阅读 · 标签:Egern、iOS、小组件、安全边界、联通、工程实践
把三张联通卡塞进一个 Egern 小组件:一次 fail-closed 的实现记录
我手上有三张联通卡。查话费这件事本身不复杂,但在 iPhone 上的操作路径是固定的:打开联通 App → 切到卡 1 → 点余额 → 切到卡 2 → 再点一次余额 → 切到卡 3 → 再来一次。每次想知道"还剩多少",都要重走一遍。
我想在桌面小组件上直接看到三张卡的话费、语音和流量。做出来不难,难的是怎么在不明确知道运行时行为的前提下,保证它不会偷偷留下不该留的东西。
这篇记录的就是后面这件事。
先确认哪些是官方说过的,哪些只是我以为
Egern 提供了脚本能力,官方文档里明确写出的是:export default async(ctx) 入口、ctx.env、ctx.widgetFamily、持久化 storage 方法、HTTP 的 redirect / credentials / timeout 参数。
但有一件事文档没有定义:storage 的作用域到底怎么划分。
跨脚本名共享吗?跨脚本 URL 共享吗?多个 widget 实例之间共享吗?主 App 和 Widget extension 是同一份存储吗?并发读写有没有事务保证?这些问题在公开文档里找不到答案。
而我的设计恰好依赖"HTTP 请求脚本捕获到的凭据,能被 generic 脚本渲染的组件读到"——也就是依赖共享存储。如果作用域假设错了,最坏的结果不是显示不出来,而是花了很大力气做隔离,却因为运行时把所有脚本塞进同一个命名空间而失效。
所以我做了两个决定:
- 存储作用域必须先在真机上验证,不能靠推断;
- 在验证通过之前,整个模块 fail-closed。
fail-closed 不是一句口号
具体到实现,就是在模块里放一个环境变量 STORAGE_CONFIRMED。它没有显式设成 "true" 时,脚本不会抓取凭据,也不会请求运营商接口,组件上只显示一行探针提示。
这样一来,"存储没验证"这个状态不会退化成"看起来能用但数据是错的",而是直接变成"什么都不做"。
验证用的探针是独立的一份脚本 StorageProbe.js,思路很简单:
- 在 Egern 里建两个 HTTP request 脚本和一个 generic 脚本,都指向同一份探针文件;
- 只给测试配置加上
example.com的 MITM; - Safari 打开一个测试 URL,由请求脚本写入一个公开的时间戳标记,这个标记里不含任何账号信息;
- 十分钟内刷新两个 widget 实例,看它们是否都能读到同一个新标记。
只有全部通过,才把 STORAGE_CONFIRMED 打开。通过之后还要等旧标记超过十分钟失效,再写一次新标记并重启 Egern 复测,排除掉"读到的其实是上一次残留数据"这种情况。
如果这一步没过,正确的动作是停下来去问官方,而不是换个写法继续试。
凭据只接受"同一个请求里的号码 + Cookie"
模块只对 m.client.10010.com 做 MITM,不碰任何电信域名。
触发方式是手动操作:打开联通 App,切到卡 1,点一次余额查询;切到卡 2,再点一次;切到卡 3,再来一次。捕获逻辑只接受同一个请求里同时出现的手机号和 Cookie,并且手机号必须在配置白名单里。
其余情况一律忽略:
- 只有 Cookie、没有号码的;
- 有号码但没有 Cookie 的;
- 不在
CARD1_PHONE/CARD2_PHONE/CARD3_PHONE里的第四张卡。
这里不做任何推断。脚本不会去猜"当前是哪张卡",不会用尾号匹配,也不会因为某个槽位空着就把它填上。
号码和 Cookie 是否真的属于同一个账号,最终还是依赖联通 App 请求本身的语义——这一点在离线环境里无法验证,只能如实标注为假设。
卡槽是配置出来的,不是抓取排序出来的
最早的思路是"抓三次,按顺序分配卡 1、卡 2、卡 3"。这个做法看起来很自然,但它把账号身份绑定在了抓取次数上——多抓一次、少抓一次,映射就错位了。
现在改成显式配置:三个槽位对应三个环境变量,每个填 11 位号码,三个号码不能重复,槽位可以留空。卡槽身份固定下来之后,缓存、凭据、失败状态全都挂在槽位上,互不影响。
顺带一个容易踩的坑:官方的作用域优先级是 Module > Widget > Script。如果把 CARD_SLOT 写在模块 Env 里,它会覆盖所有组件自己的设置,三个组件会全部显示同一张卡。所以模块 Env 里不能设 CARD_SLOT。
显示顺序是另一个独立开关。CARD_ORDER 只改三张卡在汇总组件里的排列,比如写成 3,1,2,不改变卡槽、存储键或者单卡组件。非法值(像 1,1,2、4、3,2)一律回退到默认的 1,2,3。
缓存要诚实,失败状态要看得懂
余额数据不是越快刷新越好。运营商接口有成本,频繁轮询也没意义,所以规则是:
- 一小时内直接复用,不发请求,状态标记为"有效缓存";
- 超过一小时才真正查询;
- 查询失败但 24 小时内有可用缓存时,展示旧值并明确标注原因——"缓存·连接失败"、"缓存·HTTP500"、"缓存·数据异常"、"缓存·保存失败";
- 超过 24 小时就隐藏旧值,而不是继续显示一个越来越不真实的数字。
组件的 HH:mm 始终是这个槽位最后一次成功查询的时间,不是渲染时间,也不是失败尝试的时间。成功刷新显示"更新",失败时沿用旧的成功时间戳。
连接失败 只表示请求被拒绝,脚本不会据此猜是超时、DNS 还是代理问题。官方错误码里没有的信息,就不编一个出来。
认证类失败处理得更彻底一些:401、403 或者接口 code 不是预期值时,清掉这张卡的凭据和缓存,提示重新捕获。其它卡完全不受影响——一张卡换了号或者登出,不会把另外两张也一起拖下水。
三卡并行的几个工程细节
三张卡是并行查询的,每张卡 8 秒超时,generic 脚本整体 20 秒。单卡失败只影响它自己那一行。
流量条有两种语义,混在一起最容易骗人:
- 无限量卡(接口语义是"已用"、且没填
CARDn_TOTAL):比例条从槽位色渐变为透明,末端显示∞,不显示百分比; - 已知总量的卡:填了
CARDn_TOTAL就画真实比例,比如总量 500GB、剩余 158.17GB 显示已用 68.4%; - 没填总量的普通卡:用该卡数值相对三卡中同口径最大值画相对长度——这是相对量,不是套餐百分比。
第三种情况在视觉上是有歧义的,所以实现上干脆不显示任何百分比数字,也不做"0% 以为已经清零"的假样式。
还有一个细节值得单独说:在途的凭据轮换。如果一张卡的 Cookie 正在更新,旧凭据返回的响应会被丢弃,不能覆盖新凭据对应的缓存。
组件改名之后,旧名字会变空白
这一版把模块和组件统一改名成「中国联通 / 中国联通卡2 / 中国联通卡3 / 中国联通 · 三卡」。
改名之后,桌面上原来那些用旧名字的组件会显示空白。这只是系统找不到旧名字对应的定义,不代表凭据丢了——数据还在 egern.unicom3.v1.slot.N 里。在桌面上长按组件 → 编辑小组件 → 重新选一个新名字就恢复了。
默认只提供一张卡。大多数人只有一张卡,硬塞三个槽位没有意义。需要多卡时再自己加上卡 2、卡 3,或者加一个三卡汇总组件。
测试能证明什么,不能证明什么
仓库里的测试覆盖了 15 个用例:三卡各自使用自己的凭据、cookie-only 不能覆盖已有账号、错误域名/路径/协议被拒绝、第四张卡不会挤掉配置的三张、过期凭据与认证失败互相隔离、并发轮换不会写入旧缓存、共享存储闸门会同时阻断捕获和私有请求、重复或缺失的号码配置一律 fail-closed。
这些测试全部使用离线构造的合成数据,不会向运营商接口发出任何真实请求。
清楚的是边界:Node 上的内存 Map 只能验证逻辑,不能证明 iPhone 运行时真的共享存储;YAML 解析通过,不代表 hook 一定会被触发;CI 全绿,也不代表真机上排版好看。这些必须靠前面说的探针验收和真机观察来补。
关于上游代码
这个模块参考了一个固定的上游 commit。但那个仓库没有 LICENSE 文件,GitHub 的 license 元数据也是空的,README 只有项目名——没有可确认的开源授权。
所以做法是:理解它使用的接口字段契约,自己重新实现逻辑,保留署名和来源说明,并且不公开再发布上游的代码或快照。仓库里的 evidence/upstream-* 是私人审计快照,不用于公开发布。
如果将来要正式公开发布,这一步需要先解决。
安装
模块地址:
https://raw.githubusercontent.com/liuweiqiang0523/egern-unicom-threecards/main/UnicomThreeCards.yaml
使用前请先完成 storage 探针验证。没有验证通过之前,模块会保持 fail-closed,不会抓取任何凭据。
回头看,这个项目里真正花时间的不是画组件,而是决定在信息不足的时候不做什么。共享存储的作用域没有文档,那就先验证;号码和 Cookie 的绑定关系无法离线证明,那就如实标注;上游没有授权,那就不发布代码。
这些选择让功能上线慢了一些,但换来的是:桌面上那个显示余额的小组件,不会成为一个我不知道它在干什么的进程。