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:
165
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentAPI.swift
Normal file
165
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentAPI.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
101
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentCategory.swift
Normal file
101
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentCategory.swift
Normal 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
|
||||
}
|
||||
}
|
||||
188
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentConfig.swift
Normal file
188
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentConfig.swift
Normal 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
|
||||
}
|
||||
}
|
||||
306
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentOS.swift
Normal file
306
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentOS.swift
Normal 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.
|
||||
118
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentState.swift
Normal file
118
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentState.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
101
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentStorage.swift
Normal file
101
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentStorage.swift
Normal 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
|
||||
}
|
||||
}
|
||||
128
sdks/ios/ConsentOS/Sources/ConsentOSCore/GCMBridge.swift
Normal file
128
sdks/ios/ConsentOS/Sources/ConsentOSCore/GCMBridge.swift
Normal 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
|
||||
}
|
||||
}
|
||||
196
sdks/ios/ConsentOS/Sources/ConsentOSCore/TCFStringEncoder.swift
Normal file
196
sdks/ios/ConsentOS/Sources/ConsentOSCore/TCFStringEncoder.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
111
sdks/ios/ConsentOS/Sources/ConsentOSUI/BannerTheme.swift
Normal file
111
sdks/ios/ConsentOS/Sources/ConsentOSUI/BannerTheme.swift
Normal 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
|
||||
275
sdks/ios/ConsentOS/Sources/ConsentOSUI/ConsentBannerView.swift
Normal file
275
sdks/ios/ConsentOS/Sources/ConsentOSUI/ConsentBannerView.swift
Normal 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
|
||||
@@ -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
|
||||
22
sdks/ios/ConsentOS/Sources/ConsentOSUI/ConsentOS+UIKit.swift
Normal file
22
sdks/ios/ConsentOS/Sources/ConsentOSUI/ConsentOS+UIKit.swift
Normal 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
|
||||
Reference in New Issue
Block a user