Dunfey · Hotel WWDC as data, est. 1983
Front desk everything
Years
Topics

2026 App Store, Distribution & MarketingSafari & Web

WWDC26 · 27 min · App Store, Distribution & Marketing / Safari & Web

Create web extensions for Safari

Get started with Safari web extensions by building and testing one from the ground up — no Xcode required. Explore how content blocking, page modification, native messaging, and the permissions mode work together to create a powerful, privacy-preserving browsing experience across platforms.

Watch at developer.apple.com ↗

Transcript all transcripts

Chapters

Code shown on screen · 36 snippets

Manifest file json · at 3:44 ↗
{
    "manifest_version": 3,
    "name": "Shiny OnTrack",
    "description": "Stay on track while you browse the web",
    "version": 1.0
}
Adding an extension icon json · at 4:29 ↗
{
    "manifest_version": 3,
    "name": "Shiny OnTrack",
    "description": "Stay on track while you browse the web",
    "version": 1.0,

    "icons": {
        "512": "images/icon.svg"
    }
}
Adding an action button json · at 5:30 ↗
{
    "manifest_version": 3,
    "name": "Shiny OnTrack",
    "description": "Stay on track while you browse the web",
    "version": 1.0,

    "action": {
        "default_popup": "popup.html"
    }
}
Adding custom UI to your extension json · at 6:17 ↗
{
    "manifest_version": 3,
    "name": "Shiny OnTrack",
    "description": "Stay on track while you browse the web",
    "version": 1.0,
  
    "options_ui": {
        "page": "options.html"
    }
}
Including the UI in the extension manifest json · at 6:30 ↗
{
    "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"
    }
}
Hello World xml · at 6:40 ↗
<!DOCTYPE html>
<html>
    <body>
    <p>Hello World</p>
    </body>
</html>
Adding declarativeNetRequest permission json · at 8:18 ↗
{
    "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"
    },
  
    "permissions": [ "declarativeNetRequest" ]
}
Blocking network requests javascript · at 8:22 ↗
// block rule
{
    id: 1,
    priority: 1,
    action: {
        type: "block"
    },
    condition: {
        urlFilter: "||webkit.org",
        resourceTypes: [ "main_frame" ]
    }
}
Modifying network requests json · at 8:41 ↗
{
    "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"
    },
  
    "permissions": [ "declarativeNetRequest" ],

    "declarativeNetRequest": {
        "rule_resources": [
            {
                "id": "ruleset_id",
                "enabled": true,
                "path": "rules.json"
            }
        ]
    }
}
Updating dynamic rules javascript · at 8:50 ↗
await browser.declarativeNetRequest.updateDynamicRules({
    addRules: [ rule ]
})
Wiring up the static declarativeNetRequest rules json · at 9:19 ↗
{
    "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"
    },
  
    "permissions": [ 
      "declarativeNetRequest" 
    ]
}
Adding block rules dynamically javascript · at 9:40 ↗
// A helper function to map the host to the declarative net request 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")
	}
}
Handling adding hosts to the settings javascript · at 10:10 ↗
import { createRules, removeAllRules, removeRule } from './rules.js'

export async function addHost(host, blockingMode) {
  if (!host)
    return
  
  if (blockingMode === "full")
    await createRules([host])
}
Redirecting network requests javascript · at 10:48 ↗
{
    id: 1,
    priority: 1,
    action: {
        type: "redirect",
        redirect: {
            extensionPath: "/blocked.html"
        }
    },
    condition: {
        urlFilter: "||webkit.org",
        resourceTypes: [ "main_frame" ]
    }
}
Declaring optional host permissions json · at 11:17 ↗
{
    "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"
    },
  
    "permissions": [ "declarativeNetRequestWithHostAccess" ],
    "optional_host_permissions": [ "https://webkit.org/*" ]

}
Declaring optional host permissions for all sites json · at 11:54 ↗
{
    "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"
    },
  
    "permissions": [ "declarativeNetRequestWithHostAccess" ],
    "optional_host_permissions": [ "*://*/*" ]

}
Add the redirect rule javascript · at 13:12 ↗
// A helper function to map the host to the declarative net request 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"]
		}
	}
}

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

export async function createRules(hosts) {
	try {
		await browser.declarativeNetRequest.updateDynamicRules({
			addRules: hosts.map(createRedirectRule)
		})
	} catch {
		console.log("Failed to create declarative net request rules")
	}
}
Dynamically ask for host permissions javascript · at 13:42 ↗
import { createRules, removeAllRules, removeRule } from './rules.js'

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])
}
Defining content scripts json · at 14:55 ↗
{
    "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"
    },
  
    "permissions": [ "declarativeNetRequestWithHostAccess" ],
    "optional_host_permissions": [ "*://*/*" ],
  
    "content_scripts": [
        {
            "js": [ "content.js" ],
            "css": [ "content.css" ],
            "matches": [ "*://*.webkit.org/*" ]
        }
    ]
}
Dynamically registering content scripts javascript · at 15:13 ↗
let script = {
    id: "id",
    js: [ "content.js" ],
    css: [ "content.css" ],
    matches: [ "*://*.webkit.org/*" ],
    persistAcrossSessions: true
}

