How to build a Safari extension with SwiftUI
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 theResources
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:
<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:
- The
Action
key defines the behaviour of the toolbar item. In this case, we are setting it toPopover
to display a popover with some UI when the user clicks on the toolbar item. - The
Identifier
key is a unique identifier for the toolbar item. - 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’sResources
folder. - 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:
<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:
<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
:
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:
popoverViewController()
returns the view that will be displayed when the user clicks on the toolbar item. This view must be of typeSFSafariExtensionViewController
.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:
<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:
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:
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.