Developing a Safari Extension for iOS
Safari Web Extension on iOS is the same WebExtension API as desktop browsers, but with strict mobile platform limitations. The extension is packaged inside a native iOS app, distributed through App Store, activated manually by user in Safari settings. It's not "just port a Chrome extension"—there are nuances that break half of desktop logic.
Structure and Entry Points
An extension has two parts: native Extension target (Swift/ObjC) and web part (JS/HTML/CSS). Native—SafariWebExtensionHandler, implements NSExtensionRequestHandling. Through it, JS code sends native messages via browser.runtime.sendNativeMessage().
Manifest: Safari supports Manifest V2 and V3. Apple recommends MV3, but browser.action in MV3 on iOS works differently than Chrome—toolbar popup not directly supported. Use SFSafariExtensionViewController on native side instead.
class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
func beginRequest(with context: NSExtensionContext) {
guard let item = context.inputItems.first as? NSExtensionItem,
let message = item.userInfo?[SFExtensionMessageKey] else {
context.completeRequest(returningItems: nil, completionHandler: nil)
return
}
// handle message from JS
let response = NSExtensionItem()
response.userInfo = [SFExtensionMessageKey: ["status": "ok"]]
context.completeRequest(returningItems: [response], completionHandler: nil)
}
}
iOS Limitations Breaking Desktop Logic
Background scripts don't work. Desktop Chrome's background.js lives forever. iOS Safari doesn't. Service Worker (MV3) unloads immediately after task execution. Any state desktop extensions store in background script memory, iOS must persist via browser.storage.local or pass via native host.
webRequest API missing. Network request interception/modification via Content Blockers (WKContentRuleList), not webRequest. Desktop blocking ads via webRequest.onBeforeRequest can't port directly.
Tab access limited. browser.tabs.query() returns only active tab. Iterating all open tabs like desktop isn't available.
Content scripts inject with delay. On iOS a page fully loads before content script starts running. Code expecting DOMContentLoaded capture may miss it.
Typical Scenario: Password Manager Extension
Say you need autofill via content script. JS injects into page, finds <input type="password">, tells native host via browser.runtime.sendNativeMessage(). Native host accesses keychain via Security.framework and returns data. Content script fills field.
Problem: sendNativeMessage on iOS only works when native Extension target is running. If user didn't open the app after reboot—Extension Host might not start. Need retry mechanism on JS side.
Apple Migration Tool
To convert existing Chrome/Firefox extension: xcrun safari-web-extension-converter. Creates Xcode project with proper structure but doesn't fix incompatible APIs—just warns. Always manual work remains.
Review and Permissions
App Store requires justification for each manifest permission. tabs—why? storage—what exactly? Vague answers lead to 4.0 Design rejection asking for clarification.
Extensions with <all_urls> in host_permissions undergo enhanced checking. Apple may request video demo on real device.
Timeline: 1–3 weeks depending on JS complexity and native interaction volume. Cost calculated individually.







