TestFlightの環境判定を制御する

TestFlightでのアプリ配布において、「内部テスター向け」と「外部テスター向け」のビルドを正確に識別し、適切なアップデート通知を実装するのは意外と複雑です。この記事では、FastAPI + SwiftUIを組み合わせた実装例を通じて、TestFlight環境の技術的制御方法を詳しく解説します。

## 🎯 この記事で解決する課題

- TestFlightの内部・外部テスター環境の正確な判定
- 環境に応じた適切なアップデート通知
- App Store Connect APIを活用した自動化
- パフォーマンスを考慮したキャッシング戦略

## 📊 システム構成

```
┌─────────────────┐    ┌──────────────────┐    ┌─────────────────────┐
│   iOS Client    │◄──►│  FastAPI Server  │◄──►│ App Store Connect   │
│   (SwiftUI)     │    │    (Python)      │    │      API            │
└─────────────────┘    └──────────────────┘    └─────────────────────┘
        │                        │
        ▼                        ▼
  環境判定・UI制御         JWT認証・ビルド情報取得
```

## 🔧 バックエンド実装（FastAPI）

### JWT認証システム

App Store Connect APIとの連携には、ES256アルゴリズムによる署名付きJWTが必要です。

```python
import base64
import time
import jwt
from fastapi import FastAPI, HTTPException
from functools import lru_cache

app = FastAPI()

def _make_jwt() -> str:
    """ES256 署名済み JWT（有効期限20分）を生成"""
    private_key = base64.b64decode(settings.asc_p8_content_base64).decode("utf-8")
    now = int(time.time())
    
    payload = {
        "iss": settings.asc_issuer_id,        # Issuer ID
        "iat": now,                           # 発行時刻
        "exp": now + 1200,                    # 有効期限（20分）
        "aud": "appstoreconnect-v1",          # オーディエンス
    }
    
    return jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": settings.asc_key_id})
```

### キャッシング戦略

API呼び出しを最小限に抑えるため、LRUキャッシュを実装します。

```python
@lru_cache(maxsize=1)
def _cached_builds_data() -> dict:
    """App Store Connect APIからビルド情報を取得してキャッシュ"""
    token = _make_jwt()
    headers = {"Authorization": f"Bearer {token}"}
    
    try:
        with httpx.Client() as client:
            response = client.get(
                f"https://api.appstoreconnect.apple.com/v1/apps/{settings.app_id}/builds",
                headers=headers,
                timeout=30.0,
                params={
                    "filter[betaAppReviewSubmission.betaReviewState]": "APPROVED",
                    "sort": "-version,-buildAudience",
                    "include": "buildAudience",
                    "limit": 50,
                }
            )
            response.raise_for_status()
            return response.json()
            
    except httpx.HTTPStatusError as e:
        # エラー時はキャッシュをクリア
        _cached_builds_data.cache_clear()
        raise HTTPException(
            status_code=e.response.status_code,
            detail=f"App Store Connect API error: {e.response.text}",
        )
```

### APIエンドポイント設計

環境ごとに最適化されたエンドポイントを提供します。

```python
@app.get("/testflight/latest/internal")
async def get_latest_internal_build():
    """内部テスター向け最新ビルド取得"""
    builds_data = _cached_builds_data()
    
    for build in builds_data["data"]:
        audience_type = _get_audience_type(build, builds_data["included"])
        if audience_type == "INTERNAL_ONLY":
            return _format_build_info(build)
    
    raise HTTPException(status_code=404, detail="内部テスター向けビルドが見つかりません")

@app.get("/testflight/latest/external")
async def get_latest_external_build():
    """外部テスター向け最新ビルド取得"""
    builds_data = _cached_builds_data()
    
    for build in builds_data["data"]:
        audience_type = _get_audience_type(build, builds_data["included"])
        if audience_type == "APP_STORE_ELIGIBLE":
            return _format_build_info(build)
    
    raise HTTPException(status_code=404, detail="外部テスター向けビルドが見つかりません")

@app.get("/testflight/version/{version}/audience")
async def get_version_audience(version: str):
    """特定バージョンのオーディエンス情報取得"""
    builds_data = _cached_builds_data()
    
    for build in builds_data["data"]:
        if build["attributes"]["version"] == version:
            audience_type = _get_audience_type(build, builds_data["included"])
            return {"audienceType": audience_type}
    
    raise HTTPException(status_code=404, detail=f"バージョン {version} が見つかりません")
```

## 📱 クライアント実装（SwiftUI）

### 環境判定システム

まず、ビルド環境を定義します。

