Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 74 additions & 4 deletions Sources/TelemetryDeck/Signals/Signal+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,54 @@ import Foundation
import TVUIKit
#endif

#if canImport(StoreKit)
import StoreKit
#endif

extension DefaultSignalPayload {
/// Cached TestFlight detection from StoreKit 2 (true=TestFlight, false=AppStore, nil=unknown).
private nonisolated(unsafe) static var cachedIsTestFlight: Bool?

/// Initializes environment detection (StoreKit 2 on iOS 16+, receipt-based fallback on older OS).
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
static func initializeEnvironmentDetection() async {
// Use StoreKit 2 on iOS 16+ for most reliable detection
#if canImport(StoreKit)
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
do {
let transaction = try await AppTransaction.shared

switch transaction {
case let .verified(appTransaction):
// Check the environment property
Self.cachedIsTestFlight = appTransaction.environment == .sandbox
return
case .unverified:
// If verification fails, fall back to receipt-based detection
Self.cachedIsTestFlight = Self.isTestFlightViaReceipt()
return
}
} catch {
// If AppTransaction fails, fall back to receipt-based detection
Self.cachedIsTestFlight = Self.isTestFlightViaReceipt()
return
}
}
#endif

// For iOS < 16, use receipt-based detection
Self.cachedIsTestFlight = isTestFlightViaReceipt()
}

/// Receipt-based TestFlight detection fallback for iOS < 16 or when StoreKit 2 is unavailable.
private static func isTestFlightViaReceipt() -> Bool {
guard let receiptURL = Bundle.main.appStoreReceiptURL else {
return false
}
// TestFlight builds have "sandboxReceipt" while App Store builds have "receipt"
return receiptURL.lastPathComponent == "sandboxReceipt"
}

static var calendarParameters: [String: String] {
let calendar = Calendar(identifier: .gregorian)
let nowDate = Date()
Expand Down Expand Up @@ -99,13 +146,32 @@ extension DefaultSignalPayload {
#endif
}

/// Detects if the app is running in a TestFlight environment.
///
/// Uses StoreKit 2's `AppTransaction.shared.environment` on iOS 16+ for reliable detection,
/// falling back to receipt-based detection (`sandboxReceipt` vs `receipt`) on earlier OS versions.
///
/// The environment is detected asynchronously during `TelemetryDeck.initialize()` and cached
/// for fast synchronous access throughout the app session.
///
/// - Returns: `true` if running in TestFlight, `false` otherwise
static var isTestFlight: Bool {
guard !isDebug, let path = Bundle.main.appStoreReceiptURL?.path else {
#if DEBUG
return false
}
return path.contains("sandboxReceipt")
#elseif targetEnvironment(simulator)
return false
#else
// Use cached value if available, otherwise use receipt-based fallback
return cachedIsTestFlight ?? isTestFlightViaReceipt()
#endif
}

/// Detects if the app is running in an App Store production environment.
///
/// Uses the same detection strategy as `isTestFlight` (see its documentation for details).
/// Returns `true` for App Store builds, `false` for debug, simulator, and TestFlight builds.
///
/// - Returns: `true` if running in App Store production, `false` otherwise
static var isAppStore: Bool {
#if DEBUG
return false
Expand All @@ -114,7 +180,11 @@ extension DefaultSignalPayload {
#elseif targetEnvironment(simulator)
return false
#else
return !isSimulatorOrTestFlight
// Use cached value if available, otherwise use receipt-based fallback
if let isTestFlight = cachedIsTestFlight {
return !isTestFlight
}
return !Self.isTestFlightViaReceipt()
#endif
}

Expand Down
9 changes: 9 additions & 0 deletions Sources/TelemetryDeck/TelemetryDeck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ public enum TelemetryDeck {
/// For example, you might want to call this in your `init` method of your app's `@main` entry point.
public static func initialize(config: Config) {
TelemetryManager.initializedTelemetryManager = TelemetryManager(configuration: config)

// Initialize environment detection asynchronously
// This will use StoreKit 2 on iOS 16+ or fallback to receipt-based detection
if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) {
Task {
await DefaultSignalPayload.initializeEnvironmentDetection()
}
}
// On older OS versions, the fallback method will be used on first access
}

/// Sends a telemetry signal with optional parameters to TelemetryDeck.
Expand Down