How to launch a macOS SwiftUI app from a Safari extension

This week I have added a Safari extension for my new app QReate. When interacted with, the extension creates a new QR code in the app with the current tab's URL:

In this article, I will explain how the extension retrieves the current tab's URL, how it launches the app with the URL as a query parameter and how the app handles this event.

Creating a Safari extension

First, we can create a Safari extension for an existing macOS app in Xcode by going to File > New > Target and selecting 'Safari Extension' from the list of templates:

Xcode template for a Safari extension

Setting things up

The template creates a new target in our project that gets us most of the way there but there are still a few things we need to change and remove to make it work for this specific use case:

A note on debugging

There are two separate ways to debug a Safari app extension:

  1. By launching the app target directly.
  2. By launching the extension target and selecting Safari as its host application.

You must know that in both cases you will need to 'Allow Unsigned Extensions' in Safari's Develop menu:

Allowing unsigned extensions for local debugging from Safari's Develop menu

Once you do this, you can enable the extension in Safari's preferences and start testing your code:

Enabling extensions from Safari's preferences

Launching the app

The Safari App Extension template we used earlier created a subclass of SFSafariExtensionHandler.

We can use this class to handle extension-related events. For example, we can get notified whenever the user clicks on the extension's toolbar item by overriding the toolbarItemClicked(in:) method in the subclass:

SafariExtensionHandler.swift
import SafariServices

class SafariExtensionHandler: SFSafariExtensionHandler {
    override func toolbarItemClicked(in window: SFSafariWindow) {
        window.getActiveTab { (tab) in
            tab?.getActivePage(completionHandler: { (page) in
                page?.getPropertiesWithCompletionHandler( { (properties) in
                    if let url = properties?.url {
                        NSWorkspace.shared.open(URL(string: "qreate:new?content=\(url.absoluteString)")!)
                    }
                })
            })
        }
    }
}

In the method above, the extension retrieves the current tab from the Safari window, followed by the current page in the tab and finally the URL of said page.

Once the URL is retrieved, the extension launches the app by opening a URL with a custom scheme (which we'll set up later on in the article) and feeding it the tab's URL as a query parameter named content.

Turning extension off when no URL is available

The extension should only be enabled when the user is on a page with a valid URL.

We can disable the extension's toolbar item by overriding the validateToolbarItem(in:validationHandler:) method in the SafariExtensionHandler class and only returning true when the current page has a valid URL:

SafariExtensionHandler.swift
import SafariServices

class SafariExtensionHandler: SFSafariExtensionHandler {
     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, "")
                 })
             })
         }
     }
}

Now that the extension can retrieve the current URL and open a deep link with a custom scheme ("qreate:"), we can make the app listen for deep links with that specific scheme and handle them in just two simple steps:

  1. Register the custom scheme as a URL type in the app's target.
  2. Listen for deep links from a View.

Registering a URL type

The first step to handling a deep link in the app is to add a URL type to the macOS app's target in Xcode. We need to make sure that the URL scheme we define in the app's target matches the one we've used in the extension.

A new URL type can be registered by going to the target's 'Info' tab in Xcode and adding a new entry in the 'URL Types' section:

Registering a URL type for a target in Xcode

Registering the URL type will cause the OS to open the app instead of a browser whenever a URL with the specified scheme is opened.

If you're using SwiftUI's app lifecycle, handling a deep link is as simple as adding a .onOpenURL modifier to the View that should handle said deep link:

QRCodeList.swift
List(viewModel.qrCodes, id: \.id, selection: Binding<UUID?>(get: { viewModel.selectedQRCode }, set: viewModel.listItemTapped(_:))) { qrCode in
    // ...
}
.onOpenURL { url in
    let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
    if let content = components?.queryItems?.first(where: { $0.name == "content" })?.value,
        let url = URL(string: content) {
        viewModel.handleDeepLink(url)
    }
}

The onOpenURL view modifier above retrieves the URL components from the deep link, finds the content query parameter and then calls a method in the view model with the retrieved URL which ultimately creates a new QR code.

Email icon representing an email newsletter

iOS CI Newsletter