WWDC.ai

Create web extensions for Safari

Build, test, package, and distribute Safari web extensions with Manifest V3, declarativeNetRequest, content scripts, storage, and native messaging.

Watch on Apple Developer

TL;DR

  • Safari web extensions are built with HTML, CSS, and JavaScript, run across Safari on iOS, iPadOS, macOS, and visionOS, and can be loaded temporarily in Safari for development without Xcode.
  • Use Manifest V3 plus WebExtensions APIs such as declarativeNetRequest, scripting, permissions, storage, and background pages or service workers to block, redirect, modify pages, and persist state.
  • Safari's permissions model puts users in control of host access; optional host permissions let extensions request access at runtime only for sites the user adds.
  • Extensions can be packaged through App Store Connect or Xcode, distributed with TestFlight and the App Store, and connected to native app capabilities through native messaging.

Safari web extension basics

Safari web extensions are packaged inside an app but authored with standard web technologies: HTML, CSS, and JavaScript. The session builds a distraction-blocking extension that works across Safari on iOS, iPadOS, macOS, and visionOS.

Every extension starts with a Manifest V3 manifest.json file that identifies the extension and declares its capabilities. Safari can load an unpacked extension folder for development after enabling web developer features and allowing unsigned extensions in Safari Settings.

  • Use the manifest for name, description, version, icons, UI entry points, permissions, host permissions, background scripts, and content scripts.
  • Add an SVG icon so Safari can scale it for toolbar and Extensions Settings contexts.
  • Use an action popup for compact toolbar UI, or an options page for larger settings UI.

Minimal Manifest V3 file

The manifest is the required JSON file that tells Safari what the extension is.

{
  "manifest_version": 3,
  "name": "Shiny OnTrack",
  "description": "Stay on track while you browse the web",
  "version": 1.0
}

Options page UI entry point

An options page gives the extension a full-page settings surface instead of a small toolbar popup.

{
  "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" }
}

Blocking and redirecting network requests

The extension uses the declarativeNetRequest API to block, modify, or redirect network requests. Rules include an ID, priority, action, and matching condition. Static rules can be declared up front, but dynamic rules are useful when the sites are chosen by the user at runtime.

Blocking a navigation does not require page access, but redirecting a network request does. For redirects, the extension switches to declarativeNetRequestWithHostAccess and asks for optional host permissions when the user adds a site.

  • Use declarativeNetRequest for simple block rules that do not need host access.
  • Use declarativeNetRequestWithHostAccess when rules need host access, such as redirecting to an extension page.
  • Declare optional_host_permissions when access should be requested lazily at runtime.
  • Request both the host and subdomain origins when adding a user-selected domain.

Dynamic redirect rule

Redirect rules can send blocked navigations to a custom extension page instead of Safari's default blocked/error page.

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

await browser.declarativeNetRequest.updateDynamicRules({
  addRules: hosts.map(createRedirectRule)
})

Request optional host access at runtime

Optional host permissions let the extension ask for access only when the user adds a site.

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

if (!granted) return

Modifying webpages with content scripts

Content scripts let an extension read and modify a page. The sample uses them to inject a 10-minute countdown timer on distracting sites when the user selects the light blocking mode.

Content scripts can be static in the manifest when match patterns are known ahead of time, or registered dynamically with the scripting API when the target hosts are user-defined. Dynamically registered scripts can persist across Safari relaunches, but the session notes that they should be re-registered after extension updates.

  • Add the scripting permission to dynamically register content scripts.
  • Use match patterns for both the selected domain and its subdomains.
  • Set persistAcrossSessions: true to keep registered scripts after Safari relaunches.
  • Use a background page or service worker to respond to lifecycle events such as extension updates.

Register a persistent content script dynamically

Dynamic registration is appropriate when the extension does not know target sites until the user adds them.

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

await browser.scripting.registerContentScripts(hosts.map(contentScript))

Re-register content scripts after updates

Registered content scripts persist across Safari restarts but should be restored after extension updates.

browser.runtime.onInstalled.addListener(async (details) => {
  if (details.reason !== "update") return
  const hosts = await getHosts()
  await registerScripts(hosts)
})

Persisting settings and extension state

The sample initially stores blocklist state in memory, which disappears when the extension reloads. The storage API fixes this by persisting hosts and blocking mode in browser.storage.local. Safari also supports session storage for temporary in-memory data that does not need to survive restarts.

  • Use browser.storage.local for durable extension preferences and user state.
  • Use session storage for short-lived data that should not be written to disk.
  • Store both the list of blocked hosts and the selected blocking mode.
  • When the mode changes, update stored state and recreate or remove blocking rules accordingly.

Storage helpers for hosts and mode

The extension persists its blocklist and selected mode with browser.storage.local.

export async function updateHosts(hosts) {
  await browser.storage.local.set({ 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
}

Packaging, TestFlight, and App Store distribution

Safari web extensions must be packaged inside a containing app. The session shows two packaging paths: using App Store Connect to create/package the app without Xcode, or using Xcode after generating a project with the Safari Web Extension Packager.

In App Store Connect, the developer creates an app, chooses supported platforms, sets a bundle identifier, uploads the extension resources through the Safari Web Extension Packager, tests with TestFlight, and submits the build for App Review. Choosing iOS and macOS makes the extension available on iPhone, iPad, Mac, and as a compatible app on Apple Vision Pro.

  • Use TestFlight to distribute beta builds and collect feedback before App Store submission.
  • For Xcode-based distribution, archive the app and ensure the build number is higher than any previously uploaded build.
  • Use App Store Connect's distribution metadata, screenshots, description, selected build, and review submission workflow.

Generate an Xcode project from extension resources

The Safari Web Extension Packager creates and opens an Xcode project containing the app and web extension.

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

Native messaging with the containing app

Native messaging lets JavaScript in the web extension communicate with the containing app through a Safari app extension handler. This is useful when the extension needs platform features unavailable to web APIs.

The sample adds biometric authentication before allowing changes to the blocklist. The extension sends a requestBioAuth message from its background page, and the generated SafariWebExtensionHandler uses Local Authentication to evaluate biometric authentication and return a success value.

  • Declare the nativeMessaging permission in the manifest.
  • Send messages from extension JavaScript with browser.runtime.sendNativeMessage.
  • Handle incoming messages in SafariWebExtensionHandler, using SFExtensionMessageKey to read and reply.
  • Use native APIs such as LocalAuthentication inside the containing app or app extension side of the flow.

Send a native message from the extension

The background page asks the native side to perform biometric authentication and returns whether it succeeded.

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

Reply from SafariWebExtensionHandler

The app extension returns a message payload to the web extension through SFExtensionMessageKey.

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

Resources

Unofficial, not associated with Apple. BySuperwall

On this page

Ask AI