Universal Links News
iOS 13, iOS 14 Universal Links News & Setting Up a Local Testing Environment
Photo by NASA
Introduction
For a service that has both a website and an app, the functionality of Universal Links is extremely important 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 it 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 fully implement the website’s functionality. Universal Links recognize the domain name, and as long as the domain matches, it will open the app. To address this issue, you can use NOT to exclude URLs that do not have corresponding functionality in the app. If the website service URLs are very extreme, it might be better to create a new subdomain for Universal Links.
When does apple-app-site-association update?
- iOS < 14, the app will query the Universal Links website’s apple-app-site-association upon first installation or update.
- iOS ≥ 14, Apple CDN caches and periodically updates the Universal Links website’s apple-app-site-association. The app will fetch it from Apple CDN upon first installation or update. However, there is a problem here: the apple-app-site-association on Apple CDN might still be outdated.
Regarding Apple CDN’s update mechanism, I checked the documentation, and it was not mentioned. I also checked discussions, and the official response was “it will be updated periodically,” with details to be published in the documentation… but nothing has been seen so far.
I personally think it should be updated within 48 hours at the latest. So, if there are any changes to apple-app-site-association, it is recommended to update it a few days before the app 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 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 issue, how do we debug during the development phase?
Fortunately, Apple has provided a solution for this part; otherwise, not being able to update in real-time would be frustrating. We just need to add ?mode=developer
to applinks:domain.com
. Additionally, there are managed (for enterprise internal apps)
, or developer+managed
modes that can be set.
After adding mode=developer, the APP will directly fetch the latest app-site-association from the website every time it is built and run on the simulator.
If you want to build and run on a real device, you need to go to “Settings” -> “Developer” -> turn on the “Associated Domains Development” option.
⚠️ Here’s a pitfall: app-site-association can be placed in the root directory of the website or in the
./.well-known
directory; but in mode=developer, it only looks for./.well-known/app-site-association
, which made me think it wasn’t working.
Development Testing
If it is iOS <14, remember to delete and rebuild & run the APP to fetch the latest app-site-association if it has been changed. For iOS ≥ 14, refer to the aforementioned method and add mode=developer.
For modifying the content of app-site-association, ideally, you can modify the files on the server yourself; but for those of us who sometimes can’t access the server, testing universal links can be very troublesome. You have to keep bothering the backend colleagues for help, making sure the app-site-association content is correct before going live. Constant changes can drive colleagues crazy.
Set up a local simulation environment
To solve the above problem, we can set up a small service locally.
First, install nginx on 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 in location / to the directory you want (using Documents as an example here).
listen on 8080 port, if there is no conflict, no need to modify.
After saving the modifications, start nginx with the command:
1
nginx
To stop it, use:
1
nginx -s stop
To stop.
If you have modified nginx.conf
, remember to use:
1
nginx -s reload
To restart the service.
Create a ./.well-known
directory in the root directory you just set, and place the apple-app-site-association
file in ./.well-known
.
⚠️ If
.well-known
disappears after creation, make sure to enable “Show Hidden Files” on Mac:
In the terminal, use:
1
defaults write com.apple.finder AppleShowAllFiles TRUE
Then use killall finder to restart all finders.
⚠️
apple-app-site-association
may appear to have no extension, but it actually has a .json extension:
Right-click on the file -> “Get Info” -> “Name & Extension” -> check if there is an extension & you can also uncheck “Hide extension”.
After everything is set, open your browser and test the following link to see if the apple-app-site-association file can be downloaded correctly:
1
http://localhost:8080/.well-known/apple-app-site-association
If it can be downloaded correctly, it means the local environment simulation is successful!
If a 404/403 error occurs, please check if the root directory is correct, if the directory/file is placed correctly, and if the apple-app-site-association file accidentally has an extension (e.g., .json).
Register & Download Ngrok
Unzip the ngrok executable
Go to the Dashboard page to execute Config settings
1
./ngrok authtoken yourTOKEN
After setting it up, run:
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, and we can get the public URL assigned this time from Forwarding.
⚠️ The URL assigned each time will change, so it can only be used for development testing.
Here we use the URL assigned this time
https://ec87f78bec0f.ngrok.io/
as an example
Go back to the browser and enter https://ec87f78bec0f.ngrok.io/.well-known/apple-app-site-association
to see if you can download and browse the apple-app-site-association file correctly. If there are no issues, you can proceed to the next step.
Enter the URL assigned by ngrok into the Associated Domains applinks: settings.
Remember to add ?mode=developer
to facilitate our testing.
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 effect.
If a 404 page appears, ignore it because we don’t actually have that page; we just want to test if iOS matches the URL as expected. If “Open” appears at the top, it means the match is successful. You can also test the NOT reverse situation.
Click “Open” to open the APP -> Test successful!
After all tests are OK during the development phase, submit the confirmed and modified apple-app-site-association file to the backend for uploading to the server to ensure everything is foolproof.
Finally, remember to change the Associated Domains applinks: to the official URL.
We can also see from the ngrok running status window whether the APP requests the apple-app-site-association file each time it builds and runs:
Applinks Configuration Content
iOS < 13 Before:
The configuration file is simpler, with only the following content to 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 part is the matching rules, supporting the following syntax:
*
: Matches 0~many characters, ex:/home/*
(home/alan…)?
: Matches 1 character, ex:201?
(2010~2019)?*
: Matches 1~many characters, ex:/?*
(/test, /home…)NOT
: Excludes, ex:NOT /help
(any URL but /help)
You can decide more combinations according to 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), Anchor (#title).
- URLs in Chinese must be converted to ASCII before being placed in paths (all URL characters must be ASCII).
iOS ≥ 13 After:
Enhanced the functionality of the configuration file, adding support for Query/Anchor, character sets, and encoding processing.
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 documentation, you can see the format has changed.
appIDs
is an array, allowing multiple appIDs to be included, 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.
Matching rules are now placed in components
; supporting 3 types:
/
: URL?
: Query, ex: ?name=123&place=tw#
: Anchor, ex: #title
They can also be used together. For example, if /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 is the same as the original syntax, also supporting *
?
?*
.
A new comment
field has been added for annotations to facilitate identification. (But please note this is public, others can see it too)
Reverse exclusion is now specified as exclude: true
.
A new caseSensitive
feature has been added, allowing you to specify whether the matching rules are case-sensitive, default: true
. This can reduce the number of rules you need to write if you have this requirement.
The new percentEncoded
parameter mentioned earlier means that in the old version, URLs needed to be converted to ASCII and placed in paths (if it was Chinese characters, it would become very ugly and unrecognizable); this parameter determines whether to automatically encode for us, default is true
. If it’s a Chinese URL, you can directly put it in (ex: /客服中心
).
For detailed official documentation, refer to this.
Default Character Sets:
This is one of the more 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 and 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 is multilingual and we want to support Universal links, we can set it like this:
1
2
3
"components": [
{ "/" : "/$(lang)-$(region)/$(food)/home" }
]
This way, whether it’s /zh-TW/home
or /en-US/home
, it can be supported, which is very convenient and saves you from writing a whole bunch of rules!
Custom Character Sets:
In addition to the default character sets, we can also customize character sets to increase configuration reuse and readability.
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 the example, a food
character set is customized and used in subsequent components
.
The above example can match /burrito
, /pizza
, /sushi
, /samosa
.
For details, refer to this official documentation.
No inspiration?
If you have no 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 settings.
For example: https://www.netflix.com/apple-app-site-association
Supplement
In the case of 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’s:
1
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool
Further Reading
- iOS Cross-Platform Account and Password Integration to Enhance Login Experience
- iOS Deferred Deep Link Implementation (Swift)
References
If you have any questions or comments, feel free to contact me.
===
===
This article was first published in Traditional Chinese on Medium ➡️ View Here