import Foundation // MARK: - Delegate Protocol /// Receive notifications when the user's consent choices change. public protocol ConsentOSDelegate: AnyObject { /// Called on the main thread after consent has been updated. /// /// - Parameter state: The new consent state. func consentDidChange(_ state: ConsentState) } // MARK: - ConsentOS /// The main entry point for the CMP iOS SDK. /// /// Use the shared singleton to configure the SDK, display the consent banner, /// and query consent status. All public methods are safe to call from any thread. /// /// ```swift /// // In AppDelegate or @main App /// ConsentOS.shared.configure(siteId: "my-site-id", apiBase: apiURL) /// /// // Optionally register a delegate /// ConsentOS.shared.delegate = self /// /// // Show banner if needed /// if await ConsentOS.shared.shouldShowBanner() { /// await ConsentOS.shared.showBanner(on: rootViewController) /// } /// ``` public final class ConsentOS: @unchecked Sendable { // MARK: - Singleton /// The shared SDK instance. Configure this before use. public static let shared = ConsentOS() // MARK: - State /// The site ID set during ``configure(siteId:apiBase:)``. private(set) public var siteId: String? /// Whether the SDK has been configured. public var isConfigured: Bool { siteId != nil } /// The current consent state. `nil` until storage has been read on first access. private(set) public var consentState: ConsentState? /// The currently loaded site configuration. `nil` until fetched. private(set) public var siteConfig: ConsentConfig? /// Delegate notified on consent changes. Weakly held. public weak var delegate: (any ConsentOSDelegate)? // MARK: - Dependencies private var api: (any ConsentAPIProtocol)? private var storage: any ConsentStorageProtocol private var gcmBridge: GCMBridge private let stateLock = NSLock() // MARK: - Initialiser /// Creates a new instance with the default storage and no-op GCM provider. /// Inject custom dependencies for testing via ``init(storage:api:gcmBridge:)``. public init( storage: any ConsentStorageProtocol = ConsentStorage(), api: (any ConsentAPIProtocol)? = nil, gcmBridge: GCMBridge = GCMBridge() ) { self.storage = storage self.api = api self.gcmBridge = gcmBridge } // MARK: - Configuration /// Configures the SDK with a site ID and API base URL. /// /// Must be called before any other method. Loads the persisted consent state /// and fetches the site configuration in the background. /// /// - Parameters: /// - siteId: The unique identifier for the site (from the CMP dashboard). /// - apiBase: Base URL of the CMP API (e.g. `https://api.example.com`). /// - gcmProvider: Optional GCM analytics provider. Defaults to no-op. public func configure( siteId: String, apiBase: URL, gcmProvider: (any GCMAnalyticsProvider)? = nil ) { stateLock.lock() self.siteId = siteId if self.api == nil { self.api = ConsentAPI(apiBase: apiBase) } if let provider = gcmProvider { self.gcmBridge = GCMBridge(provider: provider) } // Restore persisted state let visitorId = storage.visitorId() self.consentState = storage.loadState() ?? ConsentState(visitorId: visitorId) stateLock.unlock() // Fetch config in the background; apply GCM defaults once available Task { await refreshConfigIfNeeded() } } /// Attaches a custom API client (useful for testing or custom transports). public func setAPI(_ api: any ConsentAPIProtocol) { stateLock.lock() self.api = api stateLock.unlock() } // MARK: - Banner Display /// Returns `true` if the consent banner should be shown to this visitor. /// /// The banner is required when: /// - The user has not yet interacted (no `consentedAt`), or /// - The stored banner version differs from the current config version (re-consent needed), or /// - The stored consent is older than the site's configured expiry. public func shouldShowBanner() async -> Bool { await refreshConfigIfNeeded() stateLock.lock() let state = consentState let config = siteConfig stateLock.unlock() guard let state else { return true } guard state.hasInteracted else { return true } if let config, let consentedAt = state.consentedAt { // Check banner version mismatch if state.bannerVersion != config.bannerVersion { return true } // Check consent expiry let expiryInterval = TimeInterval(config.consentExpiryDays * 86_400) if Date().timeIntervalSince(consentedAt) > expiryInterval { return true } } return false } // MARK: - Consent Actions /// Accepts all non-necessary consent categories. /// /// Updates local state, syncs to the server, and fires GCM signals. public func acceptAll() async { await applyConsent { $0.acceptingAll() } } /// Rejects all non-necessary consent categories. public func rejectAll() async { await applyConsent { $0.rejectingAll() } } /// Accepts only the specified categories (and rejects all others). /// /// - Parameter categories: The set of categories to accept. public func acceptCategories(_ categories: Set) async { await applyConsent { $0.accepting(categories: categories) } } // MARK: - Query /// Returns the consent status for a specific category. /// /// - Returns: `true` if consent has been granted, `false` if denied or not yet given. public func getConsentStatus(for category: ConsentCategory) -> Bool { stateLock.lock() defer { stateLock.unlock() } return consentState?.isGranted(category) ?? (category == .necessary) } // MARK: - User Identity /// Associates the current visitor with a verified user identity. /// /// The JWT is sent alongside subsequent consent records for server-side /// correlation with authenticated users. /// /// - Parameter jwt: A signed JWT issued by the host application's auth system. public func identifyUser(jwt: String) { // Store JWT for inclusion in future consent payloads. // In a production implementation this would also re-sync the consent record. UserDefaults.standard.set(jwt, forKey: "com.cmp.consent.userJwt") } // MARK: - Internal: Config Refresh @discardableResult func refreshConfigIfNeeded() async -> ConsentConfig? { // Check cached config first if let cached = storage.loadCachedConfig(), !cached.isExpired { stateLock.lock() self.siteConfig = cached.config stateLock.unlock() return cached.config } guard let siteId, let api else { return nil } do { let config = try await api.fetchConfig(siteId: siteId) let cached = CachedConfig(config: config, fetchedAt: Date()) storage.saveCachedConfig(cached) stateLock.lock() self.siteConfig = config // Stamp the banner version onto the current state if var state = self.consentState { state.bannerVersion = config.bannerVersion self.consentState = state } stateLock.unlock() // Apply GCM defaults for new visitors gcmBridge.applyDefaults(config: config) return config } catch { // Non-fatal — the SDK can operate with cached or default config return nil } } // MARK: - Internal: Apply Consent private func applyConsent(transform: (ConsentState) -> ConsentState) async { stateLock.lock() guard let current = consentState else { stateLock.unlock() return } var newState = transform(current) // Stamp banner version newState = ConsentState( visitorId: newState.visitorId, accepted: newState.accepted, rejected: newState.rejected, consentedAt: newState.consentedAt, bannerVersion: siteConfig?.bannerVersion ?? current.bannerVersion ) self.consentState = newState stateLock.unlock() // Persist locally storage.saveState(newState) // Signal GCM gcmBridge.applyConsent(state: newState) // Generate TC string let tcString: String? if let config = siteConfig { tcString = TCFStringEncoder.encode(state: newState, config: config) } else { tcString = nil } // Sync to server (best-effort; failures are non-fatal) await syncConsent(state: newState, tcString: tcString) // Notify delegate on main thread let delegateState = newState await MainActor.run { delegate?.consentDidChange(delegateState) } } private func syncConsent(state: ConsentState, tcString: String?) async { guard let siteId, let api, let consentedAt = state.consentedAt else { return } let payload = ConsentPayload( siteId: siteId, visitorId: state.visitorId, accepted: Array(state.accepted), rejected: Array(state.rejected), consentedAt: consentedAt, bannerVersion: state.bannerVersion, tcString: tcString ) do { try await api.postConsent(payload) } catch { // Consent is stored locally; server sync failure is non-fatal. // In production, implement a retry queue here. } } } // Note: `showBanner(on:)` for UIKit is provided by the ConsentOSUI module. // Import ConsentOSUI and call `ConsentOS.shared.showBanner(on:)` after adding // that module as a dependency.