feat: initial public release

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.
This commit is contained in:
James Cottrill
2026-04-13 14:20:15 +00:00
commit fbf26453f2
341 changed files with 62807 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
import Foundation
// MARK: - Protocol
/// Abstracts the network layer for testability.
public protocol ConsentAPIProtocol: Sendable {
/// Fetches the effective site configuration from the API.
func fetchConfig(siteId: String) async throws -> ConsentConfig
/// Posts a consent record to the server.
func postConsent(_ payload: ConsentPayload) async throws
}
// MARK: - Errors
/// Errors that can be thrown by the CMP API client.
public enum ConsentAPIError: Error, LocalizedError {
case invalidURL
case unexpectedStatusCode(Int)
case decodingFailure(Error)
case networkFailure(Error)
public var errorDescription: String? {
switch self {
case .invalidURL:
return "The constructed API URL is invalid."
case .unexpectedStatusCode(let code):
return "The server returned an unexpected HTTP status code: \(code)."
case .decodingFailure(let underlying):
return "Failed to decode the server response: \(underlying.localizedDescription)"
case .networkFailure(let underlying):
return "A network error occurred: \(underlying.localizedDescription)"
}
}
}
// MARK: - Consent Payload
/// The request body sent when recording a consent event.
public struct ConsentPayload: Codable, Sendable {
public let siteId: String
public let visitorId: String
public let platform: String // Always "ios"
public let accepted: [String] // ConsentCategory raw values
public let rejected: [String]
public let consentedAt: Date
public let bannerVersion: String?
public let userAgent: String?
public let tcString: String?
public init(
siteId: String,
visitorId: String,
accepted: [ConsentCategory],
rejected: [ConsentCategory],
consentedAt: Date,
bannerVersion: String?,
userAgent: String? = nil,
tcString: String? = nil
) {
self.siteId = siteId
self.visitorId = visitorId
self.platform = "ios"
self.accepted = accepted.map(\.rawValue)
self.rejected = rejected.map(\.rawValue)
self.consentedAt = consentedAt
self.bannerVersion = bannerVersion
self.userAgent = userAgent
self.tcString = tcString
}
}
// MARK: - Live Implementation
/// URLSession-backed API client that communicates with the CMP API.
public final class ConsentAPI: ConsentAPIProtocol, @unchecked Sendable {
private let apiBase: URL
private let session: URLSession
private let decoder: JSONDecoder
private let encoder: JSONEncoder
// MARK: - Initialiser
/// - Parameters:
/// - apiBase: The base URL of the CMP API (e.g. `https://api.example.com`).
/// - session: Defaults to `URLSession.shared`; inject a custom session in tests.
public init(apiBase: URL, session: URLSession = .shared) {
self.apiBase = apiBase
self.session = session
let dec = JSONDecoder()
dec.dateDecodingStrategy = .iso8601
dec.keyDecodingStrategy = .convertFromSnakeCase
self.decoder = dec
let enc = JSONEncoder()
enc.dateEncodingStrategy = .iso8601
enc.keyEncodingStrategy = .convertToSnakeCase
self.encoder = enc
}
// MARK: - ConsentAPIProtocol
public func fetchConfig(siteId: String) async throws -> ConsentConfig {
let url = apiBase
.appendingPathComponent("api/v1/config/sites")
.appendingPathComponent(siteId)
.appendingPathComponent("effective")
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(sdkUserAgent, forHTTPHeaderField: "User-Agent")
let (data, response) = try await performRequest(request)
try validateResponse(response)
do {
return try decoder.decode(ConsentConfig.self, from: data)
} catch {
throw ConsentAPIError.decodingFailure(error)
}
}
public func postConsent(_ payload: ConsentPayload) async throws {
let url = apiBase.appendingPathComponent("api/v1/consent")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(sdkUserAgent, forHTTPHeaderField: "User-Agent")
do {
request.httpBody = try encoder.encode(payload)
} catch {
throw ConsentAPIError.decodingFailure(error)
}
let (_, response) = try await performRequest(request)
try validateResponse(response)
}
// MARK: - Private Helpers
private func performRequest(_ request: URLRequest) async throws -> (Data, URLResponse) {
do {
return try await session.data(for: request)
} catch {
throw ConsentAPIError.networkFailure(error)
}
}
private func validateResponse(_ response: URLResponse) throws {
guard let httpResponse = response as? HTTPURLResponse else { return }
guard (200..<300).contains(httpResponse.statusCode) else {
throw ConsentAPIError.unexpectedStatusCode(httpResponse.statusCode)
}
}
private var sdkUserAgent: String {
"ConsentOS-iOS/1.0.0 (Swift)"
}
}

View File

