1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TestFlightの環境判定を制御する

Posted at

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が必要です。

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キャッシュを実装します。

@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エンドポイント設計

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

@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)

環境判定システム

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

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
        }
    }
}

基本環境検出

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と連携して詳細な環境情報を取得します。

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))")
    }
}

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

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実装

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

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

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))
    }
}

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

@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呼び出し頻度の制御

# キャッシュ制御用のタイムスタンプ管理
@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

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

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?