Tricks for Testing External Links

On iOS, there is no deep mechanism for interapplication communication such as macOS provides through Apple events. Your app is sandboxed; other apps cannot drive it. There is, however, a simple and safe mechanism for letting another app send small messages to your app, namely through external links.

We are all familiar with links. Suppose you’re looking at a web page in your browser, and you see the word “Apple” in your browser, marked in some way that indicates it is clickable. You do click it, and the browser jumps to display Apple’s home page. That’s because this word is associated with a link to https://www.apple.com. The browser “obeys” that link by asking Apple’s server for the associated web page.

A link doesn’t have to be triggered by clicking on something in a browser. Any app can “throw” a link at the system. For example, suppose your app contains this code:

let link = URL(string: "https://www.apple.com")!
UIApplication.shared.open(link)

When that code runs, the link is “thrown” at the system, which has to decide how to “obey” it. In this case, the system’s solution is to pass the link along to Safari, which displays Apple’s home page.

But how was the decision made as to what constitutes “obeying” this link? And in particular, how can we get the system to decide that “obeying” a link means sending a message to your app? That is the mechanism that I’m calling an external link.

The ability to respond to external links can add coolness and utility to your app. The trouble is that external links can be hard to test. How are you going to make a link come at your app so that you can see whether your app responds correctly, and figure out why not if it doesn’t? You can probably imagine writing a second app whose job is to do nothing but call UIApplication.shared.open, just so the link can be routed to your real app for testing purposes. That sounds rather clumsy, especially if you have a lot of link types to test.

Fortunately, there are better ways. I’ll talk about what they are; but first, let’s examine how your app can receive an external link in the first place.

Custom scheme

One way that the system can know what to do with a link is through the scheme at the start of the link. An https: scheme at the start of the link means to go online and ask for that resource. Well, it turns out that your app is free to declare its own scheme. By registering that scheme with the system, your app is saying: “If you ever see a link with this scheme, pass it along to me.”

To illustrate, I’ll arm my app with a toy custom scheme. This is the simplest way to implement response to an external link. All you have to do is edit the app target, go to the Info pane, and add to the URL Types listed at the bottom of the screen. You need a unique identifier, a scheme (which should also be unique if this is your app’s own scheme), and a role. For example, I might configure my URL Type this way:

Key Value
Identifier com.neuburg.matt.mycoolscheme
Scheme mycoolscheme
Role Viewer

Now I’ll configure my app’s code to get an event when an external link arrives. I’ll assume we’re using iOS 13 or later and that our app has scheme delegate support, so the code goes in the scheme delegate:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    if let url = connectionOptions.urlContexts.first?.url {
        print("got scheme URL on launch", url)
    }
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    if let url = URLContexts.first?.url {
        print("got scheme URL while running", url)
    }
}

The idea is that if our app is launched by the arrival of the link, willConnectTo is called, but if the link arrives when the app is running, openURLContexts is called.

Universal links

The second way to implement an external link is what Apple calls a universal link. A universal link is a normal https: link. What’s special is what resides at that link. The link leads to a web site that you own, and what’s there is a JSON file that references your app. At the same time, your app’s Entitlements file references that web site. Thus we have what Apple calls a “two-way handshake” that leads the system to your app.

To configure your app, add the Associated Domains capability in Xcode, and specify your web site’s domain with an applinks: prefix, like this:

applinks:www.apeth.com

To configure your web site, you place at the root level in the .well-known directory a JSON file called apple-app-site-association. (Note the lack of any suffix.) Here’s a simple example of its contents:

{
    "applinks": {
        "details": [
            {
                "appID": "ABCDE12345.com.neuburg.matt.myCoolApp",
                "components": [
                    {
                        "/": "/testing/*"
                    }
                ]
            }
        ]
    }
}

In real life, the ten-character component before my app’s bundle ID is assigned by Apple, and can be obtained by going to the developer member center in the browser and looking in the Certificates, Identifiers & Profiles section. The "/testing/*" pattern ensures that not every URL involving my web site will be treated as an external link to my app, only those those whose path starts with /testing/. After all, we still want the browser to be able to display my web site in the normal way!

Okay, so my app specifies the www.apeth.com domain, and I’ve put my JSON file at https://www.apeth.com/.well-known/apple-app-site-association. The two-way handshake is ready! When the app is installed on an iOS device, the system sees the Entitlements entry pointing to my web site, and it goes online and finds the apple-app-site-association file. Now the system knows that links pointing to my web site are eligible to be repointed to the app. And the app needs to be ready to receive them:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    if let url = connectionOptions.userActivities.first?.webpageURL {
        print("got universal link on launch", url)
    }
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    if let url = userActivity.webpageURL {
        print("got universal link", url)
    }
}

Again, the idea is that if our app is launched by the arrival of the link, willConnectTo is called, but if the link arrives when the app is running, continueUserActivity is called.

Throwing a link