@@ -0,0 +1,101 @@
/// Consent categories matching the platform's taxonomy.
///
/// Each category maps to IAB TCF v2.2 purposes and Google Consent Mode consent types.
/// The `necessary` category is always granted and cannot be revoked by the user.
public enum ConsentCategory: String, CaseIterable, Codable, Sendable {
/// Strictly necessary cookies always allowed, no consent required.
case necessary
/// Functional / preference cookies (e.g. language, saved settings).
case functional
/// Analytics / statistics cookies (e.g. page views, session data).
case analytics
/// Marketing / advertising cookies (e.g. retargeting, personalised ads).
case marketing
/// Personalisation cookies (e.g. content recommendations).
case personalisation
// MARK: - TCF Mappings
/// IAB TCF v2.2 purpose IDs associated with this category.
///
/// Returns an empty array for `necessary`, which does not require consent purposes.
public var tcfPurposeIds: [Int] {
switch self {
case .necessary:
return []
case .functional:
return [1] // Store and/or access information on a device
case .analytics:
return [7, 8, 9, 10] // Measurement, market research, product development
case .marketing:
return [2, 3, 4] // Select basic/personalised ads, create ad profile
case .personalisation:
return [5, 6] // Create content profile, select personalised content
}
}
// MARK: - Google Consent Mode Mappings
/// Google Consent Mode v2 consent type string for this category.
///
/// Returns `nil` for categories that do not have a direct GCM mapping.
public var gcmConsentType: String? {
switch self {
case .necessary:
return nil
case .functional:
return "functionality_storage"
case .analytics:
return "analytics_storage"
case .marketing:
return "ad_storage"
case .personalisation:
return "personalization_storage"
}
}
// MARK: - Display
/// Human-readable display name for the category (British English).
public var displayName: String {
switch self {
case .necessary:
return "Strictly Necessary"
case .functional:
return "Functional"
case .analytics:
return "Analytics"
case .marketing:
return "Marketing"
case .personalisation:
return "Personalisation"
}
}
/// Brief description of the category for banner display.
public var displayDescription: String {
switch self {
case .necessary:
return "Essential for the website to function. These cannot be disabled."
case .functional:
return "Enable enhanced functionality such as remembering your preferences."
case .analytics:
return "Help us understand how visitors interact with the website."
case .marketing:
return "Used to deliver relevant advertisements and track ad campaign performance."
case .personalisation:
return "Allow us to personalise content based on your interests."
}
}
/// Whether consent for this category is required before storing data.
///
/// `necessary` is exempt from consent requirements under ePrivacy regulations.
public var requiresConsent: Bool {
self != .necessary
}
}

View File

@@ -0,0 +1,188 @@
import Foundation
/// The effective site configuration loaded from the CMP API.
///
/// Maps to the response of `GET {apiBase}/api/v1/config/sites/{siteId}/effective`.
/// The configuration drives banner display, theming, blocking mode, and consent expiry.
public struct ConsentConfig: Codable, Sendable {
// MARK: - Top-level Fields
/// Unique identifier for the site.
public let siteId: String
/// Human-readable name for the site.
public let siteName: String
/// The blocking mode determining the default consent model.
public let blockingMode: BlockingMode
/// Consent validity in days. After expiry the banner is shown again.
public let consentExpiryDays: Int
/// The current version of this configuration.
/// Stored alongside consent records to detect when re-consent is needed.
public let bannerVersion: String
/// Banner display and theming configuration.
public let bannerConfig: BannerConfig
/// Available categories for this site.
public let categories: [CategoryConfig]
// MARK: - Initialiser
public init(
siteId: String,
siteName: String,
blockingMode: BlockingMode,
consentExpiryDays: Int,
bannerVersion: String,
bannerConfig: BannerConfig,
categories: [CategoryConfig]
) {
self.siteId = siteId
self.siteName = siteName
self.blockingMode = blockingMode
self.consentExpiryDays = consentExpiryDays
self.bannerVersion = bannerVersion
self.bannerConfig = bannerConfig
self.categories = categories
}
// MARK: - Blocking Mode
/// The consent model applied to visitors.
public enum BlockingMode: String, Codable, Sendable {
/// User must opt in before non-essential scripts run (GDPR default).
case optIn = "opt_in"
/// Non-essential scripts run by default; user may opt out (CCPA default).
case optOut = "opt_out"
/// Informational notice only; no blocking.
case informational
}
// MARK: - Banner Configuration
/// Visual and behavioural configuration for the consent banner.
public struct BannerConfig: Codable, Sendable {
/// Display mode controlling the banner layout.
public let displayMode: DisplayMode
/// Primary background colour as a hex string (e.g. `"#FFFFFF"`).
public let backgroundColor: String?
/// Primary text colour as a hex string.
public let textColor: String?
/// Accent colour used for buttons and highlights.
public let accentColor: String?
/// Text for the "Accept all" button.
public let acceptButtonText: String?
/// Text for the "Reject all" button.
public let rejectButtonText: String?
/// Text for the "Manage preferences" button.
public let manageButtonText: String?
/// The banner title text.
public let title: String?
/// The banner body copy.
public let description: String?
/// URL for the site's privacy policy.
public let privacyPolicyUrl: String?
public enum DisplayMode: String, Codable, Sendable {
case overlay
case bottomBanner = "bottom_banner"
case topBanner = "top_banner"
case cornerPopup = "corner_popup"
case inline
}
// Default-value initialiser used in tests and previews
public init(
displayMode: DisplayMode = .bottomBanner,
backgroundColor: String? = nil,
textColor: String? = nil,
accentColor: String? = nil,
acceptButtonText: String? = nil,
rejectButtonText: String? = nil,
manageButtonText: String? = nil,
title: String? = nil,
description: String? = nil,
privacyPolicyUrl: String? = nil
) {
self.displayMode = displayMode
self.backgroundColor = backgroundColor
self.textColor = textColor
self.accentColor = accentColor
self.acceptButtonText = acceptButtonText
self.rejectButtonText = rejectButtonText
self.manageButtonText = manageButtonText
self.title = title
self.description = description
self.privacyPolicyUrl = privacyPolicyUrl
}
}
// MARK: - Category Configuration
/// Per-category configuration as returned by the API.
public struct CategoryConfig: Codable, Sendable {
/// Machine-readable category key (matches ``ConsentCategory`` raw values).
public let key: String
/// Whether this category is enabled for this site.
public let enabled: Bool
/// Overridden display name (falls back to ``ConsentCategory/displayName`` if absent).
public let displayName: String?
/// Overridden description text.
public let description: String?
public init(key: String, enabled: Bool, displayName: String?, description: String?) {
self.key = key
self.enabled = enabled
self.displayName = displayName
self.description = description
}
/// Resolves to the matching ``ConsentCategory``, if the key is recognised.
public var category: ConsentCategory? {
ConsentCategory(rawValue: key)
}
}
// MARK: - Convenience
/// Returns only the enabled ``ConsentCategory`` values for this config.
public var enabledCategories: [ConsentCategory] {
categories.compactMap { cfg in
guard cfg.enabled else { return nil }
return cfg.category
}
}
}
// MARK: - Cached Config Wrapper
/// Wraps a ``ConsentConfig`` with metadata needed for cache invalidation.
public struct CachedConfig: Codable {
public let config: ConsentConfig
public let fetchedAt: Date
/// The cache TTL in seconds (10 minutes by default).
public static let ttl: TimeInterval = 600
public var isExpired: Bool {
Date().timeIntervalSince(fetchedAt) > CachedConfig.ttl
}
public init(config: ConsentConfig, fetchedAt: Date) {
self.config = config
self.fetchedAt = fetchedAt
}
}

