WWDC Quick Look đź’“ By SwiftGGTeam
Create web extensions for Safari

Create web extensions for Safari

Watch original video

Highlight

Safari now supports building web extensions with pure HTML, CSS, and JavaScript. You can debug locally without Xcode, then package and distribute through App Store Connect to iOS, iPadOS, macOS, and visionOS.

Core Content

In the past, building a Safari extension meant opening Xcode, creating a macOS or iOS app, and embedding the extension inside it. For frontend developers, that workflow was high-friction and slow to iterate on, and many people simply gave up. This WWDC26 session changes that.

Apple is now fully embracing the W3C Web Extensions standard. You only need a code editor, a manifest.json, and a few HTML, CSS, and JavaScript files to load and debug directly in Safari. The development experience is now very close to building a Chrome extension.

The session demonstrates a real example: an extension called “Shiny OnTrack” that helps users block distracting sites. It has two modes: Full mode blocks visits directly, while Light mode shows a 10-minute countdown on the page. The full journey from building this extension from scratch to publishing it on the App Store is the backbone of the session.

Development no longer requires Xcode

(03:23)

Open Safari settings, enable “Show features for web developers” in the Advanced panel, and you can load an unsigned local extension from the Extensions tab. Choose a folder, allow unsigned extensions, and it is loaded in three steps. After editing code, click Reload and see the result immediately.

That is a completely different iteration speed from the old Xcode Build & Run loop.

Permission model: the minimum useful access

(07:53)

Safari’s permission design puts user privacy first. An extension must declare the capabilities it needs in manifest.json. Apple also recommends using optional_host_permissions instead of requesting broad site access up front, then asking for access only when the user triggers a specific feature. Once the user allows it, the extension icon becomes active, constantly reminding the user that an extension is running on the page.

One command to package for distribution

(22:49)

After development is complete, one command can generate an Xcode project:

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

App Store Connect now also supports uploading extension resources directly and packaging them automatically, without even requiring a Mac. After upload, the extension goes through TestFlight testing and then App Review, following the same flow as a normal app.

Native capabilities through Native Messaging

(23:02)

If an extension needs system-level capabilities, such as biometric authentication, it can use Native Messaging to communicate with its containing native app. JavaScript sends a message to the App Extension, the App Extension forwards it to the native app, and the result travels back along the same path. The session demonstrates the full code for protecting a settings page with Touch ID.

Details

Manifest V3 basics

(03:44)

Every extension starts with 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"
    }
}

Key points:

  • manifest_version must be 3, the current W3C standard
  • SVG icons let Safari handle scaling across resolutions automatically
  • options_ui defines a settings page, which is better than a toolbar popup for complex configuration

Blocking and redirecting requests with declarativeNetRequest

(08:18)

Content blocking is one of the most common extension needs. Apple provides the declarativeNetRequest API. Rules can be written statically in the manifest or added dynamically at runtime.

Static rules work well for known block lists:

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

Dynamic rules are better for user-defined scenarios. The session shows a helper that maps a domain to a unique rule 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");
    }
}

Key points:

  • hostToRuleID uses a string hash to generate a stable ID, so the same domain gets the same ID each time
  • urlFilter: ||${host} matches that domain and all of its subdomains
  • resourceTypes: ["main_frame"] blocks only main-document navigation, not subresources
  • Dynamic rules are added and removed in batches through updateDynamicRules

Redirecting to a custom page

(10:48)

Blocking directly shows users a harsh error page. Redirecting to a custom page inside the extension gives a much better experience. But redirection requires higher permissions:

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

Redirect rule:

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

Key points:

  • declarativeNetRequestWithHostAccess replaces plain declarativeNetRequest
  • extensionPath points to a local file inside the extension bundle
  • The extension must first get host permission for the domain before redirection can work

Requesting permissions at runtime

(13:42)

When a user adds a domain, the extension requests permission dynamically:

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]);
}

Key points:

  • permissions.request shows a system permission dialog and continues only if the user allows it
  • If permission is denied, return immediately and skip the rest of the logic
  • This “request when needed” model is easier for users to trust than asking for <all_urls> up front