So now we build and run our app in the Simulator, and we’re ready to throw a link at it to see if things are working. But how?

It turns out that the Simulator has a built-in mechanism for this. In the Terminal, you say xcrun simctl openurl booted followed by the URL. The booted part routes the URL to the simulated device that is currently running. The system on that simulated device does whatever it would normally do when this URL is thrown at it.

Okay, so first let’s test our scheme-based external link. We build and run the app in the Simulator, switch to the Terminal, and say this:

% xcrun simctl openurl booted "mycoolscheme://testing"

We look in Xcode, and sure enough, our openURLContexts is called, and we can see that it has received the URL "mycoolscheme://testing", which is what we threw at the system to start with.

Now let’s try our universal link:

% xcrun simctl openurl booted "https://www.apeth.com/testing/1"

Again, Xcode shows us that our continueUserActivity is called, and that it has received the URL "https://www.apeth.com/testing/1". Our external links are working!

Debugging on launch

It will not have escaped your attention, however, that we have only tested half of what we came here to test. We have tested what happens when an external link arrives at our app when it is already running. But what about the question of what happens when an external link arrives at our app when it is not running? Then the external link will launch our app, and willConnectTo is called. How are we going to test that?

Hmmm. Let’s say we start in the usual way, by building and running the app. Now the app is running. Now we throw the link at it. But the app was not launched by the external link; it was launched by Xcode. So openURLContexts or continueUserActivity is called. Great, but that is not what we wanted to test.

Okay, let’s come at it the other way. Let’s say we stop running the app. Now we throw the link at it. Sure enough, the app launches in the Simulator! Great, but we didn’t launch it from Xcode, so the debugger isn’t working! No print statements arrive in the Console, and we don’t break at any breakpoints.

It’s Catch-22! There appears to be no way to debug the app when it is launched from an external link.

Well, have no fear. It turns out that there’s a little-known Xcode capability of glomming on to an app just after it has launched, and hooking the debugger into it. You edit the scheme, and in the Run action, under Info, where it says Launch, you click the second radio button: “Wait for the executable to be launched.”

Now when you run the app, nothing happens! At least, nothing appears to happen. But Xcode tells you that it is watching and waiting for the app to be launched over on the Simulator. So, on the Simulator, you launch the app yourself — for example, just by tapping it in the Springboard. Presto! Xcode sees that the app has launched, hooks the debugger into it, and pauses at the first breakpoint it comes to.

So, the procedure for testing what happens when the app is launched by an external link is as follows:

  1. First, duplicate the scheme. In the duplicate, set Launch to “Wait for the executable to be launched.” This way, we have two almost identical schemes, the old one for normal build and run, and the new one for testing launches.

  2. Switch back to the original scheme, and build and run, just to make sure the latest version of the app is on the Simulator. Now stop running.

  3. Okay, switch to the second scheme, and run! Xcode is now waiting for the app to be launched in some other way.

  4. Throw the link at the system on the Simulator.

It works! The app launches, Xcode hooks into it, and when scene(_:willConnectTo:) runs, it pauses at breakpoints, and so on.

Well, I lied. It almost works. Xcode does indeed pause at breakpoints. But in this configuration, print statement outputs are not routed to the Xcode console. They go into the device log instead. Darn.

There’s nothing we can do about that, but I can think of two workarounds:

  • Replace your print statements with breakpoints configured to log into the Console. Logging of that sort does display in the Xcode Console.

  • Replace your print statements with os_log statements, and read the output in the Console application (as opposed to Xcode’s console). You’ll have a happier experience if the Console application is already running and has been configured to track the correct Simulator device and filtered by subsystem so that only messages from your app are displayed. (New in iOS 14, os_log has a great native façade, Logger, and is much easier to use than previously.)

Lists of URLs

There’s just one little further tweak we might want to make to our testing procedure. So far, we’ve been entering URLs more or less by hand into the Terminal when we want to throw them at the system on the Simulator. It would be nice to wrap some sort of GUI around that. For example, maybe we could maintain a list of URLs somewhere, so that we can choose one and throw it. That way, we can test lots of different URLs and see what happens.

There are various applications that allow you to do just that. For instance:

  • You might like to try SkyBar; it’s pretty buggy, but it’s free and it allows you to maintain a list of URLs, choose one in a pop-up menu, and direct it at the Simulator.

  • There’s also an application called RocketSim. It isn’t free, but it also does other things besides throw universal links.

  • There’s also a dedicated universal links manager called Altum.

And of course there may be others that I haven’t discovered.

You Might Also Like…

Taking Control of Rotation Animations in iOS

We all love to have animation in our app’s interface. But sometimes writing even the simplest animation can be rather elusive. Rotation is a case in point. As you probably know, the way to rotate a view (or a layer) is to change its transform, applying a rotation transform to it. But certain rotations are …

Taking Control of Rotation Animations in iOS Read More »

    Sign Up

    Get more articles and exclusive content that takes your iOS skills to the next level.