View File

@@ -0,0 +1,306 @@
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.

View File

@@ -0,0 +1,118 @@
import Foundation
/// Represents the complete consent state for a visitor.
///
/// This model mirrors the web consent cookie structure for cross-platform consistency.
/// It is persisted locally via ``ConsentStorage`` and synced to the server.
public struct ConsentState: Codable, Equatable, Sendable {
// MARK: - Properties
/// A stable, anonymous identifier for this device/visitor.
/// Generated once and persisted across sessions.
public let visitorId: String
/// The set of categories the visitor has explicitly accepted.
public var accepted: Set<ConsentCategory>
/// The set of categories the visitor has explicitly rejected.
public var rejected: Set<ConsentCategory>
/// The timestamp at which consent was last recorded.
public var consentedAt: Date?
/// The banner configuration version active when consent was collected.
/// Used to detect when consent must be re-collected after a config change.
public var bannerVersion: String?
/// Whether the user has interacted with the banner (accepted or rejected).
///
/// Returns `false` when the state represents the pre-consent default.
public var hasInteracted: Bool {
consentedAt != nil
}
// MARK: - Derived State
/// Returns `true` if the user has granted consent for the given category.
///
/// `necessary` is always considered granted regardless of the stored state.
public func isGranted(_ category: ConsentCategory) -> Bool {
guard category != .necessary else { return true }
return accepted.contains(category)
}
/// Returns `true` if the user has explicitly denied consent for the given category.
public func isDenied(_ category: ConsentCategory) -> Bool {
guard category != .necessary else { return false }
return rejected.contains(category)
}
// MARK: - Initialisers
/// Creates a new, blank consent state for the given visitor.
///
/// No categories are accepted or rejected; `consentedAt` is `nil`.
public init(visitorId: String) {
self.visitorId = visitorId
self.accepted = []
self.rejected = []
self.consentedAt = nil
self.bannerVersion = nil
}
/// Creates a fully populated consent state.
public init(
visitorId: String,
accepted: Set<ConsentCategory>,
rejected: Set<ConsentCategory>,
consentedAt: Date?,
bannerVersion: String?
) {
self.visitorId = visitorId
self.accepted = accepted
self.rejected = rejected
self.consentedAt = consentedAt
self.bannerVersion = bannerVersion
}
// MARK: - Mutations
/// Returns a new state with all non-necessary categories accepted.
public func acceptingAll() -> ConsentState {
let allOptional = ConsentCategory.allCases.filter { $0.requiresConsent }
return ConsentState(
visitorId: visitorId,
accepted: Set(allOptional),
rejected: [],
consentedAt: Date(),
bannerVersion: bannerVersion
)
}
/// Returns a new state with all non-necessary categories rejected.
public func rejectingAll() -> ConsentState {
let allOptional = ConsentCategory.allCases.filter { $0.requiresConsent }
return ConsentState(
visitorId: visitorId,
accepted: [],
rejected: Set(allOptional),
consentedAt: Date(),
bannerVersion: bannerVersion
)
}
/// Returns a new state accepting only the specified categories (and rejecting the rest).
public func accepting(categories: Set<ConsentCategory>) -> ConsentState {
let allOptional = Set(ConsentCategory.allCases.filter { $0.requiresConsent })
let toAccept = categories.filter { $0.requiresConsent }
let toReject = allOptional.subtracting(toAccept)
return ConsentState(
visitorId: visitorId,
accepted: toAccept,
rejected: toReject,
consentedAt: Date(),
bannerVersion: bannerVersion
)
}
}

View File

