WWDC Quick Look 💓 By SwiftGGTeam
Create web extensions for Safari

Create web extensions for Safari

观看原视频

Highlight

Safari 现在支持用纯 HTML、CSS、JavaScript 开发 Web 扩展,无需 Xcode 即可本地调试,通过 App Store Connect 可直接打包分发到 iOS、iPadOS、macOS 和 visionOS。

核心内容

以前开发 Safari 扩展,必须先打开 Xcode 建一个 macOS/iOS App,再把扩展嵌进去。对前端开发者来说,这套流程门槛高、反馈慢,很多人干脆放弃。WWDC26 的这场 session 彻底改变了这个局面。

Apple 现在全面拥抱 W3C Web Extensions 标准。你只需要一个代码编辑器,写个 manifest.json,配几行 HTML/CSS/JS,就能在 Safari 里直接加载、调试。整个开发体验跟写 Chrome 扩展几乎没有区别。

Session 里演示了一个真实案例:一个叫 “Shiny OnTrack” 的扩展,帮用户屏蔽分散注意力的网站。它有两种模式:Full 模式直接拦截访问,Light 模式在页面上显示一个 10 分钟倒计时。这个扩展从零搭建到上架 App Store 的完整流程,就是本场 session 的主线。

开发阶段不再需要 Xcode

03:23

打开 Safari 设置,勾选 Advanced 面板里的 “Show features for web developers”,就能在 Extensions 标签页加载未签名的本地扩展。选文件夹、允许未签名扩展,三步完成加载。改完代码点一下 Reload,立刻看到效果。

这跟以前必须 Xcode Build & Run 的迭代速度完全不在一个量级。

权限模型:最小可用原则

07:53

Safari 的权限设计把用户隐私放在前面。扩展需要什么能力,必须在 manifest.json 里声明。Apple 还推荐用 optional_host_permissions 代替直接索要全站权限,等用户触发具体功能时再请求访问。用户点了允许,扩展图标才会变成激活状态,时刻提醒用户这个页面上有扩展在运行。

一条命令打包上架

22:49

开发完成后,一条命令就能生成 Xcode 工程:

xcrun safari-web-extension-packager --copy-resources /path/to/extension

App Store Connect 现在也支持直接上传扩展资源、自动打包,连 Mac 都不需要。上传后走 TestFlight 内测,再提交审核,流程跟普通 App 完全一致。

原生能力通过 Native Messaging 桥接

23:02

如果扩展需要调用系统级能力(比如生物识别认证),可以通过 Native Messaging 与包含它的原生 App 通信。JS 发消息给 App Extension,App Extension 转给原生 App,结果再原路返回。Session 里演示了用 Touch ID 保护设置页面的完整代码。

详细内容

Manifest V3 基础配置

03:44

每个扩展的起点都是 manifest.json

{
    "manifest_version": 3,
    "name": "Shiny OnTrack",
    "description": "Stay on track while you browse the web",
    "version": 1.0,
    "icons": {
        "512": "images/icon.svg"
    },
    "options_ui": {
        "page": "options.html"
    }
}

关键点:

  • manifest_version 必须为 3,这是 W3C 当前标准
  • 图标用 SVG,Safari 自动处理多分辨率缩放
  • options_ui 定义设置页面,比 toolbar popup 更适合复杂配置

用 declarativeNetRequest 拦截和重定向请求

08:18

内容拦截是扩展最常见的需求。Apple 提供了 declarativeNetRequest API,规则可以静态写在 manifest 里,也可以运行时动态添加。

静态规则适合已知拦截列表:

{
    "permissions": ["declarativeNetRequest"],
    "declarativeNetRequest": {
        "rule_resources": [
            {
                "id": "ruleset_id",
                "enabled": true,
                "path": "rules.json"
            }
        ]
    }
}

动态规则适合用户自定义场景。Session 里演示了一个 helper,把域名映射成唯一的规则 ID:

export function hostToRuleID(host) {
    let hash = 0;
    for (let i = 0; i < host.length; i++) {
        hash = ((hash << 5) + hash) + host.charCodeAt(i);
        hash |= 0;
    }
    return Math.abs(hash) || 1;
}

function createBlockRule(host) {
    return {
        id: hostToRuleID(host),
        priority: 1,
        action: { type: "block" },
        condition: {
            urlFilter: `||${host}`,
            resourceTypes: ["main_frame"]
        }
    };
}

