diff --git a/.gitignore b/.gitignore index e598886..d9db622 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ xcuserdata -Carthage +.build/ diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Turbo.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Turbo.xcscheme index 3bbbb80..d81c404 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Turbo.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Turbo.xcscheme @@ -1,6 +1,6 @@ Bool { #if DEBUG - TurboLog.debugLoggingEnabled = true + Turbo.config.debugLoggingEnabled = true Strada.config.debugLoggingEnabled = true #endif - + return true } diff --git a/Demo/Base.lproj/Main.storyboard b/Demo/Base.lproj/Main.storyboard deleted file mode 100644 index deda0a4..0000000 --- a/Demo/Base.lproj/Main.storyboard +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Demo/Demo.swift b/Demo/Demo.swift index 33b223c..a8d2caa 100644 --- a/Demo/Demo.swift +++ b/Demo/Demo.swift @@ -3,7 +3,7 @@ import Foundation struct Demo { static let basic = URL(string: "https://turbo-native-demo.glitch.me")! static let turbolinks5 = URL(string: "https://turbo-native-demo.glitch.me?turbolinks=1")! - + static let local = URL(string: "http://localhost:45678")! static let turbolinks5Local = URL(string: "http://localhost:45678?turbolinks=1")! diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 62ffeba..b9516a2 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -3,21 +3,19 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ + 846E252B2AFFEDCA00B93F7E /* Navigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846E252A2AFFEDCA00B93F7E /* Navigator.swift */; }; 84ACD7322AAE743300234C57 /* Turbo in Frameworks */ = {isa = PBXBuildFile; productRef = 84ACD7312AAE743300234C57 /* Turbo */; }; - C106CBE3257FF87700498F6F /* ErrorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C106CBE2257FF87700498F6F /* ErrorPresenter.swift */; }; C10DF228257AB81D009412E7 /* path-configuration.json in Resources */ = {isa = PBXBuildFile; fileRef = C10DF227257AB81D009412E7 /* path-configuration.json */; }; C153F0082578057900926D30 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C153F0072578057900926D30 /* AppDelegate.swift */; }; C153F00A2578057900926D30 /* SceneController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C153F0092578057900926D30 /* SceneController.swift */; }; - C153F00F2578057900926D30 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C153F00D2578057900926D30 /* Main.storyboard */; }; C153F0112578057A00926D30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C153F0102578057A00926D30 /* Assets.xcassets */; }; C153F0142578057A00926D30 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C153F0122578057A00926D30 /* LaunchScreen.storyboard */; }; C153F03525784BEA00926D30 /* NumbersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C153F03425784BEA00926D30 /* NumbersViewController.swift */; }; C175FE782579905300C8DF50 /* Demo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C175FE772579905300C8DF50 /* Demo.swift */; }; - CB4FB651273AE23B00119FD3 /* TurboNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB4FB650273AE23B00119FD3 /* TurboNavigationController.swift */; }; E226F7822AB1B7F20059D594 /* Strada in Frameworks */ = {isa = PBXBuildFile; productRef = E226F7812AB1B7F20059D594 /* Strada */; }; E226F7842AB1BBF30059D594 /* TurboWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E226F7832AB1BBF30059D594 /* TurboWebViewController.swift */; }; E226F7872AB1BE030059D594 /* WKWebViewConfiguration+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = E226F7862AB1BE030059D594 /* WKWebViewConfiguration+App.swift */; }; @@ -41,20 +39,18 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 846E252A2AFFEDCA00B93F7E /* Navigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Navigator.swift; sourceTree = ""; }; 84ACD72F2AAE733C00234C57 /* turbo-ios */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "turbo-ios"; path = ..; sourceTree = ""; }; - C106CBE2257FF87700498F6F /* ErrorPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorPresenter.swift; sourceTree = ""; }; C10DF227257AB81D009412E7 /* path-configuration.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "path-configuration.json"; sourceTree = ""; }; C153F0042578057900926D30 /* Turbo Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Turbo Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; C153F0072578057900926D30 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; C153F0092578057900926D30 /* SceneController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneController.swift; sourceTree = ""; }; - C153F00E2578057900926D30 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; C153F0102578057A00926D30 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C153F0132578057A00926D30 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; C153F0152578057A00926D30 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C153F0332578302F00926D30 /* Turbo Demo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Turbo Demo.entitlements"; sourceTree = ""; }; C153F03425784BEA00926D30 /* NumbersViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumbersViewController.swift; sourceTree = ""; }; C175FE772579905300C8DF50 /* Demo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Demo.swift; sourceTree = ""; }; - CB4FB650273AE23B00119FD3 /* TurboNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TurboNavigationController.swift; sourceTree = ""; }; E226F7832AB1BBF30059D594 /* TurboWebViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TurboWebViewController.swift; sourceTree = ""; }; E226F7862AB1BE030059D594 /* WKWebViewConfiguration+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WKWebViewConfiguration+App.swift"; sourceTree = ""; }; E226F7892AB1BF880059D594 /* FormComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormComponent.swift; sourceTree = ""; }; @@ -114,15 +110,13 @@ children = ( E226F7882AB1BF210059D594 /* Strada */, E226F7852AB1BDF10059D594 /* Web */, - CB4FB64F273AE22800119FD3 /* Navigation */, C175FE772579905300C8DF50 /* Demo.swift */, C153F0072578057900926D30 /* AppDelegate.swift */, + 846E252A2AFFEDCA00B93F7E /* Navigator.swift */, C153F0092578057900926D30 /* SceneController.swift */, E226F7832AB1BBF30059D594 /* TurboWebViewController.swift */, C153F03425784BEA00926D30 /* NumbersViewController.swift */, - C106CBE2257FF87700498F6F /* ErrorPresenter.swift */, C153F0102578057A00926D30 /* Assets.xcassets */, - C153F00D2578057900926D30 /* Main.storyboard */, C153F0122578057A00926D30 /* LaunchScreen.storyboard */, C153F0152578057A00926D30 /* Info.plist */, C153F0332578302F00926D30 /* Turbo Demo.entitlements */, @@ -131,14 +125,6 @@ name = Demo; sourceTree = ""; }; - CB4FB64F273AE22800119FD3 /* Navigation */ = { - isa = PBXGroup; - children = ( - CB4FB650273AE23B00119FD3 /* TurboNavigationController.swift */, - ); - path = Navigation; - sourceTree = ""; - }; E226F7852AB1BDF10059D594 /* Web */ = { isa = PBXGroup; children = ( @@ -189,8 +175,9 @@ C153EFFC2578057900926D30 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1220; - LastUpgradeCheck = 1220; + LastUpgradeCheck = 1500; TargetAttributes = { C153F0032578057900926D30 = { CreatedOnToolsVersion = 12.2; @@ -226,7 +213,6 @@ C153F0142578057A00926D30 /* LaunchScreen.storyboard in Resources */, C10DF228257AB81D009412E7 /* path-configuration.json in Resources */, C153F0112578057A00926D30 /* Assets.xcassets in Resources */, - C153F00F2578057900926D30 /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -242,27 +228,18 @@ C153F0082578057900926D30 /* AppDelegate.swift in Sources */, E226F78A2AB1BF880059D594 /* FormComponent.swift in Sources */, E226F78C2AB1CE2E0059D594 /* BridgeComponent+App.swift in Sources */, - CB4FB651273AE23B00119FD3 /* TurboNavigationController.swift in Sources */, E226F7872AB1BE030059D594 /* WKWebViewConfiguration+App.swift in Sources */, C175FE782579905300C8DF50 /* Demo.swift in Sources */, C153F00A2578057900926D30 /* SceneController.swift in Sources */, E226F78E2AB1D2C20059D594 /* MenuComponent.swift in Sources */, + 846E252B2AFFEDCA00B93F7E /* Navigator.swift in Sources */, E226F7902AB1D7260059D594 /* OverflowMenuComponent.swift in Sources */, - C106CBE3257FF87700498F6F /* ErrorPresenter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ - C153F00D2578057900926D30 /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - C153F00E2578057900926D30 /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; C153F0122578057A00926D30 /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -278,6 +255,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -311,6 +289,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -339,6 +318,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -372,6 +352,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -397,7 +378,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Turbo Demo.entitlements"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 2WNYUYRS7G; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -420,7 +401,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Turbo Demo.entitlements"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 2WNYUYRS7G; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4a5d77f..314ddd7 100644 --- a/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,30 +1,12 @@ { "pins" : [ { - "identity" : "cwlcatchexception", + "identity" : "embassy", "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlCatchException.git", + "location" : "https://github.com/envoy/Embassy.git", "state" : { - "revision" : "3b123999de19bf04905bc1dfdb76f817b0f2cc00", - "version" : "2.1.2" - } - }, - { - "identity" : "cwlpreconditiontesting", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", - "state" : { - "revision" : "a23ded2c91df9156628a6996ab4f347526f17b6b", - "version" : "2.1.2" - } - }, - { - "identity" : "nimble", - "kind" : "remoteSourceControl", - "location" : "https://github.com/quick/nimble", - "state" : { - "revision" : "1f3bde57bde12f5e7b07909848c071e9b73d6edc", - "version" : "10.0.0" + "revision" : "8469f2c1b334a7c1c3566e2cb2f97826c7cca898", + "version" : "4.1.6" } }, { @@ -36,15 +18,6 @@ "version" : "9.1.0" } }, - { - "identity" : "quick", - "kind" : "remoteSourceControl", - "location" : "https://github.com/quick/quick", - "state" : { - "revision" : "f9d519828bb03dfc8125467d8f7b93131951124c", - "version" : "5.0.1" - } - }, { "identity" : "strada-ios", "kind" : "remoteSourceControl", @@ -53,15 +26,6 @@ "branch" : "main", "revision" : "3f8e6a0a07d2361bb3a64a6e6a945124eed20ccf" } - }, - { - "identity" : "swifter", - "kind" : "remoteSourceControl", - "location" : "https://github.com/httpswift/swifter.git", - "state" : { - "revision" : "9483a5d459b45c3ffd059f7b55f9638e268632fd", - "version" : "1.5.0" - } } ], "version" : 2 diff --git a/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme b/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme index 2d56ba5..6053b81 100644 --- a/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme +++ b/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme @@ -1,6 +1,6 @@ Void - - func presentError(_ error: Error, handler: @escaping Handler) -} - -extension ErrorPresenter { - func presentError(_ error: Error, handler: @escaping Handler) { - let errorViewController = ErrorViewController() - errorViewController.configure(with: error) { [unowned self] in - self.removeErrorViewController(errorViewController) - handler() - } - - let errorView = errorViewController.view! - errorView.translatesAutoresizingMaskIntoConstraints = false - - addChild(errorViewController) - view.addSubview(errorView) - NSLayoutConstraint.activate([ - errorView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - errorView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - errorView.topAnchor.constraint(equalTo: view.topAnchor), - errorView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - errorViewController.didMove(toParent: self) - } - - private func removeErrorViewController(_ errorViewController: UIViewController) { - errorViewController.willMove(toParent: nil) - errorViewController.view.removeFromSuperview() - errorViewController.removeFromParent() - } -} - -final class ErrorViewController: UIViewController { - var handler: ErrorPresenter.Handler? - - override func viewDidLoad() { - super.viewDidLoad() - setup() - } - - private func setup() { - view.backgroundColor = .systemBackground - - let vStack = UIStackView(arrangedSubviews: [imageView, titleLabel, bodyLabel, button]) - vStack.translatesAutoresizingMaskIntoConstraints = false - vStack.axis = .vertical - vStack.spacing = 16 - vStack.alignment = .center - - view.addSubview(vStack) - NSLayoutConstraint.activate([ - vStack.centerYAnchor.constraint(equalTo: view.centerYAnchor), - vStack.centerXAnchor.constraint(equalTo: view.centerXAnchor), - vStack.leadingAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 32), - vStack.trailingAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -32), - ]) - } - - func configure(with error: Error, handler: @escaping ErrorPresenter.Handler) { - titleLabel.text = "Error loading page" - bodyLabel.text = error.localizedDescription - self.handler = handler - } - - @objc func performAction(_ sender: UIButton) { - handler?() - } - - // MARK: - Views - - private let imageView: UIImageView = { - let configuration = UIImage.SymbolConfiguration(pointSize: 38, weight: .semibold) - let image = UIImage(systemName: "exclamationmark.triangle", withConfiguration: configuration) - let imageView = UIImageView(image: image) - imageView.translatesAutoresizingMaskIntoConstraints = false - - return imageView - }() - - private let titleLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = UIFont.preferredFont(forTextStyle: .largeTitle) - label.textAlignment = .center - - return label - }() - - private let bodyLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = UIFont.preferredFont(forTextStyle: .body) - label.textAlignment = .center - label.numberOfLines = 0 - - return label - }() - - private lazy var button: UIButton = { - let button = UIButton(type: .system) - button.setTitle("Retry", for: .normal) - button.addTarget(self, action: #selector(performAction(_:)), for: .touchUpInside) - button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 17) - - return button - }() -} diff --git a/Demo/Info.plist b/Demo/Info.plist index 21f2301..69c979b 100644 --- a/Demo/Info.plist +++ b/Demo/Info.plist @@ -38,8 +38,6 @@ Default Configuration UISceneDelegateClassName $(PRODUCT_MODULE_NAME).SceneController - UISceneStoryboardFile - Main @@ -48,8 +46,6 @@ UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main UIRequiredDeviceCapabilities armv7 diff --git a/Demo/Navigation/TurboNavigationController.swift b/Demo/Navigation/TurboNavigationController.swift deleted file mode 100644 index 7e50bc1..0000000 --- a/Demo/Navigation/TurboNavigationController.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// TurboNavigationController.swift -// Demo -// -// Created by Fernando Olivares on 08/11/21. -// - -import Foundation -import UIKit -import Turbo -import Strada - -class TurboNavigationController : UINavigationController { - - var session: Session! - var modalSession: Session! - - func push(url: URL) { - let properties = session.pathConfiguration?.properties(for: url) ?? [:] - route(url: url, - options: VisitOptions(action: .advance), - properties: properties) - } - - func route(url: URL, options: VisitOptions, properties: PathProperties) { - // This is a simplified version of how you might build out the routing - // and navigation functions of your app. In a real app, these would be separate objects - - // Dismiss any modals when receiving a new navigation - if presentedViewController != nil { - dismiss(animated: true) - } - - // Special case of navigating home, issue a reload - if url.path == "/", !viewControllers.isEmpty { - popViewController(animated: false) - session.reload() - return - } - - // - Create view controller appropriate for url/properties - // - Navigate to that with the correct presentation - // - Initiate the visit with Turbo - let viewController = makeViewController(for: url, properties: properties) - navigate(to: viewController, action: options.action, properties: properties) - visit(viewController: viewController, with: options, modal: isModal(properties)) - } -} - -extension TurboNavigationController { - - private func isModal(_ properties: PathProperties) -> Bool { - // For simplicity, we're using string literals for various keys and values of the path configuration - // but most likely you'll want to define your own enums these properties - let presentation = properties["presentation"] as? String - return presentation == "modal" - } - - private func makeViewController(for url: URL, properties: PathProperties = [:]) -> UIViewController { - // There are many options for determining how to map urls to view controllers - // The demo uses the path configuration for determining which view controller and presentation - // to use, but that's completely optional. You can use whatever logic you prefer to determine - // how you navigate and route different URLs. - - if let viewController = properties["view-controller"] as? String { - switch viewController { - case "numbers": - let numbersVC = NumbersViewController() - numbersVC.url = url - return numbersVC - case "numbersDetail": - let alertController = UIAlertController(title: "Number", message: "\(url.lastPathComponent)", preferredStyle: .alert) - alertController.addAction(.init(title: "OK", style: .default, handler: nil)) - return alertController - default: - assertionFailure("Invalid view controller, defaulting to WebView") - } - } - - return TurboWebViewController(url: url) - } - - private func navigate(to viewController: UIViewController, action: VisitAction, properties: PathProperties = [:], animated: Bool = true) { - // We support three types of navigation in the app: advance, replace, and modal - - if isModal(properties) { - if viewController is UIAlertController { - present(viewController, animated: animated, completion: nil) - } else { - let modalNavController = UINavigationController(rootViewController: viewController) - present(modalNavController, animated: animated) - } - } else if action == .replace { - let viewControllers = Array(viewControllers.dropLast()) + [viewController] - setViewControllers(viewControllers, animated: false) - } else { - pushViewController(viewController, animated: animated) - } - } - - private func visit(viewController: UIViewController, with options: VisitOptions, modal: Bool = false) { - guard let visitable = viewController as? Visitable else { return } - // Each Session corresponds to a single web view. A good rule of thumb - // is to use a session per navigation stack. Here we're using a different session - // when presenting a modal. We keep that around for any modal presentations so - // we don't have to create more than we need since each new session incurs a cold boot visit cost - if modal { - modalSession.visit(visitable, options: options) - } else { - session.visit(visitable, options: options) - } - } -} diff --git a/Demo/Navigator.swift b/Demo/Navigator.swift new file mode 100644 index 0000000..3318e8d --- /dev/null +++ b/Demo/Navigator.swift @@ -0,0 +1,14 @@ +import Foundation +import Turbo + +/// A bridge "back" to Turbo world from native. +/// See `NumbersViewController` for an example of navigating from native to web. +protocol Navigator: AnyObject { + func route(_ url: URL) +} + +extension TurboNavigator: Navigator { + func route(_ url: URL) { + route(url, options: VisitOptions(action: .advance), parameters: nil) + } +} diff --git a/Demo/NumbersViewController.swift b/Demo/NumbersViewController.swift index d40af7d..fd8b9d0 100644 --- a/Demo/NumbersViewController.swift +++ b/Demo/NumbersViewController.swift @@ -1,18 +1,27 @@ +import Turbo import UIKit /// A simple native table view controller to demonstrate loading non-Turbo screens /// for a visit proposal -final class NumbersViewController: UITableViewController { - - var url: URL! - +final class NumbersViewController: UITableViewController, PathConfigurationIdentifiable { + static var pathConfigurationIdentifier: String { "numbers" } + + convenience init(url: URL, navigator: Navigator) { + self.init(nibName: nil, bundle: nil) + self.url = url + self.navigator = navigator + } + + private var url: URL! + private unowned var navigator: Navigator? + override func viewDidLoad() { super.viewDidLoad() - + title = "Numbers" tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") } - + override func numberOfSections(in tableView: UITableView) -> Int { 1 } @@ -29,10 +38,10 @@ final class NumbersViewController: UITableViewController { return cell } - + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let turboNavController = navigationController as! TurboNavigationController - turboNavController.push(url: url.appendingPathComponent("\(indexPath.row + 1)")) + let detailURL = url.appendingPathComponent("\(indexPath.row + 1)") + navigator?.route(detailURL) tableView.deselectRow(at: indexPath, animated: true) } } diff --git a/Demo/SceneController.swift b/Demo/SceneController.swift index 711b541..0ad7ee4 100644 --- a/Demo/SceneController.swift +++ b/Demo/SceneController.swift @@ -1,69 +1,52 @@ -import UIKit -import WebKit import SafariServices -import Turbo import Strada +import Turbo +import UIKit +import WebKit final class SceneController: UIResponder { - private static var sharedProcessPool = WKProcessPool() - var window: UIWindow? + private let rootURL = Demo.current - private var navigationController: TurboNavigationController! - + private lazy var navigator = TurboNavigator(pathConfiguration: pathConfiguration, delegate: self) + // MARK: - Setup - + + private func configureStrada() { + Turbo.config.userAgent += " \(Strada.userAgentSubstring(for: BridgeComponent.allTypes))" + + Turbo.config.makeCustomWebView = { config in + config.defaultWebpagePreferences?.preferredContentMode = .mobile + + let webView = WKWebView(frame: .zero, configuration: .appConfiguration) + if #available(iOS 16.4, *) { + webView.isInspectable = true + } + // Initialize Strada bridge. + Bridge.initialize(webView) + + return webView + } + } + private func configureRootViewController() { guard let window = window else { fatalError() } - - window.tintColor = UIColor(named: "Tint") - - let turboNavController: TurboNavigationController - if let navController = window.rootViewController as? TurboNavigationController { - turboNavController = navController - navigationController = navController - } else { - turboNavController = TurboNavigationController() - window.rootViewController = turboNavController - } - - turboNavController.session = session - turboNavController.modalSession = modalSession + + window.tintColor = .tint + window.rootViewController = navigator.rootViewController } - + // MARK: - Authentication - + private func promptForAuthentication() { let authURL = rootURL.appendingPathComponent("/signin") - let properties = pathConfiguration.properties(for: authURL) - navigationController.route(url: authURL, options: VisitOptions(), properties: properties) - } - - // MARK: - Sessions - - private lazy var session = makeSession() - private lazy var modalSession = makeSession() - - private func makeSession() -> Session { - let webView = WKWebView(frame: .zero, - configuration: .appConfiguration) - if #available(iOS 16.4, *) { - webView.isInspectable = true - } - - // Initialize Strada bridge. - Bridge.initialize(webView) - - let session = Session(webView: webView) - session.delegate = self - session.pathConfiguration = pathConfiguration - return session + navigator.route(authURL) } - + // MARK: - Path Configuration - + private lazy var pathConfiguration = PathConfiguration(sources: [ .file(Bundle.main.url(forResource: "path-configuration", withExtension: "json")!), ]) @@ -71,73 +54,51 @@ final class SceneController: UIResponder { extension SceneController: UIWindowSceneDelegate { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - guard let _ = scene as? UIWindowScene else { return } - + guard let windowScene = scene as? UIWindowScene else { return } + + window = UIWindow(windowScene: windowScene) + window?.makeKeyAndVisible() + + configureStrada() configureRootViewController() - navigationController.route(url: rootURL, options: VisitOptions(action: .replace), properties: [:]) + + navigator.route(rootURL) + } + + func sceneDidBecomeActive(_ scene: UIScene) { + navigator.appDidBecomeActive() + } + + func sceneDidEnterBackground(_ scene: UIScene) { + navigator.appDidEnterBackground() } } -extension SceneController: SessionDelegate { - func session(_ session: Session, didProposeVisit proposal: VisitProposal) { - navigationController.route(url: proposal.url, options: proposal.options, properties: proposal.properties) +extension SceneController: TurboNavigatorDelegate { + func handle(proposal: VisitProposal) -> ProposalResult { + switch proposal.viewController { + case NumbersViewController.pathConfigurationIdentifier: + return .acceptCustom(NumbersViewController(url: proposal.url, navigator: navigator)) + + case "numbers_detail": + let alertController = UIAlertController(title: "Number", message: "\(proposal.url.lastPathComponent)", preferredStyle: .alert) + alertController.addAction(.init(title: "OK", style: .default, handler: nil)) + return .acceptCustom(alertController) + + default: + return .acceptCustom(TurboWebViewController(url: proposal.url)) + } } - - func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) { + + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) { if let turboError = error as? TurboError, case let .http(statusCode) = turboError, statusCode == 401 { promptForAuthentication() } else if let errorPresenter = visitable as? ErrorPresenter { - errorPresenter.presentError(error) { [weak self] in - self?.session.reload() - } + errorPresenter.presentError(error, retryHandler: retryHandler) } else { let alert = UIAlertController(title: "Visit failed!", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) - navigationController.present(alert, animated: true) - } - } - - // When a form submission completes in the modal session, we need to - // manually clear the snapshot cache in the default session, since we - // don't want potentially stale cached snapshots to be used - func sessionDidFinishFormSubmission(_ session: Session) { - if (session == modalSession) { - self.session.clearSnapshotCache() - } - } - - func sessionDidLoadWebView(_ session: Session) { - session.webView.navigationDelegate = self - } - - func sessionWebViewProcessDidTerminate(_ session: Session) { - session.reload() - } -} - -extension SceneController: WKNavigationDelegate { - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - if navigationAction.navigationType == .linkActivated { - // Any link that's not on the same domain as the Turbo root url will go through here - // Other links on the domain, but that have an extension that is non-html will also go here - // You can decide how to handle those, by default if you're not the navigationDelegate - // the Session will open them in the default browser - - let url = navigationAction.request.url! - - // For this demo, we'll load files from our domain in a SafariViewController so you - // don't need to leave the app. You might expand this in your app - // to open all audio/video/images in a native media viewer - if url.host == rootURL.host, !url.pathExtension.isEmpty { - let safariViewController = SFSafariViewController(url: url) - navigationController.present(safariViewController, animated: true) - } else { - UIApplication.shared.open(url) - } - - decisionHandler(.cancel) - } else { - decisionHandler(.allow) + navigator.activeNavigationController.present(alert, animated: true) } } } diff --git a/Demo/Strada/FormComponent.swift b/Demo/Strada/FormComponent.swift index ebc8201..183930a 100644 --- a/Demo/Strada/FormComponent.swift +++ b/Demo/Strada/FormComponent.swift @@ -76,4 +76,3 @@ private extension FormComponent { let submitTitle: String } } - diff --git a/Demo/Strada/MenuComponent.swift b/Demo/Strada/MenuComponent.swift index 32efbff..1a83421 100644 --- a/Demo/Strada/MenuComponent.swift +++ b/Demo/Strada/MenuComponent.swift @@ -35,7 +35,7 @@ final class MenuComponent: BridgeComponent { preferredStyle: .actionSheet) for item in items { - let action = UIAlertAction(title: item.title, style: .default) {[weak self] _ in + let action = UIAlertAction(title: item.title, style: .default) { [weak self] _ in self?.onItemSelected(item: item) } alertController.addAction(action) @@ -75,6 +75,6 @@ private extension MenuComponent { } struct SelectionMessageData: Encodable { - let selectedIndex:Int + let selectedIndex: Int } } diff --git a/Demo/Strada/OverflowMenuComponent.swift b/Demo/Strada/OverflowMenuComponent.swift index 1de844f..509119e 100644 --- a/Demo/Strada/OverflowMenuComponent.swift +++ b/Demo/Strada/OverflowMenuComponent.swift @@ -40,7 +40,6 @@ final class OverflowMenuComponent: BridgeComponent { image: .init(systemName: "ellipsis.circle"), primaryAction: action) - viewController.navigationItem.rightBarButtonItem = item } diff --git a/Demo/TurboWebViewController.swift b/Demo/TurboWebViewController.swift index 3322948..0da4d9f 100644 --- a/Demo/TurboWebViewController.swift +++ b/Demo/TurboWebViewController.swift @@ -1,18 +1,15 @@ -import UIKit -import Turbo import Strada +import Turbo +import UIKit import WebKit -final class TurboWebViewController: VisitableViewController, - ErrorPresenter, - BridgeDestination { - - private lazy var bridgeDelegate: BridgeDelegate = { - BridgeDelegate(location: visitableURL.absoluteString, - destination: self, - componentTypes: BridgeComponent.allTypes) - }() - +final class TurboWebViewController: VisitableViewController, BridgeDestination { + private lazy var bridgeDelegate = BridgeDelegate( + location: visitableURL.absoluteString, + destination: self, + componentTypes: BridgeComponent.allTypes + ) + // MARK: View lifecycle override func viewDidLoad() { diff --git a/Demo/Web/WKWebViewConfiguration+App.swift b/Demo/Web/WKWebViewConfiguration+App.swift index 2206bd9..3bf2574 100644 --- a/Demo/Web/WKWebViewConfiguration+App.swift +++ b/Demo/Web/WKWebViewConfiguration+App.swift @@ -1,6 +1,6 @@ import Foundation -import WebKit import Strada +import WebKit enum WebViewPool { static var shared = WKProcessPool() @@ -10,12 +10,12 @@ extension WKWebViewConfiguration { static var appConfiguration: WKWebViewConfiguration { let stradaSubstring = Strada.userAgentSubstring(for: BridgeComponent.allTypes) let userAgent = "Turbo Native iOS \(stradaSubstring)" - + let configuration = WKWebViewConfiguration() configuration.processPool = WebViewPool.shared configuration.applicationNameForUserAgent = userAgent configuration.defaultWebpagePreferences?.preferredContentMode = .mobile - + return configuration } } diff --git a/Demo/path-configuration.json b/Demo/path-configuration.json index ffd0d9f..ebc9829 100644 --- a/Demo/path-configuration.json +++ b/Demo/path-configuration.json @@ -1,6 +1,6 @@ { "settings": { - "enable-feature-x": true + "enable_feature_x": true }, "rules": [ { @@ -11,7 +11,8 @@ "/strada-form$" ], "properties": { - "presentation": "modal" + "context": "modal", + "pull_to_refresh_enabled": false } }, { @@ -19,7 +20,7 @@ "/numbers$" ], "properties": { - "view-controller": "numbers" + "view_controller": "numbers" } }, { @@ -27,8 +28,16 @@ "/numbers/[0-9]+$" ], "properties": { - "view-controller": "numbersDetail", - "presentation": "modal" + "view_controller": "numbers_detail", + "context": "modal" + } + }, + { + "patterns": [ + "^/$" + ], + "properties": { + "presentation": "replace_root" } }, ] diff --git a/Docs/PathConfiguration.md b/Docs/PathConfiguration.md index 94b23c7..7046fad 100644 --- a/Docs/PathConfiguration.md +++ b/Docs/PathConfiguration.md @@ -61,7 +61,7 @@ let pathConfiguration = PathConfiguration(sources: [ Path properties are the core of the path configuration. The `rules` key of the JSON is an array of dictionaries. Each dictionary has a `patterns` array which is an array of regular expressions for matching on the URL, and a dictionary of `properties` that will get returned when a pattern matches. -You can lookup the properties for a URL by using the URL itself or the `url.path` value. Currently, the path configuration only looks at the path component of the URL, but likely we'll add support for other components in the future. The path configuration finds all matching rules in order, and then merges them into one dictionary, with later rules overriding earlier ones. This way you can group similar properties together. +You can lookup the properties for a URL by using the URL itself or the `url.path` value. The path configuration finds all matching rules in order, and then merges them into one dictionary, with later rules overriding earlier ones. This way you can group similar properties together. Given the following rules: @@ -106,6 +106,25 @@ The url `example.com/messages/new` however would match both the first and second When the `Session` proposes a visit, it looks up the path properties for the proposed visit url if it has a `pathConfiguration` and it passes those path properties to your app in the `VisitProposal` via `proposal.properties`. This is for convenience, but you can also use the path configuration directly and do the same lookup in your application code. +### Query String Matching + +By default, path patterns only match against the path component of the URL. Enable query string matching via: + +```swift +Turbo.config.pathConfiguration.matchQueryStrings = true +``` + +To ensure the order of query string parameters don't affect matching, a wildcard `.*` before and after the match is recommended, like so: + +``` +{ + "patterns": [".*\\?.*foo=bar.*"], + "properties": { + "foo": "bar" + } +} +``` + ## Settings The path configuration optionally can have a top-level `settings` dictionary. This can be whatever data you want. We use it for controlling anything that we want the flexibility to change from the server without releasing an update. This might be different urls, configurations, feature flags, etc. If you don't want to use that, you can omit it entirely from the JSON. diff --git a/Docs/QuickStartGuide.md b/Docs/QuickStartGuide.md index d3fe74e..a654904 100644 --- a/Docs/QuickStartGuide.md +++ b/Docs/QuickStartGuide.md @@ -7,50 +7,29 @@ This is a quick start guide to creating the most minimal Turbo iOS application f 2. Select your app's main top-level project, go to the Swift Packages tab and add the Turbo iOS dependency by entering in `https://github.com/hotwired/turbo-ios`. 3. Open the `SceneDelegate`, and replace the entire file with this code: + ```swift -import UIKit import Turbo +import UIKit + +private let rootURL = URL(string: "https://turbo-native-demo.glitch.me")! class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - private lazy var navigationController = UINavigationController() + + private lazy var navigator = TurboNavigator() func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let _ = (scene as? UIWindowScene) else { return } - window!.rootViewController = navigationController - visit(url: URL(string: "https://turbo-native-demo.glitch.me")!) - } - - private func visit(url: URL) { - let viewController = VisitableViewController(url: url) - navigationController.pushViewController(viewController, animated: true) - session.visit(viewController) - } - - private lazy var session: Session = { - let session = Session() - session.delegate = self - return session - }() -} -extension SceneDelegate: SessionDelegate { - func session(_ session: Session, didProposeVisit proposal: VisitProposal) { - visit(url: proposal.url) - } - - func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) { - print("didFailRequestForVisitable: \(error)") - } - - func sessionWebViewProcessDidTerminate(_ session: Session) { - session.reload() + window!.rootViewController = navigator.rootViewController + navigator.route(rootURL) } } ``` -4. Hit run, and you have a basic working app. You can now tap links and navigate the demo back and forth in the simulator. We've only touched the very core requirements here of creating a `Session` and handling a visit. +4. Hit run, and you have a basic working app. You can now tap links and navigate the demo back and forth in the simulator. We've only touched the very core requirements here of creating a `Turbo Navigator` and handling a visit. -5. You can change the url we use for the initial visit to your web app. Note: if you're running your app locally without https, you'll need to adjust your `NSAppTransportSecurity` settings in the Info.plist to allow arbitrary loads. +5. You can change the url we use for the initial visit to your web app. -6. A real application will want to customize the view controller, respond to different visit actions, gracefully handle errors, and build a more powerful routing system. Read the rest of the documentation to learn more. +6. A real application will want to customize the view controller, respond to different visit actions, and build a more powerful routing system. Read the rest of the documentation to learn more. diff --git a/Docs/TurboNavigator.md b/Docs/TurboNavigator.md new file mode 100644 index 0000000..510fde3 --- /dev/null +++ b/Docs/TurboNavigator.md @@ -0,0 +1,164 @@ +# Turbo Navigator + +Turbo Navigator abstracts routing boilerplate a single class. Use this level of abstraction for default handling of the following navigation flows. + +## Handled navigation flows + +When a link is tapped, turbo-ios sends a `VisitProposal` to your application code. Based on the [Path Configuration](PathConfiguration.md), different `PathProperties` will be set. + +* **Current context** - What state the app is in. + * `modal` - a modal is currently presented + * `default` - otherwise +* **Given context** - Value of `context` on the requested link. + * `modal` or `default`/blank +* **Given presentation** - Value of `presentation` on the proposal. + * `replace`, `pop`, `refresh`, `clear_all`, `replace_root`, `none`, `default`/blank +* **Navigation** - The behavior that the navigation controller provides. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current ContextGiven ContextGiven PresentationNew Presentation
defaultdefaultdefaultPush on main stack (or)
+ Replace if visiting same page (or)
+ Pop (and visit) if previous controller is same URL +
defaultdefaultreplaceReplace controller on main stack
defaultmodaldefaultPresent a modal with only this controller
defaultmodalreplacePresent a modal with only this controller
modaldefaultdefaultDismiss then Push on main stack
modaldefaultreplaceDismiss then Replace on main stack
modalmodaldefaultPush on the modal stack
modal modalreplaceReplace controller on modal stack
default(any)popPop controller off main stack
default(any)refreshPop on main stack then
modal(any)popPop controller off modal stack (or)
+ Dismiss if one modal controller +
modal(any)refreshPop controller off modal stack then
+ Refresh last controller on modal stack
+ (or)
+ Dismiss if one modal controller then
+ Refresh last controller on main stack +
(any)(any)clearAllDismiss if modal controller then
+ Pop to root then
+ Refresh root controller on main stack +
(any)(any)replaceRootDismiss if modal controller then
+ Pop to root then
+ Replace root controller on main stack +
(any)(any)noneNothing
+ +### Examples + +To present forms (URLs ending in `/new` or `/edit`) as a modal, add the following to the `rules` key of your Path Configuration. + +```json +{ + "patterns": [ + "/new$", + "/edit$" + ], + "properties": { + "context": "modal" + } +} +``` + +To hook into the "refresh" turbo-rails native route, add the following to the `rules` key of your Path Configuration. You can then call `refresh_or_redirect_to` in your controller to handle Turbo Native and web-based navigation. + +```json +{ + "patterns": [ + "/refresh_historical_location" + ], + "properties": { + "presentation": "refresh" + } +} +``` diff --git a/README.md b/README.md index e28b2f1..5997831 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ You can also integrate the framework manually if your prefer, such as by adding - [Path Configuration](Docs/PathConfiguration.md) - [Migration](Docs/Migration.md) - [Advanced](Docs/Advanced.md) +- [TurboNavigator](Docs/TurboNavigator.md) ## Contributing diff --git a/Source/Logging.swift b/Source/Logging.swift index 375097e..b75f916 100644 --- a/Source/Logging.swift +++ b/Source/Logging.swift @@ -1,13 +1,13 @@ import Foundation -public struct TurboLog { - public static var debugLoggingEnabled = false +enum TurboLogger { + static var debugLoggingEnabled = false } /// Simple function to help in debugging, a noop in Release builds func debugLog(_ message: String, _ arguments: [String: Any] = [:]) { let timestamp = Date() - + log("\(timestamp) \(message) \(arguments)") } @@ -16,7 +16,7 @@ func debugPrint(_ message: String) { } private func log(_ message: String) { - if TurboLog.debugLoggingEnabled { + if TurboLogger.debugLoggingEnabled { print(message) } } diff --git a/Source/Path Configuration/PathConfiguration.swift b/Source/Path Configuration/PathConfiguration.swift index 8c4a3c0..a957b69 100644 --- a/Source/Path Configuration/PathConfiguration.swift +++ b/Source/Path Configuration/PathConfiguration.swift @@ -51,14 +51,13 @@ public final class PathConfiguration { public subscript(url: URL) -> PathProperties { properties(for: url) } - - /// Returns a merged dictionary containing all the properties - /// that match this url - /// Note: currently only looks at path, not query, but most likely will - /// add query support in the future, so it's best to always use this over the path variant - /// unless you're sure you'll never need to reference other parts of the URL in the future + + /// Returns a merged dictionary containing all the properties that match this URL. public func properties(for url: URL) -> PathProperties { - properties(for: url.path) + if Turbo.config.pathConfiguration.matchQueryStrings, let query = url.query { + return properties(for: "\(url.path)?\(query)") + } + return properties(for: url.path) } /// Returns a merged dictionary containing all the properties diff --git a/Source/Path Configuration/PathConfigurationLoader.swift b/Source/Path Configuration/PathConfigurationLoader.swift index 3774525..09b5438 100644 --- a/Source/Path Configuration/PathConfigurationLoader.swift +++ b/Source/Path Configuration/PathConfigurationLoader.swift @@ -4,7 +4,6 @@ typealias PathConfigurationLoaderCompletionHandler = (PathConfigurationDecoder) final class PathConfigurationLoader { private let cacheDirectory = "Turbo" - private let configurationCacheFilename = "path-configuration.json" private let sources: [PathConfiguration.Source] private let options: PathConfigurationLoaderOptions? private var completionHandler: PathConfigurationLoaderCompletionHandler? @@ -35,7 +34,7 @@ final class PathConfigurationLoader { precondition(!url.isFileURL, "URL provided for server is a file url") // Immediately load most recent cached version if available - if let data = cachedData() { + if let data = cachedData(for: url) { loadData(data) } @@ -50,29 +49,31 @@ final class PathConfigurationLoader { return } - self?.loadData(data, cache: true) + self?.loadData(data, cache: true, for: url) }.resume() } // MARK: - Caching - private func cacheRemoteData(_ data: Data) { + private func cacheRemoteData(_ data: Data, for url: URL) { createCacheDirectoryIfNeeded() do { - try data.write(to: configurationCacheURL) + let url = configurationCacheURL(for: url) + try data.write(to: url) } catch { debugPrint("[path-configuration-loader] error caching file error: \(error)") } } - private func cachedData() -> Data? { - guard FileManager.default.fileExists(atPath: configurationCacheURL.path) else { + private func cachedData(for url: URL) -> Data? { + let cachedURL = configurationCacheURL(for: url) + guard FileManager.default.fileExists(atPath: cachedURL.path) else { return nil } do { - return try Data(contentsOf: configurationCacheURL) + return try Data(contentsOf: cachedURL) } catch { debugPrint("[path-configuration-loader] *** error loading cached data: \(error)") return nil @@ -94,8 +95,8 @@ final class PathConfigurationLoader { return directory.appendingPathComponent(cacheDirectory) } - var configurationCacheURL: URL { - turboCacheDirectoryURL.appendingPathComponent(configurationCacheFilename) + func configurationCacheURL(for url: URL) -> URL { + turboCacheDirectoryURL.appendingPathComponent(url.lastPathComponent) } // MARK: - File @@ -113,7 +114,7 @@ final class PathConfigurationLoader { // MARK: - Data - private func loadData(_ data: Data, cache: Bool = false) { + private func loadData(_ data: Data, cache: Bool = false, for url: URL? = nil) { do { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw JSONDecodingError.invalidJSON @@ -121,9 +122,9 @@ final class PathConfigurationLoader { let config = try PathConfigurationDecoder(json: json) - if cache { + if cache, let url { // Only cache once we ensure we have valid data - cacheRemoteData(data) + cacheRemoteData(data, for: url) } updateHandler(with: config) diff --git a/Source/Session/Session.swift b/Source/Session/Session.swift index bcfdab3..0831b27 100644 --- a/Source/Session/Session.swift +++ b/Source/Session/Session.swift @@ -14,6 +14,8 @@ public class Session: NSObject { private lazy var bridge = WebViewBridge(webView: webView) private var initialized = false private var refreshing = false + private var isShowingStaleContent = false + private var isSnapshotCacheStale = false /// Automatically creates a web view with the passed-in configuration public convenience init(webViewConfiguration: WKWebViewConfiguration? = nil) { @@ -91,6 +93,18 @@ public class Session: NSObject { bridge.clearSnapshotCache() } + // MARK: Caching + + /// Clear the snapshot cache the next time the visitable view appears. + public func markSnapshotCacheAsStale() { + isSnapshotCacheStale = true + } + + /// Reload the `Session` the next time the visitable view appears. + public func markContentAsStale() { + isShowingStaleContent = true + } + // MARK: Visitable activation private var activatedVisitable: Visitable? @@ -215,7 +229,15 @@ extension Session: VisitableDelegate { public func visitableViewWillAppear(_ visitable: Visitable) { guard let topmostVisit = self.topmostVisit, let currentVisit = self.currentVisit else { return } - if visitable === topmostVisit.visitable && visitable.visitableViewController.isMovingToParent { + if isSnapshotCacheStale { + clearSnapshotCache() + isSnapshotCacheStale = false + } + + if isShowingStaleContent { + reload() + isShowingStaleContent = false + } else if visitable === topmostVisit.visitable && visitable.visitableViewController.isMovingToParent { // Back swipe gesture canceled if topmostVisit.state == .completed { currentVisit.cancel() diff --git a/Source/Turbo Navigator/Extensions/UINavigationControllerExtension.swift b/Source/Turbo Navigator/Extensions/UINavigationControllerExtension.swift new file mode 100644 index 0000000..981ae3b --- /dev/null +++ b/Source/Turbo Navigator/Extensions/UINavigationControllerExtension.swift @@ -0,0 +1,24 @@ +import UIKit + +extension UINavigationController { + func replaceLastViewController(with viewController: UIViewController) { + let viewControllers = viewControllers.dropLast() + setViewControllers(viewControllers + [viewController], animated: false) + } + + func setModalPresentationStyle(via proposal: VisitProposal) { + switch proposal.modalStyle { + case .medium: + modalPresentationStyle = .automatic + if #available(iOS 15.0, *) { + if let sheet = sheetPresentationController { + sheet.detents = [.medium(), .large()] + } + } + case .large: + modalPresentationStyle = .automatic + case .full: + modalPresentationStyle = .fullScreen + } + } +} diff --git a/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift new file mode 100644 index 0000000..6dce5d0 --- /dev/null +++ b/Source/Turbo Navigator/Extensions/VisitProposalExtension.swift @@ -0,0 +1,71 @@ +import UIKit + +public extension VisitProposal { + var context: TurboNavigation.Context { + if let rawValue = properties["context"] as? String { + return TurboNavigation.Context(rawValue: rawValue) ?? .default + } + return .default + } + + var presentation: TurboNavigation.Presentation { + if let rawValue = properties["presentation"] as? String { + return TurboNavigation.Presentation(rawValue: rawValue) ?? .default + } + return .default + } + + var modalStyle: TurboNavigation.ModalStyle { + if let rawValue = properties["modal_style"] as? String { + return TurboNavigation.ModalStyle(rawValue: rawValue) ?? .large + } + return .large + } + + var pullToRefreshEnabled: Bool { + properties["pull_to_refresh_enabled"] as? Bool ?? true + } + + /// Used to identify a custom native view controller if provided in the path configuration properties of a given pattern. + /// + /// For example, given the following configuration file: + /// + /// ```json + /// { + /// "rules": [ + /// { + /// "patterns": [ + /// "/recipes/*" + /// ], + /// "properties": { + /// "view_controller": "recipes", + /// } + /// } + /// ] + /// } + /// ``` + /// + /// A VisitProposal to `https://example.com/recipes/` will have + /// ```swift + /// proposal.viewController == "recipes" + /// ``` + /// + /// - Important: A default value is provided in case the view controller property is missing from the configuration file. This will route the default `VisitableViewController`. + /// - Note: A `ViewController` must conform to `PathConfigurationIdentifiable` to couple the identifier with a view controlelr. + var viewController: String { + if let viewController = properties["view_controller"] as? String { + return viewController + } + + return VisitableViewController.pathConfigurationIdentifier + } + + /// Allows the proposal to change the animation status when pushing, popping or presenting. + var animated: Bool { + if let animated = parameters?["animated"] as? Bool { + return animated + } + + return true + } +} diff --git a/Source/Turbo Navigator/Extensions/VisitableViewControllerExtension.swift b/Source/Turbo Navigator/Extensions/VisitableViewControllerExtension.swift new file mode 100644 index 0000000..f4d9d4e --- /dev/null +++ b/Source/Turbo Navigator/Extensions/VisitableViewControllerExtension.swift @@ -0,0 +1,3 @@ +extension VisitableViewController: PathConfigurationIdentifiable { + public static var pathConfigurationIdentifier: String { "web" } +} diff --git a/Source/Turbo Navigator/Extensions/WKWebView+ebContentProcess.swift b/Source/Turbo Navigator/Extensions/WKWebView+ebContentProcess.swift new file mode 100644 index 0000000..9a0d7b3 --- /dev/null +++ b/Source/Turbo Navigator/Extensions/WKWebView+ebContentProcess.swift @@ -0,0 +1,28 @@ +import Foundation +import WebKit + +enum WebContentProcessState { + case active + case terminated +} + +extension WKWebView { + /// Queries the state of the web content process asynchronously. + /// + /// This method evaluates a simple JavaScript function in the web view to determine if the web content process is active. + /// + /// - Parameter completionHandler: A closure to be called when the query completes. The closure takes a single argument representing the state of the web content process. + /// + /// - Note: The web content process is considered active if the JavaScript evaluation succeeds without error. + /// If an error occurs during evaluation, the process is considered terminated. + func queryWebContentProcessState(completionHandler: @escaping (WebContentProcessState) -> Void) { + evaluateJavaScript("(function() { return '1'; })();") { _, error in + if let _ = error { + completionHandler(.terminated) + return + } + + completionHandler(.active) + } + } +} diff --git a/Source/Turbo Navigator/Helpers/ErrorPresenter.swift b/Source/Turbo Navigator/Helpers/ErrorPresenter.swift new file mode 100644 index 0000000..c2f583e --- /dev/null +++ b/Source/Turbo Navigator/Helpers/ErrorPresenter.swift @@ -0,0 +1,93 @@ +import SwiftUI + +public protocol ErrorPresenter: UIViewController { + typealias Handler = () -> Void + + func presentError(_ error: Error, retryHandler: Handler?) +} + +public extension ErrorPresenter { + + /// Presents an error in a full screen view. + /// The error view will display a `Retry` button if `retryHandler != nil`. + /// Tapping `Retry` will call `retryHandler?()` then dismiss the error. + /// + /// - Parameters: + /// - error: presents the data in this error + /// - retryHandler: a user-triggered action to perform in case the error is recoverable + func presentError(_ error: Error, retryHandler: Handler?) { + let errorView = ErrorView(error: error, shouldShowRetryButton: (retryHandler != nil)) { + retryHandler?() + self.removeErrorViewController() + } + + let controller = UIHostingController(rootView: errorView) + addChild(controller) + addFullScreenSubview(controller.view) + controller.didMove(toParent: self) + } + + private func removeErrorViewController() { + if let child = children.first(where: { $0 is UIHostingController }) { + child.willMove(toParent: nil) + child.view.removeFromSuperview() + child.removeFromParent() + } + } +} + +extension UIViewController: ErrorPresenter {} + +// MARK: Private + +private struct ErrorView: View { + let error: Error + let shouldShowRetryButton: Bool + let handler: ErrorPresenter.Handler? + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 38, weight: .semibold)) + .foregroundColor(.accentColor) + + Text("Error loading page") + .font(.largeTitle) + + Text(error.localizedDescription) + .font(.body) + .multilineTextAlignment(.center) + + if shouldShowRetryButton { + Button("Retry") { + handler?() + } + .font(.system(size: 17, weight: .bold)) + } + } + .padding(32) + } +} + +private struct ErrorView_Previews: PreviewProvider { + static var previews: some View { + return ErrorView(error: NSError( + domain: "com.example.error", + code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Could not connect to the server."] + ), shouldShowRetryButton: true) {} + } +} + +private extension UIViewController { + func addFullScreenSubview(_ subview: UIView) { + view.addSubview(subview) + subview.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + subview.leadingAnchor.constraint(equalTo: view.leadingAnchor), + subview.trailingAnchor.constraint(equalTo: view.trailingAnchor), + subview.topAnchor.constraint(equalTo: view.topAnchor), + subview.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } +} diff --git a/Source/Turbo Navigator/Helpers/ExternalURLNavigationAction.swift b/Source/Turbo Navigator/Helpers/ExternalURLNavigationAction.swift new file mode 100644 index 0000000..f316e92 --- /dev/null +++ b/Source/Turbo Navigator/Helpers/ExternalURLNavigationAction.swift @@ -0,0 +1,15 @@ +import Foundation + +/// When TurboNavigator encounters an external URL, its delegate may handle it with any of these actions. +public enum ExternalURLNavigationAction { + /// Attempts to open via an embedded `SafariViewController` so the user stays in-app. + /// Silently fails if you pass a URL that's not `http` or `https`. + case openViaSafariController + + /// Attempts to open via `openURL(_:options:completionHandler)`. + /// This is useful if the external URL is a deeplink. + case openViaSystem + + /// Will do nothing with the external URL. + case reject +} diff --git a/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift b/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift new file mode 100644 index 0000000..295daa0 --- /dev/null +++ b/Source/Turbo Navigator/Helpers/PathConfigurationIdentifiable.swift @@ -0,0 +1,20 @@ +import UIKit + +/// As a convenience, a view controller may conform to `PathConfigurationIdentifiable`. +/// +/// Use a view controller's `pathConfigurationIdentifier` property instead of `proposal.url` when deciding how to handle a proposal. +/// +/// ```swift +/// func handle(proposal: VisitProposal) -> ProposalResult { +/// switch proposal.viewController { +/// case RecipeViewController.pathConfigurationIdentifier: +/// return .acceptCustom(RecipeViewController()) +/// default: +/// return .accept +/// } +/// } +/// ``` +/// - Note: See `VisitProposal.viewController` on how to use this in your configuration file. +public protocol PathConfigurationIdentifiable: UIViewController { + static var pathConfigurationIdentifier: String { get } +} diff --git a/Source/Turbo Navigator/Helpers/ProposalResult.swift b/Source/Turbo Navigator/Helpers/ProposalResult.swift new file mode 100644 index 0000000..3fa4bf0 --- /dev/null +++ b/Source/Turbo Navigator/Helpers/ProposalResult.swift @@ -0,0 +1,13 @@ +import UIKit + +/// Return from `TurboNavigatorDelegate.handle(proposal:)` to route a custom controller. +public enum ProposalResult: Equatable { + /// Route a `VisitableViewController`. + case accept + + /// Route a custom `UIViewController` or subclass + case acceptCustom(UIViewController) + + /// Do not route. Navigation is not modified. + case reject +} diff --git a/Source/Turbo Navigator/Helpers/TurboNavigation.swift b/Source/Turbo Navigator/Helpers/TurboNavigation.swift new file mode 100644 index 0000000..7ecfcfe --- /dev/null +++ b/Source/Turbo Navigator/Helpers/TurboNavigation.swift @@ -0,0 +1,22 @@ +public enum TurboNavigation { + public enum Context: String { + case `default` + case modal + } + + public enum Presentation: String { + case `default` + case pop + case replace + case refresh + case clearAll = "clear_all" + case replaceRoot = "replace_root" + case none + } + + public enum ModalStyle: String { + case medium + case large + case full + } +} diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyController.swift b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift new file mode 100644 index 0000000..0a1995e --- /dev/null +++ b/Source/Turbo Navigator/TurboNavigationHierarchyController.swift @@ -0,0 +1,203 @@ +import SafariServices +import UIKit +import WebKit + +class TurboNavigationHierarchyController { + let navigationController: UINavigationController + let modalNavigationController: UINavigationController + + var rootViewController: UIViewController { navigationController } + var activeNavigationController: UINavigationController { + navigationController.presentedViewController != nil ? modalNavigationController : navigationController + } + + enum NavigationStackType { + case main + case modal + } + + func navController(for navigationType: NavigationStackType) -> UINavigationController { + switch navigationType { + case .main: navigationController + case .modal: modalNavigationController + } + } + + init( + delegate: TurboNavigationHierarchyControllerDelegate, + navigationController: UINavigationController = Turbo.config.defaultNavigationController(), + modalNavigationController: UINavigationController = Turbo.config.defaultNavigationController() + ) { + self.delegate = delegate + self.navigationController = navigationController + self.modalNavigationController = modalNavigationController + } + + func route(controller: UIViewController, proposal: VisitProposal) { + if let alert = controller as? UIAlertController { + presentAlert(alert, via: proposal) + } else { + if let visitable = controller as? Visitable { + visitable.visitableView.allowsPullToRefresh = proposal.pullToRefreshEnabled + } + + switch proposal.presentation { + case .default: + navigate(with: controller, via: proposal) + case .pop: + pop(via: proposal) + case .replace: + replace(with: controller, via: proposal) + case .refresh: + refresh(via: proposal) + case .clearAll: + clearAll(via: proposal) + case .replaceRoot: + replaceRoot(with: controller, via: proposal) + case .none: + break // Do nothing. + } + } + } + + // MARK: Private + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private unowned let delegate: TurboNavigationHierarchyControllerDelegate + + private func presentAlert(_ alert: UIAlertController, via proposal: VisitProposal) { + if navigationController.presentedViewController != nil { + modalNavigationController.present(alert, animated: proposal.animated) + } else { + navigationController.present(alert, animated: proposal.animated) + } + } + + private func navigate(with controller: UIViewController, via proposal: VisitProposal) { + switch proposal.context { + case .default: + navigationController.dismiss(animated: proposal.animated) + pushOrReplace(on: navigationController, with: controller, via: proposal) + if let visitable = controller as? Visitable { + delegate.visit(visitable, on: .main, with: proposal.options) + } + case .modal: + if navigationController.presentedViewController != nil, !modalNavigationController.isBeingDismissed { + pushOrReplace(on: modalNavigationController, with: controller, via: proposal) + } else { + modalNavigationController.setViewControllers([controller], animated: proposal.animated) + modalNavigationController.setModalPresentationStyle(via: proposal) + navigationController.present(modalNavigationController, animated: proposal.animated) + } + if let visitable = controller as? Visitable { + delegate.visit(visitable, on: .modal, with: proposal.options) + } + } + } + + private func pushOrReplace(on navigationController: UINavigationController, with controller: UIViewController, via proposal: VisitProposal) { + if visitingSamePage(on: navigationController, with: controller, via: proposal.url) { + navigationController.replaceLastViewController(with: controller) + } else if visitingPreviousPage(on: navigationController, with: controller, via: proposal.url) { + navigationController.popViewController(animated: proposal.animated) + } else if proposal.options.action == .advance { + navigationController.pushViewController(controller, animated: proposal.animated) + } 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(via proposal: VisitProposal) { + if navigationController.presentedViewController != nil { + if modalNavigationController.viewControllers.count == 1 { + navigationController.dismiss(animated: proposal.animated) + } else { + modalNavigationController.popViewController(animated: proposal.animated) + } + } else { + navigationController.popViewController(animated: proposal.animated) + } + } + + private func replace(with controller: UIViewController, via proposal: VisitProposal) { + switch proposal.context { + case .default: + navigationController.dismiss(animated: proposal.animated) + navigationController.replaceLastViewController(with: controller) + if let visitable = controller as? Visitable { + delegate.visit(visitable, on: .main, with: proposal.options) + } + case .modal: + if navigationController.presentedViewController != nil { + modalNavigationController.replaceLastViewController(with: controller) + } else { + modalNavigationController.setViewControllers([controller], animated: false) + modalNavigationController.setModalPresentationStyle(via: proposal) + navigationController.present(modalNavigationController, animated: proposal.animated) + } + if let visitable = controller as? Visitable { + delegate.visit(visitable, on: .modal, with: proposal.options) + } + } + } + + private func refresh(via proposal: VisitProposal) { + if navigationController.presentedViewController != nil { + if modalNavigationController.viewControllers.count == 1 { + navigationController.dismiss(animated: proposal.animated) + refreshIfTopViewControllerIsVisitable(from: .main) + } else { + modalNavigationController.popViewController(animated: proposal.animated) + refreshIfTopViewControllerIsVisitable(from: .modal) + } + } else { + navigationController.popViewController(animated: proposal.animated) + refreshIfTopViewControllerIsVisitable(from: .main) + } + } + + private func refreshIfTopViewControllerIsVisitable(from stack: NavigationStackType) { + if let navControllerTopmostVisitable = navController(for: stack).topViewController as? Visitable { + delegate.refreshVisitable(navigationStack: stack, + newTopmostVisitable: navControllerTopmostVisitable) + } + } + + private func clearAll(via proposal: VisitProposal) { + navigationController.dismiss(animated: proposal.animated) + navigationController.popToRootViewController(animated: proposal.animated) + refreshIfTopViewControllerIsVisitable(from: .main) + } + + private func replaceRoot(with controller: UIViewController, via proposal: VisitProposal) { + navigationController.dismiss(animated: true) + navigationController.setViewControllers([controller], animated: proposal.animated) + + if let visitable = controller as? Visitable { + delegate.visit(visitable, on: .main, with: .init(action: .replace)) + } + } +} diff --git a/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift b/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift new file mode 100644 index 0000000..a49093d --- /dev/null +++ b/Source/Turbo Navigator/TurboNavigationHierarchyControllerDelegate.swift @@ -0,0 +1,23 @@ +import SafariServices +import WebKit + +protocol TurboNavigationHierarchyControllerDelegate: AnyObject { + + /// Once the navigation hierarchy is modified, begin a visit on a navigation controller. + /// + /// - Parameters: + /// - _: the Visitable destination + /// - on: the navigation controller that was modified + /// - with: the visit options + func visit(_ : Visitable, + on: TurboNavigationHierarchyController.NavigationStackType, + with: VisitOptions) + + /// A refresh will pop (or dismiss) then ask the session to refresh the previous (or underlying) Visitable. + /// + /// - Parameters: + /// - navigationStack: the stack where the refresh is happening + /// - newTopmostVisitable: the visitable to be refreshed + func refreshVisitable(navigationStack: TurboNavigationHierarchyController.NavigationStackType, + newTopmostVisitable: Visitable) +} diff --git a/Source/Turbo Navigator/TurboNavigator.swift b/Source/Turbo Navigator/TurboNavigator.swift new file mode 100644 index 0000000..bf27a0b --- /dev/null +++ b/Source/Turbo Navigator/TurboNavigator.swift @@ -0,0 +1,319 @@ +import Foundation +import SafariServices +import UIKit +import WebKit + +class DefaultTurboNavigatorDelegate: NSObject, TurboNavigatorDelegate {} + +/// Handles navigation to new URLs using the following rules: +/// [Turbo Navigator Handled Flows](https://github.com/hotwired/turbo-ios/Docs/TurboNavigator.md) +public class TurboNavigator { + public unowned var delegate: TurboNavigatorDelegate + + public var rootViewController: UINavigationController { hierarchyController.navigationController } + + public var modalRootViewController: UINavigationController { hierarchyController.modalNavigationController } + + public var activeNavigationController: UINavigationController { hierarchyController.activeNavigationController } + + /// Set to handle customize behavior of the `WKUIDelegate`. + /// + /// Subclass `TurboWKUIController` to add additional behavior alongside alert/confirm dialogs. + /// Or, provide a completely custom `WKUIDelegate` implementation. + public var webkitUIDelegate: WKUIDelegate? { + didSet { + session.webView.uiDelegate = webkitUIDelegate + modalSession.webView.uiDelegate = webkitUIDelegate + } + } + + /// Convenience initializer that doesn't require manually creating `Session` instances. + /// - Parameters: + /// - pathConfiguration: _optional:_ remote configuration reference + /// - delegate: _optional:_ delegate to handle custom view controllers + public convenience init(pathConfiguration: PathConfiguration? = nil, delegate: TurboNavigatorDelegate? = nil) { + let session = Session(webView: Turbo.config.makeWebView()) + session.pathConfiguration = pathConfiguration + + let modalSession = Session(webView: Turbo.config.makeWebView()) + modalSession.pathConfiguration = pathConfiguration + + self.init(session: session, modalSession: modalSession, delegate: delegate) + } + + /// Transforms `URL` -> `VisitProposal` -> `UIViewController`. + /// Convenience function to routing a proposal directly. + /// + /// - Parameter url: the URL to visit + /// - Parameter options: passed options will override default `advance` visit options + /// - Parameter parameters: provide context relevant to `url` + public func route(_ url: URL, + options: VisitOptions? = VisitOptions(action: .advance), + parameters: [String: Any]? = nil) { + let properties = session.pathConfiguration?.properties(for: url) ?? PathProperties() + route(VisitProposal(url: url, + options: options ?? .init(action: .advance), + properties: properties, + parameters: parameters)) + } + + /// Transforms `VisitProposal` -> `UIViewController` + /// Given the `VisitProposal`'s properties, push or present this view controller. + /// + /// - Parameter proposal: the proposal to visit + public func route(_ proposal: VisitProposal) { + guard let controller = controller(for: proposal) else { return } + hierarchyController.route(controller: controller, proposal: proposal) + } + + /// Navigate to an external URL. + /// + /// - Parameters: + /// - externalURL: the URL to navigate to + /// - via: navigation action + public func open(externalURL: URL, _ via: ExternalURLNavigationAction) { + switch via { + + case .openViaSystem: + UIApplication.shared.open(externalURL) + + case .openViaSafariController: + /// SFSafariViewController will crash if we pass along a URL that's not valid. + guard externalURL.scheme == "http" || externalURL.scheme == "https" else { return } + + let safariViewController = SFSafariViewController(url: externalURL) + safariViewController.modalPresentationStyle = .pageSheet + if #available(iOS 15.0, *) { + safariViewController.preferredControlTintColor = .tintColor + } + + activeNavigationController.present(safariViewController, animated: true) + + case .reject: + return + } + } + + public func appDidBecomeActive() { + appInBackground = false + inspectAllSessions() + } + + public func appDidEnterBackground() { + appInBackground = true + } + + // MARK: Internal + + var session: Session + var modalSession: Session + /// Modifies a UINavigationController according to visit proposals. + lazy var hierarchyController = TurboNavigationHierarchyController(delegate: self) + + /// Internal initializer requiring preconfigured `Session` instances. + /// + /// User `init(pathConfiguration:delegate:)` to only provide a `PathConfiguration`. + /// - Parameters: + /// - session: the main `Session` + /// - modalSession: the `Session` used for the modal navigation controller + /// - delegate: _optional:_ delegate to handle custom view controllers + init(session: Session, modalSession: Session, delegate: TurboNavigatorDelegate? = nil) { + self.session = session + self.modalSession = modalSession + + self.delegate = delegate ?? navigatorDelegate + + self.session.delegate = self + self.modalSession.delegate = self + + self.webkitUIDelegate = TurboWKUIController(delegate: self) + session.webView.uiDelegate = webkitUIDelegate + modalSession.webView.uiDelegate = webkitUIDelegate + } + + // MARK: Private + + /// A default delegate implementation if none is provided. + private let navigatorDelegate = DefaultTurboNavigatorDelegate() + private var backgroundTerminatedWebViewSessions = [Session]() + private var appInBackground = false + + private func controller(for proposal: VisitProposal) -> UIViewController? { + switch delegate.handle(proposal: proposal) { + case .accept: + Turbo.config.defaultViewController(proposal.url) + case .acceptCustom(let customViewController): + customViewController + case .reject: + nil + } + } +} + +// MARK: - SessionDelegate + +extension TurboNavigator: SessionDelegate { + public func session(_ session: Session, didProposeVisit proposal: VisitProposal) { + guard let controller = controller(for: proposal) else { return } + hierarchyController.route(controller: controller, proposal: proposal) + } + + public func sessionDidStartFormSubmission(_ session: Session) { + if let url = session.topmostVisitable?.visitableURL { + delegate.formSubmissionDidStart(to: url) + } + } + + public func sessionDidFinishFormSubmission(_ session: Session) { + if session == modalSession { + self.session.markSnapshotCacheAsStale() + } + if let url = session.topmostVisitable?.visitableURL { + delegate.formSubmissionDidFinish(at: url) + } + } + + public func session(_ session: Session, openExternalURL externalURL: URL) { + let decision = delegate.handle(externalURL: externalURL) + open(externalURL: externalURL, decision) + } + + public func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) { + delegate.visitableDidFailRequest(visitable, error: error) { + session.reload() + } + } + + public func sessionWebViewProcessDidTerminate(_ session: Session) { + reloadIfPermitted(session) + } + + public func session(_ session: Session, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + delegate.didReceiveAuthenticationChallenge(challenge, completionHandler: completionHandler) + } + + public func sessionDidFinishRequest(_ session: Session) { + guard let url = session.activeVisitable?.visitableURL else { return } + + WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in + HTTPCookieStorage.shared.setCookies(cookies, for: url, mainDocumentURL: url) + } + } + + public func sessionDidLoadWebView(_ session: Session) { + session.webView.navigationDelegate = session + } +} + +// MARK: - TurboNavigationHierarchyControllerDelegate + +extension TurboNavigator: TurboNavigationHierarchyControllerDelegate { + func visit(_ controller: Visitable, on navigationStack: TurboNavigationHierarchyController.NavigationStackType, with options: VisitOptions) { + switch navigationStack { + case .main: session.visit(controller, options: options) + case .modal: modalSession.visit(controller, options: options) + } + } + + func refreshVisitable(navigationStack: TurboNavigationHierarchyController.NavigationStackType, + newTopmostVisitable: any Visitable) { + switch navigationStack { + case .main: + session.visit(newTopmostVisitable, action: .restore) + case .modal: + modalSession.visit(newTopmostVisitable, action: .restore) + } + } +} + +extension TurboNavigator: TurboWKUIDelegate { + public func present(_ alert: UIAlertController, animated: Bool) { + hierarchyController.activeNavigationController.present(alert, animated: animated) + } +} + +// MARK: - Session and web view reloading + +extension TurboNavigator { + private func inspectAllSessions() { + [session, modalSession].forEach { inspect($0) } + } + + private func reloadIfPermitted(_ session: Session) { + /// If the web view process is terminated, it leaves the web view with a white screen, so we need to reload it. + /// However, if the web view is no longer onscreen, such as after visiting a page and going back to a native view, + /// then reloading will unnecessarily fetch all the content, and on next visit, + /// it will trigger various bridge messages since the web view will be added to the window and call all the connect() methods. + /// + /// We don't want to reload a view controller not on screen, since that can have unwanted + /// side-effects for the next visit (like showing the wrong bridge components). We can't just + /// check if the view controller is visible, since it may be further back in the stack of a navigation controller. + /// Seeing if there is a parent was the best solution I could find. + guard let viewController = session.activeVisitable?.visitableViewController, + viewController.parent != nil else { + return + } + + if appInBackground { + /// Don't reload the web view if the app is in the background. + /// Instead, save the session in `backgroundTerminatedWebViewSessions` + /// and reload it when the app is back in foreground. + backgroundTerminatedWebViewSessions.append(session) + return + } + + reload(session) + } + + private func reload(_ session: Session) { + session.reload() + } + + /// Inspects the provided session to handle terminated web view process and reloads or recreates the web view accordingly. + /// + /// - Parameter session: The session to inspect. + /// + /// This method checks if the web view associated with the session has terminated in the background. + /// If so, it removes the session from the list of background terminated web view processes, reloads the session, and returns. + /// If the session's topmost visitable URL is not available, the method returns without further action. + /// If the web view's content process state is non-recoverable/terminated, it recreates the web view for the session. + private func inspect(_ session: Session) { + if let index = backgroundTerminatedWebViewSessions.firstIndex(where: { $0 === session }) { + backgroundTerminatedWebViewSessions.remove(at: index) + reload(session) + return + } + + guard let _ = session.topmostVisitable?.visitableURL else { + return + } + + session.webView.queryWebContentProcessState { [weak self] state in + guard case .terminated = state else { return } + self?.recreateWebView(for: session) + } + } + + /// Recreates the web view and session for the given session and performs a `replace` visit. + /// + /// - Parameter session: The session to recreate. + private func recreateWebView(for session: Session) { + guard let _ = session.activeVisitable?.visitableViewController, + let url = session.activeVisitable?.visitableURL else { return } + + let newSession = Session(webView: Turbo.config.makeWebView()) + newSession.pathConfiguration = session.pathConfiguration + newSession.delegate = self + newSession.webView.uiDelegate = webkitUIDelegate + + if session == self.session { + self.session = newSession + } else { + self.modalSession = newSession + } + + let options = VisitOptions(action: .replace, response: nil) + let properties = session.pathConfiguration?.properties(for: url) ?? PathProperties() + route(VisitProposal(url: url, options: options, properties: properties)) + } +} diff --git a/Source/Turbo Navigator/TurboNavigatorDelegate.swift b/Source/Turbo Navigator/TurboNavigatorDelegate.swift new file mode 100644 index 0000000..12e6cb9 --- /dev/null +++ b/Source/Turbo Navigator/TurboNavigatorDelegate.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Contract for handling navigation requests and actions +/// - Note: Methods are __optional__ by default implementation in ``TurboNavigatorDelegate`` extension. +public protocol TurboNavigatorDelegate: AnyObject { + typealias RetryBlock = () -> Void + + /// Accept or reject a visit proposal. + /// There are three `ProposalResult` cases: + /// - term `accept`: Proposals are accepted and a new `VisitableViewController` is displayed. + /// - term `acceptCustom(UIViewController)`: You may provide a view controller to be displayed, otherwise a new `VisitableViewController` is displayed. + /// - term `reject`: No changes to navigation occur. + /// + /// - Parameter proposal: `VisitProposal` navigation destination + /// - Returns:`ProposalResult` - how to react to the visit proposal + func handle(proposal: VisitProposal) -> ProposalResult + + func handle(externalURL: URL) -> ExternalURLNavigationAction + + /// An error occurred loading the request, present it to the user. + /// Retry the request by executing the closure. + /// - Important: If not implemented, will present the error's localized description and a Retry button. + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) + + /// Respond to authentication challenge presented by web servers behing basic auth. + /// If not implemented, default handling will be performed. + func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + + /// Optional. Called after a form starts a submission. + /// If not implemented, no action is taken. + func formSubmissionDidStart(to url: URL) + + /// Optional. Called after a form finishes a submission. + /// If not implemented, no action is taken. + func formSubmissionDidFinish(at url: URL) +} + +public extension TurboNavigatorDelegate { + func handle(proposal: VisitProposal) -> ProposalResult { + .accept + } + + func handle(externalURL: URL) -> ExternalURLNavigationAction { + .openViaSafariController + } + + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) { + if let errorPresenter = visitable as? ErrorPresenter { + errorPresenter.presentError(error, retryHandler: retryHandler) + } + } + + func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + completionHandler(.performDefaultHandling, nil) + } + + func formSubmissionDidStart(to url: URL) {} + + func formSubmissionDidFinish(at url: URL) {} +} diff --git a/Source/Turbo Navigator/TurboWKUIDelegate.swift b/Source/Turbo Navigator/TurboWKUIDelegate.swift new file mode 100644 index 0000000..5a2ad57 --- /dev/null +++ b/Source/Turbo Navigator/TurboWKUIDelegate.swift @@ -0,0 +1,33 @@ +import Foundation +import WebKit + +public protocol TurboWKUIDelegate: AnyObject { + func present(_ alert: UIAlertController, animated: Bool) +} + +open class TurboWKUIController: NSObject, WKUIDelegate { + private unowned var delegate: TurboWKUIDelegate + + public init(delegate: TurboWKUIDelegate!) { + self.delegate = delegate + } + + open func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) { + let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in + completionHandler() + }) + delegate.present(alert, animated: true) + } + + open func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) { + let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .destructive) { _ in + completionHandler(true) + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + completionHandler(false) + }) + delegate.present(alert, animated: true) + } +} diff --git a/Source/Turbo.swift b/Source/Turbo.swift new file mode 100644 index 0000000..df750b1 --- /dev/null +++ b/Source/Turbo.swift @@ -0,0 +1,63 @@ +import WebKit + +public enum Turbo { + public static var config = TurboConfig() +} + +public class TurboConfig { + public typealias WebViewBlock = (_ configuration: WKWebViewConfiguration) -> WKWebView + + /// Override to set a custom user agent. + /// - Important: Include "Turbo Native" to use `turbo_native_app?` on your Rails server. + public var userAgent = "Turbo Native iOS" + + /// The view controller used in `TurboNavigator` for web requests. Must be + /// a `VisitableViewController` or subclass. + public var defaultViewController: (URL) -> VisitableViewController = { url in + VisitableViewController(url: url) + } + + /// The navigation controller used in `TurboNavigator` for the main and modal stacks. + /// Must be a `UINavigationController` or subclass. + public var defaultNavigationController: () -> UINavigationController = { + UINavigationController() + } + + /// Optionally customize the web views used by each Turbo Session. + /// Ensure you return a new instance each time. + public var makeCustomWebView: WebViewBlock = { (configuration: WKWebViewConfiguration) in + WKWebView(frame: .zero, configuration: configuration) + } + + public var debugLoggingEnabled = false { + didSet { + TurboLogger.debugLoggingEnabled = debugLoggingEnabled + } + } + + // MARK: - Internal + + public func makeWebView() -> WKWebView { + makeCustomWebView(makeWebViewConfiguration()) + } + + // MARK: - Private + + private let sharedProcessPool = WKProcessPool() + + // A method (not a property) because we need a new instance for each web view. + private func makeWebViewConfiguration() -> WKWebViewConfiguration { + let configuration = WKWebViewConfiguration() + configuration.applicationNameForUserAgent = userAgent + configuration.processPool = sharedProcessPool + return configuration + } + + public var pathConfiguration = PathConfiguration() +} + +public extension TurboConfig { + class PathConfiguration { + public var matchQueryStrings = false + } +} diff --git a/Source/TurboError.swift b/Source/TurboError.swift index 317946e..8d3dee2 100644 --- a/Source/TurboError.swift +++ b/Source/TurboError.swift @@ -7,7 +7,7 @@ public enum TurboError: LocalizedError, Equatable { case contentTypeMismatch case pageLoadFailure case http(statusCode: Int) - + init(statusCode: Int) { switch statusCode { case 0: @@ -20,7 +20,7 @@ public enum TurboError: LocalizedError, Equatable { self = .http(statusCode: statusCode) } } - + public var errorDescription: String? { switch self { case .networkFailure: diff --git a/Source/Visit/VisitProposal.swift b/Source/Visit/VisitProposal.swift index 32d8237..34e0583 100644 --- a/Source/Visit/VisitProposal.swift +++ b/Source/Visit/VisitProposal.swift @@ -4,10 +4,15 @@ public struct VisitProposal { public let url: URL public let options: VisitOptions public let properties: PathProperties + public let parameters: [String: Any]? - public init(url: URL, options: VisitOptions, properties: PathProperties = [:]) { + public init(url: URL, + options: VisitOptions, + properties: PathProperties = [:], + parameters: [String: Any]? = nil) { self.url = url self.options = options self.properties = properties + self.parameters = parameters } } diff --git a/Tests/Fixtures/test-configuration.json b/Tests/Fixtures/test-configuration.json index e0d050a..c957183 100644 --- a/Tests/Fixtures/test-configuration.json +++ b/Tests/Fixtures/test-configuration.json @@ -19,6 +19,10 @@ { "patterns": ["/edit$"], "properties": {"background_color": "white"} + }, + { + "patterns": [".*\\?.*open_in_external_browser=true.*"], + "properties": {"open_in_external_browser": true} } ] } diff --git a/Tests/PathConfigurationLoaderTests.swift b/Tests/PathConfigurationLoaderTests.swift index e04956d..40b7948 100644 --- a/Tests/PathConfigurationLoaderTests.swift +++ b/Tests/PathConfigurationLoaderTests.swift @@ -15,7 +15,7 @@ class PathConfigurationLoaderTests: XCTestCase { loader.load { loadedConfig = $0 } let config = try XCTUnwrap(loadedConfig) - XCTAssertEqual(config.rules.count, 4) + XCTAssertEqual(config.rules.count, 5) } func test_file_automaticallyLoadsFromTheLocalFileAndCallsTheHandler() throws { @@ -25,7 +25,7 @@ class PathConfigurationLoaderTests: XCTestCase { loader.load { loadedConfig = $0 } let config = try XCTUnwrap(loadedConfig) - XCTAssertEqual(config.rules.count, 4) + XCTAssertEqual(config.rules.count, 5) } func test_server_automaticallyDownloadsTheFileAndCallsTheHandler() throws { @@ -55,7 +55,7 @@ class PathConfigurationLoaderTests: XCTestCase { wait(for: [expectation]) XCTAssertTrue(handlerCalled) - XCTAssertTrue(FileManager.default.fileExists(atPath: loader.configurationCacheURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: loader.configurationCacheURL(for: serverURL).path)) } private func stubRequest(for loader: PathConfigurationLoader) -> XCTestExpectation { @@ -64,7 +64,7 @@ class PathConfigurationLoaderTests: XCTestCase { return HTTPStubsResponse(jsonObject: json, statusCode: 200, headers: [:]) } - clearCache(loader.configurationCacheURL) + clearCache(loader.configurationCacheURL(for: serverURL)) return expectation(description: "Wait for configuration to load.") } diff --git a/Tests/PathConfigurationTests.swift b/Tests/PathConfigurationTests.swift index 5a45e83..be702ea 100644 --- a/Tests/PathConfigurationTests.swift +++ b/Tests/PathConfigurationTests.swift @@ -12,7 +12,7 @@ class PathConfigurationTests: XCTestCase { func test_init_automaticallyLoadsTheConfigurationFromTheSpecifiedLocation() { XCTAssertEqual(configuration.settings.count, 2) - XCTAssertEqual(configuration.rules.count, 4) + XCTAssertEqual(configuration.rules.count, 5) } func test_settings_returnsCurrentSettings() { @@ -39,6 +39,18 @@ class PathConfigurationTests: XCTestCase { "background_color": "white" ]) } + + func test_propertiesForURL_withParams() { + let url = URL(string: "http://turbo.test/sample.pdf?open_in_external_browser=true")! + + Turbo.config.pathConfiguration.matchQueryStrings = false + XCTAssertEqual(configuration.properties(for: url), [:]) + + Turbo.config.pathConfiguration.matchQueryStrings = true + XCTAssertEqual(configuration.properties(for: url), [ + "open_in_external_browser": true + ]) + } func test_propertiesForPath_whenNoMatch_returnsEmptyProperties() { XCTAssertEqual(configuration.properties(for: "/missing"), [:]) @@ -49,6 +61,7 @@ class PathConfigurationTests: XCTestCase { XCTAssertEqual(configuration.properties(for: "/edit"), configuration["/edit"]) XCTAssertEqual(configuration.properties(for: "/"), configuration["/"]) XCTAssertEqual(configuration.properties(for: "/missing"), configuration["/missing"]) + XCTAssertEqual(configuration.properties(for: "/sample.pdf?open_in_external_browser=true"), configuration["/sample.pdf?open_in_external_browser=true"]) } } @@ -61,7 +74,7 @@ class PathConfigTests: XCTestCase { let config = try PathConfigurationDecoder(json: json) XCTAssertEqual(config.settings.count, 2) - XCTAssertEqual(config.rules.count, 4) + XCTAssertEqual(config.rules.count, 5) } func test_json_withMissingRulesKey_failsToDecode() throws { diff --git a/Tests/Turbo Navigator/TestableNavigationController.swift b/Tests/Turbo Navigator/TestableNavigationController.swift new file mode 100644 index 0000000..e2a01d6 --- /dev/null +++ b/Tests/Turbo Navigator/TestableNavigationController.swift @@ -0,0 +1,41 @@ +import UIKit + +/// Manipulate a navigation controller under test. +/// Ensures `viewControllers` is updated synchronously. +/// Manages `presentedViewController` directly because it isn't updated on the same thread. +class TestableNavigationController: UINavigationController { + override var presentedViewController: UIViewController? { + get { _presentedViewController } + set { _presentedViewController = newValue } + } + + override func pushViewController(_ viewController: UIViewController, animated: Bool) { + super.pushViewController(viewController, animated: false) + } + + override func popViewController(animated: Bool) -> UIViewController? { + super.popViewController(animated: false) + } + + override func popToRootViewController(animated: Bool) -> [UIViewController]? { + super.popToRootViewController(animated: false) + } + + override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) { + super.setViewControllers(viewControllers, animated: false) + } + + override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + _presentedViewController = viewControllerToPresent + super.present(viewControllerToPresent, animated: false, completion: completion) + } + + override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { + _presentedViewController = nil + super.dismiss(animated: false, completion: completion) + } + + // MARK: Private + + private var _presentedViewController: UIViewController? +} diff --git a/Tests/Turbo Navigator/TurboNavigationDelegateTests.swift b/Tests/Turbo Navigator/TurboNavigationDelegateTests.swift new file mode 100644 index 0000000..d70aea0 --- /dev/null +++ b/Tests/Turbo Navigator/TurboNavigationDelegateTests.swift @@ -0,0 +1,14 @@ +import SafariServices +@testable import Turbo +import XCTest + +final class TurboNavigationDelegateTests: TurboNavigator { + func test_controllerForProposal_defaultsToVisitableViewController() throws { + let url = URL(string: "https://example.com")! + + let proposal = VisitProposal(url: url, options: VisitOptions()) + let result = delegate.handle(proposal: proposal) + + XCTAssertEqual(result, .accept) + } +} diff --git a/Tests/Turbo Navigator/TurboNavigatorTests.swift b/Tests/Turbo Navigator/TurboNavigatorTests.swift new file mode 100644 index 0000000..cba3e6f --- /dev/null +++ b/Tests/Turbo Navigator/TurboNavigatorTests.swift @@ -0,0 +1,394 @@ +import SafariServices +@testable import Turbo +import XCTest + +/// Tests are written in the following format: +/// `test_currentContext_givenContext_givenPresentation_modifiers_result()` +/// See the README for a more visually pleasing table. +final class TurboNavigationHierarchyControllerTests: XCTestCase { + override func setUp() { + navigationController = TestableNavigationController() + modalNavigationController = TestableNavigationController() + + navigator = TurboNavigator(session: session, modalSession: modalSession) + hierarchyController = TurboNavigationHierarchyController(delegate: navigator, navigationController: navigationController, modalNavigationController: modalNavigationController) + navigator.hierarchyController = hierarchyController + + loadNavigationControllerInWindow() + } + + func test_default_default_default_defaultOptionsParamater_pushesOnMainStack() { + navigator.route(oneURL) + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) + + navigator.route(twoURL) + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) + assertVisited(url: twoURL, on: .main) + } + + func test_default_default_default_nilOptionsParameter_pushesOnMainStack() { + navigator.route(oneURL) + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) + + navigator.route(twoURL, options: nil) + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) + assertVisited(url: twoURL, on: .main) + } + + func test_default_default_default_visitingSamePage_replacesOnMainStack() { + navigator.route(oneURL) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + navigator.route(oneURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) + assertVisited(url: oneURL, on: .main) + } + + func test_default_default_default_visitingPreviousPage_popsAndVisitsOnMainStack() { + navigator.route(oneURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + + navigator.route(twoURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 2) + + navigator.route(oneURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + XCTAssert(navigator.rootViewController.viewControllers.last is VisitableViewController) + assertVisited(url: oneURL, on: .main) + } + + func test_default_default_default_replaceAction_replacesOnMainStack() { + let proposal = VisitProposal(action: .replace) + navigator.route(proposal) + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .main) + } + + func test_default_default_replace_replacesOnMainStack() { + navigationController.pushViewController(UIViewController(), animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + let proposal = VisitProposal(presentation: .replace) + navigator.route(proposal) + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .main) + } + + func test_default_default_refresh_refreshesPreviousController() { + navigator.route(oneURL) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + navigator.route(twoURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 2) + + /// Refreshing should pop the view controller and refresh the underlying controller. + let proposal = VisitProposal(presentation: .refresh) + navigator.route(proposal) + + let visitable = navigator.session.activeVisitable as! VisitableViewController + XCTAssertEqual(visitable.visitableURL, oneURL) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + } + + func test_default_modal_refresh_refreshesPreviousController() { + navigationController.pushViewController(UIViewController(), animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + let oneURLProposal = VisitProposal(path: "/one", context: .modal) + navigator.route(oneURLProposal) + + let twoURLProposal = VisitProposal(path: "/two", context: .modal) + navigator.route(twoURLProposal) + XCTAssertEqual(modalNavigationController.viewControllers.count, 2) + + /// Refreshing should pop the view controller and refresh the underlying controller. + let proposal = VisitProposal(presentation: .refresh) + navigator.route(proposal) + + let visitable = navigator.modalSession.activeVisitable as! VisitableViewController + XCTAssertEqual(visitable.visitableURL, oneURL) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + } + + func test_default_modal_refresh_dismissesAndRefreshesMainStackTopViewController() { + navigator.route(oneURL) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + let twoURLProposal = VisitProposal(path: "/two", context: .modal) + navigator.route(twoURLProposal) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + + /// Refreshing should dismiss the view controller and refresh the underlying controller. + let proposal = VisitProposal(context: .modal, presentation: .refresh) + navigator.route(proposal) + + let visitable = navigator.session.activeVisitable as! VisitableViewController + XCTAssertEqual(visitable.visitableURL, oneURL) + + XCTAssertNil(navigationController.presentedViewController) + XCTAssertEqual(navigator.rootViewController.viewControllers.count, 1) + } + + func test_default_modal_default_presentsModal() { + navigationController.pushViewController(UIViewController(), animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + let proposal = VisitProposal(context: .modal) + navigator.route(proposal) + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) + XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .modal) + } + + func test_default_modal_replace_presentsModal() { + navigationController.pushViewController(UIViewController(), animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + let proposal = VisitProposal(context: .modal, presentation: .replace) + navigator.route(proposal) + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) + XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .modal) + } + + func test_modal_default_default_dismissesModalThenPushesOnMainStack() { + navigationController.pushViewController(UIViewController(), animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + navigator.route(VisitProposal(context: .modal)) + XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) + + let proposal = VisitProposal() + navigator.route(proposal) + XCTAssertNil(navigationController.presentedViewController) + XCTAssert(navigationController.viewControllers.last is VisitableViewController) + XCTAssertEqual(navigationController.viewControllers.count, 2) + assertVisited(url: proposal.url, on: .main) + } + + func test_modal_default_replace_dismissesModalThenReplacedOnMainStack() { + navigator.route(VisitProposal(context: .modal)) + XCTAssertIdentical(navigationController.presentedViewController, modalNavigationController) + + let proposal = VisitProposal(presentation: .replace) + navigator.route(proposal) + XCTAssertNil(navigationController.presentedViewController) + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .main) + } + + func test_modal_modal_default_pushesOnModalStack() { + navigator.route(VisitProposal(path: "/one", context: .modal)) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + + let proposal = VisitProposal(path: "/two", context: .modal) + navigator.route(proposal) + XCTAssertEqual(modalNavigationController.viewControllers.count, 2) + XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .modal) + } + + func test_modal_modal_default_replaceAction_pushesOnModalStack() { + navigator.route(VisitProposal(path: "/one", context: .modal)) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + + let proposal = VisitProposal(path: "/two", action: .replace, context: .modal) + navigator.route(proposal) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .modal) + } + + func test_modal_modal_replace_pushesOnModalStack() { + navigator.route(VisitProposal(path: "/one", context: .modal)) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + + let proposal = VisitProposal(path: "/two", context: .modal, presentation: .replace) + navigator.route(proposal) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + XCTAssert(modalNavigationController.viewControllers.last is VisitableViewController) + assertVisited(url: proposal.url, on: .modal) + } + + func test_default_any_pop_popsOffMainStack() { + navigationController.pushViewController(UIViewController(), animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + navigator.route(VisitProposal()) + XCTAssertEqual(navigationController.viewControllers.count, 2) + + navigator.route(VisitProposal(presentation: .pop)) + XCTAssertEqual(navigationController.viewControllers.count, 1) + } + + func test_modal_any_pop_popsOffModalStack() { + navigator.route(VisitProposal(path: "/one", context: .modal)) + navigator.route(VisitProposal(path: "/two", context: .modal)) + XCTAssertEqual(modalNavigationController.viewControllers.count, 2) + + navigator.route(VisitProposal(presentation: .pop)) + XCTAssertNotNil(navigationController.presentedViewController) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + } + + func test_modal_any_pop_exactlyOneModal_dismissesModal() { + navigator.route(VisitProposal(path: "/one", context: .modal)) + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + + navigator.route(VisitProposal(presentation: .pop)) + XCTAssertNil(navigationController.presentedViewController) + } + + func test_any_any_clearAll_dismissesModalThenPopsToRootOnMainStack() { + let rootController = UIViewController() + navigationController.viewControllers = [rootController, UIViewController(), UIViewController()] + XCTAssertEqual(navigationController.viewControllers.count, 3) + + let proposal = VisitProposal(presentation: .clearAll) + navigator.route(proposal) + XCTAssertNil(navigationController.presentedViewController) + XCTAssertEqual(navigationController.viewControllers, [rootController]) + } + + func test_any_any_replaceRoot_dismissesModalThenReplacesRootOnMainStack() { + let rootController = UIViewController() + navigationController.viewControllers = [rootController, UIViewController(), UIViewController()] + XCTAssertEqual(navigationController.viewControllers.count, 3) + + navigator.route(VisitProposal(presentation: .replaceRoot)) + XCTAssertNil(navigationController.presentedViewController) + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.viewControllers.last is VisitableViewController) + } + + func test_presentingUIAlertController_doesNotWrapInNavigationController() { + navigator.delegate = alertControllerDelegate + + navigator.route(VisitProposal(path: "/alert")) + + XCTAssert(navigationController.presentedViewController is UIAlertController) + } + + func test_presentingUIAlertController_onTheModal_doesNotWrapInNavigationController() { + navigator.delegate = alertControllerDelegate + + navigator.route(VisitProposal(context: .modal)) + navigator.route(VisitProposal(path: "/alert")) + + XCTAssert(modalNavigationController.presentedViewController is UIAlertController) + } + + func test_none_cancelsNavigation() { + let topViewController = UIViewController() + navigationController.pushViewController(topViewController, animated: false) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + let proposal = VisitProposal(path: "/cancel", presentation: .none) + navigator.route(proposal) + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController == topViewController) + XCTAssertNotEqual(navigator.session.activeVisitable?.visitableURL, proposal.url) + } + + func test_externalURL_presentsSafariViewController() throws { + let externalURL = URL(string: "https://example.com")! + navigator.session(navigator.session, openExternalURL: externalURL) + + XCTAssert(navigationController.presentedViewController is SFSafariViewController) + XCTAssertEqual(navigationController.presentedViewController?.modalPresentationStyle, .pageSheet) + } + + func test_invalidExternalURL_doesNotPresentSafariViewController() throws { + let externalURL = URL(string: "ftp://example.com")! + navigator.session(navigator.session, openExternalURL: externalURL) + + /// No assertions needed. App will crash if we pass a non-http or non-https scheme to SFSafariViewController. + } + + // MARK: Private + + private enum Context { + case main, modal + } + + private let baseURL = URL(string: "https://example.com")! + private lazy var oneURL = baseURL.appendingPathComponent("/one") + private lazy var twoURL = baseURL.appendingPathComponent("/two") + + private let session = Session(webView: Turbo.config.makeWebView()) + private let modalSession = Session(webView: Turbo.config.makeWebView()) + + private var navigator: TurboNavigator! + private let alertControllerDelegate = AlertControllerDelegate() + private var hierarchyController: TurboNavigationHierarchyController! + private var navigationController: TestableNavigationController! + private var modalNavigationController: TestableNavigationController! + + private let window = UIWindow() + + // Simulate a "real" app so presenting view controllers works under test. + private func loadNavigationControllerInWindow() { + window.rootViewController = navigationController + window.makeKeyAndVisible() + navigationController.loadViewIfNeeded() + } + + private func assertVisited(url: URL, on context: Context) { + switch context { + case .main: + XCTAssertEqual(navigator.session.activeVisitable?.visitableURL, url) + case .modal: + XCTAssertEqual(navigator.modalSession.activeVisitable?.visitableURL, url) + } + } +} + +// MARK: - EmptyNavigationDelegate + +private class EmptyNavigationDelegate: TurboNavigationHierarchyControllerDelegate { + func visit(_: Visitable, on: TurboNavigationHierarchyController.NavigationStackType, with: VisitOptions) {} + func refreshVisitable(navigationStack: TurboNavigationHierarchyController.NavigationStackType, newTopmostVisitable: any Visitable) { } +} + +// MARK: - VisitProposal extension + +private extension VisitProposal { + init(path: String = "", action: VisitAction = .advance, context: TurboNavigation.Context = .default, presentation: TurboNavigation.Presentation = .default) { + let url = URL(string: "https://example.com")!.appendingPathComponent(path) + let options = VisitOptions(action: action, response: nil) + let properties: PathProperties = [ + "context": context.rawValue, + "presentation": presentation.rawValue + ] + self.init(url: url, options: options, properties: properties) + } +} + +// MARK: - AlertControllerDelegate + +private class AlertControllerDelegate: TurboNavigatorDelegate { + func handle(proposal: VisitProposal) -> ProposalResult { + if proposal.url.path == "/alert" { + return .acceptCustom(UIAlertController(title: "Alert", message: nil, preferredStyle: .alert)) + } + + return .accept + } +}