```swift
enum BuildEnvironment: CaseIterable {
    case debug               // デバッグビルド
    case testFlightInternal  // TestFlight内部テスター
    case testFlightExternal  // TestFlight外部テスター
    case testFlightUnknown   // TestFlight（判定不能）
    case appStore           // App Store配布
    
    var displayName: String {
        switch self {
        case .debug: return "Debug Build"
        case .testFlightInternal: return "TestFlight - 内部"
        case .testFlightExternal: return "TestFlight - 外部"
        case .testFlightUnknown: return "TestFlight"
        case .appStore: return "App Store"
        }
    }
    
    var badgeColor: Color {
        switch self {
        case .debug: return .orange
        case .testFlightInternal: return .blue
        case .testFlightExternal: return .green
        case .testFlightUnknown: return .gray
        case .appStore: return .clear
        }
    }
}
```

### 基本環境検出

```swift
extension BuildEnvironment {
    static let current: Self = {
        #if DEBUG || targetEnvironment(simulator)
            return .debug
        #else
            // サンドボックスレシートの存在でTestFlight環境を判定
            let isSandbox = Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
            return isSandbox ? .testFlightUnknown : .appStore
        #endif
    }()
}
```

### 詳細環境検出クラス

サーバーAPIと連携して詳細な環境情報を取得します。

```swift
class TestFlightEnvironmentDetector: ObservableObject {
    static let shared = TestFlightEnvironmentDetector()
    
    @Published var detailedEnvironment: BuildEnvironment = BuildEnvironment.current
    
    private init() {}
    
    func detectDetailedEnvironment() async -> BuildEnvironment {
        // 既に詳細環境が判明している場合はスキップ
        guard BuildEnvironment.current == .testFlightUnknown else {
            return BuildEnvironment.current
        }
        
        do {
            let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
            let audienceInfo = try await APIClient.shared.getAudienceInfo(version: currentVersion)
            
            let environment: BuildEnvironment = audienceInfo.audienceType == "INTERNAL_ONLY" 
                ? .testFlightInternal 
                : .testFlightExternal
            
            await MainActor.run {
                self.detailedEnvironment = environment
            }
            
            return environment
        } catch {
            print("環境検出エラー: \(error.localizedDescription)")
            return .testFlightUnknown
        }
    }
    
    func printDetailedEnvironmentInfo() async {
        let environment = await detectDetailedEnvironment()
        let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
        let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
        
        let emoji = environment == .testFlightInternal ? "🔵" : "🟢"
        print("\(emoji) \(environment.displayName) build \(version) (\(build))")
    }
}
```

### アップデート確認システム

```swift
struct TestFlightUpdateInfo {
    let currentBuild: String
    let latestBuild: String
    let isUpdateAvailable: Bool
    let updateURL: URL?
    let releaseNotes: String?
}

class TestFlightUpdateChecker: ObservableObject {
    static let shared = TestFlightUpdateChecker()
    
    @Published var updateInfo: TestFlightUpdateInfo?
    @Published var isChecking = false
    
    private init() {}
    
    func checkForUpdates() async -> TestFlightUpdateInfo? {
        await MainActor.run { isChecking = true }
        defer { Task { await MainActor.run { isChecking = false } } }
        
        do {
            let detailedEnvironment = await TestFlightEnvironmentDetector.shared.detectDetailedEnvironment()
            
            // 環境に応じて適切なエンドポイントを選択
            let latestBuildInfo = try await getLatestBuildInfo(for: detailedEnvironment)
            let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
            
            let isUpdateAvailable = compareBuilds(
                current: currentBuild, 
                latest: latestBuildInfo.buildNumber
            )
            
            let updateInfo = TestFlightUpdateInfo(
                currentBuild: currentBuild,
                latestBuild: latestBuildInfo.buildNumber,
                isUpdateAvailable: isUpdateAvailable,
                updateURL: URL(string: "itms-beta://"),
                releaseNotes: latestBuildInfo.releaseNotes
            )
            
            await MainActor.run {
                self.updateInfo = updateInfo
            }
            
            return updateInfo
            
        } catch {
            print("アップデート確認エラー: \(error.localizedDescription)")
            return nil
        }
    }
    
    private func getLatestBuildInfo(for environment: BuildEnvironment) async throws -> BuildInfo {
        switch environment {
        case .testFlightInternal:
            return try await APIClient.shared.getLatestInternalBuild()
        case .testFlightExternal:
            return try await APIClient.shared.getLatestExternalBuild()
        default:
            throw TestFlightError.unsupportedEnvironment
        }
    }
    
    private func compareBuilds(current: String, latest: String) -> Bool {
        guard let currentInt = Int(current),
              let latestInt = Int(latest) else {
            return current != latest
        }
        return latestInt > currentInt
    }
    
    func openTestFlightApp() {
        guard let url = URL(string: "itms-beta://") else { return }
        UIApplication.shared.open(url)
    }
}
```

## 🎨 UI実装

### 環境バッジコンポーネント

