分类: '经验技巧' 的归档
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 后台的每个表单字段,对应本地一个文本文件,目录按语言组织:
|
1 2 3 4 5 6 7 8 9 10 |
fastlane/metadata/ ├── zh-Hans/ │ ├── release_notes.txt ← What's New │ └── promotional_text.txt ← 推广文本 ├── en-US/ ├── ja/ ├── ko/ ├── de-DE/ ├── fr-FR/ └── ar-SA/ |
一个很贴心的设计是:目录里有什么文件,它就只上传什么。我只放了 release_notes.txt 和 promotional_text.txt,那么描述、关键词、截图这些都不会被碰。配置文件 Deliverfile 里再把二进制和截图明确跳过:
|
1 2 3 4 5 6 7 |
app_identifier "Senob.NightSnore" skip_binary_upload true skip_screenshots true force true # 跳过上传前的 HTML 预览确认 run_precheck_before_submit false submit_for_review false # 只填表单,提交审核仍手动 |
搭一条 What’s New 流水线
我的 App Store 文案一直维护在仓库的 AppStore/*.md 里(七个语言各一个文件),每次发版往里追加一段”## What’s New (X.Y.Z)”。这个 Markdown 就是唯一数据源,所以流水线只需要一个提取脚本:从 md 里抠出指定版本的段落,写到 deliver 要的 metadata 目录去。再包一个 fastlane lane 串起来:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
lane :whatsnew do |options| version = options[:version] || get_version_number( xcodeproj: "NightSnore.xcodeproj", target: "NightSnore" ) # 从 AppStore/*.md 生成 fastlane/metadata/<locale>/release_notes.txt sh("python3", "../utils/whatsnew_sync.py", version) deliver( api_key_path: "fastlane/api_key.json", app_version: version ) end |
提取脚本里顺手做了两层校验:七个语言缺任何一个对应版本的段落就直接报错(强制多语言同步,防漏),超过 ASC 的字符上限(What’s New 4000 字符、Promotional Text 170 字符)也直接拦下。以后发版就是一条命令:
|
1 2 3 4 5 6 7 8 9 |
$ fastlane whatsnew version:2.3.0 [18:02:20]: ▸ 同步版本 2.3.0 的 What's New + Promotional Text → fastlane/metadata/ [18:02:20]: ▸ zh-Hans notes 240 字符 / promo 57 字符 [18:02:20]: ▸ en-US notes 659 字符 / promo 158 字符 ... [18:02:29]: Uploading metadata to App Store Connect for localized version 'ja' [18:02:29]: Uploading metadata to App Store Connect for localized version 'ar-SA' [18:02:31]: ✅ 2.3.0 七语言 What's New 已同步到 ASC [18:02:31]: fastlane.tools finished successfully 🎉 |
从跑命令到 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 文件路径的形式,结果:
|
1 2 |
Spaceship::ConnectAPI::Token.from_json_file': [!] App Store Connect API key JSON is missing field(s): key (RuntimeError) |
查了才知道:fastlane 的 Fastfile 里有个 app_store_connect_api_key 这个 action,它支持 key_filepath 参数指向 .p8 文件;但 deliver 的 api_key_path 参数指向的 JSON 文件,只认内联的 key 字段——你得把 .p8 的 PEM 内容整个塞进 JSON 字符串里(换行转成 \n)。同一个工具链里两种密钥写法长得几乎一样但互不兼容,这不纯纯挖坑嘛。正确格式长这样:
|
1 2 3 4 5 6 |
{ "key_id": "ABCD123456", "issuer_id": "12345678-abcd-....", "key": "-----BEGIN PRIVATE KEY-----\nMIGT...\n-----END PRIVATE KEY-----", "in_house": false } |
这个文件含私钥,务必进 .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 文件:
|
1 2 3 4 5 6 7 8 |
sales/ ├── datasets/ │ └── orders_db.md ├── tables/ │ ├── orders.md │ └── customers.md └── metrics/ └── weekly_active_users.md |
每个文件头上用 YAML 写结构化字段,正文用 Markdown 写说明、schema 之类。比如一张订单表,用Google自己的例子,大概长这样:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
--- type: BigQuery Table title: Orders description: One row per completed customer order. resource: https://console.cloud.google.com/bigquery?p=acme&d=sales&t=orders tags: [sales, revenue] timestamp: 2026-05-28T14:30:00Z --- # Schema | Column | Type | Description | |--------|------|-------------| | `order_id` | STRING | Globally unique order identifier. | | `customer_id` | STRING | FK to [customers](/tables/customers.md). | ...... |
注意最后那行——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 到底什么含义、orders 和 customers 怎么关联、某个枚举值代表哪种业务状态——这些”肉”通常只长在人写的文档里。富化 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 的一个子命令:
|
1 2 |
.venv/bin/python -m reference_agent visualize --bundle ./bundles/stackoverflow # 写出 bundles/stackoverflow/viz.html |
它在生成时把整个 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 页面下载,建议顺手核对哈希:
|
1 2 3 |
curl -fsS https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem \ | openssl x509 -outform DER | shasum -a 256 1cb9823ba28ba6ad2d33a006941de2ae4f513ef1d4e831b9f7e0fa7b6242c932 - |
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 一次:
|
1 2 3 4 5 |
const base = concat(authData, clientDataHash); const nonce = new Uint8Array(await crypto.subtle.digest("SHA-256", base)); const ok = await crypto.subtle.verify( { name: "ECDSA", hash: "SHA-256" }, key, sigP1363, nonce ); |
这层”看不见的 hash”我是把原始字节 dump 出来在本地穷举才定位到的——推理走不动时,让事实说话往往更快。
实现时几个容易绊倒的点
主线讲完了,把我真机联调时踩到的坑列一下,纯属佐料,但能省你几个小时:
1. Team ID 不一定是你以为的那个。报 “RP ID hash mismatch” 时我很懵,appId 明明拼对了。后来去构建产物里一看,签名证书的 team 和描述文件的 team 是两个,App Attest 取的是 application-identifier 里那个:
|
1 2 3 |
codesign -dvv MyApp.app Authority=Apple Development: 我的名字 (TEAMBBBBBB) TeamIdentifier=TEAMAAAAAA # appId 用的是这个 |
2. COSE 公钥是整数键。authData 里那把公钥是 COSE 格式,x、y 的键是 -2、-3 这种整数。cborg 默认解对象会直接抛 “non-string keys not supported”,得开 useMaps 解成 Map 再 get:
|
1 2 3 |
const coseKey = cborDecode(authData.slice(coseOffset), { useMaps: true }); const x = coseKey.get(-2); const y = coseKey.get(-3); |
3. 模拟器测不了。App Attest 在模拟器上直接不支持,匿名链路只能上真机。开发期可以在服务端留个开关跳过验证方便联调,但上线前务必删掉。
值不值得用
如果你有”匿名 / 低门槛、但又怕被脚本滥用”的接口,App Attest 是目前苹果生态里最硬的一道闸:信任根在苹果、私钥锁在 Secure Enclave,比任何塞在客户端的密钥都难仿造。代价是只覆盖真机正版、客户端服务端都得改、还得忍受一段真机调试的来回。
它的坑也基本都不在文档主线上,而在那些”想当然”的接缝处——根证书的位置、Team ID 的来源、COSE 的键类型、ES256 那层默认的 hash。单看每个都不难,叠在一起就够耗你一天。提前知道它们长什么样,就能少趟很多。
全文完。
给 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,十有八九背后都是它。
工作流大致这样:
- App 启动或定时,Sparkle 去服务器拉一份
appcast.xml - xml 里列着最新版本号、下载地址、文件大小、加密签名
- 客户端对比版本,发现有更新就弹框问用户
- 用户点”安装”,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 里:
- 你本机生成一对密钥(私钥 + 公钥)
- 公钥写进 App 的
Info.plist(SUPublicEDKey字段,会被烧进每一份发出去的 App) - 私钥只留在你的本机 Keychain 和 CI 的 secret 里
- 每次发版,用私钥给 zip 算一个签名,写进
appcast.xml - 客户端下载 zip 后,用嵌在自己 binary 里的公钥校验签名
关键是:就算坏人完全控制了 appcast 服务器,他也伪造不出合法签名—— 除非他拿到了你的私钥。Apple 的公证和 Developer ID 不是 Sparkle 安全的最后一道防线,Sparkle 自带的 EdDSA 签名才是。
也因此:私钥一旦丢了,就再也没法给老用户推新版了—— 公钥已经烧进每一份分发出去的 App 里,没有匹配的私钥就永远签不出合法的更新包。生成后立刻备份到 1Password 或加密 U 盘里,是必做的事。
怎么接
整个接入分四部分:客户端、Info.plist、密钥、CI 流水线。一个个来。
1. SPM 引依赖
Xcode 里 File → Add Package Dependencies,地址:
|
1 |
https://github.com/sparkle-project/Sparkle |
版本选 up to next major,最低 2.x。Sparkle 2 是现代版本,支持沙盒、XPC 隔离、阶段化推送等。
引入之后写一个最简的 controller:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import Sparkle @MainActor final class UpdaterController { static let shared = UpdaterController() let controller: SPUStandardUpdaterController private init() { controller = SPUStandardUpdaterController( startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil ) } func checkForUpdates() { controller.checkForUpdates(nil) } } |
然后在 AppDelegate.applicationDidFinishLaunching 里引用一下让它实例化:
|
1 |
_ = UpdaterController.shared |
定时检查、自动下载、弹框 UI、安装重启 —— Sparkle 全包了。”Check for Updates…” 按钮只需要接到 controller.checkForUpdates(nil)。
2. 配 Info.plist
Sparkle 至少需要这三个 key:
|
1 2 3 4 5 6 |
<key>SUFeedURL</key> <string>https://你的域名/appcast.xml</string> <key>SUPublicEDKey</key> <string>你生成的 EdDSA 公钥(base64)</string> <key>SUEnableAutomaticChecks</key> <true/> |
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 解出来后能在这里找到:
|
1 |
~/Library/Developer/Xcode/DerivedData/<你的项目>-*/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_keys |
直接跑(不带参数)会在 macOS Keychain 里生成一对 ed25519 密钥,并打印公钥。把这个公钥贴进 SUPublicEDKey。
要拿私钥(CI 要用):
|
1 |
generate_keys -x ~/Desktop/private.key |
文件里就是私钥的 base64 字符串。把这个字符串塞进 CI 的 secret 里(GitHub Actions 里我用的名字是 SPARKLE_PRIVATE_KEY)。本地的私钥文件备份后立刻删掉,因为它是明文。
4. 发版流水线
每次发版多做两件事:
- 用私钥给 zip 签名:Sparkle 自带的
sign_update工具,输入 zip + 私钥文件,输出sparkle:edSignature="..." length="..."这一行 - 生成 appcast.xml:把版本号、下载 URL、上一步的签名、文件大小、release notes 套进 RSS 模板
最终 appcast.xml 长这样:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<?xml version="1.0" encoding="utf-8"?> <rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"> <channel> <title>YourApp</title> <item> <title>Version 1.2.3</title> <sparkle:version>1.2.3</sparkle:version> <sparkle:shortVersionString>1.2.3</sparkle:shortVersionString> <description><![CDATA[ <h2>Changes</h2> <ul><li>release notes 的 HTML</li></ul> ]]></description> <pubDate>Sat, 23 May 2026 10:00:00 +0000</pubDate> <enclosure url="https://.../YourApp-1.2.3.zip" sparkle:edSignature="..." length="2607122" type="application/octet-stream" /> </item> </channel> </rss> |
注意 <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,挂在了一个看似毫不相关的错误上:
|
1 2 |
CloudKit query for YourApp.app (...) failed due to "Record not found". The staple and validate action failed! Error 65. |
第一反应是网络抖动或 Apple 服务器问题,重跑一次 —— 还是一样。其实 CloudKit 这个错是 staple 在找还没存在的公证票据,根因是上一步 notarytool 返回了 Invalid。
跑一下 xcrun notarytool log <submission-id>,真相终于浮出水面:
|
1 2 3 4 5 |
"path": ".../Sparkle.framework/Versions/B/Updater.app/Contents/MacOS/Updater", "message": "The binary is not signed with a valid Developer ID certificate." "path": ".../Sparkle.framework/Versions/B/Updater.app/Contents/MacOS/Updater", "message": "The signature does not include a secure timestamp." … (Autoupdate、Downloader.xpc、Installer.xpc 同样两条 × 2) |
原因清楚了:Sparkle 通过 SPM 编译出来时,里面这四个 helper(Autoupdate、Updater.app、Downloader.xpc、Installer.xpc)自带的是 ad-hoc 签名,不是你的 Developer ID 签的,也没有安全时间戳。codesign --verify 只检查签名链是否完整、本机能否校验通过,不检查”谁签的”,所以看上去全绿 —— 但 Apple 公证要求每个嵌套可执行文件都用你的 Developer ID 加 secure timestamp 签。
解决办法:archive 完成之后,按由内向外的顺序手动重签 —— 四个 helper、framework 本身、外层 .app,每一步都带 --options runtime --timestamp,最外层那一次还要带回原本的 entitlements 文件。每改动一层嵌套件,外面的封印就破了,必须重新盖一次。
首次升级有断层
接入 Sparkle 的那一版才开始有 Sparkle,再老的版本没有。所以”老版本 → 你接入 Sparkle 的这一版”这次升级用户还得手动下,没办法。从下个版本起才是真的自动。
版本号字段都要写
Sparkle 客户端比较新旧版本时看的是 CFBundleShortVersionString 和 CFBundleVersion。两个字段都不能漏,且服务端 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 小工具,自动更新这一步真的值得做 —— 它把”我有时间发版”和”老用户能用上新功能”这两件事彻底脱钩。
Keychain和security简介
写 CCAS 的时候要做的事其实很本质:在多个 Claude 账号之间切换。第一个要弄清楚的是 —— Claude Code 把自己的 OAuth credentials 存在哪?
翻一下之前泄露的代码很容易就知道了:在 macOS Keychain 里,service 名字是 Claude Code-credentials。你现在就可以在终端里看一眼:
|
1 |
security find-generic-password -s "Claude Code-credentials" -w |
会打印出一坨 JSON,里面就是 access token、refresh token 这些。
这不是 Claude Code 的”小众”选择 —— 各种 IDE、CLI、Apple 自家的 Safari Mail,所有在 macOS 上要存”密码、token、密钥”的程序,默认就该放进 Keychain。它是这台机器上事实标准的敏感数据存储。
那它到底是什么?

