Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dfa2792
Raise minimum iOS deployment target to 15.0
OlenaPostindustria Sep 30, 2026
eedee52
Fix deprecation warnings under iOS 15 target
OlenaPostindustria Sep 30, 2026
21d1095
Raise demo pod targets to iOS 15, update pods
OlenaPostindustria Sep 30, 2026
92f4230
Run CI on xcode-27 runner
OlenaPostindustria Sep 30, 2026
84c0c12
Fix objc result check in Slack steps
OlenaPostindustria Sep 30, 2026
6648cfd
Clean up resundant comments
OlenaPostindustria Sep 30, 2026
0b4b4e1
Remove availability checks below iOS 15
OlenaPostindustria Sep 30, 2026
fa89ca1
Add Locked box and use it for ATT debug overrides
OlenaPostindustria Oct 1, 2026
914a97b
Resolve WebView user agent on the main actor
OlenaPostindustria Oct 1, 2026
1088c1d
Enable Swift 6 mode, mark OptableTargeting Sendable
OlenaPostindustria Oct 1, 2026
df4dc82
Make completion handlers Sendable, lock-guard delegate and storage
OlenaPostindustria Oct 1, 2026
1b090ff
Make LocalStorage checked Sendable, pass cacheTTL into getTargeting
OlenaPostindustria Oct 1, 2026
7c46c57
Declare Swift 6 in Package.swift and podspec
OlenaPostindustria Oct 1, 2026
b76da88
Mark OptableIdentifier Sendable
OlenaPostindustria Oct 1, 2026
0d62ac4
Build the test target in Swift 6 mode
OlenaPostindustria Oct 1, 2026
2fb8b85
Make OptableConfig thread-safe and Sendable
OlenaPostindustria Oct 1, 2026
c4f6496
Deliver OptableDelegate callbacks on the main actor
OlenaPostindustria Oct 1, 2026
af48261
Add preconcurrency to OptableDelegate
OlenaPostindustria Oct 2, 2026
389d26f
Isolate delegate to the main actor
OlenaPostindustria Oct 2, 2026
024c2f5
Add preconcurrency to completion-based APIs
OlenaPostindustria Oct 2, 2026
3b9a8f1
Raise test target deployment target to 17.0
OlenaPostindustria Oct 2, 2026
07799b7
Remove OptableTargeting Sendable comment
OlenaPostindustria Oct 2, 2026
79657a5
Make EdgeAPI, OptableSDK Sendable
OlenaPostindustria Oct 2, 2026
a114316
Merge remote-tracking branch 'origin/master' into pr72-resolve
mosherBT Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion OptableSDK.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Pod::Spec.new do |spec|

spec.platform = :ios
spec.ios.deployment_target = "15.0"
spec.swift_version = "5.0"
spec.swift_version = "6.0"

spec.source = { :git => "https://github.com/Optable/optable-ios-sdk.git", :tag => "#{spec.version}" }
spec.source_files = "Source/**/*.{h,m,swift}"
Expand Down
12 changes: 6 additions & 6 deletions OptableSDK.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@
SUPPORTS_MACCATALYST = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
Expand Down Expand Up @@ -419,7 +419,7 @@
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SUPPORTS_MACCATALYST = NO;
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
Expand All @@ -431,7 +431,7 @@
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = Tests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand All @@ -440,7 +440,7 @@
PRODUCT_BUNDLE_IDENTIFIER = co.optable.OptableSDKTests;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
Expand All @@ -452,7 +452,7 @@
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = Tests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand All @@ -461,7 +461,7 @@
PRODUCT_BUNDLE_IDENTIFIER = co.optable.OptableSDKTests;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
Expand Down
5 changes: 3 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.5
// swift-tools-version:6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
Expand Down Expand Up @@ -29,5 +29,6 @@ let package = Package(
name: "OptableSDKTests",
dependencies: ["OptableSDK"],
path: "Tests"),
]
],
swiftLanguageModes: [.v6]
)
63 changes: 34 additions & 29 deletions Source/Core/EdgeAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,34 @@ import WebKit
[](https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide)

