diff --git a/Packages/Shared/CMUXMobileCore/README.md b/Packages/Shared/CMUXMobileCore/README.md new file mode 100644 index 000000000000..bb847cd3ddd6 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/README.md @@ -0,0 +1,18 @@ +# CMUXMobileCore + +Shared protocol seams and value types used by both the iOS and macOS apps. +Higher-level mobile packages depend on this package instead of importing one +another for shared contracts. + +## Testing telemetry consent + +Inject a suite-scoped defaults store so tests do not read or mutate the user's +preferences: + +```swift +let defaults = UserDefaults(suiteName: "example.telemetry-test")! +let consent = UserDefaultsAnalyticsConsentProvider(defaults: defaults) + +defaults.set(true, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey) +#expect(consent.isTelemetryEnabled) +``` diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/AnalyticsConsentProviding.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/AnalyticsConsentProviding.swift new file mode 100644 index 000000000000..920a879ccd67 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/AnalyticsConsentProviding.swift @@ -0,0 +1,12 @@ +/// The shared opt-out gate consulted before sending telemetry. +/// +/// Analytics and crash-reporting infrastructure depend on this lower-level +/// seam so both obey the same live consent source without depending on each +/// other. +public protocol AnalyticsConsentProviding: Sendable { + /// Whether anonymous product telemetry may currently be sent. + /// + /// A conformer must return its current value on every read so consent + /// changes take effect without rebuilding the telemetry graph. + var isTelemetryEnabled: Bool { get } +} diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/UserDefaultsAnalyticsConsentProvider.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/UserDefaultsAnalyticsConsentProvider.swift new file mode 100644 index 000000000000..290544ca0f05 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/UserDefaultsAnalyticsConsentProvider.swift @@ -0,0 +1,34 @@ +public import Foundation + +/// A consent provider backed by the shared telemetry opt-out in `UserDefaults`. +/// +/// This provider reads the same backing key as the app's anonymous-telemetry +/// setting. A missing value defaults to disabled, and every access reads the +/// store again so live setting changes apply without rebuilding consumers. +/// +/// ```swift +/// let consent = UserDefaultsAnalyticsConsentProvider(defaults: .standard) +/// if consent.isTelemetryEnabled { +/// // Start telemetry infrastructure. +/// } +/// ``` +public struct UserDefaultsAnalyticsConsentProvider: AnalyticsConsentProviding { + /// The `UserDefaults` key shared with the anonymous-telemetry setting. + public static let telemetryKey = "sendAnonymousTelemetry" + + // UserDefaults is Apple-documented thread-safe; OK to hold nonisolated. + private nonisolated(unsafe) let defaults: UserDefaults + + /// Creates a consent provider over the given defaults store. + /// + /// - Parameter defaults: The store holding the opt-out flag. Inject a + /// suite-scoped store in tests; the app uses `.standard`. + public init(defaults: UserDefaults) { + self.defaults = defaults + } + + /// Whether anonymous product telemetry is enabled in the defaults store. + public var isTelemetryEnabled: Bool { + defaults.object(forKey: Self.telemetryKey) as? Bool ?? false + } +} diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/UserDefaultsAnalyticsConsentProviderTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/UserDefaultsAnalyticsConsentProviderTests.swift new file mode 100644 index 000000000000..022a1a934449 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/UserDefaultsAnalyticsConsentProviderTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing + +@testable import CMUXMobileCore + +@Suite struct UserDefaultsAnalyticsConsentProviderTests { + @Test func defaultsOffAndTracksLiveChanges() throws { + let suiteName = "cmux.analytics-consent.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let consent = UserDefaultsAnalyticsConsentProvider(defaults: defaults) + #expect(!consent.isTelemetryEnabled) + + defaults.set(true, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey) + #expect(consent.isTelemetryEnabled) + + defaults.set(false, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey) + #expect(!consent.isTelemetryEnabled) + } +} diff --git a/Packages/iOS/CmuxMobileAnalytics/Package.swift b/Packages/iOS/CmuxMobileAnalytics/Package.swift index e5615e21ad65..322c37dad72b 100644 --- a/Packages/iOS/CmuxMobileAnalytics/Package.swift +++ b/Packages/iOS/CmuxMobileAnalytics/Package.swift @@ -39,7 +39,10 @@ let package = Package( ), .testTarget( name: "CmuxMobileAnalyticsTests", - dependencies: ["CmuxMobileAnalytics"], + dependencies: [ + "CMUXMobileCore", + "CmuxMobileAnalytics", + ], swiftSettings: [ .swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny"), diff --git a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/AnalyticsConsentProvider.swift b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/AnalyticsConsentProvider.swift new file mode 100644 index 000000000000..5f7a163de9a7 --- /dev/null +++ b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/AnalyticsConsentProvider.swift @@ -0,0 +1,26 @@ +public import CMUXMobileCore + +/// A consent provider backed by an injected closure. +/// +/// The closure is read on each capture so a live settings change takes effect +/// immediately. +/// +/// ```swift +/// let consent = AnalyticsConsentProvider { +/// settings.sendAnonymousTelemetry +/// } +/// ``` +public struct AnalyticsConsentProvider: AnalyticsConsentProviding { + private let isEnabled: @Sendable () -> Bool + + /// Wraps a closure that reports the current opt-out state. + /// + /// - Parameter isEnabled: Returns `true` when telemetry is allowed. Read on + /// every capture so a live toggle is honored without rewiring. + public init(isEnabled: @escaping @Sendable () -> Bool) { + self.isEnabled = isEnabled + } + + /// Whether anonymous product telemetry may currently be sent. + public var isTelemetryEnabled: Bool { isEnabled() } +} diff --git a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/AnalyticsConsentProviding.swift b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/AnalyticsConsentProviding.swift deleted file mode 100644 index d1fd1636568f..000000000000 --- a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/AnalyticsConsentProviding.swift +++ /dev/null @@ -1,66 +0,0 @@ -public import Foundation - -/// The opt-out gate the emitter consults before every capture and identify. -/// -/// The analytics package must not depend on the settings domain (`CmuxSettings`), -/// so the telemetry opt-out is injected as this seam rather than read directly. -/// The app composition root provides a conformer backed by -/// `CmuxSettings.catalog.app.sendAnonymousTelemetry`; tests provide a fixed -/// value. The gate is evaluated *inside* the emitter so no fire-site can bypass -/// it. -public protocol AnalyticsConsentProviding: Sendable { - /// Whether anonymous product telemetry may currently be sent. - /// - /// When `false`, the emitter drops every event and identify call and sends - /// nothing over the network. - var isTelemetryEnabled: Bool { get } -} - -/// A consent provider backed by an injected closure. -/// -/// Lets the composition root bridge the telemetry opt-out into the analytics -/// package without an import edge. The closure is read on each capture so a live -/// settings change takes effect immediately. -/// -/// ```swift -/// let consent = AnalyticsConsentProvider { defaults.bool(forKey: "sendAnonymousTelemetry") } -/// ``` -public struct AnalyticsConsentProvider: AnalyticsConsentProviding { - private let isEnabled: @Sendable () -> Bool - - /// Wraps a closure that reports the current opt-out state. - /// - Parameter isEnabled: Returns `true` when telemetry is allowed. Read on - /// every capture so a live toggle is honored without rewiring. - public init(isEnabled: @escaping @Sendable () -> Bool) { - self.isEnabled = isEnabled - } - - public var isTelemetryEnabled: Bool { isEnabled() } -} - -/// A consent provider backed by the shared telemetry opt-out in `UserDefaults`. -/// -/// The iOS app cannot import the macOS-only `CmuxSettings` package, so this reads -/// the same backing key that `CmuxSettings.catalog.app.sendAnonymousTelemetry` -/// writes (`"sendAnonymousTelemetry"`). iOS defaults to telemetry off until the -/// user enables the Settings toggle. The value is read on every capture so -/// toggling the switch takes effect immediately without rewiring. -public struct UserDefaultsAnalyticsConsentProvider: AnalyticsConsentProviding { - /// The `UserDefaults` key shared with the settings catalog's - /// `app.sendAnonymousTelemetry` entry. - public static let telemetryKey = "sendAnonymousTelemetry" - - // UserDefaults is Apple-documented thread-safe; OK to hold nonisolated. - private nonisolated(unsafe) let defaults: UserDefaults - - /// Creates a consent provider over the given defaults. - /// - Parameter defaults: The defaults store holding the opt-out flag. Inject - /// a suite-scoped store in tests; the app uses `.standard`. - public init(defaults: UserDefaults) { - self.defaults = defaults - } - - public var isTelemetryEnabled: Bool { - defaults.object(forKey: Self.telemetryKey) as? Bool ?? false - } -} diff --git a/Packages/iOS/CmuxMobileAnalytics/Tests/CmuxMobileAnalyticsTests/AnalyticsEmitterTests.swift b/Packages/iOS/CmuxMobileAnalytics/Tests/CmuxMobileAnalyticsTests/AnalyticsEmitterTests.swift index ffbe469f315a..f2eaad8843f2 100644 --- a/Packages/iOS/CmuxMobileAnalytics/Tests/CmuxMobileAnalyticsTests/AnalyticsEmitterTests.swift +++ b/Packages/iOS/CmuxMobileAnalytics/Tests/CmuxMobileAnalyticsTests/AnalyticsEmitterTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +import CMUXMobileCore @testable import CmuxMobileAnalytics private struct FixedConsent: AnalyticsConsentProviding { @@ -26,21 +27,6 @@ private final class MutableConsent: AnalyticsConsentProviding, @unchecked Sendab } @Suite struct AnalyticsEmitterTests { - @Test func userDefaultsConsentDefaultsOffUntilEnabled() { - let suiteName = "cmux.analytics-consent.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - - let consent = UserDefaultsAnalyticsConsentProvider(defaults: defaults) - #expect(!consent.isTelemetryEnabled) - - defaults.set(true, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey) - #expect(consent.isTelemetryEnabled) - - defaults.set(false, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey) - #expect(!consent.isTelemetryEnabled) - } - private func makeEmitter( uploader: any AnalyticsUploading, consent: (any AnalyticsConsentProviding)? = nil, diff --git a/Packages/iOS/CmuxMobileCrashReporting/Package.swift b/Packages/iOS/CmuxMobileCrashReporting/Package.swift index 78049489de63..53372537a254 100644 --- a/Packages/iOS/CmuxMobileCrashReporting/Package.swift +++ b/Packages/iOS/CmuxMobileCrashReporting/Package.swift @@ -4,9 +4,9 @@ import PackageDescription // `CmuxMobileCrashReporting` is the iOS crash telemetry leaf package. It owns // the Sentry startup options for mobile, including watchdog termination, -// app-hang, and MetricKit diagnostics, while depending on `CmuxMobileAnalytics` -// only for the shared telemetry consent seam so crash reporting follows the -// same opt-out as analytics. +// app-hang, and MetricKit diagnostics. It depends on the telemetry consent seam +// in `CMUXMobileCore`, making crash reporting and analytics sibling consumers +// of the same opt-out contract. let package = Package( name: "CmuxMobileCrashReporting", platforms: [ @@ -20,7 +20,7 @@ let package = Package( ), ], dependencies: [ - .package(path: "../CmuxMobileAnalytics"), + .package(path: "../../Shared/CMUXMobileCore"), .package(path: "../../Shared/CmuxSentryTelemetry"), .package( url: "https://github.com/getsentry/sentry-cocoa.git", @@ -31,7 +31,7 @@ let package = Package( .target( name: "CmuxMobileCrashReporting", dependencies: [ - "CmuxMobileAnalytics", + "CMUXMobileCore", .product(name: "CmuxSentryScrubbing", package: "CmuxSentryTelemetry"), .product(name: "CmuxSentryReporting", package: "CmuxSentryTelemetry"), .product(name: "Sentry", package: "sentry-cocoa"), @@ -44,7 +44,10 @@ let package = Package( ), .testTarget( name: "CmuxMobileCrashReportingTests", - dependencies: ["CmuxMobileCrashReporting"], + dependencies: [ + "CMUXMobileCore", + "CmuxMobileCrashReporting", + ], swiftSettings: [ .swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny"), diff --git a/Packages/iOS/CmuxMobileCrashReporting/Sources/CmuxMobileCrashReporting/MobileCrashReporter.swift b/Packages/iOS/CmuxMobileCrashReporting/Sources/CmuxMobileCrashReporting/MobileCrashReporter.swift index 94e9aaa736a1..bff5efb40c1e 100644 --- a/Packages/iOS/CmuxMobileCrashReporting/Sources/CmuxMobileCrashReporting/MobileCrashReporter.swift +++ b/Packages/iOS/CmuxMobileCrashReporting/Sources/CmuxMobileCrashReporting/MobileCrashReporter.swift @@ -1,4 +1,4 @@ -public import CmuxMobileAnalytics +public import CMUXMobileCore import CmuxSentryReporting import Foundation public import Sentry @@ -6,7 +6,7 @@ public import Sentry /// Starts Sentry-backed crash reporting for the iOS app. /// /// ``MobileCrashReporter`` intentionally reuses -/// ``CmuxMobileAnalytics/AnalyticsConsentProviding`` so crash telemetry and +/// ``CMUXMobileCore/AnalyticsConsentProviding`` so crash telemetry and /// analytics obey one opt-out source. `sendDefaultPii` is disabled and every /// outgoing event, breadcrumb, and structured log is redacted by the shared /// `SentryEventScrubber` (CmuxSentryReporting) before it leaves the device. diff --git a/Packages/iOS/CmuxMobileCrashReporting/Sources/CmuxMobileCrashReporting/MobileCrashRevocationWatcher.swift b/Packages/iOS/CmuxMobileCrashReporting/Sources/CmuxMobileCrashReporting/MobileCrashRevocationWatcher.swift index bebbd5eff972..d68b0e5e3c45 100644 --- a/Packages/iOS/CmuxMobileCrashReporting/Sources/CmuxMobileCrashReporting/MobileCrashRevocationWatcher.swift +++ b/Packages/iOS/CmuxMobileCrashReporting/Sources/CmuxMobileCrashReporting/MobileCrashRevocationWatcher.swift @@ -1,4 +1,4 @@ -internal import CmuxMobileAnalytics +internal import CMUXMobileCore internal import Foundation // Safety: the app composition root is the single owner that calls `arm`. diff --git a/Packages/iOS/CmuxMobileCrashReporting/Tests/CmuxMobileCrashReportingTests/CrashTestToggleConsent.swift b/Packages/iOS/CmuxMobileCrashReporting/Tests/CmuxMobileCrashReportingTests/CrashTestToggleConsent.swift index 072713b5e372..f088197d052d 100644 --- a/Packages/iOS/CmuxMobileCrashReporting/Tests/CmuxMobileCrashReportingTests/CrashTestToggleConsent.swift +++ b/Packages/iOS/CmuxMobileCrashReporting/Tests/CmuxMobileCrashReportingTests/CrashTestToggleConsent.swift @@ -1,6 +1,6 @@ import Foundation -import CmuxMobileAnalytics +import CMUXMobileCore final class CrashTestToggleConsent: AnalyticsConsentProviding, @unchecked Sendable { private let lock = NSLock() diff --git a/Packages/iOS/CmuxMobileCrashReporting/Tests/CmuxMobileCrashReportingTests/MobileCrashReporterTests.swift b/Packages/iOS/CmuxMobileCrashReporting/Tests/CmuxMobileCrashReportingTests/MobileCrashReporterTests.swift index 3d467f272182..282d68d5f377 100644 --- a/Packages/iOS/CmuxMobileCrashReporting/Tests/CmuxMobileCrashReportingTests/MobileCrashReporterTests.swift +++ b/Packages/iOS/CmuxMobileCrashReporting/Tests/CmuxMobileCrashReportingTests/MobileCrashReporterTests.swift @@ -1,7 +1,7 @@ import Sentry import Testing -import CmuxMobileAnalytics +import CMUXMobileCore @testable import CmuxMobileCrashReporting private struct FixedConsent: AnalyticsConsentProviding {