2026-08-07 11:43:38
昨天在尝试为一个扩展程序增加 Safari 支持的时候,发现了一个神奇的问题,在 popup 中滚动感觉很迟缓。
就像是这样,滚动速度不仅缓慢,而且没有任何惯性效应。
如果在新标签页打开 popup 页面,你可以看到滚动要流畅的多
具体来说,复现步骤很简单
1 |
|
在此之前,我其实已经遇到了几个棘手的问题,包括
幸运的是,4 个表面上毫不相干的错误实际上指向同一个问题:popup 原生窗口会根据内容高度自动 resize,而在 chrome/firefox 中并没有什么问题,但 safari 似乎仍然有一些边缘的错误。
在尝试解决的过程中,一开始并未意识到是 resize 导致的问题,我尝试了不少方法,包括
关键代码如下
1 |
|
1 |
|
所以 safari popup 到底发生了什么呢?我查询来自官方文档中的信息只有一个,来自 WebKit Blog - Safari 26.2 更新日志 中的这样一句描述:extension popups could open scrolled down and some websites could flicker during scrolling。
另外还找到两条相关但不完全对得上的线索:WebKit Bug 296056 报告了 iPadOS 26 上 popup 打开时滚动位置异常,已经修复,不过根因是 <dialog> 关闭后焦点恢复触发的滚动,跟这里的自动 resize 不是一回事;Apple Developer Forums 上的一个帖子 则直接印证了 resize-to-content 这套机制本身就不太可靠——有人报告 iPad 上 popup 容器撑高后再也缩不回去了。加起来看,这块至少不是我一个人遇到的边缘情况。
没有找到任何文档或 issue 把这 4 个现象和”根据内容自动 resize”这个根因关联起来,所以我把这次调试过程整理成了一份报告提交给了 Apple(FB24198765),同时也镜像发布到了专门收集 Safari 扩展 Feedback Assistant 报告的 lapcat/SafariExtensions 仓库 —— Feedback Assistant 本身是私有不可搜索的,这个仓库算是社区自发攒起来的公开备份。如果你也遇到过类似的问题,欢迎去那边 +1 或者补充你自己的复现场景。
Safari 扩展在所有浏览器中都是一个异类,它强制扩展作为一个 app 封装和分发,也就是说,安装一个 Safari 扩展和安装一个 app 使用的安装包是一模一样的。即使不算扩展,Safari 浏览器对 Web 标准的支持也像是新时代的 IE,它如此糟糕,以至于 iOS 支持经常被视为一项痛苦的工作 – iOS 上所有浏览器都使用 webkit 内核,包括 Chrome 浏览器,Apple 禁止了浏览器使用自己的内核。
↩
,同样的现象在 Apple Developer Forums 上一个 2021 年的老帖子 里也有人报告过,至今没有回复。 ↩2026-08-05 21:14:30
在 使用 GitHub Actions 全自动发布 Safari 扩展 里,我写过一套在 macOS runner 上用 Xcode 云签名(-allowProvisioningUpdates)自动构建、签名、上传 Safari 扩展的流程。流程本身是能跑起来的,但在跑通之前,我踩过两个坑,而且巧的是,这两个坑都有同一个特点:报错信息含糊不清,搜索引擎上几乎搜不到答案。这篇文章把它们记录下来,包括现象、原因和修复方法。
两个坑追根溯源是同一件事:无状态的 CI runner,打破了苹果签名工具原本假设的前提。
场景是这样的:GitHub 提供的 macos-* runner,开着 -allowProvisioningUpdates 让云签名自动处理证书。一开始一切正常,版本一个接一个发出去。然后某一天,你什么都没改,构建却突然报错,说证书数量达到了上限。
真正发生的事情是这样的:GitHub 的每个 runner 启动时都是一个全新的、空的 keychain。云签名找不到可用的签名身份时,不会直接失败,而是很「贴心」地帮你的账号新建一张 Apple Distribution 证书,把私钥放进这次 runner 的 keychain 里。job 结束后 runner 被销毁,私钥跟着一起消失。证书本身还留在你的 Apple Developer 账号里,永久躺在那儿,再也没有对应的私钥能用它签名。
也就是说,每次发布都在悄悄烧掉一张证书。苹果对每个账号的 Distribution 证书数量设了上限,一旦顶到这个上限,云签名就没法再新建证书,构建从此开始失败。最阴险的地方在于:撞上限之前,每一次构建都是成功的,你完全看不出哪里不对,直到它毫无征兆地、永久性地坏掉。

