記事一覧に戻る
PWAをネイティブアプリ化したら
OAuthで詰んだ話

PWAをネイティブアプリ化したら OAuthで詰んだ話

Webエンジニアが初めてCapacitorでiOS/Androidアプリを作ろうとして、OAuth認証で無限ループに陥った話。Universal Linksの罠、Apple Developer Programの壁、そしてSessionStorageでの解決まで。

Webしか触ったことがない自分が、「PWAをネイティブアプリにしたい」と思い立ってCapacitorを導入してみました。

結論から言うと、OAuth認証で盛大にハマりました。この記事は、その悪戦苦闘の記録です。

やりたかったこと

Papercalという家族向けカレンダーアプリをPWAで開発していて、App StoreとGoogle Play Storeで配布したくなりました。

PWAのままでもホーム画面に追加すればアプリっぽく使えるのですが:

  • App Storeに並んでいる方が信頼感がある
  • カメラなどネイティブ機能へのアクセスが確実
  • プッシュ通知の実装が楽になる(将来的に)

といった理由から、ネイティブアプリ化を決めました。

Capacitorを選んだ理由

ネイティブアプリを作る選択肢はいくつかあります:

選択肢メリットデメリット
Swift/Kotlinパフォーマンス最高2言語で書き直し
React NativeReact資産活用結局書き直し
FlutterクロスプラットフォームDart学習コスト
Capacitor既存コードそのままWebView依存

既存のReact + Vite + TypeScriptのコードを一切書き換えずにネイティブアプリにできるCapacitorを選びました。

Capacitorの導入:調べながら悪戦苦闘

Capacitorの導入は、正直かなり苦戦しました。Webしか触ったことがない自分にとって、ネイティブアプリの世界は未知の領域。公式ドキュメントを読みながら、一つ一つ調べて進めていきました。

pnpm add @capacitor/core @capacitor/ios @capacitor/android
pnpm add -D @capacitor/cli

npx cap init "Papercal" "com.papercal.app"
npx cap add ios
npx cap add android

特にcom.papercal.appって何なんだ?という疑問がなかなか解消されず、調べる方に時間がかかってしまいました。どうやらこれは「Bundle ID」や「Application ID」と呼ばれるもので、アプリを一意に識別するための識別子らしい。逆引きドメイン形式で書くのが慣例とのこと。

capacitor.config.tsを作成して、Webアプリのビルド出力先を指定:

const config: CapacitorConfig = {
  appId: "com.papercal.app",
  appName: "Papercal",
  webDir: "dist",
};

ビルドしてXcodeを開くところまでは来ました:

pnpm build
npx cap sync
npx cap open ios

Xcodeが開いたものの、ここからも一苦労。シミュレータでアプリを実行するまでに、Signing & Capabilitiesの設定やらTeamの選択やら、見慣れない設定項目と格闘することになりました。

それでもなんとかシミュレータでアプリが動いて、ログイン画面が表示されるまでは「よし、いけるぞ」と思っていました。

問題1: OAuthがブラウザで開く

このアプリはGoogleログインを使っています。ログインボタンを押すと…

Safariが起動して、Googleのログイン画面が表示されました。

「まあ、そうだよな」と思いながらログイン。認証完了後のリダイレクトで…

Safariに留まったまま、アプリに戻ってこない。

当然です。OAuth認証後のリダイレクト先はhttps://stg.papercal.app/auth/callback。これはWebアプリのURLであって、ネイティブアプリのURLではありません。

ディープリンクという概念

ここで初めて「ディープリンク」という概念を知りました。

調べてみると、特定のURLをネイティブアプリで開く仕組みのようです。大きく2種類あるみたいでした:

1. カスタムURLスキーム

myapp://some/pathのような独自スキームを使う方式。設定が簡単だけど、他のアプリと被る可能性がある。

https://example.com/pathのような通常のHTTPS URLをアプリで開く方式。Apple Developer ProgramまたはGoogle Play Consoleへの登録が必要。

理想はUniversal Links / App Linksです。https://papercal.appへのリンクがアプリで開けば、招待リンクなども自然に動作します。

問題2: Apple Developer Programの壁