@@ -0,0 +1,101 @@
import Foundation
// MARK: - Protocol
/// Abstracts local persistence so the storage layer can be swapped in tests.
public protocol ConsentStorageProtocol: Sendable {
func loadState() -> ConsentState?
func saveState(_ state: ConsentState)
func clearState()
func loadCachedConfig() -> CachedConfig?
func saveCachedConfig(_ cached: CachedConfig)
func clearCachedConfig()
/// Loads or generates a stable visitor ID.
func visitorId() -> String
}
// MARK: - UserDefaults Implementation
/// Persists consent state and site configuration in `UserDefaults`.
///
/// All keys are namespaced under `com.cmp.consent` to avoid collisions.
public final class ConsentStorage: ConsentStorageProtocol, @unchecked Sendable {
// MARK: - Keys
private enum Keys {
static let consentState = "com.cmp.consent.state"
static let cachedConfig = "com.cmp.consent.config"
static let visitorId = "com.cmp.consent.visitorId"
}
// MARK: - Dependencies
private let defaults: UserDefaults
private let encoder: JSONEncoder
private let decoder: JSONDecoder
// MARK: - Initialiser
/// Creates a storage instance backed by the given `UserDefaults` suite.
///
/// - Parameter suiteName: Pass a custom suite name to isolate storage per app group.
/// Defaults to `nil`, which uses `UserDefaults.standard`.
public init(suiteName: String? = nil) {
self.defaults = UserDefaults(suiteName: suiteName) ?? .standard
let enc = JSONEncoder()
enc.dateEncodingStrategy = .iso8601
self.encoder = enc
let dec = JSONDecoder()
dec.dateDecodingStrategy = .iso8601
self.decoder = dec
}
// MARK: - ConsentState
public func loadState() -> ConsentState? {
guard let data = defaults.data(forKey: Keys.consentState) else { return nil }
return try? decoder.decode(ConsentState.self, from: data)
}
public func saveState(_ state: ConsentState) {
guard let data = try? encoder.encode(state) else { return }
defaults.set(data, forKey: Keys.consentState)
}
public func clearState() {
defaults.removeObject(forKey: Keys.consentState)
}
// MARK: - Cached Config
public func loadCachedConfig() -> CachedConfig? {
guard let data = defaults.data(forKey: Keys.cachedConfig) else { return nil }
return try? decoder.decode(CachedConfig.self, from: data)
}
public func saveCachedConfig(_ cached: CachedConfig) {
guard let data = try? encoder.encode(cached) else { return }
defaults.set(data, forKey: Keys.cachedConfig)
}
public func clearCachedConfig() {
defaults.removeObject(forKey: Keys.cachedConfig)
}
// MARK: - Visitor ID
/// Returns the persisted visitor ID, generating and saving a new UUID if absent.
public func visitorId() -> String {
if let existing = defaults.string(forKey: Keys.visitorId) {
return existing
}
let newId = UUID().uuidString
defaults.set(newId, forKey: Keys.visitorId)
return newId
}
}

View File

@@ -0,0 +1,128 @@
import Foundation
// MARK: - GCM Consent Types
/// The Google Consent Mode v2 consent type strings.
public enum GCMConsentType: String, CaseIterable, Sendable {
case analyticsStorage = "analytics_storage"
case adStorage = "ad_storage"
case adUserData = "ad_user_data"
case adPersonalisation = "ad_personalization"
case functionalityStorage = "functionality_storage"
case personalisationStorage = "personalization_storage"
case securityStorage = "security_storage"
}
/// The granted/denied status for a GCM consent type.
public enum GCMConsentStatus: String, Sendable {
case granted = "granted"
case denied = "denied"
}
// MARK: - GCM Analytics Provider Protocol
/// Abstracts the Firebase Analytics / Google Tag Manager call surface.
///
/// Conforming types should call the underlying GCM API. The default no-op
/// implementation is used when Firebase is not present.
public protocol GCMAnalyticsProvider: AnyObject, Sendable {
/// Sets the default consent state before any interaction.
func setConsentDefaults(_ defaults: [String: String])
/// Updates consent state after the user interacts with the banner.
func updateConsent(_ updates: [String: String])
}
// MARK: - No-Op Provider
/// A no-op ``GCMAnalyticsProvider`` used when Firebase Analytics is not linked.
public final class NoOpGCMAnalyticsProvider: GCMAnalyticsProvider, @unchecked Sendable {
public init() {}
public func setConsentDefaults(_ defaults: [String: String]) {}
public func updateConsent(_ updates: [String: String]) {}
}
// MARK: - GCM Bridge
/// Maps CMP consent state to Google Consent Mode v2 signals.
///
/// On app launch, call ``applyDefaults(config:)`` before the user interacts.
/// After consent is collected, call ``applyConsent(state:)`` to update GCM.
///
/// To integrate with Firebase Analytics, implement ``GCMAnalyticsProvider`` and
/// call the `Firebase.Analytics.setConsent(_:)` API inside it.
public final class GCMBridge: @unchecked Sendable {
// MARK: - Dependencies
private let provider: any GCMAnalyticsProvider
// MARK: - Initialiser
/// - Parameter provider: The analytics provider to forward consent signals to.
/// Defaults to a no-op implementation.
public init(provider: any GCMAnalyticsProvider = NoOpGCMAnalyticsProvider()) {
self.provider = provider
}
// MARK: - Public API
/// Sends default (pre-consent) consent signals to GCM based on the site's blocking mode.
///
/// Call this as early as possible ideally before any analytics events are sent.
///
/// - Parameter config: The effective site configuration.
public func applyDefaults(config: ConsentConfig) {
let defaults = buildDefaults(for: config.blockingMode)
provider.setConsentDefaults(defaults)
}
/// Updates GCM consent signals to reflect the user's explicit choices.
///
/// - Parameter state: The resolved consent state after user interaction.
public func applyConsent(state: ConsentState) {
let updates = buildConsentMap(from: state)
provider.updateConsent(updates)
}
// MARK: - Private Helpers
/// Builds the default GCM consent map based on blocking mode.
///
/// - `opt_in`: all types denied by default (GDPR).
/// - `opt_out`: all types granted by default (CCPA).
/// - `informational`: all types granted.
private func buildDefaults(for mode: ConsentConfig.BlockingMode) -> [String: String] {
let status: GCMConsentStatus = mode == .optIn ? .denied : .granted
return Dictionary(
uniqueKeysWithValues: GCMConsentType.allCases.map {
($0.rawValue, status.rawValue)
}
)
}
/// Maps the consent state's accepted/rejected categories to GCM consent type values.
private func buildConsentMap(from state: ConsentState) -> [String: String] {
var map: [String: String] = [:]
// security_storage is always granted it is necessary for security.
map[GCMConsentType.securityStorage.rawValue] = GCMConsentStatus.granted.rawValue
for category in ConsentCategory.allCases {
guard let gcmType = category.gcmConsentType else { continue }
let status: GCMConsentStatus = state.isGranted(category) ? .granted : .denied
map[gcmType] = status.rawValue
}
// ad_user_data and ad_personalization follow the marketing category.
let marketingGranted = state.isGranted(.marketing)
map[GCMConsentType.adUserData.rawValue] = marketingGranted
? GCMConsentStatus.granted.rawValue
: GCMConsentStatus.denied.rawValue
map[GCMConsentType.adPersonalisation.rawValue] = marketingGranted
? GCMConsentStatus.granted.rawValue
: GCMConsentStatus.denied.rawValue
return map
}
}

