() -> 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 | 
4 | 
5 | 
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 |
--------------------------------------------------------------------------------