私が個人で運営している壁紙アプリで、共有リンクを踏いても一度もアプリが開かず、静かに Safari が立ち上がるだけになっていた時期があります。クラッシュもエラーログも出ません。ある日ユーザーからの問い合わせで気づき、原因を追うのに半日を溶かしました。
ユニバーサルリンク(iOS)と App Links(Android)は、うまく動いているときはただの URL に見えます。しかし裏側では、アプリのエンタイトルメント、OS が検証のために取りに行く関連付けファイル、そしてアプリ内のインテントフィルタという三つの独立した宣言が、寸分の狂いもなく一致していることが前提になっています。どれか一つがズレた瞬間、リンクは例外ではなく「ブラウザで開く」という一見正常な挙動に落ちます。これが厄介です。壊れても誰も気づかないのです。
ここでは、この沈黙する破綻を、宣言を人手で二重管理することをやめ、Antigravity のエージェントに週次と公開前の突き合わせを任せる設計として整理します。個人開発でリンクの保守に何度も時間を取られてきた私自身の経験から、実際に効いた形をまとめます。
なぜ黙って壊れるのか — 三つの宣言が別々に管理される
リンクが開くまでには、次の三者がそれぞれ別の場所で宣言されています。ズレやすいのは、これらが別々のリポジトリ・別々の担当・別々の変更タイミングで動くからです。
| 宣言 | 置き場所 | ズレる典型例 |
|---|---|---|
| 関連付けファイル(AASA / assetlinks.json) | Webサーバーの /.well-known/ | ドメイン移設・CDN のキャッシュ・Content-Type 誤り |
| アプリの宣言(Associated Domains / intent-filter) | エンタイトルメント・AndroidManifest | ドメイン追加漏れ・autoVerify 抜け・パス変更 |
| 署名情報(Androidのフィンガープリント) | assetlinks.json 内の SHA-256 | アップロード鍵の入れ替え・Play アプリ署名の証明書変更 |
とくにエージェントに UI やルーティングの改修を任せていると、新しい画面に対応する新しいパスがアプリ側に増えても、公開ファイルの更新が置き去りになりがちです。生成されたコードは正しく動いているのに、リンクだけが静かに外れる。この非対称が、放っておくと積もっていきます。
ルート定義を単一の真実源にする
対策の起点は、リンクのパス設計を三箇所に手書きするのをやめ、1 枚の定義ファイルに集約することです。ここから関連付けファイルを生成すれば、少なくともアプリと公開ファイルの食い違いは構造的に起きなくなります。
// link-routes.json — リンク設計の唯一の真実源
{
"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" }
]
}この 1 枚から、iOS の AASA と Android の assetlinks.json をコードで書き出します。手で JSON を編集させないことが肝心です。
// gen-association-files.mjs — 依存ゼロ。ルート定義から公開ファイルを生成
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 });
// 拡張子なし・Content-Type は 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}`);アプリ側の宣言も、同じ定義から確認できる状態にしておきます。iOS のエンタイトルメントと、Android のインテントフィルタは次の形です。
<!-- AndroidManifest.xml — 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>iOS 側は Associated Domains に applinks:example.com を宣言します。ここでドメインの綴りやサブドメインの有無が一文字でも違えば、OS は関連付けファイルを取りに行かず、リンクは静かにブラウザへ落ちます。 ここでの注意点は、綴りの一文字違いがエラーとして表面化せず、アプリ側の対処だけでは気づけないことです。