For a while, one of the wallpaper apps I run on my own had a broken share link that never once opened the app. Tapping it just quietly launched Safari. No crash, no error log. I only found out through a user's message, and I burned half a day tracing it.
Universal Links on iOS and App Links on Android look like ordinary URLs when they work. Behind them, though, sit three independent declarations that must match exactly: the app's entitlements, the association file the OS fetches to verify ownership, and the app's own intent filters. The moment any one of them drifts, the link doesn't throw an exception — it falls back to "open in the browser," which looks perfectly normal. That is what makes this dangerous. When it breaks, nobody notices.
This article lays out that silent failure as a design problem: stop maintaining the declarations twice by hand, and hand the reconciliation — weekly and before every release — to an Antigravity agent.
Why it breaks silently — three declarations, three owners
Before a link can open, these three things are each declared in a different place. They drift because they live in different repositories, are owned by different people, and change at different times.
| Declaration | Location | Typical way it drifts |
|---|---|---|
| Association file (AASA / assetlinks.json) | Web server /.well-known/ | Domain move, CDN caching, wrong Content-Type |
| App declaration (Associated Domains / intent-filter) | Entitlements, AndroidManifest | Missing domain, missing autoVerify, path change |
| Signing info (Android fingerprint) | SHA-256 inside assetlinks.json | Upload key swap, Play App Signing certificate change |
This gets worse when you let an agent reshape UI and routing. A new screen brings a new path in the app, but the published file update gets left behind. The generated code runs correctly, yet the link quietly detaches. Left alone, that asymmetry accumulates.
Make the route definition a single source of truth
The fix starts by refusing to hand-write link paths in three places. Collapse them into one definition file and generate the association files from it. At minimum, the app and the published files can no longer disagree by construction.
// link-routes.json — the one source of truth for link design
{
"domain": "example.com",
"ios": {
"appID": "TEAMID123.com.example.wallpaper"
},
"android": {
"package": "com.example.wallpaper",
"sha256": ["AA:BB:CC:...:99"]
},
"routes": [
{ "id": "collection", "path": "/c/*", "screen": "CollectionScreen" },
{ "id": "wallpaper", "path": "/w/*", "screen": "WallpaperScreen" },
{ "id": "invite", "path": "/invite/*", "screen": "InviteScreen" }
]
}From this one file, write out the iOS AASA and the Android assetlinks.json in code. The point is to never let anyone hand-edit the JSON.
// gen-association-files.mjs — zero dependencies; generate published files from routes
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
const cfg = JSON.parse(readFileSync("link-routes.json", "utf8"));
// iOS: apple-app-site-association
const aasa = {
applinks: {
apps: [],
details: [
{
appID: cfg.ios.appID,
paths: cfg.routes.map((r) => r.path),
},
],
},
};
// Android: Digital Asset Links
const assetlinks = [
{
relation: ["delegate_permission/common.handle_all_urls"],
target: {
namespace: "android_app",
package_name: cfg.android.package,
sha256_cert_fingerprints: cfg.android.sha256,
},
},
];
mkdirSync("public/.well-known", { recursive: true });
// no extension, served as application/json
writeFileSync("public/.well-known/apple-app-site-association", JSON.stringify(aasa, null, 2));
writeFileSync("public/.well-known/assetlinks.json", JSON.stringify(assetlinks, null, 2));
console.log(`generated ${cfg.routes.length} routes for ${cfg.domain}`);Keep the app-side declarations verifiable against the same definition. The iOS entitlement and the Android intent filter look like this:
<!-- AndroidManifest.xml — always include autoVerify -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="example.com" />
<data android:pathPrefix="/c/" />
<data android:pathPrefix="/w/" />
<data android:pathPrefix="/invite/" />
</intent-filter>On iOS you declare applinks:example.com under Associated Domains. If a single character of the domain or a missing subdomain is off here, the OS never fetches the association file, and the link falls silently back to the browser.