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
18 changes: 18 additions & 0 deletions Packages/Shared/CMUXMobileCore/README.md
Original file line number Diff line number Diff line change
@@ -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)
```
Original file line number Diff line number Diff line change
@@ -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 }
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 4 additions & 1 deletion Packages/iOS/CmuxMobileAnalytics/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ let package = Package(
),
.testTarget(
name: "CmuxMobileAnalyticsTests",
dependencies: ["CmuxMobileAnalytics"],
dependencies: [
"CMUXMobileCore",
"CmuxMobileAnalytics",
],
swiftSettings: [
.swiftLanguageMode(.v6),
.enableUpcomingFeature("ExistentialAny"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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() }
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import Testing

import CMUXMobileCore
@testable import CmuxMobileAnalytics

private struct FixedConsent: AnalyticsConsentProviding {
Expand All @@ -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,
Expand Down
15 changes: 9 additions & 6 deletions Packages/iOS/CmuxMobileCrashReporting/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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",
Expand All @@ -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"),
Expand All @@ -44,7 +44,10 @@ let package = Package(
),
.testTarget(
name: "CmuxMobileCrashReportingTests",
dependencies: ["CmuxMobileCrashReporting"],
dependencies: [
"CMUXMobileCore",
"CmuxMobileCrashReporting",
],
swiftSettings: [
.swiftLanguageMode(.v6),
.enableUpcomingFeature("ExistentialAny"),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
public import CmuxMobileAnalytics
public import CMUXMobileCore
import CmuxSentryReporting
import Foundation
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

import CmuxMobileAnalytics
import CMUXMobileCore

final class CrashTestToggleConsent: AnalyticsConsentProviding, @unchecked Sendable {
private let lock = NSLock()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Sentry
import Testing

import CmuxMobileAnalytics
import CMUXMobileCore
@testable import CmuxMobileCrashReporting

private struct FixedConsent: AnalyticsConsentProviding {
Expand Down
Loading