*/
final class EdgeAPI {
private let kPassportHeader: String = "X-Optable-Visitor"
final class EdgeAPI: Sendable {
private static let kPassportHeader: String = "X-Optable-Visitor"

var storage: LocalStorage
var config: OptableConfig
let storage: LocalStorage
let config: OptableConfig

var userAgent: String?
/// The `User-Agent` sent with every request: `config.customUserAgent` when set, otherwise the WebView's
/// user agent, resolved asynchronously on the main actor at init (nil until it arrives).
var userAgent: String? {
get { userAgentStore.withLock { $0 } }
set { userAgentStore.withLock { $0 = newValue } }
}

private let userAgentStore: Locked<String?>

private lazy var jsonEncoder = JSONEncoder()
private let jsonEncoder = JSONEncoder()

init(_ config: OptableConfig) {
self.config = config
self.storage = LocalStorage(config)
self.userAgentStore = Locked(config.customUserAgent)

if config.customUserAgent == nil {
self.resolveUserAgent { realUserAgent in
self.userAgent = realUserAgent
let userAgentStore = self.userAgentStore
Task { @MainActor in
let realUserAgent = await EdgeAPI.resolveWebViewUserAgent()
userAgentStore.withLock { $0 = realUserAgent }
}
} else {
self.userAgent = config.customUserAgent
}
}

Expand Down Expand Up @@ -108,8 +117,8 @@ final class EdgeAPI {

// MARK: - Dispatch
extension EdgeAPI {
func dispatch(request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
return URLSession.shared.dataTask(with: request) { data, response, error in
func dispatch(request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
return URLSession.shared.dataTask(with: request) { [storage] data, response, error in
guard let res = response as? HTTPURLResponse, error == nil else {
completionHandler(data, response, error)
return
Expand All @@ -118,8 +127,8 @@ extension EdgeAPI {
completionHandler(data, response, error)
return
}
if let passport = res.value(forHTTPHeaderField: self.kPassportHeader) {
self.storage.setPassport(passport)
if let passport = res.value(forHTTPHeaderField: Self.kPassportHeader) {
storage.setPassport(passport)
}
completionHandler(data, response, error)
}
Expand All @@ -128,30 +137,26 @@ extension EdgeAPI {

// MARK: - Private
extension EdgeAPI {
private func resolveUserAgent(callback: @escaping (_ useragent: String) -> Void) {
var wkUserAgent = ""
let myGroup = DispatchGroup()
/// Reads `navigator.userAgent` from a hidden, throwaway WKWebView attached to the key window.
/// Returns an empty string when the WebView cannot provide one.
@MainActor
private static func resolveWebViewUserAgent() async -> String {
let window = UIApplication.shared.connectedScenes
.compactMap { ($0 as? UIWindowScene)?.keyWindow }
.first
let webView = WKWebView(frame: UIScreen.main.bounds)

webView.isHidden = true
window?.addSubview(webView)
myGroup.enter()

webView.loadHTMLString("<html></html>", baseURL: nil)
webView.evaluateJavaScript("navigator.userAgent", completionHandler: { (userAgent: Any?, error: Error?) in
if let userAgent = userAgent as? String {
wkUserAgent = userAgent
}
defer {
webView.stopLoading()
webView.removeFromSuperview()
myGroup.leave()
})
myGroup.notify(queue: .main) {
callback(wkUserAgent)
}

webView.loadHTMLString("<html></html>", baseURL: nil)

let userAgent = try? await webView.evaluateJavaScript("navigator.userAgent")
return (userAgent as? String) ?? ""
}

func resolveHeaders() -> HTTPHeaders {
Expand All @@ -172,7 +177,7 @@ extension EdgeAPI {
}

if let passport: String = storage.getPassport() {
headers[kPassportHeader] = passport
headers[Self.kPassportHeader] = passport
}

return headers
Expand Down
29 changes: 15 additions & 14 deletions Source/Core/LocalStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,21 @@ import Foundation

/**
The OptableSDK keeps some state in UserDefaults (https://developer.apple.com/documentation/foundation/userdefaults), a key/value store persisted across launches of the app. The state is therefore unique to the app+device, and not globally unique to the app across devices.

`Sendable`: every stored property is immutable, `UserDefaults` is thread-safe, and targeting reads and writes
are serialized by `lock`.
*/
final class LocalStorage: NSObject {
final class LocalStorage: NSObject, Sendable {
private let targetingDataKey: String
private let gamTargetingKeywordsKey: String
private let ortb2Key: String
private let id5SignatureKey: String

let keyPfx: String = "OPTABLE"
var passportKey: String
var targetingKey: String
var targetingStoredAtKey: String

private let config: OptableConfig

let passportKey: String
let targetingKey: String
let targetingStoredAtKey: String

private let lock = NSLock()

init(_ config: OptableConfig) {
Expand All @@ -33,8 +34,6 @@ final class LocalStorage: NSObject {
.data(using: .utf8)?
.base64EncodedString()

self.config = config

self.passportKey = self.keyPfx + "_PASS_" + (base64Key ?? "UNKNOWN")
self.targetingKey = self.keyPfx + "_TGT_" + (base64Key ?? "UNKNOWN")

Expand All @@ -53,13 +52,15 @@ final class LocalStorage: NSObject {
UserDefaults.standard.set(passport, forKey: passportKey)
}

func getTargeting() -> OptableTargeting? {
/// Returns the stored targeting entry while it is younger than `cacheTTL`. An expired entry is removed
/// from storage and reported as absent.
func getTargeting(cacheTTL: TimeInterval) -> OptableTargeting? {
lock.synchronized {
guard let targetingData = UserDefaults.standard.object(forKey: targetingDataKey) as? [String: Any] else {
return nil
}

guard isTargetingFresh() else {
guard isTargetingFresh(cacheTTL: cacheTTL) else {
removeTargetingEntry()
return nil
}
Expand Down Expand Up @@ -105,15 +106,15 @@ final class LocalStorage: NSObject {
UserDefaults.standard.removeObject(forKey: targetingStoredAtKey)
}

/// Whether the stored targeting entry was fetched recently enough to still be served, per `config.cacheTTL`. The caller must hold `lock`.
private func isTargetingFresh() -> Bool {
/// Whether the stored targeting entry was fetched recently enough to still be served, per `cacheTTL`. The caller must hold `lock`.
private func isTargetingFresh(cacheTTL: TimeInterval) -> Bool {
// NOTE: A missing timestamp means the entry predates cache expiry support, so its age is unknowable - treat it as expired.
guard let storedAt = UserDefaults.standard.object(forKey: targetingStoredAtKey) as? TimeInterval else {
return false
}

let age = Date().timeIntervalSince1970 - storedAt

return age >= 0 && age < config.cacheTTL
return age >= 0 && age < cacheTTL
}
}
21 changes: 18 additions & 3 deletions Source/Misc/AppTrackingTransparency.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,18 @@
import AppTrackingTransparency
import Foundation

/// The `*_DebugOverride` knobs are test-only hooks. Each one is backed by a `Locked` box, which keeps
/// the static accessors concurrency-safe under Swift 6 (plain `static var`s are rejected there).
enum ATT {
// MARK: advertisingIdentifier

#if DEBUG
static var advertisingIdentifier_DebugOverride: UUID?
private static let _advertisingIdentifier_DebugOverride = Locked<UUID?>(nil)
static var advertisingIdentifier_DebugOverride: UUID? {
get { _advertisingIdentifier_DebugOverride.withLock { $0 } }
set { _advertisingIdentifier_DebugOverride.withLock { $0 = newValue } }
}

static var advertisingIdentifier: UUID {
advertisingIdentifier_DebugOverride ?? ASIdentifierManager.shared().advertisingIdentifier
}
Expand All @@ -28,7 +35,11 @@
// MARK: advertisingIdentifierAvailable

#if DEBUG
static var advertisingIdentifierAvailable_DebugOverride: Bool?
private static let _advertisingIdentifierAvailable_DebugOverride = Locked<Bool?>(nil)
static var advertisingIdentifierAvailable_DebugOverride: Bool? {
get { _advertisingIdentifierAvailable_DebugOverride.withLock { $0 } }
set { _advertisingIdentifierAvailable_DebugOverride.withLock { $0 = newValue } }
}
#endif

static var advertisingIdentifierAvailable: Bool {
Expand All @@ -44,7 +55,11 @@
// MARK: trackingStatus

#if DEBUG
static var trackingStatus_DebugOverride: ATTrackingManager.AuthorizationStatus?
private static let _trackingStatus_DebugOverride = Locked<ATTrackingManager.AuthorizationStatus?>(nil)
static var trackingStatus_DebugOverride: ATTrackingManager.AuthorizationStatus? {
get { _trackingStatus_DebugOverride.withLock { $0 } }
set { _trackingStatus_DebugOverride.withLock { $0 = newValue } }
}
#endif

static var trackingStatus: ATTrackingManager.AuthorizationStatus {
Expand Down
28 changes: 28 additions & 0 deletions Source/Misc/Locked.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// Locked.swift
// OptableSDK
//
// Copyright © 2026 Optable Technologies, Inc. All rights reserved.
//

import Foundation

/// A value guarded by a lock, so it can be shared across threads under Swift 6 strict concurrency.
/// Stands in for `Synchronization.Mutex` (iOS 18) and `OSAllocatedUnfairLock` (iOS 16).
final class Locked<Value> {
private let lock = NSLock()
private var value: Value

init(_ value: Value) {
self.value = value
}

/// Runs `body` with exclusive access to the wrapped value and returns its result.
func withLock<T>(_ body: (inout Value) throws -> T) rethrows -> T {
lock.lock()
defer { lock.unlock() }
return try body(&value)
}
}

extension Locked: @unchecked Sendable where Value: Sendable {}
Loading
Loading