WWDC Quick Look 💓 By SwiftGGTeam
What's new in Safari extensions

What's new in Safari extensions

观看原视频

Highlight

Safari 17 继续支持 Manifest v2 和 v3,同时 Web 扩展现已覆盖 xrOS;内容拦截器新增 :has() 选择器,Declarative Net Request 支持修改请求头,App 扩展引入按站点权限,扩展在 Private Browsing 和 Profiles 中的行为更加可控。

核心内容

Web 扩展的跨平台统一

Safari 扩展有四种类型:内容拦截器(Content Blockers)、分享扩展(Share Extensions)、App 扩展(App Extensions)和 Web 扩展(Web Extensions)。Safari 17 继续支持全部四种,但 Apple 明确表示 Web 扩展是浏览器定制的未来方向。

Apple 在 W3C WebExtensions Community Group 中担任联合主席,与其他浏览器厂商共同推动标准化。这意味着用一套代码写的 Web 扩展可以在 iOS、iPadOS、macOS 和 xrOS 上运行。

xrOS 上的 Web 扩展与 iOS 版能力一致:支持脚本注入、后台内容运行和弹出窗口(popovers)。

00:45

Safari 17 同时支持 Manifest v2 和 v3,但新功能会持续添加到 v3。建议开发者在合适的时机迁移到 v3。

内容拦截器增强

内容拦截器用 JSON 规则声明式地拦截或隐藏内容,不需要访问用户访问了哪些网站的信息。今年新增了 :has() 选择器支持。

:has() 允许基于子元素匹配父元素。比如隐藏所有包含 .paid-promo 子元素的 .post 元素,这在以前很难用纯 CSS 选择器实现。

02:50

Declarative Net Request 更新

Declarative Net Request 让 Web 扩展以声明式方式拦截和修改网络请求,Safari 在底层处理规则执行,省电且保护隐私。

Safari 16.4 起支持修改请求头。你可以设置、添加、删除 HTTP 请求头的值,甚至完全移除某个头。

使用这个功能需要在 manifest 中声明 declarativeNetRequestWithHostAccess 权限,且扩展必须获得按站点授权才能生效。

03:37

新增 declarativeNetRequest.setExtensionActionOptions API,可以将 badge 文本设置为自动显示操作计数(如拦截的请求数量)。这让用户能直观看到扩展的工作效果,同时保持浏览历史的私密性。

05:09

脚本和存储 API

registerContentScript 系列 API 允许程序化地注册、更新和删除内容脚本。这与 manifest 中静态声明的内容脚本互补,可以按特定页面或条件动态注入脚本,且注册会跨会话持久化。

05:45

Safari 16.4 新增了 session storage 区域。与 local storage 不同,session storage 数据存在内存中,Safari 退出时清除。这适合存储敏感数据如解密密钥和认证令牌。

06:22

按站点权限

Safari App 扩展现在支持按站点授权,与 Web 扩展的权限模型统一。扩展首次开启时没有任何站点访问权限。当扩展尝试访问页面时,Safari 会在工具栏图标上显示提示。用户点击后可以选择”允许一天”或”始终允许”。

已开启的扩展在获得权限后,工具栏图标会着色显示。权限可以随时撤销。

从旧版本 Safari 升级的用户,现有扩展的权限会被迁移。用户也会看到一个横幅,可以选择”为每个网站询问”来提升隐私保护。

07:24

所有扩展现在默认显示工具栏图标。建议提供 PDF 矢量图标,以便正确着色。

Private Browsing 和 Profiles

Safari 17 允许用户单独控制哪些扩展可以访问 Private Browsing 窗口,不需要在其他浏览场景中关闭扩展。

注入脚本或读取页面信息的扩展默认在 Private Browsing 中关闭。内容拦截器等不访问页面内容的扩展则自动允许。

Profiles 是 Safari 今年的新功能,用于隔离浏览数据(历史、Cookie、网站数据)。每个 Profile 可以有独立的扩展设置。扩展在某个 Profile 中开启时,是一个全新的实例,拥有不同的 UUID、后台页面和存储。但按站点权限在所有 Profile 间共享。

