diff --git a/.swiftlint.yml b/.swiftlint.yml index 41adafd..828eb0f 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -5,6 +5,7 @@ disabled_rules: # rule identifiers turned on by default to exclude from running - trailing_comma - function_parameter_count - closure_parameter_position + - nesting opt_in_rules: # some rules are turned off by default, so you need to opt-in - empty_count # Find all the available rules by running: `swiftlint rules` diff --git a/Sources/TelemetryDeck/Helpers/SessionManager.swift b/Sources/TelemetryDeck/Helpers/SessionManager.swift new file mode 100644 index 0000000..11e3924 --- /dev/null +++ b/Sources/TelemetryDeck/Helpers/SessionManager.swift @@ -0,0 +1,265 @@ +#if canImport(WatchKit) +import WatchKit +#elseif canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +@available(watchOS 7, *) +final class SessionManager: @unchecked Sendable { + private struct StoredSession: Codable { + let startedAt: Date + var durationInSeconds: Int + + // Let's save some extra space in UserDefaults by using shorter keys. + private enum CodingKeys: String, CodingKey { + case startedAt = "st" + case durationInSeconds = "dn" + } + } + + static let shared = SessionManager() + + private static let recentSessionsKey = "recentSessions" + private static let deletedSessionsCountKey = "deletedSessionsCount" + + private static let firstSessionDateKey = "firstSessionDate" + private static let distinctDaysUsedKey = "distinctDaysUsed" + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + return decoder + }() + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + // removes sub-second level precision from the start date as we don't need it + encoder.dateEncodingStrategy = .custom { date, encoder in + let timestamp = Int(date.timeIntervalSince1970) + var container = encoder.singleValueContainer() + try container.encode(timestamp) + } + return encoder + }() + + private var recentSessions: [StoredSession] + + private var deletedSessionsCount: Int { + get { TelemetryDeck.customDefaults?.integer(forKey: Self.deletedSessionsCountKey) ?? 0 } + set { + self.persistenceQueue.async { + TelemetryDeck.customDefaults?.set(newValue, forKey: Self.deletedSessionsCountKey) + } + } + } + + var totalSessionsCount: Int { + self.recentSessions.count + self.deletedSessionsCount + } + + var averageSessionSeconds: Int { + guard self.recentSessions.count > 1 else { + return self.recentSessions.first?.durationInSeconds ?? -1 + } + + let completedSessions = self.recentSessions.dropLast() + let totalCompletedSessionSeconds = completedSessions.map(\.durationInSeconds).reduce(into: 0) { $0 += $1 } + return totalCompletedSessionSeconds / completedSessions.count + } + + var previousSessionSeconds: Int? { + self.recentSessions.dropLast().last?.durationInSeconds + } + + var firstSessionDate: String { + get { + TelemetryDeck.customDefaults?.string(forKey: Self.firstSessionDateKey) + ?? ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) + } + set { + self.persistenceQueue.async { + TelemetryDeck.customDefaults?.set(newValue, forKey: Self.firstSessionDateKey) + } + } + } + + var distinctDaysUsed: [String] { + get { TelemetryDeck.customDefaults?.stringArray(forKey: Self.distinctDaysUsedKey) ?? [] } + set { + self.persistenceQueue.async { + TelemetryDeck.customDefaults?.set(newValue, forKey: Self.distinctDaysUsedKey) + } + } + } + + private var currentSessionStartedAt: Date = .distantPast + private var currentSessionDuration: TimeInterval = .zero + + private var sessionDurationUpdater: Timer? + private var sessionDurationLastUpdatedAt: Date? + + private let persistenceQueue = DispatchQueue(label: "com.telemetrydeck.sessionmanager.persistence") + + private init() { + if + let existingSessionData = TelemetryDeck.customDefaults?.data(forKey: Self.recentSessionsKey), + let existingSessions = try? Self.decoder.decode([StoredSession].self, from: existingSessionData) + { + // upon app start, clean up any sessions older than 90 days to keep dict small + let cutoffDate = Date().addingTimeInterval(-(90 * 24 * 60 * 60)) + self.recentSessions = existingSessions.filter { $0.startedAt > cutoffDate } + + // Update deleted sessions count + self.deletedSessionsCount += existingSessions.count - self.recentSessions.count + } else { + self.recentSessions = [] + } + + self.updateDistinctDaysUsed() + self.setupAppLifecycleObservers() + } + + func startNewSession() { + // stop automatic duration counting of previous session + self.stopSessionTimer() + + // if the recent sessions are empty, this must be the first start after installing the app + if self.recentSessions.isEmpty { + // this ensures we only use the date, not the time –> e.g. "2025-01-31" + let todayFormatted = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) + + self.firstSessionDate = todayFormatted + + TelemetryDeck.internalSignal( + "TelemetryDeck.Acquisition.newInstallDetected", + parameters: ["TelemetryDeck.Acquisition.firstSessionDate": todayFormatted] + ) + } + + // start a new session + self.currentSessionStartedAt = Date() + self.currentSessionDuration = .zero + + // start automatic duration counting of new session + self.updateSessionDuration() + self.sessionDurationUpdater = Timer.scheduledTimer( + timeInterval: 1, + target: self, + selector: #selector(updateSessionDuration), + userInfo: nil, + repeats: true + ) + } + + private func stopSessionTimer() { + self.sessionDurationUpdater?.invalidate() + self.sessionDurationUpdater = nil + self.sessionDurationLastUpdatedAt = nil + } + + @objc + private func updateSessionDuration() { + if let sessionDurationLastUpdatedAt { + self.currentSessionDuration += Date().timeIntervalSince(sessionDurationLastUpdatedAt) + } + + self.sessionDurationLastUpdatedAt = Date() + self.persistCurrentSessionIfNeeded() + } + + private func persistCurrentSessionIfNeeded() { + // Ignore sessions under 1 second + guard self.currentSessionDuration >= 1.0 else { return } + + // Add or update the current session + if let existingSessionIndex = self.recentSessions.lastIndex(where: { $0.startedAt == self.currentSessionStartedAt }) { + self.recentSessions[existingSessionIndex].durationInSeconds = Int(self.currentSessionDuration) + } else { + let newSession = StoredSession(startedAt: self.currentSessionStartedAt, durationInSeconds: Int(self.currentSessionDuration)) + self.recentSessions.append(newSession) + } + + // Save changes to UserDefaults without blocking Main thread + self.persistenceQueue.async { + if let updatedSessionData = try? Self.encoder.encode(self.recentSessions) { + TelemetryDeck.customDefaults?.set(updatedSessionData, forKey: Self.recentSessionsKey) + } + } + } + + @objc + private func handleDidEnterBackgroundNotification() { + self.updateSessionDuration() + self.stopSessionTimer() + } + + @objc + private func handleWillEnterForegroundNotification() { + self.updateSessionDuration() + self.sessionDurationUpdater = Timer.scheduledTimer( + timeInterval: 1, + target: self, + selector: #selector(updateSessionDuration), + userInfo: nil, + repeats: true + ) + } + + private func updateDistinctDaysUsed() { + let todayFormatted = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) + + var distinctDays = self.distinctDaysUsed + if distinctDays.last != todayFormatted { + distinctDays.append(todayFormatted) + self.distinctDaysUsed = distinctDays + } + } + + private func setupAppLifecycleObservers() { + #if canImport(WatchKit) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidEnterBackgroundNotification), + name: WKApplication.didEnterBackgroundNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForegroundNotification), + name: WKApplication.willEnterForegroundNotification, + object: nil + ) + #elseif canImport(UIKit) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidEnterBackgroundNotification), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForegroundNotification), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + #elseif canImport(AppKit) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidEnterBackgroundNotification), + name: NSApplication.didResignActiveNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForegroundNotification), + name: NSApplication.willBecomeActiveNotification, + object: nil + ) + #endif + } +} diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift new file mode 100644 index 0000000..bc8668f --- /dev/null +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift @@ -0,0 +1,45 @@ +import Foundation + +extension TelemetryDeck { + private static func acquiredUser( + channel: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + let acquisitionParameters = ["TelemetryDeck.Acquisition.channel": channel] + + self.internalSignal( + "TelemetryDeck.Acquisition.userAcquired", + parameters: acquisitionParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } + + private static func leadStarted( + leadID: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + let leadParameters: [String: String] = ["TelemetryDeck.Acquisition.leadID": leadID] + + self.internalSignal( + "TelemetryDeck.Acquisition.leadStarted", + parameters: leadParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } + + private static func leadConverted( + leadID: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + let leadParameters: [String: String] = ["TelemetryDeck.Acquisition.leadID": leadID] + + self.internalSignal( + "TelemetryDeck.Acquisition.leadConverted", + parameters: leadParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } +} diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift new file mode 100644 index 0000000..f06b40d --- /dev/null +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift @@ -0,0 +1,32 @@ +import Foundation + +extension TelemetryDeck { + private static func onboardingCompleted( + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + let onboardingParameters: [String: String] = [:] + + self.internalSignal( + "TelemetryDeck.Activation.onboardingCompleted", + parameters: onboardingParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } + + private static func coreFeatureUsed( + featureName: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + let featureParameters = [ + "TelemetryDeck.Activation.featureName": featureName + ] + + self.internalSignal( + "TelemetryDeck.Activation.coreFeatureUsed", + parameters: featureParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } +} diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift new file mode 100644 index 0000000..91fe7fd --- /dev/null +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift @@ -0,0 +1,48 @@ +import Foundation + +extension TelemetryDeck { + private static func referralSent( + receiversCount: Int = 1, + kind: String? = nil, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + var referralParameters = ["TelemetryDeck.Referral.receiversCount": String(receiversCount)] + + if let kind { + referralParameters["TelemetryDeck.Referral.kind"] = kind + } + + self.internalSignal( + "TelemetryDeck.Referral.sent", + parameters: referralParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } + + private static func userRatingSubmitted( + rating: Int, + comment: String? = nil, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + guard (0...10).contains(rating) else { + TelemetryManager.shared.configuration.logHandler?.log(.error, message: "Rating must be between 0 and 10") + return + } + + var ratingParameters = [ + "TelemetryDeck.Referral.ratingValue": String(rating) + ] + + if let comment { + ratingParameters["TelemetryDeck.Referral.ratingComment"] = comment + } + + self.internalSignal( + "TelemetryDeck.Referral.userRatingSubmitted", + parameters: ratingParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } +} diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift new file mode 100644 index 0000000..e77e22f --- /dev/null +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift @@ -0,0 +1,17 @@ +import Foundation + +extension TelemetryDeck { + private static func paywallShown( + reason: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + let paywallParameters = ["TelemetryDeck.Revenue.paywallShowReason": reason] + + self.internalSignal( + "TelemetryDeck.Revenue.paywallShown", + parameters: paywallParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } +} diff --git a/Sources/TelemetryDeck/Signals/Signal+Helpers.swift b/Sources/TelemetryDeck/Signals/Signal+Helpers.swift new file mode 100644 index 0000000..c0d6ce7 --- /dev/null +++ b/Sources/TelemetryDeck/Signals/Signal+Helpers.swift @@ -0,0 +1,427 @@ +import Foundation + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +import IOKit +#elseif os(watchOS) +import WatchKit +#elseif os(tvOS) +import TVUIKit +#endif + +extension DefaultSignalPayload { + static var calendarParameters: [String: String] { + let calendar = Calendar(identifier: .gregorian) + let nowDate = Date() + + // Get components for all the metrics we need + let components = calendar.dateComponents( + [.day, .weekday, .weekOfYear, .month, .hour, .quarter, .yearForWeekOfYear], + from: nowDate + ) + + // Calculate day of year + let dayOfYear = calendar.ordinality(of: .day, in: .year, for: nowDate) ?? -1 + + // Convert Sunday=1..Saturday=7 to Monday=1..Sunday=7 + let dayOfWeek = components.weekday.map { $0 == 1 ? 7 : $0 - 1 } ?? -1 + + // Weekend is now days 6 (Saturday) and 7 (Sunday) + let isWeekend = dayOfWeek >= 6 + + return [ + // Day-based metrics + "TelemetryDeck.Calendar.dayOfMonth": "\(components.day ?? -1)", + "TelemetryDeck.Calendar.dayOfWeek": "\(dayOfWeek)", // 1 = Monday, 7 = Sunday + "TelemetryDeck.Calendar.dayOfYear": "\(dayOfYear)", + + // Week-based metrics + "TelemetryDeck.Calendar.weekOfYear": "\(components.weekOfYear ?? -1)", + "TelemetryDeck.Calendar.isWeekend": "\(isWeekend)", + + // Month and quarter + "TelemetryDeck.Calendar.monthOfYear": "\(components.month ?? -1)", + "TelemetryDeck.Calendar.quarterOfYear": "\(components.quarter ?? -1)", + + // Hours in 1-24 format + "TelemetryDeck.Calendar.hourOfDay": "\((components.hour ?? -1) + 1)" + ] + } + + @MainActor + static var accessibilityParameters: [String: String] { + var a11yParams: [String: String] = [:] + + #if os(iOS) || os(tvOS) + a11yParams["TelemetryDeck.Accessibility.isReduceMotionEnabled"] = "\(UIAccessibility.isReduceMotionEnabled)" + a11yParams["TelemetryDeck.Accessibility.isBoldTextEnabled"] = "\(UIAccessibility.isBoldTextEnabled)" + a11yParams["TelemetryDeck.Accessibility.isInvertColorsEnabled"] = "\(UIAccessibility.isInvertColorsEnabled)" + a11yParams["TelemetryDeck.Accessibility.isDarkerSystemColorsEnabled"] = "\(UIAccessibility.isDarkerSystemColorsEnabled)" + a11yParams["TelemetryDeck.Accessibility.isReduceTransparencyEnabled"] = "\(UIAccessibility.isReduceTransparencyEnabled)" + if #available(iOS 13.0, *) { + a11yParams["TelemetryDeck.Accessibility.shouldDifferentiateWithoutColor"] = "\(UIAccessibility.shouldDifferentiateWithoutColor)" + } + + // in app extensions `UIApplication.shared` is not available + if !Bundle.main.bundlePath.hasSuffix(".appex") { + a11yParams["TelemetryDeck.Accessibility.preferredContentSizeCategory"] = UIApplication.shared.preferredContentSizeCategory.rawValue + .replacingOccurrences(of: "UICTContentSizeCategory", with: "") // replaces output "UICTContentSizeCategoryL" with "L" + } + #elseif os(macOS) + if let systemPrefs = UserDefaults.standard.persistentDomain(forName: "com.apple.universalaccess") { + a11yParams["TelemetryDeck.Accessibility.isReduceMotionEnabled"] = "\(systemPrefs["reduceMotion"] as? Bool ?? false)" + a11yParams["TelemetryDeck.Accessibility.isInvertColorsEnabled"] = "\(systemPrefs["InvertColors"] as? Bool ?? false)" + } + #endif + + return a11yParams + } + + static var isSimulatorOrTestFlight: Bool { + isSimulator || isTestFlight + } + + static var isSimulator: Bool { + #if targetEnvironment(simulator) + return true + #else + return false + #endif + } + + static var isDebug: Bool { + #if DEBUG + return true + #else + return false + #endif + } + + static var isTestFlight: Bool { + guard !isDebug, let path = Bundle.main.appStoreReceiptURL?.path else { + return false + } + return path.contains("sandboxReceipt") + } + + static var isAppStore: Bool { + #if DEBUG + return false + #elseif TARGET_OS_OSX || TARGET_OS_MACCATALYST + return false + #elseif targetEnvironment(simulator) + return false + #else + return !isSimulatorOrTestFlight + #endif + } + + /// The operating system and its version + static var systemVersion: String { + let majorVersion = ProcessInfo.processInfo.operatingSystemVersion.majorVersion + let minorVersion = ProcessInfo.processInfo.operatingSystemVersion.minorVersion + let patchVersion = ProcessInfo.processInfo.operatingSystemVersion.patchVersion + return "\(platform) \(majorVersion).\(minorVersion).\(patchVersion)" + } + + /// The major system version, i.e. iOS 15 + static var majorSystemVersion: String { + return "\(platform) \(ProcessInfo.processInfo.operatingSystemVersion.majorVersion)" + } + + /// The major system version, i.e. iOS 15 + static var majorMinorSystemVersion: String { + let majorVersion = ProcessInfo.processInfo.operatingSystemVersion.majorVersion + let minorVersion = ProcessInfo.processInfo.operatingSystemVersion.minorVersion + return "\(platform) \(majorVersion).\(minorVersion)" + } + + /// The Bundle Short Version String, as described in Info.plist + static var appVersion: String { + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + return appVersion ?? "0" + } + + /// The Bundle Version String, as described in Info.plist + static var buildNumber: String { + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String + return buildNumber ?? "0" + } + + /// The extension identifer for the active resource, if available. + /// + /// This provides a value such as `com.apple.widgetkit-extension` when TelemetryDeck is run from a widget. + static var extensionIdentifier: String? { + let container = Bundle.main.infoDictionary?["NSExtension"] as? [String: Any] + return container?["NSExtensionPointIdentifier"] as? String + } + + /// The modelname as reported by systemInfo.machine + static var modelName: String { + #if os(iOS) + if #available(iOS 14.0, *) { + if ProcessInfo.processInfo.isiOSAppOnMac { + var size = 0 + sysctlbyname("hw.model", nil, &size, nil, 0) + var machine = [CChar](repeating: 0, count: size) + sysctlbyname("hw.model", &machine, &size, nil, 0) + return String(cString: machine) + } + } + #endif + + #if os(macOS) + if #available(macOS 11, *) { + let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice")) + var modelIdentifier: String? + + if let modelData = IORegistryEntryCreateCFProperty(service, "model" as CFString, kCFAllocatorDefault, 0).takeRetainedValue() as? Data { + if let modelIdentifierCString = String(data: modelData, encoding: .utf8)?.cString(using: .utf8) { + modelIdentifier = String(cString: modelIdentifierCString) + } + } + + IOObjectRelease(service) + if let modelIdentifier = modelIdentifier { + return modelIdentifier + } + } + #endif + + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + return identifier + } + + /// The build architecture + static var architecture: String { + #if arch(x86_64) + return "x86_64" + #elseif arch(arm) + return "arm" + #elseif arch(arm64) + return "arm64" + #elseif arch(i386) + return "i386" + #elseif arch(powerpc64) + return "powerpc64" + #elseif arch(powerpc64le) + return "powerpc64le" + #elseif arch(s390x) + return "s390x" + #else + return "unknown" + #endif + } + + /// The operating system as reported by Swift. Note that this will report catalyst apps and iOS apps running on + /// macOS as "iOS". See `platform` for an alternative. + static var operatingSystem: String { + #if os(macOS) + return "macOS" + #elseif os(visionOS) + return "visionOS" + #elseif os(iOS) + return "iOS" + #elseif os(watchOS) + return "watchOS" + #elseif os(tvOS) + return "tvOS" + #else + return "Unknown Operating System" + #endif + } + + /// Based on the operating version reported by swift, but adding some smartness to better detect the actual + /// platform. Should correctly identify catalyst apps and iOS apps on macOS. + static var platform: String { + #if os(macOS) + return "macOS" + #elseif os(visionOS) + return "visionOS" + #elseif os(iOS) + #if targetEnvironment(macCatalyst) + return "macCatalyst" + #else + if #available(iOS 14.0, *), ProcessInfo.processInfo.isiOSAppOnMac { + return "isiOSAppOnMac" + } + return "iOS" + #endif + #elseif os(watchOS) + return "watchOS" + #elseif os(tvOS) + return "tvOS" + #else + return "Unknown Platform" + #endif + } + + /// The target environment as reported by swift. Either "simulator", "macCatalyst" or "native" + static var targetEnvironment: String { + #if targetEnvironment(simulator) + return "simulator" + #elseif targetEnvironment(macCatalyst) + return "macCatalyst" + #else + return "native" + #endif + } + + /// The locale identifier the app currently runs in. E.g. `en_DE` for an app that does not support German on a device with preferences `[German, English]`, and region Germany. + static var locale: String { + return Locale.current.identifier + } + + /// The region identifier both the user most prefers and also the app is set to. They are always the same because formatters in apps always auto-adjust to the users preferences. + static var region: String { + if #available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) { + return Locale.current.region?.identifier ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_")).last! + } else { + return Locale.current.regionCode ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_")).last! + } + } + + /// The language identifier the app is currently running in. This represents the language the system (or the user) has chosen for the app to run in. + static var appLanguage: String { + if #available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) { + return Locale.current.language.languageCode?.identifier ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_"))[0] + } else { + return Locale.current.languageCode ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_"))[0] + } + } + + /// The language identifier of the users most preferred language set on the device. Returns also languages the current app is not even localized to. + static var preferredLanguage: String { + let preferredLocaleIdentifier = Locale.preferredLanguages.first ?? "zz-ZZ" + return preferredLocaleIdentifier.components(separatedBy: .init(charactersIn: "-_"))[0] + } + + /// The color scheme set by the user. Returns `N/A` on unsupported platforms + @MainActor + static var colorScheme: String { + #if os(iOS) || os(tvOS) + switch UIScreen.main.traitCollection.userInterfaceStyle { + case .dark: return "Dark" + case .light: return "Light" + default: return "N/A" + } + #elseif os(macOS) + if #available(macOS 10.14, *) { + switch NSAppearance.current.name { + case .aqua: return "Light" + case .darkAqua: return "Dark" + default: return "N/A" + } + } else { + return "Light" + } + #else + return "N/A" + #endif + } + + /// The user-preferred layout direction (left-to-right or right-to-left) based on the current language/region settings. + @MainActor + static var layoutDirection: String { + #if os(iOS) || os(tvOS) + if Bundle.main.bundlePath.hasSuffix(".appex") { + // we're in an app extension, where `UIApplication.shared` is not available + return "N/A" + } else { + return UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? "leftToRight" : "rightToLeft" + } + #elseif os(macOS) + if let nsApp = NSApp { + return nsApp.userInterfaceLayoutDirection == .leftToRight ? "leftToRight" : "rightToLeft" + } else { + return "N/A" + } + #else + return "N/A" + #endif + } + + /// The current devices screen resolution width in points. + @MainActor + static var screenResolutionWidth: String { + #if os(iOS) || os(tvOS) + return "\(UIScreen.main.bounds.width)" + #elseif os(watchOS) + return "\(WKInterfaceDevice.current().screenBounds.width)" + #elseif os(macOS) + if let screen = NSScreen.main { + return "\(screen.frame.width)" + } + return "N/A" + #else + return "N/A" + #endif + } + + /// The current devices screen resolution height in points. + @MainActor + static var screenResolutionHeight: String { + #if os(iOS) || os(tvOS) + return "\(UIScreen.main.bounds.height)" + #elseif os(watchOS) + return "\(WKInterfaceDevice.current().screenBounds.height)" + #elseif os(macOS) + if let screen = NSScreen.main { + return "\(screen.frame.height)" + } + return "N/A" + #else + return "N/A" + #endif + } + + @MainActor + static var screenScaleFactor: String { + #if os(iOS) || os(tvOS) + return "\(UIScreen.main.scale)" + #elseif os(macOS) + if let screen = NSScreen.main { + return "\(screen.backingScaleFactor)" + } + return "N/A" + #else + return "N/A" + #endif + } + + /// The current devices screen orientation. Returns `Fixed` for devices that don't support an orientation change. + @MainActor + static var orientation: String { + #if os(iOS) + switch UIDevice.current.orientation { + case .portrait, .portraitUpsideDown: return "Portrait" + case .landscapeLeft, .landscapeRight: return "Landscape" + default: return "Unknown" + } + #else + return "Fixed" + #endif + } + + /// The devices current time zone in the modern `UTC` format, such as `UTC+1`, or `UTC-3:30`. + static var timeZone: String { + let secondsFromGMT = TimeZone.current.secondsFromGMT() + let hours = secondsFromGMT / 3600 + let minutes = abs(secondsFromGMT / 60 % 60) + + let sign = secondsFromGMT >= 0 ? "+" : "-" + if minutes > 0 { + return "UTC\(sign)\(hours):\(String(format: "%02d", minutes))" + } else { + return "UTC\(sign)\(hours)" + } + } +} diff --git a/Sources/TelemetryDeck/Signals/Signal.swift b/Sources/TelemetryDeck/Signals/Signal.swift index 83f6684..b5499b9 100644 --- a/Sources/TelemetryDeck/Signals/Signal.swift +++ b/Sources/TelemetryDeck/Signals/Signal.swift @@ -102,6 +102,7 @@ public struct DefaultSignalPayload: Encodable { ] parameters.merge(self.accessibilityParameters, uniquingKeysWith: { $1 }) + parameters.merge(self.calendarParameters, uniquingKeysWith: { $1 }) if let extensionIdentifier = Self.extensionIdentifier { // deprecated name @@ -111,386 +112,23 @@ public struct DefaultSignalPayload: Encodable { parameters["TelemetryDeck.RunContext.extensionIdentifier"] = extensionIdentifier } - return parameters - } -} - -// MARK: - Helpers - -extension DefaultSignalPayload { - @MainActor - static var accessibilityParameters: [String: String] { - var a11yParams: [String: String] = [:] - - #if os(iOS) || os(tvOS) - a11yParams["TelemetryDeck.Accessibility.isReduceMotionEnabled"] = "\(UIAccessibility.isReduceMotionEnabled)" - a11yParams["TelemetryDeck.Accessibility.isBoldTextEnabled"] = "\(UIAccessibility.isBoldTextEnabled)" - a11yParams["TelemetryDeck.Accessibility.isInvertColorsEnabled"] = "\(UIAccessibility.isInvertColorsEnabled)" - a11yParams["TelemetryDeck.Accessibility.isDarkerSystemColorsEnabled"] = "\(UIAccessibility.isDarkerSystemColorsEnabled)" - a11yParams["TelemetryDeck.Accessibility.isReduceTransparencyEnabled"] = "\(UIAccessibility.isReduceTransparencyEnabled)" - if #available(iOS 13.0, *) { - a11yParams["TelemetryDeck.Accessibility.shouldDifferentiateWithoutColor"] = "\(UIAccessibility.shouldDifferentiateWithoutColor)" - } - - // in app extensions `UIApplication.shared` is not available - if !Bundle.main.bundlePath.hasSuffix(".appex") { - a11yParams["TelemetryDeck.Accessibility.preferredContentSizeCategory"] = UIApplication.shared.preferredContentSizeCategory.rawValue - .replacingOccurrences(of: "UICTContentSizeCategory", with: "") // replaces output "UICTContentSizeCategoryL" with "L" - } - - #elseif os(macOS) - if let systemPrefs = UserDefaults.standard.persistentDomain(forName: "com.apple.universalaccess") { - a11yParams["TelemetryDeck.Accessibility.isReduceMotionEnabled"] = "\(systemPrefs["reduceMotion"] as? Bool ?? false)" - a11yParams["TelemetryDeck.Accessibility.isInvertColorsEnabled"] = "\(systemPrefs["InvertColors"] as? Bool ?? false)" - } - #endif - - return a11yParams - } - - static var isSimulatorOrTestFlight: Bool { - isSimulator || isTestFlight - } - - static var isSimulator: Bool { - #if targetEnvironment(simulator) - return true - #else - return false - #endif - } - - static var isDebug: Bool { - #if DEBUG - return true - #else - return false - #endif - } - - static var isTestFlight: Bool { - guard !isDebug, let path = Bundle.main.appStoreReceiptURL?.path else { - return false - } - return path.contains("sandboxReceipt") - } - - static var isAppStore: Bool { - #if DEBUG - return false - #elseif TARGET_OS_OSX || TARGET_OS_MACCATALYST - return false - #elseif targetEnvironment(simulator) - return false - #else - return !isSimulatorOrTestFlight - #endif - } - - /// The operating system and its version - static var systemVersion: String { - let majorVersion = ProcessInfo.processInfo.operatingSystemVersion.majorVersion - let minorVersion = ProcessInfo.processInfo.operatingSystemVersion.minorVersion - let patchVersion = ProcessInfo.processInfo.operatingSystemVersion.patchVersion - return "\(platform) \(majorVersion).\(minorVersion).\(patchVersion)" - } - - /// The major system version, i.e. iOS 15 - static var majorSystemVersion: String { - return "\(platform) \(ProcessInfo.processInfo.operatingSystemVersion.majorVersion)" - } - - /// The major system version, i.e. iOS 15 - static var majorMinorSystemVersion: String { - let majorVersion = ProcessInfo.processInfo.operatingSystemVersion.majorVersion - let minorVersion = ProcessInfo.processInfo.operatingSystemVersion.minorVersion - return "\(platform) \(majorVersion).\(minorVersion)" - } - - /// The Bundle Short Version String, as described in Info.plist - static var appVersion: String { - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - return appVersion ?? "0" - } - - /// The Bundle Version String, as described in Info.plist - static var buildNumber: String { - let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String - return buildNumber ?? "0" - } - - /// The extension identifer for the active resource, if available. - /// - /// This provides a value such as `com.apple.widgetkit-extension` when TelemetryDeck is run from a widget. - static var extensionIdentifier: String? { - let container = Bundle.main.infoDictionary?["NSExtension"] as? [String: Any] - return container?["NSExtensionPointIdentifier"] as? String - } - - /// The modelname as reported by systemInfo.machine - static var modelName: String { - #if os(iOS) - if #available(iOS 14.0, *) { - if ProcessInfo.processInfo.isiOSAppOnMac { - var size = 0 - sysctlbyname("hw.model", nil, &size, nil, 0) - var machine = [CChar](repeating: 0, count: size) - sysctlbyname("hw.model", &machine, &size, nil, 0) - return String(cString: machine) - } - } - #endif - - #if os(macOS) - if #available(macOS 11, *) { - let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice")) - var modelIdentifier: String? - - if let modelData = IORegistryEntryCreateCFProperty(service, "model" as CFString, kCFAllocatorDefault, 0).takeRetainedValue() as? Data { - if let modelIdentifierCString = String(data: modelData, encoding: .utf8)?.cString(using: .utf8) { - modelIdentifier = String(cString: modelIdentifierCString) - } - } - - IOObjectRelease(service) - if let modelIdentifier = modelIdentifier { - return modelIdentifier - } - } - #endif - - var systemInfo = utsname() - uname(&systemInfo) - let machineMirror = Mirror(reflecting: systemInfo.machine) - let identifier = machineMirror.children.reduce("") { identifier, element in - guard let value = element.value as? Int8, value != 0 else { return identifier } - return identifier + String(UnicodeScalar(UInt8(value))) - } - return identifier - } - - /// The build architecture - static var architecture: String { - #if arch(x86_64) - return "x86_64" - #elseif arch(arm) - return "arm" - #elseif arch(arm64) - return "arm64" - #elseif arch(i386) - return "i386" - #elseif arch(powerpc64) - return "powerpc64" - #elseif arch(powerpc64le) - return "powerpc64le" - #elseif arch(s390x) - return "s390x" - #else - return "unknown" - #endif - } - - /// The operating system as reported by Swift. Note that this will report catalyst apps and iOS apps running on - /// macOS as "iOS". See `platform` for an alternative. - static var operatingSystem: String { - #if os(macOS) - return "macOS" - #elseif os(visionOS) - return "visionOS" - #elseif os(iOS) - return "iOS" - #elseif os(watchOS) - return "watchOS" - #elseif os(tvOS) - return "tvOS" - #else - return "Unknown Operating System" - #endif - } - - /// Based on the operating version reported by swift, but adding some smartness to better detect the actual - /// platform. Should correctly identify catalyst apps and iOS apps on macOS. - static var platform: String { - #if os(macOS) - return "macOS" - #elseif os(visionOS) - return "visionOS" - #elseif os(iOS) - #if targetEnvironment(macCatalyst) - return "macCatalyst" - #else - if #available(iOS 14.0, *), ProcessInfo.processInfo.isiOSAppOnMac { - return "isiOSAppOnMac" - } - return "iOS" - #endif - #elseif os(watchOS) - return "watchOS" - #elseif os(tvOS) - return "tvOS" - #else - return "Unknown Platform" - #endif - } - - /// The target environment as reported by swift. Either "simulator", "macCatalyst" or "native" - static var targetEnvironment: String { - #if targetEnvironment(simulator) - return "simulator" - #elseif targetEnvironment(macCatalyst) - return "macCatalyst" - #else - return "native" - #endif - } - - /// The locale identifier the app currently runs in. E.g. `en_DE` for an app that does not support German on a device with preferences `[German, English]`, and region Germany. - static var locale: String { - return Locale.current.identifier - } - - /// The region identifier both the user most prefers and also the app is set to. They are always the same because formatters in apps always auto-adjust to the users preferences. - static var region: String { - if #available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) { - return Locale.current.region?.identifier ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_")).last! - } else { - return Locale.current.regionCode ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_")).last! - } - } - - /// The language identifier the app is currently running in. This represents the language the system (or the user) has chosen for the app to run in. - static var appLanguage: String { - if #available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) { - return Locale.current.language.languageCode?.identifier ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_"))[0] - } else { - return Locale.current.languageCode ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_"))[0] - } - } - - /// The language identifier of the users most preferred language set on the device. Returns also languages the current app is not even localized to. - static var preferredLanguage: String { - let preferredLocaleIdentifier = Locale.preferredLanguages.first ?? "zz-ZZ" - return preferredLocaleIdentifier.components(separatedBy: .init(charactersIn: "-_"))[0] - } - - /// The color scheme set by the user. Returns `N/A` on unsupported platforms - @MainActor - static var colorScheme: String { - #if os(iOS) || os(tvOS) - switch UIScreen.main.traitCollection.userInterfaceStyle { - case .dark: return "Dark" - case .light: return "Light" - default: return "N/A" - } - #elseif os(macOS) - if #available(macOS 10.14, *) { - switch NSAppearance.current.name { - case .aqua: return "Light" - case .darkAqua: return "Dark" - default: return "N/A" - } - } else { - return "Light" - } - #else - return "N/A" - #endif - } - - /// The user-preferred layout direction (left-to-right or right-to-left) based on the current language/region settings. - @MainActor - static var layoutDirection: String { - #if os(iOS) || os(tvOS) - if Bundle.main.bundlePath.hasSuffix(".appex") { - // we're in an app extension, where `UIApplication.shared` is not available - return "N/A" - } else { - return UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? "leftToRight" : "rightToLeft" - } - #elseif os(macOS) - if let nsApp = NSApp { - return nsApp.userInterfaceLayoutDirection == .leftToRight ? "leftToRight" : "rightToLeft" - } else { - return "N/A" - } - #else - return "N/A" - #endif - } - - /// The current devices screen resolution width in points. - @MainActor - static var screenResolutionWidth: String { - #if os(iOS) || os(tvOS) - return "\(UIScreen.main.bounds.width)" - #elseif os(watchOS) - return "\(WKInterfaceDevice.current().screenBounds.width)" - #elseif os(macOS) - if let screen = NSScreen.main { - return "\(screen.frame.width)" - } - return "N/A" - #else - return "N/A" - #endif - } - - /// The current devices screen resolution height in points. - @MainActor - static var screenResolutionHeight: String { - #if os(iOS) || os(tvOS) - return "\(UIScreen.main.bounds.height)" - #elseif os(watchOS) - return "\(WKInterfaceDevice.current().screenBounds.height)" - #elseif os(macOS) - if let screen = NSScreen.main { - return "\(screen.frame.height)" + // Pirate Metrics + if #available(watchOS 7, *) { + parameters.merge( + [ + "TelemetryDeck.Acquisition.firstSessionDate": SessionManager.shared.firstSessionDate, + "TelemetryDeck.Retention.averageSessionSeconds": "\(SessionManager.shared.averageSessionSeconds)", + "TelemetryDeck.Retention.distinctDaysUsed": "\(SessionManager.shared.distinctDaysUsed.count)", + "TelemetryDeck.Retention.totalSessionsCount": "\(SessionManager.shared.totalSessionsCount)", + ], + uniquingKeysWith: { $1 } + ) + + if let previousSessionSeconds = SessionManager.shared.previousSessionSeconds { + parameters["TelemetryDeck.Retention.previousSessionSeconds"] = "\(previousSessionSeconds)" } - return "N/A" - #else - return "N/A" - #endif - } - - @MainActor - static var screenScaleFactor: String { - #if os(iOS) || os(tvOS) - return "\(UIScreen.main.scale)" - #elseif os(macOS) - if let screen = NSScreen.main { - return "\(screen.backingScaleFactor)" } - return "N/A" - #else - return "N/A" - #endif - } - /// The current devices screen orientation. Returns `Fixed` for devices that don't support an orientation change. - @MainActor - static var orientation: String { - #if os(iOS) - switch UIDevice.current.orientation { - case .portrait, .portraitUpsideDown: return "Portrait" - case .landscapeLeft, .landscapeRight: return "Landscape" - default: return "Unknown" - } - #else - return "Fixed" - #endif - } - - /// The devices current time zone in the modern `UTC` format, such as `UTC+1`, or `UTC-3:30`. - static var timeZone: String { - let secondsFromGMT = TimeZone.current.secondsFromGMT() - let hours = secondsFromGMT / 3600 - let minutes = abs(secondsFromGMT / 60 % 60) - - let sign = secondsFromGMT >= 0 ? "+" : "-" - if minutes > 0 { - return "UTC\(sign)\(hours):\(String(format: "%02d", minutes))" - } else { - return "UTC\(sign)\(hours)" - } + return parameters } } diff --git a/Sources/TelemetryDeck/Signals/SignalManager.swift b/Sources/TelemetryDeck/Signals/SignalManager.swift index ed82613..a32533c 100644 --- a/Sources/TelemetryDeck/Signals/SignalManager.swift +++ b/Sources/TelemetryDeck/Signals/SignalManager.swift @@ -250,14 +250,6 @@ private extension SignalManager { // MARK: - Helpers extension SignalManager { - #if os(macOS) - /// A custom ``UserDefaults`` instance specific to TelemetryDeck and the current application. - private var customDefaults: UserDefaults? { - let appIdHash = CryptoHashing.sha256(string: configuration.telemetryAppID, salt: "") - return UserDefaults(suiteName: "com.telemetrydeck.\(appIdHash.suffix(12))") - } - #endif - /// The default user identifier. If the platform supports it, the ``identifierForVendor``. Otherwise, a self-generated `UUID` which is persisted in custom `UserDefaults` if available. @MainActor var defaultUserIdentifier: String { @@ -272,11 +264,11 @@ extension SignalManager { return "unknown user \(DefaultSignalPayload.platform) \(DefaultSignalPayload.systemVersion) \(DefaultSignalPayload.buildNumber)" } #elseif os(macOS) - if let customDefaults = customDefaults, let defaultUserIdentifier = customDefaults.string(forKey: "defaultUserIdentifier") { + if let customDefaults = TelemetryDeck.customDefaults, let defaultUserIdentifier = customDefaults.string(forKey: "defaultUserIdentifier") { return defaultUserIdentifier } else { let defaultUserIdentifier = UUID().uuidString - customDefaults?.set(defaultUserIdentifier, forKey: "defaultUserIdentifier") + TelemetryDeck.customDefaults?.set(defaultUserIdentifier, forKey: "defaultUserIdentifier") return defaultUserIdentifier } #else diff --git a/Sources/TelemetryDeck/TelemetryClient.swift b/Sources/TelemetryDeck/TelemetryClient.swift index 0c9ae95..d08429f 100644 --- a/Sources/TelemetryDeck/TelemetryClient.swift +++ b/Sources/TelemetryDeck/TelemetryClient.swift @@ -62,13 +62,17 @@ public final class TelemetryManagerConfiguration: @unchecked Sendable { /// A random identifier for the current user session. /// - /// On iOS, tvOS, and watchOS, the session identifier will automatically update whenever your app returns from background, or if it is - /// launched from cold storage. On other platforms, a new identifier will be generated each time your app launches. If you'd like + /// On iOS, tvOS, and watchOS, the session identifier will automatically update whenever your app returns from background after 5 minutes, + /// or if it is launched from cold storage. On other platforms, a new identifier will be generated each time your app launches. If you'd like /// more fine-grained session support, write a new random session identifier into this property each time a new session begins. /// - /// Beginning a new session automatically sends a "newSessionBegan" Signal if `sendNewSessionBeganSignal` is `true` + /// Beginning a new session automatically sends a "TelemetryDeck.Session.started" Signal if `sendNewSessionBeganSignal` is `true` public var sessionID = UUID() { didSet { + if #available(watchOS 7, *) { + SessionManager.shared.startNewSession() + } + if sendNewSessionBeganSignal { TelemetryDeck.internalSignal("TelemetryDeck.Session.started") } diff --git a/Sources/TelemetryDeck/TelemetryDeck.swift b/Sources/TelemetryDeck/TelemetryDeck.swift index 2a178f7..34b8b82 100644 --- a/Sources/TelemetryDeck/TelemetryDeck.swift +++ b/Sources/TelemetryDeck/TelemetryDeck.swift @@ -221,4 +221,13 @@ public enum TelemetryDeck { public static func generateNewSession() { TelemetryManager.shared.configuration.sessionID = UUID() } + + // MARK: - Internals + /// A custom ``UserDefaults`` instance specific to TelemetryDeck and the current application. + static var customDefaults: UserDefaults? { + guard let configuration = TelemetryManager.initializedTelemetryManager?.configuration else { return nil } + + let appIdHash = CryptoHashing.sha256(string: configuration.telemetryAppID, salt: "") + return UserDefaults(suiteName: "com.telemetrydeck.\(appIdHash.suffix(12))") + } }