View File

@@ -0,0 +1,196 @@
import Foundation
// MARK: - TCF String Encoder
/// Encodes a TC string (Transparency & Consent Framework v2.2) from consent state.
///
/// The TC string is a Base64url-encoded bit field described in the IAB TCF v2.2 specification.
/// This implementation encodes the core consent section (segment type 0) sufficient for
/// signalling purpose consent to downstream vendors.
///
/// Reference: https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework
public final class TCFStringEncoder: Sendable {
// MARK: - Constants
/// TCF specification version.
private static let specVersion: Int = 2
/// A fixed CMP ID replace with your registered IAB CMP ID in production.
private static let cmpId: Int = 0
/// CMP SDK version number.
private static let cmpVersion: Int = 1
/// IAB consent language (en).
private static let consentLanguage: String = "EN"
/// Vendor list version. In production, this should be fetched from the GVL.
private static let vendorListVersion: Int = 1
/// Number of TCF purposes defined in the specification.
private static let tcfPurposeCount: Int = 24
// MARK: - Public API
/// Encodes a TC string for the given consent state and site configuration.
///
/// - Parameters:
/// - state: The resolved consent state containing accepted/rejected categories.
/// - config: The site configuration (used for CMP metadata).
/// - Returns: A Base64url-encoded TC string, or `nil` if encoding fails.
public static func encode(state: ConsentState, config: ConsentConfig) -> String? {
guard state.hasInteracted, let consentedAt = state.consentedAt else {
return nil
}
// Derive the set of consented TCF purpose IDs from accepted categories.
let consentedPurposeIds: Set<Int> = state.accepted.reduce(into: []) { result, category in
category.tcfPurposeIds.forEach { result.insert($0) }
}
return buildCoreString(
consentedAt: consentedAt,
consentedPurposeIds: consentedPurposeIds
)
}
// MARK: - Core String Construction
private static func buildCoreString(
consentedAt: Date,
consentedPurposeIds: Set<Int>
) -> String? {
var bits = BitWriter()
// --- Core segment fields (IAB TCF v2.2 spec, Table 1) ---
// Version (6 bits)
bits.write(specVersion, bitCount: 6)
// Created deciseconds since epoch (36 bits)
let deciseconds = Int(consentedAt.timeIntervalSince1970 * 10)
bits.write(deciseconds, bitCount: 36)
// LastUpdated deciseconds since epoch (36 bits)
bits.write(deciseconds, bitCount: 36)
// CmpId (12 bits)
bits.write(cmpId, bitCount: 12)
// CmpVersion (12 bits)
bits.write(cmpVersion, bitCount: 12)
// ConsentScreen (6 bits) screen number within the CMP UI
bits.write(1, bitCount: 6)
// ConsentLanguage (12 bits) two 6-bit characters, A=0 Z=25
let langBits = encodeTwoLetterLanguage(consentLanguage)
bits.write(langBits.0, bitCount: 6)
bits.write(langBits.1, bitCount: 6)
// VendorListVersion (12 bits)
bits.write(vendorListVersion, bitCount: 12)
// TcfPolicyVersion (6 bits) must be 4 for TCF v2.2
bits.write(4, bitCount: 6)
// IsServiceSpecific (1 bit)
bits.write(0, bitCount: 1)
// UseNonStandardTexts (1 bit)
bits.write(0, bitCount: 1)
// SpecialFeatureOptIns (12 bits) none opted in
bits.write(0, bitCount: 12)
// PurposesConsent (24 bits) one bit per purpose, LSB = purpose 1
for purposeId in 1 ... tcfPurposeCount {
bits.write(consentedPurposeIds.contains(purposeId) ? 1 : 0, bitCount: 1)
}
// PurposesLITransparency (24 bits) legitimate interest; none asserted
bits.write(0, bitCount: 24)
// PurposeOneTreatment (1 bit)
bits.write(0, bitCount: 1)
// PublisherCC (12 bits) "GB"
let ccBits = encodeTwoLetterLanguage("GB")
bits.write(ccBits.0, bitCount: 6)
bits.write(ccBits.1, bitCount: 6)
// Vendor Consents using BitRange encoding with MaxVendorId = 0 (no vendors)
bits.write(0, bitCount: 16) // MaxVendorId
bits.write(0, bitCount: 1) // IsRangeEncoding = false
// (no bits to write for an empty vendor list)
// Vendor Legitimate Interests MaxVendorId = 0
bits.write(0, bitCount: 16)
bits.write(0, bitCount: 1)
// Publisher Restrictions count = 0
bits.write(0, bitCount: 12)
// Serialise and Base64url-encode
let data = bits.toData()
return base64UrlEncode(data)
}
// MARK: - Helpers
/// Encodes a two-letter language/country code into two 6-bit integers (A=0, Z=25).
private static func encodeTwoLetterLanguage(_ code: String) -> (Int, Int) {
// ASCII value of 'A' is 65. Subtracting this gives 0-based index (A=0 Z=25).
let asciiA: Int = 65
let upper = code.uppercased()
let chars = Array(upper)
guard chars.count == 2,
let first = chars[0].asciiValue,
let second = chars[1].asciiValue else {
return (4, 13) // "EN" fallback (E=4, N=13)
}
return (Int(first) - asciiA, Int(second) - asciiA)
}
/// Converts a `Data` value to a Base64url string (RFC 4648, no padding).
private static func base64UrlEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.trimmingCharacters(in: CharacterSet(charactersIn: "="))
}
}
// MARK: - Bit Writer
/// A utility type for packing integers into a bit-level byte buffer.
private struct BitWriter {
private var bytes: [UInt8] = []
private var currentByte: UInt8 = 0
private var bitPosition: Int = 0 // 0 = MSB of current byte
/// Writes `bitCount` bits from the MSB of `value`.
mutating func write(_ value: Int, bitCount: Int) {
for i in stride(from: bitCount - 1, through: 0, by: -1) {
let bit: UInt8 = (value >> i) & 1 == 1 ? 1 : 0
currentByte |= bit << (7 - bitPosition)
bitPosition += 1
if bitPosition == 8 {
bytes.append(currentByte)
currentByte = 0
bitPosition = 0
}
}
}
/// Flushes any remaining partial byte and returns the accumulated data.
func toData() -> Data {
var result = bytes
if bitPosition > 0 {
result.append(currentByte)
}
return Data(result)
}
}

