From 95f04ca1d9d23a49d97f973a71e788cae09578a9 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 12 Sep 2023 21:22:25 -0700 Subject: [PATCH 01/81] Replace Quick/Nimble with XCTest --- Package.resolved | 36 ---- Package.swift | 4 - Tests/ColdBootVisitSpec.swift | 121 ------------ Tests/ColdBootVisitTests.swift | 114 ++++++++++++ Tests/JavaScriptExpressionSpec.swift | 37 ---- Tests/JavaScriptExpressionTests.swift | 30 +++ Tests/JavaScriptVisitSpec.swift | 8 - Tests/JavaScriptVisitTests.swift | 3 + Tests/PathConfigurationLoaderSpec.swift | 110 ----------- Tests/PathConfigurationLoaderTests.swift | 77 ++++++++ Tests/PathConfigurationSpec.swift | 106 ----------- Tests/PathConfigurationTests.swift | 72 ++++++++ Tests/PathRuleSpec.swift | 44 ----- Tests/PathRuleTests.swift | 30 +++ Tests/ScriptMessageSpec.swift | 69 ------- Tests/ScriptMessageTests.swift | 51 ++++++ Tests/SessionSpec.swift | 223 ----------------------- Tests/SessionTests.swift | 199 ++++++++++++++++++++ Tests/Test.swift | 6 +- Tests/VisitOptionsSpec.swift | 56 ------ Tests/VisitOptionsTests.swift | 35 ++++ 21 files changed, 615 insertions(+), 816 deletions(-) delete mode 100644 Tests/ColdBootVisitSpec.swift create mode 100644 Tests/ColdBootVisitTests.swift delete mode 100644 Tests/JavaScriptExpressionSpec.swift create mode 100644 Tests/JavaScriptExpressionTests.swift delete mode 100644 Tests/JavaScriptVisitSpec.swift create mode 100644 Tests/JavaScriptVisitTests.swift delete mode 100644 Tests/PathConfigurationLoaderSpec.swift create mode 100644 Tests/PathConfigurationLoaderTests.swift delete mode 100644 Tests/PathConfigurationSpec.swift create mode 100644 Tests/PathConfigurationTests.swift delete mode 100644 Tests/PathRuleSpec.swift create mode 100644 Tests/PathRuleTests.swift delete mode 100644 Tests/ScriptMessageSpec.swift create mode 100644 Tests/ScriptMessageTests.swift delete mode 100644 Tests/SessionSpec.swift create mode 100644 Tests/SessionTests.swift delete mode 100644 Tests/VisitOptionsSpec.swift create mode 100644 Tests/VisitOptionsTests.swift diff --git a/Package.resolved b/Package.resolved index 41b1182..b47b31d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,32 +1,5 @@ { "pins" : [ - { - "identity" : "cwlcatchexception", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlCatchException.git", - "state" : { - "revision" : "3b123999de19bf04905bc1dfdb76f817b0f2cc00", - "version" : "2.1.2" - } - }, - { - "identity" : "cwlpreconditiontesting", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", - "state" : { - "revision" : "a23ded2c91df9156628a6996ab4f347526f17b6b", - "version" : "2.1.2" - } - }, - { - "identity" : "nimble", - "kind" : "remoteSourceControl", - "location" : "https://github.com/quick/nimble", - "state" : { - "revision" : "1f3bde57bde12f5e7b07909848c071e9b73d6edc", - "version" : "10.0.0" - } - }, { "identity" : "ohhttpstubs", "kind" : "remoteSourceControl", @@ -36,15 +9,6 @@ "version" : "9.1.0" } }, - { - "identity" : "quick", - "kind" : "remoteSourceControl", - "location" : "https://github.com/quick/quick", - "state" : { - "revision" : "f9d519828bb03dfc8125467d8f7b93131951124c", - "version" : "5.0.1" - } - }, { "identity" : "swifter", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index a05cf1d..921486e 100644 --- a/Package.swift +++ b/Package.swift @@ -14,8 +14,6 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/quick/quick", .upToNextMajor(from: "5.0.0")), - .package(url: "https://github.com/quick/nimble", .upToNextMajor(from: "10.0.0")), .package(url: "https://github.com/AliSoftware/OHHTTPStubs", .upToNextMajor(from: "9.0.0")), .package(url: "https://github.com/httpswift/swifter.git", .upToNextMajor(from: "1.5.0")) ], @@ -33,8 +31,6 @@ let package = Package( name: "TurboTests", dependencies: [ "Turbo", - .product(name: "Quick", package: "quick"), - .product(name: "Nimble", package: "nimble"), .product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs"), .product(name: "Swifter", package: "Swifter") ], diff --git a/Tests/ColdBootVisitSpec.swift b/Tests/ColdBootVisitSpec.swift deleted file mode 100644 index 96666a3..0000000 --- a/Tests/ColdBootVisitSpec.swift +++ /dev/null @@ -1,121 +0,0 @@ -import Quick -import Nimble -import WebKit -@testable import Turbo - -class ColdBootVisitSpec: QuickSpec { - override func spec() { - var webView: WKWebView! - var bridge: WebViewBridge! - var visit: ColdBootVisit! - var visitDelegate: TestVisitDelegate! - let url = URL(string: "http://localhost/")! - - beforeEach { - webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) - bridge = WebViewBridge(webView: webView) - visitDelegate = TestVisitDelegate() - visit = ColdBootVisit(visitable: TestVisitable(url: url), options: VisitOptions(), bridge: bridge) - visit.delegate = visitDelegate - } - - describe(".start()") { - beforeEach { - expect(visit.state) == .initialized - visit.start() - } - - it("transitions to a started state") { - expect(visit.state) == .started - } - - it("notifies the delegate the visit will start") { - expect(visitDelegate.didCall("visitWillStart(_:)")).toEventually(beTrue()) - } - - it("kicks off the web view load") { - expect(visit.navigation).toNot(beNil()) - } - - it("becomes the navigation delegate") { - expect(webView.navigationDelegate) === visit - } - - it("notifies the delegate the visit did start") { - visit.start() - expect(visitDelegate.didCall("visitDidStart(_:)")).toEventually(beTrue()) - } - - it("ignores the call if already started") { - visit.start() - expect(visitDelegate.methodsCalled.contains("visitDidStart(_:)")).toEventually(beTrue()) - - visitDelegate.methodsCalled.remove("visitDidStart(_:)") - visit.start() - expect(visitDelegate.didCall("visitDidStart(_:)")).toEventually(beFalse()) - } - } - } -} - -private class TestVisitDelegate { - var methodsCalled: Set = [] - - func didCall(_ method: String) -> Bool { - methodsCalled.contains(method) - } - - private func record(_ string: String = #function) { - methodsCalled.insert(string) - } -} - -extension TestVisitDelegate: VisitDelegate { - func visitDidInitializeWebView(_ visit: Visit) { - record() - } - - func visitWillStart(_ visit: Visit) { - record() - } - - func visitDidStart(_ visit: Visit) { - record() - } - - func visitDidComplete(_ visit: Visit) { - record() - } - - func visitDidFail(_ visit: Visit) { - record() - } - - func visitDidFinish(_ visit: Visit) { - record() - } - - func visitWillLoadResponse(_ visit: Visit) { - record() - } - - func visitDidRender(_ visit: Visit) { - record() - } - - func visitRequestDidStart(_ visit: Visit) { - record() - } - - func visit(_ visit: Visit, requestDidFailWithError error: Error) { - record() - } - - func visitRequestDidFinish(_ visit: Visit) { - record() - } - - func visit(_ visit: Visit, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - record() - } -} diff --git a/Tests/ColdBootVisitTests.swift b/Tests/ColdBootVisitTests.swift new file mode 100644 index 0000000..9eace24 --- /dev/null +++ b/Tests/ColdBootVisitTests.swift @@ -0,0 +1,114 @@ +@testable import Turbo +import WebKit +import XCTest + +class ColdBootVisitTests: XCTestCase { + private let webView = WKWebView() + private let visitDelegate = TestVisitDelegate() + private var visit: ColdBootVisit! + + override func setUp() { + let url = URL(string: "http://localhost/")! + let bridge = WebViewBridge(webView: webView) + + visit = ColdBootVisit(visitable: TestVisitable(url: url), options: VisitOptions(), bridge: bridge) + visit.delegate = visitDelegate + } + + func test_start_transitionsToStartState() { + XCTAssertEqual(visit.state, .initialized) + visit.start() + XCTAssertEqual(visit.state, .started) + } + + func test_start_notifiesTheDelegateTheVisitWillStart() { + visit.start() + XCTAssertTrue(visitDelegate.didCall("visitWillStart(_:)")) + } + + func test_start_kicksOffTheWebViewLoad() { + visit.start() + XCTAssertNotNil(visit.navigation) + } + + func test_visit_becomesTheNavigationDelegate() { + visit.start() + XCTAssertIdentical(webView.navigationDelegate, visit) + } + + func test_visit_notifiesTheDelegateTheVisitDidStart() { + visit.start() + XCTAssertTrue(visitDelegate.didCall("visitDidStart(_:)")) + } + + func test_visit_ignoresTheCallIfAlreadyStarted() { + visit.start() + XCTAssertTrue(visitDelegate.methodsCalled.contains("visitDidStart(_:)")) + + visitDelegate.methodsCalled.remove("visitDidStart(_:)") + visit.start() + XCTAssertFalse(visitDelegate.didCall("visitDidStart(_:)")) + } +} + +private class TestVisitDelegate { + var methodsCalled: Set = [] + + func didCall(_ method: String) -> Bool { + methodsCalled.contains(method) + } + + private func record(_ string: String = #function) { + methodsCalled.insert(string) + } +} + +extension TestVisitDelegate: VisitDelegate { + func visitDidInitializeWebView(_ visit: Visit) { + record() + } + + func visitWillStart(_ visit: Visit) { + record() + } + + func visitDidStart(_ visit: Visit) { + record() + } + + func visitDidComplete(_ visit: Visit) { + record() + } + + func visitDidFail(_ visit: Visit) { + record() + } + + func visitDidFinish(_ visit: Visit) { + record() + } + + func visitWillLoadResponse(_ visit: Visit) { + record() + } + + func visitDidRender(_ visit: Visit) { + record() + } + + func visitRequestDidStart(_ visit: Visit) { + record() + } + + func visit(_ visit: Visit, requestDidFailWithError error: Error) { + record() + } + + func visitRequestDidFinish(_ visit: Visit) { + record() + } + + func visit(_ visit: Visit, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + record() + } +} diff --git a/Tests/JavaScriptExpressionSpec.swift b/Tests/JavaScriptExpressionSpec.swift deleted file mode 100644 index dd42c39..0000000 --- a/Tests/JavaScriptExpressionSpec.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Quick -import Nimble -@testable import Turbo - -class JavaScriptExpressionSpec: QuickSpec { - override func spec() { - describe(".string") { - it("converts function and arguments into a valid expression") { - let expression = JavaScriptExpression(function: "console.log", arguments: []) - expect(expression.string) == "console.log()" - - let expression2 = JavaScriptExpression(function: "console.log", arguments: ["one", nil, 2]) - expect(expression2.string) == "console.log(\"one\",null,2)" - } - } - - describe(".wrapped") { - it("wraps expression in IIFE and try/catch") { - let expression = JavaScriptExpression(function: "console.log", arguments: []) - let expected = """ - (function(result) { - try { - result.value = console.log() - } catch (error) { - result.error = error.toString() - result.stack = error.stack - } - - return result - })({}) - """ - - expect(expression.wrappedString) == expected - } - } - } -} diff --git a/Tests/JavaScriptExpressionTests.swift b/Tests/JavaScriptExpressionTests.swift new file mode 100644 index 0000000..7cfbbca --- /dev/null +++ b/Tests/JavaScriptExpressionTests.swift @@ -0,0 +1,30 @@ +@testable import Turbo +import XCTest + +class JavaScriptExpressionTests: XCTestCase { + func test_string_convertsFunctionAndArgumentsIntoAValidExpression() { + let expression = JavaScriptExpression(function: "console.log", arguments: []) + XCTAssertEqual(expression.string, "console.log()") + + let expression2 = JavaScriptExpression(function: "console.log", arguments: ["one", nil, 2]) + XCTAssertEqual(expression2.string, "console.log(\"one\",null,2)") + } + + func test_wrapped_wrapsExpressionIn_IIFE_AndTryCatch() { + let expression = JavaScriptExpression(function: "console.log", arguments: []) + let expected = """ + (function(result) { + try { + result.value = console.log() + } catch (error) { + result.error = error.toString() + result.stack = error.stack + } + + return result + })({}) + """ + + XCTAssertEqual(expression.wrappedString, expected) + } +} diff --git a/Tests/JavaScriptVisitSpec.swift b/Tests/JavaScriptVisitSpec.swift deleted file mode 100644 index c25e64e..0000000 --- a/Tests/JavaScriptVisitSpec.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Quick -import Nimble - -class JavaScriptVisitSpec: QuickSpec { - override func spec() { - - } -} diff --git a/Tests/JavaScriptVisitTests.swift b/Tests/JavaScriptVisitTests.swift new file mode 100644 index 0000000..581f9e8 --- /dev/null +++ b/Tests/JavaScriptVisitTests.swift @@ -0,0 +1,3 @@ +import XCTest + +class JavaScriptVisitTests: XCTestCase {} diff --git a/Tests/PathConfigurationLoaderSpec.swift b/Tests/PathConfigurationLoaderSpec.swift deleted file mode 100644 index e0476a3..0000000 --- a/Tests/PathConfigurationLoaderSpec.swift +++ /dev/null @@ -1,110 +0,0 @@ -import XCTest -import Quick -import Nimble -import OHHTTPStubs -import OHHTTPStubsSwift -@testable import Turbo - -class PathConfigurationLoaderSpec: QuickSpec { - override func spec() { - let serverURL = URL(string: "http://turbo.test/configuration.json")! - let fileURL = Bundle.module.url(forResource: "test-configuration", withExtension: "json", subdirectory: "Fixtures")! - - describe("load") { - context("data") { - it("automatically loads from passed in data and calls the handler") { - let data = try! Data(contentsOf: fileURL) - let loader = PathConfigurationLoader(sources: [.data(data)]) - - var config: PathConfigurationDecoder? = nil - loader.load { conf in - config = conf - } - - expect(config).toEventuallyNot(beNil()) - expect(config!.rules.count) == 4 - } - } - - context("file") { - it("automatically loads from the local file and calls the handler") { - let loader = PathConfigurationLoader(sources: [.file(fileURL)]) - - var config: PathConfigurationDecoder? = nil - loader.load { conf in - config = conf - } - - expect(config).toNot(beNil()) - expect(config!.rules.count) == 4 - } - } - - context("server") { - var loader: PathConfigurationLoader! - - beforeEach { - loader = PathConfigurationLoader(sources: [.server(serverURL)]) - stub(condition: { _ in true }) { _ in - let json = ["rules": [["patterns": ["/new"], "properties": ["presentation": "test"]] as [String : Any]]] - return HTTPStubsResponse(jsonObject: json, statusCode: 200, headers: [:]) - } - - clearCache(loader.configurationCacheURL) - } - - it("automatically downloads the file and calls the handler") { - var config: PathConfigurationDecoder? = nil - loader.load { conf in - config = conf - } - - expect(config).toEventuallyNot(beNil()) - expect(config!.rules.count) == 1 - } - - it("caches the file") { - var handlerCalled = false - loader.load { rs in - handlerCalled = true - } - - expect(handlerCalled).toEventually(beTrue()) - expect(FileManager.default.fileExists(atPath: loader!.configurationCacheURL.path)) == true - } - } - - context("when file and remote") { - it("loads the file url and the remote url") { - let loader = PathConfigurationLoader(sources: [.file(fileURL), .server(serverURL)]) - clearCache(loader.configurationCacheURL) - - stub(condition: { _ in true }) { _ in - let json = ["rules": [["patterns": ["/new"], "properties": ["presentation": "test"]] as [String : Any]]] - return HTTPStubsResponse(jsonObject: json, statusCode: 200, headers: [:]) - } - - var handlerCalledTimes = 0 - - loader.load { config in - if handlerCalledTimes == 0 { - expect(config.rules.count) == 4 - } else { - expect(config.rules.count) == 1 - } - - handlerCalledTimes += 1 - } - - expect(handlerCalledTimes).toEventually(equal(2)) - } - } - } - } -} - -private func clearCache(_ url: URL) { - do { - try FileManager.default.removeItem(at: url) - } catch {} -} diff --git a/Tests/PathConfigurationLoaderTests.swift b/Tests/PathConfigurationLoaderTests.swift new file mode 100644 index 0000000..e04956d --- /dev/null +++ b/Tests/PathConfigurationLoaderTests.swift @@ -0,0 +1,77 @@ +import OHHTTPStubs +import OHHTTPStubsSwift +@testable import Turbo +import XCTest + +class PathConfigurationLoaderTests: XCTestCase { + private let serverURL = URL(string: "http://turbo.test/configuration.json")! + private let fileURL = Bundle.module.url(forResource: "test-configuration", withExtension: "json", subdirectory: "Fixtures")! + + func test_load_data_automaticallyLoadsFromPassedInDataAndCallsHandler() throws { + let data = try! Data(contentsOf: fileURL) + let loader = PathConfigurationLoader(sources: [.data(data)]) + + var loadedConfig: PathConfigurationDecoder? = nil + loader.load { loadedConfig = $0 } + + let config = try XCTUnwrap(loadedConfig) + XCTAssertEqual(config.rules.count, 4) + } + + func test_file_automaticallyLoadsFromTheLocalFileAndCallsTheHandler() throws { + let loader = PathConfigurationLoader(sources: [.file(fileURL)]) + + var loadedConfig: PathConfigurationDecoder? = nil + loader.load { loadedConfig = $0 } + + let config = try XCTUnwrap(loadedConfig) + XCTAssertEqual(config.rules.count, 4) + } + + func test_server_automaticallyDownloadsTheFileAndCallsTheHandler() throws { + let loader = PathConfigurationLoader(sources: [.server(serverURL)]) + let expectation = stubRequest(for: loader) + + var loadedConfig: PathConfigurationDecoder? = nil + loader.load { config in + loadedConfig = config + expectation.fulfill() + } + wait(for: [expectation]) + + let config = try XCTUnwrap(loadedConfig) + XCTAssertEqual(config.rules.count, 1) + } + + func test_server_cachesTheFile() { + let loader = PathConfigurationLoader(sources: [.server(serverURL)]) + let expectation = stubRequest(for: loader) + + var handlerCalled = false + loader.load { _ in + handlerCalled = true + expectation.fulfill() + } + wait(for: [expectation]) + + XCTAssertTrue(handlerCalled) + XCTAssertTrue(FileManager.default.fileExists(atPath: loader.configurationCacheURL.path)) + } + + private func stubRequest(for loader: PathConfigurationLoader) -> XCTestExpectation { + stub(condition: { _ in true }) { _ in + let json = ["rules": [["patterns": ["/new"], "properties": ["presentation": "test"]] as [String: Any]]] + return HTTPStubsResponse(jsonObject: json, statusCode: 200, headers: [:]) + } + + clearCache(loader.configurationCacheURL) + + return expectation(description: "Wait for configuration to load.") + } + + private func clearCache(_ url: URL) { + do { + try FileManager.default.removeItem(at: url) + } catch {} + } +} diff --git a/Tests/PathConfigurationSpec.swift b/Tests/PathConfigurationSpec.swift deleted file mode 100644 index eb6486f..0000000 --- a/Tests/PathConfigurationSpec.swift +++ /dev/null @@ -1,106 +0,0 @@ -import Quick -import Nimble -import Foundation -@testable import Turbo - -class PathConfigurationSpec: QuickSpec { - override func spec() { - let fileURL = Bundle.module.url(forResource: "test-configuration", withExtension: "json", subdirectory: "Fixtures")! - var configuration: PathConfiguration! - - beforeEach { - configuration = PathConfiguration(sources: [.file(fileURL)]) - expect(configuration.rules.count).toEventually(beGreaterThan(0)) - } - - describe("init") { - it("automatically loads the configuration from the specified location") { - expect(configuration.settings.count) == 2 - expect(configuration.rules.count) == 4 - } - } - - describe("settings") { - it("returns current settings") { - expect(configuration.settings) == [ - "some-feature-enabled": true, - "server": "beta" - ] - } - } - - describe("properties(for: path)") { - context("when path matches") { - it("returns properties") { - expect(configuration.properties(for: "/")) == [ - "page": "root" - ] - } - } - - context("when path matches multiple rules") { - it("merges properties") { - expect(configuration.properties(for: "/new")) == [ - "context": "modal", - "background_color": "black" - ] - - expect(configuration.properties(for: "/edit")) == [ - "context": "modal", - "background_color": "white" - ] - } - } - - context("when no match") { - it("returns empty properties") { - expect(configuration.properties(for: "/missing")) == [:] - } - } - } - - describe("subscript") { - it("is a convenience method for properties(for path)") { - expect(configuration.properties(for: "/new")) == configuration["/new"] - expect(configuration.properties(for: "/edit")) == configuration["/edit"] - expect(configuration.properties(for: "/")) == configuration["/"] - expect(configuration.properties(for: "/missing")) == configuration["/missing"] - } - } - } -} - -class PathConfigSpec: QuickSpec { - override func spec() { - describe("json") { - context("with valid json") { - it("decodes successfully") { - let fileURL = Bundle.module.url(forResource: "test-configuration", withExtension: "json", subdirectory: "Fixtures")! - - do { - let data = try Data(contentsOf: fileURL) - let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] - let config = try PathConfigurationDecoder(json: json) - - expect(config.settings.count) == 2 - expect(config.rules.count) == 4 - } catch { - fail("Error decoding from JSON: \(error)") - } - } - } - - context("with missing rules key") { - it("fails to decode") { - do { - _ = try PathConfigurationDecoder(json: [:]) - fail("Path config should not have decoded invalid json") - } catch { - expect(error).to(matchError(JSONDecodingError.invalidJSON)) - } - } - } - } - } -} - diff --git a/Tests/PathConfigurationTests.swift b/Tests/PathConfigurationTests.swift new file mode 100644 index 0000000..5a45e83 --- /dev/null +++ b/Tests/PathConfigurationTests.swift @@ -0,0 +1,72 @@ +@testable import Turbo +import XCTest + +class PathConfigurationTests: XCTestCase { + private let fileURL = Bundle.module.url(forResource: "test-configuration", withExtension: "json", subdirectory: "Fixtures")! + var configuration: PathConfiguration! + + override func setUp() { + configuration = PathConfiguration(sources: [.file(fileURL)]) + XCTAssertGreaterThan(configuration.rules.count, 0) + } + + func test_init_automaticallyLoadsTheConfigurationFromTheSpecifiedLocation() { + XCTAssertEqual(configuration.settings.count, 2) + XCTAssertEqual(configuration.rules.count, 4) + } + + func test_settings_returnsCurrentSettings() { + XCTAssertEqual(configuration.settings, [ + "some-feature-enabled": true, + "server": "beta" + ]) + } + + func test_propertiesForPath_whenPathMatches_returnsProperties() { + XCTAssertEqual(configuration.properties(for: "/"), [ + "page": "root" + ]) + } + + func test_propertiesForPath_whenPathMatchesMultipleRules_mergesProperties() { + XCTAssertEqual(configuration.properties(for: "/new"), [ + "context": "modal", + "background_color": "black" + ]) + + XCTAssertEqual(configuration.properties(for: "/edit"), [ + "context": "modal", + "background_color": "white" + ]) + } + + func test_propertiesForPath_whenNoMatch_returnsEmptyProperties() { + XCTAssertEqual(configuration.properties(for: "/missing"), [:]) + } + + func test_subscript_isAConvenienceMethodForPropertiesForPath() { + XCTAssertEqual(configuration.properties(for: "/new"), configuration["/new"]) + XCTAssertEqual(configuration.properties(for: "/edit"), configuration["/edit"]) + XCTAssertEqual(configuration.properties(for: "/"), configuration["/"]) + XCTAssertEqual(configuration.properties(for: "/missing"), configuration["/missing"]) + } +} + +class PathConfigTests: XCTestCase { + func test_json_withValidJSON_decodesSuccessfully() throws { + let fileURL = Bundle.module.url(forResource: "test-configuration", withExtension: "json", subdirectory: "Fixtures")! + + let data = try Data(contentsOf: fileURL) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let config = try PathConfigurationDecoder(json: json) + + XCTAssertEqual(config.settings.count, 2) + XCTAssertEqual(config.rules.count, 4) + } + + func test_json_withMissingRulesKey_failsToDecode() throws { + XCTAssertThrowsError(try PathConfigurationDecoder(json: [:])) { error in + XCTAssertEqual(error as? JSONDecodingError, JSONDecodingError.invalidJSON) + } + } +} diff --git a/Tests/PathRuleSpec.swift b/Tests/PathRuleSpec.swift deleted file mode 100644 index 30482fc..0000000 --- a/Tests/PathRuleSpec.swift +++ /dev/null @@ -1,44 +0,0 @@ -import XCTest -import Quick -import Nimble -@testable import Turbo - -class PathRuleSpec: QuickSpec { - override func spec() { - describe("subscript") { - it("returns a String value for key") { - let rule = PathRule(patterns: ["^/new$"], properties: ["color": "blue", "modal": false]) - - expect(rule["color"]) == "blue" - expect(rule["modal"]).to(beNil()) - } - } - - describe(".match") { - context("when path matches single pattern") { - it("returns true") { - let rule = PathRule(patterns: ["^/new$"], properties: [:]) - - expect(rule.match(path: "/new")) == true - } - } - - context("when path matches any pattern in array") { - it("returns true") { - let rule = PathRule(patterns: ["^/new$", "^/edit"], properties: [:]) - - expect(rule.match(path: "/edit/1")) == true - } - } - - context("when path doesn't match any patterns") { - it("returns false") { - let rule = PathRule(patterns: ["^/new/bar"], properties: [:]) - - expect(rule.match(path: "/new")) == false - expect(rule.match(path: "foo")) == false - } - } - } - } -} diff --git a/Tests/PathRuleTests.swift b/Tests/PathRuleTests.swift new file mode 100644 index 0000000..c575224 --- /dev/null +++ b/Tests/PathRuleTests.swift @@ -0,0 +1,30 @@ +@testable import Turbo +import XCTest + +class PathRuleTests: XCTestCase { + func test_subscript_returnsAStringValueForKey() { + let rule = PathRule(patterns: ["^/new$"], properties: ["color": "blue", "modal": false]) + + XCTAssertEqual(rule["color"], "blue") + XCTAssertNil(rule["modal"]) + } + + func test_match_whenPathMatchesSinglePattern_returnsTrue() { + let rule = PathRule(patterns: ["^/new$"], properties: [:]) + + XCTAssertTrue(rule.match(path: "/new")) + } + + func test_match_whenPathMatchesAnyPatternInArray_returnsTrue() { + let rule = PathRule(patterns: ["^/new$", "^/edit"], properties: [:]) + + XCTAssertTrue(rule.match(path: "/edit/1")) + } + + func test_match_whenPathDoesntMatchAnyPatterns_returnsFalse() { + let rule = PathRule(patterns: ["^/new/bar"], properties: [:]) + + XCTAssertFalse(rule.match(path: "/new")) + XCTAssertFalse(rule.match(path: "foo")) + } +} diff --git a/Tests/ScriptMessageSpec.swift b/Tests/ScriptMessageSpec.swift deleted file mode 100644 index b8e1da0..0000000 --- a/Tests/ScriptMessageSpec.swift +++ /dev/null @@ -1,69 +0,0 @@ -import WebKit -import XCTest -import Quick -import Nimble -@testable import Turbo - -class ScriptMessageSpec: QuickSpec { - override func spec() { - describe(".parse") { - context("with valid data") { - it("returns message") { - let data = ["identifier": "123", "restorationIdentifier": "abc", "options": ["action": "advance"], "location": "http://turbo.test"] as [String : Any] - let script = FakeScriptMessage(body: ["name": "pageLoaded", "data": data] as [String : Any]) - - guard let message = ScriptMessage(message: script) else { - fail("Error parsing script message") - return - } - - expect(message.name) == .pageLoaded - expect(message.identifier) == "123" - expect(message.restorationIdentifier) == "abc" - expect(message.options!.action) == .advance - expect(message.location) == URL(string: "http://turbo.test")! - } - } - - context("with invalid body") { - it("returns nil") { - let script = FakeScriptMessage(body: "foo") - - let message = ScriptMessage(message: script) - expect(message).to(beNil()) - } - } - - context("with invalid name") { - it("returns nil") { - let script = FakeScriptMessage(body: ["name": "foobar"]) - - let message = ScriptMessage(message: script) - expect(message).to(beNil()) - } - } - - context("with missing data") { - it("returns nil") { - let script = FakeScriptMessage(body: ["name": "pageLoaded"]) - - let message = ScriptMessage(message: script) - expect(message).to(beNil()) - } - } - } - } -} - -// Can't instantiate a WKScriptMessage directly -private class FakeScriptMessage: WKScriptMessage { - override var body: Any { - return actualBody - } - - var actualBody: Any - - init(body: Any) { - self.actualBody = body - } -} diff --git a/Tests/ScriptMessageTests.swift b/Tests/ScriptMessageTests.swift new file mode 100644 index 0000000..3d44af3 --- /dev/null +++ b/Tests/ScriptMessageTests.swift @@ -0,0 +1,51 @@ +@testable import Turbo +import WebKit +import XCTest + +class ScriptMessageTests: XCTestCase { + func test_parse_withValidData_returnsMessage() throws { + let data = ["identifier": "123", "restorationIdentifier": "abc", "options": ["action": "advance"], "location": "http://turbo.test"] as [String: Any] + let script = FakeScriptMessage(body: ["name": "pageLoaded", "data": data] as [String: Any]) + + let message = try XCTUnwrap(ScriptMessage(message: script)) + XCTAssertEqual(message.name, .pageLoaded) + XCTAssertEqual(message.identifier, "123") + XCTAssertEqual(message.restorationIdentifier, "abc") + XCTAssertEqual(message.options!.action, .advance) + XCTAssertEqual(message.location, URL(string: "http://turbo.test")!) + } + + func test_parse_withInvalidBody_returnsNil() { + let script = FakeScriptMessage(body: "foo") + + let message = ScriptMessage(message: script) + XCTAssertNil(message) + } + + func test_parse_withInvalidName_returnsNil() { + let script = FakeScriptMessage(body: ["name": "foobar"]) + + let message = ScriptMessage(message: script) + XCTAssertNil(message) + } + + func test_parse_withMissingData_returnsNil() { + let script = FakeScriptMessage(body: ["name": "pageLoaded"]) + + let message = ScriptMessage(message: script) + XCTAssertNil(message) + } +} + +// Can't instantiate a WKScriptMessage directly +private class FakeScriptMessage: WKScriptMessage { + override var body: Any { + return actualBody + } + + var actualBody: Any + + init(body: Any) { + self.actualBody = body + } +} diff --git a/Tests/SessionSpec.swift b/Tests/SessionSpec.swift deleted file mode 100644 index 28f3f31..0000000 --- a/Tests/SessionSpec.swift +++ /dev/null @@ -1,223 +0,0 @@ -import WebKit -import XCTest -import Quick -import Nimble -import Swifter -@testable import Turbo - -private let timeout = DispatchTimeInterval.seconds(35) - -class SessionSpec: QuickSpec { - let server = HttpServer() - - override func spec() { - var session: Session! - var sessionDelegate: TestSessionDelegate! - - beforeSuite { - self.startServer() - } - - beforeEach { - sessionDelegate = TestSessionDelegate() - - let configuration = WKWebViewConfiguration() - configuration.applicationNameForUserAgent = "Turbo iOS Test/1.0" - session = Session(webViewConfiguration: configuration) - session.delegate = sessionDelegate - } - - afterEach { - session.webView.configuration.userContentController.removeScriptMessageHandler(forName: "turbo") - } - - describe("init") { - it("initializes web view with configuration") { - expect(session.webView.configuration.applicationNameForUserAgent) == "Turbo iOS Test/1.0" - } - } - - describe("cold boot visit") { - it("makes the session the visitable delegate") { - let visitable = TestVisitable(url: self.url("/")) - expect(visitable.visitableDelegate).to(beNil()) - - session.visit(visitable) - expect(visitable.visitableDelegate) === session - } - - it("calls start request") { - let visitable = TestVisitable(url: self.url("/")) - session.visit(visitable) - - expect(sessionDelegate.sessionDidStartRequestCalled).toEventually(beTrue(), timeout: timeout) - } - - context("when visit succeeds") { - beforeEach { - let visitable = TestVisitable(url: self.url("/")) - session.visit(visitable) - } - - it("calls sessionDidLoadWebView delegate method") { - expect(sessionDelegate.sessionDidLoadWebViewCalled).toEventually(beTrue(), timeout: timeout) - expect(sessionDelegate.sessionDidFailRequestCalled) == false - } - - it("calls sessionDidFinishRequest delegate method") { - expect(sessionDelegate.sessionDidFinishRequestCalled).toEventually(beTrue(), timeout: timeout) - expect(sessionDelegate.sessionDidFailRequestCalled) == false - } - - it("configures JavaScript bridge") { - expect(sessionDelegate.sessionDidLoadWebViewCalled).toEventually(beTrue(), timeout: timeout) - - waitUntil { done in - session.webView.evaluateJavaScript("Turbo.navigator.adapter == window.turboNative") { result, _ in - XCTAssertEqual(result as? Bool, true) - done() - } - } - } - } - - context("when visit fails from http error") { - beforeEach { - let visitable = TestVisitable(url: self.url("/invalid")) - session.visit(visitable) - } - - it("calls sessionDidFailRequest delegate method") { - expect(sessionDelegate.sessionDidFailRequestCalled).toEventually(beTrue(), timeout: timeout) - } - - it("provides an error") { - expect(sessionDelegate.failedRequestError).toEventuallyNot(beNil(), timeout: timeout) - guard let error = sessionDelegate.failedRequestError else { - fail("Should have gotten an error") - return - } - - expect(error).to(matchError(TurboError.http(statusCode: 404))) - } - - it("calls sessionDidFinishRequest delegate method") { - expect(sessionDelegate.sessionDidFinishRequestCalled).toEventually(beTrue(), timeout: timeout) - } - } - - context("when visit fails from missing library") { - beforeEach { - let visitable = TestVisitable(url: self.url("/missing-library")) - session.visit(visitable) - } - - it("calls sessionDidFailRequest delegate method") { - expect(sessionDelegate.sessionDidFailRequestCalled).toEventually(beTrue(), timeout: timeout) - } - - it("provides an page load error") { - expect(sessionDelegate.failedRequestError).toEventuallyNot(beNil(), timeout: timeout) - guard let error = sessionDelegate.failedRequestError else { - fail("Should have gotten an error") - return - } - - expect(error).to(matchError(TurboError.pageLoadFailure)) - } - - it("calls sessionDidFinishRequest delegate method") { - expect(sessionDelegate.sessionDidFinishRequestCalled).toEventually(beTrue(), timeout: timeout) - } - } - - describe("Turbolinks 5 compatibility") { - it("loads the page and sets the adapter") { - let visitable = TestVisitable(url: self.url("/turbolinks")) - session.visit(visitable) - - expect(sessionDelegate.sessionDidLoadWebViewCalled).toEventually(beTrue(), timeout: timeout) - - waitUntil { done in - session.webView.evaluateJavaScript("Turbolinks.controller.adapter === window.turboNative") { result, _ in - XCTAssertEqual(result as? Bool, true) - done() - } - } - } - } - - describe("Turbolinks 5.3 compatibility") { - it("loads the page and sets the adapter") { - let visitable = TestVisitable(url: self.url("/turbolinks-5.3")) - session.visit(visitable) - - expect(sessionDelegate.sessionDidLoadWebViewCalled).toEventually(beTrue(), timeout: timeout) - - waitUntil { done in - session.webView.evaluateJavaScript("Turbolinks.controller.adapter === window.turboNative") { result, _ in - XCTAssertEqual(result as? Bool, true) - done() - } - } - } - } - } - } - - // MARK: - Server - - private func url(_ path: String) -> URL { - let baseURL = URL(string: "http://localhost:8080")! - let relativePath = path.hasPrefix("/") ? String(path.dropFirst()) : path - return baseURL.appendingPathComponent(relativePath) - } - - private func startServer() { - server["/turbo-7.0.0-beta.1.js"] = { _ in - let fileURL = Bundle.module.url(forResource: "turbo-7.0.0-beta.1", withExtension: "js", subdirectory: "Server")! - let data = try! Data(contentsOf: fileURL) - return .ok(.data(data)) - } - - server["/turbolinks-5.2.0.js"] = { _ in - let fileURL = Bundle.module.url(forResource: "turbolinks-5.2.0", withExtension: "js", subdirectory: "Server")! - let data = try! Data(contentsOf: fileURL) - return .ok(.data(data)) - } - - server["/turbolinks-5.3.0-dev.js"] = { _ in - let fileURL = Bundle.module.url(forResource: "turbolinks-5.3.0-dev", withExtension: "js", subdirectory: "Server")! - let data = try! Data(contentsOf: fileURL) - return .ok(.data(data)) - } - - server["/"] = { _ in - let fileURL = Bundle.module.url(forResource: "turbo", withExtension: "html", subdirectory: "Server")! - let data = try! Data(contentsOf: fileURL) - return .ok(.data(data)) - } - - server["/turbolinks"] = { _ in - let fileURL = Bundle.module.url(forResource: "turbolinks", withExtension: "html", subdirectory: "Server")! - let data = try! Data(contentsOf: fileURL) - return .ok(.data(data)) - } - - server["/turbolinks-5.3"] = { _ in - let fileURL = Bundle.module.url(forResource: "turbolinks-5.3", withExtension: "html", subdirectory: "Server")! - let data = try! Data(contentsOf: fileURL) - return .ok(.data(data)) - } - - server["/missing-library"] = { _ in - .ok(.html("")) - } - - server["/invalid"] = { _ in - .notFound - } - - try! server.start() - } -} diff --git a/Tests/SessionTests.swift b/Tests/SessionTests.swift new file mode 100644 index 0000000..1fcbfc6 --- /dev/null +++ b/Tests/SessionTests.swift @@ -0,0 +1,199 @@ +import Swifter +@testable import Turbo +import WebKit +import XCTest + +class SessionTests: XCTestCase { + private static let server = HttpServer() + + private let sessionDelegate = TestSessionDelegate() + private var session: Session! + + override class func setUp() { + startServer() + } + + override class func tearDown() { + server.stop() + } + + override func setUp() { + let configuration = WKWebViewConfiguration() + configuration.applicationNameForUserAgent = "Turbo iOS Test/1.0" + + session = Session(webViewConfiguration: configuration) + session.delegate = sessionDelegate + } + + override func tearDown() { + session.webView.configuration.userContentController.removeScriptMessageHandler(forName: "turbo") + } + + func test_init_initializesWebViewWithConfiguration() { + XCTAssertEqual(session.webView.configuration.applicationNameForUserAgent, "Turbo iOS Test/1.0") + } + + func test_coldBootVisit_makesTheSessionTheVisitableDelegate() { + let visitable = TestVisitable(url: url("/")) + XCTAssertNil(visitable.visitableDelegate) + + session.visit(visitable) + XCTAssertIdentical(visitable.visitableDelegate, session) + } + + func test_coldBootVisit_callsStartRequest() { + let visitable = TestVisitable(url: url("/")) + session.visit(visitable) + + XCTAssertTrue(sessionDelegate.sessionDidStartRequestCalled) + } + + func test_coldBootVisit_whenVisitSucceeds_callsSessionDidLoadWebViewDelegateMethod() async { + await visit("/") + + XCTAssertTrue(sessionDelegate.sessionDidLoadWebViewCalled) + XCTAssertFalse(sessionDelegate.sessionDidFailRequestCalled) + } + + func test_coldBootVisit_whenVisitSucceeds_callsSessionDidFinishRequestDelegateMethod() async { + await visit("/") + + XCTAssertTrue(sessionDelegate.sessionDidFinishRequestCalled) + XCTAssertFalse(sessionDelegate.sessionDidFailRequestCalled) + } + + @MainActor + func test_coldBootVisit_whenVisitSucceeds_configuresJavaScriptBridge() async throws { + await visit("/") + + XCTAssertTrue(sessionDelegate.sessionDidLoadWebViewCalled) + let result = try await session.webView.evaluateJavaScript("Turbo.navigator.adapter == window.turboNative") + XCTAssertTrue(result as! Bool) + } + + func test_coldBootVisit_whenVisitFailsFromHTTPError_callsSessionDidFailRequestDelegateMethod() async { + await visit("/invalid") + + XCTAssertTrue(sessionDelegate.sessionDidFailRequestCalled) + } + + func test_coldBootVisit_whenVisitFailsFromHTTPError_providesAnError() async throws { + await visit("/invalid") + + XCTAssertNotNil(sessionDelegate.failedRequestError) + let error = try XCTUnwrap(sessionDelegate.failedRequestError) + XCTAssertEqual(error as? TurboError, TurboError.http(statusCode: 404)) + } + + func test_coldBootVisit_whenVisitFailsFromHTTPError_callsSessionDidFinishRequestDelegateMethod() async { + await visit("/invalid") + + XCTAssertTrue(sessionDelegate.sessionDidFinishRequestCalled) + } + + func test_coldBootVisit_whenVisitFailsFromMissingLibrary_callsSessionDidFailRequestDelegateMethod() async { + await visit("/missing-library") + + XCTAssertTrue(sessionDelegate.sessionDidFailRequestCalled) + } + + func test_coldBootVisit_whenVisitFailsFromMissingLibrary_providesAnPageLoadError() async throws { + await visit("/missing-library") + + XCTAssertNotNil(sessionDelegate.failedRequestError) + let error = try XCTUnwrap(sessionDelegate.failedRequestError) + XCTAssertEqual(error as? TurboError, TurboError.pageLoadFailure) + } + + func test_coldBootVisit_whenVisitFailsFromMissingLibrary_callsSessionDidFinishRequestDelegateMethod() async { + await visit("/missing-library") + + XCTAssertTrue(sessionDelegate.sessionDidFinishRequestCalled) + } + + @MainActor + func test_coldBootVisit_Turbolinks5Compatibility_loadsThePageAndSetsTheAdapter() async throws { + await visit("/turbolinks") + + XCTAssertTrue(sessionDelegate.sessionDidLoadWebViewCalled) + + let result = try await session.webView.evaluateJavaScript("Turbolinks.controller.adapter === window.turboNative") + XCTAssertTrue(result as! Bool) + } + + @MainActor + func test_coldBootVisit_Turbolinks5_3Compatibility_loadsThePageAndSetsTheAdapter() async throws { + await visit("/turbolinks-5.3") + + XCTAssertTrue(sessionDelegate.sessionDidLoadWebViewCalled) + + let result = try await session.webView.evaluateJavaScript("Turbolinks.controller.adapter === window.turboNative") + XCTAssertTrue(result as! Bool) + } + + // MARK: - Server + + @MainActor + private func visit(_ path: String) async { + let expectation = self.expectation(description: "Wait for request to load.") + sessionDelegate.didChange = { expectation.fulfill() } + + let visitable = TestVisitable(url: url(path)) + session.visit(visitable) + await fulfillment(of: [expectation]) + } + + private func url(_ path: String) -> URL { + let baseURL = URL(string: "http://localhost:8080")! + let relativePath = path.hasPrefix("/") ? String(path.dropFirst()) : path + return baseURL.appendingPathComponent(relativePath) + } + + private static func startServer() { + server["/turbo-7.0.0-beta.1.js"] = { _ in + let fileURL = Bundle.module.url(forResource: "turbo-7.0.0-beta.1", withExtension: "js", subdirectory: "Server")! + let data = try! Data(contentsOf: fileURL) + return .ok(.data(data)) + } + + server["/turbolinks-5.2.0.js"] = { _ in + let fileURL = Bundle.module.url(forResource: "turbolinks-5.2.0", withExtension: "js", subdirectory: "Server")! + let data = try! Data(contentsOf: fileURL) + return .ok(.data(data)) + } + + server["/turbolinks-5.3.0-dev.js"] = { _ in + let fileURL = Bundle.module.url(forResource: "turbolinks-5.3.0-dev", withExtension: "js", subdirectory: "Server")! + let data = try! Data(contentsOf: fileURL) + return .ok(.data(data)) + } + + server["/"] = { _ in + let fileURL = Bundle.module.url(forResource: "turbo", withExtension: "html", subdirectory: "Server")! + let data = try! Data(contentsOf: fileURL) + return .ok(.data(data)) + } + + server["/turbolinks"] = { _ in + let fileURL = Bundle.module.url(forResource: "turbolinks", withExtension: "html", subdirectory: "Server")! + let data = try! Data(contentsOf: fileURL) + return .ok(.data(data)) + } + + server["/turbolinks-5.3"] = { _ in + let fileURL = Bundle.module.url(forResource: "turbolinks-5.3", withExtension: "html", subdirectory: "Server")! + let data = try! Data(contentsOf: fileURL) + return .ok(.data(data)) + } + + server["/missing-library"] = { _ in + .ok(.html("")) + } + + server["/invalid"] = { _ in + .notFound + } + + try! server.start() + } +} diff --git a/Tests/Test.swift b/Tests/Test.swift index cffcb86..33e2b54 100644 --- a/Tests/Test.swift +++ b/Tests/Test.swift @@ -37,12 +37,14 @@ class TestVisitable: UIViewController, Visitable { } class TestSessionDelegate: NSObject, SessionDelegate { - var sessionDidLoadWebViewCalled = false + var sessionDidLoadWebViewCalled = false { didSet { didChange?() }} var sessionDidStartRequestCalled = false var sessionDidFinishRequestCalled = false var failedRequestError: Error? = nil - var sessionDidFailRequestCalled = false + var sessionDidFailRequestCalled = false { didSet { didChange?() }} var sessionDidProposeVisitCalled = false + + var didChange: (() -> Void)? func sessionDidLoadWebView(_ session: Session) { sessionDidLoadWebViewCalled = true diff --git a/Tests/VisitOptionsSpec.swift b/Tests/VisitOptionsSpec.swift deleted file mode 100644 index 577214d..0000000 --- a/Tests/VisitOptionsSpec.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Quick -import Nimble -import Foundation -@testable import Turbo - -class VisitOptionsSpec: QuickSpec { - override func spec() { - describe("Decodable") { - it("defaults to advance action when not provided") { - let json = "{}".data(using: .utf8)! - - do { - let decoder = JSONDecoder() - let options = try decoder.decode(VisitOptions.self, from: json) - expect(options.action) == .advance - expect(options.response).to(beNil()) - } catch { - fail(error.localizedDescription) - } - } - - it("uses provided action when not nil") { - let json = """ - {"action": "restore"} - """.data(using: .utf8)! - - do { - let decoder = JSONDecoder() - let options = try decoder.decode(VisitOptions.self, from: json) - expect(options.action) == .restore - expect(options.response).to(beNil()) - } catch { - fail(error.localizedDescription) - } - } - - it("can be initialized with response") { - let json = """ - {"response": {"statusCode": 200, "responseHTML": ""}} - """.data(using: .utf8)! - - do { - let decoder = JSONDecoder() - let options = try decoder.decode(VisitOptions.self, from: json) - expect(options.action) == .advance - expect(options.response).toNot(beNil()) - expect(options.response!.statusCode) == 200 - expect(options.response!.responseHTML) == "" - - } catch { - fail(error.localizedDescription) - } - } - } - } -} diff --git a/Tests/VisitOptionsTests.swift b/Tests/VisitOptionsTests.swift new file mode 100644 index 0000000..f98c1e3 --- /dev/null +++ b/Tests/VisitOptionsTests.swift @@ -0,0 +1,35 @@ +@testable import Turbo +import XCTest + +class VisitOptionsTests: XCTestCase { + func test_Decodable_defaultsToAdvanceActionWhenNotProvided() throws { + let json = "{}".data(using: .utf8)! + + let options = try JSONDecoder().decode(VisitOptions.self, from: json) + XCTAssertEqual(options.action, .advance) + XCTAssertNil(options.response) + } + + func test_Decodable_usesProvidedActionWhenNotNil() throws { + let json = """ + {"action": "restore"} + """.data(using: .utf8)! + + let options = try JSONDecoder().decode(VisitOptions.self, from: json) + XCTAssertEqual(options.action, .restore) + XCTAssertNil(options.response) + } + + func test_Decodable_canBeInitializedWithResponse() throws { + let json = """ + {"response": {"statusCode": 200, "responseHTML": ""}} + """.data(using: .utf8)! + + let options = try JSONDecoder().decode(VisitOptions.self, from: json) + XCTAssertEqual(options.action, .advance) + + let response = try XCTUnwrap(options.response) + XCTAssertEqual(response.statusCode, 200) + XCTAssertEqual(response.responseHTML, "") + } +} From 0205a22c5fc1af565c669b7c22a99c2654b1da36 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 12 Sep 2023 21:23:19 -0700 Subject: [PATCH 02/81] Format file --- Tests/Test.swift | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/Tests/Test.swift b/Tests/Test.swift index 33e2b54..295cbd9 100644 --- a/Tests/Test.swift +++ b/Tests/Test.swift @@ -1,36 +1,39 @@ +@testable import Turbo import UIKit import WebKit -@testable import Turbo class TestVisitable: UIViewController, Visitable { // MARK: - Tests + var visitableDidRenderCalled = false var visitableDidActivateWebViewWasCalled = false var visitableDidDeactivateWebViewWasCalled = false - + // MARK: - Visitable + var visitableDelegate: VisitableDelegate? var visitableView: VisitableView! var visitableURL: URL! - + init(url: URL) { self.visitableURL = url self.visitableView = VisitableView(frame: .zero) super.init(nibName: nil, bundle: nil) } - + + @available(*, unavailable) required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + func visitableDidRender() { visitableDidRenderCalled = true } - + func visitableDidActivateWebView(_ webView: WKWebView) { visitableDidActivateWebViewWasCalled = true } - + func visitableDidDeactivateWebView() { visitableDidDeactivateWebViewWasCalled = true } @@ -45,33 +48,30 @@ class TestSessionDelegate: NSObject, SessionDelegate { var sessionDidProposeVisitCalled = false var didChange: (() -> Void)? - + func sessionDidLoadWebView(_ session: Session) { sessionDidLoadWebViewCalled = true } - + func sessionDidStartRequest(_ session: Session) { sessionDidStartRequestCalled = true } - + func sessionDidFinishRequest(_ session: Session) { sessionDidFinishRequestCalled = true } - - func sesssionDidStartFormSubmission(_ session: Session) { - } - - func sessionDidFinishFormSubmission(_ session: Session) { - } - - func sessionWebViewProcessDidTerminate(_ session: Session) { - } - + + func sesssionDidStartFormSubmission(_ session: Session) {} + + func sessionDidFinishFormSubmission(_ session: Session) {} + + func sessionWebViewProcessDidTerminate(_ session: Session) {} + func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) { sessionDidFailRequestCalled = true failedRequestError = error } - + func session(_ session: Session, didProposeVisit proposal: VisitProposal) { sessionDidProposeVisitCalled = true } From 7dddd0442f473581918f3b1dde60dd5de710df37 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 12 Sep 2023 21:45:56 -0700 Subject: [PATCH 03/81] Move last test helper to Test.swift with the rest --- Tests/ColdBootVisitTests.swift | 62 ---------------------------------- Tests/Test.swift | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 62 deletions(-) diff --git a/Tests/ColdBootVisitTests.swift b/Tests/ColdBootVisitTests.swift index 9eace24..5eb7174 100644 --- a/Tests/ColdBootVisitTests.swift +++ b/Tests/ColdBootVisitTests.swift @@ -50,65 +50,3 @@ class ColdBootVisitTests: XCTestCase { XCTAssertFalse(visitDelegate.didCall("visitDidStart(_:)")) } } - -private class TestVisitDelegate { - var methodsCalled: Set = [] - - func didCall(_ method: String) -> Bool { - methodsCalled.contains(method) - } - - private func record(_ string: String = #function) { - methodsCalled.insert(string) - } -} - -extension TestVisitDelegate: VisitDelegate { - func visitDidInitializeWebView(_ visit: Visit) { - record() - } - - func visitWillStart(_ visit: Visit) { - record() - } - - func visitDidStart(_ visit: Visit) { - record() - } - - func visitDidComplete(_ visit: Visit) { - record() - } - - func visitDidFail(_ visit: Visit) { - record() - } - - func visitDidFinish(_ visit: Visit) { - record() - } - - func visitWillLoadResponse(_ visit: Visit) { - record() - } - - func visitDidRender(_ visit: Visit) { - record() - } - - func visitRequestDidStart(_ visit: Visit) { - record() - } - - func visit(_ visit: Visit, requestDidFailWithError error: Error) { - record() - } - - func visitRequestDidFinish(_ visit: Visit) { - record() - } - - func visit(_ visit: Visit, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - record() - } -} diff --git a/Tests/Test.swift b/Tests/Test.swift index 295cbd9..7fa2ce2 100644 --- a/Tests/Test.swift +++ b/Tests/Test.swift @@ -76,3 +76,65 @@ class TestSessionDelegate: NSObject, SessionDelegate { sessionDidProposeVisitCalled = true } } + +class TestVisitDelegate { + var methodsCalled: Set = [] + + func didCall(_ method: String) -> Bool { + methodsCalled.contains(method) + } + + private func record(_ string: String = #function) { + methodsCalled.insert(string) + } +} + +extension TestVisitDelegate: VisitDelegate { + func visitDidInitializeWebView(_ visit: Visit) { + record() + } + + func visitWillStart(_ visit: Visit) { + record() + } + + func visitDidStart(_ visit: Visit) { + record() + } + + func visitDidComplete(_ visit: Visit) { + record() + } + + func visitDidFail(_ visit: Visit) { + record() + } + + func visitDidFinish(_ visit: Visit) { + record() + } + + func visitWillLoadResponse(_ visit: Visit) { + record() + } + + func visitDidRender(_ visit: Visit) { + record() + } + + func visitRequestDidStart(_ visit: Visit) { + record() + } + + func visit(_ visit: Visit, requestDidFailWithError error: Error) { + record() + } + + func visitRequestDidFinish(_ visit: Visit) { + record() + } + + func visit(_ visit: Visit, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + record() + } +} From a86e6e9dd3fa8edfdf89b693036d3cfbf341b87e Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 12 Sep 2023 21:58:16 -0700 Subject: [PATCH 04/81] Specify latest stable version of Xcode on CI --- .github/workflows/main.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2bd097b..c05c6aa 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,5 +8,9 @@ jobs: steps: - uses: actions/checkout@master + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Run Tests run: xcodebuild -scheme Turbo test -quiet -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' From d247d36858ea2a2bdf85c9ef36f9e2c89d02fab4 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 13 Sep 2023 06:15:42 -0700 Subject: [PATCH 05/81] Don't use async version of expectations in tests --- Tests/SessionTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/SessionTests.swift b/Tests/SessionTests.swift index 1fcbfc6..5a3730f 100644 --- a/Tests/SessionTests.swift +++ b/Tests/SessionTests.swift @@ -134,13 +134,13 @@ class SessionTests: XCTestCase { // MARK: - Server @MainActor - private func visit(_ path: String) async { + private func visit(_ path: String) { let expectation = self.expectation(description: "Wait for request to load.") sessionDelegate.didChange = { expectation.fulfill() } let visitable = TestVisitable(url: url(path)) session.visit(visitable) - await fulfillment(of: [expectation]) + wait(for: [expectation]) } private func url(_ path: String) -> URL { From 491f206d6712822c47f175f36c4866cf90268f3d Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Thu, 9 Nov 2023 05:56:48 -0800 Subject: [PATCH 06/81] Update CI to latest version of macOS and iOS --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c05c6aa..aae956d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,7 +4,7 @@ on: [push] jobs: test: - runs-on: macos-latest + runs-on: macos-13 steps: - uses: actions/checkout@master @@ -13,4 +13,4 @@ jobs: xcode-version: latest-stable - name: Run Tests - run: xcodebuild -scheme Turbo test -quiet -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' + run: xcodebuild -scheme Turbo test -quiet -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Pro' From eabaa1c51b857c01e260b3eb8d51d2acbb7c82b5 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Thu, 9 Nov 2023 06:02:30 -0800 Subject: [PATCH 07/81] Address warnings from latest Xcode --- Tests/SessionTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/SessionTests.swift b/Tests/SessionTests.swift index 5a3730f..e2580fc 100644 --- a/Tests/SessionTests.swift +++ b/Tests/SessionTests.swift @@ -64,7 +64,7 @@ class SessionTests: XCTestCase { @MainActor func test_coldBootVisit_whenVisitSucceeds_configuresJavaScriptBridge() async throws { - await visit("/") + visit("/") XCTAssertTrue(sessionDelegate.sessionDidLoadWebViewCalled) let result = try await session.webView.evaluateJavaScript("Turbo.navigator.adapter == window.turboNative") @@ -113,7 +113,7 @@ class SessionTests: XCTestCase { @MainActor func test_coldBootVisit_Turbolinks5Compatibility_loadsThePageAndSetsTheAdapter() async throws { - await visit("/turbolinks") + visit("/turbolinks") XCTAssertTrue(sessionDelegate.sessionDidLoadWebViewCalled) @@ -123,7 +123,7 @@ class SessionTests: XCTestCase { @MainActor func test_coldBootVisit_Turbolinks5_3Compatibility_loadsThePageAndSetsTheAdapter() async throws { - await visit("/turbolinks-5.3") + visit("/turbolinks-5.3") XCTAssertTrue(sessionDelegate.sessionDidLoadWebViewCalled) From 9a34fb7478eebdfbffdd2548a169d108bd3e3714 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Thu, 9 Nov 2023 08:34:11 -0800 Subject: [PATCH 08/81] Clean up GitHub action file --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index aae956d..aedc7eb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,11 +6,11 @@ jobs: test: runs-on: macos-13 steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable - name: Run Tests - run: xcodebuild -scheme Turbo test -quiet -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Pro' + run: xcodebuild test -scheme Turbo -destination "name=iPhone 15 Pro" | xcpretty -t && exit ${PIPESTATUS[0]} From 8db3da2a949e91a7f155f51f3222260deac49f00 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Thu, 9 Nov 2023 09:54:30 -0800 Subject: [PATCH 09/81] Perform all test visits async Increase timeout of failed page load to MORE than Turbo.js timeout so it triggers the invalid configuration error. --- Tests/SessionTests.swift | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/Tests/SessionTests.swift b/Tests/SessionTests.swift index e2580fc..6e2fbc2 100644 --- a/Tests/SessionTests.swift +++ b/Tests/SessionTests.swift @@ -62,9 +62,8 @@ class SessionTests: XCTestCase { XCTAssertFalse(sessionDelegate.sessionDidFailRequestCalled) } - @MainActor func test_coldBootVisit_whenVisitSucceeds_configuresJavaScriptBridge() async throws { - visit("/") + await visit("/") XCTAssertTrue(sessionDelegate.sessionDidLoadWebViewCalled) let result = try await session.webView.evaluateJavaScript("Turbo.navigator.adapter == window.turboNative") @@ -91,29 +90,21 @@ class SessionTests: XCTestCase { XCTAssertTrue(sessionDelegate.sessionDidFinishRequestCalled) } - func test_coldBootVisit_whenVisitFailsFromMissingLibrary_callsSessionDidFailRequestDelegateMethod() async { - await visit("/missing-library") + @MainActor + func test_coldBootVisit_whenVisitFailsFromMissingLibrary_providesAnPageLoadError() async throws { + // 5 seconds more than Turbo.js timeout. + await visit("/missing-library", timeout: 35) XCTAssertTrue(sessionDelegate.sessionDidFailRequestCalled) - } - - func test_coldBootVisit_whenVisitFailsFromMissingLibrary_providesAnPageLoadError() async throws { - await visit("/missing-library") + XCTAssertTrue(sessionDelegate.sessionDidFinishRequestCalled) XCTAssertNotNil(sessionDelegate.failedRequestError) let error = try XCTUnwrap(sessionDelegate.failedRequestError) XCTAssertEqual(error as? TurboError, TurboError.pageLoadFailure) } - func test_coldBootVisit_whenVisitFailsFromMissingLibrary_callsSessionDidFinishRequestDelegateMethod() async { - await visit("/missing-library") - - XCTAssertTrue(sessionDelegate.sessionDidFinishRequestCalled) - } - - @MainActor func test_coldBootVisit_Turbolinks5Compatibility_loadsThePageAndSetsTheAdapter() async throws { - visit("/turbolinks") + await visit("/turbolinks") XCTAssertTrue(sessionDelegate.sessionDidLoadWebViewCalled) @@ -121,9 +112,8 @@ class SessionTests: XCTestCase { XCTAssertTrue(result as! Bool) } - @MainActor func test_coldBootVisit_Turbolinks5_3Compatibility_loadsThePageAndSetsTheAdapter() async throws { - visit("/turbolinks-5.3") + await visit("/turbolinks-5.3") XCTAssertTrue(sessionDelegate.sessionDidLoadWebViewCalled) @@ -134,13 +124,13 @@ class SessionTests: XCTestCase { // MARK: - Server @MainActor - private func visit(_ path: String) { + private func visit(_ path: String, timeout: TimeInterval = 5) async { let expectation = self.expectation(description: "Wait for request to load.") sessionDelegate.didChange = { expectation.fulfill() } let visitable = TestVisitable(url: url(path)) session.visit(visitable) - wait(for: [expectation]) + await fulfillment(of: [expectation], timeout: timeout) } private func url(_ path: String) -> URL { From 0b64ccdc95ef41206e546b5fd7c9daa7b97af814 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Thu, 9 Nov 2023 10:08:36 -0800 Subject: [PATCH 10/81] Increase XCTest timeout for slow GitHub Actions --- Tests/SessionTests.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Tests/SessionTests.swift b/Tests/SessionTests.swift index 6e2fbc2..2fc5d2a 100644 --- a/Tests/SessionTests.swift +++ b/Tests/SessionTests.swift @@ -3,6 +3,9 @@ import Swifter import WebKit import XCTest +private let defaultTimeout: TimeInterval = 10 +private let turboTimeout: TimeInterval = 30 + class SessionTests: XCTestCase { private static let server = HttpServer() @@ -92,8 +95,7 @@ class SessionTests: XCTestCase { @MainActor func test_coldBootVisit_whenVisitFailsFromMissingLibrary_providesAnPageLoadError() async throws { - // 5 seconds more than Turbo.js timeout. - await visit("/missing-library", timeout: 35) + await visit("/missing-library", timeout: turboTimeout + defaultTimeout) XCTAssertTrue(sessionDelegate.sessionDidFailRequestCalled) XCTAssertTrue(sessionDelegate.sessionDidFinishRequestCalled) @@ -124,7 +126,7 @@ class SessionTests: XCTestCase { // MARK: - Server @MainActor - private func visit(_ path: String, timeout: TimeInterval = 5) async { + private func visit(_ path: String, timeout: TimeInterval = defaultTimeout) async { let expectation = self.expectation(description: "Wait for request to load.") sessionDelegate.didChange = { expectation.fulfill() } From 43680a0606463eb2ff93209c236bda6bf63fbb9f Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Thu, 9 Nov 2023 10:08:54 -0800 Subject: [PATCH 11/81] Try Silicon on GitHub Actions --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index aedc7eb..646833d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,7 +4,7 @@ on: [push] jobs: test: - runs-on: macos-13 + runs-on: macos-13-arm64 steps: - uses: actions/checkout@v4 From 465e784fec24a970905cd16ff4cfb25f335202fd Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Thu, 9 Nov 2023 10:43:36 -0800 Subject: [PATCH 12/81] Try faster macOS image --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 646833d..fe19664 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,7 +4,7 @@ on: [push] jobs: test: - runs-on: macos-13-arm64 + runs-on: macos-13-xl steps: - uses: actions/checkout@v4 From 03045f84c9ba06b84c0269e4b95beeea2527baec Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Thu, 9 Nov 2023 13:52:33 -0800 Subject: [PATCH 13/81] Convert Swifter to Embassy --- Package.resolved | 16 +++---- Package.swift | 4 +- Tests/SessionTests.swift | 94 +++++++++++++++++++++------------------- 3 files changed, 59 insertions(+), 55 deletions(-) diff --git a/Package.resolved b/Package.resolved index b47b31d..4f66de1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,21 +1,21 @@ { "pins" : [ { - "identity" : "ohhttpstubs", + "identity" : "embassy", "kind" : "remoteSourceControl", - "location" : "https://github.com/AliSoftware/OHHTTPStubs", + "location" : "https://github.com/envoy/Embassy.git", "state" : { - "revision" : "12f19662426d0434d6c330c6974d53e2eb10ecd9", - "version" : "9.1.0" + "revision" : "8469f2c1b334a7c1c3566e2cb2f97826c7cca898", + "version" : "4.1.6" } }, { - "identity" : "swifter", + "identity" : "ohhttpstubs", "kind" : "remoteSourceControl", - "location" : "https://github.com/httpswift/swifter", + "location" : "https://github.com/AliSoftware/OHHTTPStubs", "state" : { - "revision" : "9483a5d459b45c3ffd059f7b55f9638e268632fd", - "version" : "1.5.0" + "revision" : "12f19662426d0434d6c330c6974d53e2eb10ecd9", + "version" : "9.1.0" } } ], diff --git a/Package.swift b/Package.swift index 921486e..a0e1b2c 100644 --- a/Package.swift +++ b/Package.swift @@ -15,7 +15,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/AliSoftware/OHHTTPStubs", .upToNextMajor(from: "9.0.0")), - .package(url: "https://github.com/httpswift/swifter.git", .upToNextMajor(from: "1.5.0")) + .package(url: "https://github.com/envoy/Embassy.git", .upToNextMajor(from: "4.1.4")) ], targets: [ .target( @@ -32,7 +32,7 @@ let package = Package( dependencies: [ "Turbo", .product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs"), - .product(name: "Swifter", package: "Swifter") + .product(name: "Embassy", package: "Embassy") ], path: "Tests", resources: [ diff --git a/Tests/SessionTests.swift b/Tests/SessionTests.swift index 2fc5d2a..b3add00 100644 --- a/Tests/SessionTests.swift +++ b/Tests/SessionTests.swift @@ -1,23 +1,27 @@ -import Swifter +import Embassy @testable import Turbo import WebKit import XCTest -private let defaultTimeout: TimeInterval = 10 +private let defaultTimeout: TimeInterval = 10000 private let turboTimeout: TimeInterval = 30 class SessionTests: XCTestCase { - private static let server = HttpServer() + private static var eventLoop: EventLoop! + private static var server: HTTPServer! private let sessionDelegate = TestSessionDelegate() private var session: Session! override class func setUp() { + super.setUp() startServer() } override class func tearDown() { - server.stop() + super.tearDown() + server.stopAndWait() + eventLoop.stop() } override func setUp() { @@ -142,50 +146,50 @@ class SessionTests: XCTestCase { } private static func startServer() { - server["/turbo-7.0.0-beta.1.js"] = { _ in - let fileURL = Bundle.module.url(forResource: "turbo-7.0.0-beta.1", withExtension: "js", subdirectory: "Server")! - let data = try! Data(contentsOf: fileURL) - return .ok(.data(data)) + let loop = try! SelectorEventLoop(selector: try! KqueueSelector()) + eventLoop = loop + + let server = DefaultHTTPServer(eventLoop: loop, port: 8080) { environ, startResponse, sendBody in + let path = environ["PATH_INFO"] as! String + + func respondWithFile(resourceName: String, resourceType: String) { + let fileURL = Bundle.module.url(forResource: resourceName, withExtension: resourceType, subdirectory: "Server")! + let data = try! Data(contentsOf: fileURL) + + let contentType = (resourceType == "js") ? "application/javascript" : "text/html" + startResponse("200 OK", [("Content-Type", contentType)]) + sendBody(data) + sendBody(Data()) + } + + switch path { + case "/turbo-7.0.0-beta.1.js": + respondWithFile(resourceName: "turbo-7.0.0-beta.1", resourceType: "js") + case "/turbolinks-5.2.0.js": + respondWithFile(resourceName: "turbolinks-5.2.0", resourceType: "js") + case "/turbolinks-5.3.0-dev.js": + respondWithFile(resourceName: "turbolinks-5.3.0-dev", resourceType: "js") + case "/": + respondWithFile(resourceName: "turbo", resourceType: "html") + case "/turbolinks": + respondWithFile(resourceName: "turbolinks", resourceType: "html") + case "/turbolinks-5.3": + respondWithFile(resourceName: "turbolinks-5.3", resourceType: "html") + case "/missing-library": + startResponse("200 OK", [("Content-Type", "text/html")]) + sendBody("".data(using: .utf8)!) + sendBody(Data()) + default: + startResponse("404 Not Found", [("Content-Type", "text/plain")]) + sendBody(Data()) + } } - server["/turbolinks-5.2.0.js"] = { _ in - let fileURL = Bundle.module.url(forResource: "turbolinks-5.2.0", withExtension: "js", subdirectory: "Server")! - let data = try! Data(contentsOf: fileURL) - return .ok(.data(data)) - } - - server["/turbolinks-5.3.0-dev.js"] = { _ in - let fileURL = Bundle.module.url(forResource: "turbolinks-5.3.0-dev", withExtension: "js", subdirectory: "Server")! - let data = try! Data(contentsOf: fileURL) - return .ok(.data(data)) - } - - server["/"] = { _ in - let fileURL = Bundle.module.url(forResource: "turbo", withExtension: "html", subdirectory: "Server")! - let data = try! Data(contentsOf: fileURL) - return .ok(.data(data)) - } - - server["/turbolinks"] = { _ in - let fileURL = Bundle.module.url(forResource: "turbolinks", withExtension: "html", subdirectory: "Server")! - let data = try! Data(contentsOf: fileURL) - return .ok(.data(data)) - } - - server["/turbolinks-5.3"] = { _ in - let fileURL = Bundle.module.url(forResource: "turbolinks-5.3", withExtension: "html", subdirectory: "Server")! - let data = try! Data(contentsOf: fileURL) - return .ok(.data(data)) - } - - server["/missing-library"] = { _ in - .ok(.html("")) - } + self.server = server + try! server.start() - server["/invalid"] = { _ in - .notFound + DispatchQueue.global().async { + loop.runForever() } - - try! server.start() } } From e8ee29ef0c9ad64522b6a451f9b7756b79e95e13 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Thu, 9 Nov 2023 13:58:07 -0800 Subject: [PATCH 14/81] Remove xcpretty formatter - GitHub can't stream it --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fe19664..275e1a6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,4 +13,4 @@ jobs: xcode-version: latest-stable - name: Run Tests - run: xcodebuild test -scheme Turbo -destination "name=iPhone 15 Pro" | xcpretty -t && exit ${PIPESTATUS[0]} + run: xcodebuild test -scheme Turbo -destination "name=iPhone 15 Pro" | xcpretty && exit ${PIPESTATUS[0]} From eb5e73317c3b560fe9ec51bdbc7b7c6b72977521 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Thu, 9 Nov 2023 14:11:44 -0800 Subject: [PATCH 15/81] Move Embassy server config to helper file --- Tests/Server.swift | 42 ++++++++++++++++++++++++++++++ Tests/SessionTests.swift | 55 +++------------------------------------- 2 files changed, 46 insertions(+), 51 deletions(-) create mode 100644 Tests/Server.swift diff --git a/Tests/Server.swift b/Tests/Server.swift new file mode 100644 index 0000000..45ca519 --- /dev/null +++ b/Tests/Server.swift @@ -0,0 +1,42 @@ +import Embassy +import Foundation + +extension DefaultHTTPServer { + static func turboServer(eventLoop: EventLoop, port: Int = 8080) -> DefaultHTTPServer { + return DefaultHTTPServer(eventLoop: eventLoop, port: port) { environ, startResponse, sendBody in + let path = environ["PATH_INFO"] as! String + + func respondWithFile(resourceName: String, resourceType: String) { + let fileURL = Bundle.module.url(forResource: resourceName, withExtension: resourceType, subdirectory: "Server")! + let data = try! Data(contentsOf: fileURL) + let contentType = (resourceType == "js") ? "application/javascript" : "text/html" + + startResponse("200 OK", [("Content-Type", contentType)]) + sendBody(data) + sendBody(Data()) + } + + switch path { + case "/turbo-7.0.0-beta.1.js": + respondWithFile(resourceName: "turbo-7.0.0-beta.1", resourceType: "js") + case "/turbolinks-5.2.0.js": + respondWithFile(resourceName: "turbolinks-5.2.0", resourceType: "js") + case "/turbolinks-5.3.0-dev.js": + respondWithFile(resourceName: "turbolinks-5.3.0-dev", resourceType: "js") + case "/": + respondWithFile(resourceName: "turbo", resourceType: "html") + case "/turbolinks": + respondWithFile(resourceName: "turbolinks", resourceType: "html") + case "/turbolinks-5.3": + respondWithFile(resourceName: "turbolinks-5.3", resourceType: "html") + case "/missing-library": + startResponse("200 OK", [("Content-Type", "text/html")]) + sendBody("".data(using: .utf8)!) + sendBody(Data()) + default: + startResponse("404 Not Found", [("Content-Type", "text/plain")]) + sendBody(Data()) + } + } + } +} diff --git a/Tests/SessionTests.swift b/Tests/SessionTests.swift index b3add00..c163a99 100644 --- a/Tests/SessionTests.swift +++ b/Tests/SessionTests.swift @@ -7,15 +7,16 @@ private let defaultTimeout: TimeInterval = 10000 private let turboTimeout: TimeInterval = 30 class SessionTests: XCTestCase { - private static var eventLoop: EventLoop! - private static var server: HTTPServer! + private static let eventLoop = try! SelectorEventLoop(selector: try! KqueueSelector()) + private static let server = DefaultHTTPServer.turboServer(eventLoop: eventLoop) private let sessionDelegate = TestSessionDelegate() private var session: Session! override class func setUp() { super.setUp() - startServer() + try! server.start() + DispatchQueue.global().async { eventLoop.runForever() } } override class func tearDown() { @@ -144,52 +145,4 @@ class SessionTests: XCTestCase { let relativePath = path.hasPrefix("/") ? String(path.dropFirst()) : path return baseURL.appendingPathComponent(relativePath) } - - private static func startServer() { - let loop = try! SelectorEventLoop(selector: try! KqueueSelector()) - eventLoop = loop - - let server = DefaultHTTPServer(eventLoop: loop, port: 8080) { environ, startResponse, sendBody in - let path = environ["PATH_INFO"] as! String - - func respondWithFile(resourceName: String, resourceType: String) { - let fileURL = Bundle.module.url(forResource: resourceName, withExtension: resourceType, subdirectory: "Server")! - let data = try! Data(contentsOf: fileURL) - - let contentType = (resourceType == "js") ? "application/javascript" : "text/html" - startResponse("200 OK", [("Content-Type", contentType)]) - sendBody(data) - sendBody(Data()) - } - - switch path { - case "/turbo-7.0.0-beta.1.js": - respondWithFile(resourceName: "turbo-7.0.0-beta.1", resourceType: "js") - case "/turbolinks-5.2.0.js": - respondWithFile(resourceName: "turbolinks-5.2.0", resourceType: "js") - case "/turbolinks-5.3.0-dev.js": - respondWithFile(resourceName: "turbolinks-5.3.0-dev", resourceType: "js") - case "/": - respondWithFile(resourceName: "turbo", resourceType: "html") - case "/turbolinks": - respondWithFile(resourceName: "turbolinks", resourceType: "html") - case "/turbolinks-5.3": - respondWithFile(resourceName: "turbolinks-5.3", resourceType: "html") - case "/missing-library": - startResponse("200 OK", [("Content-Type", "text/html")]) - sendBody("".data(using: .utf8)!) - sendBody(Data()) - default: - startResponse("404 Not Found", [("Content-Type", "text/plain")]) - sendBody(Data()) - } - } - - self.server = server - try! server.start() - - DispatchQueue.global().async { - loop.runForever() - } - } } From 0d8bd6d3fce82ea39076d3b3a6c5761c6cbcebdb Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Sat, 11 Nov 2023 06:11:56 -0800 Subject: [PATCH 16/81] Revert back to non-XL box on CI --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 275e1a6..2ddbedc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,7 +4,7 @@ on: [push] jobs: test: - runs-on: macos-13-xl + runs-on: macos-13 steps: - uses: actions/checkout@v4 From 06cf8179141a931313de3faab357d868bdf0c67d Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Sat, 11 Nov 2023 06:20:50 -0800 Subject: [PATCH 17/81] Update Tests/ScriptMessageTests.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Zoë Smith --- Tests/ScriptMessageTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/ScriptMessageTests.swift b/Tests/ScriptMessageTests.swift index 3d44af3..fd5966a 100644 --- a/Tests/ScriptMessageTests.swift +++ b/Tests/ScriptMessageTests.swift @@ -11,7 +11,8 @@ class ScriptMessageTests: XCTestCase { XCTAssertEqual(message.name, .pageLoaded) XCTAssertEqual(message.identifier, "123") XCTAssertEqual(message.restorationIdentifier, "abc") - XCTAssertEqual(message.options!.action, .advance) + let options = try XCTUnwrap(message.options) + XCTAssertEqual(options.action, .advance) XCTAssertEqual(message.location, URL(string: "http://turbo.test")!) } From e74a7801b83a4e23d27170301f1f03c6a9a0d1d8 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Sat, 11 Nov 2023 06:22:25 -0800 Subject: [PATCH 18/81] Remove force try calls in favor of XCTUnwrap --- Tests/ScriptMessageTests.swift | 1 + Tests/SessionTests.swift | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Tests/ScriptMessageTests.swift b/Tests/ScriptMessageTests.swift index fd5966a..8130fbe 100644 --- a/Tests/ScriptMessageTests.swift +++ b/Tests/ScriptMessageTests.swift @@ -11,6 +11,7 @@ class ScriptMessageTests: XCTestCase { XCTAssertEqual(message.name, .pageLoaded) XCTAssertEqual(message.identifier, "123") XCTAssertEqual(message.restorationIdentifier, "abc") + let options = try XCTUnwrap(message.options) XCTAssertEqual(options.action, .advance) XCTAssertEqual(message.location, URL(string: "http://turbo.test")!) diff --git a/Tests/SessionTests.swift b/Tests/SessionTests.swift index c163a99..bd516f0 100644 --- a/Tests/SessionTests.swift +++ b/Tests/SessionTests.swift @@ -75,7 +75,7 @@ class SessionTests: XCTestCase { XCTAssertTrue(sessionDelegate.sessionDidLoadWebViewCalled) let result = try await session.webView.evaluateJavaScript("Turbo.navigator.adapter == window.turboNative") - XCTAssertTrue(result as! Bool) + XCTAssertTrue(try XCTUnwrap(result as? Bool)) } func test_coldBootVisit_whenVisitFailsFromHTTPError_callsSessionDidFailRequestDelegateMethod() async { @@ -116,7 +116,7 @@ class SessionTests: XCTestCase { XCTAssertTrue(sessionDelegate.sessionDidLoadWebViewCalled) let result = try await session.webView.evaluateJavaScript("Turbolinks.controller.adapter === window.turboNative") - XCTAssertTrue(result as! Bool) + XCTAssertTrue(try XCTUnwrap(result as? Bool)) } func test_coldBootVisit_Turbolinks5_3Compatibility_loadsThePageAndSetsTheAdapter() async throws { @@ -125,7 +125,7 @@ class SessionTests: XCTestCase { XCTAssertTrue(sessionDelegate.sessionDidLoadWebViewCalled) let result = try await session.webView.evaluateJavaScript("Turbolinks.controller.adapter === window.turboNative") - XCTAssertTrue(result as! Bool) + XCTAssertTrue(try XCTUnwrap(result as? Bool)) } // MARK: - Server From 8e21ce67edb238d76836c47c971733f602c5596b Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Sat, 11 Nov 2023 06:28:44 -0800 Subject: [PATCH 19/81] Move Embassy server outside of class-level --- Tests/SessionTests.swift | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/Tests/SessionTests.swift b/Tests/SessionTests.swift index bd516f0..4c42767 100644 --- a/Tests/SessionTests.swift +++ b/Tests/SessionTests.swift @@ -7,34 +7,30 @@ private let defaultTimeout: TimeInterval = 10000 private let turboTimeout: TimeInterval = 30 class SessionTests: XCTestCase { - private static let eventLoop = try! SelectorEventLoop(selector: try! KqueueSelector()) - private static let server = DefaultHTTPServer.turboServer(eventLoop: eventLoop) - private let sessionDelegate = TestSessionDelegate() private var session: Session! + private var eventLoop: SelectorEventLoop! + private var server: DefaultHTTPServer! - override class func setUp() { - super.setUp() - try! server.start() - DispatchQueue.global().async { eventLoop.runForever() } - } - - override class func tearDown() { - super.tearDown() - server.stopAndWait() - eventLoop.stop() - } - - override func setUp() { + @MainActor + override func setUp() async throws { let configuration = WKWebViewConfiguration() configuration.applicationNameForUserAgent = "Turbo iOS Test/1.0" session = Session(webViewConfiguration: configuration) session.delegate = sessionDelegate + + eventLoop = try SelectorEventLoop(selector: KqueueSelector()) + server = DefaultHTTPServer.turboServer(eventLoop: eventLoop) + try! server.start() + DispatchQueue.global().async { self.eventLoop.runForever() } } override func tearDown() { session.webView.configuration.userContentController.removeScriptMessageHandler(forName: "turbo") + + server.stopAndWait() + eventLoop.stop() } func test_init_initializesWebViewWithConfiguration() { From 0ccafeaac89be7dcb9ce1a7019acb76c8b3eccb6 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Sat, 11 Nov 2023 06:36:37 -0800 Subject: [PATCH 20/81] Remove empty test file --- Tests/JavaScriptVisitTests.swift | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 Tests/JavaScriptVisitTests.swift diff --git a/Tests/JavaScriptVisitTests.swift b/Tests/JavaScriptVisitTests.swift deleted file mode 100644 index 581f9e8..0000000 --- a/Tests/JavaScriptVisitTests.swift +++ /dev/null @@ -1,3 +0,0 @@ -import XCTest - -class JavaScriptVisitTests: XCTestCase {} From f383c49120518ae0f926119135cbfb731b0137cb Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Sat, 11 Nov 2023 08:50:42 -0800 Subject: [PATCH 21/81] Upstream Turbo Navigator --- .../xcshareddata/xcschemes/Turbo.xcscheme | 2 +- Demo/AppDelegate.swift | 6 +- Demo/Base.lproj/Main.storyboard | 24 -- Demo/Demo.swift | 2 +- Demo/Demo.xcodeproj/project.pbxproj | 41 +-- .../xcshareddata/swiftpm/Package.resolved | 44 +-- .../xcshareddata/xcschemes/Demo.xcscheme | 2 +- Demo/ErrorPresenter.swift | 112 ------ Demo/Info.plist | 4 - .../TurboNavigationController.swift | 113 ------ Demo/Navigator.swift | 10 + Demo/NumbersViewController.swift | 22 +- Demo/SceneController.swift | 162 +++------ Demo/Strada/FormComponent.swift | 1 - Demo/Strada/MenuComponent.swift | 4 +- Demo/Strada/OverflowMenuComponent.swift | 1 - Demo/TurboWebViewController.swift | 21 +- Demo/Web/WKWebViewConfiguration+App.swift | 6 +- Demo/path-configuration.json | 12 +- .../UINavigationControllerExtension.swift | 8 + .../Extensions/VisitProposalExtension.swift | 45 +++ .../VisitableViewControllerExtension.swift | 3 + .../Helpers/ErrorPresenter.swift | 82 +++++ .../Turbo Navigator/Helpers/Navigation.swift | 16 + .../PathConfigurationIdentifiable.swift | 18 + .../Helpers/ProposalResult.swift | 13 + .../Turbo Navigator/Helpers/TurboConfig.swift | 37 ++ .../TurboNavigationHierarchyController.swift | 202 +++++++++++ ...avigationHierarchyControllerDelegate.swift | 10 + Source/Turbo Navigator/TurboNavigator.swift | 160 +++++++++ .../TurboNavigatorDelegate.swift | 39 +++ .../Turbo Navigator/TurboWKUIDelegate.swift | 33 ++ .../TestableNavigationController.swift | 41 +++ .../TurboNavigationDelegateTests.swift | 43 +++ .../Turbo Navigator/TurboNavigatorTests.swift | 325 ++++++++++++++++++ 35 files changed, 1201 insertions(+), 463 deletions(-) delete mode 100644 Demo/Base.lproj/Main.storyboard delete mode 100644 Demo/ErrorPresenter.swift delete mode 100644 Demo/Navigation/TurboNavigationController.swift create mode 100644 Demo/Navigator.swift create mode 100644 Source/Turbo Navigator/Extensions/UINavigationControllerExtension.swift create mode 100644 Source/Turbo Navigator/Extensions/VisitProposalExtension.swift create mode 100644 Source/Turbo Navigator/Extensions/VisitableViewControllerExtension.swift create mode 100644 Source/Turbo Navigator/Helpers/ErrorPresenter.swift create mode 100644 Source/Turbo Navigator/Helpers/Navigation.swift create mode 100644 Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift create mode 100644 Source/Turbo Navigator/Helpers/ProposalResult.swift create mode 100644 Source/Turbo Navigator/Helpers/TurboConfig.swift create mode 100644 Source/Turbo Navigator/TurboNavigationHierarchyController.swift create mode 100644 Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift create mode 100644 Source/Turbo Navigator/TurboNavigator.swift create mode 100644 Source/Turbo Navigator/TurboNavigatorDelegate.swift create mode 100644 Source/Turbo Navigator/TurboWKUIDelegate.swift create mode 100644 Tests/Turbo Navigator/TestableNavigationController.swift create mode 100644 Tests/Turbo Navigator/TurboNavigationDelegateTests.swift create mode 100644 Tests/Turbo Navigator/TurboNavigatorTests.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Turbo.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Turbo.xcscheme index 3bbbb80..d81c404 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Turbo.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Turbo.xcscheme @@ -1,6 +1,6 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Demo/Demo.swift b/Demo/Demo.swift index 33b223c..a8d2caa 100644 --- a/Demo/Demo.swift +++ b/Demo/Demo.swift @@ -3,7 +3,7 @@ import Foundation struct Demo { static let basic = URL(string: "https://turbo-native-demo.glitch.me")! static let turbolinks5 = URL(string: "https://turbo-native-demo.glitch.me?turbolinks=1")! - + static let local = URL(string: "http://localhost:45678")! static let turbolinks5Local = URL(string: "http://localhost:45678?turbolinks=1")! diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 62ffeba..b2ed132 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -3,21 +3,19 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ + 846E252B2AFFEDCA00B93F7E /* Navigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846E252A2AFFEDCA00B93F7E /* Navigator.swift */; }; 84ACD7322AAE743300234C57 /* Turbo in Frameworks */ = {isa = PBXBuildFile; productRef = 84ACD7312AAE743300234C57 /* Turbo */; }; - C106CBE3257FF87700498F6F /* ErrorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C106CBE2257FF87700498F6F /* ErrorPresenter.swift */; }; C10DF228257AB81D009412E7 /* path-configuration.json in Resources */ = {isa = PBXBuildFile; fileRef = C10DF227257AB81D009412E7 /* path-configuration.json */; }; C153F0082578057900926D30 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C153F0072578057900926D30 /* AppDelegate.swift */; }; C153F00A2578057900926D30 /* SceneController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C153F0092578057900926D30 /* SceneController.swift */; }; - C153F00F2578057900926D30 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C153F00D2578057900926D30 /* Main.storyboard */; }; C153F0112578057A00926D30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C153F0102578057A00926D30 /* Assets.xcassets */; }; C153F0142578057A00926D30 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C153F0122578057A00926D30 /* LaunchScreen.storyboard */; }; C153F03525784BEA00926D30 /* NumbersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C153F03425784BEA00926D30 /* NumbersViewController.swift */; }; C175FE782579905300C8DF50 /* Demo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C175FE772579905300C8DF50 /* Demo.swift */; }; - CB4FB651273AE23B00119FD3 /* TurboNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB4FB650273AE23B00119FD3 /* TurboNavigationController.swift */; }; E226F7822AB1B7F20059D594 /* Strada in Frameworks */ = {isa = PBXBuildFile; productRef = E226F7812AB1B7F20059D594 /* Strada */; }; E226F7842AB1BBF30059D594 /* TurboWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E226F7832AB1BBF30059D594 /* TurboWebViewController.swift */; }; E226F7872AB1BE030059D594 /* WKWebViewConfiguration+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = E226F7862AB1BE030059D594 /* WKWebViewConfiguration+App.swift */; }; @@ -41,20 +39,18 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 846E252A2AFFEDCA00B93F7E /* Navigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Navigator.swift; sourceTree = ""; }; 84ACD72F2AAE733C00234C57 /* turbo-ios */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "turbo-ios"; path = ..; sourceTree = ""; }; - C106CBE2257FF87700498F6F /* ErrorPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorPresenter.swift; sourceTree = ""; }; C10DF227257AB81D009412E7 /* path-configuration.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "path-configuration.json"; sourceTree = ""; }; C153F0042578057900926D30 /* Turbo Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Turbo Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; C153F0072578057900926D30 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; C153F0092578057900926D30 /* SceneController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneController.swift; sourceTree = ""; }; - C153F00E2578057900926D30 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; C153F0102578057A00926D30 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C153F0132578057A00926D30 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; C153F0152578057A00926D30 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C153F0332578302F00926D30 /* Turbo Demo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Turbo Demo.entitlements"; sourceTree = ""; }; C153F03425784BEA00926D30 /* NumbersViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumbersViewController.swift; sourceTree = ""; }; C175FE772579905300C8DF50 /* Demo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Demo.swift; sourceTree = ""; }; - CB4FB650273AE23B00119FD3 /* TurboNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TurboNavigationController.swift; sourceTree = ""; }; E226F7832AB1BBF30059D594 /* TurboWebViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TurboWebViewController.swift; sourceTree = ""; }; E226F7862AB1BE030059D594 /* WKWebViewConfiguration+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WKWebViewConfiguration+App.swift"; sourceTree = ""; }; E226F7892AB1BF880059D594 /* FormComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormComponent.swift; sourceTree = ""; }; @@ -114,15 +110,13 @@ children = ( E226F7882AB1BF210059D594 /* Strada */, E226F7852AB1BDF10059D594 /* Web */, - CB4FB64F273AE22800119FD3 /* Navigation */, C175FE772579905300C8DF50 /* Demo.swift */, C153F0072578057900926D30 /* AppDelegate.swift */, + 846E252A2AFFEDCA00B93F7E /* Navigator.swift */, C153F0092578057900926D30 /* SceneController.swift */, E226F7832AB1BBF30059D594 /* TurboWebViewController.swift */, C153F03425784BEA00926D30 /* NumbersViewController.swift */, - C106CBE2257FF87700498F6F /* ErrorPresenter.swift */, C153F0102578057A00926D30 /* Assets.xcassets */, - C153F00D2578057900926D30 /* Main.storyboard */, C153F0122578057A00926D30 /* LaunchScreen.storyboard */, C153F0152578057A00926D30 /* Info.plist */, C153F0332578302F00926D30 /* Turbo Demo.entitlements */, @@ -131,14 +125,6 @@ name = Demo; sourceTree = ""; }; - CB4FB64F273AE22800119FD3 /* Navigation */ = { - isa = PBXGroup; - children = ( - CB4FB650273AE23B00119FD3 /* TurboNavigationController.swift */, - ); - path = Navigation; - sourceTree = ""; - }; E226F7852AB1BDF10059D594 /* Web */ = { isa = PBXGroup; children = ( @@ -189,8 +175,9 @@ C153EFFC2578057900926D30 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1220; - LastUpgradeCheck = 1220; + LastUpgradeCheck = 1500; TargetAttributes = { C153F0032578057900926D30 = { CreatedOnToolsVersion = 12.2; @@ -226,7 +213,6 @@ C153F0142578057A00926D30 /* LaunchScreen.storyboard in Resources */, C10DF228257AB81D009412E7 /* path-configuration.json in Resources */, C153F0112578057A00926D30 /* Assets.xcassets in Resources */, - C153F00F2578057900926D30 /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -242,27 +228,18 @@ C153F0082578057900926D30 /* AppDelegate.swift in Sources */, E226F78A2AB1BF880059D594 /* FormComponent.swift in Sources */, E226F78C2AB1CE2E0059D594 /* BridgeComponent+App.swift in Sources */, - CB4FB651273AE23B00119FD3 /* TurboNavigationController.swift in Sources */, E226F7872AB1BE030059D594 /* WKWebViewConfiguration+App.swift in Sources */, C175FE782579905300C8DF50 /* Demo.swift in Sources */, C153F00A2578057900926D30 /* SceneController.swift in Sources */, E226F78E2AB1D2C20059D594 /* MenuComponent.swift in Sources */, + 846E252B2AFFEDCA00B93F7E /* Navigator.swift in Sources */, E226F7902AB1D7260059D594 /* OverflowMenuComponent.swift in Sources */, - C106CBE3257FF87700498F6F /* ErrorPresenter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ - C153F00D2578057900926D30 /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - C153F00E2578057900926D30 /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; C153F0122578057A00926D30 /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -278,6 +255,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -311,6 +289,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -339,6 +318,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -372,6 +352,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; diff --git a/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4a5d77f..314ddd7 100644 --- a/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,30 +1,12 @@ { "pins" : [ { - "identity" : "cwlcatchexception", + "identity" : "embassy", "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlCatchException.git", + "location" : "https://github.com/envoy/Embassy.git", "state" : { - "revision" : "3b123999de19bf04905bc1dfdb76f817b0f2cc00", - "version" : "2.1.2" - } - }, - { - "identity" : "cwlpreconditiontesting", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", - "state" : { - "revision" : "a23ded2c91df9156628a6996ab4f347526f17b6b", - "version" : "2.1.2" - } - }, - { - "identity" : "nimble", - "kind" : "remoteSourceControl", - "location" : "https://github.com/quick/nimble", - "state" : { - "revision" : "1f3bde57bde12f5e7b07909848c071e9b73d6edc", - "version" : "10.0.0" + "revision" : "8469f2c1b334a7c1c3566e2cb2f97826c7cca898", + "version" : "4.1.6" } }, { @@ -36,15 +18,6 @@ "version" : "9.1.0" } }, - { - "identity" : "quick", - "kind" : "remoteSourceControl", - "location" : "https://github.com/quick/quick", - "state" : { - "revision" : "f9d519828bb03dfc8125467d8f7b93131951124c", - "version" : "5.0.1" - } - }, { "identity" : "strada-ios", "kind" : "remoteSourceControl", @@ -53,15 +26,6 @@ "branch" : "main", "revision" : "3f8e6a0a07d2361bb3a64a6e6a945124eed20ccf" } - }, - { - "identity" : "swifter", - "kind" : "remoteSourceControl", - "location" : "https://github.com/httpswift/swifter.git", - "state" : { - "revision" : "9483a5d459b45c3ffd059f7b55f9638e268632fd", - "version" : "1.5.0" - } } ], "version" : 2 diff --git a/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme b/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme index 2d56ba5..6053b81 100644 --- a/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme +++ b/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme @@ -1,6 +1,6 @@ Void - - func presentError(_ error: Error, handler: @escaping Handler) -} - -extension ErrorPresenter { - func presentError(_ error: Error, handler: @escaping Handler) { - let errorViewController = ErrorViewController() - errorViewController.configure(with: error) { [unowned self] in - self.removeErrorViewController(errorViewController) - handler() - } - - let errorView = errorViewController.view! - errorView.translatesAutoresizingMaskIntoConstraints = false - - addChild(errorViewController) - view.addSubview(errorView) - NSLayoutConstraint.activate([ - errorView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - errorView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - errorView.topAnchor.constraint(equalTo: view.topAnchor), - errorView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - errorViewController.didMove(toParent: self) - } - - private func removeErrorViewController(_ errorViewController: UIViewController) { - errorViewController.willMove(toParent: nil) - errorViewController.view.removeFromSuperview() - errorViewController.removeFromParent() - } -} - -final class ErrorViewController: UIViewController { - var handler: ErrorPresenter.Handler? - - override func viewDidLoad() { - super.viewDidLoad() - setup() - } - - private func setup() { - view.backgroundColor = .systemBackground - - let vStack = UIStackView(arrangedSubviews: [imageView, titleLabel, bodyLabel, button]) - vStack.translatesAutoresizingMaskIntoConstraints = false - vStack.axis = .vertical - vStack.spacing = 16 - vStack.alignment = .center - - view.addSubview(vStack) - NSLayoutConstraint.activate([ - vStack.centerYAnchor.constraint(equalTo: view.centerYAnchor), - vStack.centerXAnchor.constraint(equalTo: view.centerXAnchor), - vStack.leadingAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 32), - vStack.trailingAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -32), - ]) - } - - func configure(with error: Error, handler: @escaping ErrorPresenter.Handler) { - titleLabel.text = "Error loading page" - bodyLabel.text = error.localizedDescription - self.handler = handler - } - - @objc func performAction(_ sender: UIButton) { - handler?() - } - - // MARK: - Views - - private let imageView: UIImageView = { - let configuration = UIImage.SymbolConfiguration(pointSize: 38, weight: .semibold) - let image = UIImage(systemName: "exclamationmark.triangle", withConfiguration: configuration) - let imageView = UIImageView(image: image) - imageView.translatesAutoresizingMaskIntoConstraints = false - - return imageView - }() - - private let titleLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = UIFont.preferredFont(forTextStyle: .largeTitle) - label.textAlignment = .center - - return label - }() - - private let bodyLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = UIFont.preferredFont(forTextStyle: .body) - label.textAlignment = .center - label.numberOfLines = 0 - - return label - }() - - private lazy var button: UIButton = { - let button = UIButton(type: .system) - button.setTitle("Retry", for: .normal) - button.addTarget(self, action: #selector(performAction(_:)), for: .touchUpInside) - button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 17) - - return button - }() -} diff --git a/Demo/Info.plist b/Demo/Info.plist index 21f2301..69c979b 100644 --- a/Demo/Info.plist +++ b/Demo/Info.plist @@ -38,8 +38,6 @@ Default Configuration UISceneDelegateClassName $(PRODUCT_MODULE_NAME).SceneController - UISceneStoryboardFile - Main @@ -48,8 +46,6 @@ UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main UIRequiredDeviceCapabilities armv7 diff --git a/Demo/Navigation/TurboNavigationController.swift b/Demo/Navigation/TurboNavigationController.swift deleted file mode 100644 index 7e50bc1..0000000 --- a/Demo/Navigation/TurboNavigationController.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// TurboNavigationController.swift -// Demo -// -// Created by Fernando Olivares on 08/11/21. -// - -import Foundation -import UIKit -import Turbo -import Strada - -class TurboNavigationController : UINavigationController { - - var session: Session! - var modalSession: Session! - - func push(url: URL) { - let properties = session.pathConfiguration?.properties(for: url) ?? [:] - route(url: url, - options: VisitOptions(action: .advance), - properties: properties) - } - - func route(url: URL, options: VisitOptions, properties: PathProperties) { - // This is a simplified version of how you might build out the routing - // and navigation functions of your app. In a real app, these would be separate objects - - // Dismiss any modals when receiving a new navigation - if presentedViewController != nil { - dismiss(animated: true) - } - - // Special case of navigating home, issue a reload - if url.path == "/", !viewControllers.isEmpty { - popViewController(animated: false) - session.reload() - return - } - - // - Create view controller appropriate for url/properties - // - Navigate to that with the correct presentation - // - Initiate the visit with Turbo - let viewController = makeViewController(for: url, properties: properties) - navigate(to: viewController, action: options.action, properties: properties) - visit(viewController: viewController, with: options, modal: isModal(properties)) - } -} - -extension TurboNavigationController { - - private func isModal(_ properties: PathProperties) -> Bool { - // For simplicity, we're using string literals for various keys and values of the path configuration - // but most likely you'll want to define your own enums these properties - let presentation = properties["presentation"] as? String - return presentation == "modal" - } - - private func makeViewController(for url: URL, properties: PathProperties = [:]) -> UIViewController { - // There are many options for determining how to map urls to view controllers - // The demo uses the path configuration for determining which view controller and presentation - // to use, but that's completely optional. You can use whatever logic you prefer to determine - // how you navigate and route different URLs. - - if let viewController = properties["view-controller"] as? String { - switch viewController { - case "numbers": - let numbersVC = NumbersViewController() - numbersVC.url = url - return numbersVC - case "numbersDetail": - let alertController = UIAlertController(title: "Number", message: "\(url.lastPathComponent)", preferredStyle: .alert) - alertController.addAction(.init(title: "OK", style: .default, handler: nil)) - return alertController - default: - assertionFailure("Invalid view controller, defaulting to WebView") - } - } - - return TurboWebViewController(url: url) - } - - private func navigate(to viewController: UIViewController, action: VisitAction, properties: PathProperties = [:], animated: Bool = true) { - // We support three types of navigation in the app: advance, replace, and modal - - if isModal(properties) { - if viewController is UIAlertController { - present(viewController, animated: animated, completion: nil) - } else { - let modalNavController = UINavigationController(rootViewController: viewController) - present(modalNavController, animated: animated) - } - } else if action == .replace { - let viewControllers = Array(viewControllers.dropLast()) + [viewController] - setViewControllers(viewControllers, animated: false) - } else { - pushViewController(viewController, animated: animated) - } - } - - private func visit(viewController: UIViewController, with options: VisitOptions, modal: Bool = false) { - guard let visitable = viewController as? Visitable else { return } - // Each Session corresponds to a single web view. A good rule of thumb - // is to use a session per navigation stack. Here we're using a different session - // when presenting a modal. We keep that around for any modal presentations so - // we don't have to create more than we need since each new session incurs a cold boot visit cost - if modal { - modalSession.visit(visitable, options: options) - } else { - session.visit(visitable, options: options) - } - } -} diff --git a/Demo/Navigator.swift b/Demo/Navigator.swift new file mode 100644 index 0000000..a3a45ba --- /dev/null +++ b/Demo/Navigator.swift @@ -0,0 +1,10 @@ +import Foundation +import Turbo + +/// A bridge "back" to Turbo world from native. +/// See `NumbersViewController` for an example of navigating from native to web. +protocol Navigator: AnyObject { + func route(_: URL) +} + +extension TurboNavigator: Navigator {} diff --git a/Demo/NumbersViewController.swift b/Demo/NumbersViewController.swift index d40af7d..4a637ee 100644 --- a/Demo/NumbersViewController.swift +++ b/Demo/NumbersViewController.swift @@ -3,16 +3,22 @@ import UIKit /// A simple native table view controller to demonstrate loading non-Turbo screens /// for a visit proposal final class NumbersViewController: UITableViewController { - - var url: URL! - + convenience init(url: URL, navigator: Navigator) { + self.init(nibName: nil, bundle: nil) + self.url = url + self.navigator = navigator + } + + private var url: URL! + private unowned var navigator: Navigator? + override func viewDidLoad() { super.viewDidLoad() - + title = "Numbers" tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") } - + override func numberOfSections(in tableView: UITableView) -> Int { 1 } @@ -29,10 +35,10 @@ final class NumbersViewController: UITableViewController { return cell } - + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let turboNavController = navigationController as! TurboNavigationController - turboNavController.push(url: url.appendingPathComponent("\(indexPath.row + 1)")) + let detailURL = url.appendingPathComponent("\(indexPath.row + 1)") + navigator?.route(detailURL) tableView.deselectRow(at: indexPath, animated: true) } } diff --git a/Demo/SceneController.swift b/Demo/SceneController.swift index 711b541..9c315b4 100644 --- a/Demo/SceneController.swift +++ b/Demo/SceneController.swift @@ -1,69 +1,53 @@ -import UIKit -import WebKit import SafariServices -import Turbo import Strada +import Turbo +import UIKit +import WebKit final class SceneController: UIResponder { - private static var sharedProcessPool = WKProcessPool() - var window: UIWindow? + private let rootURL = Demo.current - private var navigationController: TurboNavigationController! - + private lazy var navigator = TurboNavigator(pathConfiguration: pathConfiguration, delegate: self) + // MARK: - Setup - + + private func configureStrada() { + TurboConfig.shared.userAgent += + " \(Strada.userAgentSubstring(for: BridgeComponent.allTypes))" + + TurboConfig.shared.makeCustomWebView = { config in + config.defaultWebpagePreferences?.preferredContentMode = .mobile + + let webView = WKWebView(frame: .zero, configuration: .appConfiguration) + if #available(iOS 16.4, *) { + webView.isInspectable = true + } + // Initialize Strada bridge. + Bridge.initialize(webView) + + return webView + } + } + private func configureRootViewController() { guard let window = window else { fatalError() } - - window.tintColor = UIColor(named: "Tint") - - let turboNavController: TurboNavigationController - if let navController = window.rootViewController as? TurboNavigationController { - turboNavController = navController - navigationController = navController - } else { - turboNavController = TurboNavigationController() - window.rootViewController = turboNavController - } - - turboNavController.session = session - turboNavController.modalSession = modalSession + + window.tintColor = .tint + window.rootViewController = navigator.rootViewController } - + // MARK: - Authentication - + private func promptForAuthentication() { let authURL = rootURL.appendingPathComponent("/signin") - let properties = pathConfiguration.properties(for: authURL) - navigationController.route(url: authURL, options: VisitOptions(), properties: properties) + navigator.route(authURL) } - - // MARK: - Sessions - - private lazy var session = makeSession() - private lazy var modalSession = makeSession() - - private func makeSession() -> Session { - let webView = WKWebView(frame: .zero, - configuration: .appConfiguration) - if #available(iOS 16.4, *) { - webView.isInspectable = true - } - - // Initialize Strada bridge. - Bridge.initialize(webView) - - let session = Session(webView: webView) - session.delegate = self - session.pathConfiguration = pathConfiguration - return session - } - + // MARK: - Path Configuration - + private lazy var pathConfiguration = PathConfiguration(sources: [ .file(Bundle.main.url(forResource: "path-configuration", withExtension: "json")!), ]) @@ -71,73 +55,41 @@ final class SceneController: UIResponder { extension SceneController: UIWindowSceneDelegate { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - guard let _ = scene as? UIWindowScene else { return } - + guard let windowScene = scene as? UIWindowScene else { return } + + window = UIWindow(windowScene: windowScene) + window?.makeKeyAndVisible() + + configureStrada() configureRootViewController() - navigationController.route(url: rootURL, options: VisitOptions(action: .replace), properties: [:]) + + navigator.route(rootURL) } } -extension SceneController: SessionDelegate { - func session(_ session: Session, didProposeVisit proposal: VisitProposal) { - navigationController.route(url: proposal.url, options: proposal.options, properties: proposal.properties) +extension SceneController: TurboNavigatorDelegate { + func handle(proposal: VisitProposal) -> ProposalResult { + switch proposal.viewController { + case "numbers": + return .acceptCustom(NumbersViewController(url: proposal.url, navigator: navigator)) + case "numbersDetail": + let alertController = UIAlertController(title: "Number", message: "\(proposal.url.lastPathComponent)", preferredStyle: .alert) + alertController.addAction(.init(title: "OK", style: .default, handler: nil)) + return .acceptCustom(alertController) + default: + return .acceptCustom(TurboWebViewController(url: proposal.url)) + } } - - func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) { + + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) { if let turboError = error as? TurboError, case let .http(statusCode) = turboError, statusCode == 401 { promptForAuthentication() } else if let errorPresenter = visitable as? ErrorPresenter { - errorPresenter.presentError(error) { [weak self] in - self?.session.reload() - } + errorPresenter.presentError(error, handler: retry) } else { let alert = UIAlertController(title: "Visit failed!", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) - navigationController.present(alert, animated: true) - } - } - - // When a form submission completes in the modal session, we need to - // manually clear the snapshot cache in the default session, since we - // don't want potentially stale cached snapshots to be used - func sessionDidFinishFormSubmission(_ session: Session) { - if (session == modalSession) { - self.session.clearSnapshotCache() - } - } - - func sessionDidLoadWebView(_ session: Session) { - session.webView.navigationDelegate = self - } - - func sessionWebViewProcessDidTerminate(_ session: Session) { - session.reload() - } -} - -extension SceneController: WKNavigationDelegate { - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - if navigationAction.navigationType == .linkActivated { - // Any link that's not on the same domain as the Turbo root url will go through here - // Other links on the domain, but that have an extension that is non-html will also go here - // You can decide how to handle those, by default if you're not the navigationDelegate - // the Session will open them in the default browser - - let url = navigationAction.request.url! - - // For this demo, we'll load files from our domain in a SafariViewController so you - // don't need to leave the app. You might expand this in your app - // to open all audio/video/images in a native media viewer - if url.host == rootURL.host, !url.pathExtension.isEmpty { - let safariViewController = SFSafariViewController(url: url) - navigationController.present(safariViewController, animated: true) - } else { - UIApplication.shared.open(url) - } - - decisionHandler(.cancel) - } else { - decisionHandler(.allow) + navigator.activeNavigationController.present(alert, animated: true) } } } diff --git a/Demo/Strada/FormComponent.swift b/Demo/Strada/FormComponent.swift index ebc8201..183930a 100644 --- a/Demo/Strada/FormComponent.swift +++ b/Demo/Strada/FormComponent.swift @@ -76,4 +76,3 @@ private extension FormComponent { let submitTitle: String } } - diff --git a/Demo/Strada/MenuComponent.swift b/Demo/Strada/MenuComponent.swift index 32efbff..1a83421 100644 --- a/Demo/Strada/MenuComponent.swift +++ b/Demo/Strada/MenuComponent.swift @@ -35,7 +35,7 @@ final class MenuComponent: BridgeComponent { preferredStyle: .actionSheet) for item in items { - let action = UIAlertAction(title: item.title, style: .default) {[weak self] _ in + let action = UIAlertAction(title: item.title, style: .default) { [weak self] _ in self?.onItemSelected(item: item) } alertController.addAction(action) @@ -75,6 +75,6 @@ private extension MenuComponent { } struct SelectionMessageData: Encodable { - let selectedIndex:Int + let selectedIndex: Int } } diff --git a/Demo/Strada/OverflowMenuComponent.swift b/Demo/Strada/OverflowMenuComponent.swift index 1de844f..509119e 100644 --- a/Demo/Strada/OverflowMenuComponent.swift +++ b/Demo/Strada/OverflowMenuComponent.swift @@ -40,7 +40,6 @@ final class OverflowMenuComponent: BridgeComponent { image: .init(systemName: "ellipsis.circle"), primaryAction: action) - viewController.navigationItem.rightBarButtonItem = item } diff --git a/Demo/TurboWebViewController.swift b/Demo/TurboWebViewController.swift index 3322948..0da4d9f 100644 --- a/Demo/TurboWebViewController.swift +++ b/Demo/TurboWebViewController.swift @@ -1,18 +1,15 @@ -import UIKit -import Turbo import Strada +import Turbo +import UIKit import WebKit -final class TurboWebViewController: VisitableViewController, - ErrorPresenter, - BridgeDestination { - - private lazy var bridgeDelegate: BridgeDelegate = { - BridgeDelegate(location: visitableURL.absoluteString, - destination: self, - componentTypes: BridgeComponent.allTypes) - }() - +final class TurboWebViewController: VisitableViewController, BridgeDestination { + private lazy var bridgeDelegate = BridgeDelegate( + location: visitableURL.absoluteString, + destination: self, + componentTypes: BridgeComponent.allTypes + ) + // MARK: View lifecycle override func viewDidLoad() { diff --git a/Demo/Web/WKWebViewConfiguration+App.swift b/Demo/Web/WKWebViewConfiguration+App.swift index 2206bd9..3bf2574 100644 --- a/Demo/Web/WKWebViewConfiguration+App.swift +++ b/Demo/Web/WKWebViewConfiguration+App.swift @@ -1,6 +1,6 @@ import Foundation -import WebKit import Strada +import WebKit enum WebViewPool { static var shared = WKProcessPool() @@ -10,12 +10,12 @@ extension WKWebViewConfiguration { static var appConfiguration: WKWebViewConfiguration { let stradaSubstring = Strada.userAgentSubstring(for: BridgeComponent.allTypes) let userAgent = "Turbo Native iOS \(stradaSubstring)" - + let configuration = WKWebViewConfiguration() configuration.processPool = WebViewPool.shared configuration.applicationNameForUserAgent = userAgent configuration.defaultWebpagePreferences?.preferredContentMode = .mobile - + return configuration } } diff --git a/Demo/path-configuration.json b/Demo/path-configuration.json index ffd0d9f..0325713 100644 --- a/Demo/path-configuration.json +++ b/Demo/path-configuration.json @@ -11,7 +11,7 @@ "/strada-form$" ], "properties": { - "presentation": "modal" + "context": "modal" } }, { @@ -28,7 +28,15 @@ ], "properties": { "view-controller": "numbersDetail", - "presentation": "modal" + "context": "modal" + } + }, + { + "patterns": [ + "^/$" + ], + "properties": { + "presentation": "replace_root" } }, ] diff --git a/Source/Turbo Navigator/Extensions/UINavigationControllerExtension.swift b/Source/Turbo Navigator/Extensions/UINavigationControllerExtension.swift new file mode 100644 index 0000000..6e750d0 --- /dev/null +++ b/Source/Turbo Navigator/Extensions/UINavigationControllerExtension.swift @@ -0,0 +1,8 @@ +import UIKit + +extension UINavigationController { + func replaceLastViewController(with viewController: UIViewController) { + let viewControllers = viewControllers.dropLast() + setViewControllers(viewControllers + [viewController], animated: false) + } +} diff --git a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift new file mode 100644 index 0000000..7d60527 --- /dev/null +++ b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift @@ -0,0 +1,45 @@ +public extension VisitProposal { + var context: Navigation.Context { + if let rawValue = properties["context"] as? String { + return Navigation.Context(rawValue: rawValue) ?? .default + } + return .default + } + + var presentation: Navigation.Presentation { + if let rawValue = properties["presentation"] as? String { + return Navigation.Presentation(rawValue: rawValue) ?? .default + } + return .default + } + + /// Used to identify a custom native view controller if provided in the path configuration properties of a given pattern. + /// + /// For example, given the following configuration file: + /// + /// ``` + /// { + /// "rules": [ + /// { + /// "patterns": [ + /// "/recipes/*" + /// ], + /// "properties": { + /// "view-controller": "recipes", + /// } + /// } + /// ] + /// } + /// ``` + /// + /// A VisitProposal to `https://example.com/recipes/` will have `proposal.viewController == "recipes"` + /// + /// A default value is provided in case the view controller property is missing from the configuration file. This will route the default `VisitableViewController`. + var viewController: String { + if let viewController = properties["view-controller"] as? String { + return viewController + } + + return VisitableViewController.pathConfigurationIdentifier + } +} diff --git a/Source/Turbo Navigator/Extensions/VisitableViewControllerExtension.swift b/Source/Turbo Navigator/Extensions/VisitableViewControllerExtension.swift new file mode 100644 index 0000000..f4d9d4e --- /dev/null +++ b/Source/Turbo Navigator/Extensions/VisitableViewControllerExtension.swift @@ -0,0 +1,3 @@ +extension VisitableViewController: PathConfigurationIdentifiable { + public static var pathConfigurationIdentifier: String { "web" } +} diff --git a/Source/Turbo Navigator/Helpers/ErrorPresenter.swift b/Source/Turbo Navigator/Helpers/ErrorPresenter.swift new file mode 100644 index 0000000..655571d --- /dev/null +++ b/Source/Turbo Navigator/Helpers/ErrorPresenter.swift @@ -0,0 +1,82 @@ +import SwiftUI + +public protocol ErrorPresenter: UIViewController { + typealias Handler = () -> Void + + func presentError(_ error: Error, handler: @escaping Handler) +} + +public extension ErrorPresenter { + func presentError(_ error: Error, handler: @escaping () -> Void) { + let errorView = ErrorView(error: error) { [unowned self] in + handler() + self.removeErrorViewController() + } + + let controller = UIHostingController(rootView: errorView) + addChild(controller) + addFullScreenSubview(controller.view) + controller.didMove(toParent: self) + } + + private func removeErrorViewController() { + if let child = children.first(where: { $0 is UIHostingController }) { + child.willMove(toParent: nil) + child.view.removeFromSuperview() + child.removeFromParent() + } + } +} + +extension UIViewController: ErrorPresenter {} + +// MARK: Private + +private struct ErrorView: View { + let error: Error + let handler: ErrorPresenter.Handler? + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 38, weight: .semibold)) + .foregroundColor(.accentColor) + + Text("Error loading page") + .font(.largeTitle) + + Text(error.localizedDescription) + .font(.body) + .multilineTextAlignment(.center) + + Button("Retry") { + handler?() + } + .font(.system(size: 17, weight: .bold)) + } + .padding(32) + } +} + +private struct ErrorView_Previews: PreviewProvider { + static var previews: some View { + return ErrorView(error: NSError( + domain: "com.example.error", + code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Could not connect to the server."] + )) {} + } +} + +private extension UIViewController { + func addFullScreenSubview(_ subview: UIView) { + view.addSubview(subview) + subview.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + subview.leadingAnchor.constraint(equalTo: view.leadingAnchor), + subview.trailingAnchor.constraint(equalTo: view.trailingAnchor), + subview.topAnchor.constraint(equalTo: view.topAnchor), + subview.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } +} diff --git a/Source/Turbo Navigator/Helpers/Navigation.swift b/Source/Turbo Navigator/Helpers/Navigation.swift new file mode 100644 index 0000000..9aaaf6d --- /dev/null +++ b/Source/Turbo Navigator/Helpers/Navigation.swift @@ -0,0 +1,16 @@ +public enum Navigation { + public enum Context: String { + case `default` + case modal + } + + public enum Presentation: String { + case `default` + case pop + case replace + case refresh + case clearAll = "clear_all" + case replaceRoot = "replace_root" + case none + } +} diff --git a/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift b/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift new file mode 100644 index 0000000..dd1c5b3 --- /dev/null +++ b/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift @@ -0,0 +1,18 @@ +import UIKit + +/// As a convenience, your view controller may conform to `PathConfigurationIdentifiable`. +/// You may then use the view controller's `pathConfigurationIdentifier` property instead of `proposal.url` when deciding how to handle a proposal. See `VisitProposal.viewController` on how to use this in your configuration file. +/// +/// ``` +/// func handle(proposal: VisitProposal) -> ProposalResult { +/// switch proposal.viewController { +/// case RecipeViewController.pathConfigurationIdentifier: +/// return .accept(RecipeViewController.new) +/// default: +/// return .accept +/// } +/// } +/// ``` +public protocol PathConfigurationIdentifiable: UIViewController { + static var pathConfigurationIdentifier: String { get } +} diff --git a/Source/Turbo Navigator/Helpers/ProposalResult.swift b/Source/Turbo Navigator/Helpers/ProposalResult.swift new file mode 100644 index 0000000..871f536 --- /dev/null +++ b/Source/Turbo Navigator/Helpers/ProposalResult.swift @@ -0,0 +1,13 @@ +import UIKit + +/// Return from `handle(proposal:)` to route a custom controller. +public enum ProposalResult: Equatable { + /// Route a `VisitableViewController`. + case accept + + /// Route a custom `UIViewController` or subclass + case acceptCustom(UIViewController) + + /// Do not route. Navigation is not modified. + case reject +} diff --git a/Source/Turbo Navigator/Helpers/TurboConfig.swift b/Source/Turbo Navigator/Helpers/TurboConfig.swift new file mode 100644 index 0000000..cf2122e --- /dev/null +++ b/Source/Turbo Navigator/Helpers/TurboConfig.swift @@ -0,0 +1,37 @@ +import WebKit + +public class TurboConfig { + public typealias WebViewBlock = (_ configuration: WKWebViewConfiguration) -> WKWebView + + public static let shared = TurboConfig() + + /// Override to set a custom user agent. + /// Include "Turbo Native" to use `turbo_native_app?` on your Rails server. + public var userAgent = "Turbo Native iOS" + + /// Optionally customize the web views used by each Turbo Session. + /// Ensure you return a new instance each time. + public var makeCustomWebView: WebViewBlock = { (configuration: WKWebViewConfiguration) in + WKWebView(frame: .zero, configuration: configuration) + } + + // MARK: - Internal + + public func makeWebView() -> WKWebView { + makeCustomWebView(makeWebViewConfiguration()) + } + + // MARK: - Private + + private let sharedProcessPool = WKProcessPool() + + private init() {} + + // A method (not a property) because we need a new instance for each web view. + private func makeWebViewConfiguration() -> WKWebViewConfiguration { + let configuration = WKWebViewConfiguration() + configuration.applicationNameForUserAgent = userAgent + configuration.processPool = sharedProcessPool + return configuration + } +} diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift new file mode 100644 index 0000000..590e05a --- /dev/null +++ b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift @@ -0,0 +1,202 @@ +import SafariServices +import UIKit +import WebKit + +/// Handles navigation to new URLs using the following rules: +/// https://github.com/joemasilotti/TurboNavigator#handled-flows +class TurboNavigationHierarchyController { + let navigationController: UINavigationController + let modalNavigationController: UINavigationController + + var rootViewController: UIViewController { navigationController } + var activeNavigationController: UINavigationController { + navigationController.presentedViewController != nil ? modalNavigationController : navigationController + } + + enum NavigationStackType { + case main + case modal + } + + func navController(for navigationType: NavigationStackType) -> UINavigationController { + switch navigationType { + case .main: navigationController + case .modal: modalNavigationController + } + } + + init(delegate: TurboNavigationHierarchyControllerDelegate, navigationControler: UINavigationController = UINavigationController(), modalNavigationController: UINavigationController = UINavigationController()) { + self.delegate = delegate + self.navigationController = navigationControler + self.modalNavigationController = modalNavigationController + } + + func route(controller: UIViewController, proposal: VisitProposal) { + if let alert = controller as? UIAlertController { + presentAlert(alert) + } else { + switch proposal.presentation { + case .default: + navigate(with: controller, via: proposal) + case .pop: + pop() + case .replace: + replace(with: controller, via: proposal) + case .refresh: + refresh() + case .clearAll: + clearAll() + case .replaceRoot: + replaceRoot(with: controller) + case .none: + break // Do nothing. + } + } + } + + func openExternal(url: URL, navigationType: NavigationStackType) { + if ["http", "https"].contains(url.scheme) { + let safariViewController = SFSafariViewController(url: url) + safariViewController.modalPresentationStyle = .pageSheet + if #available(iOS 15.0, *) { + safariViewController.preferredControlTintColor = .tintColor + } + let navController = navController(for: navigationType) + navController.present(safariViewController, animated: true) + } else if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } + + // MARK: Private + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private unowned let delegate: TurboNavigationHierarchyControllerDelegate + + private func presentAlert(_ alert: UIAlertController) { + if navigationController.presentedViewController != nil { + modalNavigationController.present(alert, animated: true) + } else { + navigationController.present(alert, animated: true) + } + } + + private func navigate(with controller: UIViewController, via proposal: VisitProposal) { + switch proposal.context { + case .default: + navigationController.dismiss(animated: true) + pushOrReplace(on: navigationController, with: controller, via: proposal) + if let visitable = controller as? Visitable { + delegate.visit(visitable, on: .main, with: proposal.options) + } + case .modal: + if navigationController.presentedViewController != nil { + pushOrReplace(on: modalNavigationController, with: controller, via: proposal) + } else { + modalNavigationController.setViewControllers([controller], animated: true) + navigationController.present(modalNavigationController, animated: true) + } + if let visitable = controller as? Visitable { + delegate.visit(visitable, on: .modal, with: proposal.options) + } + } + } + + private func pushOrReplace(on navigationController: UINavigationController, with controller: UIViewController, via proposal: VisitProposal) { + if visitingSamePage(on: navigationController, with: controller, via: proposal.url) { + navigationController.replaceLastViewController(with: controller) + } else if visitingPreviousPage(on: navigationController, with: controller, via: proposal.url) { + navigationController.popViewController(animated: true) + } else if proposal.options.action == .advance { + navigationController.pushViewController(controller, animated: true) + } else { + navigationController.replaceLastViewController(with: controller) + } + } + + private func visitingSamePage(on navigationController: UINavigationController, with controller: UIViewController, via url: URL) -> Bool { + if let visitable = navigationController.topViewController as? Visitable { + return visitable.visitableURL == url + } else if let topViewController = navigationController.topViewController { + return topViewController.isMember(of: type(of: controller)) + } + return false + } + + private func visitingPreviousPage(on navigationController: UINavigationController, with controller: UIViewController, via url: URL) -> Bool { + guard navigationController.viewControllers.count >= 2 else { return false } + + let previousController = navigationController.viewControllers[navigationController.viewControllers.count - 2] + if let previousVisitable = previousController as? VisitableViewController { + return previousVisitable.visitableURL == url + } + return type(of: previousController) == type(of: controller) + } + + private func pop() { + if navigationController.presentedViewController != nil { + if modalNavigationController.viewControllers.count == 1 { + navigationController.dismiss(animated: true) + } else { + modalNavigationController.popViewController(animated: true) + } + } else { + navigationController.popViewController(animated: true) + } + } + + private func replace(with controller: UIViewController, via proposal: VisitProposal) { + switch proposal.context { + case .default: + navigationController.dismiss(animated: true) + navigationController.replaceLastViewController(with: controller) + if let visitable = controller as? Visitable { + delegate.visit(visitable, on: .main, with: proposal.options) + } + case .modal: + if navigationController.presentedViewController != nil { + modalNavigationController.replaceLastViewController(with: controller) + } else { + modalNavigationController.setViewControllers([controller], animated: false) + navigationController.present(modalNavigationController, animated: true) + } + if let visitable = controller as? Visitable { + delegate.visit(visitable, on: .modal, with: proposal.options) + } + } + } + + private func refresh() { + if navigationController.presentedViewController != nil { + if modalNavigationController.viewControllers.count == 1 { + navigationController.dismiss(animated: true) + delegate.refresh(navigationStack: .main) + } else { + modalNavigationController.popViewController(animated: true) + delegate.refresh(navigationStack: .modal) + } + } else { + navigationController.popViewController(animated: true) + delegate.refresh(navigationStack: .main) + } + } + + private func clearAll() { + navigationController.dismiss(animated: true) + navigationController.popToRootViewController(animated: true) + delegate.refresh(navigationStack: .main) + } + + private func replaceRoot(with controller: UIViewController) { + navigationController.dismiss(animated: true) + navigationController.setViewControllers([controller], animated: true) + + if let visitable = controller as? Visitable { + delegate.visit(visitable, on: .main, with: .init(action: .replace)) + } + } +} diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift b/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift new file mode 100644 index 0000000..04e9c7f --- /dev/null +++ b/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift @@ -0,0 +1,10 @@ +import SafariServices +import WebKit + +/// Implement to be notified when certain navigations are performed +/// or to render a native controller instead of a Turbo web visit. +protocol TurboNavigationHierarchyControllerDelegate: AnyObject { + func visit(_ : Visitable, on: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) + + func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) +} diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift new file mode 100644 index 0000000..583c93a --- /dev/null +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -0,0 +1,160 @@ +import Foundation +import SafariServices +import UIKit +import WebKit + +class DefaultTurboNavigatorDelegate: NSObject, TurboNavigatorDelegate {} + +public class TurboNavigator { + public unowned var delegate: TurboNavigatorDelegate + + public var rootViewController: UINavigationController { hierarchyController.navigationController } + public var activeNavigationController: UINavigationController { hierarchyController.activeNavigationController } + + /// Set to handle customize behavior of the `WKUIDelegate`. + /// Subclass `TurboWKUIController` to add additional behavior alongside alert/confirm dialogs. + /// Or, provide a completely custom `WKUIDelegate` implementation. + public var webkitUIDelegate: TurboWKUIController? { + didSet { + session.webView.uiDelegate = webkitUIDelegate + modalSession.webView.uiDelegate = webkitUIDelegate + } + } + + /// Default initializer requiring preconfigured `Session` instances. + /// User `init(pathConfiguration:delegate)` to only provide a `PathConfiguration`. + /// - Parameters: + /// - session: the main `Session` + /// - modalSession: the `Session` used for the modal navigation controller + /// - delegate: an optional delegate to handle custom view controllers + public init(session: Session, modalSession: Session, delegate: TurboNavigatorDelegate? = nil) { + self.session = session + self.modalSession = modalSession + + self.delegate = delegate ?? navigatorDelegate + + self.session.delegate = self + self.modalSession.delegate = self + + self.webkitUIDelegate = TurboWKUIController(delegate: self) + session.webView.uiDelegate = webkitUIDelegate + modalSession.webView.uiDelegate = webkitUIDelegate + } + + /// Convenience initializer that doesn't require manually creating `Session` instances. + /// - Parameters: + /// - pathConfiguration: + /// - delegate: an optional delegate to handle custom view controllers + public convenience init(pathConfiguration: PathConfiguration, delegate: TurboNavigatorDelegate? = nil) { + let session = Session(webView: TurboConfig.shared.makeWebView()) + session.pathConfiguration = pathConfiguration + + let modalSession = Session(webView: TurboConfig.shared.makeWebView()) + modalSession.pathConfiguration = pathConfiguration + + self.init(session: session, modalSession: modalSession, delegate: delegate) + } + + /// Transforms `URL` -> `VisitProposal` -> `UIViewController`. + /// Given the `VisitProposal`'s properties, push or present this view controller. + /// + /// - Parameter url: the URL to visit. + public func route(_ url: URL) { + let options = VisitOptions(action: .advance, response: nil) + let properties = session.pathConfiguration?.properties(for: url) ?? PathProperties() + let proposal = VisitProposal(url: url, options: options, properties: properties) + + guard let controller = controller(for: proposal) else { return } + hierarchyController.route(controller: controller, proposal: proposal) + } + + let session: Session + let modalSession: Session + + /// Modifies a UINavigationController according to visit proposals. + lazy var hierarchyController = TurboNavigationHierarchyController(delegate: self) + + /// A default delegate implementation if none is provided. + private let navigatorDelegate = DefaultTurboNavigatorDelegate() + + private func controller(for proposal: VisitProposal) -> UIViewController? { + switch delegate.handle(proposal: proposal) { + case .accept: + return VisitableViewController(url: proposal.url) + case .acceptCustom(let customViewController): + return customViewController + case .reject: + return nil + } + } +} + +// MARK: - SessionDelegate + +extension TurboNavigator: SessionDelegate { + public func session(_ session: Session, didProposeVisit proposal: VisitProposal) { + guard let controller = controller(for: proposal) else { return } + hierarchyController.route(controller: controller, proposal: proposal) + } + + public func sessionDidFinishFormSubmission(_ session: Session) { + if session == modalSession { + self.session.clearSnapshotCache() + } + } + + public func session(_ session: Session, openExternalURL url: URL) { + let navigationType: TurboNavigationHierarchyController.NavigationStackType = session === modalSession ? .modal : .main + hierarchyController.openExternal(url: url, navigationType: navigationType) + } + + public func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) { + delegate.visitableDidFailRequest(visitable, error: error) { + session.reload() + } + } + + public func sessionWebViewProcessDidTerminate(_ session: Session) { + session.reload() + } + + public func session(_ session: Session, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + delegate.didReceiveAuthenticationChallenge(challenge, completionHandler: completionHandler) + } + + public func sessionDidFinishRequest(_ session: Session) { + guard let url = session.activeVisitable?.visitableURL else { return } + + WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in + HTTPCookieStorage.shared.setCookies(cookies, for: url, mainDocumentURL: url) + } + } + + public func sessionDidLoadWebView(_ session: Session) { + session.webView.navigationDelegate = session + } +} + +// MARK: - TurboNavigationHierarchyControllerDelegate + +extension TurboNavigator: TurboNavigationHierarchyControllerDelegate { + func visit(_ controller: Visitable, on navigationStack: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) { + switch navigationStack { + case .main: session.visit(controller, action: .advance) + case .modal: modalSession.visit(controller, action: .advance) + } + } + + func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) { + switch navigationStack { + case .main: session.reload() + case .modal: modalSession.reload() + } + } +} + +extension TurboNavigator: TurboWKUIDelegate { + public func present(_ alert: UIAlertController, animated: Bool) { + hierarchyController.activeNavigationController.present(alert, animated: animated) + } +} diff --git a/Source/Turbo Navigator/TurboNavigatorDelegate.swift b/Source/Turbo Navigator/TurboNavigatorDelegate.swift new file mode 100644 index 0000000..bfa57de --- /dev/null +++ b/Source/Turbo Navigator/TurboNavigatorDelegate.swift @@ -0,0 +1,39 @@ +import Foundation + +public protocol TurboNavigatorDelegate: AnyObject { + typealias RetryBlock = () -> Void + + /// Optional. Accept or reject a visit proposal. + /// If accepted, you may provide a view controller to be displayed, otherwise a new `VisitableViewController` is displayed. + /// If rejected, no changes to navigation occur. + /// If not implemented, proposals are accepted and a new `VisitableViewController` is displayed. + /// + /// - Parameter proposal: navigation destination + /// - Returns: how to react to the visit proposal + func handle(proposal: VisitProposal) -> ProposalResult + + /// Optional. An error occurred loading the request, present it to the user. + /// Retry the request by executing the closure. + /// If not implemented, will present the error's localized description and a Retry button. + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) + + /// Optional. Respond to authentication challenge presented by web servers behing basic auth. + /// If not implemented, default handling will be performed. + func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) +} + +public extension TurboNavigatorDelegate { + func handle(proposal: VisitProposal) -> ProposalResult { + .accept + } + + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) { + if let errorPresenter = visitable as? ErrorPresenter { + errorPresenter.presentError(error, handler: retry) + } + } + + func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + completionHandler(.performDefaultHandling, nil) + } +} diff --git a/Source/Turbo Navigator/TurboWKUIDelegate.swift b/Source/Turbo Navigator/TurboWKUIDelegate.swift new file mode 100644 index 0000000..5a2ad57 --- /dev/null +++ b/Source/Turbo Navigator/TurboWKUIDelegate.swift @@ -0,0 +1,33 @@ +import Foundation +import WebKit + +public protocol TurboWKUIDelegate: AnyObject { + func present(_ alert: UIAlertController, animated: Bool) +} + +open class TurboWKUIController: NSObject, WKUIDelegate { + private unowned var delegate: TurboWKUIDelegate + + public init(delegate: TurboWKUIDelegate!) { + self.delegate = delegate + } + + open func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) { + let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in + completionHandler() + }) + delegate.present(alert, animated: true) + } + + open func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) { + let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .destructive) { _ in + completionHandler(true) + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + completionHandler(false) + }) + delegate.present(alert, animated: true) + } +} diff --git a/Tests/Turbo Navigator/TestableNavigationController.swift b/Tests/Turbo Navigator/TestableNavigationController.swift new file mode 100644 index 0000000..e2a01d6 --- /dev/null +++ b/Tests/Turbo Navigator/TestableNavigationController.swift @@ -0,0 +1,41 @@ +import UIKit + +/// Manipulate a navigation controller under test. +/// Ensures `viewControllers` is updated synchronously. +/// Manages `presentedViewController` directly because it isn't updated on the same thread. +class TestableNavigationController: UINavigationController { + override var presentedViewController: UIViewController? { + get { _presentedViewController } + set { _presentedViewController = newValue } + } + + override func pushViewController(_ viewController: UIViewController, animated: Bool) { + super.pushViewController(viewController, animated: false) + } + + override func popViewController(animated: Bool) -> UIViewController? { + super.popViewController(animated: false) + } + + override func popToRootViewController(animated: Bool) -> [UIViewController]? { + super.popToRootViewController(animated: false) + } + + override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) { + super.setViewControllers(viewControllers, animated: false) + } + + override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + _presentedViewController = viewControllerToPresent + super.present(viewControllerToPresent, animated: false, completion: completion) + } + + override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { + _presentedViewController = nil + super.dismiss(animated: false, completion: completion) + } + + // MARK: Private + + private var _presentedViewController: UIViewController? +} diff --git a/Tests/Turbo Navigator/TurboNavigationDelegateTests.swift b/Tests/Turbo Navigator/TurboNavigationDelegateTests.swift new file mode 100644 index 0000000..25d3e80 --- /dev/null +++ b/Tests/Turbo Navigator/TurboNavigationDelegateTests.swift @@ -0,0 +1,43 @@ +import SafariServices +@testable import Turbo +import XCTest + +final class TurboNavigationDelegateTests: TurboNavigator { +// func test_controllerForProposal_defaultsToVisitableViewController() throws { +// let url = URL(string: "https://example.com")! +// +// let result = delegate.handle(proposal: VisitProposal(url: url)) +// +// XCTAssertEqual(result, .accept) +// } +// +// func test_openExternalURL_presentsSafariViewController() throws { +// let url = URL(string: "https://example.com")! +// let controller = TestableNavigationController() +// +// delegate.openExternalURL(url, from: controller) +// +// XCTAssert(controller.presentedViewController is SFSafariViewController) +// XCTAssertEqual(controller.modalPresentationStyle, .pageSheet) +// } + + // MARK: Private + +// private let delegate = DefaultDelegate() +} + +// MARK: - DefaultDelegate + +private class DefaultDelegate { + func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {} +} + +// MARK: - VisitProposal extension + +private extension VisitProposal { + init(url: URL) { + let url = url + let options = VisitOptions(action: .advance, response: nil) + self.init(url: url, options: options) + } +} diff --git a/Tests/Turbo Navigator/TurboNavigatorTests.swift b/Tests/Turbo Navigator/TurboNavigatorTests.swift new file mode 100644 index 0000000..7669fce --- /dev/null +++ b/Tests/Turbo Navigator/TurboNavigatorTests.swift @@ -0,0 +1,325 @@ +@testable import Turbo +import XCTest + +/// Tests are written in the following format: +/// `test_currentContext_givenContext_givenPresentation_modifiers_result()` +/// See the README for a more visually pleasing table. +final class TurboNavigationHierarchyControllerTests: XCTestCase { + private lazy var oneURL = baseURL.appendingPathComponent("/one") + private lazy var twoURL = baseURL.appendingPathComponent("/two") + + override func setUp() { + navigationController = TestableNavigationController() + modalNavigationController = TestableNavigationController() + + navigator = TurboNavigator(session: session, modalSession: modalSession) + hierarchyController = TurboNavigationHierarchyController(delegate: navigator) + navigator.hierarchyController = hierarchyController + + pushInitialViewControllersOnNavigationController() + loadNavigationControllerInWindow() + } + + func test_default_default_default_pushesOnMainStack() { + navigator.route(oneURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) + + navigator.route(twoURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 2) + XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) + assertVisited(url: twoURL, on: .main) + } + + func test_default_default_default_visitingSamePage_replacesOnMainStack() { + navigator.route(oneURL) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + navigator.route(oneURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) + assertVisited(url: oneURL, on: .main) + } + + func test_default_default_default_visitingPreviousPage_popsAndVisitsOnMainStack() { + navigator.route(oneURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + + navigator.route(twoURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 2) + + navigator.route(oneURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) + assertVisited(url: oneURL, on: .main) + } + + func test_default_default_default_replaceAction_replacesOnMainStack() { +// let proposal = VisitProposal(action: .replace) +// navigator.route(proposal) +// +// XCTAssertEqual(navigationController.viewControllers.count, 1) +// XCTAssert(navigationController.viewControllers.last is VisitableViewController) +// assertVisited(url: proposal.url, on: .main) + } + + func test_default_default_replace_replacesOnMainStack() { +// navigationController.pushViewController(UIViewController(), animated: false) +// XCTAssertEqual(navigationController.viewControllers.count, 2) +// +// let proposal = VisitProposal(presentation: .replace) +// navigator.route(proposal) +// +// XCTAssertEqual(navigationController.viewControllers.count, 2) +// XCTAssert(navigationController.viewControllers.last is VisitableViewController) +// assertVisited(url: proposal.url, on: .main) + } + + func test_default_modal_default_presentsModal() { +// let proposal = VisitProposal(context: .modal) +// navigator.route(proposal) +// +// XCTAssertEqual(navigationController.viewControllers.count, 1) +// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) +// XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) +// XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) +// assertVisited(url: proposal.url, on: .modal) + } + + func test_default_modal_replace_presentsModal() { +// let proposal = VisitProposal(context: .modal, presentation: .replace) +// navigator.route(proposal) +// +// XCTAssertEqual(navigationController.viewControllers.count, 1) +// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) +// XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) +// XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) +// assertVisited(url: proposal.url, on: .modal) + } + + func test_modal_default_default_dismissesModalThenPushesOnMainStack() { +// navigator.route(VisitProposal(context: .modal)) +// XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) +// +// let proposal = VisitProposal() +// navigator.route(proposal) +// XCTAssertNil(navigationController.presentedViewController) +// XCTAssert(navigationController.viewControllers.last is VisitableViewController) +// XCTAssertEqual(navigationController.viewControllers.count, 2) +// assertVisited(url: proposal.url, on: .main) + } + + func test_modal_default_replace_dismissesModalThenReplacedOnMainStack() { +// navigator.route(VisitProposal(context: .modal)) +// XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) +// +// let proposal = VisitProposal(presentation: .replace) +// navigator.route(proposal) +// XCTAssertNil(navigationController.presentedViewController) +// XCTAssertEqual(navigationController.viewControllers.count, 1) +// XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) +// assertVisited(url: proposal.url, on: .main) + } + + func test_modal_modal_default_pushesOnModalStack() { +// navigator.route(VisitProposal(path: "/one", context: .modal)) +// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) +// +// let proposal = VisitProposal(path: "/two", context: .modal) +// navigator.route(proposal) +// XCTAssertEqual(modalNavigationController.viewControllers.count, 2) +// XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) +// assertVisited(url: proposal.url, on: .modal) + } + + func test_modal_modal_default_replaceAction_pushesOnModalStack() { +// navigator.route(VisitProposal(path: "/one", context: .modal)) +// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) +// +// let proposal = VisitProposal(path: "/two", action: .replace, context: .modal) +// navigator.route(proposal) +// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) +// XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) +// assertVisited(url: proposal.url, on: .modal) + } + + func test_modal_modal_replace_pushesOnModalStack() { +// navigator.route(VisitProposal(path: "/one", context: .modal)) +// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) +// +// let proposal = VisitProposal(path: "/two", context: .modal, presentation: .replace) +// navigator.route(proposal) +// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) +// XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) +// assertVisited(url: proposal.url, on: .modal) + } + + func test_default_any_pop_popsOffMainStack() { +// navigator.route(VisitProposal()) +// XCTAssertEqual(navigationController.viewControllers.count, 2) +// +// navigator.route(VisitProposal(presentation: .pop)) +// XCTAssertEqual(navigationController.viewControllers.count, 1) + } + + func test_modal_any_pop_popsOffModalStack() { +// navigator.route(VisitProposal(path: "/one", context: .modal)) +// navigator.route(VisitProposal(path: "/two", context: .modal)) +// XCTAssertEqual(modalNavigationController.viewControllers.count, 2) +// +// navigator.route(VisitProposal(presentation: .pop)) +// XCTAssertNotNil(navigationController.presentedViewController) +// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + } + + func test_modal_any_pop_exactlyOneModal_dismissesModal() { +// navigator.route(VisitProposal(path: "/one", context: .modal)) +// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) +// +// navigator.route(VisitProposal(presentation: .pop)) +// XCTAssertNil(navigationController.presentedViewController) + } + + func test_any_any_clearAll_dismissesModalThenPopsToRootOnMainStack() { +// let rootController = UIViewController() +// navigationController.viewControllers = [rootController, UIViewController(), UIViewController()] +// XCTAssertEqual(navigationController.viewControllers.count, 3) +// +// let proposal = VisitProposal(presentation: .clearAll) +// navigator.route(proposal) +// XCTAssertNil(navigationController.presentedViewController) +// XCTAssertEqual(navigationController.viewControllers, [rootController]) + } + + func test_any_any_replaceRoot_dismissesModalThenReplacesRootOnMainStack() { +// let rootController = UIViewController() +// navigationController.viewControllers = [rootController, UIViewController(), UIViewController()] +// XCTAssertEqual(navigationController.viewControllers.count, 3) +// +// navigator.route(VisitProposal(presentation: .replaceRoot)) +// XCTAssertNil(navigationController.presentedViewController) +// XCTAssertEqual(navigationController.viewControllers.count, 1) +// XCTAssert(navigationController.viewControllers.last is VisitableViewController) + } + + func test_presentingUIAlertController_doesNotWrapInNavigationController() { +// let alertControllerDelegate = AlertControllerDelegate() +// navigator = TurboNavigationHierarchyController( +// delegate: alertControllerDelegate, +// navigationController: navigationController, +// modalNavigationController: modalNavigationController +// ) +// +// navigator.route(VisitProposal(path: "/alert")) +// +// XCTAssert(navigationController.presentedViewController is UIAlertController) + } + + func test_presentingUIAlertController_onTheModal_doesNotWrapInNavigationController() { +// let alertControllerDelegate = AlertControllerDelegate() +// navigator = TurboNavigationHierarchyController( +// delegate: alertControllerDelegate, +// navigationController: navigationController, +// modalNavigationController: modalNavigationController +// ) +// +// navigator.route(VisitProposal(context: .modal)) +// navigator.route(VisitProposal(path: "/alert")) +// +// XCTAssert(modalNavigationController.presentedViewController is UIAlertController) + } + + func test_none_cancelsNavigation() { +// let topViewController = UIViewController() +// navigationController.pushViewController(topViewController, animated: false) +// XCTAssertEqual(navigationController.viewControllers.count, 2) +// +// let proposal = VisitProposal(path: "/cancel", presentation: .none) +// navigator.route(proposal) +// +// XCTAssertEqual(navigationController.viewControllers.count, 2) +// XCTAssert(navigationController.topViewController == topViewController) +// XCTAssertNotEqual(navigator.session.activeVisitable?.visitableURL, proposal.url) + } + + // MARK: Private + + private enum Context { + case main, modal + } + + private let baseURL = URL(string: "https://example.com")! + private let session = Session(webView: TurboConfig.shared.makeWebView()) + private let modalSession = Session(webView: TurboConfig.shared.makeWebView()) + private var navigator: TurboNavigator! + private var hierarchyController: TurboNavigationHierarchyController! + private let delegate = EmptyNavigationDelegate() + private var navigationController: TestableNavigationController! + private var modalNavigationController: TestableNavigationController! + private let window = UIWindow() + + // Set an initial controller to simulate a populated navigation stack. + private func pushInitialViewControllersOnNavigationController() { + navigationController.pushViewController(UIViewController(), animated: false) + modalNavigationController.pushViewController(UIViewController(), animated: false) + } + + // Simulate a "real" app so presenting view controllers works under test. + private func loadNavigationControllerInWindow() { + window.rootViewController = navigationController + window.makeKeyAndVisible() + navigationController.loadViewIfNeeded() + } + + private func assertVisited(url: URL, on context: Context) { + switch context { + case .main: + XCTAssertEqual(navigator.session.activeVisitable?.visitableURL, url) + case .modal: +// XCTAssertEqual(navigator.modalSession.activeVisitable?.visitableURL, url) + break + } + } +} + +// MARK: - EmptyNavigationDelegate + +private class EmptyNavigationDelegate: TurboNavigationHierarchyControllerDelegate { + func visit(_: Turbo.Visitable, + on: TurboNavigationHierarchyController.NavigationStackType, + with: Turbo.VisitOptions) {} + + func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) {} +} + +// MARK: - VisitProposal extension + +private extension VisitProposal { + init(path: String = "", action: VisitAction = .advance, context: Navigation.Context = .default, presentation: Navigation.Presentation = .default) { + let url = URL(string: "https://example.com")!.appendingPathComponent(path) + let options = VisitOptions(action: action, response: nil) + let properties: PathProperties = [ + "context": context.rawValue, + "presentation": presentation.rawValue + ] + self.init(url: url, options: options, properties: properties) + } +} + +// MARK: - AlertControllerDelegate + +private class AlertControllerDelegate: TurboNavigationHierarchyControllerDelegate { + func visit(_: Turbo.Visitable, on: TurboNavigationHierarchyController.NavigationStackType, with: Turbo.VisitOptions) {} + + func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) {} + + func handle(proposal: VisitProposal) -> ProposalResult { + if proposal.url.path == "/alert" { + return .acceptCustom(UIAlertController(title: "Alert", message: nil, preferredStyle: .alert)) + } + + return .accept + } + + func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {} +} From 6443d9a1d186a1a78e9b8dbc89ec5cdc603cf2e3 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Mon, 13 Nov 2023 13:19:48 -0800 Subject: [PATCH 22/81] Mimic Strada configuration for Turbo --- Demo/AppDelegate.swift | 2 +- Demo/SceneController.swift | 5 ++--- Source/Logging.swift | 8 ++++---- Source/Turbo Navigator/TurboNavigator.swift | 4 ++-- .../Helpers/TurboConfig.swift => Turbo.swift} | 14 ++++++++++---- 5 files changed, 19 insertions(+), 14 deletions(-) rename Source/{Turbo Navigator/Helpers/TurboConfig.swift => Turbo.swift} (84%) diff --git a/Demo/AppDelegate.swift b/Demo/AppDelegate.swift index 3b09f93..e0c1b4b 100644 --- a/Demo/AppDelegate.swift +++ b/Demo/AppDelegate.swift @@ -6,7 +6,7 @@ import UIKit class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { #if DEBUG - TurboLog.debugLoggingEnabled = true + Turbo.config.debugLoggingEnabled = true Strada.config.debugLoggingEnabled = true #endif diff --git a/Demo/SceneController.swift b/Demo/SceneController.swift index 9c315b4..9fc1236 100644 --- a/Demo/SceneController.swift +++ b/Demo/SceneController.swift @@ -13,10 +13,9 @@ final class SceneController: UIResponder { // MARK: - Setup private func configureStrada() { - TurboConfig.shared.userAgent += - " \(Strada.userAgentSubstring(for: BridgeComponent.allTypes))" + Turbo.config.userAgent += " \(Strada.userAgentSubstring(for: BridgeComponent.allTypes))" - TurboConfig.shared.makeCustomWebView = { config in + Turbo.config.makeCustomWebView = { config in config.defaultWebpagePreferences?.preferredContentMode = .mobile let webView = WKWebView(frame: .zero, configuration: .appConfiguration) diff --git a/Source/Logging.swift b/Source/Logging.swift index 375097e..b75f916 100644 --- a/Source/Logging.swift +++ b/Source/Logging.swift @@ -1,13 +1,13 @@ import Foundation -public struct TurboLog { - public static var debugLoggingEnabled = false +enum TurboLogger { + static var debugLoggingEnabled = false } /// Simple function to help in debugging, a noop in Release builds func debugLog(_ message: String, _ arguments: [String: Any] = [:]) { let timestamp = Date() - + log("\(timestamp) \(message) \(arguments)") } @@ -16,7 +16,7 @@ func debugPrint(_ message: String) { } private func log(_ message: String) { - if TurboLog.debugLoggingEnabled { + if TurboLogger.debugLoggingEnabled { print(message) } } diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 583c93a..e5198a0 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -46,10 +46,10 @@ public class TurboNavigator { /// - pathConfiguration: /// - delegate: an optional delegate to handle custom view controllers public convenience init(pathConfiguration: PathConfiguration, delegate: TurboNavigatorDelegate? = nil) { - let session = Session(webView: TurboConfig.shared.makeWebView()) + let session = Session(webView: Turbo.config.makeWebView()) session.pathConfiguration = pathConfiguration - let modalSession = Session(webView: TurboConfig.shared.makeWebView()) + let modalSession = Session(webView: Turbo.config.makeWebView()) modalSession.pathConfiguration = pathConfiguration self.init(session: session, modalSession: modalSession, delegate: delegate) diff --git a/Source/Turbo Navigator/Helpers/TurboConfig.swift b/Source/Turbo.swift similarity index 84% rename from Source/Turbo Navigator/Helpers/TurboConfig.swift rename to Source/Turbo.swift index cf2122e..ab6d559 100644 --- a/Source/Turbo Navigator/Helpers/TurboConfig.swift +++ b/Source/Turbo.swift @@ -1,10 +1,12 @@ import WebKit +public enum Turbo { + public static var config = TurboConfig() +} + public class TurboConfig { public typealias WebViewBlock = (_ configuration: WKWebViewConfiguration) -> WKWebView - public static let shared = TurboConfig() - /// Override to set a custom user agent. /// Include "Turbo Native" to use `turbo_native_app?` on your Rails server. public var userAgent = "Turbo Native iOS" @@ -15,6 +17,12 @@ public class TurboConfig { WKWebView(frame: .zero, configuration: configuration) } + public var debugLoggingEnabled = false { + didSet { + TurboLogger.debugLoggingEnabled = debugLoggingEnabled + } + } + // MARK: - Internal public func makeWebView() -> WKWebView { @@ -25,8 +33,6 @@ public class TurboConfig { private let sharedProcessPool = WKProcessPool() - private init() {} - // A method (not a property) because we need a new instance for each web view. private func makeWebViewConfiguration() -> WKWebViewConfiguration { let configuration = WKWebViewConfiguration() From e45570982dbbd3a02cc469f062a2edd138178ac4 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 15 Nov 2023 10:27:57 -0800 Subject: [PATCH 23/81] Expose a route function with a VisitProposal param --- Source/Turbo Navigator/TurboNavigator.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index e5198a0..e4fb46a 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -56,14 +56,20 @@ public class TurboNavigator { } /// Transforms `URL` -> `VisitProposal` -> `UIViewController`. - /// Given the `VisitProposal`'s properties, push or present this view controller. + /// Convenience function to routing a proposal directly. /// - /// - Parameter url: the URL to visit. + /// - Parameter url: the URL to visit public func route(_ url: URL) { let options = VisitOptions(action: .advance, response: nil) let properties = session.pathConfiguration?.properties(for: url) ?? PathProperties() - let proposal = VisitProposal(url: url, options: options, properties: properties) + route(VisitProposal(url: url, options: options, properties: properties)) + } + /// Transforms `VisitProposal` -> `UIViewController` + /// Given the `VisitProposal`'s properties, push or present this view controller. + /// + /// - Parameter proposal: the proposal to visit + public func route(_ proposal: VisitProposal) { guard let controller = controller(for: proposal) else { return } hierarchyController.route(controller: controller, proposal: proposal) } From 5b0b66ec1db63e2217009277ca2710edf118baff Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 15 Nov 2023 10:28:18 -0800 Subject: [PATCH 24/81] Fix remaining failing tests --- .../TurboNavigationDelegateTests.swift | 39 +-- .../Turbo Navigator/TurboNavigatorTests.swift | 328 +++++++++--------- 2 files changed, 167 insertions(+), 200 deletions(-) diff --git a/Tests/Turbo Navigator/TurboNavigationDelegateTests.swift b/Tests/Turbo Navigator/TurboNavigationDelegateTests.swift index 25d3e80..d70aea0 100644 --- a/Tests/Turbo Navigator/TurboNavigationDelegateTests.swift +++ b/Tests/Turbo Navigator/TurboNavigationDelegateTests.swift @@ -3,41 +3,12 @@ import SafariServices import XCTest final class TurboNavigationDelegateTests: TurboNavigator { -// func test_controllerForProposal_defaultsToVisitableViewController() throws { -// let url = URL(string: "https://example.com")! -// -// let result = delegate.handle(proposal: VisitProposal(url: url)) -// -// XCTAssertEqual(result, .accept) -// } -// -// func test_openExternalURL_presentsSafariViewController() throws { -// let url = URL(string: "https://example.com")! -// let controller = TestableNavigationController() -// -// delegate.openExternalURL(url, from: controller) -// -// XCTAssert(controller.presentedViewController is SFSafariViewController) -// XCTAssertEqual(controller.modalPresentationStyle, .pageSheet) -// } + func test_controllerForProposal_defaultsToVisitableViewController() throws { + let url = URL(string: "https://example.com")! - // MARK: Private + let proposal = VisitProposal(url: url, options: VisitOptions()) + let result = delegate.handle(proposal: proposal) -// private let delegate = DefaultDelegate() -} - -// MARK: - DefaultDelegate - -private class DefaultDelegate { - func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {} -} - -// MARK: - VisitProposal extension - -private extension VisitProposal { - init(url: URL) { - let url = url - let options = VisitOptions(action: .advance, response: nil) - self.init(url: url, options: options) + XCTAssertEqual(result, .accept) } } diff --git a/Tests/Turbo Navigator/TurboNavigatorTests.swift b/Tests/Turbo Navigator/TurboNavigatorTests.swift index 7669fce..43d75db 100644 --- a/Tests/Turbo Navigator/TurboNavigatorTests.swift +++ b/Tests/Turbo Navigator/TurboNavigatorTests.swift @@ -1,3 +1,4 @@ +import SafariServices @testable import Turbo import XCTest @@ -5,28 +6,24 @@ import XCTest /// `test_currentContext_givenContext_givenPresentation_modifiers_result()` /// See the README for a more visually pleasing table. final class TurboNavigationHierarchyControllerTests: XCTestCase { - private lazy var oneURL = baseURL.appendingPathComponent("/one") - private lazy var twoURL = baseURL.appendingPathComponent("/two") - override func setUp() { navigationController = TestableNavigationController() modalNavigationController = TestableNavigationController() navigator = TurboNavigator(session: session, modalSession: modalSession) - hierarchyController = TurboNavigationHierarchyController(delegate: navigator) + hierarchyController = TurboNavigationHierarchyController(delegate: navigator, navigationControler: navigationController, modalNavigationController: modalNavigationController) navigator.hierarchyController = hierarchyController - pushInitialViewControllersOnNavigationController() loadNavigationControllerInWindow() } func test_default_default_default_pushesOnMainStack() { navigator.route(oneURL) - XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + XCTAssertEqual(navigationController.viewControllers.count, 1) XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) navigator.route(twoURL) - XCTAssertEqual(navigator.rootViewController.viewControllers.count, 2) + XCTAssertEqual(navigationController.viewControllers.count, 2) XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) assertVisited(url: twoURL, on: .main) } @@ -55,191 +52,201 @@ final class TurboNavigationHierarchyControllerTests: XCTestCase { } func test_default_default_default_replaceAction_replacesOnMainStack() { -// let proposal = VisitProposal(action: .replace) -// navigator.route(proposal) -// -// XCTAssertEqual(navigationController.viewControllers.count, 1) -// XCTAssert(navigationController.viewControllers.last is VisitableViewController) -// assertVisited(url: proposal.url, on: .main) + let proposal = VisitProposal(action: .replace) + navigator.route(proposal) + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .main) } func test_default_default_replace_replacesOnMainStack() { -// navigationController.pushViewController(UIViewController(), animated: false) -// XCTAssertEqual(navigationController.viewControllers.count, 2) -// -// let proposal = VisitProposal(presentation: .replace) -// navigator.route(proposal) -// -// XCTAssertEqual(navigationController.viewControllers.count, 2) -// XCTAssert(navigationController.viewControllers.last is VisitableViewController) -// assertVisited(url: proposal.url, on: .main) + navigationController.pushViewController(UIViewController(), animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + let proposal = VisitProposal(presentation: .replace) + navigator.route(proposal) + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .main) } func test_default_modal_default_presentsModal() { -// let proposal = VisitProposal(context: .modal) -// navigator.route(proposal) -// -// XCTAssertEqual(navigationController.viewControllers.count, 1) -// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) -// XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) -// XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) -// assertVisited(url: proposal.url, on: .modal) + navigationController.pushViewController(UIViewController(), animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + let proposal = VisitProposal(context: .modal) + navigator.route(proposal) + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) + XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .modal) } func test_default_modal_replace_presentsModal() { -// let proposal = VisitProposal(context: .modal, presentation: .replace) -// navigator.route(proposal) -// -// XCTAssertEqual(navigationController.viewControllers.count, 1) -// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) -// XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) -// XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) -// assertVisited(url: proposal.url, on: .modal) + navigationController.pushViewController(UIViewController(), animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + let proposal = VisitProposal(context: .modal, presentation: .replace) + navigator.route(proposal) + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) + XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .modal) } func test_modal_default_default_dismissesModalThenPushesOnMainStack() { -// navigator.route(VisitProposal(context: .modal)) -// XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) -// -// let proposal = VisitProposal() -// navigator.route(proposal) -// XCTAssertNil(navigationController.presentedViewController) -// XCTAssert(navigationController.viewControllers.last is VisitableViewController) -// XCTAssertEqual(navigationController.viewControllers.count, 2) -// assertVisited(url: proposal.url, on: .main) + navigationController.pushViewController(UIViewController(), animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + navigator.route(VisitProposal(context: .modal)) + XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) + + let proposal = VisitProposal() + navigator.route(proposal) + XCTAssertNil(navigationController.presentedViewController) + XCTAssert(navigationController.viewControllers.last is VisitableViewController) + XCTAssertEqual(navigationController.viewControllers.count, 2) + assertVisited(url: proposal.url, on: .main) } func test_modal_default_replace_dismissesModalThenReplacedOnMainStack() { -// navigator.route(VisitProposal(context: .modal)) -// XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) -// -// let proposal = VisitProposal(presentation: .replace) -// navigator.route(proposal) -// XCTAssertNil(navigationController.presentedViewController) -// XCTAssertEqual(navigationController.viewControllers.count, 1) -// XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) -// assertVisited(url: proposal.url, on: .main) + navigator.route(VisitProposal(context: .modal)) + XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) + + let proposal = VisitProposal(presentation: .replace) + navigator.route(proposal) + XCTAssertNil(navigationController.presentedViewController) + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .main) } func test_modal_modal_default_pushesOnModalStack() { -// navigator.route(VisitProposal(path: "/one", context: .modal)) -// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) -// -// let proposal = VisitProposal(path: "/two", context: .modal) -// navigator.route(proposal) -// XCTAssertEqual(modalNavigationController.viewControllers.count, 2) -// XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) -// assertVisited(url: proposal.url, on: .modal) + navigator.route(VisitProposal(path: "/one", context: .modal)) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + + let proposal = VisitProposal(path: "/two", context: .modal) + navigator.route(proposal) + XCTAssertEqual(modalNavigationController.viewControllers.count, 2) + XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .modal) } func test_modal_modal_default_replaceAction_pushesOnModalStack() { -// navigator.route(VisitProposal(path: "/one", context: .modal)) -// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) -// -// let proposal = VisitProposal(path: "/two", action: .replace, context: .modal) -// navigator.route(proposal) -// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) -// XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) -// assertVisited(url: proposal.url, on: .modal) + navigator.route(VisitProposal(path: "/one", context: .modal)) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + + let proposal = VisitProposal(path: "/two", action: .replace, context: .modal) + navigator.route(proposal) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .modal) } func test_modal_modal_replace_pushesOnModalStack() { -// navigator.route(VisitProposal(path: "/one", context: .modal)) -// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) -// -// let proposal = VisitProposal(path: "/two", context: .modal, presentation: .replace) -// navigator.route(proposal) -// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) -// XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) -// assertVisited(url: proposal.url, on: .modal) + navigator.route(VisitProposal(path: "/one", context: .modal)) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + + let proposal = VisitProposal(path: "/two", context: .modal, presentation: .replace) + navigator.route(proposal) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .modal) } func test_default_any_pop_popsOffMainStack() { -// navigator.route(VisitProposal()) -// XCTAssertEqual(navigationController.viewControllers.count, 2) -// -// navigator.route(VisitProposal(presentation: .pop)) -// XCTAssertEqual(navigationController.viewControllers.count, 1) + navigationController.pushViewController(UIViewController(), animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + navigator.route(VisitProposal()) + XCTAssertEqual(navigationController.viewControllers.count, 2) + + navigator.route(VisitProposal(presentation: .pop)) + XCTAssertEqual(navigationController.viewControllers.count, 1) } func test_modal_any_pop_popsOffModalStack() { -// navigator.route(VisitProposal(path: "/one", context: .modal)) -// navigator.route(VisitProposal(path: "/two", context: .modal)) -// XCTAssertEqual(modalNavigationController.viewControllers.count, 2) -// -// navigator.route(VisitProposal(presentation: .pop)) -// XCTAssertNotNil(navigationController.presentedViewController) -// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + navigator.route(VisitProposal(path: "/one", context: .modal)) + navigator.route(VisitProposal(path: "/two", context: .modal)) + XCTAssertEqual(modalNavigationController.viewControllers.count, 2) + + navigator.route(VisitProposal(presentation: .pop)) + XCTAssertNotNil(navigationController.presentedViewController) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) } func test_modal_any_pop_exactlyOneModal_dismissesModal() { -// navigator.route(VisitProposal(path: "/one", context: .modal)) -// XCTAssertEqual(modalNavigationController.viewControllers.count, 1) -// -// navigator.route(VisitProposal(presentation: .pop)) -// XCTAssertNil(navigationController.presentedViewController) + navigator.route(VisitProposal(path: "/one", context: .modal)) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + + navigator.route(VisitProposal(presentation: .pop)) + XCTAssertNil(navigationController.presentedViewController) } func test_any_any_clearAll_dismissesModalThenPopsToRootOnMainStack() { -// let rootController = UIViewController() -// navigationController.viewControllers = [rootController, UIViewController(), UIViewController()] -// XCTAssertEqual(navigationController.viewControllers.count, 3) -// -// let proposal = VisitProposal(presentation: .clearAll) -// navigator.route(proposal) -// XCTAssertNil(navigationController.presentedViewController) -// XCTAssertEqual(navigationController.viewControllers, [rootController]) + let rootController = UIViewController() + navigationController.viewControllers = [rootController, UIViewController(), UIViewController()] + XCTAssertEqual(navigationController.viewControllers.count, 3) + + let proposal = VisitProposal(presentation: .clearAll) + navigator.route(proposal) + XCTAssertNil(navigationController.presentedViewController) + XCTAssertEqual(navigationController.viewControllers, [rootController]) } func test_any_any_replaceRoot_dismissesModalThenReplacesRootOnMainStack() { -// let rootController = UIViewController() -// navigationController.viewControllers = [rootController, UIViewController(), UIViewController()] -// XCTAssertEqual(navigationController.viewControllers.count, 3) -// -// navigator.route(VisitProposal(presentation: .replaceRoot)) -// XCTAssertNil(navigationController.presentedViewController) -// XCTAssertEqual(navigationController.viewControllers.count, 1) -// XCTAssert(navigationController.viewControllers.last is VisitableViewController) + let rootController = UIViewController() + navigationController.viewControllers = [rootController, UIViewController(), UIViewController()] + XCTAssertEqual(navigationController.viewControllers.count, 3) + + navigator.route(VisitProposal(presentation: .replaceRoot)) + XCTAssertNil(navigationController.presentedViewController) + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.viewControllers.last is VisitableViewController) } func test_presentingUIAlertController_doesNotWrapInNavigationController() { -// let alertControllerDelegate = AlertControllerDelegate() -// navigator = TurboNavigationHierarchyController( -// delegate: alertControllerDelegate, -// navigationController: navigationController, -// modalNavigationController: modalNavigationController -// ) -// -// navigator.route(VisitProposal(path: "/alert")) -// -// XCTAssert(navigationController.presentedViewController is UIAlertController) + navigator.delegate = alertControllerDelegate + + navigator.route(VisitProposal(path: "/alert")) + + XCTAssert(navigationController.presentedViewController is UIAlertController) } func test_presentingUIAlertController_onTheModal_doesNotWrapInNavigationController() { -// let alertControllerDelegate = AlertControllerDelegate() -// navigator = TurboNavigationHierarchyController( -// delegate: alertControllerDelegate, -// navigationController: navigationController, -// modalNavigationController: modalNavigationController -// ) -// -// navigator.route(VisitProposal(context: .modal)) -// navigator.route(VisitProposal(path: "/alert")) -// -// XCTAssert(modalNavigationController.presentedViewController is UIAlertController) + navigator.delegate = alertControllerDelegate + + navigator.route(VisitProposal(context: .modal)) + navigator.route(VisitProposal(path: "/alert")) + + XCTAssert(modalNavigationController.presentedViewController is UIAlertController) } func test_none_cancelsNavigation() { -// let topViewController = UIViewController() -// navigationController.pushViewController(topViewController, animated: false) -// XCTAssertEqual(navigationController.viewControllers.count, 2) -// -// let proposal = VisitProposal(path: "/cancel", presentation: .none) -// navigator.route(proposal) -// -// XCTAssertEqual(navigationController.viewControllers.count, 2) -// XCTAssert(navigationController.topViewController == topViewController) -// XCTAssertNotEqual(navigator.session.activeVisitable?.visitableURL, proposal.url) + let topViewController = UIViewController() + navigationController.pushViewController(topViewController, animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + let proposal = VisitProposal(path: "/cancel", presentation: .none) + navigator.route(proposal) + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController == topViewController) + XCTAssertNotEqual(navigator.session.activeVisitable?.visitableURL, proposal.url) + } + + func test_externalURL_presentsSafariViewController() throws { + let externalURL = URL(string: "https://example.com")! + navigator.session(navigator.session, openExternalURL: externalURL) + + XCTAssert(navigationController.presentedViewController is SFSafariViewController) + XCTAssertEqual(navigationController.presentedViewController?.modalPresentationStyle, .pageSheet) } // MARK: Private @@ -249,20 +256,19 @@ final class TurboNavigationHierarchyControllerTests: XCTestCase { } private let baseURL = URL(string: "https://example.com")! - private let session = Session(webView: TurboConfig.shared.makeWebView()) - private let modalSession = Session(webView: TurboConfig.shared.makeWebView()) + private lazy var oneURL = baseURL.appendingPathComponent("/one") + private lazy var twoURL = baseURL.appendingPathComponent("/two") + + private let session = Session(webView: Turbo.config.makeWebView()) + private let modalSession = Session(webView: Turbo.config.makeWebView()) + private var navigator: TurboNavigator! + private let alertControllerDelegate = AlertControllerDelegate() private var hierarchyController: TurboNavigationHierarchyController! - private let delegate = EmptyNavigationDelegate() private var navigationController: TestableNavigationController! private var modalNavigationController: TestableNavigationController! - private let window = UIWindow() - // Set an initial controller to simulate a populated navigation stack. - private func pushInitialViewControllersOnNavigationController() { - navigationController.pushViewController(UIViewController(), animated: false) - modalNavigationController.pushViewController(UIViewController(), animated: false) - } + private let window = UIWindow() // Simulate a "real" app so presenting view controllers works under test. private func loadNavigationControllerInWindow() { @@ -276,8 +282,7 @@ final class TurboNavigationHierarchyControllerTests: XCTestCase { case .main: XCTAssertEqual(navigator.session.activeVisitable?.visitableURL, url) case .modal: -// XCTAssertEqual(navigator.modalSession.activeVisitable?.visitableURL, url) - break + XCTAssertEqual(navigator.modalSession.activeVisitable?.visitableURL, url) } } } @@ -285,10 +290,7 @@ final class TurboNavigationHierarchyControllerTests: XCTestCase { // MARK: - EmptyNavigationDelegate private class EmptyNavigationDelegate: TurboNavigationHierarchyControllerDelegate { - func visit(_: Turbo.Visitable, - on: TurboNavigationHierarchyController.NavigationStackType, - with: Turbo.VisitOptions) {} - + func visit(_: Visitable, on: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) {} func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) {} } @@ -308,11 +310,7 @@ private extension VisitProposal { // MARK: - AlertControllerDelegate -private class AlertControllerDelegate: TurboNavigationHierarchyControllerDelegate { - func visit(_: Turbo.Visitable, on: TurboNavigationHierarchyController.NavigationStackType, with: Turbo.VisitOptions) {} - - func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) {} - +private class AlertControllerDelegate: TurboNavigatorDelegate { func handle(proposal: VisitProposal) -> ProposalResult { if proposal.url.path == "/alert" { return .acceptCustom(UIAlertController(title: "Alert", message: nil, preferredStyle: .alert)) @@ -320,6 +318,4 @@ private class AlertControllerDelegate: TurboNavigationHierarchyControllerDelegat return .accept } - - func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {} } From 7271d3cc386ccaa2de266bc998bfa4efb4d16da4 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 15 Nov 2023 10:38:10 -0800 Subject: [PATCH 25/81] Make path configuration optional --- Source/Turbo Navigator/TurboNavigator.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index e4fb46a..02fe835 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -43,9 +43,9 @@ public class TurboNavigator { /// Convenience initializer that doesn't require manually creating `Session` instances. /// - Parameters: - /// - pathConfiguration: + /// - pathConfiguration: an optional remote configuration reference /// - delegate: an optional delegate to handle custom view controllers - public convenience init(pathConfiguration: PathConfiguration, delegate: TurboNavigatorDelegate? = nil) { + public convenience init(pathConfiguration: PathConfiguration? = nil, delegate: TurboNavigatorDelegate? = nil) { let session = Session(webView: Turbo.config.makeWebView()) session.pathConfiguration = pathConfiguration From 2e838ba79b326e4b38b66edb50215fa810650aef Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 15 Nov 2023 10:38:21 -0800 Subject: [PATCH 26/81] Update quick start guide to use Turbo Navigator --- Docs/QuickStartGuide.md | 43 +++++++++++------------------------------ 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/Docs/QuickStartGuide.md b/Docs/QuickStartGuide.md index d3fe74e..a654904 100644 --- a/Docs/QuickStartGuide.md +++ b/Docs/QuickStartGuide.md @@ -7,50 +7,29 @@ This is a quick start guide to creating the most minimal Turbo iOS application f 2. Select your app's main top-level project, go to the Swift Packages tab and add the Turbo iOS dependency by entering in `https://github.com/hotwired/turbo-ios`. 3. Open the `SceneDelegate`, and replace the entire file with this code: + ```swift -import UIKit import Turbo +import UIKit + +private let rootURL = URL(string: "https://turbo-native-demo.glitch.me")! class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - private lazy var navigationController = UINavigationController() + + private lazy var navigator = TurboNavigator() func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let _ = (scene as? UIWindowScene) else { return } - window!.rootViewController = navigationController - visit(url: URL(string: "https://turbo-native-demo.glitch.me")!) - } - - private func visit(url: URL) { - let viewController = VisitableViewController(url: url) - navigationController.pushViewController(viewController, animated: true) - session.visit(viewController) - } - - private lazy var session: Session = { - let session = Session() - session.delegate = self - return session - }() -} -extension SceneDelegate: SessionDelegate { - func session(_ session: Session, didProposeVisit proposal: VisitProposal) { - visit(url: proposal.url) - } - - func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) { - print("didFailRequestForVisitable: \(error)") - } - - func sessionWebViewProcessDidTerminate(_ session: Session) { - session.reload() + window!.rootViewController = navigator.rootViewController + navigator.route(rootURL) } } ``` -4. Hit run, and you have a basic working app. You can now tap links and navigate the demo back and forth in the simulator. We've only touched the very core requirements here of creating a `Session` and handling a visit. +4. Hit run, and you have a basic working app. You can now tap links and navigate the demo back and forth in the simulator. We've only touched the very core requirements here of creating a `Turbo Navigator` and handling a visit. -5. You can change the url we use for the initial visit to your web app. Note: if you're running your app locally without https, you'll need to adjust your `NSAppTransportSecurity` settings in the Info.plist to allow arbitrary loads. +5. You can change the url we use for the initial visit to your web app. -6. A real application will want to customize the view controller, respond to different visit actions, gracefully handle errors, and build a more powerful routing system. Read the rest of the documentation to learn more. +6. A real application will want to customize the view controller, respond to different visit actions, and build a more powerful routing system. Read the rest of the documentation to learn more. From 8b1d90d5984c080c4423c9b06544f0635f671dbf Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 15 Nov 2023 10:51:59 -0800 Subject: [PATCH 27/81] Document navigation flows from Turbo Navigator --- Docs/TurboNavigator.md | 164 ++++++++++++++++++ .../TurboNavigationHierarchyController.swift | 2 - Source/Turbo Navigator/TurboNavigator.swift | 2 + 3 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 Docs/TurboNavigator.md diff --git a/Docs/TurboNavigator.md b/Docs/TurboNavigator.md new file mode 100644 index 0000000..510fde3 --- /dev/null +++ b/Docs/TurboNavigator.md @@ -0,0 +1,164 @@ +# Turbo Navigator + +Turbo Navigator abstracts routing boilerplate a single class. Use this level of abstraction for default handling of the following navigation flows. + +## Handled navigation flows + +When a link is tapped, turbo-ios sends a `VisitProposal` to your application code. Based on the [Path Configuration](PathConfiguration.md), different `PathProperties` will be set. + +* **Current context** - What state the app is in. + * `modal` - a modal is currently presented + * `default` - otherwise +* **Given context** - Value of `context` on the requested link. + * `modal` or `default`/blank +* **Given presentation** - Value of `presentation` on the proposal. + * `replace`, `pop`, `refresh`, `clear_all`, `replace_root`, `none`, `default`/blank +* **Navigation** - The behavior that the navigation controller provides. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current ContextGiven ContextGiven PresentationNew Presentation
defaultdefaultdefaultPush on main stack (or)
+ Replace if visiting same page (or)
+ Pop (and visit) if previous controller is same URL +
defaultdefaultreplaceReplace controller on main stack
defaultmodaldefaultPresent a modal with only this controller
defaultmodalreplacePresent a modal with only this controller
modaldefaultdefaultDismiss then Push on main stack
modaldefaultreplaceDismiss then Replace on main stack
modalmodaldefaultPush on the modal stack
modal modalreplaceReplace controller on modal stack
default(any)popPop controller off main stack
default(any)refreshPop on main stack then
modal(any)popPop controller off modal stack (or)
+ Dismiss if one modal controller +
modal(any)refreshPop controller off modal stack then
+ Refresh last controller on modal stack
+ (or)
+ Dismiss if one modal controller then
+ Refresh last controller on main stack +
(any)(any)clearAllDismiss if modal controller then
+ Pop to root then
+ Refresh root controller on main stack +
(any)(any)replaceRootDismiss if modal controller then
+ Pop to root then
+ Replace root controller on main stack +
(any)(any)noneNothing
+ +### Examples + +To present forms (URLs ending in `/new` or `/edit`) as a modal, add the following to the `rules` key of your Path Configuration. + +```json +{ + "patterns": [ + "/new$", + "/edit$" + ], + "properties": { + "context": "modal" + } +} +``` + +To hook into the "refresh" turbo-rails native route, add the following to the `rules` key of your Path Configuration. You can then call `refresh_or_redirect_to` in your controller to handle Turbo Native and web-based navigation. + +```json +{ + "patterns": [ + "/refresh_historical_location" + ], + "properties": { + "presentation": "refresh" + } +} +``` diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift index 590e05a..326a2af 100644 --- a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift +++ b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift @@ -2,8 +2,6 @@ import SafariServices import UIKit import WebKit -/// Handles navigation to new URLs using the following rules: -/// https://github.com/joemasilotti/TurboNavigator#handled-flows class TurboNavigationHierarchyController { let navigationController: UINavigationController let modalNavigationController: UINavigationController diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 02fe835..73f07c5 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -5,6 +5,8 @@ import WebKit class DefaultTurboNavigatorDelegate: NSObject, TurboNavigatorDelegate {} +/// Handles navigation to new URLs using the following rules: +/// https://github.com/hotwired/turbo-ios/Docs/TurboNavigator.md public class TurboNavigator { public unowned var delegate: TurboNavigatorDelegate From 9af2638399914f78566b2800ec59d80ac24eb38c Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 15 Nov 2023 10:52:20 -0800 Subject: [PATCH 28/81] Replace (removed) Carthage ignore with build dir --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e598886..d9db622 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ xcuserdata -Carthage +.build/ From 267cb26ba418e90838ea15662603b6128a1c6cf3 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 15 Nov 2023 10:52:37 -0800 Subject: [PATCH 29/81] Code formatting and matching docs to code --- .../Helpers/PathConfigurationIdentifiable.swift | 2 +- Source/TurboError.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift b/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift index dd1c5b3..fc59cc8 100644 --- a/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift +++ b/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift @@ -7,7 +7,7 @@ import UIKit /// func handle(proposal: VisitProposal) -> ProposalResult { /// switch proposal.viewController { /// case RecipeViewController.pathConfigurationIdentifier: -/// return .accept(RecipeViewController.new) +/// return .acceptCustom(RecipeViewController.new) /// default: /// return .accept /// } diff --git a/Source/TurboError.swift b/Source/TurboError.swift index 317946e..8d3dee2 100644 --- a/Source/TurboError.swift +++ b/Source/TurboError.swift @@ -7,7 +7,7 @@ public enum TurboError: LocalizedError, Equatable { case contentTypeMismatch case pageLoadFailure case http(statusCode: Int) - + init(statusCode: Int) { switch statusCode { case 0: @@ -20,7 +20,7 @@ public enum TurboError: LocalizedError, Equatable { self = .http(statusCode: statusCode) } } - + public var errorDescription: String? { switch self { case .networkFailure: From 8ec5b89cb6e85681b4f9368e03482a70382a5237 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 15 Nov 2023 10:55:44 -0800 Subject: [PATCH 30/81] Developers can provide a generic WKUIDelegate --- Source/Turbo Navigator/TurboNavigator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 73f07c5..b4e5d46 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -16,7 +16,7 @@ public class TurboNavigator { /// Set to handle customize behavior of the `WKUIDelegate`. /// Subclass `TurboWKUIController` to add additional behavior alongside alert/confirm dialogs. /// Or, provide a completely custom `WKUIDelegate` implementation. - public var webkitUIDelegate: TurboWKUIController? { + public var webkitUIDelegate: WKUIDelegate? { didSet { session.webView.uiDelegate = webkitUIDelegate modalSession.webView.uiDelegate = webkitUIDelegate From e49c897bac7ceffcab8768863888164a9529c311 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 16 Nov 2023 11:18:33 -0600 Subject: [PATCH 31/81] NumbersViewController should conform to PathConfigurationIdentifiable --- Demo/NumbersViewController.swift | 5 ++++- Demo/SceneController.swift | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Demo/NumbersViewController.swift b/Demo/NumbersViewController.swift index 4a637ee..2a30d00 100644 --- a/Demo/NumbersViewController.swift +++ b/Demo/NumbersViewController.swift @@ -2,7 +2,10 @@ import UIKit /// A simple native table view controller to demonstrate loading non-Turbo screens /// for a visit proposal -final class NumbersViewController: UITableViewController { +final class NumbersViewController: UITableViewController, PathConfigurationIdentifiable { + + static var pathConfigurationIdentifier: String { "numbers" } + convenience init(url: URL, navigator: Navigator) { self.init(nibName: nil, bundle: nil) self.url = url diff --git a/Demo/SceneController.swift b/Demo/SceneController.swift index 9fc1236..078358b 100644 --- a/Demo/SceneController.swift +++ b/Demo/SceneController.swift @@ -69,12 +69,15 @@ extension SceneController: UIWindowSceneDelegate { extension SceneController: TurboNavigatorDelegate { func handle(proposal: VisitProposal) -> ProposalResult { switch proposal.viewController { - case "numbers": + + case NumbersViewController.pathConfigurationIdentifier: return .acceptCustom(NumbersViewController(url: proposal.url, navigator: navigator)) + case "numbersDetail": let alertController = UIAlertController(title: "Number", message: "\(proposal.url.lastPathComponent)", preferredStyle: .alert) alertController.addAction(.init(title: "OK", style: .default, handler: nil)) return .acceptCustom(alertController) + default: return .acceptCustom(TurboWebViewController(url: proposal.url)) } From ec73f892f573c55152d00cba267bfdf39b7e70c3 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 16 Nov 2023 11:22:57 -0600 Subject: [PATCH 32/81] Add a comment on VisitProposalExtension --- Source/Turbo Navigator/Extensions/VisitProposalExtension.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift index 7d60527..cccdb2f 100644 --- a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift +++ b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift @@ -35,6 +35,8 @@ public extension VisitProposal { /// A VisitProposal to `https://example.com/recipes/` will have `proposal.viewController == "recipes"` /// /// A default value is provided in case the view controller property is missing from the configuration file. This will route the default `VisitableViewController`. + /// + /// For convenience, conform `ViewController`s to `PathConfigurationIdentifiable` to couple the identifier with a view controller. var viewController: String { if let viewController = properties["view-controller"] as? String { return viewController From 5894d0f3e9344007ad7ce2fadb83716245cdd3af Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 16 Nov 2023 12:11:16 -0600 Subject: [PATCH 33/81] Rename Navigation -> TurboNavigation Naming is quite general. I'd prefer if we could change it to something omre specific. --- .../Extensions/VisitProposalExtension.swift | 8 ++++---- .../Helpers/{Navigation.swift => TurboNavigation.swift} | 2 +- Tests/Turbo Navigator/TurboNavigatorTests.swift | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename Source/Turbo Navigator/Helpers/{Navigation.swift => TurboNavigation.swift} (91%) diff --git a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift index cccdb2f..8d97578 100644 --- a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift +++ b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift @@ -1,14 +1,14 @@ public extension VisitProposal { - var context: Navigation.Context { + var context: TurboNavigation.Context { if let rawValue = properties["context"] as? String { - return Navigation.Context(rawValue: rawValue) ?? .default + return TurboNavigation.Context(rawValue: rawValue) ?? .default } return .default } - var presentation: Navigation.Presentation { + var presentation: TurboNavigation.Presentation { if let rawValue = properties["presentation"] as? String { - return Navigation.Presentation(rawValue: rawValue) ?? .default + return TurboNavigation.Presentation(rawValue: rawValue) ?? .default } return .default } diff --git a/Source/Turbo Navigator/Helpers/Navigation.swift b/Source/Turbo Navigator/Helpers/TurboNavigation.swift similarity index 91% rename from Source/Turbo Navigator/Helpers/Navigation.swift rename to Source/Turbo Navigator/Helpers/TurboNavigation.swift index 9aaaf6d..7137d9d 100644 --- a/Source/Turbo Navigator/Helpers/Navigation.swift +++ b/Source/Turbo Navigator/Helpers/TurboNavigation.swift @@ -1,4 +1,4 @@ -public enum Navigation { +public enum TurboNavigation { public enum Context: String { case `default` case modal diff --git a/Tests/Turbo Navigator/TurboNavigatorTests.swift b/Tests/Turbo Navigator/TurboNavigatorTests.swift index 43d75db..02e855e 100644 --- a/Tests/Turbo Navigator/TurboNavigatorTests.swift +++ b/Tests/Turbo Navigator/TurboNavigatorTests.swift @@ -297,7 +297,7 @@ private class EmptyNavigationDelegate: TurboNavigationHierarchyControllerDelegat // MARK: - VisitProposal extension private extension VisitProposal { - init(path: String = "", action: VisitAction = .advance, context: Navigation.Context = .default, presentation: Navigation.Presentation = .default) { + init(path: String = "", action: VisitAction = .advance, context: TurboNavigation.Context = .default, presentation: TurboNavigation.Presentation = .default) { let url = URL(string: "https://example.com")!.appendingPathComponent(path) let options = VisitOptions(action: action, response: nil) let properties: PathProperties = [ From fda1603481e33dfec71255c27050b73eb73d6941 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Mon, 20 Nov 2023 22:38:02 -0600 Subject: [PATCH 34/81] Expose a new handle(externalURL) function --- .../Helpers/ExternalURLNavigationAction.swift | 15 +++++++++++ .../TurboNavigationHierarchyController.swift | 12 +-------- ...avigationHierarchyControllerDelegate.swift | 2 +- Source/Turbo Navigator/TurboNavigator.swift | 26 +++++++++++++++++-- .../TurboNavigatorDelegate.swift | 6 +++++ 5 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 Source/Turbo Navigator/Helpers/ExternalURLNavigationAction.swift diff --git a/Source/Turbo Navigator/Helpers/ExternalURLNavigationAction.swift b/Source/Turbo Navigator/Helpers/ExternalURLNavigationAction.swift new file mode 100644 index 0000000..f316e92 --- /dev/null +++ b/Source/Turbo Navigator/Helpers/ExternalURLNavigationAction.swift @@ -0,0 +1,15 @@ +import Foundation + +/// When TurboNavigator encounters an external URL, its delegate may handle it with any of these actions. +public enum ExternalURLNavigationAction { + /// Attempts to open via an embedded `SafariViewController` so the user stays in-app. + /// Silently fails if you pass a URL that's not `http` or `https`. + case openViaSafariController + + /// Attempts to open via `openURL(_:options:completionHandler)`. + /// This is useful if the external URL is a deeplink. + case openViaSystem + + /// Will do nothing with the external URL. + case reject +} diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift index 326a2af..ddad007 100644 --- a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift +++ b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift @@ -53,17 +53,7 @@ class TurboNavigationHierarchyController { } func openExternal(url: URL, navigationType: NavigationStackType) { - if ["http", "https"].contains(url.scheme) { - let safariViewController = SFSafariViewController(url: url) - safariViewController.modalPresentationStyle = .pageSheet - if #available(iOS 15.0, *) { - safariViewController.preferredControlTintColor = .tintColor - } - let navController = navController(for: navigationType) - navController.present(safariViewController, animated: true) - } else if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } + delegate.visit(externalURL: url, on: navigationType) } // MARK: Private diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift b/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift index 04e9c7f..21c0a27 100644 --- a/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift +++ b/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift @@ -5,6 +5,6 @@ import WebKit /// or to render a native controller instead of a Turbo web visit. protocol TurboNavigationHierarchyControllerDelegate: AnyObject { func visit(_ : Visitable, on: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) - + func visit(externalURL: URL, on: TurboNavigationHierarchyController.NavigationStackType) func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) } diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index b4e5d46..644d62e 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -146,10 +146,32 @@ extension TurboNavigator: SessionDelegate { // MARK: - TurboNavigationHierarchyControllerDelegate extension TurboNavigator: TurboNavigationHierarchyControllerDelegate { + func visit(_ controller: Visitable, on navigationStack: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) { switch navigationStack { - case .main: session.visit(controller, action: .advance) - case .modal: modalSession.visit(controller, action: .advance) + case .main: session.visit(controller, action: .advance) + case .modal: modalSession.visit(controller, action: .advance) + } + } + + func visit(externalURL: URL, on: TurboNavigationHierarchyController.NavigationStackType) { + + switch delegate.handle(externalURL: externalURL) { + + case .openViaSystem: + UIApplication.shared.open(externalURL) + + case .openViaSafariController: + let safariViewController = SFSafariViewController(url: externalURL) + safariViewController.modalPresentationStyle = .pageSheet + if #available(iOS 15.0, *) { + safariViewController.preferredControlTintColor = .tintColor + } + + rootViewController.present(safariViewController, animated: true) + + case .reject: + return } } diff --git a/Source/Turbo Navigator/TurboNavigatorDelegate.swift b/Source/Turbo Navigator/TurboNavigatorDelegate.swift index bfa57de..9298dea 100644 --- a/Source/Turbo Navigator/TurboNavigatorDelegate.swift +++ b/Source/Turbo Navigator/TurboNavigatorDelegate.swift @@ -12,6 +12,8 @@ public protocol TurboNavigatorDelegate: AnyObject { /// - Returns: how to react to the visit proposal func handle(proposal: VisitProposal) -> ProposalResult + func handle(externalURL: URL) -> ExternalURLNavigationAction + /// Optional. An error occurred loading the request, present it to the user. /// Retry the request by executing the closure. /// If not implemented, will present the error's localized description and a Retry button. @@ -26,6 +28,10 @@ public extension TurboNavigatorDelegate { func handle(proposal: VisitProposal) -> ProposalResult { .accept } + + func handle(externalURL: URL) -> ExternalURLNavigationAction { + .openViaSystem + } func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) { if let errorPresenter = visitable as? ErrorPresenter { From c304e59ab16536a382fc60c5b7ca6170576af461 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 30 Nov 2023 09:03:25 -0600 Subject: [PATCH 35/81] Present SafariController in active nav controller --- Source/Turbo Navigator/TurboNavigator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 644d62e..7fca2bc 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -168,7 +168,7 @@ extension TurboNavigator: TurboNavigationHierarchyControllerDelegate { safariViewController.preferredControlTintColor = .tintColor } - rootViewController.present(safariViewController, animated: true) + activeNavigationController.present(safariViewController, animated: true) case .reject: return From 875a4c238170c632b65b11547eddf9a1e0bdcc0e Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 30 Nov 2023 09:07:01 -0600 Subject: [PATCH 36/81] Simplify open external function --- .../TurboNavigationHierarchyController.swift | 4 -- ...avigationHierarchyControllerDelegate.swift | 1 - Source/Turbo Navigator/TurboNavigator.swift | 43 ++++++++----------- 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift index ddad007..ea95a5c 100644 --- a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift +++ b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift @@ -52,10 +52,6 @@ class TurboNavigationHierarchyController { } } - func openExternal(url: URL, navigationType: NavigationStackType) { - delegate.visit(externalURL: url, on: navigationType) - } - // MARK: Private @available(*, unavailable) diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift b/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift index 21c0a27..32fccb9 100644 --- a/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift +++ b/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift @@ -5,6 +5,5 @@ import WebKit /// or to render a native controller instead of a Turbo web visit. protocol TurboNavigationHierarchyControllerDelegate: AnyObject { func visit(_ : Visitable, on: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) - func visit(externalURL: URL, on: TurboNavigationHierarchyController.NavigationStackType) func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) } diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 7fca2bc..c6819aa 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -111,9 +111,25 @@ extension TurboNavigator: SessionDelegate { } } - public func session(_ session: Session, openExternalURL url: URL) { - let navigationType: TurboNavigationHierarchyController.NavigationStackType = session === modalSession ? .modal : .main - hierarchyController.openExternal(url: url, navigationType: navigationType) + public func session(_ session: Session, openExternalURL externalURL: URL) { + + switch delegate.handle(externalURL: externalURL) { + + case .openViaSystem: + UIApplication.shared.open(externalURL) + + case .openViaSafariController: + let safariViewController = SFSafariViewController(url: externalURL) + safariViewController.modalPresentationStyle = .pageSheet + if #available(iOS 15.0, *) { + safariViewController.preferredControlTintColor = .tintColor + } + + activeNavigationController.present(safariViewController, animated: true) + + case .reject: + return + } } public func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) { @@ -153,27 +169,6 @@ extension TurboNavigator: TurboNavigationHierarchyControllerDelegate { case .modal: modalSession.visit(controller, action: .advance) } } - - func visit(externalURL: URL, on: TurboNavigationHierarchyController.NavigationStackType) { - - switch delegate.handle(externalURL: externalURL) { - - case .openViaSystem: - UIApplication.shared.open(externalURL) - - case .openViaSafariController: - let safariViewController = SFSafariViewController(url: externalURL) - safariViewController.modalPresentationStyle = .pageSheet - if #available(iOS 15.0, *) { - safariViewController.preferredControlTintColor = .tintColor - } - - activeNavigationController.present(safariViewController, animated: true) - - case .reject: - return - } - } func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) { switch navigationStack { From 931bb7959a964ae9b706378b0b21b4dc73bb0a24 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 30 Nov 2023 09:15:04 -0600 Subject: [PATCH 37/81] Use Safari Controller as the default --- Source/Turbo Navigator/TurboNavigatorDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Turbo Navigator/TurboNavigatorDelegate.swift b/Source/Turbo Navigator/TurboNavigatorDelegate.swift index 9298dea..49a2328 100644 --- a/Source/Turbo Navigator/TurboNavigatorDelegate.swift +++ b/Source/Turbo Navigator/TurboNavigatorDelegate.swift @@ -30,7 +30,7 @@ public extension TurboNavigatorDelegate { } func handle(externalURL: URL) -> ExternalURLNavigationAction { - .openViaSystem + .openViaSafariController } func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) { From d35d443350509218faba29531e37501e9a8e3d18 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Mon, 11 Dec 2023 08:56:44 -0800 Subject: [PATCH 38/81] Add missing Turbo import --- Demo/NumbersViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Demo/NumbersViewController.swift b/Demo/NumbersViewController.swift index 2a30d00..fd8b9d0 100644 --- a/Demo/NumbersViewController.swift +++ b/Demo/NumbersViewController.swift @@ -1,11 +1,11 @@ +import Turbo import UIKit /// A simple native table view controller to demonstrate loading non-Turbo screens /// for a visit proposal final class NumbersViewController: UITableViewController, PathConfigurationIdentifiable { - static var pathConfigurationIdentifier: String { "numbers" } - + convenience init(url: URL, navigator: Navigator) { self.init(nibName: nil, bundle: nil) self.url = url From 70719665574c82b7728ef7bc2ae9cb5881b7b224 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Mon, 11 Dec 2023 09:10:55 -0800 Subject: [PATCH 39/81] Customize modal presentation style via modal-style In the path configuration, set "modal-style" to: * "medium" to present a small, half-screen modal. * "large" to present a normal sized modal, filling the screen. * "full" to present a modal that can't be swiped to dismiss. If the property is missing or doesn't exactly match one of these three options then `large` is used. --- .../UINavigationControllerExtension.swift | 16 ++++++++++++++++ .../Extensions/VisitProposalExtension.swift | 9 +++++++++ .../Helpers/TurboNavigation.swift | 6 ++++++ .../TurboNavigationHierarchyController.swift | 6 ++++-- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/Source/Turbo Navigator/Extensions/UINavigationControllerExtension.swift b/Source/Turbo Navigator/Extensions/UINavigationControllerExtension.swift index 6e750d0..981ae3b 100644 --- a/Source/Turbo Navigator/Extensions/UINavigationControllerExtension.swift +++ b/Source/Turbo Navigator/Extensions/UINavigationControllerExtension.swift @@ -5,4 +5,20 @@ extension UINavigationController { let viewControllers = viewControllers.dropLast() setViewControllers(viewControllers + [viewController], animated: false) } + + func setModalPresentationStyle(via proposal: VisitProposal) { + switch proposal.modalStyle { + case .medium: + modalPresentationStyle = .automatic + if #available(iOS 15.0, *) { + if let sheet = sheetPresentationController { + sheet.detents = [.medium(), .large()] + } + } + case .large: + modalPresentationStyle = .automatic + case .full: + modalPresentationStyle = .fullScreen + } + } } diff --git a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift index 8d97578..3c67f00 100644 --- a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift +++ b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift @@ -1,3 +1,5 @@ +import UIKit + public extension VisitProposal { var context: TurboNavigation.Context { if let rawValue = properties["context"] as? String { @@ -13,6 +15,13 @@ public extension VisitProposal { return .default } + var modalStyle: TurboNavigation.ModalStyle { + if let rawValue = properties["modal-style"] as? String { + return TurboNavigation.ModalStyle(rawValue: rawValue) ?? .large + } + return .large + } + /// Used to identify a custom native view controller if provided in the path configuration properties of a given pattern. /// /// For example, given the following configuration file: diff --git a/Source/Turbo Navigator/Helpers/TurboNavigation.swift b/Source/Turbo Navigator/Helpers/TurboNavigation.swift index 7137d9d..7ecfcfe 100644 --- a/Source/Turbo Navigator/Helpers/TurboNavigation.swift +++ b/Source/Turbo Navigator/Helpers/TurboNavigation.swift @@ -13,4 +13,10 @@ public enum TurboNavigation { case replaceRoot = "replace_root" case none } + + public enum ModalStyle: String { + case medium + case large + case full + } } diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift index ea95a5c..2b47e2a 100644 --- a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift +++ b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift @@ -18,8 +18,8 @@ class TurboNavigationHierarchyController { func navController(for navigationType: NavigationStackType) -> UINavigationController { switch navigationType { - case .main: navigationController - case .modal: modalNavigationController + case .main: navigationController + case .modal: modalNavigationController } } @@ -82,6 +82,7 @@ class TurboNavigationHierarchyController { pushOrReplace(on: modalNavigationController, with: controller, via: proposal) } else { modalNavigationController.setViewControllers([controller], animated: true) + modalNavigationController.setModalPresentationStyle(via: proposal) navigationController.present(modalNavigationController, animated: true) } if let visitable = controller as? Visitable { @@ -146,6 +147,7 @@ class TurboNavigationHierarchyController { modalNavigationController.replaceLastViewController(with: controller) } else { modalNavigationController.setViewControllers([controller], animated: false) + modalNavigationController.setModalPresentationStyle(via: proposal) navigationController.present(modalNavigationController, animated: true) } if let visitable = controller as? Visitable { From 433b4d81b21c25cee6ac638b46be90806315bff7 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 12 Dec 2023 05:24:50 -0800 Subject: [PATCH 40/81] Convert path configuration to snake case --- Demo/SceneController.swift | 2 +- Demo/path-configuration.json | 6 +++--- .../Turbo Navigator/Extensions/VisitProposalExtension.swift | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Demo/SceneController.swift b/Demo/SceneController.swift index 078358b..3fa2c76 100644 --- a/Demo/SceneController.swift +++ b/Demo/SceneController.swift @@ -73,7 +73,7 @@ extension SceneController: TurboNavigatorDelegate { case NumbersViewController.pathConfigurationIdentifier: return .acceptCustom(NumbersViewController(url: proposal.url, navigator: navigator)) - case "numbersDetail": + case "numbers_detail": let alertController = UIAlertController(title: "Number", message: "\(proposal.url.lastPathComponent)", preferredStyle: .alert) alertController.addAction(.init(title: "OK", style: .default, handler: nil)) return .acceptCustom(alertController) diff --git a/Demo/path-configuration.json b/Demo/path-configuration.json index 0325713..abbbba3 100644 --- a/Demo/path-configuration.json +++ b/Demo/path-configuration.json @@ -1,6 +1,6 @@ { "settings": { - "enable-feature-x": true + "enable_feature_x": true }, "rules": [ { @@ -19,7 +19,7 @@ "/numbers$" ], "properties": { - "view-controller": "numbers" + "view_controller": "numbers" } }, { @@ -27,7 +27,7 @@ "/numbers/[0-9]+$" ], "properties": { - "view-controller": "numbersDetail", + "view_controller": "numbers_detail", "context": "modal" } }, diff --git a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift index 3c67f00..277a9dd 100644 --- a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift +++ b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift @@ -16,7 +16,7 @@ public extension VisitProposal { } var modalStyle: TurboNavigation.ModalStyle { - if let rawValue = properties["modal-style"] as? String { + if let rawValue = properties["modal_style"] as? String { return TurboNavigation.ModalStyle(rawValue: rawValue) ?? .large } return .large @@ -34,7 +34,7 @@ public extension VisitProposal { /// "/recipes/*" /// ], /// "properties": { - /// "view-controller": "recipes", + /// "view_controller": "recipes", /// } /// } /// ] @@ -47,7 +47,7 @@ public extension VisitProposal { /// /// For convenience, conform `ViewController`s to `PathConfigurationIdentifiable` to couple the identifier with a view controller. var viewController: String { - if let viewController = properties["view-controller"] as? String { + if let viewController = properties["view_controller"] as? String { return viewController } From cddc02b3a92280fb91b4df8a78ef6fe9607c44a3 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 12 Dec 2023 05:43:44 -0800 Subject: [PATCH 41/81] Path configuration to disable pull-to-refresh --- Demo/path-configuration.json | 3 ++- .../Turbo Navigator/Extensions/VisitProposalExtension.swift | 4 ++++ .../Turbo Navigator/TurboNavigationHierarchyController.swift | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Demo/path-configuration.json b/Demo/path-configuration.json index abbbba3..ebc9829 100644 --- a/Demo/path-configuration.json +++ b/Demo/path-configuration.json @@ -11,7 +11,8 @@ "/strada-form$" ], "properties": { - "context": "modal" + "context": "modal", + "pull_to_refresh_enabled": false } }, { diff --git a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift index 277a9dd..2410bc5 100644 --- a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift +++ b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift @@ -22,6 +22,10 @@ public extension VisitProposal { return .large } + var pullToRefreshEnabled: Bool { + properties["pull_to_refresh_enabled"] as? Bool ?? true + } + /// Used to identify a custom native view controller if provided in the path configuration properties of a given pattern. /// /// For example, given the following configuration file: diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift index 2b47e2a..acc189d 100644 --- a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift +++ b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift @@ -33,6 +33,10 @@ class TurboNavigationHierarchyController { if let alert = controller as? UIAlertController { presentAlert(alert) } else { + if let visitable = controller as? Visitable { + visitable.visitableView.allowsPullToRefresh = proposal.pullToRefreshEnabled + } + switch proposal.presentation { case .default: navigate(with: controller, via: proposal) From 953a9b1147338b706287da20c36a5b8a2627de6d Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 12 Dec 2023 05:44:20 -0800 Subject: [PATCH 42/81] Omit return keyword when not needed --- Source/Turbo Navigator/TurboNavigator.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index c6819aa..3469ede 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -88,11 +88,11 @@ public class TurboNavigator { private func controller(for proposal: VisitProposal) -> UIViewController? { switch delegate.handle(proposal: proposal) { case .accept: - return VisitableViewController(url: proposal.url) + VisitableViewController(url: proposal.url) case .acceptCustom(let customViewController): - return customViewController + customViewController case .reject: - return nil + nil } } } From 76898382a5acaeeb7d6879a2b7527b3c0e2a5e37 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 12 Dec 2023 05:45:09 -0800 Subject: [PATCH 43/81] Format code and remove lines with only spaces --- Demo/SceneController.swift | 5 ++--- Source/Turbo Navigator/TurboNavigator.swift | 25 +++++++++------------ 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/Demo/SceneController.swift b/Demo/SceneController.swift index 3fa2c76..2626e27 100644 --- a/Demo/SceneController.swift +++ b/Demo/SceneController.swift @@ -69,15 +69,14 @@ extension SceneController: UIWindowSceneDelegate { extension SceneController: TurboNavigatorDelegate { func handle(proposal: VisitProposal) -> ProposalResult { switch proposal.viewController { - case NumbersViewController.pathConfigurationIdentifier: return .acceptCustom(NumbersViewController(url: proposal.url, navigator: navigator)) - + case "numbers_detail": let alertController = UIAlertController(title: "Number", message: "\(proposal.url.lastPathComponent)", preferredStyle: .alert) alertController.addAction(.init(title: "OK", style: .default, handler: nil)) return .acceptCustom(alertController) - + default: return .acceptCustom(TurboWebViewController(url: proposal.url)) } diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 3469ede..1373c35 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -87,12 +87,12 @@ public class TurboNavigator { private func controller(for proposal: VisitProposal) -> UIViewController? { switch delegate.handle(proposal: proposal) { - case .accept: - VisitableViewController(url: proposal.url) - case .acceptCustom(let customViewController): - customViewController - case .reject: - nil + case .accept: + VisitableViewController(url: proposal.url) + case .acceptCustom(let customViewController): + customViewController + case .reject: + nil } } } @@ -112,21 +112,19 @@ extension TurboNavigator: SessionDelegate { } public func session(_ session: Session, openExternalURL externalURL: URL) { - switch delegate.handle(externalURL: externalURL) { - case .openViaSystem: UIApplication.shared.open(externalURL) - + case .openViaSafariController: let safariViewController = SFSafariViewController(url: externalURL) safariViewController.modalPresentationStyle = .pageSheet if #available(iOS 15.0, *) { safariViewController.preferredControlTintColor = .tintColor } - + activeNavigationController.present(safariViewController, animated: true) - + case .reject: return } @@ -162,7 +160,6 @@ extension TurboNavigator: SessionDelegate { // MARK: - TurboNavigationHierarchyControllerDelegate extension TurboNavigator: TurboNavigationHierarchyControllerDelegate { - func visit(_ controller: Visitable, on navigationStack: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) { switch navigationStack { case .main: session.visit(controller, action: .advance) @@ -172,8 +169,8 @@ extension TurboNavigator: TurboNavigationHierarchyControllerDelegate { func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) { switch navigationStack { - case .main: session.reload() - case .modal: modalSession.reload() + case .main: session.reload() + case .modal: modalSession.reload() } } } From dbc4ca87cda81c918514d68fd7b6696f4d8173b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=AB=20Smith?= Date: Tue, 12 Dec 2023 12:52:43 -0500 Subject: [PATCH 44/81] Report form submissions via TurboNavigatorDelegate (#162) * Report form submissions via TurboNavigatorDelegate * Implement other side of form submission trigger * Rename functions to better match behavior --------- Co-authored-by: Joe Masilotti --- Source/Turbo Navigator/TurboNavigator.swift | 9 +++++++++ Source/Turbo Navigator/TurboNavigatorDelegate.swift | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 1373c35..9623415 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -105,10 +105,19 @@ extension TurboNavigator: SessionDelegate { hierarchyController.route(controller: controller, proposal: proposal) } + public func sessionDidStartFormSubmission(_ session: Session) { + if let url = session.topmostVisitable?.visitableURL { + delegate.formSubmissionDidStart(to: url) + } + } + public func sessionDidFinishFormSubmission(_ session: Session) { if session == modalSession { self.session.clearSnapshotCache() } + if let url = session.topmostVisitable?.visitableURL { + delegate.formSubmissionDidFinish(at: url) + } } public func session(_ session: Session, openExternalURL externalURL: URL) { diff --git a/Source/Turbo Navigator/TurboNavigatorDelegate.swift b/Source/Turbo Navigator/TurboNavigatorDelegate.swift index 49a2328..f05f511 100644 --- a/Source/Turbo Navigator/TurboNavigatorDelegate.swift +++ b/Source/Turbo Navigator/TurboNavigatorDelegate.swift @@ -22,6 +22,14 @@ public protocol TurboNavigatorDelegate: AnyObject { /// Optional. Respond to authentication challenge presented by web servers behing basic auth. /// If not implemented, default handling will be performed. func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + + /// Optional. Called after a form starts a submission. + /// If not implemented, no action is taken. + func formSubmissionDidStart(to url: URL) + + /// Optional. Called after a form finishes a submission. + /// If not implemented, no action is taken. + func formSubmissionDidFinish(at url: URL) } public extension TurboNavigatorDelegate { @@ -42,4 +50,8 @@ public extension TurboNavigatorDelegate { func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { completionHandler(.performDefaultHandling, nil) } + + func formSubmissionDidStart(to url: URL) {} + + func formSubmissionDidFinish(at url: URL) {} } From 1d573f0bad2387a78933fa45dcc80d454aa4af97 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 12 Dec 2023 11:38:37 -0800 Subject: [PATCH 45/81] Option to configure "default" view controller --- Source/Turbo Navigator/TurboNavigator.swift | 2 +- Source/Turbo.swift | 4 ++++ Source/Visitable/VisitableViewController.swift | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 9623415..1477972 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -88,7 +88,7 @@ public class TurboNavigator { private func controller(for proposal: VisitProposal) -> UIViewController? { switch delegate.handle(proposal: proposal) { case .accept: - VisitableViewController(url: proposal.url) + Turbo.config.defaultViewController.self.init(url: proposal.url) case .acceptCustom(let customViewController): customViewController case .reject: diff --git a/Source/Turbo.swift b/Source/Turbo.swift index ab6d559..dfc2e8e 100644 --- a/Source/Turbo.swift +++ b/Source/Turbo.swift @@ -11,6 +11,10 @@ public class TurboConfig { /// Include "Turbo Native" to use `turbo_native_app?` on your Rails server. public var userAgent = "Turbo Native iOS" + /// The view controller used in `TurboNavigator` for web requests. Must be + /// a `VisitableViewController` or subclass. + public var defaultViewController = VisitableViewController.self + /// Optionally customize the web views used by each Turbo Session. /// Ensure you return a new instance each time. public var makeCustomWebView: WebViewBlock = { (configuration: WKWebViewConfiguration) in diff --git a/Source/Visitable/VisitableViewController.swift b/Source/Visitable/VisitableViewController.swift index b245952..fb00407 100644 --- a/Source/Visitable/VisitableViewController.swift +++ b/Source/Visitable/VisitableViewController.swift @@ -5,7 +5,7 @@ open class VisitableViewController: UIViewController, Visitable { open weak var visitableDelegate: VisitableDelegate? open var visitableURL: URL! - public convenience init(url: URL) { + public required convenience init(url: URL) { self.init() self.visitableURL = url } From b2751490cd5b982e0c4f5a19e8febc56e4b4b625 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 13 Dec 2023 09:43:17 -0800 Subject: [PATCH 46/81] Remove "required" from VisitableViewController --- Source/Turbo Navigator/TurboNavigator.swift | 2 +- Source/Turbo.swift | 4 +++- Source/Visitable/VisitableViewController.swift | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 1477972..f67bca1 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -88,7 +88,7 @@ public class TurboNavigator { private func controller(for proposal: VisitProposal) -> UIViewController? { switch delegate.handle(proposal: proposal) { case .accept: - Turbo.config.defaultViewController.self.init(url: proposal.url) + Turbo.config.defaultViewController(proposal.url) case .acceptCustom(let customViewController): customViewController case .reject: diff --git a/Source/Turbo.swift b/Source/Turbo.swift index dfc2e8e..6c13fcd 100644 --- a/Source/Turbo.swift +++ b/Source/Turbo.swift @@ -13,7 +13,9 @@ public class TurboConfig { /// The view controller used in `TurboNavigator` for web requests. Must be /// a `VisitableViewController` or subclass. - public var defaultViewController = VisitableViewController.self + public var defaultViewController: (URL) -> VisitableViewController = { url in + VisitableViewController(url: url) + } /// Optionally customize the web views used by each Turbo Session. /// Ensure you return a new instance each time. diff --git a/Source/Visitable/VisitableViewController.swift b/Source/Visitable/VisitableViewController.swift index fb00407..b245952 100644 --- a/Source/Visitable/VisitableViewController.swift +++ b/Source/Visitable/VisitableViewController.swift @@ -5,7 +5,7 @@ open class VisitableViewController: UIViewController, Visitable { open weak var visitableDelegate: VisitableDelegate? open var visitableURL: URL! - public required convenience init(url: URL) { + public convenience init(url: URL) { self.init() self.visitableURL = url } From 84caebf8de548e835402fc7260fead280bfaa7a1 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 13 Dec 2023 13:27:57 -0800 Subject: [PATCH 47/81] Ensure VisitOptions are always passed on visit --- Source/Turbo Navigator/TurboNavigator.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index f67bca1..f617997 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -169,10 +169,10 @@ extension TurboNavigator: SessionDelegate { // MARK: - TurboNavigationHierarchyControllerDelegate extension TurboNavigator: TurboNavigationHierarchyControllerDelegate { - func visit(_ controller: Visitable, on navigationStack: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) { + func visit(_ controller: Visitable, on navigationStack: TurboNavigationHierarchyController.NavigationStackType, with options: VisitOptions) { switch navigationStack { - case .main: session.visit(controller, action: .advance) - case .modal: modalSession.visit(controller, action: .advance) + case .main: session.visit(controller, options: options) + case .modal: modalSession.visit(controller, options: options) } } From a5bb661dd5a5a63763a214ed4b3d18f68d9c1147 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 13 Dec 2023 13:36:07 -0800 Subject: [PATCH 48/81] If modal is being dismissed then present a new one Fixes an issue where dismissing a modal then presenting a new one right away could cause the new modal to never be presented. --- Source/Turbo Navigator/TurboNavigationHierarchyController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift index acc189d..093ddcc 100644 --- a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift +++ b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift @@ -82,7 +82,7 @@ class TurboNavigationHierarchyController { delegate.visit(visitable, on: .main, with: proposal.options) } case .modal: - if navigationController.presentedViewController != nil { + if navigationController.presentedViewController != nil, !modalNavigationController.isBeingDismissed { pushOrReplace(on: modalNavigationController, with: controller, via: proposal) } else { modalNavigationController.setViewControllers([controller], animated: true) From 93ecef6053619e2f4b95c6cfb0960246ee4ef541 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 14 Dec 2023 21:28:57 -0600 Subject: [PATCH 49/81] Allow framework users to navigate to an external URL using TurboNavigator --- Source/Turbo Navigator/TurboNavigator.swift | 44 +++++++++++++-------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index f617997..0124032 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -75,6 +75,31 @@ public class TurboNavigator { guard let controller = controller(for: proposal) else { return } hierarchyController.route(controller: controller, proposal: proposal) } + + /// Allows programmatic navigation of external URLs. + /// + /// - Parameters: + /// - externalURL: the URL to navigate to + /// - via: navigation action + public func open(externalURL: URL, via: ExternalURLNavigationAction) { + switch via { + + case .openViaSystem: + UIApplication.shared.open(externalURL) + + case .openViaSafariController: + let safariViewController = SFSafariViewController(url: externalURL) + safariViewController.modalPresentationStyle = .pageSheet + if #available(iOS 15.0, *) { + safariViewController.preferredControlTintColor = .tintColor + } + + activeNavigationController.present(safariViewController, animated: true) + + case .reject: + return + } + } let session: Session let modalSession: Session @@ -121,22 +146,9 @@ extension TurboNavigator: SessionDelegate { } public func session(_ session: Session, openExternalURL externalURL: URL) { - switch delegate.handle(externalURL: externalURL) { - case .openViaSystem: - UIApplication.shared.open(externalURL) - - case .openViaSafariController: - let safariViewController = SFSafariViewController(url: externalURL) - safariViewController.modalPresentationStyle = .pageSheet - if #available(iOS 15.0, *) { - safariViewController.preferredControlTintColor = .tintColor - } - - activeNavigationController.present(safariViewController, animated: true) - - case .reject: - return - } + let decision = delegate.handle(externalURL: externalURL) + open(externalURL: externalURL, + via: decision) } public func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) { From c31439c0c6d0fa077c413e8203445202b6fdce22 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 14 Dec 2023 21:32:53 -0600 Subject: [PATCH 50/81] Remove second label in open function --- Source/Turbo Navigator/TurboNavigator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 0124032..0b74e5e 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -81,7 +81,7 @@ public class TurboNavigator { /// - Parameters: /// - externalURL: the URL to navigate to /// - via: navigation action - public func open(externalURL: URL, via: ExternalURLNavigationAction) { + public func open(externalURL: URL, _ via: ExternalURLNavigationAction) { switch via { case .openViaSystem: From 0392240d6b6d1dcd4a5dc02c3f21302734fb9bd9 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 14 Dec 2023 21:37:42 -0600 Subject: [PATCH 51/81] Update label in session delegate function --- Source/Turbo Navigator/TurboNavigator.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 0b74e5e..402c24c 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -147,8 +147,7 @@ extension TurboNavigator: SessionDelegate { public func session(_ session: Session, openExternalURL externalURL: URL) { let decision = delegate.handle(externalURL: externalURL) - open(externalURL: externalURL, - via: decision) + open(externalURL: externalURL, decision) } public func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) { From 3c6f16bddc86fc56b1fba4f8bb7a6e5c18faf1ba Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 Dec 2023 22:12:55 -0600 Subject: [PATCH 52/81] Update Source/Turbo Navigator/TurboNavigator.swift Co-authored-by: Joe Masilotti --- Source/Turbo Navigator/TurboNavigator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 402c24c..97df059 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -76,7 +76,7 @@ public class TurboNavigator { hierarchyController.route(controller: controller, proposal: proposal) } - /// Allows programmatic navigation of external URLs. + /// Navigate to an external URL. /// /// - Parameters: /// - externalURL: the URL to navigate to From 9201f888cf5c3f4d2027997630e2e3881e46c17b Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 11 Jan 2024 09:29:04 -0600 Subject: [PATCH 53/81] Guard against passing HTTP/HTTPS to SFSafariViewController --- Source/Turbo Navigator/TurboNavigator.swift | 3 +++ Tests/Turbo Navigator/TurboNavigatorTests.swift | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 97df059..36cc2d2 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -88,6 +88,9 @@ public class TurboNavigator { UIApplication.shared.open(externalURL) case .openViaSafariController: + /// SFSafariViewController will crash if we pass along a URL that's not valid. + guard externalURL.scheme == "http" || externalURL.scheme == "https" else { return } + let safariViewController = SFSafariViewController(url: externalURL) safariViewController.modalPresentationStyle = .pageSheet if #available(iOS 15.0, *) { diff --git a/Tests/Turbo Navigator/TurboNavigatorTests.swift b/Tests/Turbo Navigator/TurboNavigatorTests.swift index 02e855e..6f3e6ea 100644 --- a/Tests/Turbo Navigator/TurboNavigatorTests.swift +++ b/Tests/Turbo Navigator/TurboNavigatorTests.swift @@ -249,6 +249,13 @@ final class TurboNavigationHierarchyControllerTests: XCTestCase { XCTAssertEqual(navigationController.presentedViewController?.modalPresentationStyle, .pageSheet) } + func test_invalidExternalURL_doesNotPresentSafariViewController() throws { + let externalURL = URL(string: "ftp://example.com")! + navigator.session(navigator.session, openExternalURL: externalURL) + + /// No assertions needed. App will crash if we pass a non-http or non-https scheme to SFSafariViewController. + } + // MARK: Private private enum Context { From 4813e5e924bf3d4af10587f16e5731d99f0f0d87 Mon Sep 17 00:00:00 2001 From: jpsphaxer <48501079+jpsphaxer@users.noreply.github.com> Date: Thu, 22 Feb 2024 16:57:21 -0500 Subject: [PATCH 54/81] Swift-ify Documentation (#166) * Swift-ify Documentation Mostly covers TurboNavigator Documentation, with the exception of a few files * TurboNavigatorDelegate Docs --- README.md | 1 + .../Extensions/VisitProposalExtension.swift | 12 ++++++----- .../PathConfigurationIdentifiable.swift | 10 +++++---- .../Helpers/ProposalResult.swift | 2 +- Source/Turbo Navigator/TurboNavigator.swift | 12 ++++++----- .../TurboNavigatorDelegate.swift | 21 +++++++++++-------- Source/Turbo.swift | 2 +- 7 files changed, 35 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 03cb615..2db1ff5 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ You can also integrate the framework manually if your prefer, such as by adding - [Path Configuration](Docs/PathConfiguration.md) - [Migration](Docs/Migration.md) - [Advanced](Docs/Advanced.md) +- [TurboNavigator](Docs/TurboNavigator.md) ## Contributing diff --git a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift index 2410bc5..9558a22 100644 --- a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift +++ b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift @@ -30,7 +30,7 @@ public extension VisitProposal { /// /// For example, given the following configuration file: /// - /// ``` + /// ```json /// { /// "rules": [ /// { @@ -45,11 +45,13 @@ public extension VisitProposal { /// } /// ``` /// - /// A VisitProposal to `https://example.com/recipes/` will have `proposal.viewController == "recipes"` - /// - /// A default value is provided in case the view controller property is missing from the configuration file. This will route the default `VisitableViewController`. + /// A VisitProposal to `https://example.com/recipes/` will have + /// ```swift + /// proposal.viewController == "recipes" + /// ``` /// - /// For convenience, conform `ViewController`s to `PathConfigurationIdentifiable` to couple the identifier with a view controller. + /// - Important: A default value is provided in case the view controller property is missing from the configuration file. This will route the default `VisitableViewController`. + /// - Note: A `ViewController` must conform to `PathConfigurationIdentifiable` to couple the identifier with a view controlelr. var viewController: String { if let viewController = properties["view_controller"] as? String { return viewController diff --git a/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift b/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift index fc59cc8..295daa0 100644 --- a/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift +++ b/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift @@ -1,18 +1,20 @@ import UIKit -/// As a convenience, your view controller may conform to `PathConfigurationIdentifiable`. -/// You may then use the view controller's `pathConfigurationIdentifier` property instead of `proposal.url` when deciding how to handle a proposal. See `VisitProposal.viewController` on how to use this in your configuration file. +/// As a convenience, a view controller may conform to `PathConfigurationIdentifiable`. /// -/// ``` +/// Use a view controller's `pathConfigurationIdentifier` property instead of `proposal.url` when deciding how to handle a proposal. +/// +/// ```swift /// func handle(proposal: VisitProposal) -> ProposalResult { /// switch proposal.viewController { /// case RecipeViewController.pathConfigurationIdentifier: -/// return .acceptCustom(RecipeViewController.new) +/// return .acceptCustom(RecipeViewController()) /// default: /// return .accept /// } /// } /// ``` +/// - Note: See `VisitProposal.viewController` on how to use this in your configuration file. public protocol PathConfigurationIdentifiable: UIViewController { static var pathConfigurationIdentifier: String { get } } diff --git a/Source/Turbo Navigator/Helpers/ProposalResult.swift b/Source/Turbo Navigator/Helpers/ProposalResult.swift index 871f536..3fa4bf0 100644 --- a/Source/Turbo Navigator/Helpers/ProposalResult.swift +++ b/Source/Turbo Navigator/Helpers/ProposalResult.swift @@ -1,6 +1,6 @@ import UIKit -/// Return from `handle(proposal:)` to route a custom controller. +/// Return from `TurboNavigatorDelegate.handle(proposal:)` to route a custom controller. public enum ProposalResult: Equatable { /// Route a `VisitableViewController`. case accept diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 36cc2d2..a23cf45 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -6,7 +6,7 @@ import WebKit class DefaultTurboNavigatorDelegate: NSObject, TurboNavigatorDelegate {} /// Handles navigation to new URLs using the following rules: -/// https://github.com/hotwired/turbo-ios/Docs/TurboNavigator.md +/// [Turbo Navigator Handled Flows](https://github.com/hotwired/turbo-ios/Docs/TurboNavigator.md) public class TurboNavigator { public unowned var delegate: TurboNavigatorDelegate @@ -14,6 +14,7 @@ public class TurboNavigator { public var activeNavigationController: UINavigationController { hierarchyController.activeNavigationController } /// Set to handle customize behavior of the `WKUIDelegate`. + /// /// Subclass `TurboWKUIController` to add additional behavior alongside alert/confirm dialogs. /// Or, provide a completely custom `WKUIDelegate` implementation. public var webkitUIDelegate: WKUIDelegate? { @@ -24,11 +25,12 @@ public class TurboNavigator { } /// Default initializer requiring preconfigured `Session` instances. - /// User `init(pathConfiguration:delegate)` to only provide a `PathConfiguration`. + /// + /// User `init(pathConfiguration:delegate:)` to only provide a `PathConfiguration`. /// - Parameters: /// - session: the main `Session` /// - modalSession: the `Session` used for the modal navigation controller - /// - delegate: an optional delegate to handle custom view controllers + /// - delegate: _optional:_ delegate to handle custom view controllers public init(session: Session, modalSession: Session, delegate: TurboNavigatorDelegate? = nil) { self.session = session self.modalSession = modalSession @@ -45,8 +47,8 @@ public class TurboNavigator { /// Convenience initializer that doesn't require manually creating `Session` instances. /// - Parameters: - /// - pathConfiguration: an optional remote configuration reference - /// - delegate: an optional delegate to handle custom view controllers + /// - pathConfiguration: _optional:_ remote configuration reference + /// - delegate: _optional:_ delegate to handle custom view controllers public convenience init(pathConfiguration: PathConfiguration? = nil, delegate: TurboNavigatorDelegate? = nil) { let session = Session(webView: Turbo.config.makeWebView()) session.pathConfiguration = pathConfiguration diff --git a/Source/Turbo Navigator/TurboNavigatorDelegate.swift b/Source/Turbo Navigator/TurboNavigatorDelegate.swift index f05f511..e6fa123 100644 --- a/Source/Turbo Navigator/TurboNavigatorDelegate.swift +++ b/Source/Turbo Navigator/TurboNavigatorDelegate.swift @@ -1,25 +1,28 @@ import Foundation +/// Contract for handling navigation requests and actions +/// - Note: Methods are __optional__ by default implementation in ``TurboNavigatorDelegate`` extension. public protocol TurboNavigatorDelegate: AnyObject { typealias RetryBlock = () -> Void - /// Optional. Accept or reject a visit proposal. - /// If accepted, you may provide a view controller to be displayed, otherwise a new `VisitableViewController` is displayed. - /// If rejected, no changes to navigation occur. - /// If not implemented, proposals are accepted and a new `VisitableViewController` is displayed. + /// Accept or reject a visit proposal. + /// There are three `ProposalResult` cases: + /// - term `accept`: Proposals are accepted and a new `VisitableViewController` is displayed. + /// - term `acceptCustom(UIViewController)`: You may provide a view controller to be displayed, otherwise a new `VisitableViewController` is displayed. + /// - term `reject`: No changes to navigation occur. /// - /// - Parameter proposal: navigation destination - /// - Returns: how to react to the visit proposal + /// - Parameter proposal: `VisitProposal` navigation destination + /// - Returns:`ProposalResult` - how to react to the visit proposal func handle(proposal: VisitProposal) -> ProposalResult func handle(externalURL: URL) -> ExternalURLNavigationAction - /// Optional. An error occurred loading the request, present it to the user. + /// An error occurred loading the request, present it to the user. /// Retry the request by executing the closure. - /// If not implemented, will present the error's localized description and a Retry button. + /// - Important: If not implemented, will present the error's localized description and a Retry button. func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) - /// Optional. Respond to authentication challenge presented by web servers behing basic auth. + /// Respond to authentication challenge presented by web servers behing basic auth. /// If not implemented, default handling will be performed. func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) diff --git a/Source/Turbo.swift b/Source/Turbo.swift index 6c13fcd..62e0f4b 100644 --- a/Source/Turbo.swift +++ b/Source/Turbo.swift @@ -8,7 +8,7 @@ public class TurboConfig { public typealias WebViewBlock = (_ configuration: WKWebViewConfiguration) -> WKWebView /// Override to set a custom user agent. - /// Include "Turbo Native" to use `turbo_native_app?` on your Rails server. + /// - Important: Include "Turbo Native" to use `turbo_native_app?` on your Rails server. public var userAgent = "Turbo Native iOS" /// The view controller used in `TurboNavigator` for web requests. Must be From cbb73564dbfbf72bde636fab4bf54718748fea18 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Mon, 26 Feb 2024 14:08:11 -0800 Subject: [PATCH 55/81] Remove development team --- Demo/Demo.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index b2ed132..b9516a2 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -378,7 +378,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Turbo Demo.entitlements"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 2WNYUYRS7G; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -401,7 +401,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Turbo Demo.entitlements"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 2WNYUYRS7G; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( From 0ee7745a7881be89adb9f4696cf5f848815ba3f7 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 28 Feb 2024 13:38:06 -0800 Subject: [PATCH 56/81] Fix snapshot cache issue in Turbo Navigator (#183) * Public functions to clear snapshot cache or reload * Fix snapshot cache issue take 3 --- Source/Session/Session.swift | 20 ++++++++++++++++++++ Source/Turbo Navigator/TurboNavigator.swift | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Source/Session/Session.swift b/Source/Session/Session.swift index bcfdab3..f9ef2fc 100644 --- a/Source/Session/Session.swift +++ b/Source/Session/Session.swift @@ -14,6 +14,8 @@ public class Session: NSObject { private lazy var bridge = WebViewBridge(webView: webView) private var initialized = false private var refreshing = false + private var isShowingStaleContent = false + private var isSnapshotCacheStale = false /// Automatically creates a web view with the passed-in configuration public convenience init(webViewConfiguration: WKWebViewConfiguration? = nil) { @@ -91,6 +93,18 @@ public class Session: NSObject { bridge.clearSnapshotCache() } + // MARK: Caching + + /// Clear the snapshot cache the next time the visitable view appears. + public func markSnapshotCacheAsStale() { + isSnapshotCacheStale = true + } + + /// Reload the `Session` the next time the visitable view appears. + public func markContentAsStale() { + isShowingStaleContent = true + } + // MARK: Visitable activation private var activatedVisitable: Visitable? @@ -228,6 +242,12 @@ extension Session: VisitableDelegate { } else if visitable !== topmostVisit.visitable { // Navigating backward visit(visitable, action: .restore) + } else if isShowingStaleContent { + reload() + isShowingStaleContent = false + } else if isSnapshotCacheStale { + clearSnapshotCache() + isSnapshotCacheStale = false } } diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index a23cf45..082ae6e 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -143,7 +143,7 @@ extension TurboNavigator: SessionDelegate { public func sessionDidFinishFormSubmission(_ session: Session) { if session == modalSession { - self.session.clearSnapshotCache() + self.session.markSnapshotCacheAsStale() } if let url = session.topmostVisitable?.visitableURL { delegate.formSubmissionDidFinish(at: url) From c9088995ff47a765207db1f7e474ff3152ab431c Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Thu, 29 Feb 2024 05:21:36 -0800 Subject: [PATCH 57/81] Fix when snapshot cache is cleared (#185) --- Source/Session/Session.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Source/Session/Session.swift b/Source/Session/Session.swift index f9ef2fc..0831b27 100644 --- a/Source/Session/Session.swift +++ b/Source/Session/Session.swift @@ -229,7 +229,15 @@ extension Session: VisitableDelegate { public func visitableViewWillAppear(_ visitable: Visitable) { guard let topmostVisit = self.topmostVisit, let currentVisit = self.currentVisit else { return } - if visitable === topmostVisit.visitable && visitable.visitableViewController.isMovingToParent { + if isSnapshotCacheStale { + clearSnapshotCache() + isSnapshotCacheStale = false + } + + if isShowingStaleContent { + reload() + isShowingStaleContent = false + } else if visitable === topmostVisit.visitable && visitable.visitableViewController.isMovingToParent { // Back swipe gesture canceled if topmostVisit.state == .completed { currentVisit.cancel() @@ -242,12 +250,6 @@ extension Session: VisitableDelegate { } else if visitable !== topmostVisit.visitable { // Navigating backward visit(visitable, action: .restore) - } else if isShowingStaleContent { - reload() - isShowingStaleContent = false - } else if isSnapshotCacheStale { - clearSnapshotCache() - isSnapshotCacheStale = false } } From 5b0a3ff55a3d870cd00c0fa233be8b6aa8a6fe54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Fri, 1 Mar 2024 10:37:49 +0100 Subject: [PATCH 58/81] Add `WKWebView` extension to provide a method for querying the state of the web content process. --- .../WKWebView+ebContentProcess.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 Source/Turbo Navigator/Extensions/WKWebView+ebContentProcess.swift diff --git a/Source/Turbo Navigator/Extensions/WKWebView+ebContentProcess.swift b/Source/Turbo Navigator/Extensions/WKWebView+ebContentProcess.swift new file mode 100644 index 0000000..9a0d7b3 --- /dev/null +++ b/Source/Turbo Navigator/Extensions/WKWebView+ebContentProcess.swift @@ -0,0 +1,28 @@ +import Foundation +import WebKit + +enum WebContentProcessState { + case active + case terminated +} + +extension WKWebView { + /// Queries the state of the web content process asynchronously. + /// + /// This method evaluates a simple JavaScript function in the web view to determine if the web content process is active. + /// + /// - Parameter completionHandler: A closure to be called when the query completes. The closure takes a single argument representing the state of the web content process. + /// + /// - Note: The web content process is considered active if the JavaScript evaluation succeeds without error. + /// If an error occurs during evaluation, the process is considered terminated. + func queryWebContentProcessState(completionHandler: @escaping (WebContentProcessState) -> Void) { + evaluateJavaScript("(function() { return '1'; })();") { _, error in + if let _ = error { + completionHandler(.terminated) + return + } + + completionHandler(.active) + } + } +} From 1f77eedb765b43fd2aa4a5ae754195378fab3551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Fri, 1 Mar 2024 13:18:25 +0100 Subject: [PATCH 59/81] Tackle terminated web view processes by reloading or recreating the web view. --- Demo/SceneController.swift | 8 ++ Source/Turbo Navigator/TurboNavigator.swift | 111 ++++++++++++++++++-- 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/Demo/SceneController.swift b/Demo/SceneController.swift index 2626e27..dc32179 100644 --- a/Demo/SceneController.swift +++ b/Demo/SceneController.swift @@ -64,6 +64,14 @@ extension SceneController: UIWindowSceneDelegate { navigator.route(rootURL) } + + func sceneDidBecomeActive(_ scene: UIScene) { + navigator.appDidBecomeActive() + } + + func sceneDidEnterBackground(_ scene: UIScene) { + navigator.appDidEnterBackground() + } } extension SceneController: TurboNavigatorDelegate { diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 082ae6e..582da80 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -105,16 +105,27 @@ public class TurboNavigator { return } } - - let session: Session - let modalSession: Session - + + public func appDidBecomeActive() { + appInBackground = false + inspectAllSession() + } + + public func appDidEnterBackground() { + appInBackground = true + } + + var session: Session + var modalSession: Session + /// Modifies a UINavigationController according to visit proposals. lazy var hierarchyController = TurboNavigationHierarchyController(delegate: self) - + /// A default delegate implementation if none is provided. private let navigatorDelegate = DefaultTurboNavigatorDelegate() - + private var backgroundTerminatedWebViewSessions = [Session]() + private var appInBackground: Bool = false + private func controller(for proposal: VisitProposal) -> UIViewController? { switch delegate.handle(proposal: proposal) { case .accept: @@ -162,7 +173,7 @@ extension TurboNavigator: SessionDelegate { } public func sessionWebViewProcessDidTerminate(_ session: Session) { - session.reload() + reloadIfPermitted(session) } public func session(_ session: Session, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { @@ -205,3 +216,89 @@ extension TurboNavigator: TurboWKUIDelegate { hierarchyController.activeNavigationController.present(alert, animated: animated) } } + +// MARK: - Session and web view reloading + +extension TurboNavigator { + private func inspectAllSession() { + [session, modalSession].forEach { inspect($0) } + } + + private func reloadIfPermitted(_ session: Session) { + /// If the web view process is terminated, it leaves the web view with a white screen, so we need to reload it. + /// However, if the web view is no longer onscreen, such as after visiting a page and going back to a native view, + /// then reloading will unnecessarily fetch all the content, and on next visit, + /// it will trigger various bridge messages since the web view will be added to the window and call all the connect() methods. + /// + /// We don't want to reload a view controller not on screen, since that can have unwanted + /// side-effects for the next visit (like showing the wrong bridge components). We can't just + /// check if the view controller is visible, since it may be further back in the stack of a navigation controller. + /// Seeing if there is a parent was the best solution I could find. + guard let viewController = session.activeVisitable?.visitableViewController, + viewController.parent != nil else { + return + } + + if appInBackground { + /// Don't reload the web view if the app is in the background. + /// Instead, save the session in `backgroundTerminatedWebViewSessions` + /// and reload it when the app is back in foreground. + backgroundTerminatedWebViewSessions.append(session) + return + } + + reload(session) + } + + private func reload(_ session: Session) { + session.reload() + } + + /// Inspects the provided session to handle terminated web view process and reloads or recreates the web view accordingly. + /// + /// - Parameter session: The session to inspect. + /// + /// This method checks if the web view associated with the session has terminated in the background. + /// If so, it removes the session from the list of background terminated web view processes, reloads the session, and returns. + /// If the session's topmost visitable URL is not available, the method returns without further action. + /// If the web view's content process state is non-recoverable/terminated, it recreates the web view for the session. + private func inspect(_ session: Session) { + if let index = backgroundTerminatedWebViewSessions.firstIndex(where: { $0 === session }) { + backgroundTerminatedWebViewSessions.remove(at: index) + reload(session) + return + } + + guard let _ = session.topmostVisitable?.visitableURL else { + return + } + + session.webView.queryWebContentProcessState { [weak self] state in + guard case .terminated = state else { return } + self?.recreateWebView(for: session) + } + } + + /// Recreates the web view and session for the given session and performs a `replace` visit. + /// + /// - Parameter session: The session to recreate. + private func recreateWebView(for session: Session) { + guard let _ = session.activeVisitable?.visitableViewController, + let url = session.activeVisitable?.visitableURL else { return } + + let newSession = Session(webView: Turbo.config.makeWebView()) + newSession.pathConfiguration = session.pathConfiguration + newSession.delegate = self + newSession.webView.uiDelegate = webkitUIDelegate + + if session == self.session { + self.session = newSession + } else { + self.modalSession = newSession + } + + let options = VisitOptions(action: .replace, response: nil) + let properties = session.pathConfiguration?.properties(for: url) ?? PathProperties() + route(VisitProposal(url: url, options: options, properties: properties)) + } +} From bc44670923ef916bf62f06ec2044f08d0c44d500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Mon, 4 Mar 2024 13:09:10 +0100 Subject: [PATCH 60/81] Remove unnecessary empty line. --- Source/Turbo Navigator/TurboNavigator.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 582da80..28f4c27 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -120,7 +120,6 @@ public class TurboNavigator { /// Modifies a UINavigationController according to visit proposals. lazy var hierarchyController = TurboNavigationHierarchyController(delegate: self) - /// A default delegate implementation if none is provided. private let navigatorDelegate = DefaultTurboNavigatorDelegate() private var backgroundTerminatedWebViewSessions = [Session]() From a4fe2102f85ef30eb31c45df435dfa7058dab7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Mon, 4 Mar 2024 13:18:35 +0100 Subject: [PATCH 61/81] Make TurboNavigator's `init(session:modalSession:delegate:)` internal to the library. --- Source/Turbo Navigator/TurboNavigator.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 28f4c27..ceae616 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -24,14 +24,14 @@ public class TurboNavigator { } } - /// Default initializer requiring preconfigured `Session` instances. + /// Internal initializer requiring preconfigured `Session` instances. /// /// User `init(pathConfiguration:delegate:)` to only provide a `PathConfiguration`. /// - Parameters: /// - session: the main `Session` /// - modalSession: the `Session` used for the modal navigation controller /// - delegate: _optional:_ delegate to handle custom view controllers - public init(session: Session, modalSession: Session, delegate: TurboNavigatorDelegate? = nil) { + init(session: Session, modalSession: Session, delegate: TurboNavigatorDelegate? = nil) { self.session = session self.modalSession = modalSession From f8c467862614ceb08cb36966e67aeed831eae368 Mon Sep 17 00:00:00 2001 From: Denis Svara Date: Mon, 4 Mar 2024 13:28:25 +0100 Subject: [PATCH 62/81] Omit explicit value type for `appInBackground`. Co-authored-by: Joe Masilotti --- Source/Turbo Navigator/TurboNavigator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index ceae616..0bbfd95 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -123,7 +123,7 @@ public class TurboNavigator { /// A default delegate implementation if none is provided. private let navigatorDelegate = DefaultTurboNavigatorDelegate() private var backgroundTerminatedWebViewSessions = [Session]() - private var appInBackground: Bool = false + private var appInBackground = false private func controller(for proposal: VisitProposal) -> UIViewController? { switch delegate.handle(proposal: proposal) { From f6e0be6db2e98461d98f21acd128e3b47b1f0c91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 5 Mar 2024 10:38:05 +0100 Subject: [PATCH 63/81] Group functions in TurboNavigator by access modifiers. --- Source/Turbo Navigator/TurboNavigator.swift | 56 +++++++++++---------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 0bbfd95..74b715e 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -24,27 +24,6 @@ public class TurboNavigator { } } - /// Internal initializer requiring preconfigured `Session` instances. - /// - /// User `init(pathConfiguration:delegate:)` to only provide a `PathConfiguration`. - /// - Parameters: - /// - session: the main `Session` - /// - modalSession: the `Session` used for the modal navigation controller - /// - delegate: _optional:_ delegate to handle custom view controllers - init(session: Session, modalSession: Session, delegate: TurboNavigatorDelegate? = nil) { - self.session = session - self.modalSession = modalSession - - self.delegate = delegate ?? navigatorDelegate - - self.session.delegate = self - self.modalSession.delegate = self - - self.webkitUIDelegate = TurboWKUIController(delegate: self) - session.webView.uiDelegate = webkitUIDelegate - modalSession.webView.uiDelegate = webkitUIDelegate - } - /// Convenience initializer that doesn't require manually creating `Session` instances. /// - Parameters: /// - pathConfiguration: _optional:_ remote configuration reference @@ -108,23 +87,48 @@ public class TurboNavigator { public func appDidBecomeActive() { appInBackground = false - inspectAllSession() + inspectAllSessions() } public func appDidEnterBackground() { appInBackground = true } - + + // MARK: Internal + var session: Session var modalSession: Session - /// Modifies a UINavigationController according to visit proposals. lazy var hierarchyController = TurboNavigationHierarchyController(delegate: self) + + /// Internal initializer requiring preconfigured `Session` instances. + /// + /// User `init(pathConfiguration:delegate:)` to only provide a `PathConfiguration`. + /// - Parameters: + /// - session: the main `Session` + /// - modalSession: the `Session` used for the modal navigation controller + /// - delegate: _optional:_ delegate to handle custom view controllers + init(session: Session, modalSession: Session, delegate: TurboNavigatorDelegate? = nil) { + self.session = session + self.modalSession = modalSession + + self.delegate = delegate ?? navigatorDelegate + + self.session.delegate = self + self.modalSession.delegate = self + + self.webkitUIDelegate = TurboWKUIController(delegate: self) + session.webView.uiDelegate = webkitUIDelegate + modalSession.webView.uiDelegate = webkitUIDelegate + } + + // MARK: Private + /// A default delegate implementation if none is provided. private let navigatorDelegate = DefaultTurboNavigatorDelegate() private var backgroundTerminatedWebViewSessions = [Session]() private var appInBackground = false - + private func controller(for proposal: VisitProposal) -> UIViewController? { switch delegate.handle(proposal: proposal) { case .accept: @@ -219,7 +223,7 @@ extension TurboNavigator: TurboWKUIDelegate { // MARK: - Session and web view reloading extension TurboNavigator { - private func inspectAllSession() { + private func inspectAllSessions() { [session, modalSession].forEach { inspect($0) } } From c4ea6f26e9a55a959778267c26a7a118c7330510 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Thu, 7 Mar 2024 12:14:49 -0800 Subject: [PATCH 64/81] Custom navigation controller (#192) * Option to customize UINavigationController * Fix typo --- .../TurboNavigationHierarchyController.swift | 8 ++++++-- Source/Turbo.swift | 6 ++++++ Tests/Turbo Navigator/TurboNavigatorTests.swift | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift index 093ddcc..cbc5afb 100644 --- a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift +++ b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift @@ -23,9 +23,13 @@ class TurboNavigationHierarchyController { } } - init(delegate: TurboNavigationHierarchyControllerDelegate, navigationControler: UINavigationController = UINavigationController(), modalNavigationController: UINavigationController = UINavigationController()) { + init( + delegate: TurboNavigationHierarchyControllerDelegate, + navigationController: UINavigationController = Turbo.config.defaultNavigationController(), + modalNavigationController: UINavigationController = Turbo.config.defaultNavigationController() + ) { self.delegate = delegate - self.navigationController = navigationControler + self.navigationController = navigationController self.modalNavigationController = modalNavigationController } diff --git a/Source/Turbo.swift b/Source/Turbo.swift index 62e0f4b..25398a9 100644 --- a/Source/Turbo.swift +++ b/Source/Turbo.swift @@ -17,6 +17,12 @@ public class TurboConfig { VisitableViewController(url: url) } + /// The navigation controller used in `TurboNavigator` for the main and modal stacks. + /// Must be a `UINavigationController` or subclass. + public var defaultNavigationController: () -> UINavigationController = { + UINavigationController() + } + /// Optionally customize the web views used by each Turbo Session. /// Ensure you return a new instance each time. public var makeCustomWebView: WebViewBlock = { (configuration: WKWebViewConfiguration) in diff --git a/Tests/Turbo Navigator/TurboNavigatorTests.swift b/Tests/Turbo Navigator/TurboNavigatorTests.swift index 6f3e6ea..3a51a54 100644 --- a/Tests/Turbo Navigator/TurboNavigatorTests.swift +++ b/Tests/Turbo Navigator/TurboNavigatorTests.swift @@ -11,7 +11,7 @@ final class TurboNavigationHierarchyControllerTests: XCTestCase { modalNavigationController = TestableNavigationController() navigator = TurboNavigator(session: session, modalSession: modalSession) - hierarchyController = TurboNavigationHierarchyController(delegate: navigator, navigationControler: navigationController, modalNavigationController: modalNavigationController) + hierarchyController = TurboNavigationHierarchyController(delegate: navigator, navigationController: navigationController, modalNavigationController: modalNavigationController) navigator.hierarchyController = hierarchyController loadNavigationControllerInWindow() From e9d7ba3352e50d5ed78d98f96fb150c055201983 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 19 Mar 2024 20:28:19 -0600 Subject: [PATCH 65/81] Hide retry button in ErrorView if no handler is given --- .../Helpers/ErrorPresenter.swift | 20 +++++++++++-------- .../TurboNavigatorDelegate.swift | 4 ++-- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Source/Turbo Navigator/Helpers/ErrorPresenter.swift b/Source/Turbo Navigator/Helpers/ErrorPresenter.swift index 655571d..772215d 100644 --- a/Source/Turbo Navigator/Helpers/ErrorPresenter.swift +++ b/Source/Turbo Navigator/Helpers/ErrorPresenter.swift @@ -3,13 +3,14 @@ import SwiftUI public protocol ErrorPresenter: UIViewController { typealias Handler = () -> Void - func presentError(_ error: Error, handler: @escaping Handler) + func presentError(_ error: Error, handler: Handler?) } public extension ErrorPresenter { - func presentError(_ error: Error, handler: @escaping () -> Void) { - let errorView = ErrorView(error: error) { [unowned self] in - handler() + func presentError(_ error: Error, handler: Handler?) { + let errorView = ErrorView(error: error, + shouldShowRetryButton: (handler != nil)) { [unowned self] in + handler?() self.removeErrorViewController() } @@ -34,6 +35,7 @@ extension UIViewController: ErrorPresenter {} private struct ErrorView: View { let error: Error + let shouldShowRetryButton: Bool let handler: ErrorPresenter.Handler? var body: some View { @@ -49,10 +51,12 @@ private struct ErrorView: View { .font(.body) .multilineTextAlignment(.center) - Button("Retry") { - handler?() + if shouldShowRetryButton { + Button("Retry") { + handler?() + } + .font(.system(size: 17, weight: .bold)) } - .font(.system(size: 17, weight: .bold)) } .padding(32) } @@ -64,7 +68,7 @@ private struct ErrorView_Previews: PreviewProvider { domain: "com.example.error", code: 1001, userInfo: [NSLocalizedDescriptionKey: "Could not connect to the server."] - )) {} + ), shouldShowRetryButton: true) {} } } diff --git a/Source/Turbo Navigator/TurboNavigatorDelegate.swift b/Source/Turbo Navigator/TurboNavigatorDelegate.swift index e6fa123..a6b36a9 100644 --- a/Source/Turbo Navigator/TurboNavigatorDelegate.swift +++ b/Source/Turbo Navigator/TurboNavigatorDelegate.swift @@ -20,7 +20,7 @@ public protocol TurboNavigatorDelegate: AnyObject { /// An error occurred loading the request, present it to the user. /// Retry the request by executing the closure. /// - Important: If not implemented, will present the error's localized description and a Retry button. - func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: RetryBlock?) /// Respond to authentication challenge presented by web servers behing basic auth. /// If not implemented, default handling will be performed. @@ -44,7 +44,7 @@ public extension TurboNavigatorDelegate { .openViaSafariController } - func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) { + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: RetryBlock?) { if let errorPresenter = visitable as? ErrorPresenter { errorPresenter.presentError(error, handler: retry) } From 0bd17babd6b86635c79d859854cccd8b9112c7e2 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 19 Mar 2024 20:51:02 -0600 Subject: [PATCH 66/81] Rename handler and add documentation --- .../Turbo Navigator/Helpers/ErrorPresenter.swift | 16 ++++++++++++---- .../Turbo Navigator/TurboNavigatorDelegate.swift | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Source/Turbo Navigator/Helpers/ErrorPresenter.swift b/Source/Turbo Navigator/Helpers/ErrorPresenter.swift index 772215d..198feaa 100644 --- a/Source/Turbo Navigator/Helpers/ErrorPresenter.swift +++ b/Source/Turbo Navigator/Helpers/ErrorPresenter.swift @@ -3,14 +3,22 @@ import SwiftUI public protocol ErrorPresenter: UIViewController { typealias Handler = () -> Void - func presentError(_ error: Error, handler: Handler?) + func presentError(_ error: Error, retryHandler: Handler?) } public extension ErrorPresenter { - func presentError(_ error: Error, handler: Handler?) { + + /// Presents an error in a full screen view. + /// The error view will display a `Retry` button if `retryHandler != nil`. + /// Tapping `Retry` will call `retryHandler?()` then dismiss the error. + /// + /// - Parameters: + /// - error: <#error description#> + /// - retryHandler: <#retryHandler description#> + func presentError(_ error: Error, retryHandler: Handler?) { let errorView = ErrorView(error: error, - shouldShowRetryButton: (handler != nil)) { [unowned self] in - handler?() + shouldShowRetryButton: (retryHandler != nil)) { [unowned self] in + retryHandler?() self.removeErrorViewController() } diff --git a/Source/Turbo Navigator/TurboNavigatorDelegate.swift b/Source/Turbo Navigator/TurboNavigatorDelegate.swift index a6b36a9..3a77121 100644 --- a/Source/Turbo Navigator/TurboNavigatorDelegate.swift +++ b/Source/Turbo Navigator/TurboNavigatorDelegate.swift @@ -46,7 +46,7 @@ public extension TurboNavigatorDelegate { func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: RetryBlock?) { if let errorPresenter = visitable as? ErrorPresenter { - errorPresenter.presentError(error, handler: retry) + errorPresenter.presentError(error, retryHandler: retry) } } From 7db075096dbb7af097f1e21c07f146022e6adf54 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 19 Mar 2024 20:52:38 -0600 Subject: [PATCH 67/81] Add more docs --- Source/Turbo Navigator/Helpers/ErrorPresenter.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/Turbo Navigator/Helpers/ErrorPresenter.swift b/Source/Turbo Navigator/Helpers/ErrorPresenter.swift index 198feaa..8b8894b 100644 --- a/Source/Turbo Navigator/Helpers/ErrorPresenter.swift +++ b/Source/Turbo Navigator/Helpers/ErrorPresenter.swift @@ -13,8 +13,8 @@ public extension ErrorPresenter { /// Tapping `Retry` will call `retryHandler?()` then dismiss the error. /// /// - Parameters: - /// - error: <#error description#> - /// - retryHandler: <#retryHandler description#> + /// - error: presents the data in this error + /// - retryHandler: a user-triggered action to perform in case the error is recoverable func presentError(_ error: Error, retryHandler: Handler?) { let errorView = ErrorView(error: error, shouldShowRetryButton: (retryHandler != nil)) { [unowned self] in From e6ac331e2c15081d2573d2a99f09394cb3784e42 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 19 Mar 2024 21:11:59 -0700 Subject: [PATCH 68/81] Update demo app to use new parameter name --- Demo/SceneController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Demo/SceneController.swift b/Demo/SceneController.swift index dc32179..c09c1cd 100644 --- a/Demo/SceneController.swift +++ b/Demo/SceneController.swift @@ -94,7 +94,7 @@ extension SceneController: TurboNavigatorDelegate { if let turboError = error as? TurboError, case let .http(statusCode) = turboError, statusCode == 401 { promptForAuthentication() } else if let errorPresenter = visitable as? ErrorPresenter { - errorPresenter.presentError(error, handler: retry) + errorPresenter.presentError(error, retryHandler: retry) } else { let alert = UIAlertController(title: "Visit failed!", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) From 1005ee249ee88a21cf7704646058a167dd437379 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 19 Mar 2024 21:13:42 -0700 Subject: [PATCH 69/81] Rename parameter to match new naming --- Demo/SceneController.swift | 4 ++-- Source/Turbo Navigator/TurboNavigatorDelegate.swift | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Demo/SceneController.swift b/Demo/SceneController.swift index c09c1cd..0ad7ee4 100644 --- a/Demo/SceneController.swift +++ b/Demo/SceneController.swift @@ -90,11 +90,11 @@ extension SceneController: TurboNavigatorDelegate { } } - func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) { + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) { if let turboError = error as? TurboError, case let .http(statusCode) = turboError, statusCode == 401 { promptForAuthentication() } else if let errorPresenter = visitable as? ErrorPresenter { - errorPresenter.presentError(error, retryHandler: retry) + errorPresenter.presentError(error, retryHandler: retryHandler) } else { let alert = UIAlertController(title: "Visit failed!", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) diff --git a/Source/Turbo Navigator/TurboNavigatorDelegate.swift b/Source/Turbo Navigator/TurboNavigatorDelegate.swift index 3a77121..12e6cb9 100644 --- a/Source/Turbo Navigator/TurboNavigatorDelegate.swift +++ b/Source/Turbo Navigator/TurboNavigatorDelegate.swift @@ -20,7 +20,7 @@ public protocol TurboNavigatorDelegate: AnyObject { /// An error occurred loading the request, present it to the user. /// Retry the request by executing the closure. /// - Important: If not implemented, will present the error's localized description and a Retry button. - func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: RetryBlock?) + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) /// Respond to authentication challenge presented by web servers behing basic auth. /// If not implemented, default handling will be performed. @@ -44,9 +44,9 @@ public extension TurboNavigatorDelegate { .openViaSafariController } - func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: RetryBlock?) { + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) { if let errorPresenter = visitable as? ErrorPresenter { - errorPresenter.presentError(error, retryHandler: retry) + errorPresenter.presentError(error, retryHandler: retryHandler) } } From 58abd0843ffb1f5e5d76acc7d58e8335cef44fb4 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 19 Mar 2024 21:17:13 -0700 Subject: [PATCH 70/81] No need to capture self, this isn't a block --- Source/Turbo Navigator/Helpers/ErrorPresenter.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Source/Turbo Navigator/Helpers/ErrorPresenter.swift b/Source/Turbo Navigator/Helpers/ErrorPresenter.swift index 8b8894b..c2f583e 100644 --- a/Source/Turbo Navigator/Helpers/ErrorPresenter.swift +++ b/Source/Turbo Navigator/Helpers/ErrorPresenter.swift @@ -16,8 +16,7 @@ public extension ErrorPresenter { /// - error: presents the data in this error /// - retryHandler: a user-triggered action to perform in case the error is recoverable func presentError(_ error: Error, retryHandler: Handler?) { - let errorView = ErrorView(error: error, - shouldShowRetryButton: (retryHandler != nil)) { [unowned self] in + let errorView = ErrorView(error: error, shouldShowRetryButton: (retryHandler != nil)) { retryHandler?() self.removeErrorViewController() } From 15a9958e0d0216ed30f29c78deb146e962e1564d Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Fri, 22 Mar 2024 17:02:24 -0400 Subject: [PATCH 71/81] Backfill turbo-ios PR #199 --- Docs/PathConfiguration.md | 21 ++++++++++++++++++- .../PathConfiguration.swift | 13 ++++++------ Source/Turbo.swift | 8 +++++++ Tests/Fixtures/test-configuration.json | 4 ++++ Tests/PathConfigurationLoaderTests.swift | 4 ++-- Tests/PathConfigurationTests.swift | 17 +++++++++++++-- 6 files changed, 55 insertions(+), 12 deletions(-) diff --git a/Docs/PathConfiguration.md b/Docs/PathConfiguration.md index 94b23c7..7046fad 100644 --- a/Docs/PathConfiguration.md +++ b/Docs/PathConfiguration.md @@ -61,7 +61,7 @@ let pathConfiguration = PathConfiguration(sources: [ Path properties are the core of the path configuration. The `rules` key of the JSON is an array of dictionaries. Each dictionary has a `patterns` array which is an array of regular expressions for matching on the URL, and a dictionary of `properties` that will get returned when a pattern matches. -You can lookup the properties for a URL by using the URL itself or the `url.path` value. Currently, the path configuration only looks at the path component of the URL, but likely we'll add support for other components in the future. The path configuration finds all matching rules in order, and then merges them into one dictionary, with later rules overriding earlier ones. This way you can group similar properties together. +You can lookup the properties for a URL by using the URL itself or the `url.path` value. The path configuration finds all matching rules in order, and then merges them into one dictionary, with later rules overriding earlier ones. This way you can group similar properties together. Given the following rules: @@ -106,6 +106,25 @@ The url `example.com/messages/new` however would match both the first and second When the `Session` proposes a visit, it looks up the path properties for the proposed visit url if it has a `pathConfiguration` and it passes those path properties to your app in the `VisitProposal` via `proposal.properties`. This is for convenience, but you can also use the path configuration directly and do the same lookup in your application code. +### Query String Matching + +By default, path patterns only match against the path component of the URL. Enable query string matching via: + +```swift +Turbo.config.pathConfiguration.matchQueryStrings = true +``` + +To ensure the order of query string parameters don't affect matching, a wildcard `.*` before and after the match is recommended, like so: + +``` +{ + "patterns": [".*\\?.*foo=bar.*"], + "properties": { + "foo": "bar" + } +} +``` + ## Settings The path configuration optionally can have a top-level `settings` dictionary. This can be whatever data you want. We use it for controlling anything that we want the flexibility to change from the server without releasing an update. This might be different urls, configurations, feature flags, etc. If you don't want to use that, you can omit it entirely from the JSON. diff --git a/Source/Path Configuration/PathConfiguration.swift b/Source/Path Configuration/PathConfiguration.swift index 8c4a3c0..a957b69 100644 --- a/Source/Path Configuration/PathConfiguration.swift +++ b/Source/Path Configuration/PathConfiguration.swift @@ -51,14 +51,13 @@ public final class PathConfiguration { public subscript(url: URL) -> PathProperties { properties(for: url) } - - /// Returns a merged dictionary containing all the properties - /// that match this url - /// Note: currently only looks at path, not query, but most likely will - /// add query support in the future, so it's best to always use this over the path variant - /// unless you're sure you'll never need to reference other parts of the URL in the future + + /// Returns a merged dictionary containing all the properties that match this URL. public func properties(for url: URL) -> PathProperties { - properties(for: url.path) + if Turbo.config.pathConfiguration.matchQueryStrings, let query = url.query { + return properties(for: "\(url.path)?\(query)") + } + return properties(for: url.path) } /// Returns a merged dictionary containing all the properties diff --git a/Source/Turbo.swift b/Source/Turbo.swift index 25398a9..df750b1 100644 --- a/Source/Turbo.swift +++ b/Source/Turbo.swift @@ -52,4 +52,12 @@ public class TurboConfig { configuration.processPool = sharedProcessPool return configuration } + + public var pathConfiguration = PathConfiguration() +} + +public extension TurboConfig { + class PathConfiguration { + public var matchQueryStrings = false + } } diff --git a/Tests/Fixtures/test-configuration.json b/Tests/Fixtures/test-configuration.json index e0d050a..c957183 100644 --- a/Tests/Fixtures/test-configuration.json +++ b/Tests/Fixtures/test-configuration.json @@ -19,6 +19,10 @@ { "patterns": ["/edit$"], "properties": {"background_color": "white"} + }, + { + "patterns": [".*\\?.*open_in_external_browser=true.*"], + "properties": {"open_in_external_browser": true} } ] } diff --git a/Tests/PathConfigurationLoaderTests.swift b/Tests/PathConfigurationLoaderTests.swift index e04956d..ffa1148 100644 --- a/Tests/PathConfigurationLoaderTests.swift +++ b/Tests/PathConfigurationLoaderTests.swift @@ -15,7 +15,7 @@ class PathConfigurationLoaderTests: XCTestCase { loader.load { loadedConfig = $0 } let config = try XCTUnwrap(loadedConfig) - XCTAssertEqual(config.rules.count, 4) + XCTAssertEqual(config.rules.count, 5) } func test_file_automaticallyLoadsFromTheLocalFileAndCallsTheHandler() throws { @@ -25,7 +25,7 @@ class PathConfigurationLoaderTests: XCTestCase { loader.load { loadedConfig = $0 } let config = try XCTUnwrap(loadedConfig) - XCTAssertEqual(config.rules.count, 4) + XCTAssertEqual(config.rules.count, 5) } func test_server_automaticallyDownloadsTheFileAndCallsTheHandler() throws { diff --git a/Tests/PathConfigurationTests.swift b/Tests/PathConfigurationTests.swift index 5a45e83..be702ea 100644 --- a/Tests/PathConfigurationTests.swift +++ b/Tests/PathConfigurationTests.swift @@ -12,7 +12,7 @@ class PathConfigurationTests: XCTestCase { func test_init_automaticallyLoadsTheConfigurationFromTheSpecifiedLocation() { XCTAssertEqual(configuration.settings.count, 2) - XCTAssertEqual(configuration.rules.count, 4) + XCTAssertEqual(configuration.rules.count, 5) } func test_settings_returnsCurrentSettings() { @@ -39,6 +39,18 @@ class PathConfigurationTests: XCTestCase { "background_color": "white" ]) } + + func test_propertiesForURL_withParams() { + let url = URL(string: "http://turbo.test/sample.pdf?open_in_external_browser=true")! + + Turbo.config.pathConfiguration.matchQueryStrings = false + XCTAssertEqual(configuration.properties(for: url), [:]) + + Turbo.config.pathConfiguration.matchQueryStrings = true + XCTAssertEqual(configuration.properties(for: url), [ + "open_in_external_browser": true + ]) + } func test_propertiesForPath_whenNoMatch_returnsEmptyProperties() { XCTAssertEqual(configuration.properties(for: "/missing"), [:]) @@ -49,6 +61,7 @@ class PathConfigurationTests: XCTestCase { XCTAssertEqual(configuration.properties(for: "/edit"), configuration["/edit"]) XCTAssertEqual(configuration.properties(for: "/"), configuration["/"]) XCTAssertEqual(configuration.properties(for: "/missing"), configuration["/missing"]) + XCTAssertEqual(configuration.properties(for: "/sample.pdf?open_in_external_browser=true"), configuration["/sample.pdf?open_in_external_browser=true"]) } } @@ -61,7 +74,7 @@ class PathConfigTests: XCTestCase { let config = try PathConfigurationDecoder(json: json) XCTAssertEqual(config.settings.count, 2) - XCTAssertEqual(config.rules.count, 4) + XCTAssertEqual(config.rules.count, 5) } func test_json_withMissingRulesKey_failsToDecode() throws { From 820cfa912fab39b7f29e092479f5efbba156b518 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 26 Apr 2024 22:05:01 -0600 Subject: [PATCH 72/81] Add bundle parameter to route --- Source/Turbo Navigator/TurboNavigator.swift | 5 +++-- Source/Visit/VisitProposal.swift | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 74b715e..5c9c1be 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -42,10 +42,11 @@ public class TurboNavigator { /// Convenience function to routing a proposal directly. /// /// - Parameter url: the URL to visit - public func route(_ url: URL) { + /// - Parameter bundle: provide context relevant to `url` + public func route(_ url: URL, bundle: [String: Any]? = nil) { let options = VisitOptions(action: .advance, response: nil) let properties = session.pathConfiguration?.properties(for: url) ?? PathProperties() - route(VisitProposal(url: url, options: options, properties: properties)) + route(VisitProposal(url: url, options: options, properties: properties, bundle: bundle)) } /// Transforms `VisitProposal` -> `UIViewController` diff --git a/Source/Visit/VisitProposal.swift b/Source/Visit/VisitProposal.swift index 32d8237..7db06a0 100644 --- a/Source/Visit/VisitProposal.swift +++ b/Source/Visit/VisitProposal.swift @@ -4,10 +4,15 @@ public struct VisitProposal { public let url: URL public let options: VisitOptions public let properties: PathProperties + public let bundle: [String: Any]? - public init(url: URL, options: VisitOptions, properties: PathProperties = [:]) { + public init(url: URL, + options: VisitOptions, + properties: PathProperties = [:], + bundle: [String: Any]? = nil) { self.url = url self.options = options self.properties = properties + self.bundle = bundle } } From a242deb9aaa0c3c3c68c01215bf5187a6cc24933 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 9 May 2024 16:02:18 -0600 Subject: [PATCH 73/81] Rename to parameters --- Source/Turbo Navigator/TurboNavigator.swift | 4 ++-- Source/Visit/VisitProposal.swift | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 5c9c1be..d59aafc 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -43,10 +43,10 @@ public class TurboNavigator { /// /// - Parameter url: the URL to visit /// - Parameter bundle: provide context relevant to `url` - public func route(_ url: URL, bundle: [String: Any]? = nil) { + public func route(_ url: URL, parameters: [String: Any]? = nil) { let options = VisitOptions(action: .advance, response: nil) let properties = session.pathConfiguration?.properties(for: url) ?? PathProperties() - route(VisitProposal(url: url, options: options, properties: properties, bundle: bundle)) + route(VisitProposal(url: url, options: options, properties: properties, parameters: parameters)) } /// Transforms `VisitProposal` -> `UIViewController` diff --git a/Source/Visit/VisitProposal.swift b/Source/Visit/VisitProposal.swift index 7db06a0..34e0583 100644 --- a/Source/Visit/VisitProposal.swift +++ b/Source/Visit/VisitProposal.swift @@ -4,15 +4,15 @@ public struct VisitProposal { public let url: URL public let options: VisitOptions public let properties: PathProperties - public let bundle: [String: Any]? + public let parameters: [String: Any]? public init(url: URL, options: VisitOptions, properties: PathProperties = [:], - bundle: [String: Any]? = nil) { + parameters: [String: Any]? = nil) { self.url = url self.options = options self.properties = properties - self.bundle = bundle + self.parameters = parameters } } From a95d7c16db3e20b70cd32b93dd028bcef27ab935 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 9 May 2024 16:02:47 -0600 Subject: [PATCH 74/81] Rename code comment --- Source/Turbo Navigator/TurboNavigator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index d59aafc..8153cc5 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -42,7 +42,7 @@ public class TurboNavigator { /// Convenience function to routing a proposal directly. /// /// - Parameter url: the URL to visit - /// - Parameter bundle: provide context relevant to `url` + /// - Parameter parameters: provide context relevant to `url` public func route(_ url: URL, parameters: [String: Any]? = nil) { let options = VisitOptions(action: .advance, response: nil) let properties = session.pathConfiguration?.properties(for: url) ?? PathProperties() From 93af6fded2aa7d65d38d4415ff2901ec531bb023 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 15 May 2024 12:55:44 -0400 Subject: [PATCH 75/81] Several improvements (#209) * Add options parameter * Add animation key to visit proposal * Add modal root view controller access * Change var -> constant * Add default parameter for visit options * Clarify default visit options --- .../Extensions/VisitProposalExtension.swift | 9 +++ .../TurboNavigationHierarchyController.swift | 56 +++++++++---------- Source/Turbo Navigator/TurboNavigator.swift | 14 ++++- .../Turbo Navigator/TurboNavigatorTests.swift | 13 ++++- 4 files changed, 60 insertions(+), 32 deletions(-) diff --git a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift index 9558a22..6dce5d0 100644 --- a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift +++ b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift @@ -59,4 +59,13 @@ public extension VisitProposal { return VisitableViewController.pathConfigurationIdentifier } + + /// Allows the proposal to change the animation status when pushing, popping or presenting. + var animated: Bool { + if let animated = parameters?["animated"] as? Bool { + return animated + } + + return true + } } diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift index cbc5afb..2d855b1 100644 --- a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift +++ b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift @@ -35,7 +35,7 @@ class TurboNavigationHierarchyController { func route(controller: UIViewController, proposal: VisitProposal) { if let alert = controller as? UIAlertController { - presentAlert(alert) + presentAlert(alert, via: proposal) } else { if let visitable = controller as? Visitable { visitable.visitableView.allowsPullToRefresh = proposal.pullToRefreshEnabled @@ -45,15 +45,15 @@ class TurboNavigationHierarchyController { case .default: navigate(with: controller, via: proposal) case .pop: - pop() + pop(via: proposal) case .replace: replace(with: controller, via: proposal) case .refresh: - refresh() + refresh(via: proposal) case .clearAll: - clearAll() + clearAll(via: proposal) case .replaceRoot: - replaceRoot(with: controller) + replaceRoot(with: controller, via: proposal) case .none: break // Do nothing. } @@ -69,18 +69,18 @@ class TurboNavigationHierarchyController { private unowned let delegate: TurboNavigationHierarchyControllerDelegate - private func presentAlert(_ alert: UIAlertController) { + private func presentAlert(_ alert: UIAlertController, via proposal: VisitProposal) { if navigationController.presentedViewController != nil { - modalNavigationController.present(alert, animated: true) + modalNavigationController.present(alert, animated: proposal.animated) } else { - navigationController.present(alert, animated: true) + navigationController.present(alert, animated: proposal.animated) } } private func navigate(with controller: UIViewController, via proposal: VisitProposal) { switch proposal.context { case .default: - navigationController.dismiss(animated: true) + navigationController.dismiss(animated: proposal.animated) pushOrReplace(on: navigationController, with: controller, via: proposal) if let visitable = controller as? Visitable { delegate.visit(visitable, on: .main, with: proposal.options) @@ -89,9 +89,9 @@ class TurboNavigationHierarchyController { if navigationController.presentedViewController != nil, !modalNavigationController.isBeingDismissed { pushOrReplace(on: modalNavigationController, with: controller, via: proposal) } else { - modalNavigationController.setViewControllers([controller], animated: true) + modalNavigationController.setViewControllers([controller], animated: proposal.animated) modalNavigationController.setModalPresentationStyle(via: proposal) - navigationController.present(modalNavigationController, animated: true) + navigationController.present(modalNavigationController, animated: proposal.animated) } if let visitable = controller as? Visitable { delegate.visit(visitable, on: .modal, with: proposal.options) @@ -103,9 +103,9 @@ class TurboNavigationHierarchyController { if visitingSamePage(on: navigationController, with: controller, via: proposal.url) { navigationController.replaceLastViewController(with: controller) } else if visitingPreviousPage(on: navigationController, with: controller, via: proposal.url) { - navigationController.popViewController(animated: true) + navigationController.popViewController(animated: proposal.animated) } else if proposal.options.action == .advance { - navigationController.pushViewController(controller, animated: true) + navigationController.pushViewController(controller, animated: proposal.animated) } else { navigationController.replaceLastViewController(with: controller) } @@ -130,22 +130,22 @@ class TurboNavigationHierarchyController { return type(of: previousController) == type(of: controller) } - private func pop() { + private func pop(via proposal: VisitProposal) { if navigationController.presentedViewController != nil { if modalNavigationController.viewControllers.count == 1 { - navigationController.dismiss(animated: true) + navigationController.dismiss(animated: proposal.animated) } else { - modalNavigationController.popViewController(animated: true) + modalNavigationController.popViewController(animated: proposal.animated) } } else { - navigationController.popViewController(animated: true) + navigationController.popViewController(animated: proposal.animated) } } private func replace(with controller: UIViewController, via proposal: VisitProposal) { switch proposal.context { case .default: - navigationController.dismiss(animated: true) + navigationController.dismiss(animated: proposal.animated) navigationController.replaceLastViewController(with: controller) if let visitable = controller as? Visitable { delegate.visit(visitable, on: .main, with: proposal.options) @@ -156,7 +156,7 @@ class TurboNavigationHierarchyController { } else { modalNavigationController.setViewControllers([controller], animated: false) modalNavigationController.setModalPresentationStyle(via: proposal) - navigationController.present(modalNavigationController, animated: true) + navigationController.present(modalNavigationController, animated: proposal.animated) } if let visitable = controller as? Visitable { delegate.visit(visitable, on: .modal, with: proposal.options) @@ -164,30 +164,30 @@ class TurboNavigationHierarchyController { } } - private func refresh() { + private func refresh(via proposal: VisitProposal) { if navigationController.presentedViewController != nil { if modalNavigationController.viewControllers.count == 1 { - navigationController.dismiss(animated: true) + navigationController.dismiss(animated: proposal.animated) delegate.refresh(navigationStack: .main) } else { - modalNavigationController.popViewController(animated: true) + modalNavigationController.popViewController(animated: proposal.animated) delegate.refresh(navigationStack: .modal) } } else { - navigationController.popViewController(animated: true) + navigationController.popViewController(animated: proposal.animated) delegate.refresh(navigationStack: .main) } } - private func clearAll() { - navigationController.dismiss(animated: true) - navigationController.popToRootViewController(animated: true) + private func clearAll(via proposal: VisitProposal) { + navigationController.dismiss(animated: proposal.animated) + navigationController.popToRootViewController(animated: proposal.animated) delegate.refresh(navigationStack: .main) } - private func replaceRoot(with controller: UIViewController) { + private func replaceRoot(with controller: UIViewController, via proposal: VisitProposal) { navigationController.dismiss(animated: true) - navigationController.setViewControllers([controller], animated: true) + navigationController.setViewControllers([controller], animated: proposal.animated) if let visitable = controller as? Visitable { delegate.visit(visitable, on: .main, with: .init(action: .replace)) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 8153cc5..7f6db64 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -11,6 +11,9 @@ public class TurboNavigator { public unowned var delegate: TurboNavigatorDelegate public var rootViewController: UINavigationController { hierarchyController.navigationController } + + public var modalRootViewController: UINavigationController { hierarchyController.modalNavigationController } + public var activeNavigationController: UINavigationController { hierarchyController.activeNavigationController } /// Set to handle customize behavior of the `WKUIDelegate`. @@ -42,11 +45,16 @@ public class TurboNavigator { /// Convenience function to routing a proposal directly. /// /// - Parameter url: the URL to visit + /// - Parameter options: passed options will override default `advance` visit options /// - Parameter parameters: provide context relevant to `url` - public func route(_ url: URL, parameters: [String: Any]? = nil) { - let options = VisitOptions(action: .advance, response: nil) + public func route(_ url: URL, + options: VisitOptions? = VisitOptions(action: .advance), + parameters: [String: Any]? = nil) { let properties = session.pathConfiguration?.properties(for: url) ?? PathProperties() - route(VisitProposal(url: url, options: options, properties: properties, parameters: parameters)) + route(VisitProposal(url: url, + options: options ?? .init(action: .advance), + properties: properties, + parameters: parameters)) } /// Transforms `VisitProposal` -> `UIViewController` diff --git a/Tests/Turbo Navigator/TurboNavigatorTests.swift b/Tests/Turbo Navigator/TurboNavigatorTests.swift index 3a51a54..aa26088 100644 --- a/Tests/Turbo Navigator/TurboNavigatorTests.swift +++ b/Tests/Turbo Navigator/TurboNavigatorTests.swift @@ -17,7 +17,7 @@ final class TurboNavigationHierarchyControllerTests: XCTestCase { loadNavigationControllerInWindow() } - func test_default_default_default_pushesOnMainStack() { + func test_default_default_default_defaultOptionsParamater_pushesOnMainStack() { navigator.route(oneURL) XCTAssertEqual(navigationController.viewControllers.count, 1) XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) @@ -27,6 +27,17 @@ final class TurboNavigationHierarchyControllerTests: XCTestCase { XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) assertVisited(url: twoURL, on: .main) } + + func test_default_default_default_nilOptionsParameter_pushesOnMainStack() { + navigator.route(oneURL) + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) + + navigator.route(twoURL, options: nil) + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) + assertVisited(url: twoURL, on: .main) + } func test_default_default_default_visitingSamePage_replacesOnMainStack() { navigator.route(oneURL) From 5f42ee44bdb89b9eacd6c0dc0fcc9924e8b8e905 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Mon, 20 May 2024 08:23:48 -0700 Subject: [PATCH 76/81] Fix demo app --- Demo/Navigator.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Demo/Navigator.swift b/Demo/Navigator.swift index a3a45ba..3318e8d 100644 --- a/Demo/Navigator.swift +++ b/Demo/Navigator.swift @@ -4,7 +4,11 @@ import Turbo /// A bridge "back" to Turbo world from native. /// See `NumbersViewController` for an example of navigating from native to web. protocol Navigator: AnyObject { - func route(_: URL) + func route(_ url: URL) } -extension TurboNavigator: Navigator {} +extension TurboNavigator: Navigator { + func route(_ url: URL) { + route(url, options: VisitOptions(action: .advance), parameters: nil) + } +} From 722749244c3dfd7a27de517e56bb52cf60f0f403 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 29 May 2024 11:10:13 -0600 Subject: [PATCH 77/81] Use URL filename as cache key --- .../PathConfigurationLoader.swift | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/Source/Path Configuration/PathConfigurationLoader.swift b/Source/Path Configuration/PathConfigurationLoader.swift index 3774525..06d671c 100644 --- a/Source/Path Configuration/PathConfigurationLoader.swift +++ b/Source/Path Configuration/PathConfigurationLoader.swift @@ -4,7 +4,6 @@ typealias PathConfigurationLoaderCompletionHandler = (PathConfigurationDecoder) final class PathConfigurationLoader { private let cacheDirectory = "Turbo" - private let configurationCacheFilename = "path-configuration.json" private let sources: [PathConfiguration.Source] private let options: PathConfigurationLoaderOptions? private var completionHandler: PathConfigurationLoaderCompletionHandler? @@ -20,7 +19,7 @@ final class PathConfigurationLoader { for source in sources { switch source { case .data(let data): - loadData(data) + loadData(data, for: .PathDataTemporaryURL) case .file(let url): loadFile(url) case .server(let url): @@ -35,8 +34,8 @@ final class PathConfigurationLoader { precondition(!url.isFileURL, "URL provided for server is a file url") // Immediately load most recent cached version if available - if let data = cachedData() { - loadData(data) + if let data = cachedData(for: url) { + loadData(data, for: url) } let session = options?.urlSessionConfiguration.map { URLSession(configuration: $0) } ?? URLSession.shared @@ -50,29 +49,31 @@ final class PathConfigurationLoader { return } - self?.loadData(data, cache: true) + self?.loadData(data, cache: true, for: url) }.resume() } // MARK: - Caching - private func cacheRemoteData(_ data: Data) { + private func cacheRemoteData(_ data: Data, for url: URL) { createCacheDirectoryIfNeeded() do { - try data.write(to: configurationCacheURL) + let url = configurationCacheURL(for: url) + try data.write(to: url) } catch { debugPrint("[path-configuration-loader] error caching file error: \(error)") } } - private func cachedData() -> Data? { - guard FileManager.default.fileExists(atPath: configurationCacheURL.path) else { + private func cachedData(for url: URL) -> Data? { + let cachedURL = configurationCacheURL(for: url) + guard FileManager.default.fileExists(atPath: cachedURL.path) else { return nil } do { - return try Data(contentsOf: configurationCacheURL) + return try Data(contentsOf: cachedURL) } catch { debugPrint("[path-configuration-loader] *** error loading cached data: \(error)") return nil @@ -94,8 +95,8 @@ final class PathConfigurationLoader { return directory.appendingPathComponent(cacheDirectory) } - var configurationCacheURL: URL { - turboCacheDirectoryURL.appendingPathComponent(configurationCacheFilename) + func configurationCacheURL(for url: URL) -> URL { + turboCacheDirectoryURL.appendingPathComponent(url.lastPathComponent) } // MARK: - File @@ -105,7 +106,7 @@ final class PathConfigurationLoader { do { let data = try Data(contentsOf: url) - loadData(data) + loadData(data, for: url) } catch { debugPrint("[path-configuration] *** error loading configuration from file: \(url), error: \(error)") } @@ -113,7 +114,7 @@ final class PathConfigurationLoader { // MARK: - Data - private func loadData(_ data: Data, cache: Bool = false) { + private func loadData(_ data: Data, cache: Bool = false, for url: URL) { do { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw JSONDecodingError.invalidJSON @@ -123,7 +124,7 @@ final class PathConfigurationLoader { if cache { // Only cache once we ensure we have valid data - cacheRemoteData(data) + cacheRemoteData(data, for: url) } updateHandler(with: config) @@ -144,3 +145,7 @@ final class PathConfigurationLoader { } } } + +private extension URL { + static let PathDataTemporaryURL = URL(string: "https://localhost/path-configuration.json")! +} From 7a5915589d51e91778d13ecabcef8e1f5cf39303 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 29 May 2024 20:46:50 -0600 Subject: [PATCH 78/81] Update unit tests --- Tests/PathConfigurationLoaderTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/PathConfigurationLoaderTests.swift b/Tests/PathConfigurationLoaderTests.swift index ffa1148..40b7948 100644 --- a/Tests/PathConfigurationLoaderTests.swift +++ b/Tests/PathConfigurationLoaderTests.swift @@ -55,7 +55,7 @@ class PathConfigurationLoaderTests: XCTestCase { wait(for: [expectation]) XCTAssertTrue(handlerCalled) - XCTAssertTrue(FileManager.default.fileExists(atPath: loader.configurationCacheURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: loader.configurationCacheURL(for: serverURL).path)) } private func stubRequest(for loader: PathConfigurationLoader) -> XCTestExpectation { @@ -64,7 +64,7 @@ class PathConfigurationLoaderTests: XCTestCase { return HTTPStubsResponse(jsonObject: json, statusCode: 200, headers: [:]) } - clearCache(loader.configurationCacheURL) + clearCache(loader.configurationCacheURL(for: serverURL)) return expectation(description: "Wait for configuration to load.") } From 30d0903a993e2fc49763515715a8624bccbda2bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Thu, 30 May 2024 16:09:25 +0200 Subject: [PATCH 79/81] Remove the need to provide a url when loading path config's data. --- .../PathConfigurationLoader.swift | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/Source/Path Configuration/PathConfigurationLoader.swift b/Source/Path Configuration/PathConfigurationLoader.swift index 06d671c..09b5438 100644 --- a/Source/Path Configuration/PathConfigurationLoader.swift +++ b/Source/Path Configuration/PathConfigurationLoader.swift @@ -19,7 +19,7 @@ final class PathConfigurationLoader { for source in sources { switch source { case .data(let data): - loadData(data, for: .PathDataTemporaryURL) + loadData(data) case .file(let url): loadFile(url) case .server(let url): @@ -35,7 +35,7 @@ final class PathConfigurationLoader { // Immediately load most recent cached version if available if let data = cachedData(for: url) { - loadData(data, for: url) + loadData(data) } let session = options?.urlSessionConfiguration.map { URLSession(configuration: $0) } ?? URLSession.shared @@ -106,7 +106,7 @@ final class PathConfigurationLoader { do { let data = try Data(contentsOf: url) - loadData(data, for: url) + loadData(data) } catch { debugPrint("[path-configuration] *** error loading configuration from file: \(url), error: \(error)") } @@ -114,7 +114,7 @@ final class PathConfigurationLoader { // MARK: - Data - private func loadData(_ data: Data, cache: Bool = false, for url: URL) { + private func loadData(_ data: Data, cache: Bool = false, for url: URL? = nil) { do { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw JSONDecodingError.invalidJSON @@ -122,7 +122,7 @@ final class PathConfigurationLoader { let config = try PathConfigurationDecoder(json: json) - if cache { + if cache, let url { // Only cache once we ensure we have valid data cacheRemoteData(data, for: url) } @@ -145,7 +145,3 @@ final class PathConfigurationLoader { } } } - -private extension URL { - static let PathDataTemporaryURL = URL(string: "https://localhost/path-configuration.json")! -} From 23d5ca9ade8f0701388fa8a52d4e935f7f38e50c Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 4 Jul 2024 15:16:14 -0600 Subject: [PATCH 80/81] Update refresh behavior --- .../TurboNavigationHierarchyController.swift | 15 +++-- ...avigationHierarchyControllerDelegate.swift | 22 +++++-- Source/Turbo Navigator/TurboNavigator.swift | 9 ++- .../Turbo Navigator/TurboNavigatorTests.swift | 57 ++++++++++++++++++- 4 files changed, 91 insertions(+), 12 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift index 2d855b1..0a1995e 100644 --- a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift +++ b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift @@ -168,21 +168,28 @@ class TurboNavigationHierarchyController { if navigationController.presentedViewController != nil { if modalNavigationController.viewControllers.count == 1 { navigationController.dismiss(animated: proposal.animated) - delegate.refresh(navigationStack: .main) + refreshIfTopViewControllerIsVisitable(from: .main) } else { modalNavigationController.popViewController(animated: proposal.animated) - delegate.refresh(navigationStack: .modal) + refreshIfTopViewControllerIsVisitable(from: .modal) } } else { navigationController.popViewController(animated: proposal.animated) - delegate.refresh(navigationStack: .main) + refreshIfTopViewControllerIsVisitable(from: .main) + } + } + + private func refreshIfTopViewControllerIsVisitable(from stack: NavigationStackType) { + if let navControllerTopmostVisitable = navController(for: stack).topViewController as? Visitable { + delegate.refreshVisitable(navigationStack: stack, + newTopmostVisitable: navControllerTopmostVisitable) } } private func clearAll(via proposal: VisitProposal) { navigationController.dismiss(animated: proposal.animated) navigationController.popToRootViewController(animated: proposal.animated) - delegate.refresh(navigationStack: .main) + refreshIfTopViewControllerIsVisitable(from: .main) } private func replaceRoot(with controller: UIViewController, via proposal: VisitProposal) { diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift b/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift index 32fccb9..a49093d 100644 --- a/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift +++ b/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift @@ -1,9 +1,23 @@ import SafariServices import WebKit -/// Implement to be notified when certain navigations are performed -/// or to render a native controller instead of a Turbo web visit. protocol TurboNavigationHierarchyControllerDelegate: AnyObject { - func visit(_ : Visitable, on: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) - func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) + + /// Once the navigation hierarchy is modified, begin a visit on a navigation controller. + /// + /// - Parameters: + /// - _: the Visitable destination + /// - on: the navigation controller that was modified + /// - with: the visit options + func visit(_ : Visitable, + on: TurboNavigationHierarchyController.NavigationStackType, + with: VisitOptions) + + /// A refresh will pop (or dismiss) then ask the session to refresh the previous (or underlying) Visitable. + /// + /// - Parameters: + /// - navigationStack: the stack where the refresh is happening + /// - newTopmostVisitable: the visitable to be refreshed + func refreshVisitable(navigationStack: TurboNavigationHierarchyController.NavigationStackType, + newTopmostVisitable: Visitable) } diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 7f6db64..24cdbe5 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -215,10 +215,13 @@ extension TurboNavigator: TurboNavigationHierarchyControllerDelegate { } } - func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) { + func refreshVisitable(navigationStack: TurboNavigationHierarchyController.NavigationStackType, + newTopmostVisitable: any Visitable) { switch navigationStack { - case .main: session.reload() - case .modal: modalSession.reload() + case .main: + session.visit(newTopmostVisitable, reload: true) + case .modal: + modalSession.visit(newTopmostVisitable, reload: true) } } } diff --git a/Tests/Turbo Navigator/TurboNavigatorTests.swift b/Tests/Turbo Navigator/TurboNavigatorTests.swift index aa26088..cba3e6f 100644 --- a/Tests/Turbo Navigator/TurboNavigatorTests.swift +++ b/Tests/Turbo Navigator/TurboNavigatorTests.swift @@ -82,6 +82,61 @@ final class TurboNavigationHierarchyControllerTests: XCTestCase { XCTAssert(navigationController.viewControllers.last is VisitableViewController) assertVisited(url: proposal.url, on: .main) } + + func test_default_default_refresh_refreshesPreviousController() { + navigator.route(oneURL) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + navigator.route(twoURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 2) + + /// Refreshing should pop the view controller and refresh the underlying controller. + let proposal = VisitProposal(presentation: .refresh) + navigator.route(proposal) + + let visitable = navigator.session.activeVisitable as! VisitableViewController + XCTAssertEqual(visitable.visitableURL, oneURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + } + + func test_default_modal_refresh_refreshesPreviousController() { + navigationController.pushViewController(UIViewController(), animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + let oneURLProposal = VisitProposal(path: "/one", context: .modal) + navigator.route(oneURLProposal) + + let twoURLProposal = VisitProposal(path: "/two", context: .modal) + navigator.route(twoURLProposal) + XCTAssertEqual(modalNavigationController.viewControllers.count, 2) + + /// Refreshing should pop the view controller and refresh the underlying controller. + let proposal = VisitProposal(presentation: .refresh) + navigator.route(proposal) + + let visitable = navigator.modalSession.activeVisitable as! VisitableViewController + XCTAssertEqual(visitable.visitableURL, oneURL) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + } + + func test_default_modal_refresh_dismissesAndRefreshesMainStackTopViewController() { + navigator.route(oneURL) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + let twoURLProposal = VisitProposal(path: "/two", context: .modal) + navigator.route(twoURLProposal) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + + /// Refreshing should dismiss the view controller and refresh the underlying controller. + let proposal = VisitProposal(context: .modal, presentation: .refresh) + navigator.route(proposal) + + let visitable = navigator.session.activeVisitable as! VisitableViewController + XCTAssertEqual(visitable.visitableURL, oneURL) + + XCTAssertNil(navigationController.presentedViewController) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + } func test_default_modal_default_presentsModal() { navigationController.pushViewController(UIViewController(), animated: false) @@ -309,7 +364,7 @@ final class TurboNavigationHierarchyControllerTests: XCTestCase { private class EmptyNavigationDelegate: TurboNavigationHierarchyControllerDelegate { func visit(_: Visitable, on: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) {} - func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) {} + func refreshVisitable(navigationStack: TurboNavigationHierarchyController.NavigationStackType, newTopmostVisitable: any Visitable) { } } // MARK: - VisitProposal extension From 84f0eca6ce904aca499c34ec2bbfd2ac9f703ec2 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 4 Jul 2024 18:20:03 -0600 Subject: [PATCH 81/81] Restore instead of reloading --- Source/Turbo Navigator/TurboNavigator.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift index 24cdbe5..bf27a0b 100644 --- a/Source/Turbo Navigator/TurboNavigator.swift +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -219,9 +219,9 @@ extension TurboNavigator: TurboNavigationHierarchyControllerDelegate { newTopmostVisitable: any Visitable) { switch navigationStack { case .main: - session.visit(newTopmostVisitable, reload: true) + session.visit(newTopmostVisitable, action: .restore) case .modal: - modalSession.visit(newTopmostVisitable, reload: true) + modalSession.visit(newTopmostVisitable, action: .restore) } } }