How to build a Safari extension with SwiftUI

Sponsored
RevenueCat logo
Releases so easy your work will never pile up

Runway handles the release coordination and busywork so you can focus on building great apps. You do the building, we'll do the shipping.

I have recently shipped a new version of my app QReate that includes a complete redesign of the Safari extension. The UI for the extension is built entirely using SwiftUI and, as I could not find many resources on how to build Safari extensions using SwiftUI online, I thought it would be a good idea to write a blog post about it.

Creating a Safari Extension target

The first step to building a Safari extension is to create a new target in your Xcode project. To do this, go to the project settings, click on the ”+” button at the bottom of the targets list, and select “Safari Extension” from the list of macOS available templates:

Once you have done this, delete the following files from the new target:

  • script.js from the Resources folder.
  • SafariExtensionViewController.swift
  • SafariExtensionViewController.xib

Modifying the Info.plist files

There are several changes that you need to make to the Info.plist files of the extension target to set up your extension correctly.

First, and as we are removed the script.js file, you need to remove the SFSafariContentScript key from the extension target’s Info.plist file.

Secondly, you need to make sure that your toolbar item is configured correctly:

Info.plist
<key>NSExtension</key>
<dict>
    <!-- ... -->
    <key>SFSafariToolbarItem</key>
    <dict>
        <!-- 1 -->
        <key>Action</key>
        <string>Popover</string>
        <!-- 2 -->
        <key>Identifier</key>
        <string>com.appdiggershq.qreate.SafariExtension</string>
        <!-- 3 -->
        <key>Image</key>
        <string>ToolbarItemIcon.pdf</string>
        <!-- 4 -->
        <key>Label</key>
        <string>Create a new QR code</string>
    </dict>
    <!-- ... -->
</dict>

Let’s break down and understand what the 4 keys that you need to configure do:

  1. The Action key defines the behaviour of the toolbar item. In this case, we are setting it to Popover to display a popover with some UI when the user clicks on the toolbar item.
  2. The Identifier key is a unique identifier for the toolbar item.
  3. The Image key is the name of the image that you want to use as the toolbar item icon and that will be shown to your users when the extension appears in Safari’s toolbar. This file must be in the target’s Resources folder.
  4. The Label key is the name that the extension will be given when presented to the user in Safari.

Thirdly, you need to modify the SFSafariWebsiteAccess key to allow your extension to access all of the websites’ properties:

Info.plist
<key>NSExtension</key>
<dict>
    <!-- ... -->
    <key>SFSafariWebsiteAccess</key>
    <dict>
        <key>Level</key>
        <string>All</string>
    </dict>
    <!-- ... -->
</dict>

If you want to narrow down the scope of your extension to only work on specific websites, you can set the Allowed Domains key with an array of strings containing a list of domains that you want to allow.

Finally, you need to provide a description users to understand what your extension does. You can do this by setting the NSHumanReadableDescription key:

Info.plist
<key>NSExtension</key>
<dict>
    <!-- ... -->
    <key>NSHumanReadableDescription</key>
    <string>QReate helps you generate QR codes from URLs quicker. The Safari extension will create a new QR code in the app when clicked.</string>
    <!-- ... -->
</dict>

Creating the SwiftUI view

Now that you have set up your extension target, you need to tell it to render a SwiftUI view. The way to react to events in Safari extensions and modify their behaviour is by using a SFSafariExtensionHandler:

SafariExtensionHandler.swift
import SafariServices

class SafariExtensionHandler: SFSafariExtensionHandler {
    override func popoverViewController() -> SFSafariExtensionViewController {
        PopoverViewController()
    }
    
    override func validateToolbarItem(in window: SFSafariWindow, validationHandler: @escaping ((Bool, String) -> Void)) {
        window.getActiveTab { (tab) in
            tab?.getActivePage(completionHandler: { (page) in
                page?.getPropertiesWithCompletionHandler( { (properties) in
                    validationHandler(properties?.url != nil, "")
                })
            })
        }
    }
}

The class above defines two methods:

  1. popoverViewController() returns the view that will be displayed when the user clicks on the toolbar item. This view must be of type SFSafariExtensionViewController.
  2. validateToolbarItem(in:validationHandler:) is used to determine whether the toolbar item is enabled or not. In this case, we are enabling the toolbar item only when the user is on a valid webpage.

Once you create the SafariExtensionHandler class, you need to set it as the extension’s principal class in the target’s Info.plist file:

Info.plist
<key>NSExtension</key>
<dict>
    <!-- ... -->
    <key>NSExtensionPrincipalClass</key>
    <string>$(PRODUCT_MODULE_NAME).SafariExtensionHandler</string>
    <!-- ... -->
</dict>

Let’s now create the PopoverViewController and set its view to be a NSHostingView with a SwiftUI view as its root:

PopoverViewController.swift
import SafariServices

final class PopoverViewController: SFSafariExtensionViewController {
    init() {
        super.init(nibName: nil, bundle: nil)
        let popover = Popover()
        self.view = NSHostingView(rootView: popover)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

We are now ready to create the SwiftUI view that will be displayed in the popover. Let’s create a simple view that displays the URL of the current webpage:

Popover.swift
import SwiftUI

struct Popover: View {
    @State private var content = ""
    
    var body: some View {
        VStack(spacing: 16) {
            Text("URL")
                .font(.headline)
            Text(content)
        }
        .frame(minWidth: 300)
        .padding()
        .onAppear {
            SFSafariApplication.getActiveWindow { (window) in
                window?.getActiveTab { (tab) in
                    tab?.getActivePage(completionHandler: { (page) in
                        page?.getPropertiesWithCompletionHandler( { (properties) in
                            DispatchQueue.main.async {
                                if let url = properties?.url {
                                    content = url.absoluteString
                                }
                            }
                        })
                    })
                }
            }
        }
    }
}

That’s it! Next time you run your app, an extension will appear in Safari’s preferences ready for you to install and test.