Keychain 是什么
最直白的描述:Keychain 是 macOS 自带的加密键值数据库,专门为存敏感数据设计。每条记录由系统加密,挂在登录用户的身份下,由 securityd 这个常驻守护进程管理。
它的物理形态是磁盘上的几个文件,常见的有:
|
1 2 |
~/Library/Keychains/login.keychain-db # 用户登录 keychain /Library/Keychains/System.keychain # 系统级(如 WiFi 密码) |
你不会直接读这些文件 —— 它们是加密的 SQLite,主密钥跟你的登录密码绑定。读写都要走系统 API,由 securityd 解锁后再返回明文。
它解决了什么
如果不用 Keychain,自己拿个 JSON 文件存 token 行不行?技术上可以,但你要面对几个问题:
1. 磁盘加密
文件丢在 ~/.config/ 下是明文。任何能读你 home 目录的进程都能拿到(包括别的 app、误装的恶意脚本)。即便你给文件 chmod 600,磁盘镜像被拷走照样能看。
Keychain 用 AES 加密,主密钥来自你的登录密码 —— 机器没登录、密码不对,磁盘镜像里的内容就解不出来。
2. 进程隔离
文件系统只有”用户级”权限。但同一用户跑的所有 app 是平等的:Claude Code 写下的 token,理论上你装的任何一个三方 app 都能读。
Keychain 的每条记录带 ACL(access control list),记录”谁创建的、谁可以读”。当一个 app 试图读另一个 app 创建的条目时,系统会弹窗让用户确认。这是文件系统本身做不到的。
3. 用户可见、可管理
打开 Keychain Access.app,你能看到所有条目、谁创建的、什么时候改的,可以手动删。要 export 还得再输一次登录密码。
4. 跨设备同步
勾上 “iCloud Keychain” 之后,相应条目会端到端加密同步到你登录同一 Apple ID 的其他 Mac、iPhone、iPad。Safari 的密码、WiFi 密码、Apple Pay 卡号走的都是这个。
数据模型
Keychain 里有几类 item,最常用的是 generic password(任意 key-value 的密钥/token,多数 app 自定义存储用这个)和 internet password(带 protocol/host/port/path 字段,Safari 存网站密码用)。此外还有 certificate、key(公私钥对)、identity(cert + key 对)。
generic password 的核心字段就两个:
- service:通常用 reverse-DNS 风格的命名空间,例如
com.apple.account或Claude Code-credentials。 - account:同一 service 下区分多条记录的标识,通常是用户名或邮箱。
service + account 唯一定位一条记录。Password 字段本身可以是任意字节(你完全可以塞一整段 JSON 进去,Claude Code 就是这么干的)。
怎么读写:security 命令
Apple 给开发者两套接口:
- C API:
SecItem*这一族(旧的SecKeychain*已经不推荐)。Swift / Objective-C app 一般用这个。 - 命令行
/usr/bin/security:脚本和调试用。
CCAS 走的是后者,省得对接 C API;好处是用户自己在终端就能验证一切。最常用的三条:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# 写入 security add-generic-password \ -s "com.example.myapp" \ -a "alice@example.com" \ -w "the-secret" # 读出 security find-generic-password \ -s "com.example.myapp" \ -a "alice@example.com" \ -w # 删除 security delete-generic-password \ -s "com.example.myapp" \ -a "alice@example.com" |
-s 是 service,-a 是 account,-w 表示”只把 password 内容打到 stdout”。不加 -w 会打印所有属性元数据,但隐去 password。
更多有用的子命令:
|
1 2 3 4 5 |
security list-keychains -d user # 列出当前 keychain search list security unlock-keychain # 手动解锁 security dump-keychain # 列出当前 keychain 中所有 item 的元数据 security add-internet-password ... # 写网站密码 security find-certificate ... # 找证书 |
几个不那么直觉的细节
search list:系统其实管理着一个 keychain “搜索列表”,security find-* 默认遍历整个列表。常见情况下只有 login.keychain-db,但用户/MDM 可能加挂别的。如果某条记录意外落在搜索列表外的 keychain 里,会出现”dump 看得到、find 找不到”的现象,这时候要在命令末尾显式带上 keychain 路径。
密码存二进制:用 -w "value" 写入时整个参数被当作 UTF-8 字符串。如果 value 里有换行、引号、控制字符,shell 转义会很烦人。更可靠的方式是 -X hex,把内容先转 hex 再交给 security,由它自己 decode 后存原始字节。
-U 不一定是真正的 upsert:add-generic-password -U 文档说”如果存在则更新”,但匹配条件偏严,遇到某些属性差异会判定为”新条目”再插一条。同 service+account 的重复记录会越积越多。要 idempotent 的话,先 delete 再 add 更稳。
account 不是严格过滤:find-generic-password -s SVC -a ACCT 如果 account 没精确匹配,可能 fallback 命中同 service 下别的 account —— 不报错,直接返回别人的内容。读完后再校验返回值是个好习惯。
小结
Keychain 是 macOS 上存敏感数据的标准答案。它做的事不复杂 —— 加密、隔离、用户可控、可同步 —— 但是把这四件事做齐了,且对开发者基本零成本。
下次写 Mac app 要存 token、API key 或者密码,不用犹豫,直接 SecItem / security 走起,比自己掂量”放哪、加什么密、谁能读”省太多事。
uv 的 inline script metadata 太香了
最近又玩了下 uv,感觉它在“小工具开发”这个场景里特别顺手。
uv 本质上是一个把 Python 依赖管理、虚拟环境、脚本运行这些事情揉在一起的工具。它最大的优点当然是快,但更让我喜欢的是:很多原本零碎又烦的动作,被压缩成了几个很好记的命令。
比如平时写个小脚本,常常只是想:
- 调一下接口
- 批量处理一点文件
- 验证一个第三方库
- 给自己做个顺手的小命令
这类东西通常不值得认真建个项目,但又往往会依赖 requests、httpx、rich 之类的包。以前一般要先建 venv,再装依赖,可能还要改个 .gitignore,多少有点麻烦。
这时候 uv 很方便的一个点,就是可以直接用 --with:
|
1 |
uv run --with requests demo.py |
或者:
|
1 |
uv run --with httpx --with requests foo.py |
这种方式特别适合刚开始写小工具的时候。先跑起来,先验证思路,不用一上来就准备完整项目。
不过,--with 也有个问题:依赖信息是在命令里,不在脚本里。
今天你记得怎么跑,过几天自己可能都忘了。发给别人时,也得额外告诉对方要带哪些依赖。
这时候真正的大招就来了:inline script metadata。
你可以直接把依赖用这样的格式,写到脚本头部:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# /// script # requires-python = ">=3.11" # dependencies = [ # "httpx", # "rich", # ] # /// import httpx from rich import print resp = httpx.get("https://luy.li") print(resp.status_code) |
可以看到,去掉# /// script这些包裹层之后,里面的内容,其实就是TOML的语法。
这样之后就不用再写长命令了,直接:
|
1 |
uv run foo.py |
就行。这个体验非常好。因为脚本自己就说明白了:
- 需要什么 Python 版本
- 依赖哪些包
- 应该怎么运行
更进一步,你甚至可以再加个 shebang,把它写成一个可以直接执行的脚本:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.11" # dependencies = [ # "httpx", # "rich", # ] # /// import httpx from rich import print print(httpx.get("https://luy.li").status_code) |
这样给脚本加上执行权限(chmod +x)之后,连 uv run foo.py 都可以省掉,直接 ./foo.py 就可以跑起来了。
最后总结一下:
- 刚开始随手写个小工具,用
uv run --with ... - 觉得这个脚本值得留下来,就上
inline script metadata - 再彻底一点,可以用
#!/usr/bin/env -S uv run --script这个shebang
OpenClaw分享
以下PPT和内容,来源是我跟一群爱学习的朋友一起学时下大火的OpenClaw小龙虾,我给大家做了个小分享。
PPT几乎是OpenClaw输出的内容,下面的会议总结也是GPT根据会议录屏总结的。
需要注意的是,我其实没有将小龙虾玩得很深,因为我从心底里是不信任AI的,也就不敢给它太多权限。所以内容也都比较浅,请见谅。
OpenClaw 会议总结
这次分享的核心,不是在介绍一个“聊天机器人”,而是在介绍一套可自托管、可扩展、可执行任务的个人 AI 基础设施。分享者把 OpenClaw 定位为“装进手机里的 AI 助手”:用户通过 WhatsApp、Telegram 等聊天入口发出请求,背后由部署在自己电脑或服务器上的 OpenClaw 网关完成会话管理、记忆注入、工具调用和本地执行,再把结果返回到聊天端。PPT 对这一定位和整体链路描述得很清楚。
点击查看全文 »
局域网影音解决方案——Jellyfin
先交代下背景:我本来是打算在家里搞个NAS的,但由于最近硬盘和内存都疯狂涨价,加上其实需求也不是那么迫切,就一直没有去折腾。
另外,家里其实已经有一个小主机(对,本blog就跑在上面呢)了,它带了1TB的SSD。所以偶尔有下载电影、在不同的屏幕(手机、pad、电脑、电视机等)看电影的需求,就想着把这个小主机再压榨一下吧。
于是有了以下的折腾:
下载的需求,其实很好搞定。但下载完之后,电影在小主机上,要在各种设备上播放,我一个想到的是用SMB在局域网里共享目录,这确实是一个方案。但这个方案有两个问题:
- 不是所有的(屏幕)设备都能找到支持SMB的播放器。
- 有些设备的解码能力太弱,遇到高码率的4K视频,就会卡住
所以,最好的方式就是再充分压榨这个小主机,因为它其实有个显卡(CPU:AMD Ryzen 3 7330U(集成 Radeon iGPU)),可以用于服务端转码。于是就给它装了个 Jellyfin。
Jellyfin是个开源的、完全自托管的影音解决方案,特别适合喜欢折腾的人。
安装过程我也偷懒了,没有去编译源码、调试环境,而是直接采用docker运行了,在我的Ubuntu下,大致如下:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# 添加docker的key和源 curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # 安装 docker 和 compose 插件 sudo apt update sudo apt install docker-ce docker-ce-cli containerd.io docker-compose-plugin |
接着,建一个 Jellyfin 的空目录,在里面建一个 docker-compose.yml 内容如下:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
services: jellyfin: image: jellyfin/jellyfin:latest container_name: jellyfin ports: - "8096:8096" volumes: - ./config:/config - ./cache:/cache - /home/shared:/media:ro devices: - /dev/dri:/dev/dri environment: - TZ=Asia/Singapore - LIBVA_DRIVER_NAME=radeonsi restart: unless-stopped |
其中,/home/shard 是存电影的目录。
然后,在此目录下,执行 docker compose up -d,不出意外的话,你的Jellyfin就应该能正常启动了。这时候就可以访问 http://192.168.你的.IP:8096 进入web管理界面进行进一步的设置了。
设置完管理员账号之类的信息以后,需要注意的一点,就是开启转码的硬件加速。做法是在 Dashboard → Playback → Transcoding 里
Hardware acceleration 选择 Video Acceleration API (VAAPI),这是AMD的芯片+Linux系统的选项,其他的硬件、软件的组合,可以参考官方文档。
VAAPI device 就选择 /dev/dri/renderD128,如果前面的 docker-compose.yml 没有写错,这里应该是可以选到的。
这么做了以后,在客户端有播放视频的时候,即使是播放的4K视频,CPU利用率也不应该太高。如果是CPU持续打满,就建议再看看硬件加速的配置。
配置好以后,家里的几个屏就都可以无缝切换看小主机里的电影了!因为每个屏至少都有浏览器可用,直接浏览器打开上述地址就可以了。当然,安装最佳实践来说的话,在有条件的情况下,最好使用官方客户端来看,而不是只用浏览器。因为用浏览器就会把所有的解码压力,都丢给服务端。尤其是你的端侧算力很强(比如Mac)的时候,用客户端可以直接拿原始视频到端侧解码!这样不仅服务器的压力更小,而且画质也是无损的。体验也会更好!
那么,如果大家还有比较全、质量又比较高的电影下载源(最好是magnet协议的)的话,也请留言告诉我!谢谢!
终于搞清楚Google账号的所属国家的逻辑了
如果你买过YouTube premium,或者加过其他人买的YouTube premium family套餐,后续又因为种种原因(比如Google 封禁了假的土耳其区等)退出了premium计划,那你再想加入另一个family的时候,大概率会看到这么一个提示:告诉你“您似乎与邀请您的人不在同一个国家/地区。”,从而“无法加入家人群组”。

