From 9ad66f21408deb0bd60b56867773f7185478166b Mon Sep 17 00:00:00 2001 From: mission-agi Date: Sat, 14 Mar 2026 22:10:07 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20production=20readiness=20=E2=80=94?= =?UTF-8?q?=20automatic=20signing=20+=20guard=20debug=20prints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enable CODE_SIGN_STYLE: Automatic in project.yml for device deployment - Wrap LocalStore print() statements in #if DEBUG to prevent console leakage in release builds --- apps/HeartCoach/Shared/Services/LocalStore.swift | 6 ++++++ apps/HeartCoach/project.yml | 1 + 2 files changed, 7 insertions(+) diff --git a/apps/HeartCoach/Shared/Services/LocalStore.swift b/apps/HeartCoach/Shared/Services/LocalStore.swift index 2af2382a..011606b8 100644 --- a/apps/HeartCoach/Shared/Services/LocalStore.swift +++ b/apps/HeartCoach/Shared/Services/LocalStore.swift @@ -299,13 +299,17 @@ public final class LocalStore: ObservableObject { // Encryption unavailable — do NOT fall back to plaintext for health data. // Data is dropped rather than stored unencrypted. The next successful // save will restore it. This protects PHI at the cost of temporary data loss. + #if DEBUG print("[LocalStore] ERROR: Encryption unavailable for key \(key.rawValue). Data NOT saved to protect health data privacy.") + #endif #if DEBUG assertionFailure("CryptoService.encrypt() returned nil for key \(key.rawValue). Fix Keychain access or mock CryptoService in tests.") #endif } } catch { + #if DEBUG print("[LocalStore] ERROR: Failed to encode \(T.self) for key \(key.rawValue): \(error)") + #endif } } @@ -342,10 +346,12 @@ public final class LocalStore: ObservableObject { // Both encrypted and plain-text decoding failed — data is corrupted // or from an incompatible schema version. Remove the bad entry so the // app can start fresh instead of crashing on every launch. + #if DEBUG print( "[LocalStore] WARNING: Removing unreadable \(T.self) " + "from key \(key.rawValue). Stored data was corrupted or incompatible." ) + #endif defaults.removeObject(forKey: key.rawValue) return nil } diff --git a/apps/HeartCoach/project.yml b/apps/HeartCoach/project.yml index d1bef5dc..fb6bb71d 100644 --- a/apps/HeartCoach/project.yml +++ b/apps/HeartCoach/project.yml @@ -22,6 +22,7 @@ settings: SWIFT_VERSION: "5.9" ENABLE_USER_SCRIPT_SANDBOXING: true DEAD_CODE_STRIPPING: true + CODE_SIGN_STYLE: Automatic ############################################################ # Targets From 5909bab8337b27834bb21ff7b7f86509943701e7 Mon Sep 17 00:00:00 2001 From: mission-agi Date: Sat, 14 Mar 2026 22:27:17 -0700 Subject: [PATCH 2/2] feat: add Sign in with Apple + observability improvements Add Sign in with Apple as the first step in the app launch flow, storing credentials locally via Keychain. Replace debugPrint calls with AppLogger across ConnectivityService and SubscriptionService, connect CrashBreadcrumbs to MetricKit diagnostics, and add sign-in analytics events. --- .../Shared/Models/HeartModels.swift | 7 +- .../iOS/Services/AnalyticsEvents.swift | 4 + .../iOS/Services/AppleSignInService.swift | 142 +++++++++++++++ .../iOS/Services/ConnectivityService.swift | 26 +-- .../iOS/Services/MetricKitService.swift | 3 + .../iOS/Services/SubscriptionService.swift | 10 +- apps/HeartCoach/iOS/ThumpiOSApp.swift | 23 ++- .../iOS/Views/AppleSignInView.swift | 172 ++++++++++++++++++ apps/HeartCoach/iOS/iOS.entitlements | 4 + apps/HeartCoach/project.yml | 1 + 10 files changed, 369 insertions(+), 23 deletions(-) create mode 100644 apps/HeartCoach/iOS/Services/AppleSignInService.swift create mode 100644 apps/HeartCoach/iOS/Views/AppleSignInView.swift diff --git a/apps/HeartCoach/Shared/Models/HeartModels.swift b/apps/HeartCoach/Shared/Models/HeartModels.swift index 6d0586de..5e0ba841 100644 --- a/apps/HeartCoach/Shared/Models/HeartModels.swift +++ b/apps/HeartCoach/Shared/Models/HeartModels.swift @@ -1409,6 +1409,9 @@ public struct UserProfile: Codable, Equatable, Sendable { /// Biological sex for metric norm stratification. public var biologicalSex: BiologicalSex + /// Email address from Sign in with Apple (optional, only provided on first sign-in). + public var email: String? + public init( displayName: String = "", joinDate: Date = Date(), @@ -1417,7 +1420,8 @@ public struct UserProfile: Codable, Equatable, Sendable { lastStreakCreditDate: Date? = nil, nudgeCompletionDates: Set = [], dateOfBirth: Date? = nil, - biologicalSex: BiologicalSex = .notSet + biologicalSex: BiologicalSex = .notSet, + email: String? = nil ) { self.displayName = displayName self.joinDate = joinDate @@ -1427,6 +1431,7 @@ public struct UserProfile: Codable, Equatable, Sendable { self.nudgeCompletionDates = nudgeCompletionDates self.dateOfBirth = dateOfBirth self.biologicalSex = biologicalSex + self.email = email } /// Computed chronological age in years from date of birth. diff --git a/apps/HeartCoach/iOS/Services/AnalyticsEvents.swift b/apps/HeartCoach/iOS/Services/AnalyticsEvents.swift index 74ab37f2..e7b90e24 100644 --- a/apps/HeartCoach/iOS/Services/AnalyticsEvents.swift +++ b/apps/HeartCoach/iOS/Services/AnalyticsEvents.swift @@ -38,6 +38,10 @@ enum AnalyticsEventName: String, CaseIterable, Sendable { // Watch case watchFeedbackReceived = "watch_feedback_received" + // Sign In + case appleSignInCompleted = "apple_sign_in_completed" + case appleSignInFailed = "apple_sign_in_failed" + // AI / Assessment case assessmentGenerated = "assessment_generated" } diff --git a/apps/HeartCoach/iOS/Services/AppleSignInService.swift b/apps/HeartCoach/iOS/Services/AppleSignInService.swift new file mode 100644 index 00000000..fb0767a4 --- /dev/null +++ b/apps/HeartCoach/iOS/Services/AppleSignInService.swift @@ -0,0 +1,142 @@ +// AppleSignInService.swift +// Thump iOS +// +// Handles Sign in with Apple authentication flow. +// Stores the Apple user identifier in the Keychain via the Security +// framework (not CryptoService, which is for data encryption). +// On subsequent launches, verifies the credential is still valid. +// +// Platforms: iOS 17+ + +import AuthenticationServices +import Foundation + +// MARK: - Apple Sign-In Service + +/// Manages Sign in with Apple credential storage and validation. +/// +/// The Apple-issued `userIdentifier` is a stable, opaque string that +/// persists across app reinstalls on the same device. We store it in +/// the Keychain so it survives app updates and UserDefaults resets. +/// +/// Usage: +/// ```swift +/// // Save after successful sign-in +/// AppleSignInService.saveUserIdentifier("001234.abc...") +/// +/// // Check on app launch +/// let isValid = await AppleSignInService.isCredentialValid() +/// ``` +public enum AppleSignInService { + + // MARK: - Keychain Constants + + /// Keychain item identifier for the Apple user ID. + private static let keychainAccount = "com.thump.appleUserIdentifier" + + /// Service name used in the Keychain query. + private static let keychainService = "com.thump.AppleSignIn" + + // MARK: - Credential Storage + + /// Save the Apple user identifier to the Keychain. + /// + /// - Parameter userIdentifier: The stable user ID from + /// `ASAuthorizationAppleIDCredential.user`. + public static func saveUserIdentifier(_ userIdentifier: String) { + guard let data = userIdentifier.data(using: .utf8) else { return } + + // Delete any existing entry first + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount + ] + SecItemDelete(deleteQuery as CFDictionary) + + // Add new entry + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + SecItemAdd(addQuery as CFDictionary, nil) + } + + /// Retrieve the stored Apple user identifier from the Keychain. + /// + /// - Returns: The user identifier string, or `nil` if not stored. + public static func loadUserIdentifier() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let identifier = String(data: data, encoding: .utf8) else { + return nil + } + return identifier + } + + /// Delete the stored Apple user identifier from the Keychain. + /// Used when credential is revoked or user signs out. + public static func deleteUserIdentifier() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount + ] + SecItemDelete(query as CFDictionary) + } + + // MARK: - Credential Validation + + /// Check whether the stored Apple ID credential is still valid. + /// + /// Apple can revoke credentials if the user disconnects the app + /// from their Apple ID settings. This async check contacts Apple's + /// servers to verify. + /// + /// - Returns: `true` if credential is authorized, `false` if revoked, + /// not found, or check failed. + public static func isCredentialValid() async -> Bool { + guard let userIdentifier = loadUserIdentifier() else { + return false + } + + let provider = ASAuthorizationAppleIDProvider() + + do { + let state = try await provider.credentialState(forUserID: userIdentifier) + switch state { + case .authorized: + return true + case .revoked, .notFound: + // Credential is no longer valid — clear stored data + deleteUserIdentifier() + return false + case .transferred: + // App ownership transferred — treat as valid + return true + @unknown default: + return false + } + } catch { + #if DEBUG + print("[AppleSignInService] Credential state check failed: \(error.localizedDescription)") + #endif + // Network error — assume valid to avoid locking user out offline + return true + } + } +} diff --git a/apps/HeartCoach/iOS/Services/ConnectivityService.swift b/apps/HeartCoach/iOS/Services/ConnectivityService.swift index ef3ace18..4593e328 100644 --- a/apps/HeartCoach/iOS/Services/ConnectivityService.swift +++ b/apps/HeartCoach/iOS/Services/ConnectivityService.swift @@ -44,7 +44,7 @@ final class ConnectivityService: NSObject, ObservableObject { /// Activates the WCSession if Watch Connectivity is supported. private func activateSessionIfSupported() { guard WCSession.isSupported() else { - debugPrint("[ConnectivityService] WCSession not supported on this device.") + AppLogger.sync.warning("[ConnectivityService] WCSession not supported on this device.") return } let wcSession = WCSession.default @@ -85,7 +85,7 @@ final class ConnectivityService: NSObject, ObservableObject { /// - Parameter assessment: The assessment to transmit to the watch. func sendAssessment(_ assessment: HeartAssessment) { guard let session = session else { - debugPrint("[ConnectivityService] No active session.") + AppLogger.sync.warning("[ConnectivityService] No active session.") return } @@ -93,7 +93,7 @@ final class ConnectivityService: NSObject, ObservableObject { assessment, type: .assessment ) else { - debugPrint("[ConnectivityService] Failed to encode assessment payload.") + AppLogger.sync.warning("[ConnectivityService] Failed to encode assessment payload.") return } @@ -124,7 +124,7 @@ final class ConnectivityService: NSObject, ObservableObject { /// - Parameter nudge: The breathing nudge to send. func sendBreathPrompt(_ nudge: DailyNudge) { guard let session = session else { - debugPrint("[ConnectivityService] No active session for breath prompt.") + AppLogger.sync.warning("[ConnectivityService] No active session for breath prompt.") return } @@ -160,7 +160,7 @@ final class ConnectivityService: NSObject, ObservableObject { /// - Parameter plan: The action plan to transmit. func sendActionPlan(_ plan: WatchActionPlan) { guard let session = session else { - debugPrint("[ConnectivityService] No active session for action plan.") + AppLogger.sync.warning("[ConnectivityService] No active session for action plan.") return } @@ -168,7 +168,7 @@ final class ConnectivityService: NSObject, ObservableObject { plan, type: .actionPlan ) else { - debugPrint("[ConnectivityService] Failed to encode action plan payload.") + AppLogger.sync.warning("[ConnectivityService] Failed to encode action plan payload.") return } @@ -219,7 +219,7 @@ final class ConnectivityService: NSObject, ObservableObject { /// Called from nonisolated WCSessionDelegate callbacks. nonisolated private func handleIncomingMessage(_ message: [String: Any]) { guard let type = message["type"] as? String else { - debugPrint("[ConnectivityService] Received message without type key.") + AppLogger.sync.warning("[ConnectivityService] Received message without type key.") return } @@ -231,7 +231,7 @@ final class ConnectivityService: NSObject, ObservableObject { // This is handled via the reply handler in didReceiveMessage. break default: - debugPrint("[ConnectivityService] Unknown message type: \(type)") + AppLogger.sync.warning("[ConnectivityService] Unknown message type: \(type)") } } @@ -241,7 +241,7 @@ final class ConnectivityService: NSObject, ObservableObject { WatchFeedbackPayload.self, from: message ) else { - debugPrint("[ConnectivityService] Feedback message missing or invalid payload.") + AppLogger.sync.warning("[ConnectivityService] Feedback message missing or invalid payload.") return } @@ -275,9 +275,9 @@ extension ConnectivityService: WCSessionDelegate { } if let error = error { - debugPrint("[ConnectivityService] Activation error: \(error.localizedDescription)") + AppLogger.sync.warning("[ConnectivityService] Activation error: \(error.localizedDescription)") } else { - debugPrint("[ConnectivityService] Activation completed with state: \(activationState.rawValue)") + AppLogger.sync.warning("[ConnectivityService] Activation completed with state: \(activationState.rawValue)") } } @@ -286,7 +286,7 @@ extension ConnectivityService: WCSessionDelegate { /// Required for iOS WCSessionDelegate conformance. No-op; the session /// will be reactivated automatically. nonisolated func sessionDidBecomeInactive(_ session: WCSession) { - debugPrint("[ConnectivityService] Session became inactive.") + AppLogger.sync.warning("[ConnectivityService] Session became inactive.") } /// Called when the session transitions to the deactivated state. @@ -294,7 +294,7 @@ extension ConnectivityService: WCSessionDelegate { /// Required for iOS WCSessionDelegate conformance. Reactivates the session /// to prepare for a new paired watch. nonisolated func sessionDidDeactivate(_ session: WCSession) { - debugPrint("[ConnectivityService] Session deactivated. Reactivating...") + AppLogger.sync.warning("[ConnectivityService] Session deactivated. Reactivating...") session.activate() } diff --git a/apps/HeartCoach/iOS/Services/MetricKitService.swift b/apps/HeartCoach/iOS/Services/MetricKitService.swift index 9421cd15..aa7a8118 100644 --- a/apps/HeartCoach/iOS/Services/MetricKitService.swift +++ b/apps/HeartCoach/iOS/Services/MetricKitService.swift @@ -78,6 +78,9 @@ final class MetricKitService: NSObject, MXMetricManagerSubscriber { /// /// - Parameter payloads: One or more diagnostic snapshots. func didReceive(_ payloads: [MXDiagnosticPayload]) { + // Dump interaction breadcrumbs so crash context is visible in logs + CrashBreadcrumbs.shared.dump() + for payload in payloads { AppLogger.error("Received diagnostic payload (potential crash)") diff --git a/apps/HeartCoach/iOS/Services/SubscriptionService.swift b/apps/HeartCoach/iOS/Services/SubscriptionService.swift index c4ea4c67..ff3b9a44 100644 --- a/apps/HeartCoach/iOS/Services/SubscriptionService.swift +++ b/apps/HeartCoach/iOS/Services/SubscriptionService.swift @@ -111,7 +111,7 @@ final class SubscriptionService: ObservableObject { self.productLoadError = nil } } catch { - debugPrint("[SubscriptionService] Failed to load products: \(error.localizedDescription)") + AppLogger.subscription.warning("[SubscriptionService] Failed to load products: \(error.localizedDescription)") await MainActor.run { self.productLoadError = error } @@ -147,13 +147,13 @@ final class SubscriptionService: ObservableObject { await updateSubscriptionStatus() case .userCancelled: - debugPrint("[SubscriptionService] User cancelled purchase.") + AppLogger.subscription.info("[SubscriptionService] User cancelled purchase.") case .pending: - debugPrint("[SubscriptionService] Purchase pending (e.g., Ask to Buy).") + AppLogger.subscription.info("[SubscriptionService] Purchase pending (e.g., Ask to Buy).") @unknown default: - debugPrint("[SubscriptionService] Unknown purchase result.") + AppLogger.subscription.warning("[SubscriptionService] Unknown purchase result.") } } @@ -264,7 +264,7 @@ final class SubscriptionService: ObservableObject { ) throws -> T { switch result { case .unverified(_, let error): - debugPrint("[SubscriptionService] Unverified transaction: \(error.localizedDescription)") + AppLogger.subscription.warning("[SubscriptionService] Unverified transaction: \(error.localizedDescription)") throw error case .verified(let value): return value diff --git a/apps/HeartCoach/iOS/ThumpiOSApp.swift b/apps/HeartCoach/iOS/ThumpiOSApp.swift index 1392e720..8d2f281f 100644 --- a/apps/HeartCoach/iOS/ThumpiOSApp.swift +++ b/apps/HeartCoach/iOS/ThumpiOSApp.swift @@ -67,15 +67,17 @@ struct ThumpiOSApp: App { } } - // MARK: - Legal Acceptance State + // MARK: - Authentication & Legal State + + /// Tracks whether the user has signed in with Apple. + @AppStorage("thump_signed_in") private var isSignedIn: Bool = false /// Tracks whether the user has accepted the Terms of Service and Privacy Policy. @AppStorage("thump_legal_accepted_v1") private var legalAccepted: Bool = false // MARK: - Root View Routing - /// Routes to legal gate, onboarding, or main tab view based on - /// the user's acceptance and onboarding state. + /// Routes through: Sign In → Legal Gate → Onboarding → Main Tab View. /// Whether the app is running in UI test mode (launched with `-UITestMode`). private var isUITestMode: Bool { CommandLine.arguments.contains("-UITestMode") @@ -89,8 +91,12 @@ struct ThumpiOSApp: App { @ViewBuilder private var rootView: some View { if isUITestMode { - // Skip legal gate and onboarding for UI tests + // Skip all gates for UI tests MainTabView() + } else if !isSignedIn { + AppleSignInView { + isSignedIn = true + } } else if !legalAccepted { LegalGateView { legalAccepted = true @@ -122,6 +128,15 @@ struct ThumpiOSApp: App { AppLogger.info("Notification authorization request failed: \(error.localizedDescription)") } + // Verify Apple Sign-In credential is still valid + if isSignedIn { + let credentialValid = await AppleSignInService.isCredentialValid() + if !credentialValid { + await MainActor.run { isSignedIn = false } + AppLogger.info("Apple Sign-In credential revoked — returning to sign-in") + } + } + // Start MetricKit crash reporting and performance monitoring MetricKitService.shared.start() diff --git a/apps/HeartCoach/iOS/Views/AppleSignInView.swift b/apps/HeartCoach/iOS/Views/AppleSignInView.swift new file mode 100644 index 00000000..4be621a1 --- /dev/null +++ b/apps/HeartCoach/iOS/Views/AppleSignInView.swift @@ -0,0 +1,172 @@ +// AppleSignInView.swift +// Thump iOS +// +// Sign in with Apple screen — the first thing a new user sees. +// Displays the ThumpBuddy, app name, and a native Apple sign-in button. +// On success, stores the credential and calls the onSignedIn closure. +// +// Platforms: iOS 17+ + +import SwiftUI +import AuthenticationServices + +// MARK: - AppleSignInView + +/// Full-screen Sign in with Apple gate shown before legal acceptance +/// and onboarding. Uses Apple's native `SignInWithAppleButton` for +/// a consistent, trustworthy sign-in experience. +struct AppleSignInView: View { + + /// Called when the user successfully signs in. + let onSignedIn: () -> Void + + /// Environment object for storing the user's name. + @EnvironmentObject var localStore: LocalStore + + /// Error message shown in an alert if sign-in fails. + @State private var errorMessage: String? + + /// Controls the error alert presentation. + @State private var showError = false + + var body: some View { + VStack(spacing: 0) { + Spacer() + + // Buddy greeting + ThumpBuddy(mood: .content, size: 120, tappable: false) + .padding(.bottom, 16) + + // App name + Text("Thump") + .font(.system(size: 40, weight: .bold, design: .rounded)) + .foregroundStyle( + LinearGradient( + colors: [Color(hex: 0x2563EB), Color(hex: 0x7C3AED)], + startPoint: .leading, + endPoint: .trailing + ) + ) + + Text("Your Heart, Your Coach") + .font(.title3) + .foregroundStyle(.secondary) + .padding(.top, 4) + + Spacer() + + // Privacy reassurance + Label( + "Your health data stays on your device", + systemImage: "lock.shield.fill" + ) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.bottom, 24) + + // Sign in with Apple button + SignInWithAppleButton(.signIn) { request in + request.requestedScopes = [.fullName, .email] + } onCompletion: { result in + handleSignInResult(result) + } + .signInWithAppleButtonStyle(.black) + .frame(height: 54) + .cornerRadius(14) + .padding(.horizontal, 32) + .padding(.bottom, 16) + + // Skip option for development/testing + #if DEBUG + Button("Skip Sign-In (Debug)") { + onSignedIn() + } + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.bottom, 8) + #endif + + Spacer() + .frame(height: 40) + } + .padding(.horizontal, 24) + .background(Color(.systemBackground)) + .alert("Sign-In Error", isPresented: $showError) { + Button("OK") { } + } message: { + Text(errorMessage ?? "An unexpected error occurred. Please try again.") + } + .accessibilityIdentifier("apple_sign_in_view") + } + + // MARK: - Sign-In Handler + + private func handleSignInResult(_ result: Result) { + switch result { + case .success(let authorization): + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { + AppLogger.error("Sign-in succeeded but credential type is unexpected") + errorMessage = "Unexpected credential type. Please try again." + showError = true + return + } + + // Store the stable user identifier in Keychain + AppleSignInService.saveUserIdentifier(credential.user) + + // Store name if provided (Apple only sends this on first sign-in) + if let fullName = credential.fullName { + let name = [fullName.givenName, fullName.familyName] + .compactMap { $0 } + .joined(separator: " ") + if !name.isEmpty { + localStore.profile.displayName = name + localStore.saveProfile() + } + } + + // Store email if provided + if let email = credential.email { + localStore.profile.email = email + localStore.saveProfile() + } + + InteractionLog.log( + .buttonTap, + element: "sign_in_with_apple", + page: "SignIn", + details: "success" + ) + + AppLogger.info("Sign in with Apple completed successfully") + onSignedIn() + + case .failure(let error): + // User cancelled is not a real error — don't show alert + let nsError = error as NSError + if nsError.domain == ASAuthorizationError.errorDomain, + nsError.code == ASAuthorizationError.canceled.rawValue { + AppLogger.info("Sign in with Apple cancelled by user") + return + } + + AppLogger.error("Sign in with Apple failed: \(error.localizedDescription)") + errorMessage = "Sign-in failed: \(error.localizedDescription)" + showError = true + + InteractionLog.log( + .buttonTap, + element: "sign_in_with_apple", + page: "SignIn", + details: "error: \(error.localizedDescription)" + ) + } + } +} + +// MARK: - Preview + +#Preview("Sign In") { + AppleSignInView { } + .environmentObject(LocalStore()) +} diff --git a/apps/HeartCoach/iOS/iOS.entitlements b/apps/HeartCoach/iOS/iOS.entitlements index 5d1c5b9e..486d07a2 100644 --- a/apps/HeartCoach/iOS/iOS.entitlements +++ b/apps/HeartCoach/iOS/iOS.entitlements @@ -11,5 +11,9 @@ group.com.thump.shared + com.apple.developer.applesignin + + Default + diff --git a/apps/HeartCoach/project.yml b/apps/HeartCoach/project.yml index fb6bb71d..68e45287 100644 --- a/apps/HeartCoach/project.yml +++ b/apps/HeartCoach/project.yml @@ -55,6 +55,7 @@ targets: - sdk: WatchConnectivity.framework - sdk: StoreKit.framework - sdk: AppIntents.framework + - sdk: AuthenticationServices.framework scheme: testTargets: - ThumpCoreTests