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 ↗Chapters
Code shown on screen · 36 snippets
Manifest file
{
"manifest_version": 3,
"name": "Shiny OnTrack",
"description": "Stay on track while you browse the web",
"version": 1.0
} 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"
}
} 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"
}
} 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"
}
} 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"
}
} Hello World
<html>
<body>
<p>Hello World</p>
</body>
</html> 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" ]
} Blocking network requests
// block rule
{
id: 1,
priority: 1,
action: {
type: "block"
},
condition: {
urlFilter: "||webkit.org",
resourceTypes: [ "main_frame" ]
}
} 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"
}
]
}
} Updating dynamic rules
await browser.declarativeNetRequest.updateDynamicRules({
addRules: [ rule ]
}) 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"
]
} 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")
}
} 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])
} Redirecting network requests
{
id: 1,
priority: 1,
action: {
type: "redirect",
redirect: {
extensionPath: "/blocked.html"
}
},
condition: {
urlFilter: "||webkit.org",
resourceTypes: [ "main_frame" ]
}
} 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/*" ]
} 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": [ "*://*/*" ]
} 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")
}
} 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])
} 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/*" ]
}
]
} Dynamically registering content scripts
let script = {
id: "id",
js: [ "content.js" ],
css: [ "content.css" ],
matches: [ "*://*.webkit.org/*" ],
persistAcrossSessions: true
}
await browser.scripting.registerContentScripts([ script ]) 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": [ "*://*/*" ]
} 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")
}
} 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 ])
} Web extensions storage APIs
await browser.session.storage.set({
key: value
})
await browser.local.storage.set({
key: value
}) 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": [ "*://*/*" ]
} 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
} 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)
} Reading from storage
// options.js
let existingHosts = await getHosts()
let blockMode = await getBlockMode()
displayBlocklist(existingHosts) 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()
} 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"
}
} 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)
}) Package your web extension into an app for Xcode
xcrun safari-web-extension-packager --copy-resources /path/to/ShinyOnTrack 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"
}
} 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
} 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)
}
}
}
}
} 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)
}
} Resources
Related sessions
-
17 min