How to Build a Safari App Extension in iOS 15

iOS 15 finally introduced Safari extensions, one year after Apple brought extensions to Safari on the desktop. Of course, they did it in a very gated, Apple-like way, which makes the process to develop a Safari extension much more difficult than it needs to be. Notably however, you still can't make Chrome extensions on iOS (or Android).


A few weeks ago I set out to build a Safari extension for my app, hbd (you can check out the extension now in the latest update). I didn't find any thorough guides for this like I usually do for other development processes, so I decided to make one.


Let's talk about the practical details of building a Safari Extension on iOS.


Before Beginning


There are some important things to know before developing a Safari extension on iOS.

  • Extensions must have a containing native application. You can't just upload a Safari extension like you can on the Chrome web store, you have to build an iOS app too (even if it's just a shell for your extension), and go through the whole App Store review process to get it published.

  • Lots of browser APIs are restricted/unavailable. Most of this is due to the fact that iOS has much stricter memory limits than a computer, so they've added requirements like non-persistent background pages. Read the list to make sure you're not planning to use any restricted APIs.


Tabula Rasa


If you want to start from scratch, Xcode has a project template for Safari App Extensions. Create a new project, then select Safari Extension App. I recommend doing this just to see what the structure of the extension looks like, but I couldn't actually use this in my case, since I had an existing Xcode project that I wanted to add a new extension to.

If you already have an Xcode project, go to File->New->Target, and select Safari Extension (NOT Safari Extension App). Xcode will add a few boilerplate files in a new group:

  • SafariWebExtensionHandler.swift is the glue between your native app and the JS code. It implements the NSExtensionRequestHandling protocol, which allows it to receive and respond to messages from the JS.

  • the Resources/ folder contains all of your extension source code. This should be structured like an unpacked browser extension, with manifest.json at the top level.

The boilerplate JS files are okay if you're making a basic extension, but won't help very much if you're using a build system and framework like React, which I assume most complex extensions do. If so, you can delete all the files inside the Resources/ folder (we'll replace them later).


If you have an existing Chrome extension that you want to convert to Safari, you can run this command-line tool to convert it (I didn't use this since I was starting my extension from scratch).


Using React


I built my extension using React + Typescript. Coming from a background in Swift, I would highly recommend using Typescript over regular Javascript. If you use SwiftUI, I would also recommend using React. React has a lot of similarities to SwiftUI making it easy to pick up, although it's just a poor man's version of SwiftUI 😎. Here is the basic structure of my code:


If you use this boilerplate, run npm install after you've cloned it to download all the dependencies. Then, running npm run dev will build the extension and output it to the dist/ subdirectory. This dist/ directory is what we want to tell Xcode about.


Xcode - Groups & References


Xcode's file navigator (in the left pane) shows you a sort of pseudo-file structure of your project. I say pseudo- because it doesn't always represent what's actually stored on your hard drive. There are two ways to add new folders to an Xcode project - using groups or folder references.

  • If you add a group, new files added to that folder in your Finder don't automatically get added to your Xcode project. In Xcode 13, groups are represented with a gray folder icon.

  • If you add a folder reference, any new files added to that folder will automatically be added to your project. In Xcode 13, folder references are represented with a blue folder icon.

After researching this, I decided that using folder references is better than using groups. For example, if we add a new image to our extension's icons/ folder, we'd like that image to automatically be added to the Xcode project.


Here's how to accomplish this: No matter where you place your extension directory on your computer, you can open its dist/ subfolder and select all of the files, then drag them into the Resources/ folder in Xcode (NOT finder). Once you do this, a popup will appear:

do not copy files, and create folder references

Make sure you are not selecting Copy items if needed, and that you are selecting Create folder references. If you select copy items, then all of the files from your dist/ folder will be unlinked from your source code. So if you make some changes and rebuild your extension, those changes won't be included in Xcode.

blue folder icons = folder references

If you open up the Resources/ folder in finder now, you will see that there are actually no files there. All of the files are actually stored in the dist/ folder in your extension directory, and are only referenced in Xcode and shown in the pseudo-file navigator. You can confirm this by opening the right pane (⌘⌥0) and looking at the file path for your JS files.


Unfortunately, we can't make the top-level Resources/ folder a folder reference in Xcode; it must be a group. This is a special folder name in Xcode and for some reason they won't let you change it to a folder reference. So if you add a new file to the top-level of your extension's dist/ folder (say you decide to add a new content script), you'll have to drag it into Xcode too. This is one change I would like Apple to make for future releases.


The Development Cycle


After you've set up the files, you can now start developing and testing your extension. The first step is to create a new scheme that runs your extension target:

create a new scheme

Every time you run this scheme, it will ask you which app to launch; select Safari. There may be a way to set this automatically, but I haven't figured it out yet. Using this scheme, you can profile your Extension's memory usage and set breakpoints in your SafariWebExtensionHandler class.


SafariWebExtensionHandler.swift is responsible for handling all messages sent from the JS scripts. The app can only listen and respond to messages; it can't initiate messages to the JS on its own. To send messages to your app, you need to add the nativeMessaging permission in the manifest.json, and use this code:

browser.runtime.sendNativeMessage("application.id", {message: "Hello from background page"}, function(response) {
    console.log("Received sendNativeMessage response:");
    console.log(response);
});

You should send native messages sparingly, as I'll discuss later on. The main reason to use them would be to request necessary data from UserDefaults or a shared keychain.


Hot Reload?


Web developers are used to hot reload, but that won't quite work here. I used npm run watch to rebuild my extension every time I changed a file, but that will only change what is in the dist/ folder (which is referenced in Xcode). It won't push your new changes on the device you're testing. To do that, you need to re-run the project in Xcode. This is a painfully slow experience. As a sort of compromise, you can download the React Preview plugin for VS Code. It works sort of like the canvas in SwiftUI, but the layout doesn't always match up with mobile Safari, so be sure to test on device.


Devtools


To debug your extension's JS scripts, you'll need to use Safari on your mac. Open Safari, enable the develop menu, then open the develop menu in the menu bar. Select whatever device you're running on, and select the relevant page, which will bring up a web inspector.


To my knowledge, there's no way to automatically open a web inspector when you open your popup page. I wish this were the case, since I spent a lot of time manually re-opening web inspectors. Also, every time you re-run your Xcode scheme, your breakpoints get cleared, which sucks. The lack of tooling here is what makes developing Safari Extensions a pain.


Deploying


So you've tested your extension and its ready to go, now you just need to archive your project and upload it to App Store Connect. There's one small step that you might forget, which is to build your extension source for prod instead of dev mode. This dramatically reduces file size (in my case from 3.6 MB to 240 KB). In dev mode, Webpack includes a source map of your original, unbuilt code so you can debug more easily. If you've looked at your popup.js file inside your dist/ folder, it's a giant mess of unreadable code. Building for prod removes these source maps and minifies your code for production.


You can manually run "npm run build" to build for prod before you archive, or you can set up a script to do it automatically. I prefer the latter. Here's how to do it:


Edit your main app's scheme (not the extension scheme). Then, under Archive->Pre-actions, add a new Run Script Action.

Write your shell script. Make sure that "Provide build settings from" is set to your app target. There are a few things to note about the actual script:

  1. I used the say command because I wasn't sure the script was actually running, so it helped debug.

  2. Pre-actions aren't logged in the build log, so the second line redirects all output from this script to a file called prearchive.log in my main project directory.

  3. Replace the path in line 4 with whatever path your extension is located at.

Now every time you archive your app, this script will run and automatically build your extension for production.


Other Challenges


Memory


iOS takes memory management seriously. As of iOS 15.0, the memory limit for a Safari Extension is 6 MB. This isn't documented anywhere to my knowledge, but you will discover it once your app is randomly terminated, as I did. This limit only refers swift code in your extension target, not JS/html/css. This seems like an incredibly unfair limit, given that running the template app in Xcode uses 3.8 MB (over 50% of the limit), and most of that is just Apple frameworks. According to one Apple employee on the Apple Dev forum, they're increasing the limit to 80 MB in iOS 15.1. If that's true, it's a 13x jump, which makes me think someone totally miscalculated and it somehow wasn't caught during beta. [Only real devices have this limit, simulators don't.]


