I am LAZY bones?
AN ancient AND boring SITE

每日一辨 DailyDiff

最近上架了一个新 App:DailyDiff(每日一辨)。做它的初衷很简单:一是我自己想把英语再往上提一提,二是儿子也在学英语,我想给他(也给自己)找一个每天花几分钟、细水长流的练法。

市面上背单词的软件很多,但它们解决的基本都是”认识”:看见 soldier 知道是战士,看见 warrior 也知道是战士。可这两个词的区别是什么、什么场合该用哪个——这种”掌握”层面的功夫,几乎没有软件管。而”认识一个单词”和”掌握一个单词”之间恰恰隔着一条鸿沟,它决定了你的英语是只能读,还是真的能用。所以我做了一个专门练这个的 App。

每天一道题,它长什么样

玩法抄了 Wordle 的作业:每天全球所有人拿到同一道题——一对容易混淆的英语近义词,各配一张黑白线稿插图。你用英语写出这两个词在含义、语气、用法上的区别,一两句话就行,然后 AI 给你批改。

批改是这个 App 的核心。不是给个分就完事,而是从五个维度(准确性、覆盖度、语言、清晰度、洞察)各打一个 0–100 的分,每个维度都附一句针对你这次答案的点评——指出你答到位的地方、漏掉的要点,像一对一外教改作业。五个分数汇总成总分和等级,从 D 到 SSS。SSS 很难拿:不光要总分 95 以上,还要求最低的那个维度也不低于 90,纯靠某一项拉平均分是蒙不到的。

批改完揭晓标准答案,对照着学。之后就是熟悉的套路:每日连击打卡、日历一格格点亮,还能导出一张成绩卡分享——卡上只有词对、插图、等级和雷达图,刻意不含答案,朋友看到照样能去玩。

题库目前 200 道,四百张插图全是 AI 生成的黑白线稿,风格统一得像一个插画师画的。

技术上几个有意思的决定

作为技术博客,还是得聊聊后端。整个服务端就是一个 Cloudflare Worker,配 D1(题库和用户数据)和 R2(插图),没有一台服务器要运维,账单基本可以忽略——独立开发选这套栈真的省心。

我之前的服务,都是部署在自己的home lab上的,这也是一次全套采用cloudflare的技术方案,发觉这个赛博菩萨果然名不虚传,免费额度够用不说,wrangler的开发体验还非常棒!

几个细节自认为做得还算讲究:

1. 评分标准(rubric)永远不下发到客户端。客户端只上传题目 ID 和你写的答案,评分要点由服务端按 ID 查表后喂给模型。否则抓个包就能看到得分点,这游戏就没法玩了。

2. 总分和等级不信任模型自己报的数。LLM 的算术是出了名的不可靠,让它算五个维度的加权和,隔三差五给你算错一个。所以模型只负责逐维度打分和写点评,加总、定级在代码里重算。

3. 匿名用户不用注册就能玩,每天免费批改一次。防滥用靠的不是强制登录,而是 Apple 的 App Attest——让苹果证明请求来自真机上的正版 App,机器人和脚本过不来。这套东西的原理和服务端验证的坑,我之前单独写过一篇《Apple App Attest 简介》,感兴趣可以看看。

4. 批改额度是”先预扣、失败退还”。最早的实现是先只读检查额度、批改成功了再扣,看起来很稳妥——但批改一次要跑上一两分钟,这个窗口里并发发请求,每个请求检查时都显示”还有额度”,就都放行了,白烧 token。改成预扣制之后,靠 D1 单写者的串行化保证并发下最多放行额度上限那么多个请求,批改失败再把额度退回去。

免费与收费

说说钱的事,明码标价:每天的题目永远免费,匿名一天能批改 1 次,登录后 2 次。Pro 订阅(月付或年付)解锁 200 道历史题库、研读模式(先看标准答案再作答)和每天 15 次批改。订阅收入拿去付 AI 推理的账单——每一次批改都是真金白银的 API 调用,所以免费额度给得抠门,请理解哈哈。

来玩

App Store 搜「DailyDiff」或「每日一辨」,或者直接点这个链接。中英文界面都有,今天的题不用注册就能做。

如果你试了之后觉得 AI 批改哪次明显不靠谱,或者有任何建议,欢迎邮件 support@dailydiff.vip 或者直接在下面留言——独立开发,每一条反馈我都会看。

顺便说一句,今天的题你打算拿几分?我至今没拿到过自己 App 里的 SSS。

就此,完毕。

fastlane——App Store Connect CLI(非官方)

我的打鼾监测 App NightSnore 支持 7 种语言(简中、英、日、韩、德、法、阿拉伯语)。这带来一个每次发版都要经历的痛苦环节:在 App Store Connect 后台,把 What’s New(新功能介绍)逐个语言粘贴进去——切语言、粘贴、保存,再切下一个,七遍。要是 Promotional Text(推广文本)也更新了,那就是十四遍。发布过APP的朋友,肯定对此就深有体会了。

这次发 2.3.0 的时候我终于忍不住了:这玩意儿就没有 CLI 能自动化吗?

还真有,而且就是 fastlane。有意思的是,fastlane 我其实早就装了——之前一直拿它给 App Store 截图加设备边框(frameit),我一直以为它就是个截图美化工具,哈哈。这次才发现,截图加框只是它十八般武艺里最不起眼的一样。

fastlane 到底是什么

fastlane 的定位是”把 iOS/Android 发布流程的每个环节都变成可脚本化的命令”。它其实是一整套工具的集合,每个工具管一段:

1. deliver:上传元数据(What’s New、描述、关键词、截图)到 App Store Connect,本文主角。
2. snapshot:跑 UI 测试自动截图,能覆盖每种语言 × 每种设备尺寸。
3. frameit:给截图加设备边框,我之前唯一用过的那个。
4. gym / pilot:打包上传、TestFlight 分发和测试员管理。
5. match / cert / sigh:证书和描述文件的团队共享管理。
6. precheck:上传前扫描文案里的审核高危词。

单人开发、Xcode 自动签名的话,match 这类团队工具基本用不上;但 deliver 对多语言 App 来说是刚需级的效率工具。

deliver:把 ASC 表单变成本地文件

