ユニバーサルリンクをAzure BlobStorageでやってみる

# はじめに

.NET MAUIでiOS向けアプリを開発中，WebアプリからiOSアプリを呼び出す必要がでてきたため，ユニバーサルリンクを実装しました．その際にWebサーバーをAzure Blob Storageの静的サイトでできないかなと思いました．

# ユニバーサルリンクとは

ユニバーサルリンク（Universal Links）は，Webサイトへのリンクをクリックしたときに，そのリンクに対応するiOSアプリがインストールされていれば直接アプリを起動し，インストールされていなければWebサイトを表示するという機能です[^1]．

## 仕組み

1. WebサーバーにApple App Site Association （以下: AASA）ファイルを置いておきます
2. Apple CDNが定期的にWebサーバーのAASAファイルをクロールします
3. iPhoneが，アプリインストール時にAppleCDNからAASAファイルを取得します
4. Webサイト上で特定のURLを踏むとAASAファイルを使ってアプリを立ち上げる

簡単に図示すると以下のような感じになります．

```mermaid
graph TB;
    AppleCDN-->WebServer;  
    iPhone-->AppleCDN;
    iPhone-->Webサイト;
    iPhone-->iOSアプリ;
```

# WebサーバーをAzure Blob Storageでやってみる

上記の通り，AppleCDNがアクセスできるWebサーバを準備する必要があるのですが，今回はお手軽に(?)用意できるであろうAzure BlobStorageの静的サイト機能を使っていきたいと思います．

## モチベーション

Azure Storageで安価に静的サイトを公開できます[^2]．

UniversalLink用のAASAファイルを設置する目的でWebサーバが必要となるのですが，他の用途では使わないので，Azure Blob Storageの静的サイト機能で代替できそうだと思いました．

## Azure Storage構築

### ストレージアカウントの作成

まずはストレージアカウントを作成します．

ストレージアカウントは基本的にデフォルトで大丈夫だと思いますが，必要に応じて値を変更します．基本情報のプライマリサービスは 「Azure Blob Storage または Azure Data Lake Storage Gen 2」 とします．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/d59b19ba-5e12-4d5e-9402-f0dbd81ab126.png)

作ったものがこちらです。アカウントの種類が `StorageV2 (汎用 v2)`  であることを確認します（上位のものでも大丈夫です）．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/8ba45ada-d1df-4f64-81c5-0710bb24dc55.png)

### 静的サイトを有効化

概要の下の方の機能タブから静的Webサイトを選択します．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/93fdd362-5e37-4a5a-a6f2-99db5a056c84.png)

ここで有効化します．プライマリエンドポイントが今回のUniversalLinkのドメインになります．
`hogehoge.z11.web.core.windows.net` のような感じになると思います．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/4b0ee2ba-2b82-40bf-bac6-8ba942915f49.png)

※ カスタムドメインを使用することも可能ですが，今回はスキップします．

静的サイトを有効化すると，`$Web` フォルダができます．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/80fac73f-ffc6-45c1-94a5-8047587ffec8.png)

### AASAファイルを配置

AASAファイルを準備します[^3]．

内容は JSON ですが，ファイルに拡張子はつけないで保存します．ファイル名は `apple-app-site-association` です．

今回はリンクURLによる制限はつけずにどんなURLでも呼び出し可能とします．

```json:apple-app-site-association
{
  "webcredentials": {
    "apps": [
      "アプリのID"
    ]
  },
  "applinks": {
    "apps": [],
    "details": [
      {
        "appIDs": [
          "アプリのID"
        ],
        "components": [
          {
            "/": "*"
          }
        ]
      }
    ]
  }
}
```

`$Web` コンテナを開いてアップロードします．
右下のアップロード先のフォルダは `.well-known` にします．フォルダが存在しない場合でも，自動的に作成してくれます．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/aa36687c-3120-4048-8ac9-ff035600dabb.png)