View File

@@ -0,0 +1,111 @@
#if canImport(SwiftUI)
import SwiftUI
import ConsentOSCore
// MARK: - Banner Theme
/// Resolved colour and typography theme for the consent banner.
///
/// Derived from ``ConsentConfig/BannerConfig`` values, falling back to sensible defaults.
public struct BannerTheme: Sendable {
// MARK: - Colours
public let backgroundColor: Color
public let textColor: Color
public let accentColor: Color
public let secondaryTextColor: Color
public let dividerColor: Color
public let acceptButtonBackground: Color
public let acceptButtonTextColor: Color
public let rejectButtonBackground: Color
public let rejectButtonTextColor: Color
// MARK: - Typography
public let titleFont: Font
public let bodyFont: Font
public let buttonFont: Font
public let captionFont: Font
// MARK: - Layout
public let cornerRadius: CGFloat
public let horizontalPadding: CGFloat
public let verticalPadding: CGFloat
public let buttonHeight: CGFloat
// MARK: - Button Labels
public let acceptButtonText: String
public let rejectButtonText: String
public let manageButtonText: String
public let title: String
public let description: String
// MARK: - Defaults
static let defaultAccentHex = "#1A73E8" // Google-blue overridden by brand config
static let defaultBackgroundHex = "#FFFFFF"
static let defaultTextHex = "#1A1A1A"
// MARK: - Factory
/// Creates a ``BannerTheme`` from the banner configuration returned by the API.
///
/// Hex values not present in the config fall back to neutral defaults.
public static func from(config: ConsentConfig.BannerConfig) -> BannerTheme {
let background = Color(hex: config.backgroundColor) ?? Color(.systemBackground)
let text = Color(hex: config.textColor) ?? Color(.label)
let accent = Color(hex: config.accentColor) ?? Color(hex: defaultAccentHex)!
return BannerTheme(
backgroundColor: background,
textColor: text,
accentColor: accent,
secondaryTextColor: text.opacity(0.6),
dividerColor: text.opacity(0.12),
acceptButtonBackground: accent,
acceptButtonTextColor: .white,
rejectButtonBackground: Color(.secondarySystemBackground),
rejectButtonTextColor: text,
titleFont: .headline,
bodyFont: .subheadline,
buttonFont: .subheadline.weight(.semibold),
captionFont: .caption,
cornerRadius: 12,
horizontalPadding: 16,
verticalPadding: 16,
buttonHeight: 44,
acceptButtonText: config.acceptButtonText ?? "Accept All",
rejectButtonText: config.rejectButtonText ?? "Reject All",
manageButtonText: config.manageButtonText ?? "Manage Preferences",
title: config.title ?? "We value your privacy",
description: config.description ?? "We use cookies to improve your experience and for analytics."
)
}
/// Default theme used when no configuration has been loaded yet.
public static var defaultTheme: BannerTheme {
from(config: ConsentConfig.BannerConfig())
}
}
// MARK: - Colour Hex Extension
extension Color {
/// Initialises a `Color` from a hex string such as `"#1A73E8"` or `"1A73E8"`.
init?(hex: String?) {
guard let hex else { return nil }
let sanitised = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
guard sanitised.count == 6,
let value = UInt64(sanitised, radix: 16) else {
return nil
}
let r = Double((value >> 16) & 0xFF) / 255
let g = Double((value >> 8) & 0xFF) / 255
let b = Double((value ) & 0xFF) / 255
self.init(red: r, green: g, blue: b)
}
}
#endif

