-
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.
Chapters
- 0:00 - Introduction
- 3:23 - Get started
- 7:23 - Block content
- 14:40 - Modify webpages
- 19:53 - Package and distribute
- 22:33 - Communicate with your app
- 26:04 - Next steps
Resources
- w3.org — W3C WebExtensions Community Group
- Packaging and distributing Safari Web Extensions with App Store Connect
- WebKit.org – Report issues to the WebKit open-source project
- Submit feedback
- MDN Web Docs - Web Extensions API
Related Videos
WWDC26
-
Search this video…
-
-
3:44 - Manifest file
{ "manifest_version": 3, "name": "Shiny OnTrack", "description": "Stay on track while you browse the web", "version": 1.0 } -
4:29 - Adding an extension icon
{ "manifest_version": 3, "name": "Shiny OnTrack", "description": "Stay on track while you browse the web", "version": 1.0, "icons": { "512": "images/icon.svg" } } -
5:30 - Adding an action button
{ "manifest_version": 3, "name": "Shiny OnTrack", "description": "Stay on track while you browse the web", "version": 1.0, "action": { "default_popup": "popup.html" } } -
6:17 - Adding custom UI to your extension
{ "manifest_version": 3, "name": "Shiny OnTrack", "description": "Stay on track while you browse the web", "version": 1.0, "options_ui": { "page": "options.html" } } -
6:30 - Including the UI in the extension manifest
{ "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" } } -
6:40 - Hello World
<!DOCTYPE html> <html> <body> <p>Hello World</p> </body> </html> -
8:18 - Adding declarativeNetRequest permission
{ "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" ] } -
8:22 - Blocking network requests
// block rule { id: 1, priority: 1, action: { type: "block" }, condition: { urlFilter: "||webkit.org", resourceTypes: [ "main_frame" ] } } -
8:41 - Modifying network requests
{ "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" } ] } } -
8:50 - Updating dynamic rules
await browser.declarativeNetRequest.updateDynamicRules({ addRules: [ rule ] }) -
9:19 - Wiring up the static declarativeNetRequest rules
{ "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" ] } -
9:40 - Adding block rules dynamically
// 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") } } -
10:10 - Handling adding hosts to the settings
import { createRules, removeAllRules, removeRule } from './rules.js' export async function addHost(host, blockingMode) { if (!host) return if (blockingMode === "full") await createRules([host]) } -
10:48 - Redirecting network requests
{ id: 1, priority: 1, action: { type: "redirect", redirect: { extensionPath: "/blocked.html" } }, condition: { urlFilter: "||webkit.org", resourceTypes: [ "main_frame" ] } } -
11:17 - Declaring optional host permissions
{ "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/*" ] } -
11:54 - Declaring optional host permissions for all sites
{ "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": [ "*://*/*" ] } -
13:12 - Add the redirect rule
// 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") } } -
13:42 - Dynamically ask for host permissions
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]) } -
14:55 - Defining content scripts
{ "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/*" ] } ] } -
15:13 - Dynamically registering content scripts
let script = { id: "id", js: [ "content.js" ], css: [ "content.css" ], matches: [ "*://*.webkit.org/*" ], persistAcrossSessions: true } await browser.scripting.registerContentScripts([ script ]) -
15:31 - Adding the scripting permission
{ "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": [ "*://*/*" ] } -
15:41 - Registering content scripts
// 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") } } -
16:02 - Adding a host
// 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 ]) } -
17:06 - Web extensions storage APIs
await browser.session.storage.set({ key: value }) await browser.local.storage.set({ key: value }) -
17:21 - Adding storage permission to the web extension 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_page": "options.html", "permissions": [ "declarativeNetRequestWithHostAccess", "scripting", "storage" ], "optional_host_permissions": [ "*://*/*" ] } -
17:30 - Saving data with storage
// 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 } -
17:41 - Persisting hosts to storage
// 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) } -
17:51 - Reading from storage
// options.js let existingHosts = await getHosts() let blockMode = await getBlockMode() displayBlocklist(existingHosts) -
18:00 - Switching block modes
// host.js export async function userDidSwitchMode(blockMode) { await saveBlockMode(blockMode) if (blockMode === "full") { let hosts = await getHosts() await createRules(hosts) } else await removeAllRules() } -
19:01 - Adding a background script
{ "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" } } -
19:39 - Background script
// 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) }) -
22:49 - Package your web extension into an app for Xcode
xcrun safari-web-extension-packager --copy-resources /path/to/ShinyOnTrack -
23:32 - Adding the nativeMessaging permission
{ "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" } } -
23:40 - Sending a native message
// 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 } -
23:55 - Handling native messages
// 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) } } } } } -
24:25 - Replying to a native message
// 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) } }
-
-
- 0:00 - Introduction
Learn how Safari web extensions — built with HTML, CSS, and JavaScript and packaged inside an app — can run across iOS, iPadOS, macOS, and visionOS. Preview the distraction-blocker extension built throughout the session, which offers a 10-minute light mode and a full redirect mode.
- 3:23 - Get started
Set up an extension from scratch by writing a manifest.json file, then add a popup UI so the extension is reachable from Safari's toolbar. The same project runs unchanged across every Apple platform that ships Safari.
- 7:23 - Block content
Use the declarativeNetRequest API to block, modify, and redirect network requests, and declare the host permissions — including optional host permissions — that let users grant access on the sites where the extension should run.
- 14:40 - Modify webpages
Inject content into pages with content scripts to render a countdown timer on distracting sites. Register scripts dynamically with the scripting API and persist user preferences and per-host state using the storage API and a background service worker.
- 19:53 - Package and distribute
Submit a Safari web extension to the App Store using App Store Connect, and share beta builds with testers through TestFlight.
- 22:33 - Communicate with your app
Generate an Xcode project with the Safari Web Extension Packager, then use native messaging to pass requests between the JavaScript extension and its containing app — unlocking platform features like Local Authentication that aren't available to web APIs.
- 26:04 - Next steps
Download the sample project, explore the cross-browser WebExtensions documentation on MDN, and file feedback through Feedback Assistant or bugs.webkit.org.