diff --git a/Example/HCaptcha.xcodeproj/project.pbxproj b/Example/HCaptcha.xcodeproj/project.pbxproj index ef12d07f..65a19682 100644 --- a/Example/HCaptcha.xcodeproj/project.pbxproj +++ b/Example/HCaptcha.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 9D974807F9A78F46BA2DF507 /* Pods_HCaptcha_UIKit_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4D10E8098B5018907486C310 /* Pods_HCaptcha_UIKit_Example.framework */; }; B239CA7190DC61887942610E /* Pods_HCaptcha_Objc_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8352086EA5EE39AFD3CDA308 /* Pods_HCaptcha_Objc_Example.framework */; }; BDFAD6E83D15B582D138D6BC /* Pods_HCaptcha_UITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF5369BD0117A5DA463EACA9 /* Pods_HCaptcha_UITests.framework */; }; + D78A8C5AF5E892324AA9F5E0 /* DispatchQueue+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 306D97A7E014C9E6042E02CB /* DispatchQueue+Helpers.swift */; }; E626A8002890568A0069D449 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E626A7FF2890568A0069D449 /* ContentView.swift */; }; E626A80A289056E00069D449 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = E626A809289056E00069D449 /* App.swift */; }; E62A74212771290A00973B5E /* HCaptcha__Config__Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62A74202771290A00973B5E /* HCaptcha__Config__Tests.swift */; }; @@ -100,6 +101,7 @@ 15CF87684AC2F129E32CC71E /* Pods-HCaptcha_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HCaptcha_Example.debug.xcconfig"; path = "Target Support Files/Pods-HCaptcha_Example/Pods-HCaptcha_Example.debug.xcconfig"; sourceTree = ""; }; 1CF662E463D56A0421D8A84A /* Pods-HCaptcha_RxSwift_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HCaptcha_RxSwift_Example.debug.xcconfig"; path = "Target Support Files/Pods-HCaptcha_RxSwift_Example/Pods-HCaptcha_RxSwift_Example.debug.xcconfig"; sourceTree = ""; }; 252CF3F651CD53804881A8EC /* Pods-HCaptcha_RxSwift_Example-HCaptcha_Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HCaptcha_RxSwift_Example-HCaptcha_Tests.debug.xcconfig"; path = "Target Support Files/Pods-HCaptcha_RxSwift_Example-HCaptcha_Tests/Pods-HCaptcha_RxSwift_Example-HCaptcha_Tests.debug.xcconfig"; sourceTree = ""; }; + 306D97A7E014C9E6042E02CB /* DispatchQueue+Helpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Helpers.swift"; sourceTree = ""; }; 3089D1286B04A4610A7EF299 /* Pods_HCaptcha_RxSwift_Example_HCaptcha_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_HCaptcha_RxSwift_Example_HCaptcha_Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 436BDD0C2E813A8F0009557B /* InMemorySink__Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemorySink__Tests.swift; sourceTree = ""; }; 436BDD0D2E813A8F0009557B /* JourneyliticsHooks__Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JourneyliticsHooks__Tests.swift; sourceTree = ""; }; @@ -448,6 +450,7 @@ 9C766C7826CB814A00E0A592 /* HCaptchaConfig+Helpers.swift */, F288E9441F9537760018688D /* HCaptchaError+Equatable.swift */, F231B39E1FED4A8C00F82943 /* HCaptchaDecoder+Helper.swift */, + 306D97A7E014C9E6042E02CB /* DispatchQueue+Helpers.swift */, ); path = Helpers; sourceTree = ""; @@ -1036,6 +1039,7 @@ E62A74212771290A00973B5E /* HCaptcha__Config__Tests.swift in Sources */, E68790E12DF8C60900F2A70F /* TestURLOpener.swift in Sources */, 9C5125BE26CB9A3A0094B023 /* HCaptchaWebViewManager__HTML__Tests.swift in Sources */, + D78A8C5AF5E892324AA9F5E0 /* DispatchQueue+Helpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/HCaptcha_Tests/Core/HCaptchaWebViewManager__HTML__Tests.swift b/Example/HCaptcha_Tests/Core/HCaptchaWebViewManager__HTML__Tests.swift index b9460b84..b6919556 100644 --- a/Example/HCaptcha_Tests/Core/HCaptchaWebViewManager__HTML__Tests.swift +++ b/Example/HCaptcha_Tests/Core/HCaptchaWebViewManager__HTML__Tests.swift @@ -18,11 +18,16 @@ class HCaptchaWebViewManager__HTML__Tests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() + DispatchQueue.resetOnceTokens() + HCaptchaWebViewManager.clearWebViewData() webViewContentIsAvailable = expectation(description: "get webview content") _ = HCaptchaDebugInfo.json } override func tearDownWithError() throws { + UIApplication.shared.keyWindow?.subviews + .filter { $0 is WKWebView } + .forEach { $0.removeFromSuperview() } webViewContentIsAvailable = nil try super.tearDownWithError() diff --git a/Example/HCaptcha_Tests/Core/HCaptchaWebViewManager__Tests.swift b/Example/HCaptcha_Tests/Core/HCaptchaWebViewManager__Tests.swift index 49c717a1..fa69591f 100644 --- a/Example/HCaptcha_Tests/Core/HCaptchaWebViewManager__Tests.swift +++ b/Example/HCaptcha_Tests/Core/HCaptchaWebViewManager__Tests.swift @@ -20,11 +20,21 @@ class HCaptchaWebViewManager__Tests: XCTestCase { override func setUp() { super.setUp() + DispatchQueue.resetOnceTokens() + HCaptchaWebViewManager.clearWebViewData() presenterView = UIApplication.shared.keyWindow?.rootViewController?.view apiKey = String(arc4random()) } override func tearDown() { + presenterView?.subviews + .filter { $0 is WKWebView } + .forEach { $0.removeFromSuperview() } + + UIApplication.shared.keyWindow?.subviews + .filter { $0 is WKWebView } + .forEach { $0.removeFromSuperview() } + presenterView = nil apiKey = nil @@ -579,7 +589,7 @@ class HCaptchaWebViewManager__Tests: XCTestCase { manager.configureWebView { _ in exp0.fulfill() } - wait(for: [exp0], timeout: TestTimeouts.short) + wait(for: [exp0], timeout: TestTimeouts.standard) manager.validate(on: presenterView) wait(for: [exp1, exp2], timeout: TestTimeouts.standard) @@ -831,4 +841,82 @@ class HCaptchaWebViewManager__Tests: XCTestCase { // Then XCTAssertEqual(rawValue, "reset();") } + + // MARK: - Pre-Validate Error Handling + + func test__Handle_Error_PreValidate__Stores_Failed_State() { + let manager = HCaptchaWebViewManager(onLoad: .networkError) + + let pred = NSPredicate { _, _ in manager.loadingState.error == .networkError } + let exp = XCTNSPredicateExpectation(predicate: pred, object: nil) + wait(for: [exp], timeout: TestTimeouts.standard) + + XCTAssertNil(manager.completion) + } + + func test__Validate_After_PreValidate_Network_Error__Silent_Retry_Reports_Error() { + let manager = HCaptchaWebViewManager(onLoad: .networkError) + + let pred = NSPredicate { _, _ in manager.loadingState.error == .networkError } + let errExp = XCTNSPredicateExpectation(predicate: pred, object: nil) + wait(for: [errExp], timeout: TestTimeouts.standard) + + let exp1 = expectation(description: "silent retry fails, then completion receives networkError") + manager.validate(on: presenterView, resetOnError: false) { result in + XCTAssertEqual(result.error, .networkError) + exp1.fulfill() + } + + waitForExpectations(timeout: TestTimeouts.standard) + + manager.stop() + } + + func test__Validate_After_PreValidate_Network_Error__Silent_Retry_Recovers() { + let manager = HCaptchaWebViewManager(messageBody: "{token: key}", + apiKey: apiKey, + onLoad: .networkOnce) + + let pred = NSPredicate { _, _ in manager.loadingState.error == .networkError } + let errExp = XCTNSPredicateExpectation(predicate: pred, object: nil) + wait(for: [errExp], timeout: TestTimeouts.standard) + + let exp1 = expectation(description: "silent retry succeeds, validate returns token") + manager.validate(on: presenterView, resetOnError: false) { result in + XCTAssertNil(result.error) + XCTAssertEqual(result.token, self.apiKey) + exp1.fulfill() + } + + waitForExpectations(timeout: TestTimeouts.standard) + } + + func test__Validate_After_PreValidate_Network_Error__No_Double_Callback() { + let manager = HCaptchaWebViewManager(onLoad: .networkError) + + let pred = NSPredicate { _, _ in manager.loadingState.error == .networkError } + let errExp = XCTNSPredicateExpectation(predicate: pred, object: nil) + wait(for: [errExp], timeout: TestTimeouts.standard) + + var completionCallCount = 0 + let exp1 = expectation(description: "completion called exactly once") + + manager.validate(on: presenterView, resetOnError: false) { result in + completionCallCount += 1 + XCTAssertEqual(result.error, .networkError) + exp1.fulfill() + } + + waitForExpectations(timeout: TestTimeouts.standard) + + let exp2 = expectation(description: "no extra callbacks") + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + XCTAssertEqual(completionCallCount, 1, "completion should be called exactly once") + exp2.fulfill() + } + + waitForExpectations(timeout: TestTimeouts.standard) + + manager.stop() + } } diff --git a/Example/HCaptcha_Tests/Core/HCaptcha__Tests.swift b/Example/HCaptcha_Tests/Core/HCaptcha__Tests.swift index bb60a62e..3e664de6 100644 --- a/Example/HCaptcha_Tests/Core/HCaptcha__Tests.swift +++ b/Example/HCaptcha_Tests/Core/HCaptcha__Tests.swift @@ -9,6 +9,7 @@ import AppSwizzle @testable import HCaptcha import RxSwift +import WebKit import XCTest @@ -20,6 +21,19 @@ class HCaptcha__Tests: XCTestCase { } } + override func setUp() { + super.setUp() + DispatchQueue.resetOnceTokens() + HCaptchaWebViewManager.clearWebViewData() + } + + override func tearDown() { + UIApplication.shared.keyWindow?.subviews + .filter { $0 is WKWebView } + .forEach { $0.removeFromSuperview() } + super.tearDown() + } + func test__valid_js_customTheme() { let customTheme = """ { diff --git a/Example/HCaptcha_Tests/HCaptcha_Tests.xctestplan b/Example/HCaptcha_Tests/HCaptcha_Tests.xctestplan index c397dc2e..61f2dab5 100644 --- a/Example/HCaptcha_Tests/HCaptcha_Tests.xctestplan +++ b/Example/HCaptcha_Tests/HCaptcha_Tests.xctestplan @@ -15,8 +15,6 @@ { "parallelizable" : false, "skippedTests" : [ - "HCaptchaWebViewManager__HTML__Tests", - "HCaptcha_Rx__Tests", "HCaptcha__Tests\/test__userJourney_enabled_without_impl_throws()" ], "target" : { diff --git a/Example/HCaptcha_Tests/Helpers/DispatchQueue+Helpers.swift b/Example/HCaptcha_Tests/Helpers/DispatchQueue+Helpers.swift new file mode 100644 index 00000000..dbfd9be5 --- /dev/null +++ b/Example/HCaptcha_Tests/Helpers/DispatchQueue+Helpers.swift @@ -0,0 +1,47 @@ +// +// DispatchQueue+Helpers.swift +// HCaptcha_Tests +// +// Copyright © 2018 HCaptcha. All rights reserved. +// + +@testable import HCaptcha +import Foundation + +extension DispatchQueue { + private static var workItems = [AnyHashable: DispatchWorkItem]() + private static var lastDebounceCallTimes = [AnyHashable: DispatchTime]() + private static let nilContext = UUID() + + func throttle(deadline: DispatchTime, context: AnyHashable = nilContext, action: @escaping () -> Void) { + let worker = DispatchWorkItem { + defer { DispatchQueue.workItems.removeValue(forKey: context) } + action() + } + + asyncAfter(deadline: deadline, execute: worker) + + DispatchQueue.workItems[context]?.cancel() + DispatchQueue.workItems[context] = worker + } + + func debounce(interval: Double, context: AnyHashable = nilContext, action: @escaping () -> Void) { + let now = DispatchTime.now() + if let last = DispatchQueue.lastDebounceCallTimes[context], last + interval > now { + return + } + + DispatchQueue.lastDebounceCallTimes[context] = now + interval + async(execute: action) + + throttle(deadline: now + interval) { + DispatchQueue.lastDebounceCallTimes.removeValue(forKey: context) + } + } + + static func resetOnceTokens() { + defer { objc_sync_exit(self) } + objc_sync_enter(self) + onceTokenStorage.removeAll() + } +} diff --git a/Example/HCaptcha_Tests/Helpers/HCaptcha+Helpers.swift b/Example/HCaptcha_Tests/Helpers/HCaptcha+Helpers.swift new file mode 100644 index 00000000..ec16b36f --- /dev/null +++ b/Example/HCaptcha_Tests/Helpers/HCaptcha+Helpers.swift @@ -0,0 +1,16 @@ +// +// HCaptcha+Helpers.swift +// HCaptcha_Tests +// +// Copyright © 2026 HCaptcha. All rights reserved. +// + +import Foundation +@testable import HCaptcha + +extension HCaptcha { + /// Test-only convenience init that defaults `userJourney` to false. + convenience init(manager: HCaptchaWebViewManager) { + self.init(manager: manager, userJourney: false) + } +} diff --git a/Example/HCaptcha_Tests/Helpers/HCaptchaWebViewManager+Helpers.swift b/Example/HCaptcha_Tests/Helpers/HCaptchaWebViewManager+Helpers.swift index 5136befa..d07f7594 100644 --- a/Example/HCaptcha_Tests/Helpers/HCaptchaWebViewManager+Helpers.swift +++ b/Example/HCaptcha_Tests/Helpers/HCaptchaWebViewManager+Helpers.swift @@ -17,12 +17,19 @@ extension HCaptchaWebViewManager { .flatMap { try? String(contentsOfFile: $0) } }() + enum MockOnLoad: String { + case didLoad + case networkError + case networkOnce + } + convenience init( messageBody: String = "undefined", apiKey: String? = nil, passiveApiKey: Bool = false, endpoint: URL? = nil, - shouldFail: Bool = false, // will fail with retriable sessionTimeout + onLoad: MockOnLoad = .didLoad, + shouldFail: Bool = false, size: HCaptchaSize = .invisible, rqdata: String? = nil, theme: String = "light", @@ -30,10 +37,12 @@ extension HCaptchaWebViewManager { userJourney: Bool = false, urlOpener: HCaptchaURLOpener = HCapchaAppURLOpener() ) { + let onExecute = shouldFail ? "sessionTimeout" : "normal" let html = String(format: HCaptchaWebViewManager.unformattedHTML, arguments: [ "message": messageBody, - "shouldFail": shouldFail.description + "onLoad": onLoad.rawValue, + "onExecute": onExecute ]) self.init( @@ -98,4 +107,23 @@ extension HCaptchaWebViewManager { validate(on: view) } + + /// Removes all session/local storage from the default data store so + /// tests start with a clean `WKWebView` environment. + static func clearWebViewData() { + let types: Set = [ + WKWebsiteDataTypeDiskCache, + WKWebsiteDataTypeMemoryCache, + WKWebsiteDataTypeSessionStorage, + WKWebsiteDataTypeLocalStorage + ] + var done = false + WKWebsiteDataStore.default().removeData( + ofTypes: types, + modifiedSince: .distantPast + ) { done = true } + while !done { + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.01)) + } + } } diff --git a/Example/HCaptcha_Tests/Helpers/TestTimeouts.swift b/Example/HCaptcha_Tests/Helpers/TestTimeouts.swift index d60a73f9..97f0457d 100644 --- a/Example/HCaptcha_Tests/Helpers/TestTimeouts.swift +++ b/Example/HCaptcha_Tests/Helpers/TestTimeouts.swift @@ -5,7 +5,7 @@ enum TestTimeouts { ProcessInfo.processInfo.environment["CI"] != nil }() - static let standard: TimeInterval = isCI ? 60 : 10 - static let long: TimeInterval = isCI ? 120 : 30 - static let short: TimeInterval = isCI ? 5 : 1 + static let standard: TimeInterval = isCI ? 30 : 5 + static let long: TimeInterval = isCI ? 60 : 15 + static let short: TimeInterval = isCI ? 10 : 2 } diff --git a/Example/HCaptcha_Tests/Journeylitics/HCaptchaWebViewManager__Journey__Tests.swift b/Example/HCaptcha_Tests/Journeylitics/HCaptchaWebViewManager__Journey__Tests.swift index c59ed1be..1382523b 100644 --- a/Example/HCaptcha_Tests/Journeylitics/HCaptchaWebViewManager__Journey__Tests.swift +++ b/Example/HCaptcha_Tests/Journeylitics/HCaptchaWebViewManager__Journey__Tests.swift @@ -6,9 +6,23 @@ // @testable import HCaptcha +import WebKit import XCTest class HCaptcha__Journey__Tests: XCTestCase { + override func setUp() { + super.setUp() + DispatchQueue.resetOnceTokens() + HCaptchaWebViewManager.clearWebViewData() + } + + override func tearDown() { + UIApplication.shared.keyWindow?.subviews + .filter { $0 is WKWebView } + .forEach { $0.removeFromSuperview() } + super.tearDown() + } + func test__setData_called_injects_journeys() { let exp = expectation(description: "journeys setData -> token posted") let manager = HCaptchaWebViewManager(messageBody: "{action: \"setData\", token: \"journeys:setData\"}") diff --git a/Example/HCaptcha_Tests/RxSwift/HCaptcha+Rx__Tests.swift b/Example/HCaptcha_Tests/RxSwift/HCaptcha+Rx__Tests.swift index e38c17a4..c7963a6c 100644 --- a/Example/HCaptcha_Tests/RxSwift/HCaptcha+Rx__Tests.swift +++ b/Example/HCaptcha_Tests/RxSwift/HCaptcha+Rx__Tests.swift @@ -11,6 +11,7 @@ import RxBlocking import RxCocoa import RxSwift +import WebKit import XCTest // these test doesn't work in parallel mode for some reason @@ -22,11 +23,21 @@ class HCaptcha_Rx__Tests: XCTestCase { override func setUp() { super.setUp() + DispatchQueue.resetOnceTokens() + HCaptchaWebViewManager.clearWebViewData() presenterView = UIApplication.shared.keyWindow! apiKey = String(arc4random()) } override func tearDown() { + presenterView?.subviews + .filter { $0 is WKWebView } + .forEach { $0.removeFromSuperview() } + + UIApplication.shared.keyWindow?.subviews + .filter { $0 is WKWebView } + .forEach { $0.removeFromSuperview() } + presenterView = nil apiKey = nil @@ -212,7 +223,6 @@ class HCaptcha_Rx__Tests: XCTestCase { var expCount = 0 let exp = expectation(description: "should call configureWebView") - // Validate let hcaptcha = HCaptcha( manager: HCaptchaWebViewManager(messageBody: "{token: key}", apiKey: apiKey, shouldFail: true) ) @@ -224,22 +234,18 @@ class HCaptcha_Rx__Tests: XCTestCase { } } - do { - // Error - _ = try hcaptcha.rx.validate(on: presenterView, resetOnError: false) - .toBlocking() - .single() + let errorExp = expectation(description: "first validate errors") + hcaptcha.validate(on: presenterView, resetOnError: false) { result in + XCTAssertEqual(result.error, .sessionTimeout) + errorExp.fulfill() } - catch let error { - XCTAssertEqual(error as? HCaptchaError, .sessionTimeout) - // Resets after failure - _ = Observable.just(()) - .bind(to: hcaptcha.rx.reset) - } + wait(for: [errorExp], timeout: TestTimeouts.standard) + + _ = Observable.just(()) + .bind(to: hcaptcha.rx.reset) do { - // Resets and tries again let result = try hcaptcha.rx.validate(on: presenterView, resetOnError: false) .toBlocking() .single() diff --git a/Example/HCaptcha_Tests/mock.html b/Example/HCaptcha_Tests/mock.html index 334cfc65..e3466aef 100644 --- a/Example/HCaptcha_Tests/mock.html +++ b/Example/HCaptcha_Tests/mock.html @@ -2,7 +2,6 @@ diff --git a/HCaptcha-Carthage.xcodeproj/project.pbxproj b/HCaptcha-Carthage.xcodeproj/project.pbxproj index 8f52669f..edbfcb68 100644 --- a/HCaptcha-Carthage.xcodeproj/project.pbxproj +++ b/HCaptcha-Carthage.xcodeproj/project.pbxproj @@ -42,7 +42,7 @@ E6DB9EA827B15954008F0327 /* HCaptchaDebugInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DB9EA727B15954008F0327 /* HCaptchaDebugInfo.swift */; }; F206BAB51F8D3DE900A25807 /* HCaptcha-Carthage.h in Headers */ = {isa = PBXBuildFile; fileRef = F206BAB31F8D3DE900A25807 /* HCaptcha-Carthage.h */; settings = {ATTRIBUTES = (Public, ); }; }; F206BB1D1F8D4DBC00A25807 /* HCaptcha_RxSwift.h in Headers */ = {isa = PBXBuildFile; fileRef = F206BB1B1F8D4DBC00A25807 /* HCaptcha_RxSwift.h */; settings = {ATTRIBUTES = (Public, ); }; }; - F231B39A1FEC51C800F82943 /* DispatchQueue+Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F231B3991FEC51C800F82943 /* DispatchQueue+Throttle.swift */; }; + F231B39A1FEC51C800F82943 /* DispatchQueue+Once.swift in Sources */ = {isa = PBXBuildFile; fileRef = F231B3991FEC51C800F82943 /* DispatchQueue+Once.swift */; }; F24EA1E11F9683FB001DEC17 /* HCaptcha+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = F24EA1DD1F9683F5001DEC17 /* HCaptcha+Rx.swift */; }; F24EA1E21F968403001DEC17 /* HCaptchaDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F24EA1D81F9683F5001DEC17 /* HCaptchaDecoder.swift */; }; F24EA1E31F968403001DEC17 /* HCaptchaWebViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F24EA1D91F9683F5001DEC17 /* HCaptchaWebViewManager.swift */; }; @@ -118,7 +118,7 @@ F206BB191F8D4DBC00A25807 /* HCaptcha_RxSwift.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = HCaptcha_RxSwift.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F206BB1B1F8D4DBC00A25807 /* HCaptcha_RxSwift.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HCaptcha_RxSwift.h; sourceTree = ""; }; F206BB1C1F8D4DBC00A25807 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - F231B3991FEC51C800F82943 /* DispatchQueue+Throttle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Throttle.swift"; sourceTree = ""; }; + F231B3991FEC51C800F82943 /* DispatchQueue+Once.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Once.swift"; sourceTree = ""; }; F24EA1D81F9683F5001DEC17 /* HCaptchaDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HCaptchaDecoder.swift; sourceTree = ""; }; F24EA1D91F9683F5001DEC17 /* HCaptchaWebViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HCaptchaWebViewManager.swift; sourceTree = ""; }; F24EA1DA1F9683F5001DEC17 /* String+Dict.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Dict.swift"; sourceTree = ""; }; @@ -303,7 +303,7 @@ F24EA1DA1F9683F5001DEC17 /* String+Dict.swift */, F24EA1DB1F9683F5001DEC17 /* HCaptchaError.swift */, F2AE8613204F3B41002E28D7 /* HCaptchaResult.swift */, - F231B3991FEC51C800F82943 /* DispatchQueue+Throttle.swift */, + F231B3991FEC51C800F82943 /* DispatchQueue+Once.swift */, F24EA1DE1F9683F5001DEC17 /* HCaptcha.swift */, E683772029053E560021BFD7 /* HCaptchaURLOpener.swift */, E6BF595B288AE915007CE1CE /* HCaptchaHtml.swift */, @@ -519,7 +519,7 @@ F24EA1E31F968403001DEC17 /* HCaptchaWebViewManager.swift in Sources */, 43DC08142E971586003839A5 /* HCaptchaVerifyParams.swift in Sources */, F24EA1E41F968403001DEC17 /* String+Dict.swift in Sources */, - F231B39A1FEC51C800F82943 /* DispatchQueue+Throttle.swift in Sources */, + F231B39A1FEC51C800F82943 /* DispatchQueue+Once.swift in Sources */, 43DC084C2EA819DB003839A5 /* HCaptchaJourneys.swift in Sources */, E6DB9EA827B15954008F0327 /* HCaptchaDebugInfo.swift in Sources */, 43C3B8FC2EBA07C900705F63 /* HCaptchaWebViewManager+Private.swift in Sources */, diff --git a/HCaptcha/Assets/hcaptcha.html b/HCaptcha/Assets/hcaptcha.html index afae2f3e..bf1ae2fb 100644 --- a/HCaptcha/Assets/hcaptcha.html +++ b/HCaptcha/Assets/hcaptcha.html @@ -80,8 +80,18 @@ } }; + var checkReady = function(caller) { + if (typeof hcaptcha === 'undefined') { + console.log(caller + " failed, resources not loaded"); + post({ error: 7 }); + return false; + } + return true; + }; + var execute = function(verifyParams) { console.log("challenge executing"); + if (!checkReady("execute")) return; try { // Apply deprecated config rqdata as a fallback when runtime verify params are absent. @@ -104,6 +114,8 @@ var reset = function() { console.log("challenge resetting"); + if (!checkReady("reset")) return; + hcaptcha.reset(); post({ action: "didLoad" }); }; diff --git a/HCaptcha/Classes/DispatchQueue+Once.swift b/HCaptcha/Classes/DispatchQueue+Once.swift new file mode 100644 index 00000000..86742a5b --- /dev/null +++ b/HCaptcha/Classes/DispatchQueue+Once.swift @@ -0,0 +1,31 @@ +// +// DispatchQueue+Once.swift +// HCaptcha +// +// Created by Flávio Caetano on 21/12/17. +// Copyright © 2018 HCaptcha. All rights reserved. +// + +import Foundation + +extension DispatchQueue { + /// Dispatched actions' token storage — `internal` so tests can reset it between runs. + static var onceTokenStorage = Set() + + /** + - parameters: + - token: The control token for each dispatched action + - action: The closure to be executed + + Dispatch the action only once for each given token + */ + static func once(token: AnyHashable, action: () -> Void) { + guard !onceTokenStorage.contains(token) else { return } + + defer { objc_sync_exit(self) } + objc_sync_enter(self) + + onceTokenStorage.insert(token) + action() + } +} diff --git a/HCaptcha/Classes/DispatchQueue+Throttle.swift b/HCaptcha/Classes/DispatchQueue+Throttle.swift deleted file mode 100644 index f756200b..00000000 --- a/HCaptcha/Classes/DispatchQueue+Throttle.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// DispatchQueue+Throttle.swift -// HCaptcha -// -// Created by Flávio Caetano on 21/12/17. -// Copyright © 2018 HCaptcha. All rights reserved. -// - -import Foundation - -/// Adds throttling to dispatch queues -extension DispatchQueue { - /// Stores a throttle DispatchWorkItem instance for a given context - private static var workItems = [AnyHashable: DispatchWorkItem]() - - /// Stores the last call times for a given context - private static var lastDebounceCallTimes = [AnyHashable: DispatchTime]() - - /// Dispatched actions' token storage - private static var onceTokenStorage = Set() - - /// An object representing a context if none is given - private static let nilContext = UUID() - - /** - - parameters: - - deadline: The timespan to delay a closure execution - - context: The context in which the throttle should be executed - - action: The closure to be executed - - Delays a closure execution and ensures no other executions are made during deadline for that context - */ - func throttle(deadline: DispatchTime, context: AnyHashable = nilContext, action: @escaping () -> Void) { - let worker = DispatchWorkItem { - defer { DispatchQueue.workItems.removeValue(forKey: context) } - action() - } - - asyncAfter(deadline: deadline, execute: worker) - - DispatchQueue.workItems[context]?.cancel() - DispatchQueue.workItems[context] = worker - } - - /** - - parameters: - - interval: The interval in which new calls will be ignored - - context: The context in which the debounce should be executed - - action: The closure to be executed - - Executes a closure and ensures no other executions will be made during the interval. - */ - func debounce(interval: Double, context: AnyHashable = nilContext, action: @escaping () -> Void) { - let now = DispatchTime.now() - if let last = DispatchQueue.lastDebounceCallTimes[context], last + interval > now { - return - } - - DispatchQueue.lastDebounceCallTimes[context] = now + interval - async(execute: action) - - // Cleanup & release context - throttle(deadline: now + interval) { - DispatchQueue.lastDebounceCallTimes.removeValue(forKey: context) - } - } - - /** - - parameters: - - token: The control token for each dispatched action - - action: The closure to be executed - - Dispatch the action only once for each given token - */ - static func once(token: AnyHashable, action: () -> Void) { - guard !onceTokenStorage.contains(token) else { return } - - defer { objc_sync_exit(self) } - objc_sync_enter(self) - - onceTokenStorage.insert(token) - action() - } -} diff --git a/HCaptcha/Classes/HCaptchaHtml.swift b/HCaptcha/Classes/HCaptchaHtml.swift index e8da1cd0..36b640e5 100644 --- a/HCaptcha/Classes/HCaptchaHtml.swift +++ b/HCaptcha/Classes/HCaptchaHtml.swift @@ -91,8 +91,18 @@ struct HCaptchaHtml { } }; + var checkReady = function(caller) { + if (typeof hcaptcha === 'undefined') { + console.log(caller + " failed, resources not loaded"); + post({ error: 7 }); + return false; + } + return true; + }; + var execute = function(verifyParams) { console.log("challenge executing"); + if (!checkReady("execute")) return; try { // Apply deprecated config rqdata as a fallback when runtime verify params are absent. @@ -115,6 +125,8 @@ struct HCaptchaHtml { var reset = function() { console.log("challenge resetting"); + if (!checkReady("reset")) return; + hcaptcha.reset(); post({ action: "didLoad" }); }; diff --git a/HCaptcha/Classes/HCaptchaWebViewManager+Private.swift b/HCaptcha/Classes/HCaptchaWebViewManager+Private.swift index 3ddee622..8bb60ca2 100644 --- a/HCaptcha/Classes/HCaptchaWebViewManager+Private.swift +++ b/HCaptcha/Classes/HCaptchaWebViewManager+Private.swift @@ -29,6 +29,18 @@ extension HCaptchaWebViewManager { return conf } + func startLoadingTimer() { + cancelLoadingTimer() + loadingTimer = Timer.scheduledTimer(withTimeInterval: self.loadingTimeout, repeats: false) { [weak self] _ in + self?.handle(error: .htmlLoadError) + } + } + + func cancelLoadingTimer() { + loadingTimer?.invalidate() + loadingTimer = nil + } + /** - parameter result: A `HCaptchaDecoder.Result` with the decoded message. @@ -41,7 +53,7 @@ extension HCaptchaWebViewManager { case .token(let token): handleToken(token) case .error(let error): handleDecoderError(error) case .showHCaptcha: webView.isHidden = false - case .didLoad: didLoad() + case .didLoad: onDidLoad() case .onOpen: onEvent?(.open, nil) case .onExpired: onEvent?(.expired, nil) case .onChallengeExpired: onEvent?(.challengeExpired, nil) @@ -67,34 +79,15 @@ extension HCaptchaWebViewManager { onEvent?(.error, error) } - private func handle(error: HCaptchaError) { - loadingTimer?.invalidate() - loadingTimer = nil - if error == .sessionTimeout { - if shouldResetOnError, let view = webView.superview { - reset() - validate(on: view) - } else { - complete(HCaptchaResult(self, error: error)) - } - } else { - if completion != nil { - complete(HCaptchaResult(self, error: error)) - } else { - lastError = error - } - } - } - - private func didLoad() { - Log.debug("WebViewManager.didLoad") - if completion != nil { - executeJS(command: .execute(verifyParams), didLoad: true) - } - didFinishLoading = true - loadingTimer?.invalidate() - loadingTimer = nil + private func onDidLoad() { + Log.debug("WebViewManager.onDidLoad") + cancelLoadingTimer() + let hasPendingExecution = completion != nil + loadingState = .loaded self.doConfigureWebView() + if hasPendingExecution { + executeJS(command: .execute(verifyParams)) + } } /** @@ -143,6 +136,7 @@ extension HCaptchaWebViewManager { */ func setupWebview(on window: UIWindow, html: String, url: URL) { Log.debug("WebViewManager.setupWebview") + loadingState = .loading if webView.superview == nil { window.addSubview(webView) } @@ -151,11 +145,7 @@ extension HCaptchaWebViewManager { webView.navigationDelegate = self webView.uiDelegate = self } - loadingTimer?.invalidate() - loadingTimer = Timer.scheduledTimer(withTimeInterval: self.loadingTimeout, repeats: false, block: { _ in - self.handle(error: .htmlLoadError) - self.loadingTimer = nil - }) + startLoadingTimer() if let observer = observer { NotificationCenter.default.removeObserver(observer) @@ -163,49 +153,32 @@ extension HCaptchaWebViewManager { } /** - - parameters: - - command: The JavaScript command to be executed - - didLoad: True if didLoad event already occured - - journeyEvents: JSON string of journey events to pass to JavaScript + - parameter command: The JavaScript command to be executed Executes the JS command that loads the HCaptcha challenge. This method has no effect if the webview hasn't finished loading. */ - func executeJS(command: JSCommand, didLoad: Bool = false) { + func executeJS(command: JSCommand) { Log.debug("WebViewManager.executeJS: \(command)") - guard didLoad else { - if let error = lastError { - loadingTimer?.invalidate() - loadingTimer = nil - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - Log.debug("WebViewManager complete with pendingError: \(error)") - - self.lastError = nil - self.complete(HCaptchaResult(self, error: error)) - } - if error == .networkError { - Log.debug("WebViewManager reloads html after \(error) error") - self.webView.loadHTMLString(formattedHTML, baseURL: baseURL) + if loadingState.isLoaded { + webView.evaluateJavaScript(command.rawValue) { [weak self] _, error in + if let error = error { + self?.decoder.send(error: .unexpected(error)) } } - return - } - - // Execute the JavaScript command - let jsCommand = command.rawValue - - webView.evaluateJavaScript(jsCommand) { [weak self] _, error in - if let error = error { - self?.decoder.send(error: .unexpected(error)) + } else if let error = loadingState.error { + cancelLoadingTimer() + if error == .networkError { + Log.debug("WebViewManager reloads html after networkError") + loadingState = .loading + webView.loadHTMLString(formattedHTML, baseURL: baseURL) + startLoadingTimer() + } else { + complete(HCaptchaResult(self, error: error)) } } } - func executeJS(command: JSCommand) { - executeJS(command: command, didLoad: self.didFinishLoading) - } - func complete(_ result: HCaptchaResult) { let completion = self.completion self.completion = nil diff --git a/HCaptcha/Classes/HCaptchaWebViewManager+WKNavigationDelegate.swift b/HCaptcha/Classes/HCaptchaWebViewManager+WKNavigationDelegate.swift index a3cb7bb6..d7621950 100644 --- a/HCaptcha/Classes/HCaptchaWebViewManager+WKNavigationDelegate.swift +++ b/HCaptcha/Classes/HCaptchaWebViewManager+WKNavigationDelegate.swift @@ -30,16 +30,38 @@ extension HCaptchaWebViewManager: WKNavigationDelegate, WKUIDelegate { /// Tells the delegate that an error occurred during navigation. func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { Log.debug("WebViewManager.webViewDidFail with \(error)") - complete(HCaptchaResult(self, error: .unexpected(error))) + handle(error: Self.classify(navigationError: error)) } /// Tells the delegate that an error occurred during the early navigation process. func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { Log.debug("WebViewManager.webViewDidFailProvisionalNavigation with \(error)") - complete(HCaptchaResult(self, error: .unexpected(error))) + handle(error: Self.classify(navigationError: error)) } - /// Tells the delegate that the web view’s content process was terminated. + private static func classify(navigationError error: Error) -> HCaptchaError { + let nsError = error as NSError + guard nsError.domain == NSURLErrorDomain else { + return .unexpected(error) + } + switch nsError.code { + case NSURLErrorNotConnectedToInternet, + NSURLErrorNetworkConnectionLost, + NSURLErrorDNSLookupFailed, + NSURLErrorCannotFindHost, + NSURLErrorCannotConnectToHost, + NSURLErrorTimedOut, + NSURLErrorSecureConnectionFailed, + NSURLErrorInternationalRoamingOff, + NSURLErrorCallIsActive, + NSURLErrorDataNotAllowed: + return .networkError + default: + return .unexpected(error) + } + } + + /// Tells the delegate that the web view's content process was terminated. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { Log.debug("WebViewManager.webViewWebContentProcessDidTerminate") let kHCaptchaErrorWebViewProcessDidTerminate = -1 @@ -49,7 +71,7 @@ extension HCaptchaWebViewManager: WKNavigationDelegate, WKUIDelegate { userInfo: [ NSLocalizedDescriptionKey: "WebView web content process did terminate", NSLocalizedRecoverySuggestionErrorKey: "Call HCaptcha.reset()"]) - didFinishLoading = false - complete(HCaptchaResult(self, error: .unexpected(error))) + loadingState = .idle + handle(error: .unexpected(error)) } } diff --git a/HCaptcha/Classes/HCaptchaWebViewManager.swift b/HCaptcha/Classes/HCaptchaWebViewManager.swift index bfef5d8f..cfae710f 100644 --- a/HCaptcha/Classes/HCaptchaWebViewManager.swift +++ b/HCaptcha/Classes/HCaptchaWebViewManager.swift @@ -9,13 +9,27 @@ import WebKit /** Handles comunications with the webview containing the HCaptcha challenge. */ internal class HCaptchaWebViewManager: NSObject { - typealias Log = HCaptchaLogger + enum LoadingState { + case idle + case loading + case loaded + case failed(HCaptchaError) + case stopped + + var isLoaded: Bool { + if case .loaded = self { return true } + return false + } - fileprivate struct Constants { - static let BotUserAgent = "bot/2.1" + var error: HCaptchaError? { + if case .failed(let error) = self { return error } + return nil + } } - fileprivate let webViewInitSize = CGSize(width: 1, height: 1) + typealias Log = HCaptchaLogger + + private let webViewInitSize = CGSize(width: 1, height: 1) /// True if validation token was dematerialized var resultHandled: Bool = false @@ -29,7 +43,7 @@ internal class HCaptchaWebViewManager: NSObject { /// Notifies the JS bundle has finished loading var onDidFinishLoading: (() -> Void)? { didSet { - if didFinishLoading { + if loadingState.isLoaded { onDidFinishLoading?() } } @@ -50,10 +64,10 @@ internal class HCaptchaWebViewManager: NSObject { /// The JS message recoder var decoder: HCaptchaDecoder! - /// Indicates if the script has already been loaded by the `webView` - var didFinishLoading = false { + /// Tracks the WebView HTML/JS loading lifecycle + internal var loadingState: LoadingState = .idle { didSet { - if didFinishLoading { + if loadingState.isLoaded { onDidFinishLoading?() } } @@ -73,18 +87,12 @@ internal class HCaptchaWebViewManager: NSObject { /// Passive apiKey var passiveApiKey: Bool - /// Keep error If it happens before validate call - var lastError: HCaptchaError? - /// Timeout to throw `.htmlLoadError` if no `didLoad` called let loadingTimeout: TimeInterval /// Responsible for external link handling let urlOpener: HCaptchaURLOpener - /// Stop async webView configuration - private var stopInitWebViewConfiguration = false - /// The webview that executes JS code lazy var webView: WKWebView = { let debug = Log.minLevel == .debug @@ -136,7 +144,7 @@ internal class HCaptchaWebViewManager: NSObject { Log.debug("WebViewManager.init formattedHTML built") DispatchQueue.main.async { [weak self] in guard let self = self else { return } - guard !self.stopInitWebViewConfiguration else { return } + guard case .idle = self.loadingState else { return } self.setupWebview(html: self.formattedHTML, url: self.baseURL) } @@ -159,7 +167,7 @@ internal class HCaptchaWebViewManager: NSObject { } view.addSubview(webView) - if self.didFinishLoading && (webView.bounds.size == CGSize.zero || webView.bounds.size == webViewInitSize) { + if loadingState.isLoaded && (webView.bounds.size == CGSize.zero || webView.bounds.size == webViewInitSize) { self.doConfigureWebView() } } @@ -170,12 +178,11 @@ internal class HCaptchaWebViewManager: NSObject { /// Stops the execution of the webview func stop() { Log.debug("WebViewManager.stop") - stopInitWebViewConfiguration = true + loadingState = .stopped completion = nil webView.stopLoading() resultHandled = true - loadingTimer?.invalidate() - loadingTimer = nil + cancelLoadingTimer() } /** @@ -186,13 +193,34 @@ internal class HCaptchaWebViewManager: NSObject { func reset() { Log.debug("WebViewManager.reset") configureWebViewDispatchToken = UUID() - stopInitWebViewConfiguration = false resultHandled = false - if didFinishLoading { + if loadingState.isLoaded { executeJS(command: .reset) - didFinishLoading = false - } else if let formattedHTML = self.formattedHTML { - setupWebview(html: formattedHTML, url: baseURL) + loadingState = .idle + } else { + loadingState = .idle + if let formattedHTML = self.formattedHTML { + setupWebview(html: formattedHTML, url: baseURL) + } + } + } + + func handle(error: HCaptchaError) { + cancelLoadingTimer() + if error == .sessionTimeout { + if shouldResetOnError, let view = webView.superview { + reset() + validate(on: view) + } else { + complete(HCaptchaResult(self, error: error)) + } + } else { + if completion != nil { + complete(HCaptchaResult(self, error: error)) + } + if !loadingState.isLoaded { + loadingState = .failed(error) + } } } }