deliver 的思路很直接:ASC 后台的每个表单字段,对应本地一个文本文件,目录按语言组织:

一个很贴心的设计是:目录里有什么文件,它就只上传什么。我只放了 release_notes.txt 和 promotional_text.txt,那么描述、关键词、截图这些都不会被碰。配置文件 Deliverfile 里再把二进制和截图明确跳过:

搭一条 What’s New 流水线

我的 App Store 文案一直维护在仓库的 AppStore/*.md 里(七个语言各一个文件),每次发版往里追加一段”## What’s New (X.Y.Z)”。这个 Markdown 就是唯一数据源,所以流水线只需要一个提取脚本:从 md 里抠出指定版本的段落,写到 deliver 要的 metadata 目录去。再包一个 fastlane lane 串起来:

提取脚本里顺手做了两层校验:七个语言缺任何一个对应版本的段落就直接报错(强制多语言同步,防漏),超过 ASC 的字符上限(What’s New 4000 字符、Promotional Text 170 字符)也直接拦下。以后发版就是一条命令:

从跑命令到 ASC 七个语言全部填好,9 秒。之前手动粘贴至少十分钟,还得祈祷别粘串了语言。

API Key,和一个专门坑你的格式问题

deliver 走的是 App Store Connect API,需要一个 API 密钥:ASC 后台”用户和访问 → 集成 → App Store Connect API”里创建一个团队密钥,角色选 App Manager,会得到一个 .p8 私钥文件(只能下载一次)加 Key ID 和 Issuer ID。

然后我就结结实实踩了个坑。deliver 支持用一个 JSON 文件传密钥,我很自然地写成了指向 .p8 文件路径的形式,结果:

查了才知道:fastlane 的 Fastfile 里有个 app_store_connect_api_key 这个 action,它支持 key_filepath 参数指向 .p8 文件;但 deliver 的 api_key_path 参数指向的 JSON 文件,只认内联的 key 字段——你得把 .p8 的 PEM 内容整个塞进 JSON 字符串里(换行转成 \n)。同一个工具链里两种密钥写法长得几乎一样但互不兼容,这不纯纯挖坑嘛。正确格式长这样:

这个文件含私钥,务必进 .gitignore,待遇跟你的其他 secrets 一样。

踩坑与注意事项

1. deliver 只能写”可编辑状态”的版本(准备提交、被拒等),已在审核中或已上架的版本改不了。所以要在上传 build 之后、点提交审核之前跑它;版本还没建也没关系,deliver 会自动创建。

2. locale 代码不都带地区后缀:日语是 ja 不是 ja-JP,韩语是 ko,但英语是 en-US、德语是 de-DE。写映射表的时候留意。

3. metadata 目录里有什么就传什么,这既是特性也是风险:目录里残留一个过期的 description.txt,就会把线上描述覆盖掉。我的做法是 metadata 目录整个 gitignore,每次由脚本从 md 重新生成,保证它永远是纯派生产物。

用 skill 操作,vibe coding 更顺畅

还有一层我觉得比工具本身更有意思:这整条流水线,从功能开发、写七语言文案,到搭 fastlane、踩坑、修好,都是在 Claude Code 里完成的。搭好之后我顺手让它把整个发版流程沉淀成一个项目 skill——仓库里的一个 .claude/skills/release/SKILL.md 文件,把九个步骤写成清单:bump 版本号 → 编译验证 → 七语言 What’s New / Promotional Text → commit → xcodebuild 归档上传 → 打 tag → fastlane 同步 ASC 表单,连”deliver 的 JSON 只认内联 key”这种坑位说明都写在里面。

下次发版,我只需要说一句 /release 2.4.0,AI 就照着清单把整条链路跑完,唯一剩下的手动操作是去 ASC 点提交审核。skill 跟着仓库走,clone 下来就有,等于把发版的”部落知识”固化成了可执行的文档。对 vibe coding 来说这很关键:写代码交给 AI 大家都会了,但发布环节往往还是人肉在各个后台之间点来点去——把这段也纳入对话式工作流,从开发到上架才算真正闭环。

下一个目标

尝到甜头之后我看了一圈 fastlane 工具箱,对我这种多语言独立开发场景,下一个最值得上的是 snapshot:七个语言的商店截图现在还是手动截的,每次界面大改就是一下午。snapshot 跑 UI 测试自动出全语言截图,再接上我已经会用的 frameit 加框、deliver 上传,理论上截图这条线也能变成一条命令。挖个坑,做完再写。

发布流程自动化这件事,本质上是把”每次发版都要凭记忆和手感重复一遍的操作”变成”写一次、以后白嫖”的脚本。对独立开发者来说,省的那十几分钟是小事,真正值钱的是不再需要担心”这次是不是又漏了哪个语言”——机器不会漏。

就此,完毕。

聊聊 Google 的 OKF

但凡自己动手给 LLM 接过点内部数据的人,大概都被同一件事恶心过:上下文到处都是,就是没法用。表结构在元数据目录里,藏在某个有专有 API 的系统后面;指标的口径写在某个 Wiki 页面,链接还时不时失效;运维手册在网盘,注释在代码里,剩下一半干脆只在某个老同事脑子里。你想做个能回答”上周活跃用户怎么算的”的 agent,光是把这些上下文拼起来就得脱层皮,而且换个数据源、换个团队,这套拼装逻辑还得从头再来一遍。

Google 最近发了个东西想治这个病,叫 OKF(Open Knowledge Format,开放知识格式),目前是 v0.1。我看完第一反应是:这不就是给”内部知识”定了个通用文件格式嘛——类比一下,有点像知识界的 Markdown,或者说,想成为 Git 之于代码那样的存在。

OKF 到底是个啥

说穿了特别朴素:一堆带 YAML frontmatter 的 Markdown 文件,按目录组织起来,就这么点东西。没有复杂的压缩、没有运行时、不强制你装任何 SDK。一个 OKF 的 bundle(知识包)就是一个目录,每个概念对应一个 .md 文件:

每个文件头上用 YAML 写结构化字段,正文用 Markdown 写说明、schema 之类。比如一张订单表,用Google自己的例子,大概长这样:

注意最后那行——customer_id 直接用 Markdown 链接指向了 customers 表。文档之间这么互相一引用,整个知识包其实就织成了一张图。人能读,agent 也能顺着链接爬。

很克制

我个人比较欣赏它的一点是”少管闲事”。整个规范里强制要求的字段只有一个:type。其余全交给生产者自己定。这意味着它不会因为你的场景特殊就用不了。

围绕这点,它立了三条原则,我觉得是整个设计的魂:

1. 极简约束——只认 type,绝不替你过度规定。
2. 生产者和消费者解耦——格式本身就是契约。谁来生成这些文件、谁来消费它们,两边可以各自独立替换,互不绑死。
3. 是格式,不是平台——没有厂商锁定,不依赖某朵云,不要专有 SDK。这条是它跟那些”元数据目录产品”最本质的区别。

这三条凑一块,好处就很实在了:人可读(任意编辑器能打开,GitHub 能直接渲染),可移植(打成 tarball、塞进 git、挂任意文件系统都行),可互操作(A 团队产的知识包,B 团队的 agent 不用翻译直接就能吃)。而且因为就是一堆文本文件,它可以躺在版本控制里,跟代码放一起,agent 能读也能改,团队像 review 代码一样 review 知识。

Google 顺手给了几个轮子

光发个规范是空的,所以这次还附带了配套,降低上手门槛:一个参考用的”富化 agent”,能从 BigQuery 数据集自动生成 OKF 文档;一个纯静态的 HTML 可视化器,不需要任何后端,打开就能看那张知识图谱长啥样;外加三个示例 bundle——GA4、Stack Overflow、Bitcoin 数据集,拿来照着学格式正好。

这里头我觉得最值得单拎出来说的是那个富化 agent,因为它远不只是”导出元数据”那么简单。

富化 agent:让 LLM 去读文档

名字里”富化”(enrichment)这俩字是关键。光靠 BigQuery 的元数据,你能拿到的只是表名、列名、类型这些骨架;可 customer_id 到底什么含义、orderscustomers 怎么关联、某个枚举值代表哪种业务状态——这些”肉”通常只长在人写的文档里。富化 agent 干的就是把骨架和肉缝起来。它的做法是两段式(two-pass):

第一段,BigQuery Pass。扫源数据集,光靠 BigQuery 自身元数据,给每个发现的概念(表、字段、数据集)生成一份初始 OKF 文档。这步是机械的、确定的,产出骨架。

第二段,Web Pass,这段才是精华——它把一个 LLM(Gemini,走 AI Studio 或 Vertex AI)当成一个会自主决策的爬虫:你丢给它几个种子 URL(--web-seed,一般是官方文档地址),agent 用一个 fetch_url 工具去抓页面,然后 LLM 自己判断每页”看起来像不像权威文档”,再决定怎么处理——是拿来丰富某个已有的概念文档,还是单独建一份参考文档,还是这页没用直接跳过。它不是傻爬,是带着”我手里有哪些概念、这页能补哪个”的目标在选择性吸收。

既然是放 LLM 出去爬网,缰绳得备好:--web-max-pages 限制最多抓多少页,防止跑飞;--web-allowed-host 上域名白名单,只许它在你信任的站点里转悠。

最后吐出来的,就是一组分层组织好、带 frontmatter、能直接进版本控制的自包含 bundle。说白了,这个 agent 是 Google 给的一份”如何把现有系统改造成 OKF 生产者”的样板:结构化元数据打底,再让 LLM 读人类文档来富化——你完全可以照着这套路,给自家的数据系统写个类似的生产者。

可视化器:一个 HTML 文件就是查看器

如果说富化 agent 是”生产端”的样板,那这个可视化器就是”消费端”的样板,而且它把 OKF “是格式不是平台”那套哲学贯彻得相当彻底——连查看器都拒绝引入后端,就一个静态 HTML 文件。生成命令是富化 agent 的一个子命令:

它在生成时把整个 bundle 当成一段 JSON 直接嵌进 HTML 里,所以产物是个自给自足的单文件,可以分享、可以丢任意静态服务器、也可以跟 bundle 一起 commit。Google 就是这么干的——仓库里直接躺着三个生成好的 viz.html(GA4 50KB、Stack Overflow 122KB、Bitcoin 28KB),双击就能在浏览器里打开,看的人那边什么都不用装。

打开后是个左图右文的单页:左边一张力导向图,每个概念是个节点,颜色按 type 区分(table / dataset / reference…),节点之间的有向边就是文档正文里那些 Markdown 互链——前面说的那张”知识图谱”,在这儿被画了出来;点某个节点,右边详情面板就用 marked 把那份 .md 的 frontmatter 和正文实时渲染出来,正文里的内部链接还被改写成”在查看器里跳转”,点了不会把你甩走,而是切到对应节点。顶上一排是搜索框(匹配标题、ID、tags)、type 过滤、还有好几种图布局可切(cose、concentric、breadth-first、circle…)。图用 Cytoscape.js 渲染,全程在浏览器里跑,数据不出本地。

光说没用,我把 Stack Overflow 那个样例的 viz.html 直接搬到了自己博客下,你点开就能玩:stackoverflow_viz.html。其实我这个动作本身就是 OKF “好传播”的一个现成注脚——一个别人生成的知识查看器,我啥都不改、原样拷过来挂上就能用,这要是搁个需要起后端、连数据库的”平台”,可没这么轻巧。

几个我会留意的点

新东西总归要泼点冷水,几个我看完留了心眼的地方:

1. 目前才 v0.1。规范号摆在这,意思是接口和约定都还可能动,真要拿去做生产,得做好跟着升级的准备。
2. “只强制 type”是把双刃剑。约束少确实灵活,但也意味着不同团队产出的字段可能五花八门,跨组织互操作时,光有格式统一、字段语义不统一,照样得对齐。这部分活儿规范帮不了你。
3. 它解决的是”知识怎么存、怎么传”,不解决”知识怎么保证是对的、是新的”。frontmatter 里那个 timestamp 写得再漂亮,背后没人维护,照样会过期。说到底它是个格式,不是个能自动保鲜的系统。

说点本质的

抛开这些细节,我觉得 OKF 真正想干的事很清楚:在这个人人都在拿内部数据喂 agent 的当口,给”知识”这件东西定一个最朴素、最不挑食的通用载体。用最普通的文件约定,换跨工具、跨组织、跨时间的可移植性——这套思路,过去在代码上是 Git 干成的,在文档上是 Markdown 干成的。OKF 想在”知识”这一层复刻同样的故事。

能不能成,要看有多少人愿意给自己的系统写”生产者”、给自己的 agent 写”消费者”,把生态滚起来。但方向我是认的:与其每家都把知识锁在自己那套不兼容的专有系统里,不如先在格式上达成一致。毕竟,朴素的东西才传得开。

就此,完毕。

Apple App Attest简介

在这个AI时代,越来越多的应用(APP)是和AI相关的,其中有不少,对用户的请求需要调用LLM来处理,也就是要消耗token。如果这个应用,是对免费用户甚至未注册用户有一定的体验使用量的话,就要考虑怎么防止token被刷爆的问题了。恰巧我就在做一个这样的应用。

此时,一个自然而然的问题是:要怎么证明”这是正版 App 发来的”?

Apple App Attest 就是来解决这个问题的。(PS:大家如果有安卓的、PWA的解决方案,可以留言)

我想实现的是”匿名用户每天送一次 AI 评分”,不登录就能用,体验好、转化高。可这接口裸奔在公网上,谁拿 curl 写个循环都能把额度刷爆。加验证码太伤体验,强制登录又把”尝鲜”这个卖点废了。

我想要的其实是一句话:能不能让服务器确信”这条请求确实是从我那个正版 App、在一台真机上发出来的”?Apple 的 App Attest 就是干这个的。这篇把它的原理、整体流程,以及服务端到底该怎么验,讲清楚;我自己趟过的几个坑放在最后当佐料。

App Attest 解决的是什么问题

传统的”防刷”思路是给请求带个密钥或 token,可只要密钥在客户端,逆向、抓包、改包就能仿造,挡不住有心人。App Attest 换了个思路:它借助 Secure Enclave(设备上独立的安全芯片),由苹果来给你的 App 背书,证明两件事——这是从 App Store 渠道的正版 App 发出的,且跑在一台真实的苹果设备上。

这个背书是密码学保证的:签名私钥生成在 Secure Enclave 里、永远导不出来,连越狱也偷不走。所以它特别适合”匿名但要防滥用”的场景:免费额度、防注册机刷号、防接口被脚本薅。它不是用户身份认证(那是 Sign in with Apple 的活),它认的是”设备 + App”这个组合可信。

代价是它只在真机、正版渠道下成立——模拟器用不了,这点后面会再提。

两段式:attestation 和 assertion

理解 App Attest,关键是分清它的两段,这俩验证逻辑完全不同。

第一段 attestation,一次性的。App 首次要证明自己时,在 Secure Enclave 里生成一对密钥,请苹果给这把公钥签发一张证书;证书连同一坨 authenticator data 打包成 attestation 对象,发给你的服务器。服务器验完,把这把公钥存进库,跟这台设备绑定。这一步只做一次。

第二段 assertion,每次请求都做。App 用第一段那把私钥,对”本次请求的内容”签个名,随请求发出。服务器用之前存下的公钥验签——对得上,就说明这条请求确实来自那台被证明过的设备,且内容没被篡改。

客户端的代码很薄,DCAppAttestService 几个方法调一调就行。真正有讲究的是服务端这两段验证,下面分开说。

服务端怎么验 attestation

attestation 对象 CBOR 解开后,核心是一条证书链 x5c 和一段 authData。要验的东西不少,挑要点说:

证书链。x5c 里只有两张证书:给设备公钥签的叶子证书,和一张中间证书。你要做的是把它验到苹果的 App Attest 根证书。这里有个反直觉的点——根证书不在链里。别去比对”链里最后一张是不是根”,那是中间证书。正确做法是把苹果根证书内嵌进代码,用它的公钥验中间证书,再用中间证书验叶子。根证书是信任锚,得自己持有,不能从对方给的链里取。苹果根证书在 certificate authority 页面下载,建议顺手核对哈希:

nonce。光证明”证书合法”挡不住重放——截一个合法 attestation 反复发也行。苹果的办法是:服务器先发一个随机 challenge,苹果会在叶子证书的扩展里(OID 1.2.840.113635.100.8.2)塞进 nonce = SHA256(authData ‖ SHA256(challenge))。服务器照样算一遍比对,对上才说明这份证明是冲着你这次的 challenge 来的,authData 也没被动过。

剩下几项是常规校验:rpIdHash 要等于 SHA256(appId)(appId = Team ID 加 Bundle ID);新鲜的 attestation 计数器必须是 0;aaguid 标明这是 App Attest(正式环境是 appattest,Xcode 调试走的开发环境是 appattestdevelop,两者都要放行);最后把 authData 里的公钥 SHA256 一下,应当等于凭证 ID。全过了,把这把公钥存库。

证书链验签自己撸 ASN.1 容易出隐蔽 bug,我直接用了 @peculiar/x509,它在 Cloudflare Worker 的 WebCrypto 环境里能跑。

服务端怎么验 assertion

每次请求的验证简单些:CBOR 解开 assertion 拿到签名和 authenticatorData,先验 rpIdHash、再查计数器是否比库里存的大(防重放,每签一次苹果会自增),最后用存下的公钥验签。

验签的消息构造是这段里唯一的”暗礁”。苹果文档说签名覆盖的是 authenticatorData ‖ clientDataHash,但你要是把这俩直接拼起来交给 ECDSA-SHA256 去验,会失败。真相是:苹果用 ES256 签的是 nonce = SHA256(authData ‖ clientDataHash),而 ES256 自己还会再 hash 一层,所以最终参与 ECDSA 的摘要是 SHA256(nonce)。WebCrypto 的 ECDSA 必定做一次 hash、跳不过,因此正确写法是先把 nonce 算出来,再把 nonce 当消息传进去,让它在上面再 hash 一次:

这层”看不见的 hash”我是把原始字节 dump 出来在本地穷举才定位到的——推理走不动时,让事实说话往往更快。

实现时几个容易绊倒的点

主线讲完了,把我真机联调时踩到的坑列一下,纯属佐料,但能省你几个小时:

1. Team ID 不一定是你以为的那个。报 “RP ID hash mismatch” 时我很懵,appId 明明拼对了。后来去构建产物里一看,签名证书的 team 和描述文件的 team 是两个,App Attest 取的是 application-identifier 里那个:

2. COSE 公钥是整数键。authData 里那把公钥是 COSE 格式,x、y 的键是 -2、-3 这种整数。cborg 默认解对象会直接抛 “non-string keys not supported”,得开 useMaps 解成 Map 再 get:

3. 模拟器测不了。App Attest 在模拟器上直接不支持,匿名链路只能上真机。开发期可以在服务端留个开关跳过验证方便联调,但上线前务必删掉。

值不值得用

如果你有”匿名 / 低门槛、但又怕被脚本滥用”的接口,App Attest 是目前苹果生态里最硬的一道闸:信任根在苹果、私钥锁在 Secure Enclave,比任何塞在客户端的密钥都难仿造。代价是只覆盖真机正版、客户端服务端都得改、还得忍受一段真机调试的来回。

它的坑也基本都不在文档主线上,而在那些”想当然”的接缝处——根证书的位置、Team ID 的来源、COSE 的键类型、ES256 那层默认的 hash。单看每个都不难,叠在一起就够耗你一天。提前知道它们长什么样,就能少趟很多。

全文完。

whois 不让用了?聊聊它的接班人 RDAP

起因是最近我的域名要过期了,在操作续费(顺便还换了个注册商)的过程中,习惯性地敲了个 whois:

被拒了。换 whois 服务器、加参数,都是同一句话:请去网页上查。哈哈,用了快二十年的命令,说不让用就不让用了。

查了一下才知道,.li 域名的注册局是瑞士的 SWITCH(和 .ch 同一家),他们已经把传统的 43 端口 whois 服务关掉了,只留了个带验证码的网页查询入口(甚至whois返回的网址都是错的…)。原因也不难猜:一是 GDPR 之后,欧洲的注册局对注册人信息的批量获取管得很严,而 whois 这个协议天生没有任何访问控制,谁都能无限爬;二是网页入口可以加验证码和限流,挡掉数据挖掘的脚本。

那命令行党就没活路了吗?还真有——RDAP。

RDAP 是什么

一句话概括:RDAP 就是基于 HTTPS + JSON 的 whois。

whois 这个协议是 1982 年的产物,比 DNS 还老。它的问题攒了几十年:输出格式没有任何标准,每家注册局返回的文本长得都不一样,想程序化解析就得给每家写一套正则;协议里压根没有字符编码的概念,中文注册人姓名怎么显示全看运气;更要命的是没有认证和权限控制,这也是 GDPR 之后各家注册局纷纷关门的直接原因。

IETF 在 2015 年发布了 RDAP(Registration Data Access Protocol)来接班,核心就是 RFC 7480 那一组标准。ICANN 从 2019 年起强制要求所有 gTLD 注册局和注册商部署。所以 .com / .org / .net 这些域名,现在都有标准的 RDAP 接口可以查。

怎么用

不需要装任何东西,一个 curl 就够了:

返回的是规规矩矩的 JSON。我自己这个域名查出来大概长这样(节选):

注册商、注册日期、NS、状态,一目了然。所有时间都是 ISO 8601 格式,状态码用的是标准的 EPP 状态(active、client transfer prohibited 这种),不再是各家自己发明的描述。对写脚本的人来说,这比解析 whois 文本舒服太多了。

顺便发现一个副作用:查一个没注册的域名,RDAP 直接返回 HTTP 404。所以拿状态码就能批量探测域名是否被注册,连响应体都不用解析。

不用记每家的地址:Bootstrap 机制

用 whois 有个老问题:你得先知道该问哪台服务器。查 .com 要问 Verisign,查 .org 要问 PIR,记不住。

RDAP 把这事标准化了。IANA 维护着一份 dns.json,里面列着每个顶级域对应的 RDAP 服务地址:

客户端缓存这份文件,就能自动路由到正确的服务器。嫌麻烦的话,直接用 rdap.org 这个公共服务,它帮你做转发:

查任何域名都是这一个入口,再也不用记谁家域名归谁管了。

那些 xn-- 开头的乱码是什么

翻 dns.json 的时候会看到一堆奇怪的顶级域,比如 xn--kpry57d、xn--fiqs8s。这不是乱码,是国际化域名(IDN)的 Punycode 编码。

DNS 天生只认 ASCII,但域名总不能只让用拉丁字母。于是有了 Punycode:把 Unicode 字符串编码成纯 ASCII,再加个 xn-- 前缀。解码出来其实都是各国文字:

想自己玩的话,Python 一行就能互转:

RDAP 对这个的支持也很到位:响应里同时给 ldhName(ASCII 形式)和 unicodeName(Unicode 形式)两个字段,客户端想显示哪个自己挑。而 whois 协议连编码都没定义,IDN 的处理完全看各家心情。

隐私这块儿

GDPR 干掉了 whois 里的注册人信息,RDAP 则把”隐藏”这件事做成了标准。响应里有个 redacted 数组,明确列出哪些字段被隐藏了、用的什么方式(整个移除、替换成占位值、还是置空),机器可读。需要完整数据的执法机构或商标方,可以走认证通道申请。这套分级访问的设计,whois 是完全做不到的——它只能简单粗暴地把字段换成 REDACTED FOR PRIVACY,别的什么都表达不了。

踩到的坑

也不是处处顺利。RDAP 标准归标准,各家实现的完整度差很多:

1. ccTLD 不强制部署。gTLD 是 ICANN 管的,必须上 RDAP;但国家域名各自为政,有的部署了,有的没有,有的部署了但缺斤短两。

2. 比如 .li / .ch 的 RDAP 就不返回到期时间。events 里只有 registration,没有 expiration,想知道域名什么时候过期,还是得去注册商后台看。而 .com / .org 的 RDAP 是给全的。

3. 注册局和注册商是两级数据。注册局(Registry)的 RDAP 只有基本信息,更详细的注册人信息要顺着响应里 rel 为 “related” 的链接去注册商(Registrar)的 RDAP 再查一次。

4. 部分url有反爬机制,直接用curl会失败,如果失败可以试试正常用浏览器打开。

最后

RDAP 其实没什么革命性的东西,本质上就是把四十多年前的纯文本协议用 HTTPS + JSON 重写了一遍。但恰恰是这种”无聊的现代化”,把格式混乱、没法国际化、没有隐私控制这几个老大难问题全解决了。另外它不只能查域名,IP 地址和 AS 号也是同一套协议(curl rdap.org/ip/8.8.8.8 试试),五大 RIR 都已经支持了。

以后再想 whois 什么东西,可以先试试 curl rdap.org。毕竟传统 whois 关一家少一家,而 RDAP 才刚刚开始。

就此,完毕。

滕王阁序

和之前的awk手册一样,又是一个Claude design的作品。

这次做了《滕王阁序》的逐字解析,还挺有意思的,丢个链接

如果有错误之处,可以去这里提issue,也不排除以后做其他经典古文的解析。

从接手到日用:我把 Notchy 改成了什么样

还记得上次那篇吗?当时我接手 Notchy 的时候,基本就是原作者 Adam Lyttle 的初始版本——点子非常好,但功能比较基础,bug 也不少。我本来只是想”修修 bug,打个包”就完事了。

结果一改就停不下来了。

55 个 commit、4600 多行 Swift 之后(当然大部分都是 vibing 的),Notchy 已经从一个”能用”的 demo 变成了我日常干活的主力终端。是的,之前我还是混着状态,现在 iTerm2 已经从 Dock 上消失了。

这篇就来聊聊,到底改了些啥,才让我有底气做出这个切换。

Terminal UX:从”能打字”到”能干活”

原版的终端体验非常朴素——打开一个黑框,能输入命令,仅此而已。要把它当日用终端,差的东西太多了。

动画和视觉:面板从菜单栏后面滑出来(slide-down),背景是 NSVisualEffectView 的毛玻璃效果。看起来比较像一个系统原生组件,而不是一个第三方窗口硬贴在那里。

快捷键:这是最影响手感的部分。

  • 全局热键 Ctrl+` 呼出/收起面板,任何应用中随时可用
  • Cmd+1..9 切 tab,Cmd+W 关 tab,Ctrl+TabCtrl+Shift+Tab 循环切换
  • Cmd++ / Cmd+- 缩放字体(全局生效,持久化),Cmd+0 重置
  • Shift+Enter 发送换行而不是提交(通过 kitty CSI u 协议实现),这对 Claude Code 的多行输入至关重要
  • Cmd+Backspace 清行(发 Ctrl-U)
  • Copy-on-selection,选中即复制,iTerm2 用户的肌肉记忆

滚动:这块踩了不少坑。原版在 TUI 应用(比如 Claude Code 自己的界面)里滚动完全不工作。修了 alternate screen buffer 的滚轮转发,修了自动跟随输出的逻辑(在底部时跟随新输出,在回看历史时保持位置不动),还修了退出 vim/less 之后视口跳到顶部的 bug——这个 bug 的原因是 alt buffer 的 yDisp 始终是 0,退出时被误判为”用户在回看滚动历史”。Scrollback buffer 大小也做成了可配置的(默认 1000 行,最大 50000)。

字体:支持 Nerd Font,Powerline 图标正常显示。

从 Claude 专属到多 Agent 支持

原版 Notchy 是纯粹为 Claude Code 设计的——检测到 CLAUDE.md 就自动启动 claude,写死的,没有别的选项。

但现实是,越来越多人在用不同的 AI coding agent。OpenAI 的 Codex 出来之后,我公司也给我们同时配备了Claude 和 Codex,我会在不同项目中用不同的agent,Notchy应该能做到自动判断:

  • 项目里有 CLAUDE.md → 启动 claude
  • 项目里有 AGENTS.md → 启动 codex
  • 两个都有 → 看 Settings 里的 Preferred Agent 设置来决定
  • 两个都没有 → 不启动,给你一个普通 shell

终端状态检测也做了相应适配。原版只认 Claude 的输出模式(大写的 Esc to interruptEsc to cancel 等),Codex 的输出格式不一样——小写的 esc to cancelyou approved … to run …Conversation interrupted。现在都能正确识别,notch 上的状态指示对两个 agent 都能工作。

这个改动的价值在于:Notchy 不再是一个”Claude Code 的前端”,而是一个通用的 AI coding agent 终端。以后再出新的 agent,加个 case 就行。

Tab 管理:三种 Tab,各司其职

原版只有 Xcode 自动检测的 tab。我加了一套完整的 tab 类型系统:

  • Xcode tab(青色边框):自动创建,跟 Xcode 项目生命周期绑定
  • Pinned tab(橙色边框):手动固定的 tab,跨重启持久化。固定时会通过 proc_pidinfo 快照当前 shell 的 CWD,重启后自动 cd 回去并重新检测 AI agent,适用于非 Xcode 的项目。
  • Normal tab(无边框):+ 按钮创建的临时 tab,关掉 app 就没了

另外加了 Shadow Tab——右键一个 Xcode 或 Pinned tab,选 Shadow Tab,会在旁边开一个 plain shell,cd 到同一个目录但不启动 Claude/Codex。跑 git statusnpm run build 这种临时命令特别方便,不用打断正在工作的 agent。名字后面会加个 $ 后缀以示区分。

关 Pinned 和 Xcode tab 之前会弹确认框,防止手滑。这些 tab 带着恢复状态,误关了成本很高。

IME 输入法支持

SwiftTerm 的 NSTextInputClient 实现有问题,输入法的 marked text(预编辑文本)直接被吞掉了。打拼音的时候只能看到候选窗,看不到自己输入了什么。

第一版我做了一个 HUD 风格的浮动面板,显示在光标上方。后来改成了 inline 渲染,和 macOS Terminal.app 的行为一致——用终端前景色画文字,背景色填充遮住底下的块状光标。视觉上自然多了。

这个功能对中文用户来说是刚需。

自动更新 (Sparkle)

手动下载更新太烦了,用户也不会主动去看 GitHub Releases。所以集成了 Sparkle——macOS 上事实标准的自动更新框架。

这块的详细过程我单独写了一篇:给 macOS App 加自动更新:Sparkle 入门。大家可以参考这里。

CI/CD 发布流水线

推一个 v* tag 到 GitHub,Actions 自动搞定剩下的事:

  1. xcodebuild archive 构建并用 Developer ID Application 签名
  2. notarytool 提交公证(Apple 审查恶意代码)
  3. 打包成 DMG 和 ZIP
  4. 用 EdDSA 私钥签名 ZIP,生成 appcast.xml
  5. 把所有产物挂到 GitHub Release 上

如意要长期维护这个应用,这些都是必不可少的基础设施了。

其他细节

  • 外接显示器支持:接了外接显示器(比如 Studio Display)的时候,鼠标悬停在外屏顶部中央(摄像头区域)也能唤出面板,和 MacBook notch 的交互保持一致
  • 通话静音:检测到麦克风在使用(Zoom、FaceTime 等),自动把 Notchy 的提示音静音,不会在开会的时候突然”叮”一声
  • Checkpoint 增强:加了一个 popover 列出所有 checkpoint,可以浏览、恢复、删除任意一个,不再只能操作最近的那个
  • Settings 窗口:从一个简单的菜单 toggle 变成了完整的 Settings 窗口(Cmd+,),分 General / Integrations / About 三个 tab
  • Notch 动画优化:改成更平滑的 ease-in-out 曲线,修了 notch 和屏幕顶部之间的缝隙,修了 hover → click 模式切换时 notch 缩小的问题
  • 面板大小持久化:拖动调整大小后会记住,下次打开恢复。调整时右上角还会显示尺寸指示

为什么能替代 iTerm2

这个问题的答案很简单:我日常用终端 90% 的场景是跑 AI coding agent。

在这个场景下,Notchy 比 iTerm2 好用。Ctrl+` 一按就出来,不用切窗口;Xcode 项目自动检测,不用手动 cd;agent 自动启动,不用手动输命令;状态一目了然,notch 上的小药丸告诉你 agent 是在干活还是在等你。

剩下 10% 的临时命令?Shadow Tab 搞定。

当然,如果你的主要场景是 SSH 管理十几台服务器、或者需要 tmux 分屏,iTerm2 仍然是更好的选择。但如果你和我一样,日常就是在本地项目里跑 Claude Code 或 Codex——试试 Notchy 吧。

GitHub: bones7456/notchy,非常欢迎提issue、MR等。。。

安装方式:去 Releases 下载 DMG 或 zip,拖进 /Applications 就行。因为签名、公证过,所以不会弹 Gatekeeper 警告。

全文完。

awk中文手册 再版

将近20年前,我自己在学习awk的时候,整理过一个awk中文手册(整理、输出真的是学习的最佳方式之一),当时内容都校对过,但格式真的挺乱的。
而刚刚的5月底,我发现我的Claude design额度要用不完了,于是干脆给这个手册来个再版。样式现代化了不少!
链接不变,之前的老版本在这。

给 macOS App 加自动更新:Sparkle 入门

最近给 Notchy 加了自动更新。原先的”检查更新”功能很糙:调一下 GitHub Releases API 看有没有新版本,发现有就开浏览器跳到 release 页面,让用户自己下 dmg、自己拖进 Applications。

这显然算不上”自动”。理想体验是:发现新版本 → 自动下载 → 校验签名 → 替换 App → 重启。这一套自己写也可以,但 macOS 生态二十年前就有现成的轮子 —— Sparkle

接进去之后回头看,整个事情比想象中简单,但中间踩了几个不算直觉的坑,整理一下。

Sparkle 是什么

Sparkle 是 macOS 上事实标准的开源自动更新框架,从 2006 年用到现在。Transmission、VLC、Handbrake、Sequel Pro、Tower、Hammerspoon、iStat Menus …… 基本上你在 App Store 之外用过的、有自动更新的 Mac App,十有八九背后都是它。

工作流大致这样:

  1. App 启动或定时,Sparkle 去服务器拉一份 appcast.xml
  2. xml 里列着最新版本号、下载地址、文件大小、加密签名
  3. 客户端对比版本,发现有更新就弹框问用户
  4. 用户点”安装”,Sparkle 后台下载 zip、校验签名是不是你这个开发者签的、没问题就解压、替换 .app、关掉旧进程、启动新的

对用户来说就是一个对话框加一次重启。对开发者来说就是发版时多产出一份 appcast.xml

谁适合用

两种情况不太适合:上架 Mac App Store 的 App(商店本身有更新机制,且 App Store 也不允许 App 自己拉远端代码下来执行),以及强沙盒 App(Sparkle 2 技术上支持沙盒,但配置麻烦得多,要走 XPC service 隔离)。

比较适合的:自己 Developer ID 签名 + 公证 + 通过官网或 GitHub Releases 分发的独立 Mac App。这也是 Notchy 这类小工具最常见的发布方式。

什么是 EdDSA

Sparkle 在客户端校验更新包真伪用的是 EdDSA 签名。这一步对安全至关重要 —— 没有它,任何能劫持你 appcast URL 的人都能给你的用户推一个伪造的更新包。

EdDSA(Edwards-curve Digital Signature Algorithm)是基于椭圆曲线的数字签名算法。相比经典的 RSA,密钥短得多(公私钥各 32 字节,base64 后大约一行),签名快,对边信道攻击有自带防御。

具体在 Sparkle 里:

  1. 你本机生成一对密钥(私钥 + 公钥)
  2. 公钥写进 App 的 Info.plistSUPublicEDKey 字段,会被烧进每一份发出去的 App)
  3. 私钥只留在你的本机 Keychain 和 CI 的 secret 里
  4. 每次发版,用私钥给 zip 算一个签名,写进 appcast.xml
  5. 客户端下载 zip 后,用嵌在自己 binary 里的公钥校验签名

关键是:就算坏人完全控制了 appcast 服务器,他也伪造不出合法签名—— 除非他拿到了你的私钥。Apple 的公证和 Developer ID 不是 Sparkle 安全的最后一道防线,Sparkle 自带的 EdDSA 签名才是。

也因此:私钥一旦丢了,就再也没法给老用户推新版了—— 公钥已经烧进每一份分发出去的 App 里,没有匹配的私钥就永远签不出合法的更新包。生成后立刻备份到 1Password 或加密 U 盘里,是必做的事。

怎么接

整个接入分四部分:客户端、Info.plist、密钥、CI 流水线。一个个来。

1. SPM 引依赖

Xcode 里 File → Add Package Dependencies,地址:

版本选 up to next major,最低 2.x。Sparkle 2 是现代版本,支持沙盒、XPC 隔离、阶段化推送等。

引入之后写一个最简的 controller:

然后在 AppDelegate.applicationDidFinishLaunching 里引用一下让它实例化:

定时检查、自动下载、弹框 UI、安装重启 —— Sparkle 全包了。”Check for Updates…” 按钮只需要接到 controller.checkForUpdates(nil)

2. 配 Info.plist

Sparkle 至少需要这三个 key:

SUFeedURL 一般有两种托管方式:

  • 单独搞个 GitHub Pages / 自家 CDN,URL 固定
  • 直接用 GitHub Release 的稳定链接https://github.com/<owner>/<repo>/releases/latest/download/appcast.xml

第二种最省事 —— GitHub 这个 URL 永远 redirect 到最新 release 里叫 appcast.xml 的那个 asset,发版时把 xml 当资产上传一份就行,零额外服务。

3. 生成 EdDSA 密钥

Sparkle 自带 generate_keys 工具。SPM 解出来后能在这里找到:

直接跑(不带参数)会在 macOS Keychain 里生成一对 ed25519 密钥,并打印公钥。把这个公钥贴进 SUPublicEDKey

要拿私钥(CI 要用):

文件里就是私钥的 base64 字符串。把这个字符串塞进 CI 的 secret 里(GitHub Actions 里我用的名字是 SPARKLE_PRIVATE_KEY)。本地的私钥文件备份后立刻删掉,因为它是明文。

4. 发版流水线

每次发版多做两件事:

  1. 用私钥给 zip 签名:Sparkle 自带的 sign_update 工具,输入 zip + 私钥文件,输出 sparkle:edSignature="..." length="..." 这一行
  2. 生成 appcast.xml:把版本号、下载 URL、上一步的签名、文件大小、release notes 套进 RSS 模板

最终 appcast.xml 长这样:

注意 <description> 里直接内嵌 release notes 的 HTML —— Sparkle 弹框会原地渲染。如果用 <sparkle:releaseNotesLink> 指向 GitHub release 详情页,Sparkle 会用 WebView 加载那个 URL,GitHub 整站的导航条、登录按钮、左侧栏全会被渲染进对话框里,体验极差。这是亲自踩过的坑。

几个不那么直觉的细节

Sparkle helper 的重签问题

这是当时折腾最久的一个。接入做完、编译一切正常、archive 顺利出来、codesign --verify --deep --strict 全绿、所有嵌套件都 --validated。一切看着都对,提交给 notarytool 跑公证 —— 几十秒后回来一个 status: Invalid

紧接着脚本继续往下跑 staple,挂在了一个看似毫不相关的错误上:

第一反应是网络抖动或 Apple 服务器问题,重跑一次 —— 还是一样。其实 CloudKit 这个错是 staple 在找还没存在的公证票据,根因是上一步 notarytool 返回了 Invalid。

跑一下 xcrun notarytool log <submission-id>,真相终于浮出水面:

原因清楚了:Sparkle 通过 SPM 编译出来时,里面这四个 helper(AutoupdateUpdater.appDownloader.xpcInstaller.xpc)自带的是 ad-hoc 签名,不是你的 Developer ID 签的,也没有安全时间戳。codesign --verify 只检查签名链是否完整、本机能否校验通过,不检查”谁签的”,所以看上去全绿 —— 但 Apple 公证要求每个嵌套可执行文件都用你的 Developer ID 加 secure timestamp 签。

解决办法:archive 完成之后,按由内向外的顺序手动重签 —— 四个 helper、framework 本身、外层 .app,每一步都带 --options runtime --timestamp,最外层那一次还要带回原本的 entitlements 文件。每改动一层嵌套件,外面的封印就破了,必须重新盖一次。

首次升级有断层

接入 Sparkle 的那一版才开始有 Sparkle,再老的版本没有。所以”老版本 → 你接入 Sparkle 的这一版”这次升级用户还得手动下,没办法。从下个版本起才是真的自动。

版本号字段都要写

Sparkle 客户端比较新旧版本时看的是 CFBundleShortVersionStringCFBundleVersion。两个字段都不能漏,且服务端 appcast 里 <sparkle:version><sparkle:shortVersionString> 要和 App 里的对得上,否则版本判断会出意外。

小结

接入 Sparkle 的核心动作就那几下:

  • SPM 加依赖、写个最小的 SPUStandardUpdaterController
  • Info.plist 写好 feed URL 加公钥
  • 生成 EdDSA 密钥对,公钥写 plist、私钥进 Keychain 和 CI secret
  • 发版时多产出 appcast.xml,签好上传

接完之后,发版就是推个 tag,CI 自动签、公证、生成 appcast、上传。用户那边就是某天打开 App,弹框告诉他有新版本,点一下、重启,完事。

如果你也在写独立的 macOS 小工具,自动更新这一步真的值得做 —— 它把”我有时间发版”和”老用户能用上新功能”这两件事彻底脱钩。

Notchy

还记得3月底的时候,在X上火过一段时间的那个Notchy吗?

当时我就下载着试用过,发现点子非常不错,也很实用,但当时还有一些小bug,于是搁置了。

今天突然又想到了,想着过了这么久,应该进化了不少吧?

于是有去GitHub看了一眼,发现原作者似乎没有在维护了。。。

想着这么好的一个项目,就这么荒废着实有点可惜,我就给接手维护一个版本吧。正好昨天研究CCAS的打包,也就顺手给打了一个包。供大家更方便地下载使用。。。

虽然我也修了一些,也合并了一些原repo里的PR,但肯定还有不少问题,如果大家有遇到,欢迎提issue,更欢迎提PR。

附上原作者的推文,以示感谢。
点击查看全文 »