这是攒了一段时间之后的样子——十几张证书,全部叫「Created via API」,全部挤在大约一周之内。它们无一例外都是孤儿证书:创建它们的 runner 早就没了,私钥自然也不存在了。
解决办法就是不要再让 CI 自己新建证书:
.p12
.p12 需要先 base64)xcodebuild 跑之前,把它导入 keychain1 |
|
keychain 里已经有一个可用的签名身份之后,云签名每次都会复用它,而不是每次都新建一个。
注:修好这个问题之后,记得去开发者后台的证书列表,把 CI 之前留下的那一堆孤儿证书清理掉——找不到对应私钥的那些就是。
传给 xcodebuild 的 App Store Connect API Key 是有角色权限的,创建的时候就定死了。坑就在这里:一个 Developer 或 App Manager 角色的 key,身份认证是能通过的,然后构建会在签名进行到一半时失败,只甩给你一句语焉不详的「Cloud signing permission error」。
正是这种失败方式让人摸不着头脑:key 本身没有被拒绝,上传之类的其他 App Store Connect API 操作用这个权限也都正常,唯独云签名这一步会死,报错里连「权限」两个字都没提。

按我的实测,云签名要求 key 必须是 Admin 角色。角色在创建时就固定了、后续改不了,唯一的办法是重新生成一个:App Store Connect → Users and Access → Integrations → App Store Connect API → 生成一个 Admin 角色的 key,下载 .p8(只有这一次下载机会)。
注:Admin 角色的
.p8要当成整套流程里真正的机密来对待,它是这里唯一的真机密。issuer id、key id、team id 这些都不是机密,.p8和.p12才是。
两个坑追根溯源都是同一件事:苹果的签名工具链假设你在用一台长期存在的开发机,而无状态的 runner 打破了这个假设,失败得又晚又含糊,而不是又早又清楚。
写上一篇文章之后,我把这整套流程——生成 Xcode 工程、用上面这两个修复方式签名、上传、提交审核——都收进了 extport,也就是我现在给所有扩展发版用的工具。如果你不想自己维护这堆 YAML,它就是这篇和上一篇里讲的这一整套东西的打包版本。
2026-07-20 15:08:07
我之前写过两篇关于 Safari 扩展的博客:转换 Chrome Extension 为 Safari 版本,以及 发布 Safari 扩展到 iOS 应用商店。它们解决的是”第一次怎么做成”。但随着我的 Safari 扩展越来越多,每次发版的手动流程变得越来越难以忍受:打开 Xcode、改版本号、Archive、Validate、Distribute,再打开 App Store Connect 选 bundle、填 release note、点提交——每个扩展十分钟起步,而且不能出错。
这篇文章介绍我现在的发布流程:改一下 package.json 里的 version,push 到 GitHub,然后就没有然后了。构建、签名、上传、提交审核,全部在 GitHub Actions 里自动完成。

先看终态。这是一个真实项目里 Safari 发布相关的完整 workflow 配置:
1 |
|
配好这份 YAML 和它引用的 secrets,就是全部了。它用到了我为此写的两个工具:
为什么 Safari 需要专门的工具?Chrome/Firefox/Edge 的发布 WXT 已经做得很好了,一条命令的事。但 Safari 扩展本质上是一个 App——浏览器扩展的代码被嵌在一个 macOS/iOS 应用里,发布走的是 App Store,这意味着要面对 Xcode 项目、证书、签名、公证、provisioning profile 这一整套 Apple 的分发体系。复杂度的直观展示——这是一个全平台扩展需要的所有 secrets:

看起来吓人,但每一个都只需要配置一次。下面按顺序讲清楚每一步在做什么、每个 secret 从哪里来。
顺带一提:这套流程也是逐步演化出来的。最早我只用 wxt-module-safari-xcode 生成项目,仍然在 Xcode 里手动构建上传;后来 action 的 build 模式让我不再需要打开 Xcode;最后加上 submit 模式,才把最后”打开 App Store Connect 点两三分钟”的步骤也消灭掉。如果你想了解完整的手动流程(以及为什么它值得被自动化),见开头那两篇博客。
Apple 官方提供了转换工具 xcrun safari-web-extension-converter,但它只做最基础的转换:生成的 Xcode 项目还需要手动设置版本号、App 分类、开发团队等。wxt-module-safari-xcode 把这些集成进了 WXT 的构建流程——运行 wxt build -b safari 时自动完成转换和全部配置注入。
1 |
|
四个配置项的来源:
projectName:扩展的显示名,包含大写字母或空格都没问题。appCategory:App Store 的应用分类,可选值见 Apple 官方分类列表。bundleIdentifier:全局唯一标识,惯例是反转域名 + 应用名,全小写、不含空格。developmentTeam:你的 Apple Developer Team ID,在 developer.apple.com/account 的 Membership 页面可以找到。
运行 pnpm wxt build -b safari 后,Xcode 项目会出现在 .output/<projectName>,版本号已经从 package.json 自动同步(模块会同时更新 MARKETING_VERSION 和 CFBundleVersion,后者按 major×10000 + minor×100 + patch 换算成递增的数字版本——Apple 要求每次上传的 build number 必须递增,这个换算规则让它跟着语义化版本自动满足)。
真实项目的配置示例:https://github.com/rxliuli/imp-translate/blob/main/wxt.config.ts
这是整个流程中最繁琐的部分,但每项只做一次。分三组。
CI 里签名的原理是:把你的分发证书(含私钥)导出成 .p12 文件,以 base64 形式存进 GitHub secrets,action 在运行时把它导入 runner 上的临时 keychain 供 Xcode 签名使用。步骤:
productbuild 打包时需要这张证书签名),以及 Apple Distribution——注意一个命名陷阱:创建页面上它叫 “Apple Distribution”,但创建后在证书列表里只显示为 “Distribution“(Platform 列为 “All”),是同一张证书。它是跨平台的,负责 iOS 分发签名。创建过程中需要用本机钥匙串生成 CSR(证书签名请求),按页面提示操作即可。这三张证书都是账户级的,不绑定任何具体扩展——配置一次,所有项目通用。APPLE_CERTIFICATE_PASSWORD。base64 -i Certificates.p12 | pbcopy,粘贴为 APPLE_CERTIFICATE_BASE64。
在 Profiles 页面 为每个 target 创建 App Store 类型的 profile。转换器生成的项目里有 App 和 Extension 两个 target,各自有独立的 bundle id——Extension 的 id 是主 id 加 .Extension 后缀(例如 com.example.my-extension.Extension),两个 bundle id 都需要先在 Identifiers 页面注册。macOS 和 iOS 各一套,所以一共 4 个 profile。每个下载后同样 base64 编码存入对应的 secret。

注意:这一步是按扩展算的——每新增一个 Safari 扩展,都要为它创建一套新的 profile。这也是所有 secrets 里唯一不能跨项目复用的部分(证书和 API key 都是账户级的,配一次全部扩展通用)。
在 App Store Connect 的 Integrations 页面 创建团队 API 密钥,角色选 App Manager 就足够(最小权限原则,不要用 Admin)。创建后:
base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy 转成 base64 后存入 APPLE_API_KEY
APPLE_API_KEY_ID
APPLE_API_ISSUER
这组密钥承担两个职责:build 模式用它上传 bundle(替代了 Xcode 的 Distribute 步骤),submit 模式用它调用 App Store Connect API 创建版本并提交审核。

