Post

Universal Links Optimization|Boost iOS 13 & 14 Integration with Local Testing Setup

iOS developers struggle with Universal Links testing and integration; this guide delivers a local testing environment setup that streamlines debugging and ensures reliable link handling for iOS 13 and 14, improving app-user engagement and reducing deployment errors.

Universal Links Optimization|Boost iOS 13 & 14 Integration with Local Testing Setup

点击这里查看本文章简体中文版本。

點擊這裡查看本文章正體中文版本。

This post was translated with AI assistance — let me know if anything sounds off!


iOS 13, iOS 14 Universal Links Updates & Setting Up a Local Testing Environment

Photo by [NASA](https://unsplash.com/@nasa?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by NASA

Preface

For a service with both a website and an app, the functionality of Universal Links is crucial for user experience, enabling seamless integration between the web and the app. However, it has always been set up simply, without much detail. Recently, I spent some time researching it again and recorded some interesting findings.

Common Considerations

For the services I’ve handled, the consideration for implementing Universal Links is that the app does not have a fully functional website. Universal Links recognize the domain name, and as long as the domain matches, the app will open. To address this issue, you can use NOT to exclude URLs that do not have corresponding features in the app. If the website URLs are very specific, it’s better to create a new subdomain specifically for Universal Links.

When is apple-app-site-association updated?

  • For iOS < 14, the app requests the apple-app-site-association file from the Universal Links website during the first install or update.

  • For iOS ≥ 14, Apple CDN caches and periodically updates the apple-app-site-association file for Universal Links websites; the app fetches it from Apple CDN during the first install or update. However, this can cause an issue where the apple-app-site-association on Apple CDN might still be outdated.

Regarding the Apple CDN update mechanism, I checked the documentation but found no mention; I also looked at the discussion, where the official reply was only “updates regularly” and that details would be published in the documentation later… but so far, nothing has appeared.

I personally think it should update within 48 hours at the latest… So next time you make changes to the apple-app-site-association, it’s recommended to update it online a few days before the app release or update.

apple-app-site-association Apple CDN Verification:

1
2
Headers: HOST=app-site-association.cdn-apple.com
GET https://app-site-association.cdn-apple.com/a/v1/your-domain

You can get the current version on Apple CDN. (Remember to add the Request Header Host=https://app-site-association.cdn-apple.com/)

iOS ≥ 14 Debug

Due to the aforementioned CDN issues, how should we debug during the development phase?

Fortunately, Apple provides a solution for this part; otherwise, real-time updates would be frustrating. We just need to add ?mode=developer after applinks:domain.com. Additionally, there are managed (for enterprise internal apps) and developer+managed modes available for configuration.

After adding mode=developer, the app will fetch the latest app-site-association from the website every time it builds and runs on the simulator.

To Build & Run on a real device, go to “Settings” -> “Developer” -> enable the “Associated Domains Development” option first.

⚠️ Here is a pitfall: app-site-association can be placed in the website root directory or the ./.well-known directory; however, in mode=developer, it only checks ./.well-known/app-site-association, which made me think it wasn’t working.

Development Testing

If you are using iOS <14 and have modified the app-site-association file, remember to delete it before rebuilding and running the app to fetch the latest version. For iOS ≥ 14, please refer to the previous method and add mode=developer.

Modifying the app-site-association content is better done by editing the file directly on the server; however, for those of us who sometimes can’t access the server, testing universal links becomes very troublesome. We have to keep bothering backend colleagues for help, so we must be sure about the app-site-association content before going live. Constant changes will drive our colleagues crazy.

Build a Local Simulation Environment

To solve the above problem, we can start a small local service.

First, install nginx on your Mac:

1
brew install nginx

If you haven’t installed brew before, you can install it first:

1
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

After installing nginx, go to /usr/local/etc/nginx/ and open the nginx.conf file for editing:

1
2
3
4
5
6
7
8
9
10
11
...omitted
server {
        listen       8080;
        server_name  localhost;
#charset koi8-r;
#access_log  logs/host.access.log  main;
location / {
            root   /Users/zhgchgli/Documents;
            index  index.html index.htm;
        }
...omitted

Around line 44, change the root inside location / to your desired directory (here, Documents is used as an example).

listen on 8080 port, no need to change if there is no conflict.

After saving the changes, run the command to start nginx:

1
nginx

To stop, enter:

1
nginx -s stop

Stop.

If you make changes to nginx.conf, remember to run:

1
nginx -s reload

Reactivate the service.

Create a ./.well-known directory inside the newly set root directory, and place the apple-app-site-association file inside ./.well-known.

⚠️ If the .well-known folder disappears after creation, please make sure to enable the “Show Hidden Files” feature on your Mac:

In the terminal:

1
defaults write com.apple.finder AppleShowAllFiles TRUE

Then run killall finder to restart all Finder processes.

⚠️ apple-app-site-association seems to have no file extension, but it actually has a .json extension:

Right-click on the file -> “Get Info” -> “Name & Extension” -> Check if there is a file extension & uncheck “Hide extension” if needed

After confirming no issues, open the browser to test if the following link downloads the apple-app-site-association file correctly:

1
http://localhost:8080/.well-known/apple-app-site-association

If the download works properly, it means the local environment simulation was successful!

If a 404/403 error occurs, please check if the root directory is correct, if the directory/file is properly placed, and whether the apple-app-site-association file accidentally has an extension (e.g., .json).

Register & Download Ngrok

[ngrok.com](https://dashboard.ngrok.com/get-started/setup){:target="_blank"}

ngrok.com

Extract the ngrok executable

Unzip the ngrok executable file

Go to the [Dashboard page](https://dashboard.ngrok.com/get-started/setup){:target="_blank"} to perform Config settings

Go to the Dashboard page to perform the Config setup.

1
./ngrok authtoken your TOKEN

After setting up, enter:

1
./ngrok http 8080

Because our nginx is on port 8080.

Start the service.

At this point, we will see a service startup status window, where the public URL assigned this time can be obtained from Forwarding.

⚠️ The assigned URL changes with each startup, so it can only be used for development testing.

Here, the allocated URL for this example is https://ec87f78bec0f.ngrok.io/

Return to the browser and enter https://ec87f78bec0f.ngrok.io/.well-known/apple-app-site-association to see if the apple-app-site-association file can be downloaded and viewed properly. If there are no issues, you can proceed to the next step.

Enter the URL assigned by ngrok into the Associated Domains applinks: setting.

Remember to include ?mode=developer for our testing convenience.

Rebuild & Run APP:

Open the browser and enter the corresponding Universal Links test URL (e.g., https://ec87f78bec0f.ngrok.io/buy/123) to check the result.

If the page shows a 404, ignore it because we don’t actually have that page; we are just testing whether iOS URL matching works as expected. If “Open” appears above, it means the match is successful. You can also test the NOT inverse condition.

Click “Open” to launch the app -> Test successful!

After successful testing in the development phase, confirm the modified apple-app-site-association file and provide it to the backend team to upload to the server to ensure everything works perfectly.

Finally, remember to change the Associated Domains applinks: to the official testing website URL.

We can also check from the ngrok status window whether each APP Build & Run requests the apple-app-site-association file:

iOS < 13 before:

The configuration file is simpler, with only the following settings available:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
  "applinks": {
      "apps": [],
      "details": [
           {
             "appID" : "TeamID.BundleID",
             "paths": [
               "NOT /help/",
               "*"
             ]
           }
       ]
   }
}

Replace TeamID.BundleId with your project settings (e.g., TeamID = ABCD, BundleID = li.zhgchg.demoapp => ABCD.li.zhgchg.demoapp).

If there are multiple appIDs, add multiple sets accordingly.

The paths section contains matching rules and supports the following syntax:

  • *: Matches 0 or more characters, e.g., /home/* (home/alan…)

  • ?: Matches 1 character, e.g., 201? (2010~2019)

  • ?*: Matches 1 or more characters, e.g., /?* (/test, /home, etc.)

  • NOT: Excludes, e.g., NOT /help (any URL except /help)

You can create more combinations based on your actual situation. For more information, please refer to the official documentation.

- Note, it is not Regex and does not support any Regex syntax.

- The old version does not support Query (?name=123) or Anchor (#title).

- Chinese URLs must be converted to ASCII before placing them in paths (all URL characters must be ASCII).

iOS ≥ 13 and later:

Enhanced the profile content features, adding more support for Query/Anchor, character sets, and encoding handling.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
"applinks": {
  "details": [
    {
      "appIDs": [ "TeamID.BundleID" ],
      "components": [
        {
          "#": "no_universal_links",
          "exclude": true,
          "comment": "Matches any URL whose fragment equals no_universal_links and instructs the system not to open it as a universal link"
        },
        {
          "/": "/buy/*",
          "comment": "Matches any URL whose path starts with /buy/"
        },
        {
          "/": "/help/website/*",
          "exclude": true,
          "comment": "Matches any URL whose path starts with /help/website/ and instructs the system not to open it as a universal link"
        },
        {
          "/": "/help/*",
          "?": { "articleNumber": "????" },
          "comment": "Matches any URL whose path starts with /help/ and that has a query item with name 'articleNumber' and a value of exactly 4 characters"
        }
      ]
    }
  ]
}

Reposted from the official document, you can see that the format has changed.

appIDs is an array that can hold multiple appIDs, so you no longer need to repeat the entire block as before.

WWDC mentioned backward compatibility: when iOS ≥ 13 reads the new format, it will ignore the old paths.

Matching rules are moved to components; supports 3 types:

  • / : URL

  • ? : Query, ex: ?name=123&place=tw

  • # : Anchor, ex: #title

And it can be used together. For example, if only /user/?id=100#detail needs to jump to the APP, you can write:

1
2
3
4
5
{
  "/": "/user/*",
  "?": { "id": "*" },
  "#": "detail"
}

The matching syntax is the same as the original syntax and also supports * ? ?*.

Add a comment field for notes to help identification. (Note this is public and visible to others)

To exclude in reverse, specify exclude: true.

Added caseSensitive option to specify whether matching rules are case-sensitive. Default: true. This can reduce the number of rules needed if enabled.

Added percentEncoded as mentioned earlier. In the old version, URLs had to be converted to ASCII and placed in paths first (Chinese characters would look ugly and unreadable). This parameter decides whether to automatically encode for us, with the default set to true.
If the URL contains Chinese characters, it can be directly used (e.g., /客服中心).

Detailed official documentation can be found here.

Default Character Set:

This is one of the important features in this update, adding support for character sets.

Character sets defined by the system for us:

  • $(alpha) :A-Z and a-z

  • $(upper) :A-Z

  • $(lower) : a-z

  • $(alnum) :A-Z, a-z, and 0–9

  • $(digit) :0–9

  • $(xdigit) : hexadecimal characters, 0–9 and a,b,c,d,e,f,A,B,C,D,E,F

  • $(region) :ISO region code isoRegionCodes, Ex: TW

  • $(lang) :ISO language code isoLanguageCodes, Ex: zh

Assuming our website supports multiple languages, we can set up Universal Links like this:

1
2
3
"components": [        
     { "/" : "/$(lang)-$(region)/$(food)/home" }      
]

This way, both /zh-TW/home and /en-US/home are supported, which is very convenient and saves you from writing a whole set of rules yourself!

Custom Character Sets:

In addition to the default character set, we can also customize character sets to enhance profile reuse and readability.

Add substitutionVariables in applinks as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "applinks": {
    "substitutionVariables": {
      "food": [ "burrito", "pizza", "sushi", "samosa" ]
    },
    "details": [{
      "appIDs": [ ... ],
      "components": [
        { "/" : "/$(food)/" }
      ]
    }]
  }
}

The example defines a custom food character set and uses it later in the components.

The above example matches /burrito, /pizza, /sushi, /samosa.

For details, refer to the official document here.

No Inspiration?

If you have no ideas for the configuration file content, you can secretly refer to other websites’ files by adding /app-site-association or /.well-known/app-site-association to the homepage URL of the service website to access their configuration.

For example: https://www.netflix.com/apple-app-site-association

Supplement

When using SceneDelegate, the entry point for opening a universal link is in the SceneDelegate:

1
func scene(_ scene: UIScene, continue userActivity: NSUserActivity)

Instead of AppDelegate:

1
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool

Further Reading

References

If you have any questions or feedback, feel free to contact me .


Buy me a beer

This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.