App Clips, universal & deep links: how we added station sharing to Eter

App Clips, universal & deep links: how we added station sharing to Eter

One of the new features that we recently added to Eter: Streaming Internet Radio is the ability to share a radio station via a universal link and a special station web profile. If you’re interested in implementing universal links and App Clips in your own app, read on to learn from Eter’s lead developer, Krystian Kozerawski:

  1. How to configure universal link support on your server.
  2. How to add universal link and deep link support to your project in Xcode.
  3. How to add App Clip support in Xcode.

Eter users can now easily share a particular radio station’s special universal link, which, depending on the situation, will:

  • Open the Eter app on an iPhone, iPad or Mac.
  • Open a special web page in the eter.apparentsoft.com domain with the station’s profile.
  • In the case of an iPhone that doesn’t yet have Eter installed, open a special version of the app that will play the shared station in the form of an App Clip, which functions as a mini-version of Eter, that gets temporarily installed directly from the App Store. 

Here’s a working example of a shared station: Shady Pines radio

Supporting universal links and App Clips is actually one topic. It requires similar moves on the server side, on the domain to whose subpage the universal link refers, as well as similar steps in the project configuration in Xcode. First, let’s look at what you need to configure on your web server to support universal links.

Configuring universal link support on your server

A station profile web page, in the domain referenced by a link, which is also supposed to act as a universal link requires special configuration on the server in the form of a JSON file named apple-app-site-association (without extension) placed in the .well-known folder. 

So the proper form of the address of such JSON configuration file is:

https://[YOUR.DOMAIN]/.well-known/apple-app-site-association

In the case of Eter, this file can be found at: https://eter.apparentsoft.com/.well-known/apple-app-site-association

Importantly, the domain must use the secure HTTPS protocol. 

In the case of Eter, that JSON configuration file looks like this: 

JSON
{
	"applinks": {
		"details": [{
			"appIDs": ["G22F34V2HZ.com.mackozer.NewWorldRadioPlayer"],
			"paths": ["/stations/*", ]
		}]
	},
	"appclips": {
		"apps": ["G22F34V2HZ.com.mackozer.NewWorldRadioPlayer.Clip"]
	}
}

As you can see, it consists of two dictionaries, one under the key applinks, referring to proper universal links, and the other under the key appclips, as you can easily guess, referring to App Clips. 

For universal links (applinks), you need to provide an array of bundle identifiers with Application Identifier Prefix in the following format: [ApplicationIdentifierPrefix].[BundleID].

Your organization’s Application Identifier Prefix can be found on your developer profile (developer.apple.com) in the Certificates list view in the upper right corner under the name of your developer account.

The second array in the configuration file for Eter is paths. Here we specify paths without subdomains. In our case, all addresses containing eter.apparentsoft.com/stations will be treated as universal links. 

It should be mentioned that instead of paths you can use an array of components, in which you specify the components of the path, while having the option to specify whether a component should be included in the address or not. 

The altered JSON on the Eter page would look like this: 

JSON
{
    "applinks":{
        "details":[
            {
                "appIDs":[
                    "G22F34V2HZ.com.mackozer.NewWorldRadioPlayer",
                ],
                "components": [
               {
                  "/": "/stations/*",
                  "comment": "Any URL in Eter subdomain with a path that starts with /stations/*"
               }
                ]
            }
        ]
    },
    "appclips": {
        "apps": [
            "G22F34V2HZ.com.mackozer.NewWorldRadioPlayer.Clip"
        ]
    }
}

The second dictionary, appclips, contains an array with the identifiers of individual App Clips, each beginning with an Application Identifier Prefix. The principle here is the same as for application identifiers. 

Here’s a sample JSON app-association file from Apple’s documentation: 