export async function createRules(hosts) {
    try {
        await browser.declarativeNetRequest.updateDynamicRules({
            addRules: hosts.map(createBlockRule)
        });
    } catch {
        console.log("Failed to create declarative net request rules");
    }
}

关键点:

  • hostToRuleID 用字符串哈希生成稳定 ID,同一域名每次得到相同 ID
  • urlFilter: ||${host} 匹配该域名及其所有子域名
  • resourceTypes: ["main_frame"] 只拦截主文档导航,不碰子资源
  • 动态规则通过 updateDynamicRules 批量增删

重定向到自定义页面

10:48

直接 block 会给用户看一个生硬的错误页。改成重定向到扩展内的自定义页面,体验好很多。但重定向需要更高的权限:

{
    "permissions": ["declarativeNetRequestWithHostAccess"],
    "optional_host_permissions": ["*://*/*"]
}

重定向规则:

function createRedirectRule(host) {
    return {
        id: hostToRuleID(host),
        priority: 1,
        action: {
            type: "redirect",
            redirect: { extensionPath: "/blocked.html" }
        },
        condition: {
            urlFilter: `||${host}`,
            resourceTypes: ["main_frame"]
        }
    };
}

关键点:

  • declarativeNetRequestWithHostAccess 替代普通 declarativeNetRequest
  • extensionPath 指向扩展包内的本地文件
  • 必须先获得对应域名的 host permission,重定向才会生效

运行时请求权限

13:42

用户添加域名时,扩展动态申请权限:

export async function addHost(host, blockingMode) {
    if (!host) return;

    const granted = await browser.permissions.request({
        origins: [`*://${host}/*`, `*://*.${host}/*`]
    });
    if (!granted) return;

    if (blockingMode === "full")
        await createRules([host]);
}

关键点:

  • permissions.request 弹出系统授权对话框,用户点了允许才继续
  • 被拒绝就直接 return,不执行后续逻辑
  • 这种”按需申请”模式比一次性索要 <all_urls> 更容易获得用户信任

Content Scripts 注入页面

14:55

Light 模式需要在目标页面上显示倒计时,这要用到 Content Scripts:

function contentScript(host) {
    return {
        id: `cs-${host}`,
        js: ["content.js"],
        css: ["content.css"],
        matches: [`*://${host}/*`, `*://*.${host}/*`],
        persistAcrossSessions: true
    };
}

export async function registerScripts(hosts) {
    const scripts = hosts.map(contentScript);
    try {
        await browser.scripting.registerContentScripts(scripts);
    } catch {
        console.log("Failed to register content scripts");
    }
}

关键点:

  • persistAcrossSessions: true 保证 Safari 重启后脚本还在
  • 每个域名分配独立 ID,避免冲突
  • 需要 scripting 权限

Storage API 持久化数据

17:06

扩展重启后数据不能丢。Safari 提供两种存储:

  • browser.session.storage:内存级,重启清空
  • browser.storage.local:磁盘级,持久保存
export async function updateHosts(hosts) {
    await browser.storage.local.set({ hosts: hosts });
}

export async function getHosts() {
    const { hosts = [] } = await browser.storage.local.get("hosts");
    return hosts;
}

export async function saveBlockMode(mode) {
    await browser.storage.local.set({ blockMode: mode });
}

export async function getBlockMode() {
    const { blockMode = "full" } = await browser.storage.local.get("blockMode");
    return blockMode;
}

关键点:

  • storage.local 写磁盘,适合用户配置和黑名单
  • 读取时用解构赋默认值,避免首次使用时 undefined

Background Script 处理生命周期

19:01

Content Scripts 在扩展更新后会丢失,需要在 background script 里重新注册:

browser.runtime.onInstalled.addListener(async (details) => {
    if (details.reason !== "update") return;

    const hosts = await getHosts();
    await registerScripts(hosts);
});

关键点:

  • onInstalled 在扩展安装或更新时触发
  • 过滤 reason !== "update" 避免重复执行
  • 从 storage 读取已保存的域名列表,恢复 content scripts

Native Messaging 调用系统能力

23:32

扩展通过 Native Messaging 与包含它的原生 App 通信。JS 端:

export async function requestBioAuth() {
    const message = { message: "requestBioAuth" };
    const response = await browser.runtime.sendNativeMessage(message);
    return response?.success;
}

Swift 端在 SafariWebExtensionHandler 中处理:

import LocalAuthentication

class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
    func beginRequest(with context: NSExtensionContext) {
        let request = context.inputItems.first as? NSExtensionItem
        let message = request?.userInfo?[SFExtensionMessageKey] as? [String: Any]

        if message?["message"] as? String == "requestBioAuth" {
            let laContext = LAContext()
            Task {
                do {
                    let success = try await laContext.evaluatePolicy(
                        .deviceOwnerAuthenticationWithBiometrics,
                        localizedReason: "Authenticate to change blocked sites"
                    )
                    self.reply(context: context, success: success)
                } catch {
                    self.reply(context: context, success: false)
                }
            }
        }
    }

    private func reply(context: NSExtensionContext, success: Bool) {
        let response = NSExtensionItem()
        response.userInfo = [SFExtensionMessageKey: ["success": success]]
        context.completeRequest(returningItems: [response], completionHandler: nil)
    }
}

关键点:

  • sendNativeMessage 把 JSON 消息发给 App Extension
  • App Extension 通过 NSExtensionRequestHandling 接收消息
  • LocalAuthentication 框架做生物识别认证
  • 结果通过 completeRequest 原路返回给 JS

核心启发

1. 做一款跨浏览器的密码管理扩展

Safari 现在用标准 WebExtensions API,意味着 Chrome/Firefox 扩展的代码可以直接复用。把核心逻辑(自动填充、密码生成)用纯 JS 写好,再为 Safari 包一层 App Store 壳子。Native Messaging 可以用来调用 iOS Keychain 做生物识别解锁。

入口:复用现有的 Manifest V3 代码,重点适配 browser.storage.local 和权限模型。

2. 做一款网页内容高亮和笔记工具

Content Scripts 可以读写任意网页的 DOM。用 scripting.registerContentScripts 动态注入高亮逻辑,用 storage.local 保存高亮位置和笔记内容。跨设备同步可以通过 iCloud Key-Value Storage 走 Native Messaging 桥接。

入口:先实现 content.js 中的文本选区捕获和高亮渲染,再接入 storage 持久化。

3. 做一款专注模式扩展

参考 session 里的 “Shiny OnTrack”,但可以做得更智能。比如根据时间自动切换拦截列表(工作时间屏蔽社交网站,晚上屏蔽工作邮箱),或者根据屏幕使用时长动态调整拦截强度。declarativeNetRequest.updateDynamicRules 支持运行时批量更新规则,完全能支撑这种动态策略。

入口:在 options 页面配置多套规则集,background script 根据时间或事件切换规则集。

4. 做一款网页自动化测试辅助工具

Content Scripts 可以注入测试脚本、收集页面性能数据、截屏对比。用 browser.scripting.executeScript 在特定页面临时注入测试代码,结果通过 Native Messaging 传回原生 App 做可视化报告。

入口:用 scripting API 在目标页面注入测试 runner,用 storage.local 缓存测试结果。

5. 把现有的 Chrome 扩展迁移到 Safari

如果你已经有 Manifest V3 的 Chrome 扩展,迁移成本极低。主要检查点:

  • chrome.* API 换成 browser.*(Safari 同时支持两种命名空间)
  • 把宽泛的 host permissions 改成 optional_host_permissions
  • 确认 background script 不是 persistent page(Safari 要求 service worker 或 non-persistent background page)

入口:用 safari-web-extension-packager 一键生成 Xcode 工程,测试通过后直接上架。

关联 Session

  • 204 - WebKit — Safari 27 的新 Web 平台特性,扩展开发者需要了解底层 WebKit 支持的变化
  • 314 - CSS Grid — 扩展的 UI 页面(options/popup)本质就是网页,CSS Grid 的新能力可以直接用上
  • 320 - Immersive web — 如果扩展需要在 visionOS Safari 中提供空间化体验,需要了解沉浸式 Web 标准
  • 344 - Code-along: Build a great experience for Siri — 扩展可以通过 App Intents 与 Siri 集成,让用户用语音控制扩展功能
  • 370 - TextKit — 如果扩展涉及网页文本处理(如高亮、翻译),TextKit 的新排版能力值得了解

评论

GitHub Issues · utterances