ConsentOS — a privacy-first cookie consent management platform. Self-hosted, source-available alternative to OneTrust, Cookiebot, and CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant architecture with role-based access, configuration cascade (system → org → group → site → region), dark-pattern detection in the scanner, and a tamper-evident consent record audit trail. This is the initial public release. Prior development history is retained internally. See README.md for the feature list, architecture overview, and quick-start instructions. Licensed under the Elastic Licence 2.0 — self-host freely; do not resell as a managed service.
307 lines
10 KiB
Swift
307 lines
10 KiB
Swift
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<ConsentCategory>) 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.
|