JSON
{
  "applinks": {
      "details": [
           {
             "appIDs": [ "ABCDE12345.com.example.app", "ABCDE12345.com.example.app2" ],
             "components": [
               {
                  "#": "no_universal_links",
                  "exclude": true,
                  "comment": "Matches any URL with a fragment that equals no_universal_links and instructs the system not to open it as a universal link."
               },
               {
                  "/": "/buy/*",
                  "comment": "Matches any URL with a path that starts with /buy/."
               },
               {
                  "/": "/help/website/*",
                  "exclude": true,
                  "comment": "Matches any URL with a path that starts with /help/website/ and instructs the system not to open it as a universal link."
               },
               {
                  "/": "/help/*",
                  "?": { "articleNumber": "????" },
                  "comment": "Matches any URL with a path that starts with /help/ and that has a query item with name 'articleNumber' and a value of exactly four characters."
               }
             ]
           }
       ]
   },
   "webcredentials": {
      "apps": [ "ABCDE12345.com.example.app" ]
   },


    "appclips": {
        "apps": ["ABCDE12345.com.example.MyApp.Clip"]
    }
}

If we put the JSON file in the mentioned folder (./well-known), that’s half done. We can update the file at any time by adding more bundle IDs, paths or components. 

Have patience; the update takes time

Apple’s web robots will not immediately read this file. Sometimes it takes several hours for it to be updated in the Apple database. You can check the current status / contents of the app-site-association JSON file at:

https://app-site-association.cdn-apple.com/a/v1/YOURDOMAIN.

In the case of Eter, this address is: https://app-site-association.cdn-apple.com/a/v1/eter.apparentsoft.com.

Adding universal and deep link support to your Xcode project

Now it’s time to configure the project appropriately so that, depending on whether the user has an application installed or not, the universal link opens either that application or its App Clip. 

To add support for universal links to your app, you need to add the Associated Domains capability. This is done in the Signing & Capabilities tab of the project view in Xcode. Click the + Capabilities button and select Associated Domains from the list,. Then, add two domains with the appropriate prefixes to the domains list. In case of Eter, it looks like this:


As you can see we added not only associated domains to support universal links (applinks) but also to support App Clips. 

A universal link will not always work. For example, when the link is tapped in Facebook or X/Twitter for iOS, these applications use their own built-in browsers. In such cases, a page in the eter.apparentsoft.com domain will open in this built-in browser with the profile of the given radio station and a button to open the station directly in Eter. This button is a deep link, which the application must also support.

Configuring deep links

As  mentioned, if a universal link opens in a browser that does not support it (like the internal web browser in the Facebook or X apps for iOS), a page with the profile of the given station will be displayed. There is a button with a deep link on it, which will open Eter and start playing (receiving) the given radio station stream.

A deep link with a coded stream address, station name and its identifier starts with the prefix com.apparentsoft.eter:// and the system must recognize this prefix to open Eter. Therefore, it should be added to the list of URL types in the project settings in Xcode in the Info tab.

Click the + button in the URL Types section and enter the identifier (preferably one that will describe the purpose of the deep link). In the URL Schemes field, enter your deep link address prefix, which will inform the system that it should open your application. In the case of Eter, we decided to use the classic format for bundle ID. Leave Role set to Viewer, its default value.

Note that you can add as many URL Types (deep links) as your app needs. Each deep link can be used for different features.   

Handling universal and deep links with onOpenURL

Now that we’ve added support for universal links and deep links to our project, it’s time to handle those incoming links in our app. In SwiftUI, we can catch the universal link events and deep link events using the view modifier .onOpenURL.

Swift
struct ContentView: View {
    var body: some View {
	      Text("Hello World!")
	          .onOpenURL { incomingURL in
                handleIncomingURL(incomingURL)
            }
    }
}

In the handleIncomingURL(_ url: URL) method, we extract the stream URL hidden in the deep link or universal link, along with the station name and its ID.

However, if we’re working in UIKit and we use an app delegate, we can use the following method:

Swift
func application(_ application: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool {
    /// Handle incoming url accordingly
}

It is now a simple matter to extract the stream link, station name or identifier embedded in such a deep link and establish a connection and start receiving the stream if the address is correct. We also need to handle possible errors, including unreadable deep links.

In the case of Eter, the same method supports deep links from the profile page of a given station in the eter.apparentsoft.com domain as well as the data contained in a universal link.

All of this is enough to handle both universal and deep links placed on a station’s profile page, but the App Clip requires a bit more work.

Adding an App Clip target to your project 

When we were considering ways to implement sharing stations via their profile websites on the eter.apparentsoft.com domain, we immediately had the idea to implement an App Clip, so that people who do not have Eter installed could open a universal link and listen to a given station. An App Clip would be a kind of a demo of the full application for them, which is why it is, to some extent, a mini version of Eter, with an available list of radio stations recommended by us.

Technically, an App Clip is a separate application. To add an App Clip to your project, you need to add it as a new target: File menu -> New -> Target.

Creating a mini version of Eter was not difficult. In the case of most files, we added AppClip to the Target Membership list of each of them. We only created a separate view of the simplified list, and added the aforementioned .onOpenURL modifier to the VStack in the body property of ContentView.

All that remains is to add the Associated Domains capability for the App Clip. The process is exactly the same as for the main application target. In the Signing & Capabilities tab in the project, click the + Capabilities button and add, this time only appclips:eter.apparentsoft.com.

And that’s pretty much it for the project configuration and code. The last part is App Store Connect. 

Configuring an App Clip in App Store Connect

First of all, you should push a new build of the app that includes the App Clip to App Store Connect.  It’s worth mentioning that the App Clip can be tested via TestFlight; however, it is tested as a separate build. This means that testers will test the main app and the App Clip separately. 

When you are ready to submit the app, and when you choose the build that includes the App Clip in the submission form, you’ll see a new App Clip section. You need to provide a header image that will be presented when the universal link opens and the main app is not installed, as well as providing a subtitle and an the action description (in case of Eter it is just “play”). 

With all of this accomplished, when the new build is accepted and released, your app will support universal links, deep links and the App Clip! 

Testing universal links and App Clips

When testing universal links, remember that they are supported by the default web browser in the system, both in iOS and macOS, but the link is not opened in the browser but a dedicated application opens the link instead (in our case, Eter). However, do not paste the universal link into the browser’s address bar. This will not work. We made this mistake ourselves at first and it seemed that universal links did not work. However, if you paste the link, for example, to the system Notes app and then tap (or click) on it, the universal link should work and the application associated with it should open.

We also noticed that universal links do not always work in the simulator or with an application built with Xcode. However, they do work with the test beta version downloaded from TestFlight.

On the other hand, we encountered no problems with testing deep links. These can be pasted into the browser’s address bar, which should result in opening the application associated with such a deep link. This should work in the simulator as well.

As for the App Clip, the app build containing it should be sent to App Store Connect. The App Clip itself is tested in TestFlight as a separate app. Keep in mind that the App Clip is ultimately a mini app temporarily installed ad hoc from the App Store — it is not part of the actual app.

We hope that in sharing our experience adding universal links, deep links, and App Clips to Eter: Streaming Internet Radio, you too can find interesting uses for these features and implement with a bit less trial and error than we encountered. For the ultimate programming productivity boost, we recommend streaming your favorite internet radio station as you work. And, hey, we have an app for that!

How to open document in “Untitled” window instead of new one

Have you noticed how TextEdit or TextMate will open an existing file in the “Untitled” window, if it is empty? I thought this was a default behaviour of the multi-document architecture of AppKit. While writing multi-document support for ImageFramer 3 (ImageFramer 2 is a single-document application) I noticed that it always opens a document in a new window, even if I have not touched the default empty document.

After some research I’ve found the following facts:

  • NSDocumentController always opens documents in a new window by default
  • TextEdit uses a lot of custom code to manage this behavior. TextEdit is an open sourced examples which is installed with Xcode, at /Developer/Examples/TextEdit/. I didn’t want such a complicated solution for myself.

I ended up implementing my own solution for my case. It might not suit your needs but could help choose the correct path for you.

My solution maybe unique to me since ImageFramer’s document are images and not some custom format. In ImageFramer version 3 I’ll be saving the framing designs in Core Data inside the application, so they’re not regular documents by default.

I have a method in my NSDocument subclass that loads the image into the already existing document. So the image is not read in - (BOOL)readFromURL:ofType:error: but rather at a later stage, in - (void)windowControllerDidLoadNib: (NSWindowController *)aController method. There I check [self fileURL]. If it’s not nil, then I import the image into the document. If it’s nil, I load the default one and it becomes the “Untitled” document.

So what did I do to open images in the same window instead of the default one? I had to make changes in 2 classes:

1. In my NSDocument subclass I setup a method isDefaultImage which does what its name implies. I then set it to YES if I load the default image file. In general case you could name it
- (BOOL)isReplaceableDocument and handle all logic there. For example, you might want to return NO here if user has modified the document and you don’t want him to loose his changes.

2. In NSDocumentController subclass, I overloaded openDocumentWithContentsOfURL:display:error:. Here’s the whole method:

- (id)openDocumentWithContentsOfURL:(NSURL *)absoluteURL display:(BOOL)displayDocument error:(NSError **)outError
{
    if (![[self currentDocument] isDefaultImage])
        return [super openDocumentWithContentsOfURL:absoluteURL display:displayDocument error:outError];
    else {
        [[self currentDocument] importImage:absoluteURL];
        [[self currentDocument] setFileURL:absoluteURL];
        return [self currentDocument];
    }
}

So, if I can’t replace to document, I just call super and return its result. If I can replace, I ask the current document to import the image, replacing my placeholder image. Then I set document’s URL to the provided one, so that it won’t stay as Untitled and will place the icon on the titlebar of the window.

That’s all.

Granted, in TextEdit example they take a more complex path, which probably may be better for them. From what I gathered, they create a new document manually and then copy the window controller from the Untitled document (they call it transient) to the new document. There’s more code there and you probably should take a look there to see if it suits you better.

DMG for distribution incompatibilty on Snow Leopard?

Do you use DMG to distribute your software for download?
Do you create it using a script, by creating a template DMG and then replacing its content upon release?

If you do, which I fully endorse, and you’ve created your template in Finder on Snow Leopard, read below.

In the last couple of days before the ininitial release of Cashculator to the public I’ve been struggling with the DMG creating process. I thought I’d already mastered it. After all, I’ve been already doing it with ImageFramer. So I prepared my template, copied the .DS_Store file from it (I used this process with ImageFramer) and script handled it all fine for me. I look at the final opened DMG and I see the following image (which is what I indended it to be):


Cashculator DMG background

Cashculator DMG background

I send it to the server and let my partner Kosta check it on his machine. He’s using Leopard and not the Snowy kind. He send me back the following image, which is far from what I thought it to be:


Cashculator DMG on Leopard

Cashculator DMG on Leopard

Not good. So I tried this way and that way. I even moved to another system, where I first create the DMG and use  the DMG itself as the template, without exracting .DS_Store first. Nothing helped on Leopard.

So we met and he brought the Leopard machine with him. I open the DMG, press Cmd-J and see that Finder thinks there’s no backgrond image and icon sizes are different.

After some research I came to the conclusion that DMGs created on Snow Leopard don’t show the same at all on Leopard. Frightened, I also tested my ImageFramer releases, which also sport a new background since the release of Snow Leopard. To my shock, it was also totally wrong on Leopard. That’s not how I wanted to convey the first impression of ImageFramer to potential customers.

The solution, of course, was to create the DMG on Leopard and use it as template instead. I ran the new template through my scripts and reopened the final DMG on Leopard to check. It was fine. Finally.

Today I did the same with ImageFramer‘s DMG. For some reason, the one from Leopard showed without background on my Snow Leopard machine. I only added the background again and saved the DMG. It worked fine on Leopard too.

That’s it. So, if you use a similar technique for creating DMG, check them on Leopard and on Snow Leopard before shipping to avoid later embarrassment.