```swift
struct EnvironmentBadgeView: View {
    @StateObject private var detector = TestFlightEnvironmentDetector.shared
    
    var body: some View {
        Group {
            switch detector.detailedEnvironment {
            case .debug:
                badgeView(text: "Debug Build", color: .orange)
            case .testFlightInternal:
                badgeView(text: "TestFlight - 内部", color: .blue)
            case .testFlightExternal:
                badgeView(text: "TestFlight - 外部", color: .green)
            case .testFlightUnknown:
                badgeView(text: "TestFlight", color: .gray)
            case .appStore:
                EmptyView()
            }
        }
        .task {
            await detector.detectDetailedEnvironment()
        }
    }
    
    private func badgeView(text: String, color: Color) -> some View {
        Text(text)
            .font(.caption)
            .padding(.horizontal, 8)
            .padding(.vertical, 4)
            .background(color.opacity(0.2))
            .foregroundColor(color)
            .clipShape(Capsule())
    }
}
```

### アップデート確認UI

```swift
struct UpdateCheckView: View {
    @StateObject private var updateChecker = TestFlightUpdateChecker.shared
    
    var body: some View {
        VStack(spacing: 16) {
            if updateChecker.isChecking {
                ProgressView("アップデート確認中...")
            } else if let updateInfo = updateChecker.updateInfo {
                if updateInfo.isUpdateAvailable {
                    updateAvailableView(updateInfo)
                } else {
                    Text("最新バージョンです")
                        .foregroundColor(.secondary)
                }
            }
            
            Button("アップデート確認") {
                Task {
                    await updateChecker.checkForUpdates()
                }
            }
            .buttonStyle(.bordered)
            .disabled(updateChecker.isChecking)
        }
        .task {
            await updateChecker.checkForUpdates()
        }
    }
    
    private func updateAvailableView(_ updateInfo: TestFlightUpdateInfo) -> some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack {
                Image(systemName: "arrow.up.circle.fill")
                    .foregroundColor(.blue)
                Text("アップデートが利用可能です")
                    .font(.headline)
            }
            
            Text("現在: \(updateInfo.currentBuild) → 最新: \(updateInfo.latestBuild)")
                .font(.caption)
                .foregroundColor(.secondary)
            
            if let releaseNotes = updateInfo.releaseNotes, !releaseNotes.isEmpty {
                Text(releaseNotes)
                    .font(.caption)
                    .padding(.top, 4)
            }
            
            Button("TestFlightで更新") {
                updateChecker.openTestFlightApp()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        .background(Color.blue.opacity(0.1))
        .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}
```

## 🚀 実装例：アプリ起動時の処理

```swift
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    // アプリ起動時に環境情報を出力
                    await BuildEnvironment.printDetailedEnvironmentInfo()
                    
                    // アップデート確認（バックグラウンドで実行）
                    await TestFlightUpdateChecker.shared.checkForUpdates()
                }
        }
    }
}

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack(spacing: 20) {
                EnvironmentBadgeView()
                
                UpdateCheckView()
                
                // その他のコンテンツ
                Spacer()
            }
            .padding()
            .navigationTitle("My App")
        }
    }
}
```

## 📊 パフォーマンス最適化

### API呼び出し頻度の制御

```python
# キャッシュ制御用のタイムスタンプ管理
@lru_cache(maxsize=1)
def _get_cache_timestamp():
    return time.time()

def _should_refresh_cache():
    """5分経過でキャッシュをリフレッシュ"""
    return time.time() - _get_cache_timestamp() > 300

@app.middleware("http")
async def cache_control_middleware(request: Request, call_next):
    if _should_refresh_cache():
        _cached_builds_data.cache_clear()
        _get_cache_timestamp.cache_clear()
    
    response = await call_next(request)
    return response
```

### エラー時のフォールバック戦略

```swift
extension TestFlightEnvironmentDetector {
    func detectWithFallback() async -> BuildEnvironment {
        // まず基本判定を試行
        let basicEnvironment = BuildEnvironment.current
        guard basicEnvironment == .testFlightUnknown else {
            return basicEnvironment
        }
        
        // 詳細判定を試行（タイムアウト付き）
        do {
            return try await withTimeout(5.0) {
                await detectDetailedEnvironment()
            }
        } catch {
            // タイムアウトまたはエラー時は基本判定を返す
            print("詳細環境検出に失敗、基本判定を使用: \(error)")
            return basicEnvironment
        }
    }
}
```

## 🎯 まとめ

この実装により、以下の課題を解決できます：

| 課題 | 解決方法 |
|------|----------|
| 内部・外部テスターの判別 | App Store Connect APIとの連携 |
| パフォーマンスの最適化 | LRUキャッシュとタイムアウト制御 |
| エラー処理 | 段階的フォールバック戦略 |
| ユーザビリティ | 直感的な環境表示とアップデート通知 |

### 導入のメリット

- **自動化**: 手動での環境管理が不要
- **正確性**: App Store Connect APIベースの確実な判定
- **保守性**: 構造化されたエラーハンドリング
- **拡張性**: モジュール化された設計

TestFlightの複雑な配布環境も、適切な技術的アプローチにより効率的に制御できます。この実装を参考に、プロジェクトの要件に合わせてカスタマイズしてみてください。