From 483cc64a2ef1497541bf4b8672fee9956ed95e5b Mon Sep 17 00:00:00 2001 From: Jonas Pardeyke Date: Thu, 9 Jul 2026 18:52:32 +0200 Subject: [PATCH] Open same-host non-HTTP(S) URLs via the system AppNavigationRouteDecisionHandler matched on host alone, so a same-host URL with a custom scheme (e.g. webcal://my.app.com/calendar.ics) was routed as an in-app Turbo visit, which fails: Turbo.js cannot history.pushState() across protocols and the visit ends with error 0. Require an HTTP(S) scheme in AppNavigationRouteDecisionHandler and make SystemNavigationRouteDecisionHandler claim non-HTTP(S) URLs regardless of host, so the system opens them like it already does for sms: and mailto: links on other hosts. Co-Authored-By: Claude Fable 5 --- .../Handlers/AppNavigationRouteDecisionHandler.swift | 8 ++++++++ .../Handlers/SystemNavigationRouteDecisionHandler.swift | 7 +++++++ .../Routing/AppNavigationRouteDecisionHandlerTest.swift | 7 +++++++ .../SystemNavigationRouteDecisionHandlerTest.swift | 7 +++++++ 4 files changed, 29 insertions(+) diff --git a/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift b/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift index 859d17a..838dfdd 100644 --- a/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift +++ b/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift @@ -8,6 +8,14 @@ public final class AppNavigationRouteDecisionHandler: RouteDecisionHandler { public func matches(proposal: VisitProposal, configuration: Navigator.Configuration) -> Bool { let location = proposal.url + + /// Only HTTP(S) URLs can be routed in-app. A same-host URL with a custom + /// scheme (e.g. `webcal://my.app.com/calendar.ics`) must fall through to + /// `SystemNavigationRouteDecisionHandler` so the system can open it. + guard location.scheme == "http" || location.scheme == "https" else { + return false + } + if #available(iOS 16, *) { return configuration.startLocation.host() == location.host() } diff --git a/Source/Turbo/Navigator/Routing/Handlers/SystemNavigationRouteDecisionHandler.swift b/Source/Turbo/Navigator/Routing/Handlers/SystemNavigationRouteDecisionHandler.swift index 162b403..818be4a 100644 --- a/Source/Turbo/Navigator/Routing/Handlers/SystemNavigationRouteDecisionHandler.swift +++ b/Source/Turbo/Navigator/Routing/Handlers/SystemNavigationRouteDecisionHandler.swift @@ -10,6 +10,13 @@ public final class SystemNavigationRouteDecisionHandler: RouteDecisionHandler { public func matches(proposal: VisitProposal, configuration: Navigator.Configuration) -> Bool { let location = proposal.url + + /// Non-HTTP(S) URLs (webcal:, mailto:, sms:, ...) are always opened via + /// the system, even when they point at the app's own host. + guard location.scheme == "http" || location.scheme == "https" else { + return true + } + if #available(iOS 16, *) { return configuration.startLocation.host() != location.host() } diff --git a/Tests/Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift b/Tests/Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift index ae11b11..43302cd 100644 --- a/Tests/Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift +++ b/Tests/Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift @@ -38,6 +38,13 @@ final class AppNavigationRouteDecisionHandlerTest: XCTestCase { XCTAssertFalse(result) } + func test_non_http_scheme_on_app_domain_does_not_match() { + let url = URL(string: "webcal://my.app.com/calendars/subscribe.ics?token=123")! + let result = route.matches(proposal: proposal(for: url), configuration: navigatorConfiguration) + + XCTAssertFalse(result) + } + func test_masqueraded_url_does_not_match() { let url = URL(string: "https://app.my.com@fake.domain")! let result = route.matches(proposal: proposal(for: url), configuration: navigatorConfiguration) diff --git a/Tests/Turbo/Routing/SystemNavigationRouteDecisionHandlerTest.swift b/Tests/Turbo/Routing/SystemNavigationRouteDecisionHandlerTest.swift index 8965a34..5ea0f14 100644 --- a/Tests/Turbo/Routing/SystemNavigationRouteDecisionHandlerTest.swift +++ b/Tests/Turbo/Routing/SystemNavigationRouteDecisionHandlerTest.swift @@ -52,6 +52,13 @@ final class SystemNavigationRouteDecisionHandlerTests: XCTestCase { XCTAssertTrue(result) } + func test_non_http_scheme_on_app_domain_matches() { + let url = URL(string: "webcal://my.app.com/calendars/subscribe.ics?token=123")! + let result = route.matches(proposal: proposal(for: url), configuration: navigatorConfiguration) + + XCTAssertTrue(result) + } + func test_sms_urls_match() { let url = URL(string: "sms:1-408-555-1212")! let result = route.matches(proposal: proposal(for: url), configuration: navigatorConfiguration)