How to build an iOS Safari Web Extension

Raza P.
9 min readFeb 5, 2023

My experience developing an iOS Safari Web Extension for Linguisticat and a full walkthrough of building a simple app that modifies webpage content in iOS Safari.

Over time I’ve become more interested to learn about the increasing number of iOS platform specific APIs that allow for a more tailored user experience (see Xcode → File → New → Target). It’s also challenging to get hands on experience when building exterprise applications because the focus usually isn’t on these deep platform specific capabilities.

I decided to write about iOS Safari Extensions because I didn’t find a lot of helpful information online around the challenges I had while going from idea to release. In this article I’ll share more about what I learned by walking through an fresh example starting from a native app all the way through modifying content on a webpage in the iOS Safari app.

As Apple mentions, Safari Extensions allow you to customize your web browsing experience when using Safari. Specifically, the extension can read and modify content in Safari. Also super cool that there is some tool that allows you to take an existing browser extension and port it to a Safari Extension. Since I built mine from scratch I haven’t gotten to use this tool.

Safari Web Extension Basics

Let’s start with how to create an application that will have an iOS Safari Extension component. You may be tempted to start a Multiplatform Safari Extension app. I recommend against it if you’re just getting started, opposite Apple’s advice.

The target dependencies really add up and are frustrating to keep organized. Here is what my multiplatform safari extension bundle looks like

Multiplatform safari extension application bundle

A Safari Extension project is basically made up of the main application, the app extension which acts as a bridge between the main application and the actual Safari Extension, and the Javascript code which interacts with Safari directly.

I’ve broken down the Javascript section is broken down further into

  • background.js: code here will be running regardless of the page. Communication outside of javascript happens in here
  • content.js: code here can modify content within Safari
  • popup.html/css/js: code here runs a popup accessible from the Safari search bar extensions menu
  • manifest.json: metadata about the extension, permissions
  • _locales / images: self explanatory

Getting the app to run

You have an empty application and you want to get it to run. This took me a while with the multiplatform set up because my bundleIds were not correct by default but that problem seems to have been fixed with the new project template. The important part here is not actually specific to Safari Extensions, but any extension. The bundleId of the extension must contain the BundleId of the main application. So if you main app bundleId is com.testapp, then your extension bundleId should be com.testapp.morehere.

The Xcode scheme should be set to run the main app which is correct. This will open up a prebuilt UI the template provides of the native app. But you actually want swipe out of the native app and go “Home” on your simulator or device and go to the Safari app. Tap the AA to open the Safari menu and toggle your extension on.

Now when you go back to your AA to open the Safari menu and then tap your extension. In this case it is TestMultiplatformSafariExtension., here is how you can access the popup mentioned above. We’re not going to talk much about the popup, but more about modifying Safari webpage content. Popup is a good place to share more about the extension and even have some settings.

Your popup.html/css/js code should run and show the template provided <strong> tag.

Communicating from the Main app to modify Safari content

If you’re comfortable with Javascript, have experience building Chrome Extensions, or are ok building the whole functionality of your application in Javascript then you can stop here. You can simply replace the main app content with some simple UI explaining how the user can enable the extension (as I’ve shown above), and you can code the entire behavior of the extension across the popup, background.js, and content.js

If you’re an iOS Developer and fancy Swift/SwiftUI over Javascript, or if you have things you want the user to do in the main application that influences, keep reading.

There are actually two ways to accomplish communicating from the main app to javascript and back. We can do it remotely via the cloud or we can pipe data through User Defaults. We’re going to focus on situation B since that is the more challenging one in my opinion.

The pink flows show getting and setting data to storage and purple flows shows getting and setting data from storage for interaction with Safari and these two flows can happen at different times.

Before we start, we need to set up a UserDefaultSuite. This is different from basic UserDefaults which doesn’t allow you to access the same local storage container across multiple extensions. There are plenty of articles sharing how to do this so we won’t go over that here. It should take less than 5 minutes to set up. Don’t forget you need to enable App Group Capability in Xcode and on developer.apple.com.

In Linguisticat, I have a native experience with flashcards to study vocabulary in languages the users are learning. This is simple information with an English word such as “blue” with a Dutch equivalent of “blauw”.

When a user is ready to see this vocabulary in action, I save this dictionary [blue: blauw] to my UserDefaultsSuite. At any point in the future, when a browser page in Safari is loaded and my Safari web extension is toggled on, some code gets called in Javascript(more about this below), which reaches out to the App Extension Mediator. This Mediator accesses the UserDefaultsSuite and passes this [blue: blauw] information back to Javascript, and is finally replaced in the webpage seen by the user.

We’re going to do something simpler and more foundational in this example below. We’re just going to check if the user has enabled this extension, and if so we will make all <p> text say “Hello World!”.

Here is the step by step process to set something from the main app, modify browser page content, and send information back up to the main app:

  1. From the main application, let’s say this user has enabled some functionality.
  • Create a UserDefaultsSuite which will store data across extensions with let suiteKeyStore = UserDefaults(suiteName: "group.thisisatest")
  • set [user_defaults_suite_enabled: true] in defaults

ViewController.swift

#if os(iOS)
import UIKit
typealias PlatformViewController = UIViewController
#elseif os(macOS)
import Cocoa
import SafariServices
typealias PlatformViewController = NSViewController
#endif

class ViewController: PlatformViewController {

override func viewDidLoad() {
super.viewDidLoad()
let suiteKeyStore = UserDefaults(suiteName: "group.thisisatest")
suiteKeyStore?.set(true, forKey: "user_defaults_suite_enabled")
}
}

