From d9d261fd5a99c6ea05309b3cac6f723b25894a8f Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 13 May 2026 11:27:29 +0100 Subject: [PATCH] Support checked Sendable configuration storage --- .../Sources/Lib/Logger.swift | 7 +- .../ShopifyCheckoutKit/Configuration.swift | 8 +- .../ShopifyCheckoutKit/LockedValue.swift | 100 ++++++++++++++ .../Sources/ShopifyCheckoutKit/Logger.swift | 6 +- .../ShopifyCheckoutKit.swift | 18 ++- .../ConfigurationTests.swift | 33 ++++- .../LockedValueTests.swift | 41 ++++++ .../Mocks/MockLogger.swift | 10 +- platforms/swift/api/ShopifyCheckoutKit.json | 122 +++++++++++++++++- 9 files changed, 316 insertions(+), 29 deletions(-) create mode 100644 platforms/swift/Sources/ShopifyCheckoutKit/LockedValue.swift create mode 100644 platforms/swift/Tests/ShopifyCheckoutKitTests/LockedValueTests.swift diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/Logger.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/Logger.swift index baf31052..6cb895e1 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/Logger.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/Logger.swift @@ -1,10 +1,10 @@ import Foundation import ShopifyCheckoutKit -class FileLogger: Logger { - private var fileHandle: FileHandle? +final class FileLogger: Logger { + private let fileHandle: FileHandle? - var logFileUrl: URL + let logFileUrl: URL public init(_ filename: String) { let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) @@ -18,6 +18,7 @@ class FileLogger: Logger { do { fileHandle = try FileHandle(forWritingTo: logFileUrl) } catch let error as NSError { + fileHandle = nil print("Couldn't open the log file. Error: \(error)") } } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift index ab0da97b..be8bcc53 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift @@ -1,6 +1,6 @@ import UIKit -public struct Platform: Equatable { +public struct Platform: Equatable, Sendable { public let identifier: String public let version: String? @@ -11,7 +11,7 @@ public struct Platform: Equatable { } } -public struct Configuration { +public struct Configuration: Sendable { /// Determines the color scheme used when checkout is presented. /// /// By default, the color scheme is determined based on the current @@ -48,7 +48,7 @@ public struct Configuration { } extension Configuration { - public enum ColorScheme: String, CaseIterable { + public enum ColorScheme: String, CaseIterable, Sendable { /// Uses a light, idiomatic color scheme. case light /// Uses a dark, idiomatic color scheme. @@ -61,7 +61,7 @@ extension Configuration { } extension Configuration { - public struct Confetti { + public struct Confetti: Sendable { public var enabled: Bool = false public var particles = [UIImage]() diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/LockedValue.swift b/platforms/swift/Sources/ShopifyCheckoutKit/LockedValue.swift new file mode 100644 index 00000000..3a36e2c4 --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/LockedValue.swift @@ -0,0 +1,100 @@ +import Foundation +import os.lock + +private protocol LockedValueStorage: Sendable { + associatedtype Value: Sendable + + func get() -> Value + func set(_ newValue: Value) + func update(_ block: (inout Value) -> Void) +} + +@available(iOS 16.0, *) +private final class OSAllocatedUnfairLockedValueStorage: LockedValueStorage { + /// When the package minimum deployment target is iOS 18, consider + /// replacing this storage with Synchronization.Mutex. + private let lock: OSAllocatedUnfairLock + + init(_ value: Value) { + lock = OSAllocatedUnfairLock(initialState: value) + } + + func get() -> Value { + lock.withLock { $0 } + } + + func set(_ newValue: Value) { + lock.withLock { + $0 = newValue + } + } + + func update(_ block: (inout Value) -> Void) { + lock.withLockUnchecked { + block(&$0) + } + } +} + +/// iOS 15 fallback for lock-backed Sendable storage. +/// +/// SAFETY: +/// - `value` is only read and written while holding `lock`. +/// - `Value` is constrained to `Sendable`, so returned values can cross concurrency domains. +/// - This exists because `OSAllocatedUnfairLock` is only available on iOS 16+. +/// +/// Delete this fallback when the package minimum deployment target is iOS 16. +@available(iOS, deprecated: 16.0, message: "Use OSAllocatedUnfairLockedValueStorage on iOS 16+.") +private final class NSLockedValueStorage: LockedValueStorage, @unchecked Sendable { + private let lock = NSLock() + private var value: Value + + init(_ value: Value) { + self.value = value + } + + func get() -> Value { + lock.withLock { value } + } + + func set(_ newValue: Value) { + lock.withLock { + value = newValue + } + } + + func update(_ block: (inout Value) -> Void) { + lock.withLock { + block(&value) + } + } +} + +/// Lock-backed storage for mutable values that must remain synchronously readable and writable. +/// +/// Use this for module-internal backing storage when a public or static mutable API needs to +/// preserve synchronous mutation, but the stored value would otherwise be non-isolated shared +/// mutable state under Swift 6 concurrency checking. +final class LockedValue: Sendable { + private let storage: any LockedValueStorage + + init(_ value: Value) { + if #available(iOS 16.0, *) { + storage = OSAllocatedUnfairLockedValueStorage(value) + } else { + storage = NSLockedValueStorage(value) + } + } + + func get() -> Value { + storage.get() + } + + func set(_ newValue: Value) { + storage.set(newValue) + } + + func update(_ block: (inout Value) -> Void) { + storage.update(block) + } +} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/Logger.swift b/platforms/swift/Sources/ShopifyCheckoutKit/Logger.swift index 0ce01e2d..5107ee83 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/Logger.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/Logger.swift @@ -3,7 +3,7 @@ import os.log private let subsystem = "com.shopify.checkoutkit" -public enum LogLevel: String, CaseIterable { +public enum LogLevel: String, CaseIterable, Sendable { case all case debug case error @@ -66,12 +66,12 @@ public class OSLogger { } } -public protocol Logger { +public protocol Logger: Sendable { func log(_ message: String) func clearLogs() } -public class NoOpLogger: Logger { +public final class NoOpLogger: Logger { public func log(_: String) {} public func clearLogs() {} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index 5ee84e63..caae2187 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -6,16 +6,26 @@ import UIKit /// The version of the `ShopifyCheckoutKit` library. public let version = "4.0.0-alpha.1" +private let lockedCheckoutKitConfiguration = LockedValue(Configuration()) + /// The configuration options for the `ShopifyCheckoutKit` library. -public var configuration = Configuration() { - didSet { - OSLogger.shared.logLevel = configuration.logLevel +public var configuration: Configuration { + get { lockedCheckoutKitConfiguration.get() } + set { + lockedCheckoutKitConfiguration.set(newValue) + applyConfigurationChange() } } /// A convienence function for configuring the `ShopifyCheckoutKit` library. public func configure(_ block: (inout Configuration) -> Void) { - block(&configuration) + lockedCheckoutKitConfiguration.update(block) + applyConfigurationChange() +} + +private func applyConfigurationChange() { + let configuration = lockedCheckoutKitConfiguration.get() + OSLogger.shared.logLevel = configuration.logLevel } @MainActor diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift index 1792170d..d0a6ec8f 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift @@ -5,7 +5,15 @@ import XCTest class ConfigurationTests: XCTestCase { override func setUp() { super.setUp() - // Reset configuration to defaults + resetConfigurationState() + } + + override func tearDown() { + resetConfigurationState() + super.tearDown() + } + + private func resetConfigurationState() { ShopifyCheckoutKit.configuration = Configuration() } @@ -27,4 +35,27 @@ class ConfigurationTests: XCTestCase { ShopifyCheckoutKit.configuration.closeButtonTintColor = nil XCTAssertNil(ShopifyCheckoutKit.configuration.closeButtonTintColor) } + + func testColorSchemeCanBeSetDirectly() { + ShopifyCheckoutKit.configuration.colorScheme = .light + + XCTAssertEqual(ShopifyCheckoutKit.configuration.colorScheme, .light) + } + + func testConfigureCanBatchConfigurationChanges() { + ShopifyCheckoutKit.configure { + $0.colorScheme = .dark + $0.closeButtonTintColor = .blue + } + + XCTAssertEqual(ShopifyCheckoutKit.configuration.colorScheme, .dark) + XCTAssertEqual(ShopifyCheckoutKit.configuration.closeButtonTintColor, .blue) + } + + func testDirectConfigurationMutationUpdatesLogger() { + ShopifyCheckoutKit.configuration.logLevel = .all + + XCTAssertEqual(ShopifyCheckoutKit.configuration.logLevel, .all) + XCTAssertEqual(OSLogger.shared.logLevel, .all) + } } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/LockedValueTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/LockedValueTests.swift new file mode 100644 index 00000000..2e4e1577 --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/LockedValueTests.swift @@ -0,0 +1,41 @@ +@testable import ShopifyCheckoutKit +import XCTest + +final class LockedValueTests: XCTestCase { + func testGetReturnsInitialValue() { + let value = LockedValue("initial") + + XCTAssertEqual(value.get(), "initial") + } + + func testSetReplacesStoredValue() { + let value = LockedValue("initial") + + value.set("updated") + + XCTAssertEqual(value.get(), "updated") + } + + func testUpdateMutatesStoredValue() { + let value = LockedValue(["initial"]) + + value.update { storedValue in + storedValue.append("updated") + } + + XCTAssertEqual(value.get(), ["initial", "updated"]) + } + + func testUpdateSerializesConcurrentMutations() { + let value = LockedValue(0) + let iterations = 1000 + + DispatchQueue.concurrentPerform(iterations: iterations) { _ in + value.update { storedValue in + storedValue += 1 + } + } + + XCTAssertEqual(value.get(), iterations) + } +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockLogger.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockLogger.swift index d5a8ccf0..06ffaf45 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockLogger.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockLogger.swift @@ -1,11 +1,7 @@ @testable import ShopifyCheckoutKit -class MockLogger: NoOpLogger { - var loggedError: Error? - var loggedMessage: String? +struct MockLogger: Logger { + func log(_: String) {} - func logError(_ error: Error, _ message: String) { - loggedError = error - loggedMessage = message - } + func clearLogs() {} } diff --git a/platforms/swift/api/ShopifyCheckoutKit.json b/platforms/swift/api/ShopifyCheckoutKit.json index 49d87b3c..448a9a7b 100644 --- a/platforms/swift/api/ShopifyCheckoutKit.json +++ b/platforms/swift/api/ShopifyCheckoutKit.json @@ -95,6 +95,13 @@ "declKind": "Import", "moduleName": "ShopifyCheckoutKit" }, + { + "kind": "Import", + "name": "os.lock", + "printedName": "os.lock", + "declKind": "Import", + "moduleName": "ShopifyCheckoutKit" + }, { "kind": "Import", "name": "os.log", @@ -2631,6 +2638,20 @@ "usr": "s:SQ", "mangledName": "$sSQ" }, + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, { "kind": "Conformance", "name": "Copyable", @@ -3841,6 +3862,20 @@ "usr": "s:s12CaseIterableP", "mangledName": "$ss12CaseIterableP" }, + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, { "kind": "Conformance", "name": "Copyable", @@ -4039,6 +4074,20 @@ "moduleName": "ShopifyCheckoutKit", "isFromExtension": true, "conformances": [ + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, { "kind": "Conformance", "name": "Copyable", @@ -4061,6 +4110,20 @@ "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV", "moduleName": "ShopifyCheckoutKit", "conformances": [ + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, { "kind": "Conformance", "name": "Copyable", @@ -4486,6 +4549,20 @@ "usr": "s:s12CaseIterableP", "mangledName": "$ss12CaseIterableP" }, + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, { "kind": "Conformance", "name": "Copyable", @@ -4879,6 +4956,7 @@ "usr": "s:18ShopifyCheckoutKit6LoggerP", "mangledName": "$s18ShopifyCheckoutKit6LoggerP", "moduleName": "ShopifyCheckoutKit", + "genericSig": "", "conformances": [ { "kind": "Conformance", @@ -4893,6 +4971,20 @@ "printedName": "Copyable", "usr": "s:s8CopyableP", "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" } ] }, @@ -4922,6 +5014,9 @@ "usr": "s:18ShopifyCheckoutKit10NoOpLoggerC3logyySSF", "mangledName": "$s18ShopifyCheckoutKit10NoOpLoggerC3logyySSF", "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "Final" + ], "funcSelfKind": "NonMutating" }, { @@ -4939,6 +5034,9 @@ "usr": "s:18ShopifyCheckoutKit10NoOpLoggerC9clearLogsyyF", "mangledName": "$s18ShopifyCheckoutKit10NoOpLoggerC9clearLogsyyF", "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "Final" + ], "funcSelfKind": "NonMutating" } ], @@ -4946,6 +5044,9 @@ "usr": "s:18ShopifyCheckoutKit10NoOpLoggerC", "mangledName": "$s18ShopifyCheckoutKit10NoOpLoggerC", "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "Final" + ], "hasMissingDesignatedInitializers": true, "conformances": [ { @@ -4955,6 +5056,20 @@ "usr": "s:18ShopifyCheckoutKit6LoggerP", "mangledName": "$s18ShopifyCheckoutKit6LoggerP" }, + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, { "kind": "Conformance", "name": "Copyable", @@ -5361,11 +5476,6 @@ "usr": "s:18ShopifyCheckoutKit13configurationAA13ConfigurationVvp", "mangledName": "$s18ShopifyCheckoutKit13configurationAA13ConfigurationVvp", "moduleName": "ShopifyCheckoutKit", - "declAttributes": [ - "HasInitialValue", - "HasStorage" - ], - "hasStorage": true, "accessors": [ { "kind": "Accessor", @@ -5383,7 +5493,6 @@ "usr": "s:18ShopifyCheckoutKit13configurationAA13ConfigurationVvg", "mangledName": "$s18ShopifyCheckoutKit13configurationAA13ConfigurationVvg", "moduleName": "ShopifyCheckoutKit", - "implicit": true, "accessorKind": "get" }, { @@ -5407,7 +5516,6 @@ "usr": "s:18ShopifyCheckoutKit13configurationAA13ConfigurationVvs", "mangledName": "$s18ShopifyCheckoutKit13configurationAA13ConfigurationVvs", "moduleName": "ShopifyCheckoutKit", - "implicit": true, "accessorKind": "set" } ]