await browser.scripting.registerContentScripts([ script ])
Adding the scripting permission json · at 15:31 ↗
{
    "manifest_version": 3,
    "name": "Shiny OnTrack",
    "description": "Stay on track while you browse the web",
    "version": 1.0,
  
    "icons": {
        "512": "images/icon.svg"
    },
  
    "options_page": "options.html",
  
    "permissions": [
        "declarativeNetRequestWithHostAccess",
        "scripting"
    ],
  
    "optional_host_permissions": [ "*://*/*" ]
}
Registering content scripts javascript · at 15:41 ↗
// scripting.js

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

export function registerScripts(hosts) {
    const scripts = hosts.map(contentScript)
    try {
        await browser.scripting.registerContentScripts(scripts)
    } catch {
        console.log("Failed to register content scripts")
    }
}
Adding a host javascript · at 16:02 ↗
// host.js

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

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

    if (!granted)
        return

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

    await registerScripts([ host ])
}
Web extensions storage APIs javascript · at 17:06 ↗
await browser.session.storage.set({
  key: value
})

await browser.local.storage.set({
  key: value
})
Adding storage permission to the web extension manifest.json json · at 17:21 ↗
{
    "manifest_version": 3,
    "name": "Shiny OnTrack",
    "description": "Stay on track while you browse the web",
    "version": 1.0,
  
    "icons": {
        "512": "images/icon.svg"
    },
  
    "options_page": "options.html",
  
    "permissions": [
        "declarativeNetRequestWithHostAccess",
        "scripting",
        "storage"
    ],
  
    "optional_host_permissions": [ "*://*/*" ]
}
Saving data with storage javascript · at 17:30 ↗
// storage.js

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
}
Persisting hosts to storage javascript · at 17:41 ↗
// host.js

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

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

    if (!granted)
        return

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

    await registerScripts([ host ])

    let existingHosts = await getHosts()
    let updatedHosts = [ ...existingHosts, host ]
    await updateHosts(updatedHosts)
}
Reading from storage javascript · at 17:51 ↗
// options.js

let existingHosts = await getHosts()
let blockMode = await getBlockMode()

displayBlocklist(existingHosts)
Switching block modes javascript · at 18:00 ↗
// host.js

export async function userDidSwitchMode(blockMode) {
    await saveBlockMode(blockMode)

    if (blockMode === "full") {
        let hosts = await getHosts()
        await createRules(hosts)
    } else
        await removeAllRules()
}
Adding a background script json · at 19:01 ↗
{
    "manifest_version": 3,
    "name": "Shiny OnTrack",
    "description": "Stay on track while you browse the web",
    "version": 1.0,
  
    "icons": {
        "512": "images/icon.svg"
    },
  
    "options_page": "options.html",
  
    "permissions": [
        "declarativeNetRequestWithHostAccess",
        "scripting",
        "storage"
    ],
  
    "optional_host_permissions": [ "*://*/*" ],
  
    "background": {
        "scripts": [ "background.js" ],
        "type": "module"
    }
}
Background script javascript · at 19:39 ↗
// background.js

import { registerScripts } from "./utilities/scripting.js"
import { getHosts } from "./utilities/storage.js"

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

    const hosts = await getHosts()
    await registerScripts(hosts)
})
Package your web extension into an app for Xcode bash · at 22:49 ↗
xcrun safari-web-extension-packager --copy-resources /path/to/ShinyOnTrack
Adding the nativeMessaging permission json · at 23:32 ↗
{
    "manifest_version": 3,
    "name": "Shiny OnTrack",
    "description": "Stay on track while you browse the web",
    "version": 1.0,
  
    "icons": {
        "512": "images/icon.svg"
    },
  
    "options_page": "options.html",
  
    "permissions": [
        "declarativeNetRequestWithHostAccess",
        "scripting",
        "storage",
        "nativeMessaging"
    ],
  
    "optional_host_permissions": [ "*://*/*" ],
  
    "background": {
        "scripts": [ "background.js" ],
        "type": "module"
    }
}
Sending a native message javascript · at 23:40 ↗
// background.js

import { registerScripts } from "./utilities/scripting.js"
import { getHosts } from "./utilities/storage.js"

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

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

export async function requestBioAuth() {
    const message = { message: "requestBioAuth" }
    const response = await browser.runtime.sendNativeMessage(message)
    return response?.success
}
Handling native messages swift · at 23:55 ↗
// SafariWebExtensionHandler.swift

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)
                }
            }
        }
    }
}
Replying to a native message swift · at 24:25 ↗
// SafariWebExtensionHandler.swift

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

Resources