找不到这些页面入口是正常的,Apple 的后台并不以易用著称。一个现代解法:让能操作浏览器的 LLM 帮你导航到对应页面,比自己翻文档快得多。
回头看开头那份 YAML,Safari 发布被拆成了两个 job,这个拆分有讲究:
safari(build 模式) 跑在 macOS runner 上——只有这一步真正需要 macOS 和 Xcode(注意必须是 macos-26 及以上,action 会检查 Xcode 26 SDK)。它做四件事:导入证书到临时 keychain、用 xcodebuild 构建并签名(macOS 产出 x86_64+arm64 通用二进制)、打包、通过 API 上传到 App Store Connect。跑完之后,bundle 已经出现在 App Store Connect 的构建版本列表里,等价于你在 Xcode 里点完了 Archive → Distribute。一个省心的细节:action 会自动从 Xcode 项目的 scheme 检测目标平台——项目里只有 macOS scheme 就自动跳过 iOS 步骤,反之亦然,不需要额外配置。
safari-submit(submit 模式) 跑在 ubuntu runner 上——它只调用 App Store Connect API,不需要 macOS,用便宜的 runner 就好。它等价于你在网页上手动做的那套:轮询等待 bundle 处理完成、创建新版本、关联刚上传的 bundle、填写 What’s New、提交审核。也因此它只需要 API 三件套这 3 个 secrets。开头抱怨过的”每次都要手动填 release note”也在这里被解决了:通过 release-notes 输入传入,不传则默认 “Bug fixes and improvements.”。