View File

@@ -0,0 +1,275 @@
#if canImport(SwiftUI)
import SwiftUI
import ConsentOSCore
// MARK: - Consent Banner View
/// A SwiftUI consent banner that respects the site's ``BannerTheme``.
///
/// The banner displays in a bottom-sheet style by default. It presents three
/// actions: accept all, reject all, and manage preferences (category-level toggles).
///
/// Usage:
/// ```swift
/// ConsentBannerView(theme: theme) {
/// await ConsentOS.shared.acceptAll()
/// } onRejectAll: {
/// await ConsentOS.shared.rejectAll()
/// } onSave: { categories in
/// await ConsentOS.shared.acceptCategories(categories)
/// }
/// ```
public struct ConsentBannerView: View {
// MARK: - State
@State private var showingManage: Bool = false
@State private var categoryToggles: [ConsentCategory: Bool] = [:]
// MARK: - Properties
private let theme: BannerTheme
private let onAcceptAll: () async -> Void
private let onRejectAll: () async -> Void
private let onSave: (Set<ConsentCategory>) async -> Void
/// The categories available for granular control (excludes `necessary`).
private let manageableCategories: [ConsentCategory] = ConsentCategory.allCases
.filter { $0.requiresConsent }
// MARK: - Initialisers
public init(
theme: BannerTheme = .defaultTheme,
onAcceptAll: @escaping () async -> Void,
onRejectAll: @escaping () async -> Void,
onSave: @escaping (Set<ConsentCategory>) async -> Void
) {
self.theme = theme
self.onAcceptAll = onAcceptAll
self.onRejectAll = onRejectAll
self.onSave = onSave
}
// MARK: - Body
public var body: some View {
VStack(spacing: 0) {
if showingManage {
manageView
.transition(.move(edge: .bottom).combined(with: .opacity))
} else {
mainBannerView
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.background(theme.backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: theme.cornerRadius, style: .continuous))
.shadow(color: .black.opacity(0.1), radius: 12, x: 0, y: -4)
.padding(.horizontal, theme.horizontalPadding)
.onAppear {
// Default all optional categories to off
for category in manageableCategories {
categoryToggles[category] = false
}
}
.animation(.easeInOut(duration: 0.25), value: showingManage)
.accessibilityElement(children: .contain)
.accessibilityLabel("Cookie consent banner")
}
// MARK: - Main Banner
private var mainBannerView: some View {
VStack(alignment: .leading, spacing: 12) {
Text(theme.title)
.font(theme.titleFont)
.foregroundColor(theme.textColor)
.accessibilityAddTraits(.isHeader)
Text(theme.description)
.font(theme.bodyFont)
.foregroundColor(theme.secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
Divider()
.background(theme.dividerColor)
// Action buttons
VStack(spacing: 8) {
acceptButton
HStack(spacing: 8) {
rejectButton
manageButton
}
}
}
.padding(theme.verticalPadding)
}
// MARK: - Manage View
private var manageView: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack {
Button(action: { showingManage = false }) {
Image(systemName: "chevron.left")
.foregroundColor(theme.accentColor)
}
.accessibilityLabel("Back")
Text(theme.manageButtonText)
.font(theme.titleFont)
.foregroundColor(theme.textColor)
.accessibilityAddTraits(.isHeader)
Spacer()
}
.padding(theme.verticalPadding)
Divider().background(theme.dividerColor)
// Necessary category always on
categoryRow(
name: ConsentCategory.necessary.displayName,
description: ConsentCategory.necessary.displayDescription,
isOn: .constant(true),
isToggleable: false
)
Divider().background(theme.dividerColor)
// Optional categories
ScrollView {
VStack(spacing: 0) {
ForEach(manageableCategories, id: \.rawValue) { category in
categoryRow(
name: category.displayName,
description: category.displayDescription,
isOn: Binding(
get: { categoryToggles[category] ?? false },
set: { categoryToggles[category] = $0 }
),
isToggleable: true
)
Divider().background(theme.dividerColor)
}
}
}
// Save button
Button(action: {
Task {
let selected = Set(
manageableCategories.filter { categoryToggles[$0] == true }
)
await onSave(selected)
}
}) {
Text("Save Preferences")
.font(theme.buttonFont)
.foregroundColor(theme.acceptButtonTextColor)
.frame(maxWidth: .infinity)
.frame(height: theme.buttonHeight)
.background(theme.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.padding(theme.verticalPadding)
.accessibilityLabel("Save your cookie preferences")
}
}
// MARK: - Category Row
private func categoryRow(
name: String,
description: String,
isOn: Binding<Bool>,
isToggleable: Bool
) -> some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(name)
.font(theme.bodyFont.weight(.semibold))
.foregroundColor(theme.textColor)
Text(description)
.font(theme.captionFont)
.foregroundColor(theme.secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Toggle("", isOn: isOn)
.labelsHidden()
.toggleStyle(SwitchToggleStyle(tint: theme.accentColor))
.disabled(!isToggleable)
.accessibilityLabel(isToggleable ? "Toggle \(name)" : "\(name) always active")
}
.padding(.horizontal, theme.horizontalPadding)
.padding(.vertical, 12)
}
// MARK: - Buttons
private var acceptButton: some View {
Button(action: {
Task { await onAcceptAll() }
}) {
Text(theme.acceptButtonText)
.font(theme.buttonFont)
.foregroundColor(theme.acceptButtonTextColor)
.frame(maxWidth: .infinity)
.frame(height: theme.buttonHeight)
.background(theme.acceptButtonBackground)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.accessibilityLabel("Accept all cookies")
}
private var rejectButton: some View {
Button(action: {
Task { await onRejectAll() }
}) {
Text(theme.rejectButtonText)
.font(theme.buttonFont)
.foregroundColor(theme.rejectButtonTextColor)
.frame(maxWidth: .infinity)
.frame(height: theme.buttonHeight)
.background(theme.rejectButtonBackground)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.accessibilityLabel("Reject all non-essential cookies")
}
private var manageButton: some View {
Button(action: { showingManage = true }) {
Text(theme.manageButtonText)
.font(theme.buttonFont)
.foregroundColor(theme.accentColor)
.frame(maxWidth: .infinity)
.frame(height: theme.buttonHeight)
.background(theme.accentColor.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.accessibilityLabel("Manage your cookie preferences")
}
}
// MARK: - Preview
#if DEBUG
#Preview("Main Banner") {
VStack {
Spacer()
ConsentBannerView(
theme: .defaultTheme,
onAcceptAll: {},
onRejectAll: {},
onSave: { _ in }
)
.padding(.bottom, 8)
}
.background(Color(.systemGroupedBackground))
}
#endif
#endif

View File

@@ -0,0 +1,106 @@
#if canImport(UIKit)
import UIKit
import SwiftUI
import ConsentOSCore
// MARK: - Consent Modal Controller
/// A UIKit view controller that presents the SwiftUI ``ConsentBannerView`` as a
/// bottom-sheet modal overlay.
///
/// Use this when your app is primarily UIKit-based.
///
/// ```swift
/// let modal = ConsentModalController()
/// modal.onDismiss = { [weak self] in
/// // Banner dismissed
/// }
/// present(modal, animated: true)
/// ```
public final class ConsentModalController: UIViewController {
// MARK: - Properties
/// Called when the banner is dismissed (after any consent action).
public var onDismiss: (() -> Void)?
/// The theme to apply to the banner. Defaults to ``BannerTheme/defaultTheme``.
public var theme: BannerTheme = .defaultTheme
// MARK: - Private
private var hostingController: UIHostingController<AnyView>?
// MARK: - Lifecycle
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.black.withAlphaComponent(0.4)
setupBannerView()
setupBackgroundDismiss()
}
// MARK: - Setup
private func setupBannerView() {
let bannerView = ConsentBannerView(
theme: theme,
onAcceptAll: { [weak self] in
await ConsentOS.shared.acceptAll()
await MainActor.run { self?.dismiss(animated: true) }
},
onRejectAll: { [weak self] in
await ConsentOS.shared.rejectAll()
await MainActor.run { self?.dismiss(animated: true) }
},
onSave: { [weak self] categories in
await ConsentOS.shared.acceptCategories(categories)
await MainActor.run { self?.dismiss(animated: true) }
}
)
let hosting = UIHostingController(rootView: AnyView(
VStack {
Spacer()
bannerView
.padding(.bottom, 16)
}
))
hosting.view.backgroundColor = .clear
addChild(hosting)
view.addSubview(hosting.view)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hosting.view.topAnchor.constraint(equalTo: view.topAnchor),
hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
hosting.didMove(toParent: self)
self.hostingController = hosting
}
private func setupBackgroundDismiss() {
// Tapping the dim overlay does not dismiss users must interact with the banner.
// Remove this behaviour if your design requires tap-to-dismiss.
}
// MARK: - Dismiss Override
public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
super.dismiss(animated: flag) { [weak self] in
self?.onDismiss?()
completion?()
}
}
// MARK: - Accessibility
public override func accessibilityPerformEscape() -> Bool {
// Do not allow VoiceOver escape gesture to dismiss the banner without a consent choice.
return false
}
}
#endif

View File

@@ -0,0 +1,22 @@
#if canImport(UIKit)
import UIKit
import ConsentOSCore
// MARK: - UIKit Banner Presentation (ConsentOSUI extension)
public extension ConsentOS {
/// Presents the consent banner modally on the given view controller.
///
/// This extension is provided by the ConsentOSUI module.
/// Ensure you import ConsentOSUI alongside ConsentOSCore to use this method.
///
/// - Parameter viewController: The presenting view controller.
@MainActor
func showBanner(on viewController: UIViewController) {
let modal = ConsentModalController()
modal.modalPresentationStyle = .overFullScreen
modal.modalTransitionStyle = .crossDissolve
viewController.present(modal, animated: true)
}
}
#endif