アップロードしたファイルを確認します．
階層が `$Web/.well-known/apple-app-site-association` となっていればOKです．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/fc732214-7e6a-4d86-b204-ada56a2dbf85.png)

最後に `ContentType` を指定します．

ファイルをクリックするとプロパティが開くので編集します．
`application/json; charset=utf-8` にします．`application/json` は必須だと思います． charset=utf-8はなくても大丈夫ですが，念の為に指定します．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/ee38825f-c52f-4daf-80d0-177ddd70236d.png)

### $WebをPublic化

Apple CDNがアップロードしたAASAファイルを取得できるようにします． コンテナを選択してアクセスレベルを変更します． デフォルトだとプライベートになっていると思うので，コンテナしておきます．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/87e2be26-1aca-406e-9cea-e922c3a2dae4.png)

AASAファイルを確認します．

先程のプライマリエンドポイントに `.well-known/apple-app-site-association` つけてURLバーに入れて，AASAファイルが見えることを確認します．

`https://{ドメイン}/.well-known/apple-app-site-association`
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/7bcbdc9f-e3a3-4491-a2a0-25d855f3e774.png)

### Apple CDNを確認

アップロード後，しばらくするとAASAファイルをApple CDNが回収してくれます．
そのキャッシュを確認できます．

`https://app-site-association.cdn-apple.com/a/v1/{アップロードした値}.z11.web.core.windows.net`
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/7451cf90-95ce-4977-9fa3-8484bee761c6.png)

以上で環境構築は完了です．

## Mauiアプリの設定

MauiプロジェクトのPlatformフォルダのIOSフォルダ直下に `Entitlements.plist` を作成します．
内容は以下の通りです．

先程のプライマリエンドポイントのを記載します．「https://」は不要です．

```xml:Entitlements.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>com.apple.developer.associated-domains</key>
    <array>
      <string>applinks:hogehoge.z11.web.core.windows.net</string>
    </array>
  </dict>
</plist>
```

Entitlements.plistを作りたくない人はcsprjで設定することもできます．
MSドキュメントより引用[^4]

```xml .csproj
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios' Or $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">

    <!-- For debugging, use '?mode=developer' for debug to bypass apple's CDN cache -->
    <CustomEntitlements
        Condition="$(Configuration) == 'Debug'"
        Include="com.apple.developer.associated-domains"
        Type="StringArray"
        Value="applinks:hogehoge.z11.web.core.windows.net?mode=developer" />

    <!-- Non-debugging, use normal applinks:url value -->
    <CustomEntitlements
        Condition="$(Configuration) != 'Debug'"
        Include="com.apple.developer.associated-domains"
        Type="StringArray"
        Value="applinks:hogehoge.z11.web.core.windows.net" />

</ItemGroup>
```

※ `?mode=developer` をつけておくとApple CDNをバイパスして，直接WebサーバからAASAファイルを取得するようになります．

UniversalLinkで起動されたときのハンドラーを書きます．

MauiProgram.csを編集します。

MSドキュメントより引用[^4]

