├── .gitignore ├── Docs └── Assets │ ├── demo-dark.png │ └── demo-light.png ├── Demo ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Demo.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── Demo.xcscheme ├── Demo.swift ├── Info.plist ├── path-configuration.json ├── Tabs.swift ├── Base.lproj │ └── LaunchScreen.storyboard ├── Bridge │ ├── OverflowMenuComponent.swift │ ├── FormComponent.swift │ └── MenuComponent.swift ├── NumbersViewController.swift ├── AppDelegate.swift └── SceneController.swift ├── Source ├── Turbo │ ├── Visit │ │ ├── VisitAction.swift │ │ ├── VisitProposal.swift │ │ ├── VisitResponse.swift │ │ ├── VisitOptions.swift │ │ ├── VisitDelegate.swift │ │ ├── Visit.swift │ │ └── JavaScriptVisit.swift │ ├── Navigator │ │ ├── Extensions │ │ │ ├── VisitableViewControllerExtension.swift │ │ │ ├── UIViewController+ModalBehaviour.swift │ │ │ ├── URL+Compare.swift │ │ │ ├── VisitProposalExtension.swift │ │ │ ├── WKNavigationAction+Utils.swift │ │ │ ├── UINavigationControllerExtension.swift │ │ │ ├── WKWebView+ebContentProcess.swift │ │ │ └── PathPropertiesExtensions.swift │ │ ├── Navigator+Configuration.swift │ │ ├── Helpers │ │ │ ├── ProposalResult.swift │ │ │ ├── Navigation.swift │ │ │ ├── PathConfigurationIdentifiable.swift │ │ │ ├── Navigation+QueryStringPresentation.swift │ │ │ └── ErrorPresenter.swift │ │ ├── Routing │ │ │ ├── Handlers │ │ │ │ ├── AppNavigationRouteDecisionHandler.swift │ │ │ │ ├── SystemNavigationRouteDecisionHandler.swift │ │ │ │ └── SafariViewControllerRouteDecisionHandler.swift │ │ │ ├── Router.swift │ │ │ └── RouteDecisionHandler.swift │ │ ├── WebViewPolicy │ │ │ ├── Handlers │ │ │ │ ├── ReloadWebViewPolicyDecisionHandler.swift │ │ │ │ ├── LinkActivatedWebViewPolicyDecisionHandler.swift │ │ │ │ ├── NewWindowWebViewPolicyDecisionHandler.swift │ │ │ │ └── ExternalNavigationWebViewPolicyDecisionHandler.swift │ │ │ ├── WebViewRouteDecisionHandler.swift │ │ │ └── WebViewPolicyManager.swift │ │ ├── NavigationHierarchyControllerDelegate.swift │ │ ├── WKUIController.swift │ │ └── NavigatorDelegate.swift │ ├── .swiftpm │ │ └── xcode │ │ │ ├── package.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── Turbo.xcscheme │ ├── Path Configuration │ │ ├── PathConfigurationDecoder.swift │ │ ├── PathRule+ServerRoutes.swift │ │ ├── PathRule.swift │ │ └── PathConfiguration.swift │ ├── WebView │ │ ├── JSON.swift │ │ ├── JavaScriptExpression.swift │ │ └── ScriptMessage.swift │ ├── TurboError.swift │ ├── Utils │ │ └── AppLifecycleObserver.swift │ ├── Session │ │ └── SessionDelegate.swift │ ├── Networking │ │ └── RedirectHandler.swift │ ├── ViewControllers │ │ ├── HotwireWebViewController.swift │ │ └── HotwireNavigationController.swift │ └── Visitable │ │ └── Visitable.swift ├── Bridge │ ├── Extensions │ │ ├── Encodable+Utils.swift │ │ ├── Data+Utils.swift │ │ ├── String+JSON.swift │ │ └── Dictionary+JSON.swift │ ├── Strada.xcodeproj │ │ ├── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── Strada.xcscheme │ ├── UserAgent.swift │ ├── JavaScript.swift │ ├── bridge.js │ └── InternalMessage.swift ├── HotwireLogger.swift ├── WebView.swift ├── ScriptMessageHandler.swift ├── NavigationHandler.swift └── Hotwire.swift ├── Tests ├── Bridge │ ├── Extensions │ │ └── TimeInterval+ExpectationTimeout.swift │ ├── Spies │ │ ├── BridgeDelegateSpy.swift │ │ ├── BridgeComponentSpy.swift │ │ └── BridgeSpy.swift │ ├── UserAgentTests.swift │ ├── TestData.swift │ ├── JavaScriptTests.swift │ ├── ComponentTestExample │ │ ├── ComposerComponent.swift │ │ └── ComposerComponentTests.swift │ ├── BridgeComponentAsyncAPITests.swift │ ├── BridgeDelegate+DestinationTests.swift │ └── InternalMessageTests.swift └── Turbo │ ├── Server │ ├── turbo.html │ ├── turbolinks.html │ └── turbolinks-5.3.html │ ├── Spies │ └── SessionSpy.swift │ ├── HotwireConfigTests.swift │ ├── Fixtures │ ├── test-configuration-historical-locations.json │ ├── test-configuration.json │ └── test-modal-styles-configuration.json │ ├── WebViewPolicy │ ├── BaseWebViewPolicyDecisionHandlerTests.swift │ ├── Spies │ │ └── NavigationSpy.swift │ ├── NavigationPolicyHTML.swift │ ├── LinkActivatedWebViewPolicyDecisionHandlerTests.swift │ ├── ReloadWebViewPolicyDecisionHandlerTests.swift │ ├── NewWindowWebViewPolicyDecisionHandlerTests.swift │ ├── WebViewNavigationSimulator.swift │ └── ExternalNavigationWebViewPolicyDecisionHandlerTests.swift │ ├── Info.plist │ ├── Navigator │ ├── NavigationDelegateTests.swift │ └── TestableNavigationController.swift │ ├── JavaScriptExpressionTests.swift │ ├── Routing │ ├── AppNavigationRouteDecisionHandlerTest.swift │ ├── SafariViewControllerRouteDecisionHandlerTests.swift │ ├── SystemNavigationRouteDecisionHandlerTest.swift │ └── RouterTests.swift │ ├── ScriptMessageTests.swift │ ├── PathConfigurationModalStyleTests.swift │ ├── Server.swift │ ├── VisitOptionsTests.swift │ ├── ColdBootVisitTests.swift │ ├── VisitableViewControllerTests.swift │ ├── PathRuleTests.swift │ └── PathConfigurationLoaderTests.swift ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── Package.resolved ├── .github └── workflows │ └── run_tests.yml ├── LICENSE ├── CONTRIBUTING.md ├── README.md ├── Package.swift └── CODE_OF_CONDUCT.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build 3 | Packages 4 | xcuserdata/ 5 | -------------------------------------------------------------------------------- /Docs/Assets/demo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenkel/hotwire-native-ios/main/Docs/Assets/demo-dark.png -------------------------------------------------------------------------------- /Docs/Assets/demo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenkel/hotwire-native-ios/main/Docs/Assets/demo-light.png -------------------------------------------------------------------------------- /Demo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Source/Turbo/Visit/VisitAction.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum VisitAction: String, Codable { 4 | case advance 5 | case replace 6 | case restore 7 | } 8 | -------------------------------------------------------------------------------- /Tests/Bridge/Extensions/TimeInterval+ExpectationTimeout.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension TimeInterval { 4 | static let expectationTimeout: TimeInterval = 5 5 | } 6 | -------------------------------------------------------------------------------- /Source/Bridge/Extensions/Encodable+Utils.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Encodable { 4 | func encoded() throws -> Data { 5 | return try JSONEncoder().encode(self) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Source/Bridge/Extensions/Data+Utils.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Data { 4 | func decoded() throws -> T { 5 | return try JSONDecoder().decode(T.self, from: self) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Extensions/VisitableViewControllerExtension.swift: -------------------------------------------------------------------------------- 1 | extension VisitableViewController: PathConfigurationIdentifiable { 2 | public static var pathConfigurationIdentifier: String { "web" } 3 | } 4 | -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Source/Turbo/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Extensions/UIViewController+ModalBehaviour.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIViewController { 4 | func configureModalBehaviour(with proposal: VisitProposal) { 5 | isModalInPresentation = !proposal.modalDismissGestureEnabled 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Demo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Source/Bridge/Strada.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Demo/Demo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Demo { 4 | static let remote = URL(string: "https://hotwire-native-demo.dev")! 5 | static let local = URL(string: "http://localhost:3000")! 6 | 7 | /// Update this to choose which demo is run 8 | static var current: URL { 9 | remote 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Source/Bridge/Strada.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/Turbo/Server/turbo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Turbo 7 6 | 7 | 10 | 11 | 12 |

Unit Tests

13 | 14 | 15 | -------------------------------------------------------------------------------- /Tests/Turbo/Server/turbolinks.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Turbolinks 5 6 | 7 | 10 | 11 | 12 |

Unit Tests

13 | 14 | 15 | -------------------------------------------------------------------------------- /Tests/Turbo/Server/turbolinks-5.3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Turbolinks 5.3 6 | 7 | 10 | 11 | 12 |

Unit Tests

13 | 14 | 15 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Navigator+Configuration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Navigator { 4 | struct Configuration { 5 | public let name: String 6 | public let startLocation: URL 7 | 8 | public init(name: String, startLocation: URL) { 9 | self.name = name 10 | self.startLocation = startLocation 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/Turbo/Spies/SessionSpy.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | 3 | final class SessionSpy: Session { 4 | var visitWasCalled = false 5 | var visitAction: VisitAction? 6 | 7 | override func visit(_ visitable: any Visitable, action: VisitAction) { 8 | visitWasCalled = true 9 | visitAction = action 10 | super.visit(visitable, action: action) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Demo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "244", 9 | "green" : "139", 10 | "red" : "193" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Helpers/ProposalResult.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Return from `NavigatorDelegate.handle(proposal:from:)` to route a custom controller. 4 | public enum ProposalResult: Equatable { 5 | /// Route a `VisitableViewController`. 6 | case accept 7 | 8 | /// Route a custom `UIViewController` or subclass 9 | case acceptCustom(UIViewController) 10 | 11 | /// Do not route. Navigation is not modified. 12 | case reject 13 | } 14 | -------------------------------------------------------------------------------- /Source/HotwireLogger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os.log 3 | 4 | enum HotwireLogger { 5 | static var debugLoggingEnabled: Bool = false { 6 | didSet { 7 | logger = debugLoggingEnabled ? enabledLogger : disabledLogger 8 | } 9 | } 10 | 11 | static let enabledLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Hotwire") 12 | static let disabledLogger = Logger(.disabled) 13 | } 14 | 15 | var logger = HotwireLogger.disabledLogger 16 | -------------------------------------------------------------------------------- /Source/Turbo/Visit/VisitProposal.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct VisitProposal { 4 | public let url: URL 5 | public let options: VisitOptions 6 | public let properties: PathProperties 7 | public let parameters: [String: Any]? 8 | 9 | public init(url: URL, options: VisitOptions, properties: PathProperties = [:], parameters: [String: Any]? = nil) { 10 | self.url = url 11 | self.options = options 12 | self.properties = properties 13 | self.parameters = parameters 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Source/Turbo/Visit/VisitResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct VisitResponse: Codable { 4 | public let statusCode: Int 5 | public let responseHTML: String? 6 | 7 | public init(statusCode: Int, responseHTML: String? = nil) { 8 | self.statusCode = statusCode 9 | self.responseHTML = responseHTML 10 | } 11 | 12 | public var isSuccessful: Bool { 13 | switch statusCode { 14 | case 200 ... 299: 15 | return true 16 | default: 17 | return false 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Source/Bridge/UserAgent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum UserAgent { 4 | static func build(applicationPrefix: String?, componentTypes: [BridgeComponent.Type]) -> String { 5 | let components = componentTypes.map { $0.name }.joined(separator: " ") 6 | let componentsSubstring = "bridge-components: [\(components)]" 7 | 8 | return [ 9 | applicationPrefix, 10 | "Hotwire Native iOS;", 11 | "Turbo Native iOS;", 12 | componentsSubstring 13 | ].compactMap { $0 }.joined(separator: " ") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Source/WebView.swift: -------------------------------------------------------------------------------- 1 | import WebKit 2 | 3 | extension WKWebView { 4 | static func debugInspectable(configuration: WKWebViewConfiguration) -> WKWebView { 5 | let webView = WKWebView(frame: .zero, configuration: configuration) 6 | webView.makeInspectableInDebugBuilds() 7 | return webView 8 | } 9 | } 10 | 11 | private extension WKWebView { 12 | func makeInspectableInDebugBuilds() { 13 | #if DEBUG 14 | if #available(iOS 16.4, *) { 15 | isInspectable = true 16 | } 17 | #endif 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Source/Bridge/Extensions/String+JSON.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | func jsonObject() -> Any? { 5 | guard let jsonData = data(using: .utf8) else { 6 | logger.error("Error converting JSON string to data. \nJSON string: \(self)") 7 | return nil 8 | } 9 | 10 | do { 11 | let object = try JSONSerialization.jsonObject(with: jsonData) 12 | return object 13 | } catch { 14 | logger.error("Error converting JSON data to object: \(error)") 15 | return nil 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Source/Bridge/Extensions/Dictionary+JSON.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Dictionary where Key == String, Value == AnyHashable { 4 | func jsonData() -> Data? { 5 | guard JSONSerialization.isValidJSONObject(self) else { 6 | logger.warning("The provided object is not a valid JSON object. \(self)") 7 | return nil 8 | } 9 | 10 | do { 11 | let data = try JSONSerialization.data(withJSONObject: self) 12 | return data 13 | } catch { 14 | logger.error("Error converting JSON object to data: \(error)") 15 | return nil 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Helpers/Navigation.swift: -------------------------------------------------------------------------------- 1 | public enum Navigation { 2 | public enum Context: String { 3 | case `default` 4 | case modal 5 | } 6 | 7 | public enum Presentation: String { 8 | case `default` 9 | case pop 10 | case replace 11 | case refresh 12 | case clearAll = "clear_all" 13 | case replaceRoot = "replace_root" 14 | case none 15 | } 16 | 17 | public enum ModalStyle: String { 18 | case medium 19 | case large 20 | case full 21 | case pageSheet = "page_sheet" 22 | case formSheet = "form_sheet" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "embassy", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/envoy/Embassy.git", 7 | "state" : { 8 | "revision" : "8469f2c1b334a7c1c3566e2cb2f97826c7cca898", 9 | "version" : "4.1.6" 10 | } 11 | }, 12 | { 13 | "identity" : "ohhttpstubs", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/AliSoftware/OHHTTPStubs", 16 | "state" : { 17 | "revision" : "12f19662426d0434d6c330c6974d53e2eb10ecd9", 18 | "version" : "9.1.0" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/run_tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | pull_request: 12 | 13 | jobs: 14 | build-and-test: 15 | runs-on: macos-13 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Select Xcode 21 | uses: maxim-lobanov/setup-xcode@v1 22 | with: 23 | xcode-version: latest-stable 24 | 25 | - name: Run Tests 26 | run: xcodebuild test -scheme HotwireNative -destination "name=iPhone 15 Pro" | xcpretty && exit ${PIPESTATUS[0]} 27 | -------------------------------------------------------------------------------- /Tests/Turbo/HotwireConfigTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import HotwireNative 3 | 4 | final class HotwireConfigTests: XCTestCase { 5 | func testUserAgent() { 6 | var config = HotwireConfig() 7 | config.applicationUserAgentPrefix = "TestApp/1.0" 8 | 9 | let testComponent = MockBridgeComponent.self 10 | Hotwire.registerBridgeComponents([testComponent]) 11 | 12 | XCTAssertEqual(config.userAgent, "TestApp/1.0 Hotwire Native iOS; Turbo Native iOS; bridge-components: [MockComponent]") 13 | } 14 | } 15 | 16 | private class MockBridgeComponent: BridgeComponent { 17 | static override var name: String { "MockComponent" } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/Turbo/Fixtures/test-configuration-historical-locations.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules":[ 3 | { 4 | "patterns":[ 5 | "/recede_historical_location" 6 | ], 7 | "properties":{ 8 | "presentation":"pop", 9 | "context":"modal" 10 | } 11 | }, 12 | { 13 | "patterns":[ 14 | "/resume_historical_location" 15 | ], 16 | "properties":{ 17 | "presentation":"refresh", 18 | "context":"modal" 19 | } 20 | }, 21 | { 22 | "patterns":[ 23 | "/refresh_historical_location" 24 | ], 25 | "properties":{ 26 | "presentation":"pop", 27 | "context":"modal" 28 | } 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /Source/Turbo/Visit/VisitOptions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct VisitOptions: Codable, JSONCodable { 4 | public let action: VisitAction 5 | public let response: VisitResponse? 6 | 7 | public init(action: VisitAction = .advance, response: VisitResponse? = nil) { 8 | self.action = action 9 | self.response = response 10 | } 11 | 12 | public init(from decoder: Decoder) throws { 13 | let container = try decoder.container(keyedBy: CodingKeys.self) 14 | self.action = try container.decodeIfPresent(VisitAction.self, forKey: .action) ?? .advance 15 | self.response = try container.decodeIfPresent(VisitResponse.self, forKey: .response) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Source/ScriptMessageHandler.swift: -------------------------------------------------------------------------------- 1 | import WebKit 2 | 3 | protocol ScriptMessageHandlerDelegate: AnyObject { 4 | func scriptMessageHandlerDidReceiveMessage(_ scriptMessage: WKScriptMessage) 5 | } 6 | 7 | // Avoids retain cycle caused by WKUserContentController 8 | final class ScriptMessageHandler: NSObject, WKScriptMessageHandler { 9 | weak var delegate: ScriptMessageHandlerDelegate? 10 | 11 | init(delegate: ScriptMessageHandlerDelegate?) { 12 | self.delegate = delegate 13 | } 14 | 15 | func userContentController(_ userContentController: WKUserContentController, didReceive scriptMessage: WKScriptMessage) { 16 | delegate?.scriptMessageHandlerDidReceiveMessage(scriptMessage) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "embassy", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/envoy/Embassy.git", 7 | "state" : { 8 | "revision" : "8469f2c1b334a7c1c3566e2cb2f97826c7cca898", 9 | "version" : "4.1.6" 10 | } 11 | }, 12 | { 13 | "identity" : "ohhttpstubs", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/AliSoftware/OHHTTPStubs", 16 | "state" : { 17 | "revision" : "12f19662426d0434d6c330c6974d53e2eb10ecd9", 18 | "version" : "9.1.0" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /Tests/Turbo/Fixtures/test-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "server": "beta", 4 | "some-feature-enabled": true 5 | }, 6 | "rules": [ 7 | { 8 | "patterns": ["/$"], 9 | "properties": {"page": "root"} 10 | }, 11 | { 12 | "patterns": ["/new$", "/edit$"], 13 | "properties": {"context": "modal"} 14 | }, 15 | { 16 | "patterns": ["/new$"], 17 | "properties": {"background_color": "black"} 18 | }, 19 | { 20 | "patterns": ["/edit$"], 21 | "properties": {"background_color": "white"} 22 | }, 23 | { 24 | "patterns": [".*\\?.*open_in_external_browser=true.*"], 25 | "properties": {"open_in_external_browser": true} 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /Demo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneController 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | @preconcurrency import WebKit 3 | import XCTest 4 | 5 | @MainActor 6 | class BaseWebViewPolicyDecisionHandlerTests: XCTestCase { 7 | var webNavigationSimulator: WebViewNavigationSimulator! 8 | var navigatorSpy: NavigationSpy! 9 | let navigatorConfiguration = Navigator.Configuration( 10 | name: "test", 11 | startLocation: URL(string: "https://my.app.com")! 12 | ) 13 | 14 | override func setUp() async throws { 15 | navigatorSpy = NavigationSpy(configuration: navigatorConfiguration) 16 | webNavigationSimulator = WebViewNavigationSimulator() 17 | } 18 | 19 | override func tearDown() async throws { 20 | webNavigationSimulator = nil 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public final class AppNavigationRouteDecisionHandler: RouteDecisionHandler { 4 | public let name: String = "app-navigation" 5 | 6 | public init() {} 7 | 8 | public func matches(location: URL, 9 | configuration: Navigator.Configuration) -> Bool { 10 | if #available(iOS 16, *) { 11 | return configuration.startLocation.host() == location.host() 12 | } 13 | 14 | return configuration.startLocation.host == location.host 15 | } 16 | 17 | public func handle(location: URL, 18 | configuration: Navigator.Configuration, 19 | navigator: Navigator) -> Router.Decision { 20 | return .navigate 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/Turbo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Extensions/URL+Compare.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URL { 4 | /// Returns a Bool value indicating whether the given URL refers to the same location, 5 | /// taking into account the specified path properties. 6 | /// 7 | /// - Parameters: 8 | /// - url: The URL to compare against. 9 | /// - pathProperties: The path properties of the given URL. 10 | /// - Returns: `true` if the current instance and the given URL represent the same location; otherwise, `false`. 11 | func isSameLocation(as url: URL, pathProperties: PathProperties) -> Bool { 12 | switch pathProperties.queryStringPresentation { 13 | case .replace: 14 | return path == url.path 15 | case .default: 16 | return path == url.path && query == url.query 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import Foundation 3 | 4 | final class NavigationSpy: Navigator { 5 | var routeWasCalled = false 6 | var routeURL: URL? 7 | var reloadWasCalled = false 8 | 9 | init(configuration: Navigator.Configuration) { 10 | super.init( 11 | session: Session(webView: Hotwire.config.makeWebView()), 12 | modalSession: Session(webView: Hotwire.config.makeWebView()), 13 | configuration: configuration 14 | ) 15 | } 16 | 17 | override func route(_ url: URL, options: VisitOptions? = VisitOptions(action: .advance), parameters: [String : Any]? = nil) { 18 | routeWasCalled = true 19 | routeURL = url 20 | } 21 | 22 | override func reload() { 23 | reloadWasCalled = true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Demo/path-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "enable_feature_x": true 4 | }, 5 | "rules": [ 6 | { 7 | "patterns": [ 8 | "/new$", 9 | "/edit$", 10 | "/modal" 11 | ], 12 | "properties": { 13 | "context": "modal", 14 | "pull_to_refresh_enabled": false 15 | }, 16 | "comment": "Present forms and custom modal path as modals." 17 | }, 18 | { 19 | "patterns": [ 20 | "/numbers$" 21 | ], 22 | "properties": { 23 | "view_controller": "numbers" 24 | }, 25 | "comment": "Intercept with a native view." 26 | }, 27 | { 28 | "patterns": [ 29 | "^/$" 30 | ], 31 | "properties": { 32 | "presentation": "clear_all" 33 | }, 34 | "comment": "Reset navigation stacks when visiting root page." 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /Source/NavigationHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A protocol to bridge back to Hotwire world from a native context. Use this 4 | /// to trigger a new page visit including routing and presentation. 5 | /// 6 | /// When responding to `NavigatorDelegate.handle(proposal:navigator:)`, to 7 | /// route a native view controller, pass in an instance of `Navigator` typed 8 | /// as this protocol with a weak reference. This ensures you avoid a 9 | /// circular dependency between the two. 10 | /// 11 | /// - Note: See `NumbersViewController` in the demo app for an example. 12 | public protocol NavigationHandler: AnyObject { 13 | func route(_ url: URL) 14 | 15 | func route(_ proposal: VisitProposal) 16 | } 17 | 18 | extension Navigator: NavigationHandler { 19 | public func route(_ url: URL) { 20 | route(url, options: VisitOptions(action: .advance), parameters: nil) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Extensions/VisitProposalExtension.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public extension VisitProposal { 4 | var context: Navigation.Context { 5 | properties.context 6 | } 7 | 8 | var presentation: Navigation.Presentation { 9 | properties.presentation 10 | } 11 | 12 | var modalStyle: Navigation.ModalStyle { 13 | properties.modalStyle 14 | } 15 | 16 | var pullToRefreshEnabled: Bool { 17 | properties.pullToRefreshEnabled 18 | } 19 | 20 | var modalDismissGestureEnabled: Bool { 21 | properties.modalDismissGestureEnabled 22 | } 23 | 24 | var viewController: String { 25 | properties.viewController 26 | } 27 | 28 | var animated: Bool { 29 | properties.animated 30 | } 31 | 32 | internal var isHistoricalLocation: Bool { 33 | properties.historicalLocation 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Extensions/WKNavigationAction+Utils.swift: -------------------------------------------------------------------------------- 1 | import WebKit 2 | 3 | extension WKNavigationAction { 4 | var isMainFrameNavigation: Bool { 5 | targetFrame?.isMainFrame ?? false 6 | } 7 | 8 | var shouldNavigateInApp: Bool { 9 | navigationType == .linkActivated || 10 | isMainFrameNavigation 11 | } 12 | 13 | /// Indicates if the navigation action requests a new window (e.g., target="_blank"). 14 | var requestsNewWindow: Bool { 15 | guard let targetFrame else { return true } 16 | return !targetFrame.isMainFrame 17 | } 18 | 19 | var shouldReloadPage: Bool { 20 | return isMainFrameNavigation && navigationType == .reload 21 | } 22 | 23 | var shouldOpenURLExternally: Bool { 24 | return navigationType == .linkActivated || 25 | (isMainFrameNavigation && navigationType == .other) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Helpers/PathConfigurationIdentifiable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// As a convenience, a view controller may conform to `PathConfigurationIdentifiable`. 4 | /// 5 | /// Use a view controller's `pathConfigurationIdentifier` property instead of `proposal.url` when deciding how to handle a proposal. 6 | /// 7 | /// ```swift 8 | /// func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult { 9 | /// switch proposal.viewController { 10 | /// case RecipeViewController.pathConfigurationIdentifier: 11 | /// return .acceptCustom(RecipeViewController()) 12 | /// default: 13 | /// return .accept 14 | /// } 15 | /// } 16 | /// ``` 17 | /// - Note: See `VisitProposal.viewController` on how to use this in your configuration file. 18 | public protocol PathConfigurationIdentifiable: UIViewController { 19 | static var pathConfigurationIdentifier: String { get } 20 | } 21 | -------------------------------------------------------------------------------- /Source/Turbo/Visit/VisitDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol VisitDelegate: AnyObject { 4 | func visitDidInitializeWebView(_ visit: Visit) 5 | 6 | func visitWillStart(_ visit: Visit) 7 | func visitDidStart(_ visit: Visit) 8 | func visitDidComplete(_ visit: Visit) 9 | func visitDidFail(_ visit: Visit) 10 | func visitDidFinish(_ visit: Visit) 11 | 12 | func visitWillLoadResponse(_ visit: Visit) 13 | func visitDidRender(_ visit: Visit) 14 | 15 | func visitRequestDidStart(_ visit: Visit) 16 | func visit(_ visit: Visit, requestDidFailWithError error: Error) 17 | func visitRequestDidFinish(_ visit: Visit) 18 | func visitDidProposeVisitToLocation(_ location: URL) 19 | 20 | func visit(_ visit: Visit, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) 21 | } 22 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Routing/Handlers/SystemNavigationRouteDecisionHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | /// Opens external URLs via `openURL(_:options:completionHandler)`. 5 | public final class SystemNavigationRouteDecisionHandler: RouteDecisionHandler { 6 | public let name: String = "system-navigation" 7 | 8 | public init() {} 9 | 10 | public func matches(location: URL, 11 | configuration: Navigator.Configuration) -> Bool { 12 | if #available(iOS 16, *) { 13 | return configuration.startLocation.host() != location.host() 14 | } 15 | 16 | return configuration.startLocation.host != location.host 17 | } 18 | 19 | public func handle(location: URL, 20 | configuration: Navigator.Configuration, 21 | navigator: Navigator) -> Router.Decision { 22 | UIApplication.shared.open(location) 23 | 24 | return .cancel 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Source/Turbo/Path Configuration/PathConfigurationDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Internal struct for simplifying decoding 4 | /// since the public PathConfiguration can have multiple sources 5 | /// that update async 6 | struct PathConfigurationDecoder: Equatable { 7 | let settings: [String: AnyHashable] 8 | let rules: [PathRule] 9 | 10 | init(settings: [String: AnyHashable] = [:], rules: [PathRule] = []) { 11 | self.settings = settings 12 | self.rules = rules 13 | } 14 | } 15 | 16 | extension PathConfigurationDecoder { 17 | init(json: [String: Any]) throws { 18 | // rules must be present, settings are optional 19 | guard let rulesArray = json["rules"] as? [[String: AnyHashable]] else { 20 | throw JSONDecodingError.invalidJSON 21 | } 22 | 23 | let rules = try rulesArray.compactMap(PathRule.init) 24 | let settings = (json["settings"] as? [String: AnyHashable]) ?? [:] 25 | 26 | self.init(settings: settings, rules: rules) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/WebViewPolicy/Handlers/ReloadWebViewPolicyDecisionHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebKit 3 | 4 | /// A web view policy decision handler that intercepts navigation actions intended to reload the page. 5 | /// 6 | /// When such an action is detected, it triggers a reload in the provided navigator 7 | /// and cancels the default navigation action. 8 | public struct ReloadWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler { 9 | public let name: String = "reload-policy" 10 | 11 | public init() {} 12 | 13 | public func matches(navigationAction: WKNavigationAction, 14 | configuration: Navigator.Configuration) -> Bool { 15 | return navigationAction.shouldReloadPage 16 | } 17 | 18 | public func handle(navigationAction: WKNavigationAction, 19 | configuration: Navigator.Configuration, 20 | navigator: Navigator) -> WebViewPolicyManager.Decision { 21 | navigator.reload() 22 | 23 | return .cancel 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/NavigationHierarchyControllerDelegate.swift: -------------------------------------------------------------------------------- 1 | import SafariServices 2 | import WebKit 3 | 4 | protocol NavigationHierarchyControllerDelegate: AnyObject { 5 | 6 | /// Once the navigation hierarchy is modified, begin a visit on a navigation controller. 7 | /// 8 | /// - Parameters: 9 | /// - _: the Visitable destination 10 | /// - on: the navigation controller that was modified 11 | /// - with: the visit options 12 | func visit(_ : Visitable, 13 | on: NavigationHierarchyController.NavigationStackType, 14 | with: VisitOptions) 15 | 16 | /// A refresh will pop (or dismiss) then ask the session to refresh the previous (or underlying) Visitable. 17 | /// 18 | /// - Parameters: 19 | /// - navigationStack: the stack where the refresh is happening 20 | /// - newTopmostVisitable: the visitable to be refreshed 21 | func refreshVisitable(navigationStack: NavigationHierarchyController.NavigationStackType, 22 | newTopmostVisitable: Visitable) 23 | } 24 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Extensions/UINavigationControllerExtension.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UINavigationController { 4 | func replaceLastViewController(with viewController: UIViewController) { 5 | let viewControllers = viewControllers.dropLast() 6 | setViewControllers(viewControllers + [viewController], animated: false) 7 | } 8 | 9 | func setModalPresentationStyle(via proposal: VisitProposal) { 10 | switch proposal.modalStyle { 11 | case .medium: 12 | modalPresentationStyle = .automatic 13 | if #available(iOS 15.0, *) { 14 | if let sheet = sheetPresentationController { 15 | sheet.detents = [.medium(), .large()] 16 | } 17 | } 18 | case .large: 19 | modalPresentationStyle = .automatic 20 | case .full: 21 | modalPresentationStyle = .fullScreen 22 | case .pageSheet: 23 | modalPresentationStyle = .pageSheet 24 | case .formSheet: 25 | modalPresentationStyle = .formSheet 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/Turbo/Navigator/NavigationDelegateTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import SafariServices 3 | import XCTest 4 | 5 | final class NavigationDelegateTests: XCTestCase { 6 | override func setUp() async throws { 7 | delegate = TestNavigatorDelegate() 8 | } 9 | 10 | func test_handleProposalFrom_defaultsDefaultViewController() throws { 11 | let url = URL(string: "https://example.com/testing")! 12 | let proposal = VisitProposal(url: url, options: VisitOptions()) 13 | 14 | let result = delegate.handle(proposal: proposal, from: navigator) 15 | XCTAssertEqual(result, .accept) 16 | } 17 | 18 | private var delegate: NavigatorDelegate! 19 | private let navigator = Navigator( 20 | session: Session(webView: Hotwire.config.makeWebView()), 21 | modalSession: Session(webView: Hotwire.config.makeWebView()), 22 | configuration: .init( 23 | name: "", 24 | startLocation: URL(string: "https://example.com")! 25 | ) 26 | ) 27 | 28 | private class TestNavigatorDelegate: NavigatorDelegate {} 29 | } 30 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/WebViewPolicy/Handlers/LinkActivatedWebViewPolicyDecisionHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebKit 3 | 4 | /// A web view policy decision handler that intercepts navigation actions 5 | /// triggered by link activation in the main frame. 6 | /// 7 | /// When such an action is detected, the handler cancels the navigation, 8 | /// preventing the web view from following the link. 9 | public struct LinkActivatedWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler { 10 | public let name: String = "link-activated-policy" 11 | 12 | public init() {} 13 | 14 | public func matches(navigationAction: WKNavigationAction, 15 | configuration: Navigator.Configuration) -> Bool { 16 | navigationAction.navigationType == .linkActivated && 17 | navigationAction.isMainFrameNavigation 18 | } 19 | 20 | public func handle(navigationAction: WKNavigationAction, 21 | configuration: Navigator.Configuration, 22 | navigator: Navigator) -> WebViewPolicyManager.Decision { 23 | return .cancel 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Hotwire 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Tests/Turbo/JavaScriptExpressionTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import XCTest 3 | 4 | class JavaScriptExpressionTests: XCTestCase { 5 | func test_string_convertsFunctionAndArgumentsIntoAValidExpression() { 6 | let expression = JavaScriptExpression(function: "console.log", arguments: []) 7 | XCTAssertEqual(expression.string, "console.log()") 8 | 9 | let expression2 = JavaScriptExpression(function: "console.log", arguments: ["one", nil, 2]) 10 | XCTAssertEqual(expression2.string, "console.log(\"one\",null,2)") 11 | } 12 | 13 | func test_wrapped_wrapsExpressionIn_IIFE_AndTryCatch() { 14 | let expression = JavaScriptExpression(function: "console.log", arguments: []) 15 | let expected = """ 16 | (function(result) { 17 | try { 18 | result.value = console.log() 19 | } catch (error) { 20 | result.error = error.toString() 21 | result.stack = error.stack 22 | } 23 | 24 | return result 25 | })({}) 26 | """ 27 | 28 | XCTAssertEqual(expression.wrappedString, expected) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Source/Turbo/Path Configuration/PathRule+ServerRoutes.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PathRule { 4 | static let defaultServerRoutes: [PathRule] = [ 5 | .recedeHistoricalLocation, 6 | .resumeHistoricalLocation, 7 | .refreshHistoricalLocation 8 | ] 9 | 10 | static let recedeHistoricalLocation = PathRule( 11 | patterns: ["/recede_historical_location"], 12 | properties: [ 13 | "presentation": "pop", 14 | "context": "default", 15 | "historical_location": true 16 | ] 17 | ) 18 | 19 | static let resumeHistoricalLocation = PathRule( 20 | patterns: ["/resume_historical_location"], 21 | properties: [ 22 | "presentation": "none", 23 | "context": "default", 24 | "historical_location": true 25 | ] 26 | ) 27 | 28 | static let refreshHistoricalLocation = PathRule( 29 | patterns: ["/refresh_historical_location"], 30 | properties: [ 31 | "presentation": "refresh", 32 | "context": "default", 33 | "historical_location": true 34 | ] 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /Source/Turbo/WebView/JSON.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum JSONDecodingError: Error { 4 | case invalidJSON 5 | } 6 | 7 | protocol JSONCodable: Codable { 8 | init?(json: [String: Any]) 9 | func toJSON() -> Any 10 | } 11 | 12 | // These methods are inefficient as they require extra conversion 13 | // to/from Data and then reparsing, but in practice it should be so fast to not matter 14 | extension JSONCodable { 15 | init?(json: [String: Any]) { 16 | do { 17 | let data = try JSONSerialization.data(withJSONObject: json, options: []) 18 | let decoder = JSONDecoder() 19 | self = try decoder.decode(Self.self, from: data) 20 | } catch { 21 | debugPrint("[json] *** Error decoding json: \(json) -> \(error)") 22 | return nil 23 | } 24 | } 25 | 26 | func toJSON() -> Any { 27 | do { 28 | let encoder = JSONEncoder() 29 | let data = try encoder.encode(self) 30 | return try JSONSerialization.jsonObject(with: data, options: []) 31 | } catch { 32 | debugPrint("[json] *** Error encoding JSON: \(error)") 33 | return [String: String]() 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/Bridge/Spies/BridgeDelegateSpy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HotwireNative 3 | import WebKit 4 | 5 | final class BridgeDelegateSpy: BridgingDelegate { 6 | let location: String = "" 7 | let destination: BridgeDestination? = AppBridgeDestination() 8 | var webView: WKWebView? = nil 9 | 10 | var replyWithMessageWasCalled = false 11 | var replyWithMessageArg: Message? 12 | 13 | func webViewDidBecomeActive(_ webView: WKWebView) {} 14 | 15 | func webViewDidBecomeDeactivated() {} 16 | 17 | func reply(with message: Message) async throws -> Bool { 18 | replyWithMessageWasCalled = true 19 | replyWithMessageArg = message 20 | 21 | return true 22 | } 23 | 24 | func onViewDidLoad() {} 25 | 26 | func onViewWillAppear() {} 27 | 28 | func onViewDidAppear() {} 29 | 30 | func onViewWillDisappear() {} 31 | 32 | func onViewDidDisappear() {} 33 | 34 | func component() -> C? where C: BridgeComponent { 35 | return nil 36 | } 37 | 38 | func bridgeDidInitialize() {} 39 | 40 | func bridgeDidReceiveMessage(_ message: Message) -> Bool { 41 | return false 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Extensions/WKWebView+ebContentProcess.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebKit 3 | 4 | enum WebContentProcessState { 5 | case active 6 | case terminated 7 | } 8 | 9 | extension WKWebView { 10 | /// Queries the state of the web content process asynchronously. 11 | /// 12 | /// This method evaluates a simple JavaScript function in the web view to determine if the web content process is active. 13 | /// 14 | /// - 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. 15 | /// 16 | /// - Note: The web content process is considered active if the JavaScript evaluation succeeds without error. 17 | /// If an error occurs during evaluation, the process is considered terminated. 18 | func queryWebContentProcessState(completionHandler: @escaping (WebContentProcessState) -> Void) { 19 | evaluateJavaScript("(function() { return '1'; })();") { _, error in 20 | if let _ = error { 21 | completionHandler(.terminated) 22 | return 23 | } 24 | 25 | completionHandler(.active) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/Bridge/UserAgentTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import HotwireNative 3 | import XCTest 4 | 5 | class UserAgentTests: XCTestCase { 6 | func testUserAgentSubstringWithNoComponents() { 7 | let userAgentSubstring = UserAgent.build( 8 | applicationPrefix: nil, 9 | componentTypes: [] 10 | ) 11 | XCTAssertEqual(userAgentSubstring, "Hotwire Native iOS; Turbo Native iOS; bridge-components: []") 12 | } 13 | 14 | func testUserAgentSubstringWithTwoComponents() { 15 | let userAgentSubstring = UserAgent.build( 16 | applicationPrefix: nil, 17 | componentTypes: [OneBridgeComponent.self, TwoBridgeComponent.self] 18 | ) 19 | XCTAssertEqual(userAgentSubstring, "Hotwire Native iOS; Turbo Native iOS; bridge-components: [one two]") 20 | } 21 | 22 | func testUserAgentSubstringCustomPrefix() { 23 | let userAgentSubstring = UserAgent.build( 24 | applicationPrefix: "Hotwire Demo;", 25 | componentTypes: [OneBridgeComponent.self, TwoBridgeComponent.self] 26 | ) 27 | XCTAssertEqual(userAgentSubstring, "Hotwire Demo; Hotwire Native iOS; Turbo Native iOS; bridge-components: [one two]") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/WebViewPolicy/Handlers/NewWindowWebViewPolicyDecisionHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebKit 3 | 4 | /// A web view policy decision handler that intercepts navigation actions requesting a new window. 5 | /// 6 | /// When such an action is detected, it routes the URL via the provided navigator 7 | /// and cancels the default navigation action. 8 | public struct NewWindowWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler { 9 | public let name: String = "new-window-policy" 10 | 11 | public init() {} 12 | 13 | public func matches(navigationAction: WKNavigationAction, 14 | configuration: Navigator.Configuration) -> Bool { 15 | return navigationAction.request.url != nil && 16 | navigationAction.navigationType == .linkActivated && 17 | navigationAction.requestsNewWindow 18 | } 19 | 20 | public func handle(navigationAction: WKNavigationAction, 21 | configuration: Navigator.Configuration, 22 | navigator: Navigator) -> WebViewPolicyManager.Decision { 23 | if let url = navigationAction.request.url { 24 | navigator.route(url) 25 | } 26 | 27 | return .cancel 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebKit 3 | 4 | /// A web view policy decision handler that intercepts navigation actions 5 | /// where the requested URL should be opened externally. 6 | /// 7 | /// When such an action is detected, it routes the URL using the provided navigator 8 | /// and cancels the web view's default navigation. 9 | public struct ExternalNavigationWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler { 10 | public let name: String = "external-navigation-policy" 11 | 12 | public init() {} 13 | 14 | public func matches(navigationAction: WKNavigationAction, 15 | configuration: Navigator.Configuration) -> Bool { 16 | return navigationAction.request.url != nil && 17 | navigationAction.shouldOpenURLExternally 18 | } 19 | 20 | public func handle(navigationAction: WKNavigationAction, 21 | configuration: Navigator.Configuration, 22 | navigator: Navigator) -> WebViewPolicyManager.Decision { 23 | if let url = navigationAction.request.url { 24 | navigator.route(url) 25 | } 26 | 27 | return .cancel 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Hotwire Native iOS 2 | 3 | Note that we have a [code of conduct](/CODE_OF_CONDUCT.md). Please follow it in your interactions with this project. 4 | 5 | ## Developing locally 6 | 7 | Hotwire Native for iOS is built using Swift 5.3 and iOS 14 as its minimum version. To set up your development environment: 8 | 9 | 1. Clone the repo 10 | 1. Open the directory in Xcode 15+ to install Swift packages 11 | 12 | To run the test suite: 13 | 14 | 1. Open the directory in Xcode 15 | 1. Click Product → Test or +U 16 | 17 | ## Sending a Pull Request 18 | 19 | The core team is monitoring for pull requests. We will review your pull request and either merge it, request changes to it, or close it with an explanation. 20 | 21 | Before submitting a pull request, please: 22 | 23 | 1. Fork the repository and create your branch. 24 | 2. Follow the setup instructions in this file. 25 | 3. If you’re fixing a bug or adding code that should be tested, add tests! 26 | 4. Ensure the test suite passes. 27 | 28 | ## Feature parity with Android 29 | 30 | New features will not be merged until also added to [Hotwire Native Android](https://github.com/hotwired/hotwire-native-android). 31 | 32 | This does not apply to bugs that only appear on iOS. 33 | -------------------------------------------------------------------------------- /Source/Turbo/TurboError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // https://github.com/hotwired/turbo/blob/main/src/core/drive/visit.js#L33-L37 4 | public enum TurboError: LocalizedError, Equatable { 5 | case networkFailure 6 | case timeoutFailure 7 | case contentTypeMismatch 8 | case pageLoadFailure 9 | case http(statusCode: Int) 10 | 11 | init(statusCode: Int) { 12 | switch statusCode { 13 | case 0: 14 | self = .networkFailure 15 | case -1: 16 | self = .timeoutFailure 17 | case -2: 18 | self = .contentTypeMismatch 19 | default: 20 | self = .http(statusCode: statusCode) 21 | } 22 | } 23 | 24 | public var errorDescription: String? { 25 | switch self { 26 | case .networkFailure: 27 | return "A network error occurred." 28 | case .timeoutFailure: 29 | return "A network timeout occurred." 30 | case .contentTypeMismatch: 31 | return "The server returned an invalid content type." 32 | case .pageLoadFailure: 33 | return "The page could not be loaded due to a configuration error." 34 | case .http(let statusCode): 35 | return "There was an HTTP error (\(statusCode))." 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/Turbo/WebViewPolicy/NavigationPolicyHTML.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | /// A simple clickable link. 5 | static var simpleLink = """ 6 | 7 | 8 | Simple Link 9 | 10 | 11 | """ 12 | 13 | /// A link with a target attribute (target="_blank"). 14 | static var targetBlank = """ 15 | 16 | 17 | Target Blank Link 18 | 19 | 20 | """ 21 | 22 | /// A link that is programmatically clicked via JavaScript. 23 | static var jsClick = """ 24 | 25 | 26 | JS Click Link 27 | 30 | 31 | 32 | """ 33 | 34 | /// A JavaScript-initiated reload via a button click. 35 | static var reload = """ 36 | 37 | 38 |

Click the button below to reload the page.

39 | 40 | 41 | 42 | """ 43 | } 44 | -------------------------------------------------------------------------------- /Tests/Turbo/Fixtures/test-modal-styles-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules":[ 3 | { 4 | "patterns":[ 5 | "/new$" 6 | ], 7 | "properties":{ 8 | "context":"modal", 9 | "modal_dismiss_gesture_enabled": false 10 | } 11 | }, 12 | { 13 | "patterns":[ 14 | "/newMedium$" 15 | ], 16 | "properties":{ 17 | "context":"modal", 18 | "modal_style":"medium", 19 | "modal_dismiss_gesture_enabled": true 20 | } 21 | }, 22 | { 23 | "patterns":[ 24 | "/newLarge$" 25 | ], 26 | "properties":{ 27 | "background_color":"black", 28 | "context":"modal", 29 | "modal_style":"large" 30 | } 31 | }, 32 | { 33 | "patterns":[ 34 | "/newFull$" 35 | ], 36 | "properties":{ 37 | "context":"modal", 38 | "modal_style":"full" 39 | } 40 | }, 41 | { 42 | "patterns":[ 43 | "/newPageSheet$" 44 | ], 45 | "properties":{ 46 | "context":"modal", 47 | "modal_style":"page_sheet" 48 | } 49 | }, 50 | { 51 | "patterns":[ 52 | "/newFormSheet$" 53 | ], 54 | "properties":{ 55 | "context":"modal", 56 | "modal_style":"form_sheet" 57 | } 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hotwire Native for iOS 2 | 3 | ![Swift](https://img.shields.io/badge/Swift-5.3-blue) 4 | ![iOS](https://img.shields.io/badge/iOS-14+-green) 5 | ![Turbo](https://img.shields.io/badge/Turbo-7+-purple) 6 | 7 | [Hotwire Native](https://native.hotwired.dev) is a high-level native framework, available for iOS and Android, that provides you with all the tools you need to leverage your web app and build great mobile apps. 8 | 9 | This native Swift library integrates with your [Hotwire](https://hotwired.dev) web app by wrapping it in a native iOS shell. It manages a single WKWebView instance across multiple view controllers, giving you native navigation UI with all the client-side performance benefits of Hotwire. 10 | 11 | Read more on [native.hotwired.dev](https://native.hotwired.dev). 12 | 13 | ## Contributing 14 | 15 | Hotwire Native for iOS is open-source software, freely distributable under the terms of an [MIT-style license](LICENSE). The [source code is hosted on GitHub](https://github.com/hotwired/hotwire-native-bridge). Development is sponsored by [37signals](https://37signals.com/). 16 | 17 | We welcome contributions in the form of bug reports, pull requests, or thoughtful discussions in the [GitHub issue tracker](https://github.com/hotwired/hotwire-native-bridge/issues). 18 | 19 | --------- 20 | 21 | © 2024 37signals LLC 22 | -------------------------------------------------------------------------------- /Source/Turbo/Utils/AppLifecycleObserver.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | protocol AppLifecycleObserverDelegate: AnyObject { 5 | func appDidEnterBackground() 6 | func appWillEnterForeground() 7 | } 8 | 9 | final class AppLifecycleObserver { 10 | weak var delegate: AppLifecycleObserverDelegate? 11 | 12 | var appState: UIApplication.State { 13 | UIApplication.shared.applicationState 14 | } 15 | 16 | init(delegate: AppLifecycleObserverDelegate? = nil) { 17 | self.delegate = delegate 18 | 19 | NotificationCenter.default.addObserver( 20 | self, 21 | selector: #selector(appDidEnterBackground), 22 | name: UIApplication.didEnterBackgroundNotification, 23 | object: nil 24 | ) 25 | 26 | NotificationCenter.default.addObserver( 27 | self, 28 | selector: #selector(appWillEnterForeground), 29 | name: UIApplication.willEnterForegroundNotification, 30 | object: nil 31 | ) 32 | } 33 | 34 | @objc private func appDidEnterBackground() { 35 | delegate?.appDidEnterBackground() 36 | } 37 | 38 | @objc private func appWillEnterForeground() { 39 | delegate?.appWillEnterForeground() 40 | } 41 | 42 | deinit { 43 | NotificationCenter.default.removeObserver(self) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "HotwireNative", 7 | platforms: [ 8 | .iOS(.v14), 9 | ], 10 | products: [ 11 | .library( 12 | name: "HotwireNative", 13 | targets: ["HotwireNative"] 14 | ), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/AliSoftware/OHHTTPStubs", .upToNextMajor(from: "9.0.0")), 18 | .package(url: "https://github.com/envoy/Embassy.git", .upToNextMajor(from: "4.1.4")) 19 | ], 20 | targets: [ 21 | .target( 22 | name: "HotwireNative", 23 | dependencies: [], 24 | path: "Source", 25 | resources: [ 26 | .copy("Turbo/WebView/turbo.js"), 27 | .copy("Bridge/bridge.js") 28 | ] 29 | ), 30 | .testTarget( 31 | name: "HotwireNativeTests", 32 | dependencies: [ 33 | "HotwireNative", 34 | .product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs"), 35 | .product(name: "Embassy", package: "Embassy") 36 | ], 37 | path: "Tests", 38 | resources: [ 39 | .copy("Turbo/Fixtures"), 40 | .copy("Turbo/Server") 41 | ] 42 | ), 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Helpers/Navigation+QueryStringPresentation.swift: -------------------------------------------------------------------------------- 1 | /// Represents how a given navigation destination should be presented when the current 2 | /// location path on the backstack matches the new location path *and* a query string is 3 | /// present in either location. 4 | /// 5 | /// Example situation: 6 | /// current location: /feature 7 | /// new location: /feature?filter=true 8 | /// 9 | /// Another example situation: 10 | /// current location: /feature?filter=a 11 | /// new location: /feature?filter=b 12 | public extension Navigation { 13 | enum QueryStringPresentation: String { 14 | // A generic default value when no specific presentation value is provided and results in 15 | // generally accepted "normal" behavior — replacing the root when on the start destination and 16 | // going to the start destination again, popping when the location is in the immediate 17 | // backstack, replacing when going to the same destination, and pushing in all other cases. 18 | case `default` 19 | 20 | // Pops the current location off the nav stack and pushes the new location onto the nav stack. 21 | // If you use query strings in your app to act as a way to filter results in a destination, 22 | // this allows you to present the new (filtered) destination without adding onto the backstack. 23 | case replace 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/Bridge/Spies/BridgeComponentSpy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HotwireNative 3 | 4 | final class BridgeComponentSpy: BridgeComponent { 5 | override static var name: String { "two" } 6 | 7 | var onReceiveMessageWasCalled = false 8 | var onReceiveMessageArg: Message? 9 | 10 | var onViewDidLoadWasCalled = false 11 | var onViewWillAppearWasCalled = false 12 | var onViewDidAppearWasCalled = false 13 | var onViewWillDisappearWasCalled = false 14 | var onViewDidDisappearWasCalled = false 15 | 16 | required init(destination: BridgeDestination, delegate: BridgingDelegate) { 17 | super.init(destination: destination, delegate: delegate) 18 | } 19 | 20 | override func onReceive(message: Message) { 21 | onReceiveMessageWasCalled = true 22 | onReceiveMessageArg = message 23 | } 24 | 25 | override func onViewDidLoad() { 26 | onViewDidLoadWasCalled = true 27 | } 28 | 29 | override func onViewWillAppear() { 30 | onViewWillAppearWasCalled = true 31 | } 32 | 33 | override func onViewDidAppear() { 34 | onViewDidAppearWasCalled = true 35 | } 36 | 37 | override func onViewWillDisappear() { 38 | onViewWillDisappearWasCalled = true 39 | } 40 | 41 | override func onViewDidDisappear() { 42 | onViewDidDisappearWasCalled = true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Routing/Router.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Routes location urls within in-app navigation or with custom behaviors 4 | /// provided in `RouteDecisionHandler` instances. 5 | public final class Router { 6 | let decisionHandlers: [RouteDecisionHandler] 7 | 8 | init(decisionHandlers: [RouteDecisionHandler]) { 9 | self.decisionHandlers = decisionHandlers 10 | } 11 | 12 | func decideRoute(for location: URL, 13 | configuration: Navigator.Configuration, 14 | navigator: Navigator) -> Router.Decision { 15 | for handler in decisionHandlers { 16 | if handler.matches(location: location, configuration: configuration) { 17 | logger.debug("[Router] handler match found handler: \(handler.name) location: \(location)") 18 | return handler.handle(location: location, 19 | configuration: configuration, 20 | navigator: navigator) 21 | } 22 | } 23 | 24 | logger.warning("[Router] no handler for location: \(location)") 25 | return .cancel 26 | } 27 | } 28 | 29 | public extension Router { 30 | enum Decision { 31 | // Permit in-app navigation with your app's domain urls. 32 | case navigate 33 | // Prevent in-app navigation. Always use this for external domain urls. 34 | case cancel 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Routing/RouteDecisionHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebKit 3 | 4 | /// An interface to implement to provide custom 5 | /// route decision handling behaviors in your app. 6 | public protocol RouteDecisionHandler { 7 | /// The decision handler name used in debug logging. 8 | var name: String { get } 9 | 10 | /// Determines whether the location matches this decision handler. 11 | /// Use your own custom rules based on the location's domain, protocol, path, or any other factors. 12 | /// - Parameters: 13 | /// - location: The location URL. 14 | /// - configuration: The configuration of the navigator where the navigation is taking place. 15 | /// - Returns: `true` if location matches this decision handler, `false` otherwise. 16 | func matches(location: URL, 17 | configuration: Navigator.Configuration) -> Bool 18 | 19 | /// Handle custom routing behavior when a match is found. 20 | /// For example, open an external browser or app for external domain urls. 21 | /// - Parameters: 22 | /// - location: The location URL. 23 | /// - configuration: The configuration of the navigator where the navigation is taking place. 24 | /// - navigator: The navigator instance responsible for the navigation. 25 | func handle(location: URL, 26 | configuration: Navigator.Configuration, 27 | navigator: Navigator) -> Router.Decision 28 | } 29 | -------------------------------------------------------------------------------- /Source/Hotwire.swift: -------------------------------------------------------------------------------- 1 | import WebKit 2 | 3 | public enum Hotwire { 4 | /// Use this instance to configure Hotwire. 5 | public static var config = HotwireConfig() 6 | 7 | /// Registers your bridge components to use with `HotwireWebViewController`. 8 | /// 9 | /// Use `Hotwire.config.makeCustomWebView` to customize the web view or web view 10 | /// configuration further, making sure to call `Bridge.initialize()`. 11 | public static func registerBridgeComponents(_ componentTypes: [BridgeComponent.Type]) { 12 | bridgeComponentTypes = componentTypes 13 | } 14 | 15 | 16 | public static func registerRouteDecisionHandlers(_ decisionHandlers: [any RouteDecisionHandler]) { 17 | config.router = Router(decisionHandlers: decisionHandlers) 18 | } 19 | 20 | public static func registerWebViewPolicyDecisionHandlers(_ policyDecisionHandlers: [any WebViewPolicyDecisionHandler]) { 21 | config.webViewPolicyManager = WebViewPolicyManager(policyDecisionHandlers: policyDecisionHandlers) 22 | } 23 | 24 | /// Loads the `PathConfiguration` JSON file(s) from the provided sources 25 | /// to configure navigation rules 26 | /// - Parameter sources: An array of `PathConfiguration.Source` objects representing 27 | /// the sources to load. 28 | public static func loadPathConfiguration(from sources: [PathConfiguration.Source]) { 29 | config.pathConfiguration.sources = sources 30 | } 31 | 32 | static var bridgeComponentTypes = [BridgeComponent.Type]() 33 | } 34 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/WKUIController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebKit 3 | 4 | public protocol WKUIControllerDelegate: AnyObject { 5 | func present(_ alert: UIAlertController, animated: Bool) 6 | } 7 | 8 | open class WKUIController: NSObject, WKUIDelegate { 9 | private weak var delegate: WKUIControllerDelegate? 10 | 11 | public init(delegate: WKUIControllerDelegate!) { 12 | self.delegate = delegate 13 | } 14 | 15 | open func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) { 16 | let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert) 17 | alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in 18 | completionHandler() 19 | }) 20 | delegate?.present(alert, animated: true) 21 | } 22 | 23 | open func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) { 24 | let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert) 25 | alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in 26 | completionHandler(true) 27 | }) 28 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in 29 | completionHandler(false) 30 | }) 31 | delegate?.present(alert, animated: true) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Source/Turbo/WebView/JavaScriptExpression.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct JavaScriptExpression { 4 | let function: String 5 | let arguments: [Any?] 6 | 7 | var string: String? { 8 | guard let encodedArguments = encode(arguments: arguments) else { return nil } 9 | return "\(function)(\(encodedArguments))" 10 | } 11 | 12 | var wrappedString: String? { 13 | guard let encodedArguments = encode(arguments: arguments) else { return nil } 14 | return wrap(function: function, encodedArguments: encodedArguments) 15 | } 16 | 17 | private func wrap(function: String, encodedArguments arguments: String) -> String { 18 | """ 19 | (function(result) { 20 | try { 21 | result.value = \(function)(\(arguments)) 22 | } catch (error) { 23 | result.error = error.toString() 24 | result.stack = error.stack 25 | } 26 | 27 | return result 28 | })({}) 29 | """ 30 | } 31 | 32 | private func encode(arguments: [Any?]) -> String? { 33 | let arguments = arguments.map { $0 == nil ? NSNull() : $0! } 34 | 35 | guard let data = try? JSONSerialization.data(withJSONObject: arguments), 36 | let string = String(data: data, encoding: .utf8) 37 | else { 38 | return nil 39 | } 40 | 41 | // Strip leading/trailing [] so we have a list of arguments suitable for inserting between parens 42 | return String(string.dropFirst().dropLast()) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/Bridge/Spies/BridgeSpy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import HotwireNative 3 | import WebKit 4 | 5 | final class BridgeSpy: Bridgable { 6 | var delegate: BridgeDelegate? = nil 7 | var webView: WKWebView? = nil 8 | 9 | var registerComponentWasCalled = false 10 | var registerComponentArg: String? = nil 11 | 12 | var registerComponentsWasCalled = false { 13 | didSet { 14 | if registerComponentsWasCalled { 15 | registerComponentsContinuation?.resume() 16 | registerComponentsContinuation = nil 17 | } 18 | } 19 | } 20 | 21 | var registerComponentsContinuation: CheckedContinuation? 22 | var registerComponentsArg: [String]? = nil 23 | 24 | var unregisterComponentWasCalled = false 25 | var unregisterComponentArg: String? = nil 26 | 27 | var replyWithMessageWasCalled = false 28 | var replyWithMessageArg: Message? = nil 29 | 30 | func register(component: String) { 31 | registerComponentWasCalled = true 32 | registerComponentArg = component 33 | } 34 | 35 | func register(components: [String]) { 36 | registerComponentsWasCalled = true 37 | registerComponentsArg = components 38 | } 39 | 40 | func unregister(component: String) { 41 | unregisterComponentWasCalled = true 42 | unregisterComponentArg = component 43 | } 44 | 45 | func reply(with message: Message) { 46 | replyWithMessageWasCalled = true 47 | replyWithMessageArg = message 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/WebViewPolicy/WebViewRouteDecisionHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebKit 3 | 4 | /// An interface to implement to provide custom 5 | /// WebView policy decision handling behaviors in your app. 6 | public protocol WebViewPolicyDecisionHandler { 7 | /// The decision handler name used in debug logging. 8 | var name: String { get } 9 | 10 | /// Determines whether this handler should process the given navigation action. 11 | /// 12 | /// - Parameters: 13 | /// - navigationAction: The navigation action to evaluate. 14 | /// - configuration: The configuration of the navigator where the navigation is taking place. 15 | /// - Returns: `true` if the handler matches the navigation action; otherwise, `false`. 16 | func matches(navigationAction: WKNavigationAction, 17 | configuration: Navigator.Configuration) -> Bool 18 | 19 | /// Handles the navigation action if it matches this handler's criteria. 20 | /// 21 | /// - Parameters: 22 | /// - navigationAction: The navigation action to handle. 23 | /// - configuration: The configuration of the navigator where the navigation is taking place. 24 | /// - navigator: The navigator instance responsible for the navigation. 25 | /// - Returns: A decision, represented by a `WebViewPolicyManager.Decision`, indicating 26 | /// whether to allow or cancel the WebView navigation. 27 | func handle(navigationAction: WKNavigationAction, 28 | configuration: Navigator.Configuration, 29 | navigator: Navigator) -> WebViewPolicyManager.Decision 30 | } 31 | -------------------------------------------------------------------------------- /Source/Turbo/Path Configuration/PathRule.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct PathRule: Equatable { 4 | /// Array of regular expressions to match against 5 | public let patterns: [String] 6 | 7 | /// The properties to apply for matches 8 | public let properties: PathProperties 9 | 10 | /// Convenience method to retrieve a String value for a key 11 | /// Access `properties` directly to get a different type 12 | public subscript(key: String) -> String? { 13 | properties[key] as? String 14 | } 15 | 16 | init(patterns: [String], properties: PathProperties) { 17 | self.patterns = patterns 18 | self.properties = properties 19 | } 20 | 21 | /// Returns true if any pattern in this rule matches `path` 22 | public func match(path: String) -> Bool { 23 | for pattern in patterns { 24 | guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } 25 | 26 | let range = NSRange(path.startIndex ..< path.endIndex, in: path) 27 | if regex.numberOfMatches(in: path, range: range) > 0 { 28 | return true 29 | } 30 | } 31 | 32 | return false 33 | } 34 | } 35 | 36 | extension PathRule { 37 | init(json: [String: Any]) throws { 38 | guard let patterns = json["patterns"] as? [String], 39 | let properties = json["properties"] as? [String: AnyHashable] 40 | else { 41 | throw JSONDecodingError.invalidJSON 42 | } 43 | 44 | self.init(patterns: patterns, properties: properties) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebKit 3 | 4 | /// Manages web view policy. 5 | /// You can provide custom behaviors in `WebViewPolicyDecisionHandler` instances. 6 | public final class WebViewPolicyManager { 7 | let policyDecisionHandlers: [WebViewPolicyDecisionHandler] 8 | 9 | init(policyDecisionHandlers: [WebViewPolicyDecisionHandler]) { 10 | self.policyDecisionHandlers = policyDecisionHandlers 11 | } 12 | 13 | func decidePolicy(for navigationAction: WKNavigationAction, 14 | configuration: Navigator.Configuration, 15 | navigator: Navigator) -> WebViewPolicyManager.Decision { 16 | for handler in policyDecisionHandlers { 17 | if handler.matches(navigationAction: navigationAction, configuration: configuration) { 18 | logger.debug("[WebViewPolicyManager] handler match found handler: \(handler.name) navigation action:\(navigationAction)") 19 | return handler.handle(navigationAction: navigationAction, 20 | configuration: configuration, 21 | navigator: navigator) 22 | } 23 | } 24 | 25 | logger.warning("[WebViewPolicyManager] no handler for navigation action: \(navigationAction)") 26 | return .allow 27 | } 28 | } 29 | 30 | public extension WebViewPolicyManager { 31 | enum Decision { 32 | // Cancel navigation to a webpage. Always use this when handling navigation yourself. 33 | case cancel 34 | // Allow navigation to a webpage. 35 | case allow 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Demo/Tabs.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import HotwireNative 4 | 5 | extension HotwireTab { 6 | static let all: [HotwireTab] = { 7 | var tabs: [HotwireTab] = [ 8 | .navigation, 9 | .bridgeComponents, 10 | .resources 11 | ] 12 | 13 | if Demo.current == Demo.local { 14 | tabs.append(.bugsAndFixes) 15 | } 16 | 17 | return tabs 18 | }() 19 | 20 | static let navigation = HotwireTab( 21 | title: "Navigation", 22 | image: .init(systemName: "arrow.left.arrow.right")!, 23 | url: Demo.current 24 | ) 25 | 26 | static let bridgeComponents = HotwireTab( 27 | title: "Bridge Components", 28 | image: { 29 | if #available(iOS 17.4, *) { 30 | return UIImage(systemName: "widget.small")! 31 | } else { 32 | return UIImage(systemName: "square.grid.2x2")! 33 | } 34 | }(), 35 | url: Demo.current.appendingPathComponent("components") 36 | ) 37 | 38 | static let resources = HotwireTab( 39 | title: "Resources", 40 | image: { 41 | if #available(iOS 17.4, *) { 42 | return UIImage(systemName: "questionmark.text.page")! 43 | } else { 44 | return UIImage(systemName: "book.closed")! 45 | } 46 | }(), 47 | url: Demo.current.appendingPathComponent("resources") 48 | ) 49 | 50 | static let bugsAndFixes = HotwireTab( 51 | title: "Bugs & Fixes", 52 | image: .init(systemName: "ladybug")!, 53 | url: Demo.current.appendingPathComponent("bugs") 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /Tests/Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import XCTest 3 | 4 | final class AppNavigationRouteDecisionHandlerTest: XCTestCase { 5 | let navigatorConfiguration = Navigator.Configuration( 6 | name: "test", 7 | startLocation: URL(string: "https://my.app.com")! 8 | ) 9 | var route: AppNavigationRouteDecisionHandler! 10 | var navigator: Navigator! 11 | 12 | override func setUp() { 13 | route = AppNavigationRouteDecisionHandler() 14 | navigator = Navigator(configuration: navigatorConfiguration) 15 | } 16 | 17 | func test_handling_matching_result_navigates() { 18 | let url = URL(string: "https://my.app.com/page")! 19 | let result = route.handle(location: url, configuration: navigatorConfiguration, navigator: navigator) 20 | XCTAssertEqual(result, Router.Decision.navigate) 21 | } 22 | 23 | func test_url_on_app_domain_matches() { 24 | let url = URL(string: "https://my.app.com/page")! 25 | let result = route.matches(location: url, configuration: navigatorConfiguration) 26 | 27 | XCTAssertTrue(result) 28 | } 29 | 30 | func test_url_without_subdomain_does_not_match() { 31 | let url = URL(string: "https://app.com/page")! 32 | let result = route.matches(location: url, configuration: navigatorConfiguration) 33 | 34 | XCTAssertFalse(result) 35 | } 36 | 37 | func test_masqueraded_url_does_not_match() { 38 | let url = URL(string: "https://app.my.com@fake.domain")! 39 | let result = route.matches(location: url, configuration: navigatorConfiguration) 40 | 41 | XCTAssertFalse(result) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Routing/Handlers/SafariViewControllerRouteDecisionHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SafariServices 3 | 4 | /// Opens external URLs via an embedded `SafariViewController` so the user stays in-app. 5 | public final class SafariViewControllerRouteDecisionHandler: RouteDecisionHandler { 6 | public let name: String = "safari" 7 | 8 | public init() {} 9 | 10 | public func matches(location: URL, 11 | configuration: Navigator.Configuration) -> Bool { 12 | /// SFSafariViewController will crash if we pass along a URL that's not valid. 13 | guard location.scheme == "http" || location.scheme == "https" else { 14 | return false 15 | } 16 | 17 | if #available(iOS 16, *) { 18 | return configuration.startLocation.host() != location.host() 19 | } 20 | 21 | return configuration.startLocation.host != location.host 22 | } 23 | 24 | public func handle(location: URL, 25 | configuration: Navigator.Configuration, 26 | navigator: Navigator) -> Router.Decision { 27 | open(externalURL: location, 28 | viewController: navigator.activeNavigationController) 29 | 30 | return .cancel 31 | } 32 | 33 | func open(externalURL: URL, 34 | viewController: UIViewController) { 35 | let safariViewController = SFSafariViewController(url: externalURL) 36 | safariViewController.modalPresentationStyle = .pageSheet 37 | if #available(iOS 15.0, *) { 38 | safariViewController.preferredControlTintColor = .tintColor 39 | } 40 | 41 | viewController.present(safariViewController, animated: true) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Source/Bridge/JavaScript.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum JavaScriptError: Error, Equatable { 4 | case invalidArgumentType 5 | } 6 | 7 | /// Represents a single JavaScript function call 8 | /// Handling the conversion of the arguments into a suitable format 9 | struct JavaScript { 10 | /// The object to call the function on, nil by default 11 | var object: String? = nil 12 | 13 | /// The function name without parens 14 | let functionName: String 15 | 16 | /// An array representing arguments. Arguments will passed to function like so: 17 | /// functionName(args[0], args[1], ...) 18 | var arguments: [Any] = [] 19 | 20 | /// Final string that can be passed to `webView.evaluateJavaScript()` method 21 | func toString() throws -> String { 22 | let encodedArguments = try encode(arguments: arguments) 23 | let function = sanitizedFunctionName(functionName) 24 | return "\(function)(\(encodedArguments))" 25 | } 26 | 27 | private func encode(arguments: [Any]) throws -> String { 28 | guard JSONSerialization.isValidJSONObject(arguments) else { 29 | throw JavaScriptError.invalidArgumentType 30 | } 31 | 32 | let data = try JSONSerialization.data(withJSONObject: arguments, options: []) 33 | let string = String(data: data, encoding: .utf8)! 34 | return String(string.dropFirst().dropLast()) 35 | } 36 | 37 | private func sanitizedFunctionName(_ name: String) -> String { 38 | // Strip parens if included 39 | let name = name.hasSuffix("()") ? String(name.dropLast(2)) : name 40 | 41 | if let object = object { 42 | return "\(object).\(name)" 43 | } else { 44 | return name 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Demo/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Tests/Turbo/WebViewPolicy/LinkActivatedWebViewPolicyDecisionHandlerTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | @preconcurrency import WebKit 3 | import XCTest 4 | 5 | @MainActor 6 | final class LinkActivatedWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDecisionHandlerTests { 7 | var policyHandler: LinkActivatedWebViewPolicyDecisionHandler! 8 | 9 | override func setUp() async throws { 10 | try await super.setUp() 11 | policyHandler = LinkActivatedWebViewPolicyDecisionHandler() 12 | } 13 | 14 | func test_link_activated_matches() async throws { 15 | guard let action = try await webNavigationSimulator.simulateNavigation( 16 | withHTML: .simpleLink, 17 | simulateLinkClickElementId: "link") else { 18 | XCTFail("No navigation action captured") 19 | return 20 | } 21 | 22 | let result = policyHandler.matches( 23 | navigationAction: action, 24 | configuration: navigatorConfiguration 25 | ) 26 | 27 | XCTAssertTrue(result) 28 | } 29 | 30 | func test_handling_matching_result_cancels_web_navigation() async throws { 31 | guard let action = try await webNavigationSimulator.simulateNavigation( 32 | withHTML: .simpleLink, 33 | simulateLinkClickElementId: "link") else { 34 | XCTFail("No navigation action captured") 35 | return 36 | } 37 | 38 | let result = policyHandler.handle( 39 | navigationAction: action, 40 | configuration: navigatorConfiguration, 41 | navigator: navigatorSpy 42 | ) 43 | 44 | XCTAssertEqual(result, WebViewPolicyManager.Decision.cancel) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/Bridge/TestData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import HotwireNative 3 | 4 | final class AppBridgeDestination: BridgeDestination { 5 | var onBridgeComponentInitializedWasCalled = false 6 | var initializedBridgeComponent: BridgeComponent? 7 | 8 | func onBridgeComponentInitialized(_ component: BridgeComponent) { 9 | onBridgeComponentInitializedWasCalled = true 10 | initializedBridgeComponent = component 11 | } 12 | } 13 | 14 | final class OneBridgeComponent: BridgeComponent { 15 | override static var name: String { "one" } 16 | 17 | required init(destination: BridgeDestination, delegate: BridgingDelegate) { 18 | super.init(destination: destination, delegate: delegate) 19 | } 20 | 21 | override func onReceive(message: Message) {} 22 | } 23 | 24 | final class TwoBridgeComponent: BridgeComponent { 25 | override static var name: String { "two" } 26 | 27 | required init(destination: BridgeDestination, delegate: BridgingDelegate) { 28 | super.init(destination: destination, delegate: delegate) 29 | } 30 | 31 | override func onReceive(message: Message) {} 32 | } 33 | 34 | struct PageData: Codable { 35 | let metadata: InternalMessage.Metadata 36 | let title: String 37 | let subtitle: String 38 | let actions: [String] 39 | } 40 | 41 | struct MessageData: Codable, Equatable { 42 | let title: String 43 | let subtitle: String 44 | let actionName: String 45 | } 46 | 47 | extension Message { 48 | static let test = Message( 49 | id: "1", 50 | component: "two", 51 | event: "connect", 52 | metadata: .init(url: "https://37signals.com"), 53 | jsonData: """ 54 | {"title":"Page-title","subtitle":"Page-subtitle"} 55 | """ 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /Source/Turbo/Session/SessionDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import WebKit 3 | 4 | public protocol SessionDelegate: AnyObject { 5 | func session(_ session: Session, didProposeVisit proposal: VisitProposal) 6 | func session(_ session: Session, didProposeVisitToCrossOriginRedirect location: URL) 7 | func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) 8 | 9 | func session(_ session: Session, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) 10 | func session(_ session: Session, decidePolicyFor navigationAction: WKNavigationAction) -> WebViewPolicyManager.Decision 11 | 12 | func sessionDidLoadWebView(_ session: Session) 13 | func sessionDidStartRequest(_ session: Session) 14 | func sessionDidFinishRequest(_ session: Session) 15 | func sessionDidStartFormSubmission(_ session: Session) 16 | func sessionDidFinishFormSubmission(_ session: Session) 17 | 18 | func sessionWebViewProcessDidTerminate(_ session: Session) 19 | } 20 | 21 | public extension SessionDelegate { 22 | func sessionDidLoadWebView(_ session: Session) { 23 | session.webView.navigationDelegate = session 24 | } 25 | func sessionDidStartRequest(_ session: Session) {} 26 | func sessionDidFinishRequest(_ session: Session) {} 27 | func sessionDidStartFormSubmission(_ session: Session) {} 28 | func sessionDidFinishFormSubmission(_ session: Session) {} 29 | 30 | func session(_ session: Session, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { 31 | completionHandler(.performDefaultHandling, nil) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/Turbo/WebViewPolicy/ReloadWebViewPolicyDecisionHandlerTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | @preconcurrency import WebKit 3 | import XCTest 4 | 5 | @MainActor 6 | final class ReloadWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDecisionHandlerTests { 7 | var policyHandler: ReloadWebViewPolicyDecisionHandler! 8 | 9 | override func setUp() async throws { 10 | try await super.setUp() 11 | policyHandler = ReloadWebViewPolicyDecisionHandler() 12 | } 13 | 14 | func test_reload_matches() async throws { 15 | guard let action = try await webNavigationSimulator.simulateNavigation( 16 | withHTML: .reload, 17 | simulateLinkClickElementId: "reloadButton") else { 18 | XCTFail("No navigation action captured") 19 | return 20 | } 21 | 22 | let result = policyHandler.matches( 23 | navigationAction: action, 24 | configuration: navigatorConfiguration 25 | ) 26 | 27 | XCTAssertTrue(result) 28 | } 29 | 30 | func test_handling_matching_result_cancels_web_navigation_and_reloads() async throws { 31 | guard let action = try await webNavigationSimulator.simulateNavigation( 32 | withHTML: .reload, 33 | simulateLinkClickElementId: "reloadButton") else { 34 | XCTFail("No navigation action captured") 35 | return 36 | } 37 | 38 | let result = policyHandler.handle( 39 | navigationAction: action, 40 | configuration: navigatorConfiguration, 41 | navigator: navigatorSpy 42 | ) 43 | 44 | XCTAssertEqual(result, WebViewPolicyManager.Decision.cancel) 45 | XCTAssertTrue(navigatorSpy.reloadWasCalled) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/Turbo/Navigator/TestableNavigationController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | @testable import HotwireNative 3 | 4 | /// Manipulate a navigation controller under test. 5 | /// Ensures `viewControllers` is updated synchronously. 6 | /// Manages `presentedViewController` directly because it isn't updated on the same thread. 7 | class TestableNavigationController: HotwireNavigationController { 8 | override var presentedViewController: UIViewController? { 9 | get { _presentedViewController } 10 | set { _presentedViewController = newValue } 11 | } 12 | 13 | override func pushViewController(_ viewController: UIViewController, animated: Bool) { 14 | super.pushViewController(viewController, animated: false) 15 | } 16 | 17 | override func popViewController(animated: Bool) -> UIViewController? { 18 | super.popViewController(animated: false) 19 | } 20 | 21 | override func popToRootViewController(animated: Bool) -> [UIViewController]? { 22 | super.popToRootViewController(animated: false) 23 | } 24 | 25 | override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) { 26 | super.setViewControllers(viewControllers, animated: false) 27 | } 28 | 29 | override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { 30 | _presentedViewController = viewControllerToPresent 31 | super.present(viewControllerToPresent, animated: false, completion: completion) 32 | } 33 | 34 | override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { 35 | _presentedViewController = nil 36 | super.dismiss(animated: false, completion: completion) 37 | } 38 | 39 | // MARK: Private 40 | 41 | private var _presentedViewController: UIViewController? 42 | } 43 | -------------------------------------------------------------------------------- /Tests/Turbo/WebViewPolicy/NewWindowWebViewPolicyDecisionHandlerTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | @preconcurrency import WebKit 3 | import XCTest 4 | 5 | @MainActor 6 | final class NewWindowWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDecisionHandlerTests { 7 | var policyHandler: NewWindowWebViewPolicyDecisionHandler! 8 | 9 | override func setUp() async throws { 10 | try await super.setUp() 11 | policyHandler = NewWindowWebViewPolicyDecisionHandler() 12 | } 13 | 14 | func test_target_blank_matches() async throws { 15 | guard let action = try await webNavigationSimulator.simulateNavigation( 16 | withHTML: .targetBlank, 17 | simulateLinkClickElementId: "externalLink") else { 18 | XCTFail("No navigation action captured") 19 | return 20 | } 21 | 22 | let result = policyHandler.matches( 23 | navigationAction: action, 24 | configuration: navigatorConfiguration 25 | ) 26 | 27 | XCTAssertTrue(result) 28 | } 29 | 30 | func test_handling_matching_result_cancels_web_navigation_and_routes_internally() async throws { 31 | guard let action = try await webNavigationSimulator.simulateNavigation( 32 | withHTML: .targetBlank, 33 | simulateLinkClickElementId: "externalLink") else { 34 | XCTFail("No navigation action captured") 35 | return 36 | } 37 | 38 | let result = policyHandler.handle( 39 | navigationAction: action, 40 | configuration: navigatorConfiguration, 41 | navigator: navigatorSpy 42 | ) 43 | 44 | XCTAssertEqual(result, WebViewPolicyManager.Decision.cancel) 45 | XCTAssertTrue(navigatorSpy.routeWasCalled) 46 | XCTAssertEqual(action.request.url, navigatorSpy.routeURL) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Demo/Bridge/OverflowMenuComponent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HotwireNative 3 | import UIKit 4 | 5 | /// Bridge component to display a native 3-dot menu in the toolbar, 6 | /// which will notify the web when it has been tapped. 7 | final class OverflowMenuComponent: BridgeComponent { 8 | override class var name: String { "overflow-menu" } 9 | 10 | override func onReceive(message: Message) { 11 | guard let event = Event(rawValue: message.event) else { 12 | return 13 | } 14 | 15 | switch event { 16 | case .connect: 17 | handleConnectEvent(message: message) 18 | } 19 | } 20 | 21 | // MARK: Private 22 | 23 | private var viewController: UIViewController? { 24 | delegate?.destination as? UIViewController 25 | } 26 | 27 | private func handleConnectEvent(message: Message) { 28 | guard let data: MessageData = message.data() else { return } 29 | showOverflowMenuItem(data) 30 | } 31 | 32 | private func showOverflowMenuItem(_ data: MessageData) { 33 | guard let viewController else { return } 34 | 35 | let action = UIAction { [unowned self] _ in 36 | overflowAction() 37 | } 38 | 39 | viewController.navigationItem.rightBarButtonItem = UIBarButtonItem( 40 | title: data.label, 41 | image: .init(systemName: "ellipsis.circle"), 42 | primaryAction: action 43 | ) 44 | } 45 | 46 | private func overflowAction() { 47 | reply(to: Event.connect.rawValue) 48 | } 49 | } 50 | 51 | // MARK: Events 52 | 53 | private extension OverflowMenuComponent { 54 | enum Event: String { 55 | case connect 56 | } 57 | } 58 | 59 | // MARK: Message data 60 | 61 | private extension OverflowMenuComponent { 62 | struct MessageData: Decodable { 63 | let label: String 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Demo/NumbersViewController.swift: -------------------------------------------------------------------------------- 1 | import HotwireNative 2 | import UIKit 3 | 4 | /// A simple native table view controller to demonstrate loading non-Turbo screens 5 | /// for a visit proposal 6 | final class NumbersViewController: UITableViewController, PathConfigurationIdentifiable { 7 | static var pathConfigurationIdentifier: String { "numbers" } 8 | 9 | init(url: URL, navigator: NavigationHandler) { 10 | self.url = url 11 | self.navigator = navigator 12 | super.init(nibName: nil, bundle: nil) 13 | } 14 | 15 | required init?(coder: NSCoder) { 16 | fatalError("init(coder:) has not been implemented") 17 | } 18 | 19 | private var url: URL 20 | private weak var navigator: NavigationHandler? 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | 25 | title = "Numbers" 26 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") 27 | } 28 | 29 | override func numberOfSections(in tableView: UITableView) -> Int { 30 | 1 31 | } 32 | 33 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 34 | 100 35 | } 36 | 37 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 38 | let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) 39 | 40 | let number = indexPath.row + 1 41 | cell.textLabel?.text = "Row \(number)" 42 | cell.accessoryType = .disclosureIndicator 43 | 44 | return cell 45 | } 46 | 47 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 48 | let detailURL = url.appendingPathComponent("\(indexPath.row + 1)") 49 | navigator?.route(detailURL) 50 | tableView.deselectRow(at: indexPath, animated: true) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/Turbo/ScriptMessageTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import WebKit 3 | import XCTest 4 | 5 | class ScriptMessageTests: XCTestCase { 6 | func test_parse_withValidData_returnsMessage() throws { 7 | let data = ["identifier": "123", "restorationIdentifier": "abc", "options": ["action": "advance"], "location": "http://turbo.test"] as [String: Any] 8 | let script = FakeScriptMessage(body: ["name": "pageLoaded", "data": data] as [String: Any]) 9 | 10 | let message = try XCTUnwrap(ScriptMessage(message: script)) 11 | XCTAssertEqual(message.name, .pageLoaded) 12 | XCTAssertEqual(message.identifier, "123") 13 | XCTAssertEqual(message.restorationIdentifier, "abc") 14 | 15 | let options = try XCTUnwrap(message.options) 16 | XCTAssertEqual(options.action, .advance) 17 | XCTAssertEqual(message.location, URL(string: "http://turbo.test")!) 18 | } 19 | 20 | func test_parse_withInvalidBody_returnsNil() { 21 | let script = FakeScriptMessage(body: "foo") 22 | 23 | let message = ScriptMessage(message: script) 24 | XCTAssertNil(message) 25 | } 26 | 27 | func test_parse_withInvalidName_returnsNil() { 28 | let script = FakeScriptMessage(body: ["name": "foobar"]) 29 | 30 | let message = ScriptMessage(message: script) 31 | XCTAssertNil(message) 32 | } 33 | 34 | func test_parse_withMissingData_returnsNil() { 35 | let script = FakeScriptMessage(body: ["name": "pageLoaded"]) 36 | 37 | let message = ScriptMessage(message: script) 38 | XCTAssertNil(message) 39 | } 40 | } 41 | 42 | // Can't instantiate a WKScriptMessage directly 43 | private class FakeScriptMessage: WKScriptMessage { 44 | override var body: Any { 45 | return actualBody 46 | } 47 | 48 | var actualBody: Any 49 | 50 | init(body: Any) { 51 | self.actualBody = body 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Tests/Bridge/JavaScriptTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import XCTest 3 | 4 | class JavaScriptTests: XCTestCase { 5 | func testToStringWithNoArguments() throws { 6 | let javaScript = JavaScript(functionName: "test") 7 | XCTAssertEqual(try javaScript.toString(), "test()") 8 | } 9 | 10 | func testToStringWithEmptyArguments() throws { 11 | let javaScript = JavaScript(functionName: "test", arguments: []) 12 | XCTAssertEqual(try javaScript.toString(), "test()") 13 | } 14 | 15 | func testToStringWithOneStringArgument() throws { 16 | let javaScript = JavaScript(functionName: "test", arguments: ["foo"]) 17 | XCTAssertEqual(try javaScript.toString(), "test(\"foo\")") 18 | } 19 | 20 | func testToStringWithOneNumberArgument() throws { 21 | let javaScript = JavaScript(functionName: "test", arguments: [1]) 22 | XCTAssertEqual(try javaScript.toString(), "test(1)") 23 | } 24 | 25 | func testToStringWithMultipleArguments() throws { 26 | let javaScript = JavaScript(functionName: "test", arguments: ["foo", 1]) 27 | XCTAssertEqual(try javaScript.toString(), "test(\"foo\",1)") 28 | } 29 | 30 | func testToStringWithObject() throws { 31 | let javaScript = JavaScript(object: "window", functionName: "test") 32 | XCTAssertEqual(try javaScript.toString(), "window.test()") 33 | } 34 | 35 | func testToStringWithNestedObject() throws { 36 | let javaScript = JavaScript(object: "window.bridge", functionName: "test") 37 | XCTAssertEqual(try javaScript.toString(), "window.bridge.test()") 38 | } 39 | 40 | func testToStringWithInvalidArgumentTypeThrowsError() { 41 | let javaScript = JavaScript(functionName: "test", arguments: [InvalidType()]) 42 | XCTAssertThrowsError(try javaScript.toString()) 43 | } 44 | } 45 | 46 | private struct InvalidType {} 47 | -------------------------------------------------------------------------------- /Demo/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import HotwireNative 2 | import UIKit 3 | 4 | @main 5 | class AppDelegate: UIResponder, UIApplicationDelegate { 6 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 7 | configureAppearance() 8 | configureHotwire() 9 | return true 10 | } 11 | 12 | // MARK: UISceneSession Lifecycle 13 | 14 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 15 | UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 16 | } 17 | 18 | // Make navigation and tab bars opaque. 19 | private func configureAppearance() { 20 | UINavigationBar.appearance().scrollEdgeAppearance = .init() 21 | UITabBar.appearance().scrollEdgeAppearance = .init() 22 | } 23 | 24 | private func configureHotwire() { 25 | // Load the path configuration 26 | Hotwire.loadPathConfiguration(from: [ 27 | .file(Bundle.main.url(forResource: "path-configuration", withExtension: "json")!), 28 | .server(Demo.current.appendingPathComponent("configurations/ios_v1.json")) 29 | ]) 30 | 31 | // Set an optional custom user agent application prefix. 32 | Hotwire.config.applicationUserAgentPrefix = "Hotwire Demo;" 33 | 34 | // Register bridge components 35 | Hotwire.registerBridgeComponents([ 36 | FormComponent.self, 37 | MenuComponent.self, 38 | OverflowMenuComponent.self, 39 | ]) 40 | 41 | // Set configuration options 42 | Hotwire.config.backButtonDisplayMode = .minimal 43 | Hotwire.config.showDoneButtonOnModals = true 44 | #if DEBUG 45 | Hotwire.config.debugLoggingEnabled = true 46 | #endif 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/Turbo/Routing/SafariViewControllerRouteDecisionHandlerTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import XCTest 3 | 4 | final class SafariViewControllerRouteDecisionHandlerTests: XCTestCase { 5 | let navigatorConfiguration = Navigator.Configuration( 6 | name: "test", 7 | startLocation: URL(string: "https://my.app.com")! 8 | ) 9 | var navigator: Navigator! 10 | var route: SafariViewControllerRouteDecisionHandler! 11 | 12 | override func setUp() { 13 | route = SafariViewControllerRouteDecisionHandler() 14 | navigator = Navigator(configuration: navigatorConfiguration) 15 | } 16 | 17 | func test_handling_matching_result_stops_navigation() { 18 | let url = URL(string: "https://external.com/page")! 19 | let result = route.handle(location: url, configuration: navigatorConfiguration, navigator: navigator) 20 | XCTAssertEqual(result, Router.Decision.cancel) 21 | } 22 | 23 | func test_url_on_external_domain_matches() { 24 | let url = URL(string: "https://external.com/page")! 25 | let result = route.matches(location: url, configuration: navigatorConfiguration) 26 | 27 | XCTAssertTrue(result) 28 | } 29 | 30 | func test_url_without_subdomain_matches() { 31 | let url = URL(string: "https://app.com/page")! 32 | let result = route.matches(location: url, configuration: navigatorConfiguration) 33 | 34 | XCTAssertTrue(result) 35 | } 36 | 37 | func test_url_on_app_domain_does_not_match() { 38 | let url = URL(string: "https://my.app.com/page")! 39 | let result = route.matches(location: url, configuration: navigatorConfiguration) 40 | 41 | XCTAssertFalse(result) 42 | } 43 | 44 | func test_non_http_urls_do_not_match() { 45 | let url = URL(string: "file:///path/to/file")! 46 | let result = route.matches(location: url, configuration: navigatorConfiguration) 47 | 48 | XCTAssertFalse(result) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/Turbo/PathConfigurationModalStyleTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import XCTest 3 | 4 | class PathConfigurationModalStyleTests: XCTestCase { 5 | private let fileURL = Bundle.module.url(forResource: "test-modal-styles-configuration", withExtension: "json", subdirectory: "Fixtures")! 6 | var configuration: PathConfiguration! 7 | 8 | override func setUp() { 9 | configuration = PathConfiguration(sources: [.file(fileURL)]) 10 | XCTAssertGreaterThan(configuration.rules.count, 0) 11 | } 12 | 13 | // MARK: - Modal Styles 14 | 15 | func test_defaultModalStyle() { 16 | XCTAssertEqual(configuration.properties(for: "/new").modalStyle, .large) 17 | } 18 | 19 | func test_mediumModalStyle() { 20 | XCTAssertEqual(configuration.properties(for: "/newMedium").modalStyle, .medium) 21 | } 22 | 23 | func test_largeModalStyle() { 24 | XCTAssertEqual(configuration.properties(for: "/newLarge").modalStyle, .large) 25 | } 26 | 27 | func fullModalStyle() { 28 | XCTAssertEqual(configuration.properties(for: "/newFull").modalStyle, .full) 29 | } 30 | 31 | func test_pageSheetModalStyle() { 32 | XCTAssertEqual(configuration.properties(for: "/newPageSheet").modalStyle, .pageSheet) 33 | } 34 | 35 | func test_formSheetModalStyle() { 36 | XCTAssertEqual(configuration.properties(for: "/newFormSheet").modalStyle, .formSheet) 37 | } 38 | 39 | func test_unknownModalStyle_returnsDefault() { 40 | XCTAssertEqual(configuration.properties(for: "/unknown").modalStyle, .large) 41 | } 42 | 43 | // MARK: - Modal properties 44 | 45 | func test_modalDismissEnabled() { 46 | XCTAssertEqual(configuration.properties(for: "/new").modalDismissGestureEnabled, false) 47 | } 48 | 49 | func test_modalDismissDisabled() { 50 | XCTAssertEqual(configuration.properties(for: "/newMedium").modalDismissGestureEnabled, true) 51 | } 52 | 53 | func test_modalDismissMissing() { 54 | XCTAssertEqual(configuration.properties(for: "/newLarge").modalDismissGestureEnabled, true) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Tests/Turbo/Server.swift: -------------------------------------------------------------------------------- 1 | import Embassy 2 | import Foundation 3 | 4 | extension DefaultHTTPServer { 5 | static func turboServer(eventLoop: EventLoop, port: Int = 8080) -> DefaultHTTPServer { 6 | return DefaultHTTPServer(eventLoop: eventLoop, port: port) { environ, startResponse, sendBody in 7 | let path = environ["PATH_INFO"] as! String 8 | 9 | func respondWithFile(resourceName: String, resourceType: String) { 10 | let fileURL = Bundle.module.url(forResource: resourceName, withExtension: resourceType, subdirectory: "Server")! 11 | let data = try! Data(contentsOf: fileURL) 12 | let contentType = (resourceType == "js") ? "application/javascript" : "text/html" 13 | 14 | startResponse("200 OK", [("Content-Type", contentType)]) 15 | sendBody(data) 16 | sendBody(Data()) 17 | } 18 | 19 | switch path { 20 | case "/turbo-7.0.0-beta.1.js": 21 | respondWithFile(resourceName: "turbo-7.0.0-beta.1", resourceType: "js") 22 | case "/turbolinks-5.2.0.js": 23 | respondWithFile(resourceName: "turbolinks-5.2.0", resourceType: "js") 24 | case "/turbolinks-5.3.0-dev.js": 25 | respondWithFile(resourceName: "turbolinks-5.3.0-dev", resourceType: "js") 26 | case "/": 27 | respondWithFile(resourceName: "turbo", resourceType: "html") 28 | case "/turbolinks": 29 | respondWithFile(resourceName: "turbolinks", resourceType: "html") 30 | case "/turbolinks-5.3": 31 | respondWithFile(resourceName: "turbolinks-5.3", resourceType: "html") 32 | case "/missing-library": 33 | startResponse("200 OK", [("Content-Type", "text/html")]) 34 | sendBody("".data(using: .utf8)!) 35 | sendBody(Data()) 36 | default: 37 | startResponse("404 Not Found", [("Content-Type", "text/plain")]) 38 | sendBody(Data()) 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Source/Turbo/WebView/ScriptMessage.swift: -------------------------------------------------------------------------------- 1 | import WebKit 2 | 3 | struct ScriptMessage { 4 | let name: Name 5 | let data: [String: Any] 6 | 7 | var identifier: String? { 8 | data["identifier"] as? String 9 | } 10 | 11 | /// Milliseconds since unix epoch as provided by JavaScript Date.now() 12 | var timestamp: TimeInterval { 13 | data["timestamp"] as? TimeInterval ?? 0 14 | } 15 | 16 | var date: Date { 17 | Date(timeIntervalSince1970: timestamp / 1000.0) 18 | } 19 | 20 | var restorationIdentifier: String? { 21 | data["restorationIdentifier"] as? String 22 | } 23 | 24 | var location: URL? { 25 | guard let locationString = data["location"] as? String else { return nil } 26 | return URL(string: locationString) 27 | } 28 | 29 | var options: VisitOptions? { 30 | guard let options = data["options"] as? [String: Any] else { return nil } 31 | return VisitOptions(json: options) 32 | } 33 | } 34 | 35 | extension ScriptMessage { 36 | init?(message: WKScriptMessage) { 37 | guard let body = message.body as? [String: Any], 38 | let rawName = body["name"] as? String, 39 | let name = Name(rawValue: rawName), 40 | let data = body["data"] as? [String: Any] 41 | else { 42 | return nil 43 | } 44 | 45 | self.init(name: name, data: data) 46 | } 47 | } 48 | 49 | extension ScriptMessage { 50 | enum Name: String { 51 | case pageLoaded 52 | case pageLoadFailed 53 | case errorRaised 54 | case visitProposed 55 | case visitProposalScrollingToAnchor 56 | case visitProposalRefreshingPage 57 | case visitStarted 58 | case visitRequestStarted 59 | case visitRequestCompleted 60 | case visitRequestFailed 61 | case visitRequestFailedWithNonHttpStatusCode 62 | case visitRequestFinished 63 | case visitRendered 64 | case visitCompleted 65 | case formSubmissionStarted 66 | case formSubmissionFinished 67 | case pageInvalidated 68 | case log 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tests/Turbo/VisitOptionsTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import XCTest 3 | 4 | class VisitOptionsTests: XCTestCase { 5 | func test_Decodable_defaultsToAdvanceActionWhenNotProvided() throws { 6 | let json = "{}".data(using: .utf8)! 7 | 8 | let options = try JSONDecoder().decode(VisitOptions.self, from: json) 9 | XCTAssertEqual(options.action, .advance) 10 | XCTAssertNil(options.response) 11 | } 12 | 13 | func test_Decodable_usesProvidedActionWhenNotNil() throws { 14 | let json = """ 15 | {"action": "restore"} 16 | """.data(using: .utf8)! 17 | 18 | let options = try JSONDecoder().decode(VisitOptions.self, from: json) 19 | XCTAssertEqual(options.action, .restore) 20 | XCTAssertNil(options.response) 21 | } 22 | 23 | func test_Decodable_canBeInitializedWithResponse() throws { 24 | _ = try validVisitVisitOptions(responseHTMLString: "") 25 | } 26 | } 27 | 28 | extension VisitOptionsTests { 29 | func validVisitVisitOptions(responseHTMLString: String?) throws -> VisitOptions { 30 | var responseJSON = "" 31 | if let responseHTMLString { 32 | responseJSON = ", \"responseHTML\": \"\(responseHTMLString)\"" 33 | } 34 | 35 | let json = """ 36 | {"response": {"statusCode": 200\(responseJSON)}} 37 | """.data(using: .utf8)! 38 | 39 | let options = try JSONDecoder().decode(VisitOptions.self, from: json) 40 | XCTAssertEqual(options.action, .advance) 41 | 42 | let response = try XCTUnwrap(options.response) 43 | XCTAssertEqual(response.statusCode, 200) 44 | XCTAssertEqual(response.responseHTML, responseHTMLString) 45 | return options 46 | } 47 | } 48 | 49 | extension VisitOptions: Equatable { 50 | public static func == (lhs: VisitOptions, rhs: VisitOptions) -> Bool { 51 | lhs.action == rhs.action && lhs.response == rhs.response 52 | } 53 | } 54 | 55 | extension VisitResponse: Equatable { 56 | public static func == (lhs: VisitResponse, rhs: VisitResponse) -> Bool { 57 | lhs.responseHTML == rhs.responseHTML && lhs.statusCode == rhs.statusCode 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Tests/Turbo/Routing/SystemNavigationRouteDecisionHandlerTest.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import XCTest 3 | 4 | final class SystemNavigationRouteDecisionHandlerTests: XCTestCase { 5 | let navigatorConfiguration = Navigator.Configuration( 6 | name: "test", 7 | startLocation: URL(string: "https://my.app.com")! 8 | ) 9 | var navigator: Navigator! 10 | var route: SystemNavigationRouteDecisionHandler! 11 | 12 | override func setUp() { 13 | route = SystemNavigationRouteDecisionHandler() 14 | navigator = Navigator(configuration: navigatorConfiguration) 15 | } 16 | 17 | func test_handling_matching_result_stops_navigation() { 18 | let url = URL(string: "https://external.com/page")! 19 | let result = route.handle(location: url, configuration: navigatorConfiguration, navigator: navigator) 20 | XCTAssertEqual(result, Router.Decision.cancel) 21 | } 22 | 23 | func test_url_on_external_domain_matches() { 24 | let url = URL(string: "https://external.com/page")! 25 | let result = route.matches(location: url, configuration: navigatorConfiguration) 26 | 27 | XCTAssertTrue(result) 28 | } 29 | 30 | func test_url_without_subdomain_matches() { 31 | let url = URL(string: "https://app.com/page")! 32 | let result = route.matches(location: url, configuration: navigatorConfiguration) 33 | 34 | XCTAssertTrue(result) 35 | } 36 | 37 | func test_url_on_app_domain_does_not_match() { 38 | let url = URL(string: "https://my.app.com/page")! 39 | let result = route.matches(location: url, configuration: navigatorConfiguration) 40 | 41 | XCTAssertFalse(result) 42 | } 43 | 44 | func test_non_http_urls_match() { 45 | let url = URL(string: "file:///path/to/file")! 46 | let result = route.matches(location: url, configuration: navigatorConfiguration) 47 | 48 | XCTAssertTrue(result) 49 | } 50 | 51 | func test_sms_urls_match() { 52 | let url = URL(string: "sms:1-408-555-1212")! 53 | let result = route.matches(location: url, configuration: navigatorConfiguration) 54 | 55 | XCTAssertTrue(result) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Demo/Bridge/FormComponent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HotwireNative 3 | import UIKit 4 | 5 | /// Bridge component to display a submit button in the native toolbar, 6 | /// which will submit the form on the page when tapped. 7 | final class FormComponent: BridgeComponent { 8 | override class var name: String { "form" } 9 | 10 | override func onReceive(message: Message) { 11 | guard let event = Event(rawValue: message.event) else { 12 | return 13 | } 14 | 15 | switch event { 16 | case .connect: 17 | handleConnectEvent(message: message) 18 | case .submitEnabled: 19 | handleSubmitEnabled() 20 | case .submitDisabled: 21 | handleSubmitDisabled() 22 | } 23 | } 24 | 25 | // MARK: Private 26 | 27 | private weak var submitBarButtonItem: UIBarButtonItem? 28 | private var viewController: UIViewController? { 29 | delegate?.destination as? UIViewController 30 | } 31 | 32 | private func handleConnectEvent(message: Message) { 33 | guard let data: MessageData = message.data() else { return } 34 | configureBarButton(with: data.submitTitle) 35 | } 36 | 37 | private func handleSubmitEnabled() { 38 | submitBarButtonItem?.isEnabled = true 39 | } 40 | 41 | private func handleSubmitDisabled() { 42 | submitBarButtonItem?.isEnabled = false 43 | } 44 | 45 | private func configureBarButton(with title: String) { 46 | guard let viewController else { return } 47 | 48 | let action = UIAction { [unowned self] _ in 49 | reply(to: Event.connect.rawValue) 50 | } 51 | 52 | let item = UIBarButtonItem(title: title, primaryAction: action) 53 | viewController.navigationItem.rightBarButtonItem = item 54 | submitBarButtonItem = item 55 | } 56 | } 57 | 58 | // MARK: Events 59 | 60 | private extension FormComponent { 61 | enum Event: String { 62 | case connect 63 | case submitEnabled 64 | case submitDisabled 65 | } 66 | } 67 | 68 | // MARK: Message data 69 | 70 | private extension FormComponent { 71 | struct MessageData: Decodable { 72 | let submitTitle: String 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Tests/Turbo/ColdBootVisitTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import WebKit 3 | import XCTest 4 | 5 | class ColdBootVisitTests: XCTestCase { 6 | private let webView = WKWebView() 7 | private let visitDelegate = TestVisitDelegate() 8 | private var visit: ColdBootVisit! 9 | private var visitable: TestVisitable! 10 | let url = URL(string: "http://localhost/")! 11 | 12 | override func setUp() { 13 | 14 | let bridge = WebViewBridge(webView: webView) 15 | visitable = TestVisitable(url: url) 16 | visitable.currentVisitableURL = URL(string: "http://localhost/new")! 17 | 18 | visit = ColdBootVisit(visitable: visitable, options: VisitOptions(), bridge: bridge) 19 | visit.delegate = visitDelegate 20 | } 21 | 22 | func test_start_transitionsToStartState() { 23 | XCTAssertEqual(visit.state, .initialized) 24 | visit.start() 25 | XCTAssertEqual(visit.state, .started) 26 | } 27 | 28 | func test_start_notifiesTheDelegateTheVisitWillStart() { 29 | visit.start() 30 | XCTAssertTrue(visitDelegate.didCall("visitWillStart(_:)")) 31 | } 32 | 33 | func test_start_kicksOffTheWebViewLoad() { 34 | visit.start() 35 | XCTAssertNotNil(visit.navigation) 36 | } 37 | 38 | func test_visit_becomesTheNavigationDelegate() { 39 | visit.start() 40 | XCTAssertIdentical(webView.navigationDelegate, visit) 41 | } 42 | 43 | func test_visit_notifiesTheDelegateTheVisitDidStart() { 44 | visit.start() 45 | XCTAssertTrue(visitDelegate.didCall("visitDidStart(_:)")) 46 | } 47 | 48 | func test_visit_ignoresTheCallIfAlreadyStarted() { 49 | visit.start() 50 | XCTAssertTrue(visitDelegate.methodsCalled.contains("visitDidStart(_:)")) 51 | 52 | visitDelegate.methodsCalled.remove("visitDidStart(_:)") 53 | visit.start() 54 | XCTAssertFalse(visitDelegate.didCall("visitDidStart(_:)")) 55 | } 56 | 57 | func test_visit_takesTheCurrentVisitableURL() { 58 | visit.start() 59 | XCTAssertTrue(visitDelegate.visitDidStartWasCalled) 60 | XCTAssertEqual(URL(string: "http://localhost/new")!, visitDelegate.visitDidStartVisit?.location) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/Bridge/ComponentTestExample/ComposerComponent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HotwireNative 3 | import XCTest 4 | 5 | final class ComposerComponent: BridgeComponent { 6 | override static var name: String { "composer" } 7 | 8 | override func onReceive(message: Message) { 9 | guard let event = InboundEvent(rawValue: message.event) else { 10 | return 11 | } 12 | 13 | switch event { 14 | case .connect: 15 | // Handle connect event if needed. 16 | break 17 | } 18 | } 19 | 20 | func selectSender(emailAddress: String) async throws { 21 | guard let message = receivedMessage(for: InboundEvent.connect.rawValue), 22 | let senders: [Sender] = message.data() 23 | else { 24 | return 25 | } 26 | 27 | guard let sender = senders.first(where: { $0.email == emailAddress }) else { 28 | return 29 | } 30 | 31 | let newMessage = message.replacing(event: OutboundEvent.selectSender.rawValue, 32 | data: SelectSenderMessageData(selectedIndex: sender.index)) 33 | try await reply(with: newMessage) 34 | } 35 | 36 | func selectedSender() -> String? { 37 | guard let message = receivedMessage(for: InboundEvent.connect.rawValue), 38 | let senders: [Sender] = message.data() 39 | else { 40 | return nil 41 | } 42 | 43 | guard let selected = senders.first(where: { $0.selected }) else { 44 | return nil 45 | } 46 | 47 | return selected.email 48 | } 49 | } 50 | 51 | // MARK: Events 52 | 53 | extension ComposerComponent { 54 | private enum InboundEvent: String { 55 | case connect 56 | } 57 | 58 | private enum OutboundEvent: String { 59 | case selectSender = "select-sender" 60 | } 61 | } 62 | 63 | // MARK: Message data 64 | 65 | extension ComposerComponent { 66 | private struct Sender: Decodable { 67 | let email: String 68 | let index: Int 69 | let selected: Bool 70 | } 71 | 72 | private struct SelectSenderMessageData: Encodable { 73 | let selectedIndex: Int 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Demo/SceneController.swift: -------------------------------------------------------------------------------- 1 | import HotwireNative 2 | import SafariServices 3 | import UIKit 4 | import WebKit 5 | 6 | final class SceneController: UIResponder { 7 | var window: UIWindow? 8 | 9 | private let rootURL = Demo.current 10 | private lazy var tabBarController = HotwireTabBarController(navigatorDelegate: self) 11 | 12 | // MARK: - Authentication 13 | 14 | private func promptForAuthentication() { 15 | let authURL = rootURL.appendingPathComponent("/signin") 16 | tabBarController.activeNavigator.route(authURL) 17 | } 18 | } 19 | 20 | extension SceneController: UIWindowSceneDelegate { 21 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 22 | guard let windowScene = scene as? UIWindowScene else { return } 23 | 24 | window = UIWindow(windowScene: windowScene) 25 | window?.rootViewController = tabBarController 26 | window?.makeKeyAndVisible() 27 | 28 | tabBarController.load(HotwireTab.all) 29 | } 30 | } 31 | 32 | extension SceneController: NavigatorDelegate { 33 | func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult { 34 | switch proposal.viewController { 35 | case NumbersViewController.pathConfigurationIdentifier: 36 | return .acceptCustom(NumbersViewController( 37 | url: proposal.url, 38 | navigator: navigator 39 | ) 40 | ) 41 | 42 | default: 43 | return .accept 44 | } 45 | } 46 | 47 | func visitableDidFailRequest(_ visitable: any Visitable, error: any Error, retryHandler: RetryBlock?) { 48 | if let turboError = error as? TurboError, case let .http(statusCode) = turboError, statusCode == 401 { 49 | promptForAuthentication() 50 | } else if let errorPresenter = visitable as? ErrorPresenter { 51 | errorPresenter.presentError(error) { 52 | retryHandler?() 53 | } 54 | } else { 55 | let alert = UIAlertController(title: "Visit failed!", message: error.localizedDescription, preferredStyle: .alert) 56 | alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) 57 | tabBarController.activeNavigator.present(alert, animated: true) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Tests/Turbo/VisitableViewControllerTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import WebKit 3 | import XCTest 4 | 5 | class VisitableViewControllerTests: XCTestCase { 6 | var viewController: VisitableViewController! 7 | var webView: WebViewSpy! 8 | let originalURL = URL(string: "https://example.com")! 9 | 10 | override func setUp() { 11 | webView = WebViewSpy(frame: .zero) 12 | webView.overriddenURL = originalURL 13 | viewController = VisitableViewController(url: originalURL) 14 | } 15 | 16 | func test_visitableURL_and_currentURL_match_on_init() { 17 | XCTAssertEqual(viewController.initialVisitableURL, originalURL) 18 | XCTAssertEqual(viewController.currentVisitableURL, originalURL) 19 | } 20 | 21 | func test_currentURL_matches_new_webview_url_on_webView_activated_and_rendered() { 22 | viewController.visitableView.activateWebView(webView, forVisitable: viewController) 23 | viewController.visitableDidRender() 24 | 25 | XCTAssertEqual(viewController.initialVisitableURL, originalURL) 26 | XCTAssertEqual(viewController.currentVisitableURL, originalURL) 27 | 28 | let overriddenURL = URL(string: "https://example.com?tab=a")! 29 | webView.overriddenURL = overriddenURL 30 | 31 | XCTAssertEqual(viewController.initialVisitableURL, originalURL) 32 | XCTAssertEqual(viewController.currentVisitableURL, overriddenURL) 33 | } 34 | 35 | func test_currentURL_matches_new_webview_url_on_webView_deactivation() { 36 | viewController.visitableView.activateWebView(webView, forVisitable: viewController) 37 | viewController.visitableDidRender() 38 | 39 | XCTAssertEqual(viewController.initialVisitableURL, originalURL) 40 | XCTAssertEqual(viewController.currentVisitableURL, originalURL) 41 | 42 | let overriddenURL = URL(string: "https://example.com?tab=a")! 43 | webView.overriddenURL = overriddenURL 44 | viewController.visitableWillDeactivateWebView() 45 | viewController.visitableDidDeactivateWebView() 46 | 47 | XCTAssertEqual(viewController.initialVisitableURL, originalURL) 48 | XCTAssertEqual(viewController.currentVisitableURL, overriddenURL) 49 | } 50 | } 51 | 52 | final class WebViewSpy: WKWebView { 53 | var overriddenURL: URL? 54 | 55 | override var url: URL? { 56 | overriddenURL 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Source/Turbo/Networking/RedirectHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum RedirectHandlerError: Error { 4 | case requestFailed(Error) 5 | case responseValidationFailed(reason: ResponseValidationFailureReason) 6 | 7 | /// The underlying reason the `.responseValidationFailed` error occurred. 8 | public enum ResponseValidationFailureReason: Sendable { 9 | case missingURL 10 | case invalidResponse 11 | case unacceptableStatusCode(code: Int) 12 | } 13 | } 14 | 15 | struct RedirectHandler { 16 | enum Result { 17 | case noRedirect 18 | case sameOriginRedirect(URL) 19 | case crossOriginRedirect(URL) 20 | } 21 | 22 | func resolve(location: URL) async throws -> Result { 23 | do { 24 | let request = URLRequest(url: location) 25 | let (_, response) = try await URLSession.shared.data(for: request) 26 | let httpResponse = try validateResponse(response) 27 | 28 | guard let responseUrl = httpResponse.url else { 29 | throw RedirectHandlerError.responseValidationFailed(reason: .missingURL) 30 | } 31 | 32 | let isRedirect = location != responseUrl 33 | let redirectIsCrossOrigin = isRedirect && location.host != responseUrl.host 34 | 35 | guard isRedirect else { 36 | return .noRedirect 37 | } 38 | 39 | if redirectIsCrossOrigin { 40 | return .crossOriginRedirect(responseUrl) 41 | } 42 | 43 | return .sameOriginRedirect(responseUrl) 44 | } catch let error as RedirectHandlerError { 45 | throw error 46 | } catch { 47 | throw RedirectHandlerError.requestFailed(error) 48 | } 49 | } 50 | 51 | private func validateResponse(_ response: URLResponse) throws -> HTTPURLResponse { 52 | guard let httpResponse = response as? HTTPURLResponse else { 53 | throw RedirectHandlerError.responseValidationFailed(reason: .invalidResponse) 54 | } 55 | 56 | guard httpResponse.isSuccessful else { 57 | throw RedirectHandlerError.responseValidationFailed(reason: .unacceptableStatusCode(code: httpResponse.statusCode)) 58 | } 59 | 60 | return httpResponse 61 | } 62 | } 63 | 64 | extension HTTPURLResponse { 65 | public var isSuccessful: Bool { 66 | (200...299).contains(statusCode) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Source/Turbo/ViewControllers/HotwireWebViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import WebKit 3 | 4 | /// A base controller to use or subclass that handles bridge lifecycle callbacks. 5 | /// Use `Hotwire.registerBridgeComponents(_:)` to register bridge components. 6 | open class HotwireWebViewController: VisitableViewController, BridgeDestination { 7 | public lazy var bridgeDelegate = BridgeDelegate( 8 | location: initialVisitableURL.absoluteString, 9 | destination: self, 10 | componentTypes: Hotwire.bridgeComponentTypes 11 | ) 12 | 13 | // MARK: View lifecycle 14 | 15 | override open func viewDidLoad() { 16 | super.viewDidLoad() 17 | 18 | navigationItem.backButtonDisplayMode = Hotwire.config.backButtonDisplayMode 19 | view.backgroundColor = .systemBackground 20 | 21 | if Hotwire.config.showDoneButtonOnModals { 22 | addDoneButtonToModals() 23 | } 24 | 25 | bridgeDelegate.onViewDidLoad() 26 | } 27 | 28 | override open func viewWillAppear(_ animated: Bool) { 29 | super.viewWillAppear(animated) 30 | bridgeDelegate.onViewWillAppear() 31 | } 32 | 33 | override open func viewDidAppear(_ animated: Bool) { 34 | super.viewDidAppear(animated) 35 | bridgeDelegate.onViewDidAppear() 36 | } 37 | 38 | override open func viewWillDisappear(_ animated: Bool) { 39 | super.viewWillDisappear(animated) 40 | bridgeDelegate.onViewWillDisappear() 41 | } 42 | 43 | override open func viewDidDisappear(_ animated: Bool) { 44 | super.viewDidDisappear(animated) 45 | bridgeDelegate.onViewDidDisappear() 46 | } 47 | 48 | // MARK: Visitable 49 | 50 | override open func visitableDidActivateWebView(_ webView: WKWebView) { 51 | super.visitableDidActivateWebView(webView) 52 | bridgeDelegate.webViewDidBecomeActive(webView) 53 | } 54 | 55 | override open func visitableDidDeactivateWebView() { 56 | super.visitableDidDeactivateWebView() 57 | bridgeDelegate.webViewDidBecomeDeactivated() 58 | } 59 | 60 | // MARK: Private 61 | 62 | private func addDoneButtonToModals() { 63 | if presentingViewController != nil { 64 | let action = UIAction { [unowned self] _ in 65 | dismiss(animated: true) 66 | } 67 | navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .done, primaryAction: action) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Source/Bridge/bridge.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | // This represents the adapter that is installed on the webBridge 3 | // All adapters implement the same interface so the web doesn't need to 4 | // know anything specific about the client platform 5 | class NativeBridge { 6 | constructor() { 7 | this.supportedComponents = [] 8 | this.registerCalled = new Promise(resolve => this.registerResolver = resolve) 9 | document.addEventListener("web-bridge:ready", async () => { 10 | await this.setAdapter() 11 | }) 12 | } 13 | 14 | async setAdapter() { 15 | await this.registerCalled 16 | this.webBridge.setAdapter(this) 17 | } 18 | 19 | register(component) { 20 | if (Array.isArray(component)) { 21 | this.supportedComponents = this.supportedComponents.concat(component) 22 | } else { 23 | this.supportedComponents.push(component) 24 | } 25 | 26 | this.registerResolver() 27 | this.notifyBridgeOfSupportedComponentsUpdate() 28 | } 29 | 30 | unregister(component) { 31 | const index = this.supportedComponents.indexOf(component) 32 | if (index != -1) { 33 | this.supportedComponents.splice(index, 1) 34 | this.notifyBridgeOfSupportedComponentsUpdate() 35 | } 36 | } 37 | 38 | notifyBridgeOfSupportedComponentsUpdate() { 39 | if (this.isWebBridgeAvailable) { 40 | this.webBridge.adapterDidUpdateSupportedComponents() 41 | } 42 | } 43 | 44 | supportsComponent(component) { 45 | return this.supportedComponents.includes(component) 46 | } 47 | 48 | // Reply to web with message. 49 | replyWith(message) { 50 | if (this.isWebBridgeAvailable) { 51 | this.webBridge.receive(message) 52 | } 53 | } 54 | 55 | // Receive from web 56 | receive(message) { 57 | this.postMessage(message) 58 | } 59 | 60 | get platform() { 61 | return "ios" 62 | } 63 | 64 | // Native handler 65 | 66 | postMessage(message) { 67 | webkit.messageHandlers.bridge.postMessage(message) 68 | } 69 | 70 | get isWebBridgeAvailable() { 71 | // Fallback to Strada for legacy Strada web JavaScript. 72 | return window.HotwireNative ?? window.Strada 73 | } 74 | 75 | get webBridge() { 76 | // Fallback to Strada for legacy Strada web JavaScript. 77 | return window.HotwireNative?.web ?? window.Strada.web 78 | } 79 | } 80 | 81 | window.nativeBridge = new NativeBridge() 82 | window.nativeBridge.postMessage("ready") 83 | })() 84 | -------------------------------------------------------------------------------- /Tests/Turbo/WebViewPolicy/WebViewNavigationSimulator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | @preconcurrency import WebKit 4 | 5 | @MainActor 6 | class WebViewNavigationSimulator: NSObject, WKNavigationDelegate { 7 | var capturedNavigationAction: WKNavigationAction? 8 | var simulateLinkClickElementId: String? 9 | 10 | private var didSimulateLinkClick: Bool = false 11 | private var continuation: CheckedContinuation? 12 | 13 | let webView: WKWebView 14 | 15 | override init() { 16 | webView = WKWebView() 17 | super.init() 18 | webView.navigationDelegate = self 19 | } 20 | 21 | /// Loads the given HTML into the web view and awaits the resulting navigation action. 22 | /// - Parameters: 23 | /// - html: The HTML content to load. 24 | /// - simulateLinkClickElementId: If provided, once the page loads, a simulated click will be triggered on the element with this ID. 25 | /// - Returns: The captured `WKNavigationAction`. 26 | func simulateNavigation(withHTML html: String, simulateLinkClickElementId: String? = nil) async throws -> WKNavigationAction? { 27 | self.simulateLinkClickElementId = simulateLinkClickElementId 28 | webView.loadHTMLString(html, baseURL: nil) 29 | return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in 30 | self.continuation = continuation 31 | } 32 | } 33 | 34 | // MARK: - WKNavigationDelegate 35 | 36 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 37 | guard let elementId = simulateLinkClickElementId else { return } 38 | didSimulateLinkClick = true 39 | let js = "document.getElementById('\(elementId)').click();" 40 | webView.evaluateJavaScript(js) { [weak self] (_, error) in 41 | if let error = error { 42 | self?.continuation?.resume(throwing: error) 43 | self?.continuation = nil 44 | } 45 | } 46 | } 47 | 48 | func webView(_ webView: WKWebView, 49 | decidePolicyFor navigationAction: WKNavigationAction, 50 | decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { 51 | capturedNavigationAction = navigationAction 52 | decisionHandler(.allow) 53 | 54 | // When there is no simulated click, or after a simulated click is performed, resume the continuation. 55 | if simulateLinkClickElementId == nil || didSimulateLinkClick { 56 | continuation?.resume(returning: navigationAction) 57 | continuation = nil 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Tests/Turbo/PathRuleTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import XCTest 3 | 4 | class PathRuleTests: XCTestCase { 5 | func test_subscript_returnsAStringValueForKey() { 6 | let rule = PathRule(patterns: ["^/new$"], properties: ["color": "blue", "modal": false]) 7 | 8 | XCTAssertEqual(rule["color"], "blue") 9 | XCTAssertNil(rule["modal"]) 10 | } 11 | 12 | func test_match_whenPathMatchesSinglePattern_returnsTrue() { 13 | let rule = PathRule(patterns: ["^/new$"], properties: [:]) 14 | 15 | XCTAssertTrue(rule.match(path: "/new")) 16 | } 17 | 18 | func test_match_whenPathMatchesAnyPatternInArray_returnsTrue() { 19 | let rule = PathRule(patterns: ["^/new$", "^/edit"], properties: [:]) 20 | 21 | XCTAssertTrue(rule.match(path: "/edit/1")) 22 | } 23 | 24 | func test_match_whenPathDoesntMatchAnyPatterns_returnsFalse() { 25 | let rule = PathRule(patterns: ["^/new/bar"], properties: [:]) 26 | 27 | XCTAssertFalse(rule.match(path: "/new")) 28 | XCTAssertFalse(rule.match(path: "foo")) 29 | } 30 | 31 | func test_recedeHistoricalLocation() { 32 | let rule = PathRule.recedeHistoricalLocation 33 | XCTAssertEqual(rule.patterns, ["/recede_historical_location"]) 34 | XCTAssertEqual(rule.properties, ["presentation": "pop", 35 | "context": "default", 36 | "historical_location": true]) 37 | } 38 | 39 | func test_refreshHistoricalLocation() { 40 | let rule = PathRule.refreshHistoricalLocation 41 | XCTAssertEqual(rule.patterns, ["/refresh_historical_location"]) 42 | XCTAssertEqual(rule.properties, ["presentation": "refresh", 43 | "context": "default", 44 | "historical_location": true]) 45 | } 46 | 47 | func test_resumeHistoricalLocation() { 48 | let rule = PathRule.resumeHistoricalLocation 49 | XCTAssertEqual(rule.patterns, ["/resume_historical_location"]) 50 | XCTAssertEqual(rule.properties, ["presentation": "none", 51 | "context": "default", 52 | "historical_location": true]) 53 | } 54 | 55 | func test_defaultHistoricalLocationRules() { 56 | XCTAssertEqual(PathRule.defaultServerRoutes.count, 3) 57 | let expectedRules: [PathRule] = [ 58 | PathRule.recedeHistoricalLocation, 59 | PathRule.resumeHistoricalLocation, 60 | PathRule.refreshHistoricalLocation 61 | ] 62 | 63 | if #available(iOS 16.0, *) { 64 | XCTAssertTrue(PathRule.defaultServerRoutes.contains(expectedRules)) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Source/Turbo/Visit/Visit.swift: -------------------------------------------------------------------------------- 1 | import WebKit 2 | 3 | enum VisitState { 4 | case initialized 5 | case started 6 | case canceled 7 | case failed 8 | case completed 9 | } 10 | 11 | class Visit: NSObject { 12 | weak var delegate: VisitDelegate? 13 | let visitable: Visitable 14 | var restorationIdentifier: String? 15 | let options: VisitOptions 16 | let bridge: WebViewBridge 17 | var webView: WKWebView { bridge.webView } 18 | let location: URL 19 | 20 | var hasCachedSnapshot: Bool = false 21 | var isPageRefresh: Bool = false 22 | private(set) var state: VisitState 23 | 24 | init(visitable: Visitable, options: VisitOptions, bridge: WebViewBridge) { 25 | self.visitable = visitable 26 | self.location = visitable.currentVisitableURL 27 | self.options = options 28 | self.bridge = bridge 29 | self.state = .initialized 30 | } 31 | 32 | func start() { 33 | guard state == .initialized else { return } 34 | 35 | delegate?.visitWillStart(self) 36 | state = .started 37 | startVisit() 38 | } 39 | 40 | func cancel() { 41 | guard state == .started else { return } 42 | 43 | state = .canceled 44 | cancelVisit() 45 | } 46 | 47 | func complete() { 48 | guard state == .started else { return } 49 | 50 | if !requestFinished { 51 | finishRequest() 52 | } 53 | 54 | state = .completed 55 | 56 | completeVisit() 57 | delegate?.visitDidComplete(self) 58 | delegate?.visitDidFinish(self) 59 | } 60 | 61 | func fail(with error: Error) { 62 | guard state == .started else { return } 63 | 64 | state = .failed 65 | delegate?.visit(self, requestDidFailWithError: error) 66 | failVisit() 67 | delegate?.visitDidFail(self) 68 | delegate?.visitDidFinish(self) 69 | } 70 | 71 | func cacheSnapshot() { 72 | bridge.cacheSnapshot() 73 | } 74 | 75 | func startVisit() {} 76 | func cancelVisit() {} 77 | func completeVisit() {} 78 | func failVisit() {} 79 | 80 | // MARK: Request state 81 | 82 | private var requestStarted = false 83 | private var requestFinished = false 84 | 85 | func startRequest() { 86 | guard !requestStarted else { return } 87 | 88 | requestStarted = true 89 | delegate?.visitRequestDidStart(self) 90 | } 91 | 92 | func finishRequest() { 93 | guard requestStarted, !requestFinished else { return } 94 | 95 | requestFinished = true 96 | delegate?.visitRequestDidFinish(self) 97 | } 98 | } 99 | 100 | // CustomDebugStringConvertible 101 | extension Visit { 102 | override var debugDescription: String { 103 | "<\(type(of: self)) state: \(state), location: \(location)>" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Source/Turbo/Visitable/Visitable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import WebKit 3 | 4 | public protocol VisitableDelegate: AnyObject { 5 | func visitableViewWillAppear(_ visitable: Visitable) 6 | func visitableViewDidAppear(_ visitable: Visitable) 7 | func visitableViewWillDisappear(_ visitable: Visitable) 8 | func visitableViewDidDisappear(_ visitable: Visitable) 9 | func visitableDidRequestReload(_ visitable: Visitable) 10 | func visitableDidRequestRefresh(_ visitable: Visitable) 11 | } 12 | 13 | public protocol Visitable: AnyObject { 14 | var visitableViewController: UIViewController { get } 15 | var visitableDelegate: VisitableDelegate? { get set } 16 | var visitableView: VisitableView { get } 17 | var initialVisitableURL: URL { get } 18 | var currentVisitableURL: URL { get } 19 | 20 | func visitableDidRender() 21 | func showVisitableActivityIndicator() 22 | func hideVisitableActivityIndicator() 23 | 24 | func visitableDidActivateWebView(_ webView: WKWebView) 25 | func visitableWillDeactivateWebView() 26 | func visitableDidDeactivateWebView() 27 | } 28 | 29 | extension Visitable { 30 | public func reloadVisitable() { 31 | visitableDelegate?.visitableDidRequestReload(self) 32 | } 33 | 34 | public func showVisitableActivityIndicator() { 35 | visitableView.showActivityIndicator() 36 | } 37 | 38 | public func hideVisitableActivityIndicator() { 39 | visitableView.hideActivityIndicator() 40 | } 41 | 42 | public func visitableDidActivateWebView(_ webView: WKWebView) { 43 | // No-op 44 | } 45 | 46 | public func visitableDidDeactivateWebView() { 47 | // No-op 48 | } 49 | 50 | func activateVisitableWebView(_ webView: WKWebView) { 51 | visitableView.activateWebView(webView, forVisitable: self) 52 | visitableDidActivateWebView(webView) 53 | } 54 | 55 | func deactivateVisitableWebView() { 56 | visitableWillDeactivateWebView() 57 | visitableView.deactivateWebView() 58 | visitableDidDeactivateWebView() 59 | } 60 | 61 | func updateVisitableScreenshot() { 62 | visitableView.updateScreenshot() 63 | } 64 | 65 | func showVisitableScreenshot() { 66 | visitableView.showScreenshot() 67 | } 68 | 69 | func hideVisitableScreenshot() { 70 | visitableView.hideScreenshot() 71 | } 72 | 73 | func clearVisitableScreenshot() { 74 | visitableView.clearScreenshot() 75 | } 76 | 77 | func visitableWillRefresh() { 78 | visitableView.refreshControl.beginRefreshing() 79 | } 80 | 81 | func visitableDidRefresh() { 82 | visitableView.refreshControl.endRefreshing() 83 | } 84 | 85 | func visitableViewDidRequestRefresh() { 86 | visitableDelegate?.visitableDidRequestRefresh(self) 87 | } 88 | } 89 | 90 | public extension Visitable where Self: UIViewController { 91 | var visitableViewController: UIViewController { 92 | self 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Demo/Bridge/MenuComponent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HotwireNative 3 | import UIKit 4 | 5 | /// Bridge component to display a native bottom sheet menu, 6 | /// which will send the selected index of the tapped menu item back to the web. 7 | final class MenuComponent: BridgeComponent { 8 | override class var name: String { "menu" } 9 | 10 | override func onReceive(message: Message) { 11 | guard let event = Event(rawValue: message.event) else { 12 | return 13 | } 14 | 15 | switch event { 16 | case .display: 17 | handleDisplayEvent(message: message) 18 | } 19 | } 20 | 21 | // MARK: Private 22 | 23 | private var viewController: UIViewController? { 24 | delegate?.destination as? UIViewController 25 | } 26 | 27 | private func handleDisplayEvent(message: Message) { 28 | guard let data: MessageData = message.data() else { return } 29 | showAlertSheet(with: data.title, items: data.items) 30 | } 31 | 32 | private func showAlertSheet(with title: String, items: [Item]) { 33 | let alertController = UIAlertController( 34 | title: title, 35 | message: nil, 36 | preferredStyle: .actionSheet 37 | ) 38 | 39 | for item in items { 40 | let action = UIAlertAction(title: item.title, style: .default) { [unowned self] _ in 41 | onItemSelected(item: item) 42 | } 43 | alertController.addAction(action) 44 | } 45 | 46 | let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) 47 | alertController.addAction(cancelAction) 48 | 49 | // Set popoverController for iPads 50 | if let popoverController = alertController.popoverPresentationController { 51 | if let barButtonItem = viewController?.navigationItem.rightBarButtonItem { 52 | popoverController.barButtonItem = barButtonItem 53 | } else { 54 | popoverController.sourceView = viewController?.view 55 | popoverController.sourceRect = viewController?.view.bounds ?? .zero 56 | popoverController.permittedArrowDirections = [] 57 | } 58 | } 59 | 60 | viewController?.present(alertController, animated: true) 61 | } 62 | 63 | private func onItemSelected(item: Item) { 64 | reply( 65 | to: Event.display.rawValue, 66 | with: SelectionMessageData(selectedIndex: item.index) 67 | ) 68 | } 69 | } 70 | 71 | // MARK: Events 72 | 73 | private extension MenuComponent { 74 | enum Event: String { 75 | case display 76 | } 77 | } 78 | 79 | // MARK: Message data 80 | 81 | private extension MenuComponent { 82 | struct MessageData: Decodable { 83 | let title: String 84 | let items: [Item] 85 | } 86 | 87 | struct Item: Decodable { 88 | let title: String 89 | let index: Int 90 | } 91 | 92 | struct SelectionMessageData: Encodable { 93 | let selectedIndex: Int 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Tests/Turbo/WebViewPolicy/ExternalNavigationWebViewPolicyDecisionHandlerTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | @preconcurrency import WebKit 3 | import XCTest 4 | 5 | @MainActor 6 | final class ExternalNavigationWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDecisionHandlerTests { 7 | var policyHandler: ExternalNavigationWebViewPolicyDecisionHandler! 8 | 9 | override func setUp() async throws { 10 | try await super.setUp() 11 | policyHandler = ExternalNavigationWebViewPolicyDecisionHandler() 12 | } 13 | 14 | func test_link_activated_matches() async throws { 15 | guard let action = try await webNavigationSimulator.simulateNavigation( 16 | withHTML: .simpleLink, 17 | simulateLinkClickElementId: "link") else { 18 | XCTFail("No navigation action captured") 19 | return 20 | } 21 | let result = policyHandler.matches(navigationAction: action, configuration: navigatorConfiguration) 22 | XCTAssertTrue(result) 23 | } 24 | 25 | func test_handling_link_activated_cancels_web_navigation_and_routes_internally() async throws { 26 | guard let action = try await webNavigationSimulator.simulateNavigation( 27 | withHTML: .simpleLink, 28 | simulateLinkClickElementId: "link") else { 29 | XCTFail("No navigation action captured") 30 | return 31 | } 32 | let result = policyHandler.handle( 33 | navigationAction: action, 34 | configuration: navigatorConfiguration, 35 | navigator: navigatorSpy 36 | ) 37 | XCTAssertEqual(result, WebViewPolicyManager.Decision.cancel) 38 | XCTAssertTrue(navigatorSpy.routeWasCalled) 39 | XCTAssertEqual(action.request.url, navigatorSpy.routeURL) 40 | } 41 | 42 | func test_js_click_matches() async throws { 43 | guard let action = try await webNavigationSimulator.simulateNavigation( 44 | withHTML: .jsClick, 45 | simulateLinkClickElementId: nil) else { 46 | XCTFail("No navigation action captured") 47 | return 48 | } 49 | let result = policyHandler.matches(navigationAction: action, configuration: navigatorConfiguration) 50 | XCTAssertTrue(result) 51 | } 52 | 53 | func test_handling_js_click_cancels_web_navigation_and_routes_internally() async throws { 54 | guard let action = try await webNavigationSimulator.simulateNavigation( 55 | withHTML: .jsClick, 56 | simulateLinkClickElementId: nil) else { 57 | XCTFail("No navigation action captured") 58 | return 59 | } 60 | let result = policyHandler.handle( 61 | navigationAction: action, 62 | configuration: navigatorConfiguration, 63 | navigator: navigatorSpy 64 | ) 65 | XCTAssertEqual(result, WebViewPolicyManager.Decision.cancel) 66 | XCTAssertTrue(navigatorSpy.routeWasCalled) 67 | XCTAssertEqual(action.request.url, navigatorSpy.routeURL) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/Turbo/PathConfigurationLoaderTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import OHHTTPStubs 3 | import OHHTTPStubsSwift 4 | import XCTest 5 | 6 | class PathConfigurationLoaderTests: XCTestCase { 7 | private let serverURL = URL(string: "http://turbo.test/configuration.json")! 8 | private let fileURL = Bundle.module.url(forResource: "test-configuration", withExtension: "json", subdirectory: "Fixtures")! 9 | 10 | func test_load_data_automaticallyLoadsFromPassedInDataAndCallsHandler() throws { 11 | let data = try! Data(contentsOf: fileURL) 12 | let loader = PathConfigurationLoader(sources: [.data(data)]) 13 | 14 | var loadedConfig: PathConfigurationDecoder? = nil 15 | loader.load { loadedConfig = $0 } 16 | 17 | let config = try XCTUnwrap(loadedConfig) 18 | XCTAssertEqual(config.rules.count, 5) 19 | } 20 | 21 | func test_file_automaticallyLoadsFromTheLocalFileAndCallsTheHandler() throws { 22 | let loader = PathConfigurationLoader(sources: [.file(fileURL)]) 23 | 24 | var loadedConfig: PathConfigurationDecoder? = nil 25 | loader.load { loadedConfig = $0 } 26 | 27 | let config = try XCTUnwrap(loadedConfig) 28 | XCTAssertEqual(config.rules.count, 5) 29 | } 30 | 31 | func test_server_automaticallyDownloadsTheFileAndCallsTheHandler() throws { 32 | let loader = PathConfigurationLoader(sources: [.server(serverURL)]) 33 | let expectation = stubRequest(for: loader) 34 | 35 | var loadedConfig: PathConfigurationDecoder? = nil 36 | loader.load { config in 37 | loadedConfig = config 38 | expectation.fulfill() 39 | } 40 | wait(for: [expectation]) 41 | 42 | let config = try XCTUnwrap(loadedConfig) 43 | XCTAssertEqual(config.rules.count, 1) 44 | } 45 | 46 | func test_server_cachesTheFile() { 47 | let loader = PathConfigurationLoader(sources: [.server(serverURL)]) 48 | let expectation = stubRequest(for: loader) 49 | 50 | var handlerCalled = false 51 | loader.load { _ in 52 | handlerCalled = true 53 | expectation.fulfill() 54 | } 55 | wait(for: [expectation]) 56 | 57 | XCTAssertTrue(handlerCalled) 58 | XCTAssertTrue(FileManager.default.fileExists(atPath: loader.configurationCacheURL(for: serverURL).path)) 59 | } 60 | 61 | private func stubRequest(for loader: PathConfigurationLoader) -> XCTestExpectation { 62 | stub(condition: { _ in true }) { _ in 63 | let json = ["rules": [["patterns": ["/new"], "properties": ["presentation": "test"]] as [String: Any]]] 64 | return HTTPStubsResponse(jsonObject: json, statusCode: 200, headers: [:]) 65 | } 66 | 67 | clearCache(loader.configurationCacheURL(for: serverURL)) 68 | 69 | return expectation(description: "Wait for configuration to load.") 70 | } 71 | 72 | private func clearCache(_ url: URL) { 73 | do { 74 | try FileManager.default.removeItem(at: url) 75 | } catch {} 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Tests/Bridge/ComponentTestExample/ComposerComponentTests.swift: -------------------------------------------------------------------------------- 1 | import HotwireNative 2 | import WebKit 3 | import XCTest 4 | 5 | final class ComposerComponentTests: XCTestCase { 6 | private var delegate: BridgeDelegateSpy! 7 | private var destination: AppBridgeDestination! 8 | private var component: ComposerComponent! 9 | private lazy var connectMessage = Message(id: "1", 10 | component: ComposerComponent.name, 11 | event: "connect", 12 | metadata: .init(url: "https://37signals.com"), 13 | jsonData: connectMessageJsonData) 14 | private let connectMessageJsonData = """ 15 | [ 16 | { 17 | "email":"user@37signals.com", 18 | "index":0, 19 | "selected":true 20 | }, 21 | { 22 | "email":"user1@37signals.com", 23 | "index":1, 24 | "selected":false 25 | }, 26 | { 27 | "email":"user2@37signals.com", 28 | "index":2, 29 | "selected":false 30 | } 31 | ] 32 | """ 33 | 34 | @MainActor 35 | override func setUp() async throws { 36 | delegate = BridgeDelegateSpy() 37 | destination = AppBridgeDestination() 38 | component = ComposerComponent(destination: destination, delegate: delegate) 39 | } 40 | 41 | // MARK: Retreive sender tests 42 | 43 | @MainActor 44 | func test_connectMessageContainsSelectedSender() { 45 | component.didReceive(message: connectMessage) 46 | 47 | XCTAssertEqual(component.selectedSender(), "user@37signals.com") 48 | } 49 | 50 | // MARK: Select sender tests 51 | 52 | @MainActor 53 | func test_selectSender_emailFound_sendsTheCorrectMessageReply() async throws { 54 | component.didReceive(message: connectMessage) 55 | 56 | try await component.selectSender(emailAddress: "user1@37signals.com") 57 | 58 | let expectedMessage = connectMessage.replacing(event: "select-sender", 59 | jsonData: "{\"selectedIndex\":1}") 60 | XCTAssertTrue(delegate.replyWithMessageWasCalled) 61 | XCTAssertEqual(delegate.replyWithMessageArg, expectedMessage) 62 | } 63 | 64 | @MainActor 65 | func test_selectSender_emailNotFound_doesNotSendAnyMessage() async throws { 66 | component.didReceive(message: connectMessage) 67 | 68 | try await component.selectSender(emailAddress: "test@37signals.com") 69 | 70 | XCTAssertFalse(delegate.replyWithMessageWasCalled) 71 | XCTAssertNil(delegate.replyWithMessageArg) 72 | } 73 | 74 | @MainActor 75 | func test_selectSender_beforeConnectMessage_doesNotSendAnyMessage() async throws { 76 | try await component.selectSender(emailAddress: "user1@37signals.com") 77 | 78 | XCTAssertFalse(delegate.replyWithMessageWasCalled) 79 | XCTAssertNil(delegate.replyWithMessageArg) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/NavigatorDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Contract for handling navigation requests and actions 4 | /// - Note: Methods are __optional__ by default implementation in `NavigatorDelegate` extension. 5 | public protocol NavigatorDelegate: AnyObject { 6 | typealias RetryBlock = () -> Void 7 | 8 | /// Accept or reject a visit proposal. 9 | /// There are three `ProposalResult` cases: 10 | /// - term `accept`: Proposals are accepted and a new `VisitableViewController` is displayed. 11 | /// - term `acceptCustom(UIViewController)`: You may provide a view controller to be displayed, otherwise a new `VisitableViewController` is displayed. 12 | /// - term `reject`: No changes to navigation occur. 13 | /// 14 | /// - Parameter proposal: `VisitProposal` navigation destination 15 | /// - Parameter from: the `Navigator` receiving the proposal 16 | /// - Returns:`ProposalResult` - how to react to the visit proposal 17 | func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult 18 | 19 | /// An error occurred loading the request, present it to the user. 20 | /// Retry the request by executing the closure. 21 | /// - Important: If not implemented, will present the error's localized description and a Retry button. 22 | func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) 23 | 24 | /// Respond to authentication challenge presented by web servers behing basic auth. 25 | /// If not implemented, default handling will be performed. 26 | func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) 27 | 28 | /// Optional. Called after a form starts a submission. 29 | /// If not implemented, no action is taken. 30 | func formSubmissionDidStart(to url: URL) 31 | 32 | /// Optional. Called after a form finishes a submission. 33 | /// If not implemented, no action is taken. 34 | func formSubmissionDidFinish(at url: URL) 35 | 36 | /// Optional. Called after a request has completed. 37 | /// If not implemented, no action is taken. 38 | func requestDidFinish(at url: URL) 39 | } 40 | 41 | public extension NavigatorDelegate { 42 | func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult { 43 | .accept 44 | } 45 | 46 | func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) { 47 | if let errorPresenter = visitable as? ErrorPresenter { 48 | errorPresenter.presentError(error, retryHandler: retryHandler) 49 | } 50 | } 51 | 52 | func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { 53 | completionHandler(.performDefaultHandling, nil) 54 | } 55 | 56 | func formSubmissionDidStart(to url: URL) {} 57 | 58 | func formSubmissionDidFinish(at url: URL) {} 59 | 60 | func requestDidFinish(at url: URL) {} 61 | } 62 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Extensions/PathPropertiesExtensions.swift: -------------------------------------------------------------------------------- 1 | public extension PathProperties { 2 | var context: Navigation.Context { 3 | guard let rawValue = self["context"] as? String, 4 | let context = Navigation.Context(rawValue: rawValue) else { 5 | return .default 6 | } 7 | 8 | return context 9 | } 10 | 11 | var presentation: Navigation.Presentation { 12 | guard let rawValue = self["presentation"] as? String, 13 | let presentation = Navigation.Presentation(rawValue: rawValue) else { 14 | return .default 15 | } 16 | 17 | return presentation 18 | } 19 | 20 | var modalStyle: Navigation.ModalStyle { 21 | guard let rawValue = self["modal_style"] as? String, 22 | let modalStyle = Navigation.ModalStyle(rawValue: rawValue) else { 23 | return .large 24 | } 25 | 26 | return modalStyle 27 | } 28 | 29 | var pullToRefreshEnabled: Bool { 30 | self["pull_to_refresh_enabled"] as? Bool ?? true 31 | } 32 | 33 | var modalDismissGestureEnabled: Bool { 34 | self["modal_dismiss_gesture_enabled"] as? Bool ?? true 35 | } 36 | 37 | /// Used to identify a custom native view controller if provided in the path configuration properties of a given pattern. 38 | /// 39 | /// For example, given the following configuration file: 40 | /// 41 | /// ```json 42 | /// { 43 | /// "rules": [ 44 | /// { 45 | /// "patterns": [ 46 | /// "/recipes/*" 47 | /// ], 48 | /// "properties": { 49 | /// "view_controller": "recipes", 50 | /// } 51 | /// } 52 | /// ] 53 | /// } 54 | /// ``` 55 | /// 56 | /// A VisitProposal to `https://example.com/recipes/` will have 57 | /// ```swift 58 | /// proposal.viewController == "recipes" 59 | /// ``` 60 | /// 61 | /// - Important: A default value is provided in case the view controller property is missing from the configuration file. This will route the default `VisitableViewController`. 62 | /// - Note: A `ViewController` must conform to `PathConfigurationIdentifiable` to couple the identifier with a view controlelr. 63 | var viewController: String { 64 | guard let viewController = self["view_controller"] as? String else { 65 | return VisitableViewController.pathConfigurationIdentifier 66 | } 67 | 68 | return viewController 69 | } 70 | 71 | /// Allows the proposal to change the animation status when pushing, popping or presenting. 72 | var animated: Bool { 73 | self["animated"] as? Bool ?? true 74 | } 75 | 76 | internal var historicalLocation: Bool { 77 | self["historical_location"] as? Bool ?? false 78 | } 79 | 80 | var queryStringPresentation: Navigation.QueryStringPresentation { 81 | guard let rawValue = self["query_string_presentation"] as? String, 82 | let presentation = Navigation.QueryStringPresentation(rawValue: rawValue) else { 83 | return .default 84 | } 85 | 86 | return presentation 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Source/Turbo/Navigator/Helpers/ErrorPresenter.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public protocol ErrorPresenter: UIViewController { 4 | typealias Handler = () -> Void 5 | 6 | func presentError(_ error: Error, retryHandler: Handler?) 7 | } 8 | 9 | public extension ErrorPresenter { 10 | /// Presents an error in a full screen view. 11 | /// The error view will display a `Retry` button if `retryHandler != nil`. 12 | /// Tapping `Retry` will call `retryHandler?()` then dismiss the error. 13 | /// 14 | /// - Parameters: 15 | /// - error: presents the data in this error 16 | /// - retryHandler: a user-triggered action to perform in case the error is recoverable 17 | func presentError(_ error: Error, retryHandler: Handler?) { 18 | let errorView = ErrorView(error: error, shouldShowRetryButton: retryHandler != nil) { 19 | retryHandler?() 20 | self.removeErrorViewController() 21 | } 22 | 23 | let controller = UIHostingController(rootView: errorView) 24 | addChild(controller) 25 | addFullScreenSubview(controller.view) 26 | controller.didMove(toParent: self) 27 | } 28 | 29 | private func removeErrorViewController() { 30 | if let child = children.first(where: { $0 is UIHostingController }) { 31 | child.willMove(toParent: nil) 32 | child.view.removeFromSuperview() 33 | child.removeFromParent() 34 | } 35 | } 36 | } 37 | 38 | extension UIViewController: ErrorPresenter {} 39 | 40 | // MARK: Private 41 | 42 | private struct ErrorView: View { 43 | let error: Error 44 | let shouldShowRetryButton: Bool 45 | let handler: ErrorPresenter.Handler? 46 | 47 | var body: some View { 48 | VStack(spacing: 16) { 49 | Image(systemName: "exclamationmark.triangle") 50 | .font(.system(size: 38, weight: .semibold)) 51 | .foregroundColor(.accentColor) 52 | 53 | Text("Error loading page") 54 | .font(.largeTitle) 55 | 56 | Text(error.localizedDescription) 57 | .font(.body) 58 | .multilineTextAlignment(.center) 59 | 60 | if shouldShowRetryButton { 61 | Button("Retry") { 62 | handler?() 63 | } 64 | .font(.system(size: 17, weight: .bold)) 65 | } 66 | } 67 | .padding(32) 68 | } 69 | } 70 | 71 | private struct ErrorView_Previews: PreviewProvider { 72 | static var previews: some View { 73 | return ErrorView(error: NSError( 74 | domain: "com.example.error", 75 | code: 1001, 76 | userInfo: [NSLocalizedDescriptionKey: "Could not connect to the server."] 77 | ), shouldShowRetryButton: true) {} 78 | } 79 | } 80 | 81 | private extension UIViewController { 82 | func addFullScreenSubview(_ subview: UIView) { 83 | view.addSubview(subview) 84 | subview.translatesAutoresizingMaskIntoConstraints = false 85 | NSLayoutConstraint.activate([ 86 | subview.leadingAnchor.constraint(equalTo: view.leadingAnchor), 87 | subview.trailingAnchor.constraint(equalTo: view.trailingAnchor), 88 | subview.topAnchor.constraint(equalTo: view.topAnchor), 89 | subview.bottomAnchor.constraint(equalTo: view.bottomAnchor) 90 | ]) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting one of the project maintainers listed below. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Project Maintainers 69 | 70 | * Jay Ohms <> 71 | * Joe Masilotti <> 72 | 73 | ## Attribution 74 | 75 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 76 | available at [http://contributor-covenant.org/version/1/4][version] 77 | 78 | [homepage]: http://contributor-covenant.org 79 | [version]: http://contributor-covenant.org/version/1/4/ 80 | -------------------------------------------------------------------------------- /Source/Turbo/.swiftpm/xcode/xcshareddata/xcschemes/Turbo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 48 | 54 | 55 | 56 | 57 | 58 | 68 | 69 | 75 | 76 | 82 | 83 | 84 | 85 | 87 | 88 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /Source/Bridge/InternalMessage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebKit 3 | 4 | typealias InternalMessageData = [String: AnyHashable] 5 | 6 | struct InternalMessage { 7 | let id: String 8 | let component: String 9 | let event: String 10 | let data: InternalMessageData 11 | 12 | init(id: String, 13 | component: String, 14 | event: String, 15 | data: InternalMessageData) 16 | { 17 | self.id = id 18 | self.component = component 19 | self.event = event 20 | self.data = data 21 | } 22 | 23 | init(from message: Message) { 24 | let data = (message.jsonData.jsonObject() as? InternalMessageData) ?? [:] 25 | self.init(id: message.id, 26 | component: message.component, 27 | event: message.event, 28 | data: data) 29 | } 30 | 31 | init?(scriptMessage: WKScriptMessage) { 32 | guard let message = scriptMessage.body as? [String: AnyHashable] else { 33 | logger.warning("Script message is missing body: \(scriptMessage)") 34 | return nil 35 | } 36 | 37 | self.init(jsonObject: message) 38 | } 39 | 40 | init?(jsonObject: [String: AnyHashable]) { 41 | guard let id = jsonObject[CodingKeys.id.rawValue] as? String, 42 | let component = jsonObject[CodingKeys.component.rawValue] as? String, 43 | let event = jsonObject[CodingKeys.event.rawValue] as? String 44 | else { 45 | logger.error("Error parsing script message: \(jsonObject)") 46 | return nil 47 | } 48 | 49 | let data = (jsonObject[CodingKeys.data.rawValue] as? InternalMessageData) ?? [:] 50 | 51 | self.init(id: id, 52 | component: component, 53 | event: event, 54 | data: data) 55 | } 56 | 57 | // MARK: Utils 58 | 59 | func toMessage() -> Message { 60 | return Message(id: id, 61 | component: component, 62 | event: event, 63 | metadata: metadata(), 64 | jsonData: dataAsJSONString() ?? "{}") 65 | } 66 | 67 | /// Used internally for converting the message into a JSON-friendly format for sending over the bridge 68 | func toJSON() -> [String: AnyHashable] { 69 | [ 70 | CodingKeys.id.rawValue: id, 71 | CodingKeys.component.rawValue: component, 72 | CodingKeys.event.rawValue: event, 73 | CodingKeys.data.rawValue: data 74 | ] 75 | } 76 | 77 | // MARK: Private 78 | 79 | private func metadata() -> Message.Metadata? { 80 | guard let jsonData = data.jsonData(), 81 | let internalMetadata: InternalMessage.DataMetadata = try? jsonData.decoded() else { return nil } 82 | 83 | return Message.Metadata(url: internalMetadata.metadata.url) 84 | } 85 | 86 | private func dataAsJSONString() -> String? { 87 | guard let jsonData = data.jsonData() else { return nil } 88 | 89 | return String(data: jsonData, encoding: .utf8) 90 | } 91 | } 92 | 93 | extension InternalMessage { 94 | struct DataMetadata: Codable { 95 | let metadata: InternalMessage.Metadata 96 | } 97 | 98 | struct Metadata: Codable { 99 | let url: String 100 | } 101 | 102 | enum CodingKeys: String { 103 | case id 104 | case component 105 | case event 106 | case data 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Tests/Bridge/BridgeComponentAsyncAPITests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HotwireNative 3 | import WebKit 4 | import XCTest 5 | 6 | @MainActor 7 | class BridgeComponentAsyncAPITests: XCTestCase { 8 | private var delegate: BridgeDelegateSpy! 9 | private var destination: AppBridgeDestination! 10 | private var component: OneBridgeComponent! 11 | private let message = Message(id: "1", 12 | component: OneBridgeComponent.name, 13 | event: "connect", 14 | metadata: .init(url: "https://37signals.com"), 15 | jsonData: "{\"title\":\"Page-title\",\"subtitle\":\"Page-subtitle\"}") 16 | 17 | override func setUp() async throws { 18 | destination = AppBridgeDestination() 19 | delegate = BridgeDelegateSpy() 20 | component = OneBridgeComponent(destination: destination, delegate: delegate) 21 | component.didReceive(message: message) 22 | } 23 | 24 | func test_didReceiveCachesTheMessage() { 25 | let cachedMessage = component.receivedMessage(for: "connect") 26 | XCTAssertEqual(cachedMessage, message) 27 | } 28 | 29 | func test_replyWithNilDelegateReturnsFalse() async throws { 30 | component.delegate = nil 31 | let success = try await component.reply(to: "connect") 32 | 33 | XCTAssertFalse(success) 34 | } 35 | 36 | func test_replyToReceivedMessageSucceeds() async throws { 37 | let success = try await component.reply(to: "connect") 38 | 39 | XCTAssertTrue(success) 40 | XCTAssertTrue(delegate.replyWithMessageWasCalled) 41 | XCTAssertEqual(delegate.replyWithMessageArg, message) 42 | } 43 | 44 | func test_replyToReceivedMessageWithACodableObjectSucceeds() async throws { 45 | let messageData = MessageData(title: "hey", subtitle: "", actionName: "tap") 46 | let newJsonData = "{\"title\":\"hey\",\"subtitle\":\"\",\"actionName\":\"tap\"}" 47 | let newMessage = message.replacing(jsonData: newJsonData) 48 | 49 | let success = try await component.reply(to: "connect", with: messageData) 50 | 51 | XCTAssertTrue(success) 52 | XCTAssertTrue(delegate.replyWithMessageWasCalled) 53 | XCTAssertEqual(delegate.replyWithMessageArg, newMessage) 54 | } 55 | 56 | func test_replyToMessageNotReceivedWithACodableObjectIgnoresTheReply() async throws { 57 | let messageData = MessageData(title: "hey", subtitle: "", actionName: "tap") 58 | 59 | let success = try await component.reply(to: "disconnect", with: messageData) 60 | 61 | XCTAssertFalse(success) 62 | XCTAssertFalse(delegate.replyWithMessageWasCalled) 63 | XCTAssertNil(delegate.replyWithMessageArg) 64 | } 65 | 66 | func test_replyToMessageNotReceivedIgnoresTheReply() async throws { 67 | let success = try await component.reply(to: "disconnect") 68 | 69 | XCTAssertFalse(success) 70 | XCTAssertFalse(delegate.replyWithMessageWasCalled) 71 | XCTAssertNil(delegate.replyWithMessageArg) 72 | } 73 | 74 | func test_replyToMessageNotReceivedWithJsonDataIgnoresTheReply() async throws { 75 | let success = try await component.reply(to: "disconnect", with: "{\"title\":\"Page-title\"}") 76 | 77 | XCTAssertFalse(success) 78 | XCTAssertFalse(delegate.replyWithMessageWasCalled) 79 | XCTAssertNil(delegate.replyWithMessageArg) 80 | } 81 | 82 | func test_replyWithSucceedsWhenBridgeIsSet() async throws { 83 | let newJsonData = "{\"title\":\"Page-title\"}" 84 | let newMessage = message.replacing(jsonData: newJsonData) 85 | 86 | let success = try await component.reply(with: newMessage) 87 | 88 | XCTAssertTrue(success) 89 | XCTAssertTrue(delegate.replyWithMessageWasCalled) 90 | XCTAssertEqual(delegate.replyWithMessageArg, newMessage) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Tests/Bridge/BridgeDelegate+DestinationTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebKit 3 | import XCTest 4 | @testable import HotwireNative 5 | 6 | @MainActor 7 | class BridgeDelegateDestinationTests: XCTestCase { 8 | private var delegate: BridgeDelegate! 9 | private var destination: AppBridgeDestination! 10 | private var bridge: BridgeSpy! 11 | 12 | override func setUp() { 13 | destination = AppBridgeDestination() 14 | delegate = BridgeDelegate(location: "https://37signals.com", 15 | destination: destination, 16 | componentTypes: [BridgeComponentSpy.self]) 17 | 18 | bridge = BridgeSpy() 19 | delegate.bridge = bridge 20 | } 21 | 22 | // NOTE: viewDidLoad() is always called as the first view lifecycle method. 23 | // However, in some cases, such as in a tab bar controller, the view might not trigger `viewDidLoad()`, 24 | // yet it will still receive calls to `webViewDidBecomeActive(_)` and `webViewDidBecomeDeactivated()`. 25 | func testBridgeDestinationIsActiveAfterViewDidLoad() { 26 | delegate.onViewDidLoad() 27 | delegate.bridgeDidReceiveMessage(.test) 28 | 29 | let component: BridgeComponentSpy? = delegate.component() 30 | XCTAssertNotNil(component) 31 | } 32 | 33 | func testBridgeDestinationIsActiveAfterViewWillAppear() { 34 | delegate.onViewDidLoad() 35 | delegate.onViewWillAppear() 36 | delegate.bridgeDidReceiveMessage(.test) 37 | 38 | let component: BridgeComponentSpy? = delegate.component() 39 | XCTAssertNotNil(component) 40 | } 41 | 42 | func testBridgeDestinationIsActiveAfterViewDidAppear() { 43 | delegate.onViewDidLoad() 44 | delegate.onViewDidAppear() 45 | delegate.bridgeDidReceiveMessage(.test) 46 | 47 | let component: BridgeComponentSpy? = delegate.component() 48 | XCTAssertNotNil(component) 49 | } 50 | 51 | func testBridgeDestinationIsActiveAfterViewWillDisappear() { 52 | delegate.onViewDidLoad() 53 | delegate.onViewWillDisappear() 54 | delegate.bridgeDidReceiveMessage(.test) 55 | 56 | let component: BridgeComponentSpy? = delegate.component() 57 | XCTAssertNotNil(component) 58 | } 59 | 60 | func testBridgeDestinationIsInactiveAfterViewDidDisappear() { 61 | delegate.onViewDidLoad() 62 | delegate.onViewDidDisappear() 63 | delegate.bridgeDidReceiveMessage(.test) 64 | 65 | let component: BridgeComponentSpy? = delegate.component() 66 | XCTAssertNil(component) 67 | } 68 | 69 | func testBridgeDestinationIsActiveAfterWebViewDidBecomeActive() { 70 | delegate.webViewDidBecomeActive(WKWebView()) 71 | delegate.bridgeDidReceiveMessage(.test) 72 | 73 | let component: BridgeComponentSpy? = delegate.component() 74 | XCTAssertNotNil(component) 75 | } 76 | 77 | func testBridgeDestinationIsInactiveAfterWebViewBecomesDeactivated() { 78 | delegate.webViewDidBecomeDeactivated() 79 | delegate.bridgeDidReceiveMessage(.test) 80 | 81 | let component: BridgeComponentSpy? = delegate.component() 82 | XCTAssertNil(component) 83 | } 84 | 85 | func testBridgeDestinationIsNotifiedWhenComponentIsInitialized() { 86 | delegate.onViewDidLoad() 87 | delegate.bridgeDidReceiveMessage(.test) 88 | 89 | XCTAssertTrue(destination.onBridgeComponentInitializedWasCalled) 90 | XCTAssertTrue(destination.initializedBridgeComponent is BridgeComponentSpy) 91 | 92 | destination.onBridgeComponentInitializedWasCalled = false 93 | destination.initializedBridgeComponent = nil 94 | 95 | delegate.bridgeDidReceiveMessage(.test) 96 | 97 | XCTAssertFalse(destination.onBridgeComponentInitializedWasCalled) 98 | XCTAssertNil(destination.initializedBridgeComponent) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Tests/Turbo/Routing/RouterTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import XCTest 3 | 4 | final class RouterTests: XCTestCase { 5 | let navigatorConfiguration = Navigator.Configuration( 6 | name: "test", 7 | startLocation: URL(string: "https://my.app.com")! 8 | ) 9 | let url = URL(string: "https://my.app.com/page")! 10 | var router: Router! 11 | var navigator: Navigator! 12 | 13 | override func setUp() { 14 | navigator = Navigator(configuration: navigatorConfiguration) 15 | } 16 | 17 | func test_no_handlers_stops_navigation() { 18 | router = Router(decisionHandlers: []) 19 | 20 | let result = router.decideRoute( 21 | for: url, 22 | configuration: navigatorConfiguration, 23 | navigator: navigator 24 | ) 25 | 26 | XCTAssertEqual(result, Router.Decision.cancel) 27 | } 28 | 29 | func test_no_matching_handlers_stops_navigation() { 30 | let noMatchSpy1 = NoMatchRouteDecisionHandlerSpy() 31 | let noMatchSpy2 = NoMatchRouteDecisionHandlerSpy() 32 | 33 | router = Router( 34 | decisionHandlers: [ 35 | noMatchSpy1, 36 | noMatchSpy2 37 | ] 38 | ) 39 | 40 | let result = router.decideRoute( 41 | for: url, 42 | configuration: navigatorConfiguration, 43 | navigator: navigator 44 | ) 45 | 46 | XCTAssertTrue(noMatchSpy1.matchesWasCalled) 47 | XCTAssertFalse(noMatchSpy1.handleWasCalled) 48 | XCTAssertTrue(noMatchSpy2.matchesWasCalled) 49 | XCTAssertFalse(noMatchSpy2.handleWasCalled) 50 | XCTAssertEqual(result, Router.Decision.cancel) 51 | } 52 | 53 | func test_only_first_matching_handler_is_executed() { 54 | let noMatchSpy = NoMatchRouteDecisionHandlerSpy() 55 | let matchSpy1 = MatchRouteDecisionHandlerSpy() 56 | let matchSpy2 = MatchRouteDecisionHandlerSpy() 57 | 58 | router = Router( 59 | decisionHandlers: [ 60 | noMatchSpy, 61 | matchSpy1, 62 | matchSpy2 63 | ] 64 | ) 65 | 66 | let result = router.decideRoute( 67 | for: url, 68 | configuration: navigatorConfiguration, 69 | navigator: navigator 70 | ) 71 | 72 | XCTAssertTrue(noMatchSpy.matchesWasCalled) 73 | XCTAssertFalse(noMatchSpy.handleWasCalled) 74 | XCTAssertTrue(matchSpy1.matchesWasCalled) 75 | XCTAssertTrue(matchSpy1.handleWasCalled) 76 | XCTAssertFalse(matchSpy2.matchesWasCalled) 77 | XCTAssertFalse(matchSpy2.handleWasCalled) 78 | XCTAssertEqual(result, Router.Decision.navigate) 79 | } 80 | } 81 | 82 | final class NoMatchRouteDecisionHandlerSpy: RouteDecisionHandler { 83 | let name: String = "no-match-spy" 84 | var matchesWasCalled = false 85 | var handleWasCalled = false 86 | 87 | func matches(location: URL, configuration: HotwireNative.Navigator.Configuration) -> Bool { 88 | matchesWasCalled = true 89 | return false 90 | } 91 | 92 | func handle(location: URL, configuration: HotwireNative.Navigator.Configuration, navigator: HotwireNative.Navigator) -> HotwireNative.Router.Decision { 93 | handleWasCalled = true 94 | return .cancel 95 | } 96 | } 97 | 98 | final class MatchRouteDecisionHandlerSpy: RouteDecisionHandler { 99 | let name: String = "match-spy" 100 | var matchesWasCalled = false 101 | var handleWasCalled = false 102 | 103 | func matches(location: URL, configuration: HotwireNative.Navigator.Configuration) -> Bool { 104 | matchesWasCalled = true 105 | return true 106 | } 107 | 108 | func handle(location: URL, configuration: HotwireNative.Navigator.Configuration, navigator: HotwireNative.Navigator) -> HotwireNative.Router.Decision { 109 | handleWasCalled = true 110 | return .navigate 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Source/Turbo/Visit/JavaScriptVisit.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A `JavaScript` managed visit through the Hotwire library. 4 | /// All visits are `JavaScriptVisits` except the initial `ColdBootVisit` 5 | /// or if a `reload()` is issued. 6 | final class JavaScriptVisit: Visit { 7 | var identifier = "(pending)" 8 | 9 | init(visitable: Visitable, options: VisitOptions, bridge: WebViewBridge, restorationIdentifier: String?) { 10 | super.init(visitable: visitable, options: options, bridge: bridge) 11 | self.restorationIdentifier = restorationIdentifier 12 | } 13 | 14 | override var debugDescription: String { 15 | "" 16 | } 17 | 18 | override func startVisit() { 19 | log("startVisit") 20 | bridge.visitDelegate = self 21 | bridge.visitLocation(location, options: options, restorationIdentifier: restorationIdentifier) 22 | } 23 | 24 | override func cancelVisit() { 25 | log("cancelVisit") 26 | bridge.cancelVisit(withIdentifier: identifier) 27 | finishRequest() 28 | } 29 | 30 | override func failVisit() { 31 | log("failVisit") 32 | finishRequest() 33 | } 34 | } 35 | 36 | extension JavaScriptVisit: WebViewVisitDelegate { 37 | func webView(_ webView: WebViewBridge, didStartVisitWithIdentifier identifier: String, hasCachedSnapshot: Bool, isPageRefresh: Bool) { 38 | log("didStartVisitWithIdentifier", ["identifier": identifier, "hasCachedSnapshot": hasCachedSnapshot, "isPageRefresh": isPageRefresh]) 39 | self.identifier = identifier 40 | self.hasCachedSnapshot = hasCachedSnapshot 41 | self.isPageRefresh = isPageRefresh 42 | 43 | delegate?.visitDidStart(self) 44 | } 45 | 46 | func webView(_ webView: WebViewBridge, didStartRequestForVisitWithIdentifier identifier: String, date: Date) { 47 | guard identifier == self.identifier else { return } 48 | log("didStartRequestForVisitWithIdentifier", ["identifier": identifier, "date": date]) 49 | startRequest() 50 | } 51 | 52 | func webView(_ webView: WebViewBridge, didCompleteRequestForVisitWithIdentifier identifier: String) { 53 | guard identifier == self.identifier else { return } 54 | log("didCompleteRequestForVisitWithIdentifier", ["identifier": identifier]) 55 | 56 | if hasCachedSnapshot { 57 | delegate?.visitWillLoadResponse(self) 58 | } 59 | } 60 | 61 | func webView(_ webView: WebViewBridge, didFailRequestForVisitWithIdentifier identifier: String, statusCode: Int) { 62 | guard identifier == self.identifier else { return } 63 | 64 | log("didFailRequestForVisitWithIdentifier", ["identifier": identifier, "statusCode": statusCode]) 65 | fail(with: TurboError(statusCode: statusCode)) 66 | } 67 | 68 | func webView(_ webView: WebViewBridge, didFinishRequestForVisitWithIdentifier identifier: String, date: Date) { 69 | guard identifier == self.identifier else { return } 70 | 71 | log("didFinishRequestForVisitWithIdentifier", ["identifier": identifier, "date": date]) 72 | finishRequest() 73 | } 74 | 75 | func webView(_ webView: WebViewBridge, didRenderForVisitWithIdentifier identifier: String) { 76 | guard identifier == self.identifier else { return } 77 | 78 | log("didRenderForVisitWithIdentifier", ["identifier": identifier]) 79 | delegate?.visitDidRender(self) 80 | } 81 | 82 | func webView(_ webView: WebViewBridge, didCompleteVisitWithIdentifier identifier: String, restorationIdentifier: String) { 83 | guard identifier == self.identifier else { return } 84 | 85 | log("didCompleteVisitWithIdentifier", ["identifier": identifier, "restorationIdentifier": restorationIdentifier]) 86 | self.restorationIdentifier = restorationIdentifier 87 | complete() 88 | } 89 | 90 | private func log(_ name: String, _ arguments: [String: Any] = [:]) { 91 | logger.debug("[JavascriptVisit] \(name) \(self.location.absoluteString), \(arguments)") 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Source/Bridge/Strada.xcodeproj/xcshareddata/xcschemes/Strada.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 44 | 45 | 46 | 47 | 49 | 55 | 56 | 57 | 58 | 59 | 69 | 70 | 76 | 77 | 78 | 79 | 85 | 86 | 92 | 93 | 94 | 95 | 97 | 98 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /Tests/Bridge/InternalMessageTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HotwireNative 2 | import WebKit 3 | import XCTest 4 | 5 | class InternalMessageTests: XCTestCase { 6 | private let json = """ 7 | { 8 | "id":"1", 9 | "component":"page", 10 | "event":"connect", 11 | "data":{ 12 | "metadata":{ 13 | "url":"https://37signals.com" 14 | }, 15 | "title":"Page-title", 16 | "subtitle":"Page-subtitle", 17 | "actions": [ 18 | "one", 19 | "two", 20 | "three" 21 | ] 22 | } 23 | } 24 | """ 25 | func testToMessage() { 26 | let messageJsonData = """ 27 | { 28 | "metadata":{ 29 | "url":"https://37signals.com" 30 | }, 31 | "title":"Page-title", 32 | "subtitle":"Page-subtitle", 33 | "actions":[ 34 | "one", 35 | "two", 36 | "three" 37 | ] 38 | } 39 | """ 40 | let page = createPage() 41 | let pageData = try? JSONEncoder().encode(page) 42 | let pageJSON = try? JSONSerialization.jsonObject(with: pageData!) as? [String: AnyHashable] 43 | let internalMessage = InternalMessage(id: "1", 44 | component: "page", 45 | event: "connect", 46 | data: pageJSON!) 47 | let message = internalMessage.toMessage() 48 | 49 | XCTAssertEqual(message.id, "1") 50 | XCTAssertEqual(message.component, "page") 51 | XCTAssertEqual(message.event, "connect") 52 | XCTAssertEqual(message.metadata?.url, "https://37signals.com") 53 | 54 | let originalJSONObject = messageJsonData.jsonObject() as? [String: AnyHashable] 55 | let messageJSONObject = message.jsonData.jsonObject() as? [String: AnyHashable] 56 | XCTAssertEqual(originalJSONObject, messageJSONObject) 57 | } 58 | 59 | func testToJson() { 60 | let page = createPage() 61 | let pageData = try? JSONEncoder().encode(page) 62 | let pageJSON = try? JSONSerialization.jsonObject(with: pageData!) as? [String: AnyHashable] 63 | let message = InternalMessage(id: "1", 64 | component: "page", 65 | event: "connect", 66 | data: pageJSON!) 67 | 68 | let messageJSONObject = json.jsonObject() as? [String: AnyHashable] 69 | XCTAssertEqual(message.toJSON(), messageJSONObject) 70 | } 71 | 72 | func testFromJson() { 73 | let jsonObject = json.jsonObject() as! [String: AnyHashable] 74 | let message = InternalMessage(jsonObject: jsonObject) 75 | XCTAssertEqual(message?.id, "1") 76 | XCTAssertEqual(message?.component, "page") 77 | XCTAssertEqual(message?.event, "connect") 78 | 79 | let page: PageData? = try? message?.data.jsonData()?.decoded() 80 | XCTAssertEqual(page?.title, "Page-title") 81 | XCTAssertEqual(page?.subtitle, "Page-subtitle") 82 | XCTAssertEqual(page?.actions[0], "one") 83 | XCTAssertEqual(page?.actions[1], "two") 84 | XCTAssertEqual(page?.actions[2], "three") 85 | } 86 | 87 | func testFromJsonNoData() { 88 | let noDataJson = """ 89 | { 90 | "id":"1", 91 | "component":"page", 92 | "event":"connect" 93 | } 94 | """ 95 | let jsonObject = noDataJson.jsonObject() as! [String: AnyHashable] 96 | let message = InternalMessage(jsonObject: jsonObject) 97 | 98 | XCTAssertEqual(message?.id, "1") 99 | XCTAssertEqual(message?.data, [:]) 100 | } 101 | 102 | private func createPage() -> PageData { 103 | return PageData( 104 | metadata: InternalMessage.Metadata(url: "https://37signals.com"), 105 | title: "Page-title", 106 | subtitle: "Page-subtitle", 107 | actions: ["one", "two", "three"] 108 | ) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Source/Turbo/Path Configuration/PathConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias PathProperties = [String: AnyHashable] 4 | 5 | public protocol PathConfigurationDelegate: AnyObject { 6 | /// Notifies delegate when a path configuration has been updated with new data 7 | func pathConfigurationDidUpdate() 8 | } 9 | 10 | public struct PathConfigurationLoaderOptions { 11 | public init(urlSessionConfiguration: URLSessionConfiguration? = nil) { 12 | self.urlSessionConfiguration = urlSessionConfiguration 13 | } 14 | 15 | /// If present, the ``PathConfigurationLoader`` will initialize a new `URLSession` with 16 | /// this configuration to make its network request 17 | public let urlSessionConfiguration: URLSessionConfiguration? 18 | } 19 | 20 | public final class PathConfiguration { 21 | public weak var delegate: PathConfigurationDelegate? 22 | 23 | /// Enable to include the query string (in addition to the path) when applying rules. 24 | /// Disable to only consider the path when applying rules. 25 | public var matchQueryStrings = true 26 | 27 | /// Returns top-level settings: `{ settings: {} }` 28 | public private(set) var settings: [String: AnyHashable] = [:] 29 | 30 | /// The list of rules from the configuration: `{ rules: [] }` 31 | /// Default server route rules are included by default. 32 | public private(set) var rules: [PathRule] = PathRule.defaultServerRoutes 33 | 34 | /// Sources for this configuration, setting it will 35 | /// cause the configuration to be loaded from the new sources 36 | public var sources: [Source] = [] { 37 | didSet { 38 | load() 39 | } 40 | } 41 | 42 | /// Multiple sources will be loaded in order 43 | /// Remote sources should be last since they're loaded async 44 | public init(sources: [Source] = [], options: PathConfigurationLoaderOptions? = nil) { 45 | self.sources = sources 46 | self.options = options 47 | load() 48 | } 49 | 50 | /// Convenience method for getting properties for path: configuration["/path"] 51 | public subscript(path: String) -> PathProperties { 52 | properties(for: path) 53 | } 54 | 55 | /// Convenience method for retrieving properties for url: configuration[url] 56 | public subscript(url: URL) -> PathProperties { 57 | properties(for: url) 58 | } 59 | 60 | /// Returns a merged dictionary containing all the properties that match this URL. 61 | public func properties(for url: URL) -> PathProperties { 62 | if Hotwire.config.pathConfiguration.matchQueryStrings, let query = url.query { 63 | return properties(for: "\(url.path)?\(query)") 64 | } 65 | return properties(for: url.path) 66 | } 67 | 68 | /// Returns a merged dictionary containing all the properties 69 | /// that match this path 70 | public func properties(for path: String) -> PathProperties { 71 | var properties: PathProperties = [:] 72 | 73 | for rule in rules where rule.match(path: path) { 74 | properties.merge(rule.properties) { _, new in new } 75 | } 76 | 77 | return properties 78 | } 79 | 80 | // MARK: - Loading 81 | 82 | private let options: PathConfigurationLoaderOptions? 83 | 84 | private var loader: PathConfigurationLoader? 85 | 86 | private func load() { 87 | loader = PathConfigurationLoader(sources: sources, options: options) 88 | loader?.load { [weak self] config in 89 | self?.update(with: config) 90 | } 91 | } 92 | 93 | private func update(with config: PathConfigurationDecoder) { 94 | // Update our internal state with the config from the loader 95 | settings = config.settings 96 | rules = config.rules 97 | // Always include the default server route rules. 98 | rules.append(contentsOf: PathRule.defaultServerRoutes) 99 | delegate?.pathConfigurationDidUpdate() 100 | } 101 | } 102 | 103 | extension PathConfiguration: Equatable { 104 | public static func == (lhs: PathConfiguration, rhs: PathConfiguration) -> Bool { 105 | lhs.settings == rhs.settings && lhs.rules == rhs.rules 106 | } 107 | } 108 | 109 | public extension PathConfiguration { 110 | enum Source: Equatable { 111 | case data(Data) 112 | case file(URL) 113 | case server(URL) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Source/Turbo/ViewControllers/HotwireNavigationController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// The `HotwireNavigationController` is a custom subclass of `UINavigationController` designed to enhance the management of `VisitableViewController` instances within a navigation stack. 4 | /// It tracks the reasons why a view controller appears or disappears, which is crucial for handling navigation in Hotwire-powered applications. 5 | /// - Important: If you are using a custom or third-party navigation controller, subclass `HotwireNavigationController` to integrate its behavior. 6 | /// 7 | /// ## Usage Notes 8 | /// 9 | /// - **Integrating with Custom Navigation Controllers:** 10 | /// If you're using a custom or third-party navigation controller, subclass `HotwireNavigationController` to incorporate the necessary behavior. 11 | /// 12 | /// ```swift 13 | /// open class YourCustomNavigationController: HotwireNavigationController { 14 | /// // Make sure to always call super when overriding functions from `HotwireNavigationController`. 15 | /// } 16 | /// ``` 17 | /// 18 | /// - **Extensibility:** 19 | /// The class is marked as `open`, allowing you to subclass and extend its functionality to suit your specific needs. 20 | /// 21 | /// ## Limitations 22 | /// 23 | /// - **Other Container Controllers:** 24 | /// The current implementation focuses on `UINavigationController` and includes handling for `UITabBarController`. It does not provide out-of-the-box support for other container controllers like `UISplitViewController`. 25 | /// 26 | /// - **Custom Navigation Setups:** 27 | /// For completely custom navigation setups or container controllers, you will need to implement similar logic to manage the `appearReason` and `disappearReason` of `VisitableViewController` instances. 28 | open class HotwireNavigationController: UINavigationController { 29 | open override func pushViewController(_ viewController: UIViewController, animated: Bool) { 30 | if let visitableViewController = viewController as? VisitableViewController { 31 | visitableViewController.appearReason = .pushedOntoNavigationStack 32 | } 33 | 34 | if let topVisitableViewController = topViewController as? VisitableViewController { 35 | topVisitableViewController.disappearReason = .coveredByPush 36 | } 37 | 38 | super.pushViewController(viewController, animated: animated) 39 | } 40 | 41 | open override func popViewController(animated: Bool) -> UIViewController? { 42 | let poppedViewController = super.popViewController(animated: animated) 43 | if let poppedVisitableViewController = poppedViewController as? VisitableViewController { 44 | poppedVisitableViewController.disappearReason = .poppedFromNavigationStack 45 | } 46 | 47 | if let topVisitableViewController = topViewController as? VisitableViewController { 48 | topVisitableViewController.appearReason = .revealedByPop 49 | } 50 | 51 | return poppedViewController 52 | } 53 | 54 | open override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { 55 | if let topVisitableViewController = topViewController as? VisitableViewController { 56 | topVisitableViewController.appearReason = .revealedByModalDismiss 57 | } 58 | super.dismiss(animated: flag, completion: completion) 59 | } 60 | 61 | open override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { 62 | if let topVisitableViewController = topViewController as? VisitableViewController { 63 | topVisitableViewController.disappearReason = .coveredByModal 64 | } 65 | super.present(viewControllerToPresent, animated: flag, completion: completion) 66 | } 67 | 68 | open override func viewWillAppear(_ animated: Bool) { 69 | if let topVisitableViewController = topViewController as? VisitableViewController, 70 | topVisitableViewController.disappearReason == .tabDeselected { 71 | topVisitableViewController.appearReason = .tabSelected 72 | } 73 | super.viewWillAppear(animated) 74 | } 75 | 76 | open override func viewWillDisappear(_ animated: Bool) { 77 | if tabBarController != nil, 78 | let topVisitableViewController = topViewController as? VisitableViewController { 79 | topVisitableViewController.disappearReason = .tabDeselected 80 | } 81 | 82 | super.viewWillDisappear(animated) 83 | } 84 | } 85 | --------------------------------------------------------------------------------