From 5059969e9a3f4d545264b4ea81431564f8972633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:56:44 +0200 Subject: [PATCH 1/3] Test computed properties, the dev server loader and version reporting Pins current behaviour before moving these onto iOS 15 and Swift 6.2 APIs. Co-Authored-By: Claude Opus 5.5 --- .../DevServer/DevServerLocator.swift | 2 +- SuperwallKit.xcodeproj/project.pbxproj | 8 ++ .../DevServerLocatorURLSessionTests.swift | 93 +++++++++++++++++++ .../Network/DeviceHelperTests.swift | 11 +++ ...CoreDataManagerComputedPropertyTests.swift | 80 ++++++++++++++++ 5 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 Tests/SuperwallKitTests/DevServer/DevServerLocatorURLSessionTests.swift create mode 100644 Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerComputedPropertyTests.swift diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index e7d6bcd174..e1c5df5dfd 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -186,7 +186,7 @@ actor DevServerLocator { } } - private static func loadWithURLSession(_ request: URLRequest) async throws -> (Data, URLResponse?) { + static func loadWithURLSession(_ request: URLRequest) async throws -> (Data, URLResponse?) { return try await withCheckedThrowingContinuation { continuation in let task = URLSession.shared.dataTask(with: request) { data, response, error in if let data = data { diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index cd73864696..e58fedaae6 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -103,6 +103,7 @@ 2653909358966BE9AC9894F1 /* EvaluationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 911CD5859EC1BE7E428F06C4 /* EvaluationResult.swift */; }; 2698874EEAE37BAECE7B8FD8 /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79E2143AD65D151AE7A4BF0F /* Network.swift */; }; 2743143ED664F942D5D758B1 /* DevicePreloadScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AC1F76564A6EC47EE696F9 /* DevicePreloadScriptTests.swift */; }; + 27C9585AF7E12C7280FDC51C /* DevServerLocatorURLSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8684B1F9A4CAE200794C2689 /* DevServerLocatorURLSessionTests.swift */; }; 27DC2F109FAE3357DC8418F6 /* AutomaticPurchaseController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1F47410CBC84D9ABD2F14 /* AutomaticPurchaseController.swift */; }; 27E396F717A62BA4E0D98086 /* PaywallCacheLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BA95995DE57E811FD65C02 /* PaywallCacheLogicTests.swift */; }; 28A1E7AFF222A9C36F4AE019 /* Superwall+CustomerCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618F0D37FD3B2213A296DEF4 /* Superwall+CustomerCenter.swift */; }; @@ -551,6 +552,7 @@ CF2064883604B915C8768FC5 /* ASIdManagerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF989B3D90DC3D88FACC4D45 /* ASIdManagerProxy.swift */; }; CF3683E2AD703237EC0CE22E /* PaywallProducts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95DABA23C6CBEF0AAA63C0 /* PaywallProducts.swift */; }; CFEB0D797815E8EDFB059767 /* Superwall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F7EDB6D68D0AEDD332E40BB /* Superwall.swift */; }; + D0580483B7A26B0FD912D7E4 /* CoreDataManagerComputedPropertyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59C3C6C9F66BF8EC45CFADF2 /* CoreDataManagerComputedPropertyTests.swift */; }; D0D4568B72D20652C12AA89B /* GrantedEntitlementsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1099A063D46DE7373CFABC4C /* GrantedEntitlementsTests.swift */; }; D0E19F665C7B230BF3FA122D /* TrackingResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85ED994DEC92BB90ACC6AC2 /* TrackingResult.swift */; }; D11EE875D4F3B3AC212526CD /* PurchasePresentationBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4032D6E844683EBEFB6FF619 /* PurchasePresentationBuilderTests.swift */; }; @@ -937,6 +939,7 @@ 58EA2C2A9FFC16FA7B90A31B /* CustomerCenterStringsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterStringsTests.swift; sourceTree = ""; }; 59A767F107FB1FBBC2F22DB3 /* AppSessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSessionManagerTests.swift; sourceTree = ""; }; 59AADECFD10DA2D0BC3FFF1D /* CustomerCenterViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterViewModelTests.swift; sourceTree = ""; }; + 59C3C6C9F66BF8EC45CFADF2 /* CoreDataManagerComputedPropertyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataManagerComputedPropertyTests.swift; sourceTree = ""; }; 59C73BC10AC2F6DE8AB1074A /* SuperwallKit_AppleIncRootCertificate.cer */ = {isa = PBXFileReference; path = SuperwallKit_AppleIncRootCertificate.cer; sourceTree = ""; }; 5A413B6FF46B130D90A428B4 /* ProductPurchaserLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserLogic.swift; sourceTree = ""; }; 5AC35B7D7641BEB17798C199 /* SupportEmailComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportEmailComposer.swift; sourceTree = ""; }; @@ -1063,6 +1066,7 @@ 862888AB2869D09AA55A017D /* InAppReceiptPayloadContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppReceiptPayloadContainer.swift; sourceTree = ""; }; 865262BC55BAEA135E51698D /* CacheKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheKeys.swift; sourceTree = ""; }; 866F99509EDFBE8BAE10E575 /* LoadingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingModel.swift; sourceTree = ""; }; + 8684B1F9A4CAE200794C2689 /* DevServerLocatorURLSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerLocatorURLSessionTests.swift; sourceTree = ""; }; 8708F74D80CB9A440F34A556 /* FakeContactsAuthorizationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeContactsAuthorizationStatus.swift; sourceTree = ""; }; 8719AC2E83128EE469E58C36 /* RedeemRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemRequest.swift; sourceTree = ""; }; 87AD727C5A8639E704F7BE98 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = ""; }; @@ -3057,6 +3061,7 @@ children = ( 577D25646DA26238881BF6AB /* DevModeTests.swift */, 21C1CF3FA0D1266FFF8A29FF /* DevServerLocatorTests.swift */, + 8684B1F9A4CAE200794C2689 /* DevServerLocatorURLSessionTests.swift */, A349A124DD1DF28EEF04592C /* DevServerManifestTests.swift */, A4FD16729844D83A3EAA02FC /* DevServerPaywallTests.swift */, 6E0A1ED94DE7737BCB7D4D8C /* DevServerPreviewTests.swift */, @@ -3268,6 +3273,7 @@ CE732EAB1FB2F1C6395243FE /* Core Data */ = { isa = PBXGroup; children = ( + 59C3C6C9F66BF8EC45CFADF2 /* CoreDataManagerComputedPropertyTests.swift */, E74C7DE0FAFE0C01F374DDF0 /* CoreDataManagerFakeDataMock.swift */, EB1B228FB279EA92C35C7394 /* CoreDataManagerMock.swift */, 0D9CC1B947A08633E1C7BAE3 /* CoreDataManagerTests.swift */, @@ -3768,6 +3774,7 @@ AE1D15070BC159212967CAD4 /* ConfirmHoldoutAssignmentTests.swift in Sources */, 61EC2A032D127B323D4A8124 /* ConfirmPaywallAssignmentOperatorTests.swift in Sources */, CBC90EC17DC1EC4C2FE9DFC8 /* ContactStoreProxyTests.swift in Sources */, + D0580483B7A26B0FD912D7E4 /* CoreDataManagerComputedPropertyTests.swift in Sources */, AEAB0C0C168B0B3D864109EA /* CoreDataManagerFakeDataMock.swift in Sources */, A1621A749D8F05959A486ACE /* CoreDataManagerMock.swift in Sources */, C7AB21123540550E513AD28A /* CoreDataManagerTests.swift in Sources */, @@ -3797,6 +3804,7 @@ 8E04DDF7FA8D6A76E96378F3 /* DesignReviewSnapshots.swift in Sources */, FEA3AED0B70D730993A16B2C /* DevModeTests.swift in Sources */, EA21B417513519FEDE6232D4 /* DevServerLocatorTests.swift in Sources */, + 27C9585AF7E12C7280FDC51C /* DevServerLocatorURLSessionTests.swift in Sources */, 069391992F6191874022F2BA /* DevServerManifestTests.swift in Sources */, 669B86B82B4CCD7BC7D02B55 /* DevServerPaywallTests.swift in Sources */, EE1A7003F266DF3C1481EEBA /* DevServerPreviewTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/DevServer/DevServerLocatorURLSessionTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerLocatorURLSessionTests.swift new file mode 100644 index 0000000000..2c81fdf8c5 --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevServerLocatorURLSessionTests.swift @@ -0,0 +1,93 @@ +// +// DevServerLocatorURLSessionTests.swift +// SuperwallKitTests +// + +import Foundation +import Testing +@testable import SuperwallKit + +/// Answers requests to one made-up host, so registering it globally leaves every +/// other request in the suite alone. +private final class StubURLProtocol: URLProtocol { + static let host = "devserver-loader-stub.invalid" + static let body = Data(#"{"surfaces":[]}"#.utf8) + static let registered: Void = { + URLProtocol.registerClass(StubURLProtocol.self) + }() + + override class func canInit(with request: URLRequest) -> Bool { + return request.url?.host == host + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + guard let url = request.url else { + return + } + switch url.path { + case "/ok": + respond(url: url, status: 200, body: Self.body) + case "/missing": + respond(url: url, status: 404, body: Data("not found".utf8)) + case "/empty": + respond(url: url, status: 200, body: nil) + default: + client?.urlProtocol(self, didFailWithError: URLError(.cannotConnectToHost)) + } + } + + override func stopLoading() {} + + private func respond(url: URL, status: Int, body: Data?) { + let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if let body = body { + client?.urlProtocol(self, didLoad: body) + } + client?.urlProtocolDidFinishLoading(self) + } +} + +struct DevServerLocatorURLSessionTests { + init() { + StubURLProtocol.registered + } + + private func load(_ path: String) async throws -> (Data, URLResponse?) { + let url = URL(string: "http://\(StubURLProtocol.host)\(path)")! + return try await DevServerLocator.loadWithURLSession(URLRequest(url: url)) + } + + @Test func success_returnsBodyAndResponse() async throws { + let (data, response) = try await load("/ok") + + #expect(data == StubURLProtocol.body) + #expect((response as? HTTPURLResponse)?.statusCode == 200) + } + + /// The locator reads the status itself, so an error status must come back as a + /// response rather than a thrown error. + @Test func errorStatus_returnsResponseWithoutThrowing() async throws { + let (data, response) = try await load("/missing") + + #expect(data == Data("not found".utf8)) + #expect((response as? HTTPURLResponse)?.statusCode == 404) + } + + @Test func emptyBody_returnsEmptyData() async throws { + let (data, response) = try await load("/empty") + + #expect(data.isEmpty) + #expect((response as? HTTPURLResponse)?.statusCode == 200) + } + + @Test func connectionFailure_throwsTheURLError() async { + await #expect(throws: URLError.self) { + _ = try await load("/unreachable") + } + } +} diff --git a/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift index 6298bf67b2..0be77a73ad 100644 --- a/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift +++ b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift @@ -26,6 +26,17 @@ struct DeviceHelperTests { ) } + /// The SDK needs Swift 6.2 to build. In the Swift 5 language mode it builds in, a + /// Swift 6 compiler reports its language version as 5.10. + @Test func swiftAndCompilerVersions_matchTheToolchainFloor() { + let (deviceHelper, dependencyContainer) = makeDeviceHelper(gate: Gate(false)) + withExtendedLifetime(dependencyContainer) { + #expect(["5.10", "6.2"].contains(deviceHelper.currentSwiftVersion())) + let compilerVersion = deviceHelper.currentCompilerVersion() + #expect(compilerVersion.compare("6.2", options: .numeric) != .orderedAscending) + } + } + @Test func makePaddedSdkVersion_withBeta() { let version = "3.0.0-beta.1" let paddedVersion = DeviceHelper.makePaddedVersion(using: version) diff --git a/Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerComputedPropertyTests.swift b/Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerComputedPropertyTests.swift new file mode 100644 index 0000000000..773e9cebb0 --- /dev/null +++ b/Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerComputedPropertyTests.swift @@ -0,0 +1,80 @@ +// +// CoreDataManagerComputedPropertyTests.swift +// SuperwallKitTests +// + +import Foundation +import Testing +@testable import SuperwallKit + +struct CoreDataManagerComputedPropertyTests { + private let coreDataManager = CoreDataManager(coreDataStack: CoreDataStackMock()) + + private func save(_ name: String, secondsAgo: TimeInterval) async -> PlacementData { + let placementData = PlacementData.stub() + .setting(\.name, to: name) + .setting(\.createdAt, to: Date().addingTimeInterval(-secondsAgo)) + await withCheckedContinuation { continuation in + coreDataManager.savePlacementData(placementData) { _ in + continuation.resume() + } + } + return placementData + } + + private func hoursSince( + _ name: String, + after placement: PlacementData? = nil + ) async -> Int? { + return await coreDataManager.getComputedPropertySincePlacement( + placement, + request: ComputedPropertyRequest(type: .hoursSince, placementName: name) + ) + } + + @Test func noSavedPlacement_returnsNil() async { + #expect(await hoursSince("missing") == nil) + } + + @Test func savedPlacement_returnsTimeSinceIt() async { + _ = await save("opened", secondsAgo: 3 * 3600 + 60) + + #expect(await hoursSince("opened") == 3) + } + + @Test func usesTheMostRecentPlacement() async { + _ = await save("opened", secondsAgo: 8 * 3600 + 60) + _ = await save("opened", secondsAgo: 2 * 3600 + 60) + + #expect(await hoursSince("opened") == 2) + } + + @Test func ignoresOtherPlacementNames() async { + _ = await save("opened", secondsAgo: 4 * 3600 + 60) + _ = await save("closed", secondsAgo: 60) + + #expect(await hoursSince("opened") == 4) + } + + /// The placement being evaluated has just been saved, so it's skipped and the one + /// before it is measured instead. + @Test func samePlacementBeingEvaluated_measuresTheOneBeforeIt() async { + _ = await save("opened", secondsAgo: 5 * 3600 + 60) + let current = await save("opened", secondsAgo: 0) + + #expect(await hoursSince("opened", after: current) == 5) + } + + @Test func differentPlacementBeingEvaluated_measuresTheLatest() async { + _ = await save("opened", secondsAgo: 6 * 3600 + 60) + let current = await save("closed", secondsAgo: 0) + + #expect(await hoursSince("opened", after: current) == 6) + } + + @Test func onlyPlacementIsTheOneBeingEvaluated_returnsNil() async { + let current = await save("opened", secondsAgo: 0) + + #expect(await hoursSince("opened", after: current) == nil) + } +} From 7e372aa81229aee1362b929ffbc1abd7c24636e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:54:44 +0200 Subject: [PATCH 2/3] Use iOS 15 and Swift 6.2 APIs now that they're the floor - Core Data reads use the async perform, and a computed property reads the placement's date on the context's queue instead of after it. - The dev server probe uses URLSession's async data(for:). - Window lookups use UIWindowScene.keyWindow, and test mode reuses the existing top view controller helper instead of the deprecated windows list. - Superscript is imported internally so it stays out of the SDK's interface. - Drop Swift and compiler version branches below 6.2. Co-Authored-By: Claude Opus 5.5 --- .../DevServer/DevServerLocator.swift | 12 +- .../UIApplication+ActiveWindow.swift | 4 +- .../Network/Device Helper/SwiftVersion.swift | 172 +----------------- .../Expression Evaluator/CELEvaluator.swift | 2 +- .../EvaluationContext.swift | 2 +- .../Loading/Listeners/HiddenListener.swift | 1 + .../Loading/Listeners/PaddingListener.swift | 1 + .../Storage/Core Data/CoreDataManager.swift | 54 ++---- .../Storage/Core Data/CoreDataStack.swift | 35 ++-- .../TestMode/TestModeTransactionHandler.swift | 16 +- .../Core Data/CoreDataManagerMock.swift | 6 +- 11 files changed, 42 insertions(+), 263 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index e1c5df5dfd..fb966186af 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -187,15 +187,7 @@ actor DevServerLocator { } static func loadWithURLSession(_ request: URLRequest) async throws -> (Data, URLResponse?) { - return try await withCheckedThrowingContinuation { continuation in - let task = URLSession.shared.dataTask(with: request) { data, response, error in - if let data = data { - continuation.resume(returning: (data, response)) - } else { - continuation.resume(throwing: error ?? URLError(.badServerResponse)) - } - } - task.resume() - } + let (data, response) = try await URLSession.shared.data(for: request) + return (data, response) } } diff --git a/Sources/SuperwallKit/Misc/Extensions/UIApplication+ActiveWindow.swift b/Sources/SuperwallKit/Misc/Extensions/UIApplication+ActiveWindow.swift index 80b6323ec3..5e020d7474 100644 --- a/Sources/SuperwallKit/Misc/Extensions/UIApplication+ActiveWindow.swift +++ b/Sources/SuperwallKit/Misc/Extensions/UIApplication+ActiveWindow.swift @@ -16,13 +16,13 @@ extension UIApplication { // First, try to find a key window in the foreground active scene if let windowScene = sharedApplication.connectedScenes .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { - return windowScene.windows.first { $0.isKeyWindow } ?? windowScene.windows.first + return windowScene.keyWindow ?? windowScene.windows.first } // Then try to find a key window in the foreground inactive scene if let windowScene = sharedApplication.connectedScenes .first(where: { $0.activationState == .foregroundInactive }) as? UIWindowScene { - return windowScene.windows.first { $0.isKeyWindow } ?? windowScene.windows.first + return windowScene.keyWindow ?? windowScene.windows.first } // Fallback: search across all scenes for a key window diff --git a/Sources/SuperwallKit/Network/Device Helper/SwiftVersion.swift b/Sources/SuperwallKit/Network/Device Helper/SwiftVersion.swift index 89a92fb2cc..ddb4c63081 100644 --- a/Sources/SuperwallKit/Network/Device Helper/SwiftVersion.swift +++ b/Sources/SuperwallKit/Network/Device Helper/SwiftVersion.swift @@ -4,108 +4,14 @@ // // Created by Yusuf Tör on 05/08/2025. // -// swiftlint:disable function_body_length extension DeviceHelper { func currentSwiftVersion() -> String { #if swift(>=6.2) return "6.2" - #elseif swift(>=6.1) - return "6.1" - #elseif swift(>=6.0) - return "6.0" - #elseif swift(>=5.10) - return "5.10" - #elseif swift(>=5.9.2) - return "5.9.2" - #elseif swift(>=5.9.1) - return "5.9.1" - #elseif swift(>=5.9) - return "5.9" - #elseif swift(>=5.8.1) - return "5.8.1" - #elseif swift(>=5.8) - return "5.8" - #elseif swift(>=5.7.3) - return "5.7.3" - #elseif swift(>=5.7.2) - return "5.7.2" - #elseif swift(>=5.7.1) - return "5.7.1" - #elseif swift(>=5.7) - return "5.7" - #elseif swift(>=5.6.1) - return "5.6.1" - #elseif swift(>=5.6) - return "5.6" - #elseif swift(>=5.5.2) - return "5.5.2" - #elseif swift(>=5.5.1) - return "5.5.1" - #elseif swift(>=5.5) - return "5.5" - #elseif swift(>=5.4.2) - return "5.4.2" - #elseif swift(>=5.4) - return "5.4" - #elseif swift(>=5.3.2) - return "5.3.2" - #elseif swift(>=5.3.1) - return "5.3.1" - #elseif swift(>=5.3) - return "5.3" - #elseif swift(>=5.2.4) - return "5.2.4" - #elseif swift(>=5.2.2) - return "5.2.2" - #elseif swift(>=5.2) - return "5.2" - #elseif swift(>=5.1.3) - return "5.1.3" - #elseif swift(>=5.1.2) - return "5.1.2" - #elseif swift(>=5.1) - return "5.1" - #elseif swift(>=5.0.1) - return "5.0.1" - #elseif swift(>=5.0) - return "5.0" - #elseif swift(>=4.2.1) - return "4.2.1" - #elseif swift(>=4.2) - return "4.2" - #elseif swift(>=4.1.2) - return "4.1.2" - #elseif swift(>=4.1) - return "4.1" - #elseif swift(>=4.0.3) - return "4.0.3" - #elseif swift(>=4.0.2) - return "4.0.2" - #elseif swift(>=4.0) - return "4.0" - #elseif swift(>=3.1) - return "3.1" - #elseif swift(>=3.0.2) - return "3.0.2" - #elseif swift(>=3.0.1) - return "3.0.1" - #elseif swift(>=3.0) - return "3.0" - #elseif swift(>=2.2) - return "2.2" - #elseif swift(>=2.1) - return "2.1" - #elseif swift(>=2.0) - return "2.0" - #elseif swift(>=1.2) - return "1.2" - #elseif swift(>=1.1) - return "1.1" - #elseif swift(>=1.0) - return "1.0" #else - return "Unknown" + // A Swift 6 compiler reports this in the Swift 5 language mode. + return "5.10" #endif } @@ -124,80 +30,6 @@ extension DeviceHelper { return "6.2.3" #elseif compiler(>=6.2) return "6.2" - #elseif compiler(>=6.1) - return "6.1" - #elseif compiler(>=6.0) - return "6.0" - #elseif compiler(>=5.10) - return "5.10" - #elseif compiler(>=5.9.2) - return "5.9.2" - #elseif compiler(>=5.9.1) - return "5.9.1" - #elseif compiler(>=5.9) - return "5.9" - #elseif compiler(>=5.8.1) - return "5.8.1" - #elseif compiler(>=5.8) - return "5.8" - #elseif compiler(>=5.7.3) - return "5.7.3" - #elseif compiler(>=5.7.2) - return "5.7.2" - #elseif compiler(>=5.7.1) - return "5.7.1" - #elseif compiler(>=5.7) - return "5.7" - #elseif compiler(>=5.6.1) - return "5.6.1" - #elseif compiler(>=5.6) - return "5.6" - #elseif compiler(>=5.5.2) - return "5.5.2" - #elseif compiler(>=5.5.1) - return "5.5.1" - #elseif compiler(>=5.5) - return "5.5" - #elseif compiler(>=5.4.2) - return "5.4.2" - #elseif compiler(>=5.4) - return "5.4" - #elseif compiler(>=5.3.2) - return "5.3.2" - #elseif compiler(>=5.3.1) - return "5.3.1" - #elseif compiler(>=5.3) - return "5.3" - #elseif compiler(>=5.2.4) - return "5.2.4" - #elseif compiler(>=5.2.2) - return "5.2.2" - #elseif compiler(>=5.2) - return "5.2" - #elseif compiler(>=5.1.3) - return "5.1.3" - #elseif compiler(>=5.1.2) - return "5.1.2" - #elseif compiler(>=5.1) - return "5.1" - #elseif compiler(>=5.0.1) - return "5.0.1" - #elseif compiler(>=5.0) - return "5.0" - #elseif compiler(>=4.2.1) - return "4.2.1" - #elseif compiler(>=4.2) - return "4.2" - #elseif compiler(>=4.1.2) - return "4.1.2" - #elseif compiler(>=4.1) - return "4.1" - #elseif compiler(>=4.0.3) - return "4.0.3" - #elseif compiler(>=4.0.2) - return "4.0.2" - #elseif compiler(>=4.0) - return "4.0" #else return "Unknown" #endif diff --git a/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/CELEvaluator.swift b/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/CELEvaluator.swift index 966af3efb0..a1aaad1b23 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/CELEvaluator.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/CELEvaluator.swift @@ -7,7 +7,7 @@ // swiftlint:disable function_body_length import Foundation -import Superscript +internal import Superscript protocol ExpressionEvaluating { func evaluateExpression( diff --git a/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/EvaluationContext.swift b/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/EvaluationContext.swift index a1f996b008..42fd00eec2 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/EvaluationContext.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/EvaluationContext.swift @@ -7,7 +7,7 @@ // swiftlint:disable function_body_length import Foundation -import Superscript +internal import Superscript final class EvaluationContext: HostContext { let storage: Storage diff --git a/Sources/SuperwallKit/Paywall/View Controller/Loading/Listeners/HiddenListener.swift b/Sources/SuperwallKit/Paywall/View Controller/Loading/Listeners/HiddenListener.swift index 0e43e65128..d920c3805f 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Loading/Listeners/HiddenListener.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Loading/Listeners/HiddenListener.swift @@ -5,6 +5,7 @@ // Created by Yusuf Tör on 12/10/2022. // +import Combine import SwiftUI struct HiddenListenerViewModifier: ViewModifier { diff --git a/Sources/SuperwallKit/Paywall/View Controller/Loading/Listeners/PaddingListener.swift b/Sources/SuperwallKit/Paywall/View Controller/Loading/Listeners/PaddingListener.swift index 911736e853..5b7cf5c0cb 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Loading/Listeners/PaddingListener.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Loading/Listeners/PaddingListener.swift @@ -5,6 +5,7 @@ // Created by Yusuf Tör on 01/02/2023. // +import Combine import SwiftUI struct PaddingListenerViewModifier: ViewModifier { diff --git a/Sources/SuperwallKit/Storage/Core Data/CoreDataManager.swift b/Sources/SuperwallKit/Storage/Core Data/CoreDataManager.swift index 9b46cc148e..92d75bfb5d 100644 --- a/Sources/SuperwallKit/Storage/Core Data/CoreDataManager.swift +++ b/Sources/SuperwallKit/Storage/Core Data/CoreDataManager.swift @@ -174,26 +174,18 @@ class CoreDataManager { lastEventDate = placement.name == request.placementName ? placement.createdAt : nil } - return await withCheckedContinuation { continuation in - coreDataStack.getLastSavedPlacement( - name: request.placementName, - before: lastEventDate - ) { placement in - guard let placement = placement else { - return continuation.resume(returning: nil) - } - let createdAt = placement.createdAt - let calendar = Calendar.current - let currentDate = Date() - let components = calendar.dateComponents( - [request.type.calendarComponent], - from: createdAt, - to: currentDate - ) - - continuation.resume(returning: request.type.dateComponent(from: components)) - } + guard let createdAt = await coreDataStack.getLastSavedPlacementDate( + name: request.placementName, + before: lastEventDate + ) else { + return nil } + let components = Calendar.current.dateComponents( + [request.type.calendarComponent], + from: createdAt, + to: Date() + ) + return request.type.dateComponent(from: components) } func countAudienceOccurrences( @@ -223,18 +215,10 @@ class CoreDataManager { audienceOccurrence.key ) - return await withCheckedContinuation { continuation in - coreDataStack.count(for: fetchRequest) { count in - continuation.resume(returning: count) - } - } + return await coreDataStack.count(for: fetchRequest) case .infinity: fetchRequest.predicate = NSPredicate(format: "occurrenceKey == %@", audienceOccurrence.key) - return await withCheckedContinuation { continuation in - coreDataStack.count(for: fetchRequest) { count in - continuation.resume(returning: count) - } - } + return await coreDataStack.count(for: fetchRequest) } } @@ -265,18 +249,10 @@ class CoreDataManager { placementName ) - return await withCheckedContinuation { continuation in - coreDataStack.count(for: fetchRequest) { count in - continuation.resume(returning: count) - } - } + return await coreDataStack.count(for: fetchRequest) case .infinity: fetchRequest.predicate = NSPredicate(format: "name == %@", placementName) - return await withCheckedContinuation { continuation in - coreDataStack.count(for: fetchRequest) { count in - continuation.resume(returning: count) - } - } + return await coreDataStack.count(for: fetchRequest) } } } diff --git a/Sources/SuperwallKit/Storage/Core Data/CoreDataStack.swift b/Sources/SuperwallKit/Storage/Core Data/CoreDataStack.swift index 3e2c4cf468..e016d601c1 100644 --- a/Sources/SuperwallKit/Storage/Core Data/CoreDataStack.swift +++ b/Sources/SuperwallKit/Storage/Core Data/CoreDataStack.swift @@ -78,20 +78,16 @@ class CoreDataStack { self.mainContext = mainContext } - func count( - for fetchRequest: NSFetchRequest, - completion: @escaping ((Int) -> Void) - ) { + func count(for fetchRequest: NSFetchRequest) async -> Int { guard let backgroundContext = backgroundContext, persistentContainer != nil else { - return completion(0) + return 0 } - backgroundContext.perform { + return await backgroundContext.perform { do { - let count = try backgroundContext.count(for: fetchRequest) - completion(count) + return try backgroundContext.count(for: fetchRequest) } catch let error as NSError { Logger.debug( logLevel: .error, @@ -100,21 +96,22 @@ class CoreDataStack { info: error.userInfo, error: error ) - completion(0) + return 0 } } } - func getLastSavedPlacement( + /// When the most recent placement with this name was saved, optionally only looking + /// before `date`. + func getLastSavedPlacementDate( name: String, - before date: Date?, - completion: @escaping ((ManagedEventData?) -> Void) - ) { + before date: Date? + ) async -> Date? { guard let backgroundContext = backgroundContext else { - return completion(nil) + return nil } - backgroundContext.perform { + return await backgroundContext.perform { let fetchRequest = ManagedEventData.fetchRequest() if let date = date { fetchRequest.predicate = NSPredicate(format: "name == %@ AND createdAt < %@", name, date as NSDate) @@ -125,11 +122,7 @@ class CoreDataStack { fetchRequest.fetchLimit = 1 do { - let results = try backgroundContext.fetch(fetchRequest) - guard let placement = results.first else { - return completion(nil) - } - completion(placement) + return try backgroundContext.fetch(fetchRequest).first?.createdAt } catch { Logger.debug( logLevel: .error, @@ -138,7 +131,7 @@ class CoreDataStack { info: ["placement": name], error: error ) - completion(nil) + return nil } } } diff --git a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift index b422adec90..21fa50a19c 100644 --- a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift +++ b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift @@ -25,7 +25,7 @@ final class TestModeTransactionHandler { product: StoreProduct, purchaseSource: PurchaseSource ) async -> PurchaseResult { - guard let viewController = topViewController() else { + guard let viewController = UIViewController.topMostViewController else { return .failed(PurchaseError.productUnavailable) } @@ -109,7 +109,7 @@ final class TestModeTransactionHandler { /// or `.failed(nil)` when the user cancels. @MainActor func handleRestore() async -> RestorationResult { - guard let viewController = topViewController() else { + guard let viewController = UIViewController.topMostViewController else { return .failed(nil) } @@ -177,16 +177,4 @@ final class TestModeTransactionHandler { return .failed(nil) } } - - @MainActor - private func topViewController() -> UIViewController? { - guard let window = UIApplication.sharedApplication?.windows.first(where: { $0.isKeyWindow }), - var topVC = window.rootViewController else { - return nil - } - while let presented = topVC.presentedViewController { - topVC = presented - } - return topVC - } } diff --git a/Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerMock.swift b/Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerMock.swift index 565db7e8de..7f8a6f9599 100644 --- a/Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerMock.swift +++ b/Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerMock.swift @@ -19,10 +19,6 @@ final class CoreDataManagerMock: CoreDataManager { func countAllEvents() async -> Int { let fetchRequest = ManagedEventData.fetchRequest() - return await withCheckedContinuation { continuation in - internalDataStack.count(for: fetchRequest) { count in - continuation.resume(returning: count) - } - } + return await internalDataStack.count(for: fetchRequest) } } From dd25776c0fe4b93582d239c6aa8f81d561348197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:25:05 +0200 Subject: [PATCH 3/3] Stop using the app-wide windows list and the scene-less review request The landscape check reads the active scene instead of UIApplication.windows, and a review request with no window of its own falls back to the active scene. Co-Authored-By: Claude Opus 5.5 --- .../Extensions/UIWindow/UIWindow+Landscape.swift | 9 +-------- .../View Controller/PaywallViewController.swift | 14 +++++--------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/Sources/SuperwallKit/Misc/Extensions/UIWindow/UIWindow+Landscape.swift b/Sources/SuperwallKit/Misc/Extensions/UIWindow/UIWindow+Landscape.swift index 2b03c193a8..2b8275406b 100644 --- a/Sources/SuperwallKit/Misc/Extensions/UIWindow/UIWindow+Landscape.swift +++ b/Sources/SuperwallKit/Misc/Extensions/UIWindow/UIWindow+Landscape.swift @@ -9,13 +9,6 @@ import UIKit extension UIWindow { static var isLandscape: Bool { - guard let sharedApplication = UIApplication.sharedApplication else { - return false - } - return sharedApplication.windows - .first? - .windowScene? - .interfaceOrientation - .isLandscape ?? false + return UIApplication.sharedApplication?.activeWindowScene?.interfaceOrientation.isLandscape ?? false } } diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 4b8a06500b..8ca32fe45e 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -1439,21 +1439,17 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { func requestReview(type: ReviewType) { switch type { case .inApp: - #if os(visionOS) - if let scene = view.window?.windowScene { + if let scene = view.window?.windowScene ?? UIApplication.sharedApplication?.activeWindowScene { + #if os(visionOS) AppStore.requestReview(in: scene) - } - #else - if let scene = view.window?.windowScene { + #else if #available(iOS 16.0, *) { AppStore.requestReview(in: scene) } else { SKStoreReviewController.requestReview(in: scene) } - } else { - SKStoreReviewController.requestReview() - } - #endif + #endif + } trackReviewRequest(type: .inApp) case .external: let appId: String