Even if the limit is increased to 80 MB, I would try to use the SafariWebExtensionHandler as little as possible and do most of your work in the JS; the browser does not have the same strict memory limits. In my case, I needed to use native messaging to get the user's auth state from the shared keychain. Other than that, I used node modules and JS fetch requests to get all the data I needed.


Safe Area


It's trivial to ignore the safe area in a native app, but what about in Safari? Turns out Safari uses css environment variables to accomplish this. The only problem was that they didn't work for me. I tried a bunch of different things, like setting viewport-fit in a meta tag, but no matter what, the variables all evaluated to 0px. Maybe I was doing something wrong, but this whole approach is such a mess.


Other notes


In the documentation, Apple specifies to use the browser API namespace, but it seems that chrome APIs work just fine. Why?


Final thoughts


Many people say that the browser is a second-class citizen on iOS, and after developing a Safari extension, I understand why. There's a major lack of tooling here compared to native development. Granted, Safari Extensions are only a few months old.


Apple's strategy has generally been to push towards its native App Store, where it has much tighter control and can extract more value. The browser, on the other hand, is the wild west. For a while people thought that PWAs would be the next big thing, since they work cross-platform out of the box and are easier to deploy and maintain. But that never happened, since the UX of a PWA is still significantly worse than a native app. That must be at least somewhat intentional on Apple's part. Maybe they'll find a way to levy a 30% tax on all payments in Safari, too.


Anyway, the market for browser extensions has never been that big. Compared to the market for mobile apps, it's tiny. Browser extensions tend to be more like utilities (e.g. a password manager, ad blocker, theme provider), rather than full-blown applications. They're called "extensions" for a reason - they're meant to incrementally extend or enhance your browsing experience. The one notable winner here is Honey, a browser extension that got acquired for $4 billion (!) in 2019. I think there are a lot fewer opportunities to build standalone businesses around browser extensions, but they can make a great feature to complement your other apps. That's the approach I took with my app, hbd. (If you've made it to this far, please check out the hbd Safari extension!)


Anytime Apple introduces a user-facing framework like this, it's worth paying attention to. That's the beauty of developing for iOS - every year there are 3 or 4 totally new APIs that affect 1B people. I'm not super bullish on Safari extensions since I don't see any big opportunities, but I wouldn't be surprised to see an emergent viral Safari extension soon. So far, popular Safari extensions look a lot like regular chrome extensions, just ported to Safari. What can you do on iOS that you can't on desktop? What's different about how people use Safari on iOS? Whoever cleverly answers those questions will have a big opportunity on their hands.


If you have any comments, corrections, or improvements to my process, reach out to me at bedelstein12@gmail.com and I'll be happy to update this post.