Universal Linksを設定しようとしたところ、Associated Domainsという機能を使う必要があることがわかりました。

Xcodeで設定しようとすると:

Provisioning profile doesn't support the Associated Domains capability

調べてみると、Associated DomainsはApple Developer Program(年額$99)に登録しないと使えないとのこと。

Personal Team(無料)では使えません。

まだ開発段階でストアに出すかも決まっていないのに、いきなり年額$99…さすがにしんどいなあ…となりました。

方針転換: カスタムURLスキームで進める

利便性を考えるといつかは実現したいなあ、と思いつつ、Universal Linksは一旦諦めてカスタムURLスキームで進めることにしました。

com.papercal.app://auth/callbackというURLをアプリで受け取れるように設定します。iOSの場合はios/App/App/Info.plistというファイルを編集する必要がありました:

<!-- ios/App/App/Info.plist -->
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>com.papercal.app</string>
    </array>
  </dict>
</array>

さらに、Supabase側でもこのカスタムスキームをリダイレクト先として許可する必要があります。Terraformで設定を追加:

# terraform/supabase.tf
resource "supabase_settings" "production" {
  # ...
  auth = jsonencode({
    additional_redirect_urls = [
      "http://localhost:5173",
      "com.papercal.app://auth/callback",  # カスタムスキーム追加
    ]
  })
}

これでGoogleログイン後、アプリに戻るかを確認するプロンプトが表示されるようになりました!

OAuthコールバック後にアプリに戻るかを確認するプロンプト

問題3: 無限ループ地獄

…が、喜んだのも束の間。アプリに戻ってきたものの、ローディング画面が永遠に表示され続ける

DevToolsで確認すると、同じURLに何度もリダイレクトしている。無限ループです。

原因の特定

CapacitorにはApp.getLaunchUrl()というAPIがあります。アプリがディープリンクで起動された場合、そのURLを取得できます。

const { url } = await App.getLaunchUrl() ?? {};
if (url) {
  handleDeepLink(url);  // OAuth処理
}

問題は、OAuth処理が完了して画面遷移しても、getLaunchUrl()は同じURLを返し続けること。

window.location.hrefでページ遷移すると、Capacitor的にはアプリの再起動扱いになります。再起動時にgetLaunchUrl()を呼ぶと、前回と同じOAuth URLが返ってきて、また認証処理が走り、また遷移して…の無限ループ。

解決策: 処理済みURLの記録

「このURLは処理済み」という情報をどこかに保存する必要があります。

最初は単純にSetで管理しようとしました:

const processedUrls = new Set<string>();

function handleDeepLink(url: string) {
  if (processedUrls.has(url)) return;  // 処理済みならスキップ
  processedUrls.add(url);
  // OAuth処理...
}

これでは動きませんでした。

window.location.hrefでページ遷移すると、JavaScriptのメモリ上の変数は全てリセットされます。processedUrlsも空になり、同じURLを「未処理」と判定してしまう。

SessionStorageで永続化

ページ遷移後も情報を保持するために、SessionStorageを使いました:

const PROCESSED_URLS_KEY = "deeplink_processed_urls";

function hasProcessedUrl(url: string): boolean {
  const stored = sessionStorage.getItem(PROCESSED_URLS_KEY);
  if (!stored) return false;
  const urls: string[] = JSON.parse(stored);
  return urls.includes(url);
}

function addProcessedUrl(url: string): void {
  const stored = sessionStorage.getItem(PROCESSED_URLS_KEY);
  const urls: string[] = stored ? JSON.parse(stored) : [];
  urls.push(url);
  sessionStorage.setItem(PROCESSED_URLS_KEY, JSON.stringify(urls));
}

SessionStorageはページ遷移後も保持されるので、これで無限ループが解消されました。

問題4: カメラが動かない

OAuth問題を解決して、ようやくアプリが動くように。しかし、カメラで予定表を撮影する機能が動かない。

カメラボタンを押しても何も起きない。

権限設定の追加

iOS/Androidではカメラやフォトライブラリへのアクセスに権限が必要です。Info.plistに設定を追加:

<key>NSCameraUsageDescription</key>
<string>予定表の写真を撮影するためにカメラを使用します</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>予定表の画像を選択するためにフォトライブラリにアクセスします</string>

Androidも同様にAndroidManifest.xmlに追加:

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

これでカメラが動くようになりました。

問題5: Androidでディープリンクが動かない

iOSで動いたので、Androidも同じように…と思ったら、またディープリンクが動かない。

Googleログイン後、ブラウザに留まってしまう

Intent Filterの設定

AndroidではIntent Filterでディープリンクを受け取ります。AndroidManifest.xmlを確認すると、設定が足りていませんでした:

<!-- 修正前 -->
<intent-filter>
  <data android:scheme="com.papercal.app" />
</intent-filter>

<!-- 修正後 -->
<intent-filter>
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data
    android:scheme="com.papercal.app"
    android:host="auth"
    android:pathPrefix="/callback"
  />
</intent-filter>

hostpathPrefixを明示的に指定する必要がありました。これでAndroidでもディープリンクが動作するように。

最終的な構成

紆余曲折を経て、こんな構成になりました:

apps/web/
├── capacitor.config.ts    # 環境別設定(local/stg/prod)
├── src/lib/
│   ├── deepLink.ts        # ディープリンク処理
│   └── platform.ts        # プラットフォーム判定
├── ios/
│   └── App/App/Info.plist # 権限・URLスキーム設定
└── android/
    └── app/src/main/AndroidManifest.xml

環境別の設定

開発時はローカルサーバーに接続、本番はWebアプリのURLに接続するように、環境変数で切り替えられるようにしました:

// capacitor.config.ts
type CapacitorEnv = "local" | "stg" | "prod";
const env = (process.env.CAPACITOR_ENV || "stg") as CapacitorEnv;

const SERVER_URLS: Record<CapacitorEnv, string | undefined> = {
  local: process.env.CAPACITOR_SERVER_URL,  // ライブリロード用
  stg: "https://stg.papercal.app",
  prod: "https://papercal.app",
};

ローカル開発時はライブリロードが効くので、コード変更が即座に反映されます:

# ローカル開発
CAPACITOR_SERVER_URL=http://192.168.1.10:5173 CAPACITOR_ENV=local npx cap sync

# ステージング環境
CAPACITOR_ENV=stg npx cap sync

学んだこと

1. ネイティブアプリは「別世界」

Webの常識が通用しない場面が多い。カスタムURLスキーム、アプリ権限、ディープリンク…ネイティブ特有の概念に慣れるのに時間がかかりました。

2. 公式ドキュメントだけでは足りない

Capacitorのドキュメントは親切だけど、OAuth連携やディープリンクの細かいハマりポイントはカバーされていない。Stack OverflowやGitHub Issuesを読み漁ることになりました。

3. シミュレータでの検証は必須

実機を持っていなくても、XcodeのiOSシミュレータやAndroid Studioのエミュレータで大体の検証はできます。特にディープリンクのテストは、シミュレータでURLを直接開けるので便利。ただ、エミュレータは動作が重く、ちょっと実機が欲しくなりました。

4. 段階的に進める

「一気に全部動かそう」とすると、どこで問題が起きているかわからなくなります。「まず画面表示」「次にログイン」「次にカメラ」と、一つずつ確認しながら進めるのが大事。

まだ残っている課題

カスタムURLスキームで動いているけど、理想はUniversal Links。Apple Developer Programに登録したら対応する予定。

本番ビルドとストア申請

今はデバッグビルドで動かしているだけ。実際にストアに出すには、証明書やProvisioning Profileの設定、審査対応など、まだまだやることがありそうです。

おわりに

「Capacitorなら既存コードそのままでネイティブアプリ化できる」は半分本当で半分嘘でした。

確かにReactのコードは一切変更していません。でも、ネイティブ特有の設定やハマりポイントは山ほどありました。

それでも、Swift/Kotlinでゼロから書き直すより遥かに楽なのは間違いないです。Webの知識だけでここまで来れたのは、Capacitorのおかげ。

次はストア申請に挑戦してみます。また別の沼が待っていそうな予感…。