09:02

如果扩展与原生宿主 App 通信,需要处理来自多个 Profile 的消息。在 beginRequest(with context:) 中解码 userInfo,如果扩展在 Profile 中运行,会有 SFExtensionProfileKey 对应的 profileIdentifier 值。

Safari 17 的 Develop 菜单中,Web Extension Background Content 菜单项会按扩展分组列出后台页面和 service worker,每个扩展下按 Profile 列出可检查的内容。

详细内容

内容拦截器的 :has() 规则

内容拦截器规则使用 JSON 格式定义。:has() 选择器让基于子元素的匹配成为可能。

[
  {
    "action": {
      "type": "hide"
    },
    "trigger": {
      "url-filter": ".*",
      "if-domain": ["example.com"],
      "selector": ".post:has(.paid-promo)"
    }
  }
]

关键点:

  • "type": "hide" 隐藏匹配的元素,不阻止网络请求
  • "url-filter": ".*" 匹配所有 URL
  • "if-domain" 限制规则只在指定域名生效
  • "selector": ".post:has(.paid-promo)" 匹配包含 .paid-promo 子元素的 .post 元素
  • :has() 支持嵌套和组合其他选择器

Declarative Net Request 修改请求头

在 manifest.json 中声明权限:

{
  "manifest_version": 3,
  "permissions": [
    "declarativeNetRequest",
    "declarativeNetRequestWithHostAccess"
  ],
  "host_permissions": [
    "*://example.com/*"
  ]
}

关键点:

  • declarativeNetRequestWithHostAccess 是修改请求头和重定向必需的权限
  • host_permissions 声明扩展可以访问的域名
  • 用户必须按站点授予权限后规则才会生效

规则定义(rules.json):

[
  {
    "id": 1,
    "priority": 1,
    "action": {
      "type": "modifyHeaders",
      "requestHeaders": [
        {
          "header": "User-Agent",
          "operation": "set",
          "value": "MyExtension/1.0"
        },
        {
          "header": "X-Custom-Header",
          "operation": "append",
          "value": "extra-value"
        },
        {
          "header": "X-Tracking-Id",
          "operation": "remove"
        }
      ]
    },
    "condition": {
      "urlFilter": "||example.com",
      "resourceTypes": ["main_frame"]
    }
  }
]

关键点:

  • "operation": "set" 替换现有头的值
  • "operation": "append" 在现有值后追加
  • "operation": "remove" 完全移除该头
  • "resourceTypes" 限制规则生效的资源类型

Badge 计数设置(background script):

// Safari 16.4+
chrome.declarativeNetRequest.setExtensionActionOptions({
  displayActionCountAsBadgeText: true
});

关键点:

  • displayActionCountAsBadgeText: true 自动将操作计数显示为 badge 文本
  • 目前这是该 API 唯一的选项
  • 计数基于规则触发的次数自动更新

程序化内容脚本注册

// 注册一个动态内容脚本
await chrome.scripting.registerContentScripts([
  {
    id: "webkit-highlight",
    matches: ["https://webkit.org/*"],
    js: ["highlight.js"],
    runAt: "document_idle",
    persistAcrossSessions: true
  }
]);

// 更新已有脚本
await chrome.scripting.updateContentScripts([
  {
    id: "webkit-highlight",
    matches: ["https://webkit.org/*", "https://bugs.webkit.org/*"]
  }
]);

// 删除脚本
await chrome.scripting.unregisterContentScripts({ ids: ["webkit-highlight"] });

关键点:

  • registerContentScripts 动态注册内容脚本
  • persistAcrossSessions: true 使注册跨会话持久化
  • updateContentScripts 修改已有脚本的配置
  • unregisterContentScripts 按 ID 删除脚本
  • 与 manifest 中静态声明的 content_scripts 互补

Session Storage 使用

// 存储敏感数据到 session storage
await chrome.storage.session.set({
  authToken: "eyJhbGciOiJIUzI1NiIs...",
  decryptionKey: "base64-encoded-key"
});

// 读取数据
const data = await chrome.storage.session.get(["authToken"]);
console.log(data.authToken);

