What’s New with Universal Links
iOS 13, iOS 14 What’s New with Universal Links & Setting Up a Local Testing Environment
Photo by NASA
Preface
For a service that has both a website and an app, the functionality of Universal Links is crucial for user experience, achieving seamless integration between the web and the app. However, it has always been set up simply without much emphasis. Recently, I spent some time researching and documenting some interesting things.
Common Considerations
In services I have worked on, the consideration for implementing Universal Links is that the app does not have complete website functionality. Universal Links recognize the domain name, and as long as the domain name matches, the app will open. To address this issue, you can exclude URLs on the app that do not have corresponding functionality on the website. If the website service URLs are very specific, it may be better to create a new subdomain for Universal Links.
When does apple-app-site-association update?
- For iOS < 14, the app will query the apple-app-site-association of the Universal Links website during the first installation or update.
- For iOS ≥ 14, Apple CDN caches and periodically updates the apple-app-site-association of the Universal Links website. The app will fetch it from Apple CDN during the first installation or update. However, there may be a problem here as the apple-app-site-association on Apple CDN may still be outdated.
Regarding the update mechanism of Apple CDN, after checking the documentation, there is no mention of it. In a discussion, the official response was only “regular updates” with details to be released in the documentation… but I have not seen it yet.
I personally think it should be updated at least every 48 hours… so if you make changes to apple-app-site-association, it is recommended to update it online a few days before the app update is released.
apple-app-site-association Apple CDN Confirmation:
1
2
Headers: HOST=app-site-association.cdn-apple.com
GET https://app-site-association.cdn-apple.com/a/v1/your-domain
You can see the current version on Apple CDN. (Remember to add Request Header Host=https://app-site-association.cdn-apple.com/
)
iOS ≥ 14 Debug
Due to the aforementioned CDN issue, how can we debug during the development phase?
Fortunately, Apple provides a solution for this part, otherwise it would be really frustrating not being able to update in real-time; we just need to add ?mode=developer
after applinks:domain.com
, and there are also managed (for enterprise internal APP)
or developer+managed
modes that can be set.
After adding mode=developer
, the app will fetch the latest app-site-association directly from the website every time you Build & Run on the simulator.
If you want to Build & Run on a real device, you need to go to “Settings” -> “Developer” -> enable the “Associated Domains Development” option.
⚠️ There is a pitfall here, app-site-association can be placed in the root directory of the website or in the
./.well-known
directory; but inmode=developer
, it will only look for./.well-known/app-site-association
, which made me think it wasn’t working.
Development Testing
If you are using iOS <14, remember that if you have made changes to app-site-association, you need to delete it and then Build & Run the app again to fetch the latest one. For iOS ≥ 14, please refer to the aforementioned method and add mode=developer
.
For better modification of the app-site-association content, you can modify the file on the server yourself. However, for those of us who sometimes cannot access the server side, testing universal links can be very troublesome. You have to constantly bother backend colleagues for help, and it becomes necessary to be very certain about the app-site-association content before going live, as constantly changing it can drive your colleagues crazy.
Setting up a Local Simulation Environment
To solve the above problem, we can set up a small service locally.
First, install nginx on your Mac:
1
brew install nginx
If you haven’t installed brew yet, you can do so by running:
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/yourusername/Documents;
index index.html index.htm;
}
...omitted
Around line 44, change the root
in the location /
to the directory you want (using Documents as an example here).
Listening on port 8080, no need to change if there are no conflicts.
Save the changes, then start nginx by running:
1
nginx
To stop it, run:
1
nginx -s stop
If you make changes to nginx.conf
, remember to run:
1
nginx -s reload
to restart the service.
Create a ./.well-known
directory inside the root
directory you just configured, and place the apple-app-site-association
file inside ./.well-known
.
⚠️ If
.well-known
disappears after creation, please note that on Mac, you need to enable “Show hidden files” feature:
In the terminal, run:
1
defaults write com.apple.finder AppleShowAllFiles TRUE
Then run killall finder
to restart all finders.
⚠️ The
apple-app-site-association
file may not have an extension, but it actually has the.json
extension:
Right-click on the file -> “Get Info” -> “Name & Extension” -> Check for the extension and uncheck “Hide extension” if necessary.
Once confirmed, open the browser to test if the following link can be downloaded successfully: apple-app-site-association at:
1
http://localhost:8080/.well-known/apple-app-site-association
If the download is successful, it means the local environment simulation is successful!
If you encounter a 404/403 error, please check if the root directory is correct, if the directory/file is placed correctly, and if the apple-app-site-association file accidentally includes the extension (
.json
).
Register & Download Ngrok
Extract the ngrok executable
Access the Dashboard page to execute Config settings
1
./ngrok authtoken YOUR_TOKEN
After setting up, run:
1
./ngrok http 8080
Because our nginx is on port 8080.
Start the service.
At this point, you will see a window showing the status of the service startup, and you can obtain the public URL assigned for this session from the Forwarding section.
⚠️ The assigned URL changes every time you start, so it can only be used for development testing purposes.
Here, we will use the assigned URL for this session
https://ec87f78bec0f.ngrok.io/
_as an example.
Return to the browser and enter https://ec87f78bec0f.ngrok.io/.well-known/apple-app-site-association
to see if you can successfully download and view the apple-app-site-association file. If everything is fine, you can proceed to the next step.
Enter the ngrok-assigned URL into the Associated Domains applinks: settings.
Remember to add ?mode=developer
for testing purposes.
Rebuild & Run the APP:
Open the browser and enter the corresponding Universal Links test URL (e.g., https://ec87f78bec0f.ngrok.io/buy/123
) to see the results.
If a 404 page appears, ignore it as we don’t actually have that page. We are testing if iOS matches the URL functionality as expected. If you see “Open” above, it means the match is successful. You can also test the reverse scenario.
Click “Open” to open the APP -> Test successful!
After testing OK in the development phase, confirming the modified apple-app-site-association file and handing it over to the backend for uploading to the server can ensure everything goes smoothly~
Finally, remember to change the Associated Domains applinks to the correct trial site URL.
Additionally, we can also check whether the apple-app-site-association file is requested each time the APP Build & Run is executed from the ngrok status window:
Applinks Configuration
Before iOS < 13:
The configuration file is relatively simple, and only the following content can be set:
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 (ex: TeamID = ABCD
, BundleID = li.zhgchg.demoapp
=> ABCD.li.zhgchg.demoapp
).
If there are multiple appIDs, you need to add multiple sets.
The paths
section represents the matching rules, supporting the following syntax:
*
: Matches 0 to multiple characters, ex:/home/*
(home/alan…)?
: Matches 1 character, ex:201?
(2010~2019)?*
: Matches 1 to multiple characters, ex:/?*
(/test, /home…)NOT
: Excludes in reverse, ex:NOT /help
(any URL but /help)
You can decide on more combinations based on the actual situation, for more information, refer to the official documentation.
- Please note, it is not Regex and does not support any Regex syntax. - The old version does not support Query (?name=123) and Anchor (#title). - Chinese URLs must be converted to ASCII before being placed in paths (all URL characters must be ASCII).
After iOS ≥ 13:
The functionality of the configuration file has been enhanced, with added 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"
}
]
}
]
}
Copied content:
appIDs
is an array that can contain multiple appIDs, so you don’t have to repeat the entire block as before.
WWDC mentioned compatibility with the old version, when iOS ≥ 13 reads the new format, it will ignore the old paths.
The matching rules are now placed in components
; supporting 3 types:
/
: URL?
: Query, ex: ?name=123&place=tw#
: Anchor, ex: #title
They can be used together. For example, if only /user/?id=100#detail
needs to jump to the app, it can be written as:
1
2
3
4
5
{
"/": "/user/*",
"?": { "id": "*" },
"#": "detail"
}
The matching syntax remains the same as the original syntax, also supporting *
, ?
, ?*
.
Added comment
field for comments to help identification. (But please note that this is public and visible to others)
Reverse exclusion is now specified with exclude: true
.
Added caseSensitive
feature to specify whether the matching rules are case-sensitive, default: true
. This can reduce the number of rules needed if required.
Added percentEncoded
as mentioned earlier, in the old version, URLs needed to be converted to ASCII and placed in paths first (if it’s Chinese characters, it will look ugly and unrecognizable); this parameter specifies whether to automatically encode for us, default is true
. If it’s a Chinese URL, it can be directly included (ex: /customer service
).
For detailed official documentation, refer to this.
Default character sets:
This is one of the important features of this update, adding support for character sets.
System-defined character sets:
$(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 codes isoRegionCodes, Ex: TW$(lang)
: ISO language codes isoLanguageCodes, Ex: zh
If our URL has multiple languages and we want to support Universal links, we can set it up like this:
1
2
3
"components": [
{ "/" : "/$(lang)-$(region)/$(food)/home" }
]
This way, both /zh-TW/home
and /en-US/home
will be supported, making it very convenient without having to write a long list of rules!
Custom character sets:
In addition to the default character sets, we can also define custom character sets for increased configurability and readability.
Simply add substitutionVariables
in applinks
:
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"applinks": {
"substitutionVariables": {
"food": [ "burrito", "pizza", "sushi", "samosa" ]
},
"details": [{
"appIDs": [ ... ],
"components": [
{ "/" : "/$(food)/" }
]
}]
}
}
In this example, a custom food
character set is defined and used in subsequent components
.
The example can match /burrito
, /pizza
, /sushi
, /samosa
.
For more details, refer to this article in the official documentation.
No inspiration?
If you don’t have any inspiration for the content of the configuration file, you can secretly refer to the content of other websites. Just add /app-site-association
or /.well-known/app-site-association
to the homepage URL of the service website to read their configuration.
For example: https://www.netflix.com/apple-app-site-association
Supplement
In the case of using SceneDelegate
, the entry point for opening universal links is in the SceneDelegate:
1
func scene(_ scene: UIScene, continue userActivity: NSUserActivity)
Instead of in AppDelegate:
1
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool
Further Reading
- iOS Cross-Platform Account Password Integration, Enhancing Login Experience
- iOS Deferred Deep Link Implementation (Swift)
References
If you have any questions or suggestions, feel free to contact me.
===
===
This article was first published in Traditional Chinese on Medium ➡️ View Here