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:
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