```cs:MauiProgram.cs
using Microsoft.Maui.LifecycleEvents;
using Microsoft.Extensions.Logging;

namespace MyNamespace;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            })
            .ConfigureLifecycleEvents(lifecycle =>
            {
#if IOS || MACCATALYST
                lifecycle.AddiOS(ios =>
                {
                    // Universal link delivered to FinishedLaunching after app launch.
                    ios.FinishedLaunching((app, data) => HandleAppLink(app.UserActivity));

                    // Universal link delivered to ContinueUserActivity when the app is running or suspended.
                    ios.ContinueUserActivity((app, userActivity, handler) => HandleAppLink(userActivity));

                    // Only required if using Scenes for multi-window support.
                    if (OperatingSystem.IsIOSVersionAtLeast(13) || OperatingSystem.IsMacCatalystVersionAtLeast(13))
                    {
                        // Universal link delivered to SceneWillConnect after app launch
                        ios.SceneWillConnect((scene, sceneSession, sceneConnectionOptions)
                            => HandleAppLink(sceneConnectionOptions.UserActivities.ToArray()
                                .FirstOrDefault(a => a.ActivityType == Foundation.NSUserActivityType.BrowsingWeb)));

                        // Universal link delivered to SceneContinueUserActivity when the app is running or suspended
                        ios.SceneContinueUserActivity((scene, userActivity) => HandleAppLink(userActivity));
                    }
                });
#endif
            });

#if DEBUG
        builder.Logging.AddDebug();
#endif

        return builder.Build();
    }

#if IOS || MACCATALYST
    static bool HandleAppLink(Foundation.NSUserActivity? userActivity)
    {
        if (userActivity is not null && userActivity.ActivityType == Foundation.NSUserActivityType.BrowsingWeb && userActivity.WebPageUrl is not null)
        {
            HandleAppLink(userActivity.WebPageUrl.ToString());
            return true;
        }
        return false;
    }
#endif

    static void HandleAppLink(string url)
    {
        if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri))
            App.Current?.SendOnAppLinkRequestReceived(uri);
    }
}
```

HandleAppLinkメソッドから．

AppクラスのOnAppLinkRequestReceivedを呼んでくれるのでOverrideすることで受け取ったURLのハンドルができます．
クエリパラメータもらうときなどはここで処理するとよいと思います．

```cs:App.xaml.cs
protected override void OnAppLinkRequestReceived(Uri uri)
{
   //uriの処理が必要であれば
}
```

Mauiアプリの修正は以上です．

## Apple DeveloperアカウントのアプリIDに、関連ドメインの使用を許可

UniversalLinkを使うにはApple上のアプリに使用を許可する必要があります．
手順は以下の通りです[^4]．

1. Web ブラウザで[Apple Developer アカウント](https://developer.apple.com/account/)にログインし、 「証明書、ID、およびプロファイル」ページに移動します
2. 「証明書、識別子、プロファイル」ページで、[識別子]タブを選択します
3. 「識別子」ページで、アプリに対応するアプリ ID を選択します
4. 「App ID 構成の編集」ページで、「関連ドメイン」機能を有効にし、「保存」ボタンを選択します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/de829d4c-5896-4821-9276-3588841044ba.png)
5. profileに反映させます．Editから先程設定したApp IDを選択します
Enabled CapacitiesにAssociated DomainがあればOKです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/bb7ba1e7-dabc-4ac6-80a7-cc2a70268ab7.png)
6. 最後にVisual Studioのプロファイルを更新します

# アプリをリリースする

あとはアプリをリリースするだけです．デバッグでも大丈夫です．

今回の場合のユニバーサルリンクのURLはブロブストレージのプライマリエンドポイントになります．
`https://hogehoge.z11.web.core.windows.net/`
これをWebサイトに埋め込んでおきます．

アプリがインストールされている状態であれば，iPhoneのメモ帳でも確認ができます．
リンク用のURLを打ち込んで長押しするとアプリを開くという選択肢がでてきます．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/4737c699-09a4-4141-8f42-91e9249d548f.png)

# 最後に

Azure Blob Storageの静的サイト機能で簡単にUniversalLink用のWebサーバを立ち上げられました．Azure環境がある場合は選択肢に入れてもいいかなと思います．

[^1]: [Allowing apps and websites to link to your content](https://developer.apple.com/documentation/xcode/allowing-apps-and-websites-to-link-to-your-content/)
[^2]: [Azure Storage での静的 Web サイト ホスティング](https://learn.microsoft.com/ja-jp/azure/storage/blobs/storage-blob-static-website?WT.mc_id=AZ-MVP-5002499)
[^3]: [Supporting associated domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains)
[^4]: [Apple universal links](https://learn.microsoft.com/ja-jp/dotnet/maui/macios/universal-links?view=net-maui-9.0)