// 与 local storage 对比
await chrome.storage.local.set({ settings: { theme: "dark" } });    // 持久化到磁盘
await chrome.storage.session.set({ tempKey: "value" });              // 内存中,退出清除

关键点:

  • chrome.storage.session 是 Safari 16.4 新增的存储区域
  • 数据存储在内存中,不写入磁盘
  • Safari 退出时自动清除
  • 适合存储认证令牌、解密密钥等敏感数据
  • API 与 localsync 存储区域一致

SVG 图标配置

{
  "manifest_version": 3,
  "icons": {
    "16": "icon.svg",
    "32": "icon.svg",
    "48": "icon.svg",
    "128": "icon.svg"
  }
}

关键点:

  • Safari 16.4 起支持 SVG 图标
  • 一个 SVG 文件可以替代所有尺寸的 PNG
  • Safari 自动处理缩放,保持清晰
  • 建议提供矢量图标以便正确着色

Profile 感知的消息处理

import SafariServices

class ExtensionRequestHandler: NSObject, NSExtensionRequestHandling {
    func beginRequest(with context: NSExtensionContext) {
        guard let item = context.inputItems.first as? NSExtensionItem,
              let userInfo = item.userInfo else { return }
        
        // 检查是否在 Profile 中运行
        if let profileId = userInfo[SFExtensionProfileKey] as? String {
            // 扩展在某个 Profile 中运行
            // 根据 profileId 隔离数据
            handleRequest(forProfile: profileId, context: context)
        } else {
            // 默认浏览模式
            handleRequest(context: context)
        }
    }
}

关键点:

  • SFExtensionProfileKey 标识扩展运行的 Profile
  • profileIdentifier 是每个 Profile 的唯一标识
  • 每个 Profile 中的扩展实例有独立的 UUID、后台页面和存储
  • 原生宿主 App 需要根据 profileIdentifier 隔离数据

核心启发

1. 用 :has() 选择器做精准内容拦截

  • 做什么:在内容拦截器规则中使用 :has() 选择器,基于页面结构而非 URL 来隐藏特定内容
  • 为什么值得做:可以精准拦截”包含广告标签的文章卡片”这类复杂场景,减少误拦截
  • 怎么开始:分析目标网站的 DOM 结构,找出广告/干扰内容的父元素特征,编写 :has() 规则测试效果

2. 用 Declarative Net Request 做请求头管理

  • 做什么:用 Declarative Net Request 修改或清理 HTTP 请求头,比如统一 User-Agent、移除追踪头
  • 为什么值得做:声明式处理比拦截所有网络请求再修改更省电,且不需要访问页面内容
  • 怎么开始:在 manifest v3 中声明 declarativeNetRequestWithHostAccess 权限,编写 modifyHeaders 规则,测试按站点授权流程

3. 用 Session Storage 替代敏感数据的 localStorage

  • 做什么:将认证令牌、临时解密密钥等敏感数据从 localStorage 迁移到 sessionStorage
  • 为什么值得做:数据不写入磁盘,Safari 退出自动清除,降低敏感信息泄漏风险
  • 怎么开始:将 chrome.storage.local.set/get 中涉及敏感数据的地方替换为 chrome.storage.session.set/get

4. 适配 Private Browsing 和 Profiles

  • 做什么:测试扩展在 Private Browsing 和多个 Profile 中的行为,确保数据正确隔离
  • 为什么值得做:Safari 17 用户可能同时使用工作 Profile 和个人 Profile,扩展需要在不同场景下独立运行
  • 怎么开始:在 Safari 设置中创建多个 Profile,在每个 Profile 中独立安装和测试扩展。检查原生宿主 App 是否正确处理 SFExtensionProfileKey

5. 用程序化脚本注册实现动态功能

  • 做什么:根据用户设置或当前页面动态注册和注销内容脚本
  • 为什么值得做:不需要为所有页面都注入脚本,只在需要时加载,减少性能开销
  • 怎么开始:在扩展选项页中提供功能开关,用户开启时调用 registerContentScripts,关闭时调用 unregisterContentScripts

关联 Session

评论

GitHub Issues · utterances