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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user