Safari 仅支持 macOS,且需要完整的 Xcode 应用。
dev 与 build 都可用。
preview 与 start 不可用,原因见下方的「命令支持情况」。extension dev --browser safari 会打包你的代码,用 Apple 的 safari-web-extension-converter 转换它,再用 xcodebuild 构建并签名一个 app,然后打开它。你只需要在 Safari 设置中启用一次扩展。之后 extension logs 会流式输出 background 与 content 的日志行,控制通道会连上,每次保存都会在 Safari 中重载扩展。这个 dev 循环从 4.1.20 起提供。
本页描述的行为测量自 macOS 26.5.2、Safari 26.5.2 与 Xcode 26.6。Safari 27
已经存在,但没有重新测量。
你需要准备什么
Safari 仅支持 macOS,并且需要 完整的 Xcode app,不能只装 Command Line Tools。转换器(safari-web-extension-converter)与 xcodebuild 都包含在 Xcode.app 中。
--development-team 时 app 为临时(ad-hoc)签名,而 Safari 接受临时签名的 app:它会像列出其他扩展一样列出它,并且「开发」菜单里的 允许未签名的扩展 处于关闭状态。那个开关属于另一条路径,见下方的「临时扩展(不用 Xcode)」。
如果缺少 Xcode,Safari 目标的 extension dev 与 extension build 会在打包之前就快速失败,并给出指引,而不是后续抛出一个含糊的错误。
产物
extension build --browser=safari 会在你项目旁创建:
整条流水线端到端运行:打包 → 转换 →
xcodebuild,并且在 dev(或
build --open)下还会 打开 app → 引导启用。普通的 build
在打包后就停下,并打印 open 命令供你自己执行。
name(例如 React Sidebar Example → bundle id dev.extensionjs.React-Sidebar-Example)。工程默认面向 macOS。
app 身份与打包选项
dev 与 build 都接受身份覆盖参数(仅对 Safari 目标生效):
不带
--bundle-id 时,Extension.js 会推导出 dev.extensionjs.<name>,其中 <name> 是经过清洗的 app 名称(连续的非字母数字字符会变成连字符)。你自己提供的 bundle id 必须符合反向 DNS 形状:至少两段以点分隔、由字母、数字和连字符组成、且每段以字母开头。不合法的值会在打包前被拒绝。
同样的选项也可以写在 extension.config.js 里(CLI 标志优先):
在其他平台上构建 web 扩展产物
extension build --browser=safari 在 Linux 和 Windows 上同样可用:它会产出完整的
dist/safari web 扩展产物,并带着一条警告 跳过 Xcode 打包步骤。
这样 CI 可以在任意平台构建载荷,之后再由一台 Mac(或 macOS runner)完成转换 +
xcodebuild 的部分。dev --browser=safari 仍然需要装有 Xcode 的
macOS,因为没有打包步骤的 Safari 开发循环没有任何东西可运行。
在 Safari 中启用扩展
app 打开时(dev,或 build --open),Extension.js 会打印与你这次构建相匹配的步骤,并在 macOS 注册扩展后给出确认。
无论是团队签名还是临时签名,这个一次性操作都一样:
- Safari ▸ 设置 ▸ 扩展 ▸ 开启你的扩展。
content_scripts.matches 和 host_permissions
里都声明了 <all_urls>,情况也是如此,这正是从 Chrome
过来的人最容易意外的地方,因为在 Chrome 里安装即授予。
在同一个面板的 权限(Permissions) 下,使用:
- 在每个网站上始终允许…(Always Allow on Every Website…) 适合你正在迭代的开发扩展。它会持久保存,只需授予一次。
- 编辑网站…(Edit Websites…) 只允许你正在测试的那些主机。
用 Apple Developer 团队签名的构建
带上--development-team 和你的 Apple Developer team id,app 就会用你自己的证书签名:
xcrun security find-identity -v -p codesigning。证书名称里那个十位字符的代码就是你的 team id,它也出现在你 Apple Developer 账号的 Membership 页面上。
团队签名是分发时需要的。对日常开发来说,它不会改变 Safari 列出或运行扩展的方式。
临时签名的构建(没有 Apple Developer 账号)
不带--development-team 时 app 为临时签名。Safari 会列出并运行它,上面那个启用操作就是全部设置。这是在 Safari ▸ 开发 ▸ 允许未签名的扩展 关闭 的状态下测量的。
临时扩展(不用 Xcode)
Safari 还有第二条路径,Extension.js 不会驱动它:Safari ▸ 设置 ▸ 开发者 ▸ 添加临时扩展…,它会加载一个未打包的目录(例如dist/safari),完全不需要 Xcode 步骤。它需要 允许未签名的扩展,而且扩展会在 Safari 退出时消失,或者在 24 小时后消失。想快速看一眼产物时用它。想要一个重启后仍然存在的开发循环,请用 extension dev。
dev 循环
extension dev --browser=safari 会运行一个 watch 循环:
- 首次编译:完整流程:转换、构建、打开 app,并打印启用步骤。
- 之后每次保存:一次增量
xcodebuild重新同步,用刚刚重新构建的dist/safari更新 app 的资源,并在 Safari 中重载扩展。
extension dev --browser safari 打开的是转换器构建出的容器 app,而不是 Safari 本身。启用和使用扩展都在 Safari 里,所以请自己打开它,或者传入 --safari-binary,会话就会在 app 之外一并打开 Safari。
每次保存都会打印它做了什么。一次重新同步以 Rebuilt <App>. 结束,内容脚本的改动会紧接着打印 Reloading content_script (content.ts)…。如果那一刻扩展没有连接,这一行会改为 Queued content_script (content.ts) for the extension to apply when it reconnects.。background 或 manifest 的改动只会打印 Rebuilt,因为 Safari 会在重新同步之后自己重启扩展,没有什么需要再分发的了。
转换之后,转换器自己的警告列表会被打印出来,它不认识的每个 manifest 键各占一行。构建有意保留的键会在 Extension.js kept one of these keys on purpose: 下面加注。目前那就是 world,理由是 Safari has honored the MAIN world since Safari 18, so the key stays。
在 dev 模式下,桥接器会暂存早期错误。在扩展连到 dev 服务器的 socket 打开之前抛出的错误,会存进扩展的 chrome.storage.local 里的 __extjsPendingErrors,等 socket 连上后再重放,所以 extension logs 仍然能看到它。
一次保存的成本
重新同步在后台运行,因此打包循环永远不会被阻塞。连续的多次保存会合并成针对最新产物的一次后续同步,所以五次快速保存只需一次重建,而不是五次。如果首次完整打包失败,下一次编译会重跑完整流程,而不是去同步一个从未构建成功的工程。Xcode 工程何时会重新生成
Xcode 工程只生成一次,之后的重新同步都复用它。是否过期由一个指纹文件dist/safari-xcode/.manifest-fingerprint 决定。从 4.1.20 起写入的 v4
指纹记录了身份输入(app 名称、bundle id 和仅 macOS 设置)、dist/safari
的顶层条目,以及 manifest 的 icons 集合。至于 manifest
本身,它只记录转换器会判断的那部分:顶层键名、permissions 与
optional_permissions 的值,以及每个 content_scripts 条目和 options_ui
的键名。manifest 的其余字节不参与,因此每次保存都会变化的内容哈希脚本名不会让转换器重跑。当存储的指纹不再匹配,或你传入
--force-regenerate 时,转换器会再次运行。纯外观性的 manifest
改动(键顺序、空白)不会触发它。
重新生成会替换整个工程:你在 Xcode 里做的定制(entitlements、capabilities、新增的文件或
target)都会被 丢弃。只有这几项签名设置会被自动保留:DEVELOPMENT_TEAM、CODE_SIGN_STYLE
和 PROVISIONING_PROFILE_SPECIFIER。每次要重新生成一个已存在的工程时,Extension.js
都会先发出警告。如果你在 Xcode 里做过定制,请先备份。删除 dist/safari-xcode 可以从头来过。
bundle id 是如何被强制生效的
Apple 的转换器会根据 app 名称推导父 app 的 id,而不是原样采用--bundle-identifier。每次转换之后,Extension.js 都会重写生成的
project.pbxproj 中两处 PRODUCT_BUNDLE_IDENTIFIER:app target 拿到你的 bundle
id,扩展 target 拿到 <bundle-id>.Extension。这样保留的是你配置的身份,而不是转换器猜出来的那个。
xcodebuild 具体做了什么
编译步骤使用 Release 配置,derived data 写入
dist/safari-xcode/.derived,这个目录值得加进 .gitignore。签名设置取决于
--development-team。带 team id 时,构建会传入
DEVELOPMENT_TEAM=<id>、CODE_SIGN_STYLE=Automatic 和
-allowProvisioningUpdates,因此 Xcode 无需打开就能生成所需的 provisioning
profile。不带时则传入临时签名设置(CODE_SIGN_IDENTITY=-、CODE_SIGNING_REQUIRED=NO、CODE_SIGNING_ALLOWED=YES),这样即使没有
Apple Developer 账号,内嵌的 .appex 也能通过校验。对仅 macOS
的工程,scheme 名就是你的 app 名称;对通用工程则是 <App> (macOS)。
注册确认
打开 app 之后,Extension.js 会轮询pluginkit
查询扩展的注册状态,大约在 5 秒内尝试 6 次,然后打印确认或一条「尚未注册」的提示。在
--no-open(以及不带 --open 的普通 build)下,app 根本不会启动,因此还谈不上注册。这时轮询会被跳过,CLI 改为打印
open 命令。
读取日志
从 4.1.20 起,一个 Safari dev 会话对logs 来说就是一个普通会话:
--context、--level 与 --output 过滤器也都一样。
如果编辑之后日志流一片安静,那就是下一节描述的症状:background 上下文已经没了,而不是日志不受支持。
一个调用就能杀死 Safari 的 background
在 background 脚本 顶层 有一个没有保护的、仅 Chromium 才有的调用,在 Safari 上就会抛错。Safari 随后会丢弃这个 background 上下文,于是你拿不到日志、拿不到重载,任何地方也看不到错误。扩展看上去就是死的。EXTENSION_PUBLIC_BROWSER 取值,见 环境变量。
Safari 构建会改动你的 manifest 的哪些地方
用 background 页面,而不是 service worker
从 4.1.20 起,Safari 构建产出的是一个非持久的 background 页面(background: {scripts: [...]}),而不是 service worker。Extension.js 会把 background.service_worker 条目翻译成这种形状,和它为 Firefox 做的翻译一样。
原因是测量出来的,而不是风格选择。在 Safari 26.5.2 上,测试中 Manifest V3 的 service worker 从未启动过,包括用 Apple 自己的转换器构建的扩展,而 background 页面则会立即运行。WebKit 有意偏好页面形式(WebKit bug 270750)。
只编写一处 background.service_worker,它就能在 Chromium、Firefox 与 Safari 上加载。两个方向的翻译见 Background 脚本。
Safari 用不了的键与权限
Safari 没有实现的 manifest 键与权限会从 Safari 构建里自动移除。从 4.1.20 起,构建会为每个被移除的键打印一行,说明是哪个键、为什么被移除。没有任何东西被静默丢弃,你的源manifest.json 也不会被改动。
content_scripts[].world 是被 保留 的,因为从 Safari 18 起 Safari 就支持它。
只针对 Safari 的覆盖请使用 safari: 与 webkit: manifest 前缀。它们优先于 Safari 默认继承的 chromium: 家族键。详见 按浏览器划分的 manifest 字段。
Safari 没有的 API
从 4.1.20 起,构建会对 Safari 缺少的扩展 API 发出警告,并指名它在你代码里找到的每个命名空间或成员。一共覆盖十二个命名空间:sidePanel、offscreen、tabGroups、management、identity、notifications、bookmarks、history、downloads、idle、omnibox 与 userScripts。这些警告在生产构建和 dev 中都会运行。
构建还会对 Safari 已有命名空间中缺少的 21 个成员发出警告,这些命名空间能解析,抛错发生在更深一层:action.getUserSettings、action.getBadgeTextColor、action.setBadgeTextColor、action.onUserSettingsChanged、storage.managed、runtime.getContexts、runtime.onSuspend、runtime.onSuspendCanceled、runtime.onUpdateAvailable、declarativeNetRequest.getAvailableStaticRuleCount、declarativeNetRequest.getDisabledRuleIds、declarativeNetRequest.updateStaticRules、declarativeNetRequest.testMatchOutcome、declarativeNetRequest.onRuleMatchedDebug、tabs.group、tabs.ungroup、webNavigation.onCreatedNavigationTarget、webNavigation.onHistoryStateUpdated、webNavigation.onReferenceFragmentUpdated、webNavigation.onTabReplaced 与 windows.onBoundsChanged。
这个扫描是文本扫描。注释或字符串字面量里提到这些名字之一,同样会触发警告。可选链能让它安静:命名空间用 chrome.sidePanel?.setPanelBehavior(),成员用 chrome.tabs.group?.()。
警告不是构建失败。用到这些 API 的代码仍然会被打包,是否保护它、按目标分支,还是接受这个功能在 Safari 上缺失,由你决定。
在 Safari 中调试
Web Inspector 覆盖了每一种扩展上下文。当你想要的是调试器而不是日志流时,它是正确的工具:- background:Safari ▸ 开发 ▸ Web Extension Background Content ▸ 你的扩展。
- popup / options / sidebar 页面:打开对应界面,然后右键 ▸ 检查元素(或 开发 ▸ 你的 Mac ▸ 该页面)。
- 内容脚本:检查宿主页面,扩展的脚本上下文会出现在 Sources 标签页的 Extension Scripts 下。
xcrun / xcodebuild
输出的末尾部分,限制在最后 50 行和 8 KB 以内,让诊断信息保持可读。传入
--debug(或设置 EXTENSION_DEBUG=true)可以改为实时输出完整的工具日志。
引擎目标
safari 有一个引擎别名 webkit-based,与 chromium-based 和 gecko-based 平行:
命令支持情况
preview 与 start 的存在是为了在正在运行的浏览器中启动一个已经构建好的扩展。Safari 这条路走的是 WebDriver,而它已经被测量过:safaridriver 确实能加载一个未打包的目录,background 也确实会运行,但 Safari 给这个扩展的主机来源是 零个。内容脚本永远不会注入,之后也没有任何 API 调用可以补上这个权限。一个看起来健康、实际什么都没测到的会话毫无意义,所以这两个命令会拒绝 Safari 目标,并把你引导到 extension dev --browser safari 或 extension build --browser safari --open。
限制
- 打包仅限 macOS。 Xcode 步骤需要 macOS 与完整的 Xcode app。(在其他平台上
build仍会产出dist/safari,而dev需要 macOS。) preview与start没有 Safari 路径。 测量到的结论见上面的「命令支持情况」。- 启用操作是手动的。 把扩展打开、以及授予网站访问权限,都是 Safari 的安全控制项,无法自动化。两者都能在重启后保留,所以你只付出一次。
- 签名止步于开发阶段。
--development-team用你的开发证书为本地 app 签名。用于分发的签名、公证(notarization)与 App Store 提交是这条工作流之外的另一步(见下文)。 - 仅产出 macOS 目标。 目前这条工作流不会生成 iOS 应用。
dev 之后:发布到 App Store
上面的 Safari 工作流最终得到的是一个本地签名的 app,临时签名或开发签名。把它分发出去(上架 Mac App Store,或作为经过公证的直接下载)是另一条流水线,Extension.js 计划通过 extension.dev 平台提供。在那之前,请遵循 Apple 自己的指南:- 分发你的 Safari web 扩展(Apple Developer Program、签名、App Store Connect)。
- 非 App Store 分发请参考 公证 macOS 软件。
- 设置你自己的
--bundle-id(反向 DNS,用你拥有的域名),从第一次构建开始。bundle id 就是扩展在 Apple 平台上的身份。 - 在 Xcode 里设置一次你的
DEVELOPMENT_TEAM:它会随工程重新生成自动保留,CODE_SIGN_STYLE和PROVISIONING_PROFILE_SPECIFIER也一样。
一次会话会留下什么
dist/extension-js/safari/ready.json 处的就绪契约带有 Safari 专属的值:
extension logs、extension eval、extension reload 和 extension doctor --browser safari 都通过这份契约附着到会话上,方式与 Chromium 上相同。
Ctrl+C 只会停止 dev 服务器。容器 app 保持打开,Safari 保持打开,扩展仍然注册在 macOS 上并在 Safari 设置里保持启用,dist/safari-xcode/ 也留在磁盘上。下一次 dev 会复用那个工程。想要干净的起点,删除 dist/safari-xcode/,下一次运行就会从头转换并构建。
最佳实践
- 先读构建警告。 它们会指名 Safari 没有的 API 与 manifest 键,这是发现一个起不来的 background 最省事的办法。
- 保护顶层的仅 Chromium 调用,在 background 与内容脚本里用可选链,或按
EXTENSION_PUBLIC_BROWSER分支。 - 使用按浏览器划分的字段 来处理真正的行为差异。Safari 会解析 chromium 系的家族前缀
chromium:,而在 Safari 目标上,带safari:/webkit:前缀的键优先于它,--browser=safari和--browser=webkit-based都是如此。chrome:和edge:键不会进入 Safari。 - 保留生成的工程,除非你确实需要一个全新的工程,因为重新生成会丢弃被保留的签名设置之外的所有 Xcode 侧定制。
下一步
- 查看所有 支持的浏览器。
- 用
logs流式读取一个 Safari 会话。 - 使用 按浏览器划分的 manifest 字段。
- 了解 多平台构建。

