Home / Blog

Universal Link はどのパスで開くのか — AASA マッチの落とし穴

#iOS#Universal Link#Deep Link#AASA

前回は Universal Link が開かない原因を、受け口から AASA まで疑う順に並べました。今回はその一段だけ深いところ、入力 URL が AASA のどのルールにマッチするのかを扱います。

きっかけは、検証アプリを実機に入れて試していて踏んだ違和感でした。

開くのに「扱うアプリはありません」

あるサービスのリンクを Safari で踏むと、アプリが開きます。UL は効いている。ところが同じドメインを AASA インスペクタで見ると「このURLを扱うアプリはありません」と出ました。

ツールのバグを疑いましたが、結論から言うとツールが正しく、こちらの読みが甘かった。順に見ていきます。

そのドメインの AASA(CDN 版)はこうなっていました。ドメイン名は伏せます。

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAMID.com.example.app",
        "paths": ["/app_store/", "/reserve/"]
      }
    ]
  }
}

そして私がインスペクタに入れていたのは、ドメインのルート https://example.com でした。ルートのパスは /。この AASA が主張しているパスは /app_store//reserve/ の 2 つだけで、/ はどちらにも当たりません。だから「対象外」で正しい。

ドメインが UL 対応 ≠ どの URL でも開く

ここが最初の思い込みでした。「このアプリは UL 対応している」を、つい「このドメインのどの URL でもアプリが開く」と読んでしまう。実際は違います。

UL が成立するのは、AASA が pathscomponents で明示的に claim したパスだけです。ルートも、claim されていない任意のページも、対応アプリが入っていても Safari に落ちます。claim されたパスの集合がすべてで、そこに無い URL は「UL ではない普通の https URL」として扱われます。

私が「開く」と思っていたリンクは、実際にはルートではなく /reserve/… のページ(かそこへのリダイレクト先)だったわけです。ルート直打ちでは、この AASA では開きません。

レガシー paths は完全一致

もう一段。上の AASA には、実は落とし穴がもう一つ隠れています。/reserve/ というパターンは、パスがちょうど /reserve/ のときだけマッチします。

paths のパターンは前方一致でも部分一致でもありません。*(0 文字以上)と ?(ちょうど 1 文字)を使ったグロブで、パス全体に対して評価されます。ワイルドカードが無ければ、書いた文字列と完全に一致したときだけ当たります。

パターン: /reserve/
  /reserve/        → マッチ
  /reserve/12345   → 非マッチ(末尾に * が無い)
  /reserve         → 非マッチ(末尾スラッシュまで含めて一致が要る)

つまりこの AASA は、/reserve/ ちょうどのページしか UL にしていません。/reserve/店舗ID/ のような実際の予約ページを開かせたいなら、"/reserve/*" と書く必要があります。意図してこうしているのか、* の付け忘れなのかは外からは判断できませんが、挙動は AASA に書いた通りにしか決まりません。ツールが「対象外」と言うのは、ここまで含めて正しかったわけです。

正規表現ではない点も注意です。.+ はメタ文字ではなくただの文字で、使えるのは *? だけ。レガシー paths が見るのはパスだけで、クエリやフラグメントは対象外、既定で大文字小文字を区別します。

components は「先頭一致」で決まる

モダンな components 形式は配列で、iOS は上から順に評価し、最初にマッチしたルールで結果を確定します(first match wins)。だから並び順が意味を持ちます。

典型は、除外を先に、包括を後に置く形です。

"components": [
  { "/": "/help/*", "exclude": true },
  { "/": "/*" }
]

/help/faq は最初の exclude ルールに当たって「このリンクは扱わない」で確定します。後ろの /* も形の上では /help/faq にマッチしますが、先頭一致で既に決着しているので効きません。もしこの 2 つを逆順に書くと、/help/* は前に来た /* に食われてしまい、除外したかったページでアプリが開きます。

components は path だけでなく ?(クエリ)や #(フラグメント)も条件にでき、指定したキーはすべて満たす必要があります。caseSensitive の既定は true です。前回触れた「components キーがあると、たとえ空配列でも paths は無視される」も、この先頭一致の延長にある挙動です。

「どのパターンで決まったか」を見る

マッチの有無だけ分かっても、なぜ非マッチなのかは分かりません。デバッグで欲しいのは、全パターンを並べて、どれがマッチし、どれが結果を決めたかです。

素の手段なら、AASA を CDN から取って(前回参照)、パスを目視で当てていきます。数が少なければこれで足りますが、components が長く first match の順番が絡むと、追うのがだんだん厳しくなります。

Schemely の AASA インスペクタは、details を開くと全パターンを列挙して、各行に マッチ / 非マッチ / 除外 と、先頭一致で結果を決めた「効いたルール」を出します。冒頭の例なら、/app_store/ 非マッチ・/reserve/ 非マッチ → どのルールも当たらない、が一目で分かります。「開くはずなのに」で止まったとき、思い込みとパターンのどちらがずれているかを、並べて確かめられます。

まとめ

  • ドメインが UL 対応でも、開くのは AASA が claim したパスだけ。ルート ≠ 対象
  • レガシー paths完全一致/reserve//reserve/123 に当たらない。サブパスまで開かせるなら * が要る。
  • components先頭一致(first match wins)。exclude は並び順で効き方が変わる。
  • 「開くのに開かない(逆も)」の多くは、pathscomponents のマッチを誤読しているだけ。全パターンを並べて、どれが効いたルールかを見れば早い。

AASA は「書いた通り」に淡々とマッチします。人間の期待とずれるのは、たいてい * 一つの有無か、並び順です。