诚实地说:如果你只有一两个 Safari 扩展,Xcode 的手动流程是可以忍受的,它替你隐藏了证书和签名的大部分复杂性,为它配置这一整套 secrets 未必划算。但如果你有三个以上的 Safari 扩展,或者单个扩展发版频繁,这条流水线会彻底改变你的发版体验——一次性的配置成本,换来之后每次发版为零的心智负担。我的十几个 Safari 扩展现在全部走这条流水线,配置完成之后,我再也没有为发版打开过 Xcode。
2026-05-29 20:31:47
自去年年底去过一次之后,我就喜欢上了在海岛上旅行的感觉,相比于大陆近岸的海水,海岛周围的颜色透着一种天空的蓝。
这次旅行又走了一圈环岛徒步,只花了不到 3 个小时,比上次要快得多。
碧蓝之海,启动!
远方还能看到几艘渔船。
靠近岸边的海水反而泛着一些浅绿色。
顺时针走到大约 1/4 的地方,可以看到一个废弃的村庄,似乎目前只然有一些海防人员在此居住。
回头望去,几座岛屿在海平线的地方,却是无法上去的无人岛。
无论何时何地,总会不经意间看到大橘的身影。
一直都很喜欢这种海水不断拍打着礁石的画面。
正午,整个海平面都泛着白光。
远方矗立着一个不知名的三角形箭头,不知道是做什么的。
似乎还有人居住的村庄,但也主要集中在东北角接近轮渡码头的地方。
当下午回来之后,就可以等待日落了,轮渡中心的码头就是一个不错的拍摄点,非常适合同时拍摄日落和岛上标志性的灯塔。
半隐。
稍微放大一些的画面。
想要看日出非常依赖天气,我只有第二次去的时候看到了,第一次只看到了早起的渔船在海上忙碌着。
这次比较幸运,看到了美丽的朝霞。
随后,太阳从海平线的云层中慢慢升起。
太阳完全升起,但天边的云彩仍然残留着一缕朝霞。
两个过于勇敢的人爬上了那块大石头。
最后仍然用刚上岛就能看到的灯塔作为结尾吧。
随着独立开发的时间增加,几乎完全失去了与其他人交流的机会,旅行成了排忧解闷的主要方式。尽管效果越来越不明显,但我仍然决定下半年出去,走完今年十个国家目标中剩余的七个。
2026-03-21 20:13:55
回来一个星期了,才开始动笔写新加坡和越南的旅行。相比于呆了 24 天的马来西亚,新加坡和越南都只呆了 3 天,尤其是因为越南航空晚点 16 个小时,导致越南之行实际上只有 2 天时间,非常匆忙。
从新山走陆路口岸通过,如预料之中,新加坡的电子化极其发达,包括海关都是直接刷护照即可自动通关,完全没有人工环节。公交或地铁也都可以直接刷 Visa 卡,尽管国内的 Visa 双币卡在国外就是废物一条着实坑了我,但仍然难以掩盖信息化的发达。
住在市中心附近,所以下午先去逛了会街,但大部分小店的商品总感觉是从义乌直接批发的。
走了一会就能看到地标建筑金沙酒店,三栋楼上面放着一艘船的样子。
沿着河边走,还能看到远处已经停运的巨型摩天轮。
偶然看到一个有趣的设计,通过一个凹面镜反射出周围的景色。
经典 CBD 照片,建筑密集度相当惊人。
去附近的商场吃个饭,似乎在下层还有一些给小孩子玩的设施。
之后出来继续沿河边散步,一个奇怪的 Apple Store 映入眼帘。
之后,就是经典的新加坡城市夜景了,稍微拍了几张。
完全出乎意料的是还看到了 MLP 的联名花展 Floral Fantasy,这是万万没想到的,当即去看了。
在路边走时突然就看到了这个,当机立断买票就去了。
门口的宣传把 Rarity 和 Apple Jack 放在后面也是日常了,她们算是 Mane Six 中人气最少的两个 Pony。
进入之后看到 Celestia 和 Luna 时旁边一个女孩直接说 “妈妈,看,是 Celestia”,真是温馨的小场景。
不过 Celestia 制作的确实太棒了!
还有 Twilight 和 Rainbow Dash,制作的也很棒。
唯一糟糕的就是没有 Starlight,不过她终究不是 Mane Six,也是可以理解的。
第二天去了附近的教堂,巨大的清真寺,旁边有一条网红打卡街道。
下午前往了海边,尽管新加坡是一个繁忙的货运港口,但海水的质量看起来也非常棒。
旁边的公园还能看到不知名的小鸟。
第三天仍然出去玩,超级喜欢新加坡的绿化,这种绿色看着太舒服了。
所以下午又跑去圣淘沙岛看海去了。
悬索吊桥晃晃悠悠,还挺好玩的。
碧蓝之海,启动!
一个完全不知何故水平生长的椰子树,几乎都长到了海里。
乘坐这个轻轨,离岛乘坐公交车回去准备前往越南。
刚下飞机,就感受到了强烈的共产主义国家的气息,宣传几乎无处不在。
殖民时期遗留的教堂,新增的大概只有胡志明的雕像?
傍晚的广场上人来人往的还挺热闹。
旁边就能看到胡志明市的最高建筑了。
这个压迫感有点引起 PTSD 了。
这里过马路有时只有斑马线没有红绿灯只能硬过也是体验到了。
第二天前往博物馆看到的东西还算有趣。
之后去了市博物馆(统一宫),建筑本身还可以拍几张,只是房间内部的装潢有些过于宣传了。
楼上能看到下面巨大的广场和远方的街道。
无论是新加坡还是越南,我都没能留下太多美好的回忆,可能我还是呆的时间太短了吧。但世事总是不如人意,希望今年还能继续走完七个国家,考虑到世界上有接近两百个国家和地区,就算每年走十个,也需要二十年才能完成,但这显然是一个值得去做的目标,至少对我来说是这样。