From a71d2d36a9c2571040f3edddb509a43fd27bed4e Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 1 Nov 2023 19:58:10 -0600 Subject: [PATCH 01/20] TurboNavigator -> TurboNavigationHierarchyController --- .../TurboNavigationHierarchyController.swift | 271 ++++++++++++++++ Sources/TurboNavigator.swift | 291 ++---------------- Tests/TurboNavigatorTests.swift | 8 +- 3 files changed, 296 insertions(+), 274 deletions(-) create mode 100644 Sources/TurboNavigationHierarchyController.swift diff --git a/Sources/TurboNavigationHierarchyController.swift b/Sources/TurboNavigationHierarchyController.swift new file mode 100644 index 0000000..60fb901 --- /dev/null +++ b/Sources/TurboNavigationHierarchyController.swift @@ -0,0 +1,271 @@ +import SafariServices +import Turbo +import UIKit +import WebKit + +/// Handles navigation to new URLs using the following rules: +/// https://github.com/joemasilotti/TurboNavigator#handled-flows +public class TurboNavigationHierarchyController { + + /// Default initializer. + /// - Parameters: + /// - delegate: handle custom controller routing + /// - pathConfiguration: assigned to internal `Session` instances for custom configuration + /// - navigationController: optional: override the main navigation stack + /// - modalNavigationController: optional: override the modal navigation stack + public init(delegate: TurboNavigationDelegate, + pathConfiguration: PathConfiguration? = nil, + navigationController: UINavigationController = UINavigationController(), + modalNavigationController: UINavigationController = UINavigationController()) + { + self.session = Session(webView: TurboConfig.shared.makeWebView()) + self.modalSession = Session(webView: TurboConfig.shared.makeWebView()) + self.delegate = delegate + self.navigationController = navigationController + self.modalNavigationController = modalNavigationController + + session.delegate = self + modalSession.delegate = self + session.pathConfiguration = pathConfiguration + modalSession.pathConfiguration = pathConfiguration + } + + /// Provide `Turbo.Session` instances with preconfigured path configurations and delegates. + /// Note that TurboNavigationDelegate.controller(_:forProposal:) will no longer be called. + /// - Parameters: + /// - preconfiguredMainSession: a session whose delegate is not `TurboNavigator` + /// - preconfiguredModalSession: a session whose delegate is not `TurboNavigator` + /// - delegate: handle non-routing behavior, like custom error handling + /// - navigationController: optional: override the main navigation stack + /// - modalNavigationController: optional: override the modal navigation stack + public init(preconfiguredMainSession: Turbo.Session, + preconfiguredModalSession: Turbo.Session, + delegate: TurboNavigationDelegate, + navigationController: UINavigationController = UINavigationController(), + modalNavigationController: UINavigationController = UINavigationController()) + { + self.session = preconfiguredMainSession + self.modalSession = preconfiguredModalSession + self.navigationController = navigationController + self.modalNavigationController = modalNavigationController + + self.delegate = delegate + } + + public var rootViewController: UIViewController { navigationController } + public let navigationController: UINavigationController + public let modalNavigationController: UINavigationController + + public func route(_ proposal: VisitProposal) { + guard let controller = controller(for: proposal) else { return } + + 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. + } + } + } + + // MARK: Internal + + let session: Session + let modalSession: Session + + // MARK: Private + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private unowned let delegate: TurboNavigationDelegate + + 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 + } + } + + 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) + visit(controller, on: session, with: proposal.options) + case .modal: + if navigationController.presentedViewController != nil { + pushOrReplace(on: modalNavigationController, with: controller, via: proposal) + } else { + modalNavigationController.setViewControllers([controller], animated: false) + navigationController.present(modalNavigationController, animated: true) + } + visit(controller, on: modalSession, 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) + visit(controller, on: session, with: proposal.options) + case .modal: + if navigationController.presentedViewController != nil { + modalNavigationController.replaceLastViewController(with: controller) + } else { + modalNavigationController.setViewControllers([controller], animated: false) + navigationController.present(modalNavigationController, animated: true) + } + visit(controller, on: modalSession, with: proposal.options) + } + } + + private func refresh() { + if navigationController.presentedViewController != nil { + if modalNavigationController.viewControllers.count == 1 { + navigationController.dismiss(animated: true) + session.reload() + } else { + modalNavigationController.popViewController(animated: true) + modalSession.reload() + } + } else { + navigationController.popViewController(animated: true) + session.reload() + } + } + + private func clearAll() { + navigationController.dismiss(animated: true) + navigationController.popToRootViewController(animated: true) + session.reload() + } + + private func replaceRoot(with controller: UIViewController) { + navigationController.dismiss(animated: true) + navigationController.setViewControllers([controller], animated: true) + + if let visitable = controller as? Visitable { + session.visit(visitable, action: .replace) + } + } + + private func visit(_ controller: UIViewController, on session: Session, with options: VisitOptions) { + if let visitable = controller as? Visitable { + session.visit(visitable, options: options) + } + } +} + +// MARK: - SessionDelegate + +extension TurboNavigationHierarchyController: SessionDelegate { + public func session(_ session: Session, didProposeVisit proposal: VisitProposal) { + route(proposal) + } + + public func sessionDidFinishFormSubmission(_ session: Session) { + if session == modalSession { + self.session.clearSnapshotCache() + } + } + + public func session(_ session: Session, openExternalURL url: URL) { + let controller = session === modalSession ? modalNavigationController : navigationController + delegate.openExternalURL(url, from: controller) + } + + 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) { + delegate.sessionDidFinishRequest(session) + } + + public func sessionDidLoadWebView(_ session: Session) { + session.webView.navigationDelegate = session + delegate.sessionDidLoadWebView(session) + } +} diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 4e90bcc..864f4e0 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -1,277 +1,28 @@ -import SafariServices +// +// File 2.swift +// +// +// Created by Fernando Olivares on 01/11/23. +// + +import Foundation import Turbo -import UIKit -import WebKit -/// Handles navigation to new URLs using the following rules: -/// https://github.com/joemasilotti/TurboNavigator#handled-flows -public class TurboNavigator { - /// Default initializer. - /// - Parameters: - /// - delegate: handle custom controller routing - /// - pathConfiguration: assigned to internal `Session` instances for custom configuration - /// - navigationController: optional: override the main navigation stack - /// - modalNavigationController: optional: override the modal navigation stack - public init(delegate: TurboNavigationDelegate, - pathConfiguration: PathConfiguration? = nil, - navigationController: UINavigationController = UINavigationController(), - modalNavigationController: UINavigationController = UINavigationController()) - { - self.session = Session(webView: TurboConfig.shared.makeWebView()) - self.modalSession = Session(webView: TurboConfig.shared.makeWebView()) - self.delegate = delegate - self.navigationController = navigationController - self.modalNavigationController = modalNavigationController - - session.delegate = self - modalSession.delegate = self - session.pathConfiguration = pathConfiguration - modalSession.pathConfiguration = pathConfiguration - } - - /// Provide `Turbo.Session` instances with preconfigured path configurations and delegates. - /// Note that TurboNavigationDelegate.controller(_:forProposal:) will no longer be called. - /// - Parameters: - /// - preconfiguredMainSession: a session whose delegate is not `TurboNavigator` - /// - preconfiguredModalSession: a session whose delegate is not `TurboNavigator` - /// - delegate: handle non-routing behavior, like custom error handling - /// - navigationController: optional: override the main navigation stack - /// - modalNavigationController: optional: override the modal navigation stack - public init(preconfiguredMainSession: Turbo.Session, - preconfiguredModalSession: Turbo.Session, - delegate: TurboNavigationDelegate, - navigationController: UINavigationController = UINavigationController(), - modalNavigationController: UINavigationController = UINavigationController()) - { - self.session = preconfiguredMainSession - self.modalSession = preconfiguredModalSession - self.navigationController = navigationController - self.modalNavigationController = modalNavigationController - - self.delegate = delegate - } - - public var rootViewController: UIViewController { navigationController } - public let navigationController: UINavigationController - public let modalNavigationController: UINavigationController - - public func route(_ url: URL) { +class TurboNavigator { + + let session: Session + let modalSession: Session + let hierarchyController: TurboNavigationHierarchyController + init(session: Session, modalSession: Session, hierarchyController: TurboNavigationHierarchyController) { + self.session = session + self.modalSession = modalSession + self.hierarchyController = hierarchyController + } + + 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(proposal) - } - - public func route(_ proposal: VisitProposal) { - guard let controller = controller(for: proposal) else { return } - - 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. - } - } - } - - // MARK: Internal - - let session: Session - let modalSession: Session - - // MARK: Private - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private unowned let delegate: TurboNavigationDelegate - - 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 - } - } - - 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) - visit(controller, on: session, with: proposal.options) - case .modal: - if navigationController.presentedViewController != nil { - pushOrReplace(on: modalNavigationController, with: controller, via: proposal) - } else { - modalNavigationController.setViewControllers([controller], animated: false) - navigationController.present(modalNavigationController, animated: true) - } - visit(controller, on: modalSession, 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) - visit(controller, on: session, with: proposal.options) - case .modal: - if navigationController.presentedViewController != nil { - modalNavigationController.replaceLastViewController(with: controller) - } else { - modalNavigationController.setViewControllers([controller], animated: false) - navigationController.present(modalNavigationController, animated: true) - } - visit(controller, on: modalSession, with: proposal.options) - } - } - - private func refresh() { - if navigationController.presentedViewController != nil { - if modalNavigationController.viewControllers.count == 1 { - navigationController.dismiss(animated: true) - session.reload() - } else { - modalNavigationController.popViewController(animated: true) - modalSession.reload() - } - } else { - navigationController.popViewController(animated: true) - session.reload() - } - } - - private func clearAll() { - navigationController.dismiss(animated: true) - navigationController.popToRootViewController(animated: true) - session.reload() - } - - private func replaceRoot(with controller: UIViewController) { - navigationController.dismiss(animated: true) - navigationController.setViewControllers([controller], animated: true) - - if let visitable = controller as? Visitable { - session.visit(visitable, action: .replace) - } - } - - private func visit(_ controller: UIViewController, on session: Session, with options: VisitOptions) { - if let visitable = controller as? Visitable { - session.visit(visitable, options: options) - } - } -} - -// MARK: - SessionDelegate - -extension TurboNavigator: SessionDelegate { - public func session(_ session: Session, didProposeVisit proposal: VisitProposal) { - route(proposal) - } - - public func sessionDidFinishFormSubmission(_ session: Session) { - if session == modalSession { - self.session.clearSnapshotCache() - } - } - - public func session(_ session: Session, openExternalURL url: URL) { - let controller = session === modalSession ? modalNavigationController : navigationController - delegate.openExternalURL(url, from: controller) - } - - 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) { - delegate.sessionDidFinishRequest(session) - } - - public func sessionDidLoadWebView(_ session: Session) { - session.webView.navigationDelegate = session - delegate.sessionDidLoadWebView(session) + hierarchyController.route(proposal) } } diff --git a/Tests/TurboNavigatorTests.swift b/Tests/TurboNavigatorTests.swift index 06e0051..fa7b164 100644 --- a/Tests/TurboNavigatorTests.swift +++ b/Tests/TurboNavigatorTests.swift @@ -10,7 +10,7 @@ final class TurboNavigatorTests: XCTestCase { navigationController = TestableNavigationController() modalNavigationController = TestableNavigationController() - navigator = TurboNavigator( + navigator = TurboNavigationHierarchyController( delegate: delegate, navigationController: navigationController, modalNavigationController: modalNavigationController @@ -207,7 +207,7 @@ final class TurboNavigatorTests: XCTestCase { func test_presentingUIAlertController_doesNotWrapInNavigationController() { let alertControllerDelegate = AlertControllerDelegate() - navigator = TurboNavigator( + navigator = TurboNavigationHierarchyController( delegate: alertControllerDelegate, navigationController: navigationController, modalNavigationController: modalNavigationController @@ -220,7 +220,7 @@ final class TurboNavigatorTests: XCTestCase { func test_presentingUIAlertController_onTheModal_doesNotWrapInNavigationController() { let alertControllerDelegate = AlertControllerDelegate() - navigator = TurboNavigator( + navigator = TurboNavigationHierarchyController( delegate: alertControllerDelegate, navigationController: navigationController, modalNavigationController: modalNavigationController @@ -251,7 +251,7 @@ final class TurboNavigatorTests: XCTestCase { case main, modal } - private var navigator: TurboNavigator! + private var navigator: TurboNavigationHierarchyController! private let delegate = EmptyNavigationDelegate() private var navigationController: TestableNavigationController! private var modalNavigationController: TestableNavigationController! From e3790d0ef2dd0e3a45fcf04e43c9326b569c74da Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 1 Nov 2023 20:11:24 -0600 Subject: [PATCH 02/20] TurboNavigator now becomes a single entry point --- Sources/TurboNavigator.swift | 65 ++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 864f4e0..6aae444 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -6,17 +6,34 @@ // import Foundation +import UIKit import Turbo +import SafariServices + +protocol TurboNavigatorDelegate : AnyObject { + typealias RetryBlock = () -> Void + + /// 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) + + /// Respond to authentication challenge presented by web servers behing basic auth. + func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) +} class TurboNavigator { let session: Session let modalSession: Session let hierarchyController: TurboNavigationHierarchyController - init(session: Session, modalSession: Session, hierarchyController: TurboNavigationHierarchyController) { + + weak var delegate: TurboNavigatorDelegate? + + init(session: Session, modalSession: Session) { self.session = session self.modalSession = modalSession - self.hierarchyController = hierarchyController + self.hierarchyController = TurboNavigationHierarchyController(delegate: <#T##TurboNavigationHierarchyControllerDelegate#>) } func route(url: URL) { @@ -26,3 +43,47 @@ class TurboNavigator { hierarchyController.route(proposal) } } + +// MARK: - SessionDelegate + +extension TurboNavigator: SessionDelegate { + + public func session(_ session: Session, didProposeVisit proposal: VisitProposal) { + hierarchyController.route(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) { + // Handle cookies. Do we need to expose this? + } + + public func sessionDidLoadWebView(_ session: Session) { + session.webView.navigationDelegate = session + // Do we need to expose this? + // delegate.sessionDidLoadWebView(session) + } +} From 6dc761d181fd41b129a1cf54562e3fc26ab448ad Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 1 Nov 2023 20:20:50 -0600 Subject: [PATCH 03/20] Remove session behavior from old TurboNavigation (now TurboNavigationHierarchyController) --- Sources/TurboNavigationDelegate.swift | 70 --------- .../TurboNavigationHierarchyController.swift | 143 +++++------------- ...avigationHierarchyControllerDelegate.swift | 13 ++ Sources/TurboNavigator.swift | 37 ++++- 4 files changed, 84 insertions(+), 179 deletions(-) delete mode 100644 Sources/TurboNavigationDelegate.swift create mode 100644 Sources/TurboNavigationHierarchyControllerDelegate.swift diff --git a/Sources/TurboNavigationDelegate.swift b/Sources/TurboNavigationDelegate.swift deleted file mode 100644 index 7991216..0000000 --- a/Sources/TurboNavigationDelegate.swift +++ /dev/null @@ -1,70 +0,0 @@ -import SafariServices -import Turbo -import WebKit - -/// Implement to be notified when certain navigations are performed -/// or to render a native controller instead of a Turbo web visit. -public protocol TurboNavigationDelegate: AnyObject { - typealias RetryBlock = () -> Void - - /// Respond to authentication challenge presented by web servers behing basic auth. - func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> 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. Implement to customize handling of external URLs. - /// If not implemented, will present `SFSafariViewController` as a modal and load the URL. - func openExternalURL(_ url: URL, from controller: UIViewController) - - /// Optional. Implement to become the web view's navigation delegate after the initial cold boot visit is completed. - /// https://github.com/hotwired/turbo-ios/blob/main/Docs/Overview.md#becoming-the-web-views-navigation-delegate - func sessionDidLoadWebView(_ session: Session) - - /// Optional. Useful for interacting with the web view after the page loads. - func sessionDidFinishRequest(_ session: Session) -} - -public extension TurboNavigationDelegate { - func handle(proposal: VisitProposal) -> ProposalResult { .accept } - - func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) { - if let errorPresenter = visitable as? ErrorPresenter { - errorPresenter.presentError(error) { - retry() - } - } - } - - func openExternalURL(_ url: URL, from controller: UIViewController) { - if ["http", "https"].contains(url.scheme) { - let safariViewController = SFSafariViewController(url: url) - safariViewController.modalPresentationStyle = .pageSheet - if #available(iOS 15.0, *) { - safariViewController.preferredControlTintColor = .tintColor - } - controller.present(safariViewController, animated: true) - } else if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } - - func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - completionHandler(.performDefaultHandling, nil) - } - - func sessionDidFinishRequest(_ session: Session) {} - - func sessionDidLoadWebView(_ session: Session) {} -} diff --git a/Sources/TurboNavigationHierarchyController.swift b/Sources/TurboNavigationHierarchyController.swift index 60fb901..8eedda1 100644 --- a/Sources/TurboNavigationHierarchyController.swift +++ b/Sources/TurboNavigationHierarchyController.swift @@ -13,51 +13,20 @@ public class TurboNavigationHierarchyController { /// - pathConfiguration: assigned to internal `Session` instances for custom configuration /// - navigationController: optional: override the main navigation stack /// - modalNavigationController: optional: override the modal navigation stack - public init(delegate: TurboNavigationDelegate, - pathConfiguration: PathConfiguration? = nil, + public init(delegate: TurboNavigationHierarchyControllerDelegate, navigationController: UINavigationController = UINavigationController(), modalNavigationController: UINavigationController = UINavigationController()) { - self.session = Session(webView: TurboConfig.shared.makeWebView()) - self.modalSession = Session(webView: TurboConfig.shared.makeWebView()) self.delegate = delegate self.navigationController = navigationController self.modalNavigationController = modalNavigationController - - session.delegate = self - modalSession.delegate = self - session.pathConfiguration = pathConfiguration - modalSession.pathConfiguration = pathConfiguration - } - - /// Provide `Turbo.Session` instances with preconfigured path configurations and delegates. - /// Note that TurboNavigationDelegate.controller(_:forProposal:) will no longer be called. - /// - Parameters: - /// - preconfiguredMainSession: a session whose delegate is not `TurboNavigator` - /// - preconfiguredModalSession: a session whose delegate is not `TurboNavigator` - /// - delegate: handle non-routing behavior, like custom error handling - /// - navigationController: optional: override the main navigation stack - /// - modalNavigationController: optional: override the modal navigation stack - public init(preconfiguredMainSession: Turbo.Session, - preconfiguredModalSession: Turbo.Session, - delegate: TurboNavigationDelegate, - navigationController: UINavigationController = UINavigationController(), - modalNavigationController: UINavigationController = UINavigationController()) - { - self.session = preconfiguredMainSession - self.modalSession = preconfiguredModalSession - self.navigationController = navigationController - self.modalNavigationController = modalNavigationController - - self.delegate = delegate } - + public var rootViewController: UIViewController { navigationController } public let navigationController: UINavigationController public let modalNavigationController: UINavigationController - public func route(_ proposal: VisitProposal) { - guard let controller = controller(for: proposal) else { return } + public func route(controller: UIViewController, proposal: VisitProposal) { if let alert = controller as? UIAlertController { presentAlert(alert) @@ -82,9 +51,30 @@ public class TurboNavigationHierarchyController { } // MARK: Internal - - let session: Session - let modalSession: Session + + public enum NavigationStackType { + case main + case modal + } + + func openExternal(url: URL, navigationType: NavigationStackType) { + let controller: UINavigationController + switch navigationType { + case .main: controller = navigationController + case .modal: controller = modalNavigationController + } + + if ["http", "https"].contains(url.scheme) { + let safariViewController = SFSafariViewController(url: url) + safariViewController.modalPresentationStyle = .pageSheet + if #available(iOS 15.0, *) { + safariViewController.preferredControlTintColor = .tintColor + } + controller.present(safariViewController, animated: true) + } else if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } // MARK: Private @@ -93,18 +83,7 @@ public class TurboNavigationHierarchyController { fatalError("init(coder:) has not been implemented") } - private unowned let delegate: TurboNavigationDelegate - - 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 - } - } + private unowned let delegate: TurboNavigationHierarchyControllerDelegate private func presentAlert(_ alert: UIAlertController) { if navigationController.presentedViewController != nil { @@ -119,7 +98,7 @@ public class TurboNavigationHierarchyController { case .default: navigationController.dismiss(animated: true) pushOrReplace(on: navigationController, with: controller, via: proposal) - visit(controller, on: session, with: proposal.options) + delegate.visit(controller, on: .main, with: proposal.options) case .modal: if navigationController.presentedViewController != nil { pushOrReplace(on: modalNavigationController, with: controller, via: proposal) @@ -127,7 +106,7 @@ public class TurboNavigationHierarchyController { modalNavigationController.setViewControllers([controller], animated: false) navigationController.present(modalNavigationController, animated: true) } - visit(controller, on: modalSession, with: proposal.options) + delegate.visit(controller, on: .modal, with: proposal.options) } } @@ -179,7 +158,7 @@ public class TurboNavigationHierarchyController { case .default: navigationController.dismiss(animated: true) navigationController.replaceLastViewController(with: controller) - visit(controller, on: session, with: proposal.options) + delegate.visit(controller, on: .main, with: proposal.options) case .modal: if navigationController.presentedViewController != nil { modalNavigationController.replaceLastViewController(with: controller) @@ -187,7 +166,7 @@ public class TurboNavigationHierarchyController { modalNavigationController.setViewControllers([controller], animated: false) navigationController.present(modalNavigationController, animated: true) } - visit(controller, on: modalSession, with: proposal.options) + delegate.visit(controller, on: .modal, with: proposal.options) } } @@ -195,21 +174,21 @@ public class TurboNavigationHierarchyController { if navigationController.presentedViewController != nil { if modalNavigationController.viewControllers.count == 1 { navigationController.dismiss(animated: true) - session.reload() + delegate.refresh(navigationStack: .main) } else { modalNavigationController.popViewController(animated: true) - modalSession.reload() + delegate.refresh(navigationStack: .modal) } } else { navigationController.popViewController(animated: true) - session.reload() + delegate.refresh(navigationStack: .main) } } private func clearAll() { navigationController.dismiss(animated: true) navigationController.popToRootViewController(animated: true) - session.reload() + delegate.refresh(navigationStack: .main) } private func replaceRoot(with controller: UIViewController) { @@ -217,55 +196,7 @@ public class TurboNavigationHierarchyController { navigationController.setViewControllers([controller], animated: true) if let visitable = controller as? Visitable { - session.visit(visitable, action: .replace) + delegate.visit(controller, on: .main, with: .init(action: .replace)) } } - - private func visit(_ controller: UIViewController, on session: Session, with options: VisitOptions) { - if let visitable = controller as? Visitable { - session.visit(visitable, options: options) - } - } -} - -// MARK: - SessionDelegate - -extension TurboNavigationHierarchyController: SessionDelegate { - public func session(_ session: Session, didProposeVisit proposal: VisitProposal) { - route(proposal) - } - - public func sessionDidFinishFormSubmission(_ session: Session) { - if session == modalSession { - self.session.clearSnapshotCache() - } - } - - public func session(_ session: Session, openExternalURL url: URL) { - let controller = session === modalSession ? modalNavigationController : navigationController - delegate.openExternalURL(url, from: controller) - } - - 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) { - delegate.sessionDidFinishRequest(session) - } - - public func sessionDidLoadWebView(_ session: Session) { - session.webView.navigationDelegate = session - delegate.sessionDidLoadWebView(session) - } } diff --git a/Sources/TurboNavigationHierarchyControllerDelegate.swift b/Sources/TurboNavigationHierarchyControllerDelegate.swift new file mode 100644 index 0000000..1e37a8a --- /dev/null +++ b/Sources/TurboNavigationHierarchyControllerDelegate.swift @@ -0,0 +1,13 @@ +import SafariServices +import Turbo +import WebKit + +/// Implement to be notified when certain navigations are performed +/// or to render a native controller instead of a Turbo web visit. +public protocol TurboNavigationHierarchyControllerDelegate: AnyObject { + func visit(_ : UIViewController, + on: TurboNavigationHierarchyController.NavigationStackType, + with: VisitOptions) + + func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) +} diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 6aae444..9c6ea6a 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -13,6 +13,15 @@ import SafariServices 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. @@ -33,14 +42,32 @@ class TurboNavigator { init(session: Session, modalSession: Session) { self.session = session self.modalSession = modalSession - self.hierarchyController = TurboNavigationHierarchyController(delegate: <#T##TurboNavigationHierarchyControllerDelegate#>) + self.hierarchyController = TurboNavigationHierarchyController() } 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) - hierarchyController.route(proposal) + let controller = controller(for: proposal) + + guard let controller else { return } + + hierarchyController.route(controller: controller, proposal: proposal) + } + + private func controller(for proposal: VisitProposal) -> UIViewController? { + + guard let delegate else { return nil } + + switch delegate.handle(proposal: proposal) { + case .accept: + return VisitableViewController(url: proposal.url) + case .acceptCustom(let customViewController): + return customViewController + case .reject: + return nil + } } } @@ -49,7 +76,11 @@ class TurboNavigator { extension TurboNavigator: SessionDelegate { public func session(_ session: Session, didProposeVisit proposal: VisitProposal) { - hierarchyController.route(proposal) + + guard let controller = controller(for: proposal) else { return } + + hierarchyController.route(controller: controller, + proposal: proposal) } public func sessionDidFinishFormSubmission(_ session: Session) { From 93685d78166b51b84ac76230a493e22ab4ef852f Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 3 Nov 2023 20:22:33 -0600 Subject: [PATCH 04/20] Continue polishing --- Demo/Demo/SceneDelegate.swift | 15 ++-- .../UINavigationControllerExtension.swift | 0 .../VisitProposalExtension.swift | 0 .../VisitableViewControllerExtension.swift | 0 Sources/{ => HElpers}/ErrorPresenter.swift | 0 Sources/{ => HElpers}/Navigation.swift | 0 .../PathConfigurationIdentifiable.swift | 0 Sources/{ => HElpers}/ProposalResult.swift | 0 Sources/{ => HElpers}/TurboConfig.swift | 2 +- .../TurboNavigationHierarchyController.swift | 83 +++++++++++-------- ...avigationHierarchyControllerDelegate.swift | 4 +- Sources/TurboNavigator.swift | 79 ++++++++++-------- Sources/TurboNavigatorDelegate.swift | 26 ++++++ 13 files changed, 132 insertions(+), 77 deletions(-) rename Sources/{ => Extensions}/UINavigationControllerExtension.swift (100%) rename Sources/{ => Extensions}/VisitProposalExtension.swift (100%) rename Sources/{ => Extensions}/VisitableViewControllerExtension.swift (100%) rename Sources/{ => HElpers}/ErrorPresenter.swift (100%) rename Sources/{ => HElpers}/Navigation.swift (100%) rename Sources/{ => HElpers}/PathConfigurationIdentifiable.swift (100%) rename Sources/{ => HElpers}/ProposalResult.swift (100%) rename Sources/{ => HElpers}/TurboConfig.swift (96%) create mode 100644 Sources/TurboNavigatorDelegate.swift diff --git a/Demo/Demo/SceneDelegate.swift b/Demo/Demo/SceneDelegate.swift index d099f8e..87e5845 100644 --- a/Demo/Demo/SceneDelegate.swift +++ b/Demo/Demo/SceneDelegate.swift @@ -6,21 +6,26 @@ let baseURL = URL(string: "http://localhost:3000")! class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - - private lazy var turboNavigator = TurboNavigator(delegate: self, pathConfiguration: pathConfiguration) + + private var turboNavigator: TurboNavigator! + private lazy var pathConfiguration = PathConfiguration(sources: [ .server(baseURL.appendingPathComponent("/configurations/ios_v1.json")) ]) func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + let mainSession = Session(webView: TurboConfig.shared.makeWebView()) + let modalSession = Session(webView: TurboConfig.shared.makeWebView()) + turboNavigator = TurboNavigator(session: mainSession, + modalSession: modalSession) + self.window = UIWindow(windowScene: windowScene) self.window?.makeKeyAndVisible() self.window?.rootViewController = self.turboNavigator.rootViewController - self.turboNavigator.route(baseURL) + self.turboNavigator.route(url: baseURL) } } - -extension SceneDelegate: TurboNavigationDelegate {} diff --git a/Sources/UINavigationControllerExtension.swift b/Sources/Extensions/UINavigationControllerExtension.swift similarity index 100% rename from Sources/UINavigationControllerExtension.swift rename to Sources/Extensions/UINavigationControllerExtension.swift diff --git a/Sources/VisitProposalExtension.swift b/Sources/Extensions/VisitProposalExtension.swift similarity index 100% rename from Sources/VisitProposalExtension.swift rename to Sources/Extensions/VisitProposalExtension.swift diff --git a/Sources/VisitableViewControllerExtension.swift b/Sources/Extensions/VisitableViewControllerExtension.swift similarity index 100% rename from Sources/VisitableViewControllerExtension.swift rename to Sources/Extensions/VisitableViewControllerExtension.swift diff --git a/Sources/ErrorPresenter.swift b/Sources/HElpers/ErrorPresenter.swift similarity index 100% rename from Sources/ErrorPresenter.swift rename to Sources/HElpers/ErrorPresenter.swift diff --git a/Sources/Navigation.swift b/Sources/HElpers/Navigation.swift similarity index 100% rename from Sources/Navigation.swift rename to Sources/HElpers/Navigation.swift diff --git a/Sources/PathConfigurationIdentifiable.swift b/Sources/HElpers/PathConfigurationIdentifiable.swift similarity index 100% rename from Sources/PathConfigurationIdentifiable.swift rename to Sources/HElpers/PathConfigurationIdentifiable.swift diff --git a/Sources/ProposalResult.swift b/Sources/HElpers/ProposalResult.swift similarity index 100% rename from Sources/ProposalResult.swift rename to Sources/HElpers/ProposalResult.swift diff --git a/Sources/TurboConfig.swift b/Sources/HElpers/TurboConfig.swift similarity index 96% rename from Sources/TurboConfig.swift rename to Sources/HElpers/TurboConfig.swift index 5c4c51f..cf2122e 100644 --- a/Sources/TurboConfig.swift +++ b/Sources/HElpers/TurboConfig.swift @@ -17,7 +17,7 @@ public class TurboConfig { // MARK: - Internal - func makeWebView() -> WKWebView { + public func makeWebView() -> WKWebView { makeCustomWebView(makeWebViewConfiguration()) } diff --git a/Sources/TurboNavigationHierarchyController.swift b/Sources/TurboNavigationHierarchyController.swift index 8eedda1..d4ce74a 100644 --- a/Sources/TurboNavigationHierarchyController.swift +++ b/Sources/TurboNavigationHierarchyController.swift @@ -5,28 +5,35 @@ import WebKit /// Handles navigation to new URLs using the following rules: /// https://github.com/joemasilotti/TurboNavigator#handled-flows -public class TurboNavigationHierarchyController { +class TurboNavigationHierarchyController { + + let navigationController: UINavigationController + let modalNavigationController: UINavigationController + var rootViewController: UIViewController { navigationController } + + enum NavigationStackType { + case main + case modal + } + + func navController(for navigationType: NavigationStackType) -> UINavigationController { + switch navigationType { + case .main: return navigationController + case .modal: return modalNavigationController + } + } /// Default initializer. + /// /// - Parameters: - /// - delegate: handle custom controller routing - /// - pathConfiguration: assigned to internal `Session` instances for custom configuration - /// - navigationController: optional: override the main navigation stack - /// - modalNavigationController: optional: override the modal navigation stack - public init(delegate: TurboNavigationHierarchyControllerDelegate, - navigationController: UINavigationController = UINavigationController(), - modalNavigationController: UINavigationController = UINavigationController()) - { + /// - delegate: handles visits and refresh + init(delegate: TurboNavigationHierarchyControllerDelegate) { self.delegate = delegate - self.navigationController = navigationController - self.modalNavigationController = modalNavigationController + self.navigationController = UINavigationController() + self.modalNavigationController = UINavigationController() } - - public var rootViewController: UIViewController { navigationController } - public let navigationController: UINavigationController - public let modalNavigationController: UINavigationController - public func route(controller: UIViewController, proposal: VisitProposal) { + func route(controller: UIViewController, proposal: VisitProposal) { if let alert = controller as? UIAlertController { presentAlert(alert) @@ -49,28 +56,16 @@ public class TurboNavigationHierarchyController { } } } - - // MARK: Internal - - public enum NavigationStackType { - case main - case modal - } func openExternal(url: URL, navigationType: NavigationStackType) { - let controller: UINavigationController - switch navigationType { - case .main: controller = navigationController - case .modal: controller = modalNavigationController - } - if ["http", "https"].contains(url.scheme) { let safariViewController = SFSafariViewController(url: url) safariViewController.modalPresentationStyle = .pageSheet if #available(iOS 15.0, *) { safariViewController.preferredControlTintColor = .tintColor } - controller.present(safariViewController, animated: true) + let navController = navController(for: navigationType) + navController.present(safariViewController, animated: true) } else if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } @@ -98,7 +93,11 @@ public class TurboNavigationHierarchyController { case .default: navigationController.dismiss(animated: true) pushOrReplace(on: navigationController, with: controller, via: proposal) - delegate.visit(controller, on: .main, with: proposal.options) + 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) @@ -106,7 +105,11 @@ public class TurboNavigationHierarchyController { modalNavigationController.setViewControllers([controller], animated: false) navigationController.present(modalNavigationController, animated: true) } - delegate.visit(controller, on: .modal, with: proposal.options) + if let visitable = controller as? Visitable { + delegate.visit(visitable, + on: .modal, + with: proposal.options) + } } } @@ -158,7 +161,11 @@ public class TurboNavigationHierarchyController { case .default: navigationController.dismiss(animated: true) navigationController.replaceLastViewController(with: controller) - delegate.visit(controller, on: .main, with: proposal.options) + if let visitable = controller as? Visitable { + delegate.visit(visitable, + on: .main, + with: proposal.options) + } case .modal: if navigationController.presentedViewController != nil { modalNavigationController.replaceLastViewController(with: controller) @@ -166,7 +173,11 @@ public class TurboNavigationHierarchyController { modalNavigationController.setViewControllers([controller], animated: false) navigationController.present(modalNavigationController, animated: true) } - delegate.visit(controller, on: .modal, with: proposal.options) + if let visitable = controller as? Visitable { + delegate.visit(visitable, + on: .modal, + with: proposal.options) + } } } @@ -196,7 +207,9 @@ public class TurboNavigationHierarchyController { navigationController.setViewControllers([controller], animated: true) if let visitable = controller as? Visitable { - delegate.visit(controller, on: .main, with: .init(action: .replace)) + delegate.visit(visitable, + on: .main, + with: .init(action: .replace)) } } } diff --git a/Sources/TurboNavigationHierarchyControllerDelegate.swift b/Sources/TurboNavigationHierarchyControllerDelegate.swift index 1e37a8a..02dc546 100644 --- a/Sources/TurboNavigationHierarchyControllerDelegate.swift +++ b/Sources/TurboNavigationHierarchyControllerDelegate.swift @@ -4,8 +4,8 @@ import WebKit /// Implement to be notified when certain navigations are performed /// or to render a native controller instead of a Turbo web visit. -public protocol TurboNavigationHierarchyControllerDelegate: AnyObject { - func visit(_ : UIViewController, +protocol TurboNavigationHierarchyControllerDelegate: AnyObject { + func visit(_ : Visitable, on: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 9c6ea6a..36cace8 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -10,55 +10,45 @@ import UIKit import Turbo import SafariServices -protocol TurboNavigatorDelegate : AnyObject { - typealias RetryBlock = () -> Void +public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { - /// 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) - - /// Respond to authentication challenge presented by web servers behing basic auth. - func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -} - -class TurboNavigator { + public weak var delegate: TurboNavigatorDelegate? - let session: Session - let modalSession: Session - let hierarchyController: TurboNavigationHierarchyController + public var rootViewController: UINavigationController { hierarchyController.navigationController } - weak var delegate: TurboNavigatorDelegate? - - init(session: Session, modalSession: Session) { + public init(session: Session, + modalSession: Session, + delegate: TurboNavigatorDelegate? = nil) { self.session = session self.modalSession = modalSession - self.hierarchyController = TurboNavigationHierarchyController() + self.delegate = delegate } - func route(url: URL) { + /// 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) - let controller = controller(for: proposal) - guard let controller else { return } + guard let controller = controller(for: proposal) else { return } hierarchyController.route(controller: controller, proposal: proposal) } + private let session: Session + private let modalSession: Session + + /// Modifies a UINavigationController according to visit proposals. + private lazy var hierarchyController = TurboNavigationHierarchyController(delegate: self) + private func controller(for proposal: VisitProposal) -> UIViewController? { - guard let delegate else { return nil } + guard let delegate else { + return VisitableViewController(url: proposal.url) + } switch delegate.handle(proposal: proposal) { case .accept: @@ -109,12 +99,33 @@ extension TurboNavigator: SessionDelegate { } public func sessionDidFinishRequest(_ session: Session) { - // Handle cookies. Do we need to expose this? + // Do we need to expose this if we save cookies? } public func sessionDidLoadWebView(_ session: Session) { session.webView.navigationDelegate = session // Do we need to expose this? - // delegate.sessionDidLoadWebView(session) + } +} + +// MARK: TurboNavigationHierarchyControllerDelegate +extension TurboNavigator { + + func visit(_ controller: Visitable, + on: TurboNavigationHierarchyController.NavigationStackType, + with: Turbo.VisitOptions) { + switch on { + case .main: + session.visit(controller, action: .advance) + case .modal: + session.visit(controller, action: .advance) + } + } + + func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) { + switch navigationStack { + case .main: session.reload() + case .modal: session.reload() + } } } diff --git a/Sources/TurboNavigatorDelegate.swift b/Sources/TurboNavigatorDelegate.swift new file mode 100644 index 0000000..e6711a9 --- /dev/null +++ b/Sources/TurboNavigatorDelegate.swift @@ -0,0 +1,26 @@ +import Foundation +import Turbo + +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) + + /// Respond to authentication challenge presented by web servers behing basic auth. + func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) +} From 5275fd11117fd34e96b682c03266a06bef31aaa7 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Mon, 6 Nov 2023 18:37:53 -0600 Subject: [PATCH 05/20] Set session delegate --- Sources/TurboNavigator.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 36cace8..ab4bb62 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -22,6 +22,9 @@ public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { self.session = session self.modalSession = modalSession self.delegate = delegate + + self.session.delegate = self + self.modalSession.delegate = self } /// Transforms `URL` -> `VisitProposal` -> `UIViewController`. From 979400e1c9524f6455f1d2f9ddc8ebb47216a361 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Mon, 6 Nov 2023 18:46:37 -0600 Subject: [PATCH 06/20] Add missing path config --- Demo/Demo/SceneDelegate.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Demo/Demo/SceneDelegate.swift b/Demo/Demo/SceneDelegate.swift index 87e5845..9a3879d 100644 --- a/Demo/Demo/SceneDelegate.swift +++ b/Demo/Demo/SceneDelegate.swift @@ -18,7 +18,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard let windowScene = scene as? UIWindowScene else { return } let mainSession = Session(webView: TurboConfig.shared.makeWebView()) + mainSession.pathConfiguration = pathConfiguration + let modalSession = Session(webView: TurboConfig.shared.makeWebView()) + modalSession.pathConfiguration = pathConfiguration + turboNavigator = TurboNavigator(session: mainSession, modalSession: modalSession) From 438f8e71942acb8f17651b2f978498eb7cc68304 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Mon, 6 Nov 2023 20:45:29 -0600 Subject: [PATCH 07/20] Begin re-enabling tests --- .../TurboNavigationHierarchyController.swift | 42 +- Sources/TurboNavigator.swift | 6 +- Tests/TurboNavigationDelegateTests.swift | 41 +- Tests/TurboNavigatorTests.swift | 374 ++++++++++-------- 4 files changed, 245 insertions(+), 218 deletions(-) diff --git a/Sources/TurboNavigationHierarchyController.swift b/Sources/TurboNavigationHierarchyController.swift index d4ce74a..b2202d1 100644 --- a/Sources/TurboNavigationHierarchyController.swift +++ b/Sources/TurboNavigationHierarchyController.swift @@ -11,6 +11,8 @@ class TurboNavigationHierarchyController { let modalNavigationController: UINavigationController var rootViewController: UIViewController { navigationController } + var animationsEnabled: Bool = true + enum NavigationStackType { case main case modal @@ -65,7 +67,7 @@ class TurboNavigationHierarchyController { safariViewController.preferredControlTintColor = .tintColor } let navController = navController(for: navigationType) - navController.present(safariViewController, animated: true) + navController.present(safariViewController, animated: animationsEnabled) } else if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } @@ -82,16 +84,16 @@ class TurboNavigationHierarchyController { private func presentAlert(_ alert: UIAlertController) { if navigationController.presentedViewController != nil { - modalNavigationController.present(alert, animated: true) + modalNavigationController.present(alert, animated: animationsEnabled) } else { - navigationController.present(alert, animated: true) + navigationController.present(alert, animated: animationsEnabled) } } private func navigate(with controller: UIViewController, via proposal: VisitProposal) { switch proposal.context { case .default: - navigationController.dismiss(animated: true) + navigationController.dismiss(animated: animationsEnabled) pushOrReplace(on: navigationController, with: controller, via: proposal) if let visitable = controller as? Visitable { delegate.visit(visitable, @@ -102,8 +104,8 @@ class TurboNavigationHierarchyController { if navigationController.presentedViewController != nil { pushOrReplace(on: modalNavigationController, with: controller, via: proposal) } else { - modalNavigationController.setViewControllers([controller], animated: false) - navigationController.present(modalNavigationController, animated: true) + modalNavigationController.setViewControllers([controller], animated: animationsEnabled) + navigationController.present(modalNavigationController, animated: animationsEnabled) } if let visitable = controller as? Visitable { delegate.visit(visitable, @@ -117,9 +119,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: animationsEnabled) } else if proposal.options.action == .advance { - navigationController.pushViewController(controller, animated: true) + navigationController.pushViewController(controller, animated: animationsEnabled) } else { navigationController.replaceLastViewController(with: controller) } @@ -147,19 +149,19 @@ class TurboNavigationHierarchyController { private func pop() { if navigationController.presentedViewController != nil { if modalNavigationController.viewControllers.count == 1 { - navigationController.dismiss(animated: true) + navigationController.dismiss(animated: animationsEnabled) } else { - modalNavigationController.popViewController(animated: true) + modalNavigationController.popViewController(animated: animationsEnabled) } } else { - navigationController.popViewController(animated: true) + navigationController.popViewController(animated: animationsEnabled) } } private func replace(with controller: UIViewController, via proposal: VisitProposal) { switch proposal.context { case .default: - navigationController.dismiss(animated: true) + navigationController.dismiss(animated: animationsEnabled) navigationController.replaceLastViewController(with: controller) if let visitable = controller as? Visitable { delegate.visit(visitable, @@ -171,7 +173,7 @@ class TurboNavigationHierarchyController { modalNavigationController.replaceLastViewController(with: controller) } else { modalNavigationController.setViewControllers([controller], animated: false) - navigationController.present(modalNavigationController, animated: true) + navigationController.present(modalNavigationController, animated: animationsEnabled) } if let visitable = controller as? Visitable { delegate.visit(visitable, @@ -184,27 +186,27 @@ class TurboNavigationHierarchyController { private func refresh() { if navigationController.presentedViewController != nil { if modalNavigationController.viewControllers.count == 1 { - navigationController.dismiss(animated: true) + navigationController.dismiss(animated: animationsEnabled) delegate.refresh(navigationStack: .main) } else { - modalNavigationController.popViewController(animated: true) + modalNavigationController.popViewController(animated: animationsEnabled) delegate.refresh(navigationStack: .modal) } } else { - navigationController.popViewController(animated: true) + navigationController.popViewController(animated: animationsEnabled) delegate.refresh(navigationStack: .main) } } private func clearAll() { - navigationController.dismiss(animated: true) - navigationController.popToRootViewController(animated: true) + navigationController.dismiss(animated: animationsEnabled) + navigationController.popToRootViewController(animated: animationsEnabled) delegate.refresh(navigationStack: .main) } private func replaceRoot(with controller: UIViewController) { - navigationController.dismiss(animated: true) - navigationController.setViewControllers([controller], animated: true) + navigationController.dismiss(animated: animationsEnabled) + navigationController.setViewControllers([controller], animated: animationsEnabled) if let visitable = controller as? Visitable { delegate.visit(visitable, diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index ab4bb62..4e9a193 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -41,11 +41,11 @@ public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { hierarchyController.route(controller: controller, proposal: proposal) } - private let session: Session - private let modalSession: Session + let session: Session + let modalSession: Session /// Modifies a UINavigationController according to visit proposals. - private lazy var hierarchyController = TurboNavigationHierarchyController(delegate: self) + lazy var hierarchyController = TurboNavigationHierarchyController(delegate: self) private func controller(for proposal: VisitProposal) -> UIViewController? { diff --git a/Tests/TurboNavigationDelegateTests.swift b/Tests/TurboNavigationDelegateTests.swift index 65aa5f9..2e1798b 100644 --- a/Tests/TurboNavigationDelegateTests.swift +++ b/Tests/TurboNavigationDelegateTests.swift @@ -3,33 +3,34 @@ import Turbo @testable import TurboNavigator import XCTest -final class TurboNavigationDelegateTests: XCTestCase { - 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) - } +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() +// private let delegate = DefaultDelegate() } // MARK: - DefaultDelegate -private class DefaultDelegate: TurboNavigationDelegate { +private class DefaultDelegate { func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {} } diff --git a/Tests/TurboNavigatorTests.swift b/Tests/TurboNavigatorTests.swift index fa7b164..2e14474 100644 --- a/Tests/TurboNavigatorTests.swift +++ b/Tests/TurboNavigatorTests.swift @@ -5,244 +5,244 @@ 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 TurboNavigatorTests: XCTestCase { +final class TurboNavigationHierarchyControllerTests: XCTestCase { + override func setUp() { navigationController = TestableNavigationController() modalNavigationController = TestableNavigationController() - navigator = TurboNavigationHierarchyController( - delegate: delegate, - navigationController: navigationController, - modalNavigationController: modalNavigationController - ) + navigator = TurboNavigator(session: session, modalSession: modalSession) + hierarchyController = TurboNavigationHierarchyController(delegate: navigator) + hierarchyController.animationsEnabled = false + navigator.hierarchyController = hierarchyController pushInitialViewControllersOnNavigationController() loadNavigationControllerInWindow() } func test_default_default_default_pushesOnMainStack() { - navigator.route(VisitProposal(path: "/one")) - XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.viewControllers.last is VisitableViewController) - - let proposal = VisitProposal(path: "/two") - navigator.route(proposal) - XCTAssertEqual(navigationController.viewControllers.count, 3) - XCTAssert(navigationController.viewControllers.last is VisitableViewController) - assertVisited(url: proposal.url, on: .main) + navigator.route(url: baseURL.appendingPathComponent("/one")) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) + + let twoURL = baseURL.appendingPathComponent("/two") + navigator.route(url: 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(VisitProposal(path: "/one")) - XCTAssertEqual(navigationController.viewControllers.count, 2) - - let proposal = VisitProposal(path: "/one") - navigator.route(proposal) - XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.viewControllers.last is VisitableViewController) - assertVisited(url: proposal.url, on: .main) +// navigator.route(VisitProposal(path: "/one")) +// XCTAssertEqual(navigationController.viewControllers.count, 2) +// +// let proposal = VisitProposal(path: "/one") +// navigator.route(proposal) +// XCTAssertEqual(navigationController.viewControllers.count, 2) +// XCTAssert(navigationController.viewControllers.last is VisitableViewController) +// assertVisited(url: proposal.url, on: .main) } func test_default_default_default_visitingPreviousPage_popsAndVisitsOnMainStack() { - navigator.route(VisitProposal(path: "/one")) - XCTAssertEqual(navigationController.viewControllers.count, 2) - - navigator.route(VisitProposal(path: "/two")) - XCTAssertEqual(navigationController.viewControllers.count, 3) - - let proposal = VisitProposal(path: "/one") - navigator.route(proposal) - XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.viewControllers.last is VisitableViewController) - assertVisited(url: proposal.url, on: .main) +// navigator.route(VisitProposal(path: "/one")) +// XCTAssertEqual(navigationController.viewControllers.count, 2) +// +// navigator.route(VisitProposal(path: "/two")) +// XCTAssertEqual(navigationController.viewControllers.count, 3) +// +// let proposal = VisitProposal(path: "/one") +// navigator.route(proposal) +// XCTAssertEqual(navigationController.viewControllers.count, 2) +// XCTAssert(navigationController.viewControllers.last is VisitableViewController) +// assertVisited(url: proposal.url, 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) +// 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, 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) +// 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) +// 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) +// 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) +// 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) +// 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) +// 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) +// 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 @@ -251,7 +251,11 @@ final class TurboNavigatorTests: XCTestCase { case main, modal } - private var navigator: TurboNavigationHierarchyController! + 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! @@ -275,14 +279,26 @@ final class TurboNavigatorTests: XCTestCase { case .main: XCTAssertEqual(navigator.session.activeVisitable?.visitableURL, url) case .modal: - XCTAssertEqual(navigator.modalSession.activeVisitable?.visitableURL, url) +// XCTAssertEqual(navigator.modalSession.activeVisitable?.visitableURL, url) + break } } } // MARK: - EmptyNavigationDelegate -private class EmptyNavigationDelegate: TurboNavigationDelegate {} +private class EmptyNavigationDelegate: TurboNavigationHierarchyControllerDelegate { + + func visit(_: Turbo.Visitable, + on: TurboNavigationHierarchyController.NavigationStackType, + with: Turbo.VisitOptions) { + + } + + func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) { + + } +} // MARK: - VisitProposal extension @@ -300,7 +316,15 @@ private extension VisitProposal { // MARK: - AlertControllerDelegate -private class AlertControllerDelegate: TurboNavigationDelegate { +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)) From 10f5e5d469269ec7d59ae4789e87a154eeeb87d4 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Mon, 6 Nov 2023 20:49:46 -0600 Subject: [PATCH 08/20] Fix some more unit tests --- Tests/TurboNavigatorTests.swift | 42 ++++++++++++++++----------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/Tests/TurboNavigatorTests.swift b/Tests/TurboNavigatorTests.swift index 2e14474..dc0802a 100644 --- a/Tests/TurboNavigatorTests.swift +++ b/Tests/TurboNavigatorTests.swift @@ -7,6 +7,9 @@ import XCTest /// 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() @@ -21,11 +24,10 @@ final class TurboNavigationHierarchyControllerTests: XCTestCase { } func test_default_default_default_pushesOnMainStack() { - navigator.route(url: baseURL.appendingPathComponent("/one")) + navigator.route(url: oneURL) XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) - let twoURL = baseURL.appendingPathComponent("/two") navigator.route(url: twoURL) XCTAssertEqual(navigator.rootViewController.viewControllers.count, 2) XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) @@ -33,28 +35,26 @@ final class TurboNavigationHierarchyControllerTests: XCTestCase { } func test_default_default_default_visitingSamePage_replacesOnMainStack() { -// navigator.route(VisitProposal(path: "/one")) -// XCTAssertEqual(navigationController.viewControllers.count, 2) -// -// let proposal = VisitProposal(path: "/one") -// navigator.route(proposal) -// XCTAssertEqual(navigationController.viewControllers.count, 2) -// XCTAssert(navigationController.viewControllers.last is VisitableViewController) -// assertVisited(url: proposal.url, on: .main) + navigator.route(url: oneURL) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + navigator.route(url: 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(VisitProposal(path: "/one")) -// XCTAssertEqual(navigationController.viewControllers.count, 2) -// -// navigator.route(VisitProposal(path: "/two")) -// XCTAssertEqual(navigationController.viewControllers.count, 3) -// -// let proposal = VisitProposal(path: "/one") -// navigator.route(proposal) -// XCTAssertEqual(navigationController.viewControllers.count, 2) -// XCTAssert(navigationController.viewControllers.last is VisitableViewController) -// assertVisited(url: proposal.url, on: .main) + navigator.route(url: oneURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + + navigator.route(url: twoURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 2) + + navigator.route(url: 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() { From 7dd626086536faa5e2530e8cc35305bbd5227f62 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Mon, 6 Nov 2023 20:58:16 -0600 Subject: [PATCH 09/20] Add a WebkitUIDelegate object --- Sources/TurboNavigator.swift | 9 ++++++++- Sources/TurboWKUIDelegate.swift | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 Sources/TurboWKUIDelegate.swift diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 4e9a193..801c20c 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -16,7 +16,14 @@ public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { public var rootViewController: UINavigationController { hierarchyController.navigationController } - public init(session: Session, + public var webkitUIDelegate: TurboWKUIController? { + didSet { + session.webView.uiDelegate = webkitUIDelegate + modalSession.webView.uiDelegate = webkitUIDelegate + } + } + + public init(session: Session, modalSession: Session, delegate: TurboNavigatorDelegate? = nil) { self.session = session diff --git a/Sources/TurboWKUIDelegate.swift b/Sources/TurboWKUIDelegate.swift new file mode 100644 index 0000000..ebd11d6 --- /dev/null +++ b/Sources/TurboWKUIDelegate.swift @@ -0,0 +1,33 @@ +import Foundation +import WebKit + +public protocol TurboWKUIDelegate : AnyObject { + func present(_ alert: UIAlertController, animated: Bool) +} + +public class TurboWKUIController : NSObject, WKUIDelegate { + + weak var delegate: TurboWKUIDelegate? + 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) + } +} From ad0bddab2d71b8b9dc166ed1e052c951c6d9f698 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Mon, 6 Nov 2023 21:10:21 -0600 Subject: [PATCH 10/20] Add TurboWKUIDelegate conformance to TurboNavigator --- Sources/TurboNavigator.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 801c20c..6ff1c37 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -139,3 +139,9 @@ extension TurboNavigator { } } } + +extension TurboNavigator : TurboWKUIDelegate { + public func present(_ alert: UIAlertController, animated: Bool) { + rootViewController.present(alert, animated: animated) + } +} From dd662ee7202fd444831b44e40345c3869efb57ad Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 7 Nov 2023 11:08:54 -0800 Subject: [PATCH 11/20] =?UTF-8?q?Fix=20typo=20in=20group=20name:=20HElpers?= =?UTF-8?q?=20=E2=86=92=20Helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/{HElpers => Helpers}/ErrorPresenter.swift | 0 Sources/{HElpers => Helpers}/Navigation.swift | 0 Sources/{HElpers => Helpers}/PathConfigurationIdentifiable.swift | 0 Sources/{HElpers => Helpers}/ProposalResult.swift | 0 Sources/{HElpers => Helpers}/TurboConfig.swift | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename Sources/{HElpers => Helpers}/ErrorPresenter.swift (100%) rename Sources/{HElpers => Helpers}/Navigation.swift (100%) rename Sources/{HElpers => Helpers}/PathConfigurationIdentifiable.swift (100%) rename Sources/{HElpers => Helpers}/ProposalResult.swift (100%) rename Sources/{HElpers => Helpers}/TurboConfig.swift (100%) diff --git a/Sources/HElpers/ErrorPresenter.swift b/Sources/Helpers/ErrorPresenter.swift similarity index 100% rename from Sources/HElpers/ErrorPresenter.swift rename to Sources/Helpers/ErrorPresenter.swift diff --git a/Sources/HElpers/Navigation.swift b/Sources/Helpers/Navigation.swift similarity index 100% rename from Sources/HElpers/Navigation.swift rename to Sources/Helpers/Navigation.swift diff --git a/Sources/HElpers/PathConfigurationIdentifiable.swift b/Sources/Helpers/PathConfigurationIdentifiable.swift similarity index 100% rename from Sources/HElpers/PathConfigurationIdentifiable.swift rename to Sources/Helpers/PathConfigurationIdentifiable.swift diff --git a/Sources/HElpers/ProposalResult.swift b/Sources/Helpers/ProposalResult.swift similarity index 100% rename from Sources/HElpers/ProposalResult.swift rename to Sources/Helpers/ProposalResult.swift diff --git a/Sources/HElpers/TurboConfig.swift b/Sources/Helpers/TurboConfig.swift similarity index 100% rename from Sources/HElpers/TurboConfig.swift rename to Sources/Helpers/TurboConfig.swift From ba44036c080eceaceb7de384e73a77795ed1c625 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 7 Nov 2023 11:11:14 -0800 Subject: [PATCH 12/20] =?UTF-8?q?route(url:)=20=E2=86=92=20route(=5F:)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Demo/Demo/SceneDelegate.swift | 2 +- Sources/TurboNavigator.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Demo/Demo/SceneDelegate.swift b/Demo/Demo/SceneDelegate.swift index 9a3879d..709f847 100644 --- a/Demo/Demo/SceneDelegate.swift +++ b/Demo/Demo/SceneDelegate.swift @@ -30,6 +30,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { self.window?.makeKeyAndVisible() self.window?.rootViewController = self.turboNavigator.rootViewController - self.turboNavigator.route(url: baseURL) + self.turboNavigator.route(baseURL) } } diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 6ff1c37..d5bad62 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -38,7 +38,7 @@ public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { /// Given the `VisitProposal`'s properties, push or present this view controller. /// /// - Parameter url: the URL to visit. - public func route(url: URL) { + 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) From 9c82925348535619861cb1017571dd2142e1b481 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 7 Nov 2023 11:21:50 -0800 Subject: [PATCH 13/20] Ensure presenting modals on alerts works --- Sources/TurboNavigationHierarchyController.swift | 5 ++++- Sources/TurboNavigator.swift | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/TurboNavigationHierarchyController.swift b/Sources/TurboNavigationHierarchyController.swift index b2202d1..3cefb5d 100644 --- a/Sources/TurboNavigationHierarchyController.swift +++ b/Sources/TurboNavigationHierarchyController.swift @@ -10,7 +10,10 @@ class TurboNavigationHierarchyController { let navigationController: UINavigationController let modalNavigationController: UINavigationController var rootViewController: UIViewController { navigationController } - + var activeNavigationController: UINavigationController { + navigationController.presentedViewController != nil ? modalNavigationController : navigationController + } + var animationsEnabled: Bool = true enum NavigationStackType { diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index d5bad62..8f76b78 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -142,6 +142,6 @@ extension TurboNavigator { extension TurboNavigator : TurboWKUIDelegate { public func present(_ alert: UIAlertController, animated: Bool) { - rootViewController.present(alert, animated: animated) + hierarchyController.activeNavigationController.present(alert, animated: animated) } } From 2e250ce96e19984c9e979be6f7cd7127541c644a Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 7 Nov 2023 11:23:39 -0800 Subject: [PATCH 14/20] Rework TurboNavigatorDelegate * Ensure one is always set * Expose an open, default implementation * Implement optional methods in extension --- Sources/TurboNavigator.swift | 28 ++++++++++++---------------- Sources/TurboNavigatorDelegate.swift | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 8f76b78..8100972 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -11,9 +11,8 @@ import Turbo import SafariServices public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { - - public weak var delegate: TurboNavigatorDelegate? - + public unowned var delegate: TurboNavigatorDelegate + public var rootViewController: UINavigationController { hierarchyController.navigationController } public var webkitUIDelegate: TurboWKUIController? { @@ -22,14 +21,13 @@ public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { modalSession.webView.uiDelegate = webkitUIDelegate } } - - public init(session: Session, - modalSession: Session, - delegate: TurboNavigatorDelegate? = nil) { + + public init(session: Session, modalSession: Session, delegate: TurboNavigatorDelegate? = nil) { self.session = session self.modalSession = modalSession - self.delegate = delegate + self.delegate = delegate ?? navigatorDelegate + self.session.delegate = self self.modalSession.delegate = self } @@ -53,13 +51,11 @@ public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { /// 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? { - - guard let delegate else { - return VisitableViewController(url: proposal.url) - } - switch delegate.handle(proposal: proposal) { case .accept: return VisitableViewController(url: proposal.url) @@ -95,7 +91,7 @@ extension TurboNavigator: SessionDelegate { } public func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) { - delegate?.visitableDidFailRequest(visitable, error: error) { + delegate.visitableDidFailRequest(visitable, error: error) { session.reload() } } @@ -105,7 +101,7 @@ extension TurboNavigator: SessionDelegate { } public func session(_ session: Session, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - delegate?.didReceiveAuthenticationChallenge(challenge, completionHandler: completionHandler) + delegate.didReceiveAuthenticationChallenge(challenge, completionHandler: completionHandler) } public func sessionDidFinishRequest(_ session: Session) { diff --git a/Sources/TurboNavigatorDelegate.swift b/Sources/TurboNavigatorDelegate.swift index e6711a9..5203379 100644 --- a/Sources/TurboNavigatorDelegate.swift +++ b/Sources/TurboNavigatorDelegate.swift @@ -24,3 +24,19 @@ public protocol TurboNavigatorDelegate : AnyObject { func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) } + +open class DefaultTurboNavigatorDelegate: NSObject, TurboNavigatorDelegate { + open func handle(proposal: VisitProposal) -> ProposalResult { + .accept + } + + open func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) { + if let errorPresenter = visitable as? ErrorPresenter { + errorPresenter.presentError(error, handler: retry) + } + } + + open func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + completionHandler(.performDefaultHandling, nil) + } +} From 657078a2fa428303e622ecdc82061f8b159c5cd1 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 7 Nov 2023 11:25:15 -0800 Subject: [PATCH 15/20] Ensure WKUIDelegate is set upon initialization --- Sources/TurboNavigator.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 8100972..bde897b 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -30,6 +30,8 @@ public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { self.session.delegate = self self.modalSession.delegate = self + + defer { self.webkitUIDelegate = TurboWKUIController(delegate: self) } } /// Transforms `URL` -> `VisitProposal` -> `UIViewController`. From 74124a0ff81b9b32157b77db3841e5faef461b24 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 7 Nov 2023 11:25:54 -0800 Subject: [PATCH 16/20] Code formatting --- Demo/Demo/SceneDelegate.swift | 18 +++--- .../TurboNavigationHierarchyController.swift | 37 ++++------- ...avigationHierarchyControllerDelegate.swift | 4 +- Sources/TurboNavigator.swift | 62 +++++++------------ Sources/TurboNavigatorDelegate.swift | 18 +++--- Sources/TurboWKUIDelegate.swift | 1 - 6 files changed, 53 insertions(+), 87 deletions(-) diff --git a/Demo/Demo/SceneDelegate.swift b/Demo/Demo/SceneDelegate.swift index 709f847..c7b7d95 100644 --- a/Demo/Demo/SceneDelegate.swift +++ b/Demo/Demo/SceneDelegate.swift @@ -6,26 +6,24 @@ let baseURL = URL(string: "http://localhost:3000")! class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - + private var turboNavigator: TurboNavigator! - + private lazy var pathConfiguration = PathConfiguration(sources: [ .server(baseURL.appendingPathComponent("/configurations/ios_v1.json")) ]) func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - guard let windowScene = scene as? UIWindowScene else { return } let mainSession = Session(webView: TurboConfig.shared.makeWebView()) - mainSession.pathConfiguration = pathConfiguration - + mainSession.pathConfiguration = self.pathConfiguration + let modalSession = Session(webView: TurboConfig.shared.makeWebView()) - modalSession.pathConfiguration = pathConfiguration - - turboNavigator = TurboNavigator(session: mainSession, - modalSession: modalSession) - + modalSession.pathConfiguration = self.pathConfiguration + + self.turboNavigator = TurboNavigator(session: mainSession, modalSession: modalSession) + self.window = UIWindow(windowScene: windowScene) self.window?.makeKeyAndVisible() diff --git a/Sources/TurboNavigationHierarchyController.swift b/Sources/TurboNavigationHierarchyController.swift index 3cefb5d..3750a8d 100644 --- a/Sources/TurboNavigationHierarchyController.swift +++ b/Sources/TurboNavigationHierarchyController.swift @@ -6,30 +6,30 @@ 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 } var animationsEnabled: Bool = true - + enum NavigationStackType { case main case modal } - + func navController(for navigationType: NavigationStackType) -> UINavigationController { switch navigationType { - case .main: return navigationController - case .modal: return modalNavigationController + case .main: navigationController + case .modal: modalNavigationController } } - + /// Default initializer. - /// + /// /// - Parameters: /// - delegate: handles visits and refresh init(delegate: TurboNavigationHierarchyControllerDelegate) { @@ -39,7 +39,6 @@ class TurboNavigationHierarchyController { } func route(controller: UIViewController, proposal: VisitProposal) { - if let alert = controller as? UIAlertController { presentAlert(alert) } else { @@ -61,7 +60,7 @@ class TurboNavigationHierarchyController { } } } - + func openExternal(url: URL, navigationType: NavigationStackType) { if ["http", "https"].contains(url.scheme) { let safariViewController = SFSafariViewController(url: url) @@ -99,9 +98,7 @@ class TurboNavigationHierarchyController { navigationController.dismiss(animated: animationsEnabled) pushOrReplace(on: navigationController, with: controller, via: proposal) if let visitable = controller as? Visitable { - delegate.visit(visitable, - on: .main, - with: proposal.options) + delegate.visit(visitable, on: .main, with: proposal.options) } case .modal: if navigationController.presentedViewController != nil { @@ -111,9 +108,7 @@ class TurboNavigationHierarchyController { navigationController.present(modalNavigationController, animated: animationsEnabled) } if let visitable = controller as? Visitable { - delegate.visit(visitable, - on: .modal, - with: proposal.options) + delegate.visit(visitable, on: .modal, with: proposal.options) } } } @@ -167,9 +162,7 @@ class TurboNavigationHierarchyController { navigationController.dismiss(animated: animationsEnabled) navigationController.replaceLastViewController(with: controller) if let visitable = controller as? Visitable { - delegate.visit(visitable, - on: .main, - with: proposal.options) + delegate.visit(visitable, on: .main, with: proposal.options) } case .modal: if navigationController.presentedViewController != nil { @@ -179,9 +172,7 @@ class TurboNavigationHierarchyController { navigationController.present(modalNavigationController, animated: animationsEnabled) } if let visitable = controller as? Visitable { - delegate.visit(visitable, - on: .modal, - with: proposal.options) + delegate.visit(visitable, on: .modal, with: proposal.options) } } } @@ -212,9 +203,7 @@ class TurboNavigationHierarchyController { navigationController.setViewControllers([controller], animated: animationsEnabled) if let visitable = controller as? Visitable { - delegate.visit(visitable, - on: .main, - with: .init(action: .replace)) + delegate.visit(visitable, on: .main, with: .init(action: .replace)) } } } diff --git a/Sources/TurboNavigationHierarchyControllerDelegate.swift b/Sources/TurboNavigationHierarchyControllerDelegate.swift index 02dc546..d50ab17 100644 --- a/Sources/TurboNavigationHierarchyControllerDelegate.swift +++ b/Sources/TurboNavigationHierarchyControllerDelegate.swift @@ -5,9 +5,7 @@ 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 visit(_ : Visitable, on: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) func refresh(navigationStack: TurboNavigationHierarchyController.NavigationStackType) } diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index bde897b..9719159 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -1,20 +1,13 @@ -// -// File 2.swift -// -// -// Created by Fernando Olivares on 01/11/23. -// - import Foundation -import UIKit -import Turbo import SafariServices +import Turbo +import UIKit public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { public unowned var delegate: TurboNavigatorDelegate public var rootViewController: UINavigationController { hierarchyController.navigationController } - + public var webkitUIDelegate: TurboWKUIController? { didSet { session.webView.uiDelegate = webkitUIDelegate @@ -33,7 +26,7 @@ public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { defer { self.webkitUIDelegate = TurboWKUIController(delegate: self) } } - + /// Transforms `URL` -> `VisitProposal` -> `UIViewController`. /// Given the `VisitProposal`'s properties, push or present this view controller. /// @@ -42,15 +35,14 @@ public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { 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) @@ -59,12 +51,12 @@ public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { 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 + case .accept: + return VisitableViewController(url: proposal.url) + case .acceptCustom(let customViewController): + return customViewController + case .reject: + return nil } } } @@ -72,13 +64,9 @@ public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { // 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) + hierarchyController.route(controller: controller, proposal: proposal) } public func sessionDidFinishFormSubmission(_ session: Session) { @@ -117,28 +105,24 @@ extension TurboNavigator: SessionDelegate { } // MARK: TurboNavigationHierarchyControllerDelegate + extension TurboNavigator { - - func visit(_ controller: Visitable, - on: TurboNavigationHierarchyController.NavigationStackType, - with: Turbo.VisitOptions) { - switch on { - case .main: - session.visit(controller, action: .advance) - case .modal: - session.visit(controller, action: .advance) + func visit(_ controller: Visitable, on navigationStack: TurboNavigationHierarchyController.NavigationStackType, with: Turbo.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: session.reload() + case .main: session.reload() + case .modal: modalSession.reload() } } } -extension TurboNavigator : TurboWKUIDelegate { +extension TurboNavigator: TurboWKUIDelegate { public func present(_ alert: UIAlertController, animated: Bool) { hierarchyController.activeNavigationController.present(alert, animated: animated) } diff --git a/Sources/TurboNavigatorDelegate.swift b/Sources/TurboNavigatorDelegate.swift index 5203379..452b0c1 100644 --- a/Sources/TurboNavigatorDelegate.swift +++ b/Sources/TurboNavigatorDelegate.swift @@ -1,9 +1,9 @@ import Foundation import Turbo -public protocol TurboNavigatorDelegate : AnyObject { +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. @@ -12,17 +12,15 @@ public protocol TurboNavigatorDelegate : AnyObject { /// - 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) - - /// Respond to authentication challenge presented by web servers behing basic auth. - func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + 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) } open class DefaultTurboNavigatorDelegate: NSObject, TurboNavigatorDelegate { diff --git a/Sources/TurboWKUIDelegate.swift b/Sources/TurboWKUIDelegate.swift index ebd11d6..94eb1d4 100644 --- a/Sources/TurboWKUIDelegate.swift +++ b/Sources/TurboWKUIDelegate.swift @@ -6,7 +6,6 @@ public protocol TurboWKUIDelegate : AnyObject { } public class TurboWKUIController : NSObject, WKUIDelegate { - weak var delegate: TurboWKUIDelegate? init(delegate: TurboWKUIDelegate!) { self.delegate = delegate From ee81553bf5b98d9197d5ea6b0fd264b25c3c567c Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 7 Nov 2023 11:37:24 -0800 Subject: [PATCH 17/20] Convenience initializer to not expose Session --- Sources/TurboNavigator.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 9719159..8978a5d 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -27,6 +27,16 @@ public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { defer { self.webkitUIDelegate = TurboWKUIController(delegate: self) } } + public convenience init(pathConfiguration: PathConfiguration, delegate: TurboNavigatorDelegate? = nil) { + let session = Session() + session.pathConfiguration = pathConfiguration + + let modalSession = Session() + session.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. /// From f753dffaf67187470405e23a526fb91d7af98b1d Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 7 Nov 2023 11:41:33 -0800 Subject: [PATCH 18/20] Copy over cookies after each completed web request for authentication! --- Sources/TurboNavigator.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 8978a5d..1dc7e47 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -2,6 +2,7 @@ import Foundation import SafariServices import Turbo import UIKit +import WebKit public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { public unowned var delegate: TurboNavigatorDelegate @@ -105,7 +106,11 @@ extension TurboNavigator: SessionDelegate { } public func sessionDidFinishRequest(_ session: Session) { - // Do we need to expose this if we save cookies? + 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) { From eb1b4ea2b5b8b1abb71baf4d164542cd4da8a516 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 8 Nov 2023 11:04:23 -0800 Subject: [PATCH 19/20] Refactor default class to a protocol extension --- Demo/Demo/SceneDelegate.swift | 13 ++----------- Sources/TurboNavigator.swift | 22 +++++++++++++++++++--- Sources/TurboNavigatorDelegate.swift | 8 ++++---- Sources/TurboWKUIDelegate.swift | 15 ++++++++------- 4 files changed, 33 insertions(+), 25 deletions(-) diff --git a/Demo/Demo/SceneDelegate.swift b/Demo/Demo/SceneDelegate.swift index c7b7d95..2c9c290 100644 --- a/Demo/Demo/SceneDelegate.swift +++ b/Demo/Demo/SceneDelegate.swift @@ -7,23 +7,14 @@ let baseURL = URL(string: "http://localhost:3000")! class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - private var turboNavigator: TurboNavigator! - - private lazy var pathConfiguration = PathConfiguration(sources: [ + private lazy var turboNavigator = TurboNavigator(pathConfiguration: pathConfiguration) + private let pathConfiguration = PathConfiguration(sources: [ .server(baseURL.appendingPathComponent("/configurations/ios_v1.json")) ]) func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = scene as? UIWindowScene else { return } - let mainSession = Session(webView: TurboConfig.shared.makeWebView()) - mainSession.pathConfiguration = self.pathConfiguration - - let modalSession = Session(webView: TurboConfig.shared.makeWebView()) - modalSession.pathConfiguration = self.pathConfiguration - - self.turboNavigator = TurboNavigator(session: mainSession, modalSession: modalSession) - self.window = UIWindow(windowScene: windowScene) self.window?.makeKeyAndVisible() diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 1dc7e47..53dc7e8 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -4,11 +4,16 @@ import Turbo import UIKit import WebKit +class DefaultTurboNavigatorDelegate: NSObject, TurboNavigatorDelegate {} + public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { public unowned var delegate: TurboNavigatorDelegate public var rootViewController: UINavigationController { hierarchyController.navigationController } + /// 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 @@ -16,23 +21,34 @@ public class TurboNavigator: TurboNavigationHierarchyControllerDelegate { } } + /// 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 + // Defer to trigger didSet callback. defer { self.webkitUIDelegate = TurboWKUIController(delegate: self) } } + /// 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() + let session = Session(webView: TurboConfig.shared.makeWebView()) session.pathConfiguration = pathConfiguration - let modalSession = Session() + let modalSession = Session(webView: TurboConfig.shared.makeWebView()) session.pathConfiguration = pathConfiguration self.init(session: session, modalSession: modalSession, delegate: delegate) diff --git a/Sources/TurboNavigatorDelegate.swift b/Sources/TurboNavigatorDelegate.swift index 452b0c1..1492c81 100644 --- a/Sources/TurboNavigatorDelegate.swift +++ b/Sources/TurboNavigatorDelegate.swift @@ -23,18 +23,18 @@ public protocol TurboNavigatorDelegate: AnyObject { func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) } -open class DefaultTurboNavigatorDelegate: NSObject, TurboNavigatorDelegate { - open func handle(proposal: VisitProposal) -> ProposalResult { +public extension TurboNavigatorDelegate { + func handle(proposal: VisitProposal) -> ProposalResult { .accept } - open func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) { + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) { if let errorPresenter = visitable as? ErrorPresenter { errorPresenter.presentError(error, handler: retry) } } - open func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { completionHandler(.performDefaultHandling, nil) } } diff --git a/Sources/TurboWKUIDelegate.swift b/Sources/TurboWKUIDelegate.swift index 94eb1d4..5a2ad57 100644 --- a/Sources/TurboWKUIDelegate.swift +++ b/Sources/TurboWKUIDelegate.swift @@ -1,22 +1,23 @@ import Foundation import WebKit -public protocol TurboWKUIDelegate : AnyObject { +public protocol TurboWKUIDelegate: AnyObject { func present(_ alert: UIAlertController, animated: Bool) } -public class TurboWKUIController : NSObject, WKUIDelegate { - weak var delegate: TurboWKUIDelegate? - init(delegate: TurboWKUIDelegate!) { +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) + delegate.present(alert, animated: true) } open func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) { @@ -27,6 +28,6 @@ public class TurboWKUIController : NSObject, WKUIDelegate { alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completionHandler(false) }) - delegate?.present(alert, animated: true) + delegate.present(alert, animated: true) } } From ded3633f07ad54a4b83b3723553ab6d5a6dce613 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Wed, 8 Nov 2023 11:05:25 -0800 Subject: [PATCH 20/20] TEMP: Example with more Turbo Navigator features --- Demo/Demo/SceneDelegate.swift | 54 +++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/Demo/Demo/SceneDelegate.swift b/Demo/Demo/SceneDelegate.swift index 2c9c290..683ddff 100644 --- a/Demo/Demo/SceneDelegate.swift +++ b/Demo/Demo/SceneDelegate.swift @@ -22,3 +22,57 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { self.turboNavigator.route(baseURL) } } + +import WebKit + +/// This example class shows how one can use more features of Turbo Navigator. +class ComplexSceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + private lazy var turboNavigator = TurboNavigator(pathConfiguration: pathConfiguration, delegate: self) + private let pathConfiguration = PathConfiguration(sources: [ + .server(baseURL.appendingPathComponent("/configurations/ios_v1.json")) + ]) + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + + TurboConfig.shared.userAgent += " CUSTOM STRADA USER AGENT" + TurboConfig.shared.makeCustomWebView = { config in + let webView = WKWebView(frame: .zero, configuration: config) + // Bridge.initialize(webView) + return webView + } + + self.turboNavigator.webkitUIDelegate = ExampleWKUIController(delegate: self.turboNavigator) + + self.window = UIWindow(windowScene: windowScene) + self.window?.makeKeyAndVisible() + + self.window?.rootViewController = self.turboNavigator.rootViewController + self.turboNavigator.route(baseURL) + } +} + +extension ComplexSceneDelegate: TurboNavigatorDelegate { + func handle(proposal: VisitProposal) -> ProposalResult { + switch proposal.viewController { + case "example": .acceptCustom(ExampleViewController()) + default: .accept + } + } +} + +class ExampleViewController: UIViewController {} + +class ExampleWKUIController: TurboWKUIController { + // Overridden: custom handling of confirm() dialog. + override func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) { + completionHandler(true) + } + + // New function: custom handling of prompt() dialog. + func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) { + completionHandler("Hi!") + } +}