2. Now data has been stored in our shared container we call UserDefaultsSuite. Now we’re going to try to get that data and apply it to the webpage. Since background.js runs while the user is browsing, we basically want to set this up so we’re listening to and messages from content.js. Here we are doing a few things:

  • always listening for messages from content.js with the type content_request:enabled
  • sending a message from the Javascript to the Safari Mediation Extension with the message type request:enabled_status
  • sending a response to the caller, in this case content.js with a key background_response:enabled

background.js

browser.runtime.onMessage.addListener((request, sender, sendResponse) => {

if (request.type == "content_request:enabled") {
browser.runtime.sendNativeMessage({message: "request:enabled_status"}, function(response) {
sendResponse({
"background_response:enabled": response["delivery:enabled_status"]
});
});
}

return true;
});

3. content.js is run every time a web page is loaded. So in this case when the page is loaded, we want to reach out to background.js (which gets the data from storage) and gives it back to content.js which will change the web page. There is a lot going on so we will start from the bottom of this code sample and work our way up.

  • on web page load, handleContentEnabled() is called
  • function handleContentEnabled() sends a message to whoever is listening, in this base it is background.js typed content_request:enabled and handles response and error
  • we’re not doing anything with the error at this time in function handleError(error)
  • however, on a successful response, is the response contains a key background_response:enabled with a value of true **we are going to handleContentEnabled()
  • function handleContentEnabled() is going to turn all <p> tags to <strong> tags on the page turning paragraph text into bold text.

content.js

function onResponse(response) {
if (response["background_response:enabled"] == true) {
handleContentReplacement();
return;
} else {
// no-op
}
}

function handleError(error) {
// no-op
}

function handleContentEnabled() {
let sending = browser.runtime.sendMessage({type: "content_request:enabled"});
sending.then(onResponse, handleError);
}

function handleContentReplacement() {
const collection = document.getElementsByTagName("p");
alert(collection.length);
for (let i = 0; i < collection.length; i++) {
collection[i].innerHTML = "Hello World!";
alert(collection[i]);
}
}

handleContentEnabled();

4. Now we come to the Mediator. If you have a template this should be called SafariWebExtensionHandler.swift. Here, we are going to listen to the request from background.js, get some data and pass it back to background.js

  • We reference the same UserDefaultsSuite we created in the main app with private let suiteKeyStore = UserDefaults(suiteName: "group.thisisatest")
  • if the request is request:enabled_status then check defaults for user_defaults_suite_enabled which we know if true in this case
  • return it to background.js as an object message: [delivery:enabled_status: true]

SafariWebExtensionHandler.swift

import SafariServices
import os.log

let SFExtensionMessageKey = "message"

enum SafariExtensionRequestEnum: String {
case requestEnabledStatus = "request:enabled_status"
}

enum SafariExtensionDeliveryEnum: String {
case deliveryEnabledStatus = "delivery:enabled_status"
}

class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {

// this should be the same name as the one created in the native app
private let suiteKeyStore = UserDefaults(suiteName: "group.thisisatest")

func beginRequest(with context: NSExtensionContext) {
guard let item = context.inputItems[0] as? NSExtensionItem else {
return
}
let message = item.userInfo?[SFExtensionMessageKey]
let messageDictionary = message as? [String: String]
let innerMessage = messageDictionary?[SFExtensionMessageKey] as? String

if let msg = message as? CVarArg {
os_log(.default, "SafariWebExtensionHandler XXXX Received message from browser.runtime.sendNativeMessage: %@", msg)
}

let response = NSExtensionItem()

if
let msg = innerMessage,
let request = SafariExtensionRequestEnum(rawValue: msg)
{
switch request {
case .requestEnabledStatus:
// this keyName should be the same as in the native app
let keyName = "user_defaults_suite_enabled"
let enabledFromDefaults = defaults.bool(forKey: keyName)
response.userInfo = [
SFExtensionMessageKey : [
SafariExtensionDeliveryEnum.deliveryEnabledStatus.rawValue: enabledFromDefaults
]
]
}
}
os_log(.default, "SafariWebExtensionHandler XXXX response: \([response])")
context.completeRequest(returningItems: [response], completionHandler: nil)
}
}

5. Finally just making sure the manifest.json file is up to date

  • The key change is "permissions": [ "nativeMessaging" ] because you’ll need to allow your extension to communicate from javascript to the mediator
  • Change the background[persistence] = false
  • Change content_scripts[matches] = [ "<all_urls>" ] . This basically allows the content.js code to run on every URL
{
"manifest_version": 3,
"default_locale": "en",

"name": "__MSG_extension_name__",
"description": "__MSG_extension_description__",
"version": "1.0",

"icons": {
"48": "images/icon-48.png",
"96": "images/icon-96.png",
"128": "images/icon-128.png",
"256": "images/icon-256.png",
"512": "images/icon-512.png"
},

"background": {
"scripts": [ "background.js" ],
"persistent": false
},

"content_scripts": [{
"js": [ "content.js" ],
"matches": [ "<all_urls>" ]
}],

"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "images/toolbar-icon-16.png",
"19": "images/toolbar-icon-19.png",
"32": "images/toolbar-icon-32.png",
"38": "images/toolbar-icon-38.png",
"48": "images/toolbar-icon-48.png",
"72": "images/toolbar-icon-72.png"
}
},

"permissions": [ "nativeMessaging" ]
}

Now let’s compare before the extension or when the extension is off vs while it is set this behavior enabled from the native side in UserDefaultsSuite:

I think there is a lot of potential with these extensions. Lots of development in the area of adblock and password managers but I haven’t seen a lot outside of that.

Debugging extensions and Javascript code in this set up isn’t easy either and I’m thinking about writing about that next. Let me know if you think that would be helpful for you! Happy coding!

--

--