然后,你可以会在Google账号的后台管理账号做各种尝试,包括用不同国家的VPN,拿到特定的IP地址、修改账号的住址信息、添加不同国家的信用卡作为付款方式等等,但我猜这大概率都解决不了你的问题!
我曾经也因此折腾了很久,知道最近,才明白怎么查看以及修改这个字段。先说好:方法仅供参考,也别拿大号玩得太花,不然被封就嘎了~
查看:
先得有办法确认当前的账号所属国家,才知道要不要去做修改。
这个方法很简单,登陆Google Play的web页面,登陆以后,确定右上角的头像是要查看的账号头像,然后把页面拉到最底部,右下角就会显示 国家(语言)。
这可能是唯一精确知道目前账号所属国的办法。
修改:
如果确定了你的国家不是你想要的,要怎么修改呢?
点击查看全文 »
WordPress文章如何同步到X
其实,十几年前,我的blog就有这个功能的,比如:
luy.li 新文章: mysql主备部署笔记 ( https://luy.li/2011/05/11/mysql_replication/ )
— 骨头 (@senob_) May 11, 2011
当时用的是一个叫 WP2Twitter 的 WordPress插件,当时的逻辑也非常简单,有新post的时候,就把标题和URL拼一个字符串,直接往Twitter发了,而且那时候Twitter的权限也比较松。
最近重拾blog以后,想着也把这个功能恢复一下,而且现在不是有AI了嘛,其实还可以更进一步,不仅可以输出标题,还顺便把文章的内容做个大致的摘要,可以让有兴趣的朋友点进来,也可以避免浪费对此内容不感兴趣的朋友的时间。
搜了一下IFTTT、RSS转X等方案,其实倒是有很多平台都能实现,但要么功能过于强大,搞得很复杂;要么需要订阅,要交一笔费用。后来眼光还是回到WordPress插件上来了,还真让我找到一个至少非常适合我的插件,分享给大家,如果有需要的话,也可以参考。
这个插件叫 Automator,其功能非常强大,可以连接WP和各种社交媒体和平台,部分功能也需要订阅pro,但发布到X,则是免费版就够用的。
点击查看全文 »