Injecting pages with Content Scripts

(14:55)

Light mode needs to show a countdown on the target page, which requires 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");
    }
}

Key points:

  • persistAcrossSessions: true keeps scripts registered after Safari restarts
  • Each domain gets its own ID to avoid collisions
  • The scripting permission is required

Persisting data with the Storage API

(17:06)

Extension data must survive restarts. Safari provides two storage options:

  • browser.session.storage: memory-level storage that is cleared on restart
  • browser.storage.local: disk-level storage for persistent data
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;
}

Key points:

  • storage.local writes to disk and is appropriate for user configuration and block lists
  • Use destructuring with default values while reading to avoid undefined on first launch

Handling lifecycle in a Background Script

(19:01)

Content Scripts can be lost after an extension update, so they need to be re-registered in the background script:

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

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

Key points:

  • onInstalled fires when the extension is installed or updated
  • Filter with reason !== "update" to avoid duplicate work
  • Read the saved domain list from storage and restore Content Scripts

Calling system capabilities with Native Messaging

(23:32)

Extensions communicate with their containing native app through Native Messaging. JavaScript side:

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

Swift side, handled in 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)
    }
}

Key points:

  • sendNativeMessage sends a JSON message to the App Extension
  • The App Extension receives messages through NSExtensionRequestHandling
  • The LocalAuthentication framework handles biometric authentication
  • The result is returned to JavaScript through completeRequest

Key Takeaways

1. Build a cross-browser password manager extension

Safari now uses the standard WebExtensions API, which means Chrome and Firefox extension code can be reused directly. Build the core logic, such as autofill and password generation, in pure JavaScript, then wrap it for Safari distribution through the App Store. Native Messaging can call iOS Keychain and biometric unlock.

Entry point: Reuse existing Manifest V3 code and focus on adapting browser.storage.local and the permission model.

2. Build a web highlighting and note-taking tool

Content Scripts can read and write the DOM of any web page. Use scripting.registerContentScripts to dynamically inject highlighting logic, and use storage.local to save highlight positions and note content. Cross-device sync can be bridged through Native Messaging to iCloud Key-Value Storage.

Entry point: First implement text selection capture and highlight rendering in content.js, then connect persistent storage.

3. Build a focus-mode extension

Use the “Shiny OnTrack” extension from the session as a starting point, but make it smarter. For example, switch block lists by time of day, blocking social sites during work hours and work email at night, or dynamically adjust blocking strength based on screen time. declarativeNetRequest.updateDynamicRules supports batch rule updates at runtime, which is enough for this kind of dynamic policy.

Entry point: Configure multiple rule sets on the options page, then let the background script switch rule sets by time or event.

4. Build a helper extension for web automation testing

Content Scripts can inject test scripts, collect page performance data, and support screenshot comparison. Use browser.scripting.executeScript to temporarily inject a test runner on specific pages, then send the results back to the native app through Native Messaging for a visual report.

Entry point: Inject a test runner into the target page with the scripting API, then cache test results with storage.local.

5. Move an existing Chrome extension to Safari

If you already have a Manifest V3 Chrome extension, migration cost is very low. The main checklist:

  • Replace chrome.* APIs with browser.* APIs, though Safari supports both namespaces
  • Replace broad host permissions with optional_host_permissions
  • Confirm that the background script is not a persistent page, because Safari expects a service worker or non-persistent background page

Entry point: Use safari-web-extension-packager to generate an Xcode project in one command, test it, and submit it to the App Store.

  • 204 - WebKit — New web platform features in Safari 27, useful for extension developers who need to understand underlying WebKit support
  • 314 - CSS Grid — Extension UI pages such as options and popups are web pages, so new CSS Grid capabilities can be used directly
  • 320 - Immersive web — If an extension needs to provide spatial experiences in Safari on visionOS, immersive web standards are relevant
  • 344 - Code-along: Build a great experience for Siri — Extensions can integrate with Siri through App Intents so users can control extension features by voice
  • 370 - TextKit — If an extension works with page text, such as highlighting or translation, TextKit’s new layout capabilities are worth understanding

Comments

GitHub Issues · utterances