From 3398de456c6426d97061be71b293210e19877528 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Thu, 28 Mar 2024 13:24:02 -0400 Subject: [PATCH 1/4] Initial work to detect cross-origin redirect visit requests --- Source/Session/Session.swift | 4 ++++ Source/WebView/ScriptMessage.swift | 1 + Source/WebView/WebViewBridge.swift | 3 +++ Source/WebView/turbo.js | 29 +++++++++++++++++++++++++++-- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/Source/Session/Session.swift b/Source/Session/Session.swift index 3ed74cd..6dbeeb8 100644 --- a/Source/Session/Session.swift +++ b/Source/Session/Session.swift @@ -310,6 +310,10 @@ extension Session: WebViewDelegate { delegate?.session(self, didProposeVisit: proposal) } + func webView(_ bridge: WebViewBridge, didProposeVisitToCrossOriginRedirect location: URL) { + // TODO + } + func webView(_ webView: WebViewBridge, didStartFormSubmissionToLocation location: URL) { delegate?.sessionDidStartFormSubmission(self) } diff --git a/Source/WebView/ScriptMessage.swift b/Source/WebView/ScriptMessage.swift index c0abd71..0a388cc 100644 --- a/Source/WebView/ScriptMessage.swift +++ b/Source/WebView/ScriptMessage.swift @@ -52,6 +52,7 @@ extension ScriptMessage { case pageLoadFailed case errorRaised case visitProposed + case visitProposedToCrossOriginRedirect case visitProposalScrollingToAnchor case visitProposalRefreshingPage case visitStarted diff --git a/Source/WebView/WebViewBridge.swift b/Source/WebView/WebViewBridge.swift index 74c23fb..05e6dae 100644 --- a/Source/WebView/WebViewBridge.swift +++ b/Source/WebView/WebViewBridge.swift @@ -2,6 +2,7 @@ import WebKit protocol WebViewDelegate: AnyObject { func webView(_ webView: WebViewBridge, didProposeVisitToLocation location: URL, options: VisitOptions) + func webView(_ webView: WebViewBridge, didProposeVisitToCrossOriginRedirect location: URL) func webViewDidInvalidatePage(_ webView: WebViewBridge) func webView(_ webView: WebViewBridge, didStartFormSubmissionToLocation location: URL) func webView(_ webView: WebViewBridge, didFinishFormSubmissionToLocation location: URL) @@ -129,6 +130,8 @@ extension WebViewBridge: ScriptMessageHandlerDelegate { delegate?.webViewDidInvalidatePage(self) case .visitProposed: delegate?.webView(self, didProposeVisitToLocation: message.location!, options: message.options!) + case .visitProposedToCrossOriginRedirect: + delegate?.webView(self, didProposeVisitToCrossOriginRedirect: message.location!) case .visitProposalScrollingToAnchor: break case .visitProposalRefreshingPage: diff --git a/Source/WebView/turbo.js b/Source/WebView/turbo.js index b9299bd..000563e 100644 --- a/Source/WebView/turbo.js +++ b/Source/WebView/turbo.js @@ -141,8 +141,18 @@ this.loadResponseForVisitWithIdentifier(visit.identifier) } - visitRequestFailedWithStatusCode(visit, statusCode) { - this.postMessage("visitRequestFailed", { identifier: visit.identifier, statusCode: statusCode }) + async visitRequestFailedWithStatusCode(visit, statusCode) { + // Turbo does not permit cross-origin fetch redirect attempts and + // they'll lead to a visit request failure. Attempt to see if the + // visit request failure was due to a cross-origin redirect. + const redirect = await this.fetchFailedRequestCrossOriginRedirect(visit, statusCode) + const location = visit.location.toString() + + if (redirect != null) { + this.postMessage("visitProposedToCrossOriginRedirect", { location: redirect.toString(), identifier: visit.identifier }) + } else { + this.postMessage("visitRequestFailed", { location: location, identifier: visit.identifier, statusCode: statusCode }) + } } visitRequestFinished(visit) { @@ -174,6 +184,21 @@ } // Private + + async fetchFailedRequestCrossOriginRedirect(visit, statusCode) { + // Non-HTTP status codes are sent by Turbo for network + // failures, including cross-origin fetch redirect attempts. + if (statusCode <= 0) { + try { + const response = await fetch(visit.location, { redirect: "follow" }) + if (response.url != null && response.url.origin != visit.location.origin) { + return response.url + } + } catch {} + } + + return null + } postMessage(name, data = {}) { data["timestamp"] = Date.now() From 171ceabe116623a18206e2887615b6f3bb007b69 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Thu, 28 Mar 2024 14:02:00 -0400 Subject: [PATCH 2/4] Remove the current visitable from the backstack before opening the cross-origin redirect url --- Source/Session/Session.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Source/Session/Session.swift b/Source/Session/Session.swift index 6dbeeb8..3e47f37 100644 --- a/Source/Session/Session.swift +++ b/Source/Session/Session.swift @@ -311,7 +311,10 @@ extension Session: WebViewDelegate { } func webView(_ bridge: WebViewBridge, didProposeVisitToCrossOriginRedirect location: URL) { - // TODO + // Remove the current visitable from the backstack since it + // resulted in a visit failure due to a cross-origin redirect. + activatedVisitable?.visitableViewController.navigationController?.popViewController(animated: false) + openExternalURL(location) } func webView(_ webView: WebViewBridge, didStartFormSubmissionToLocation location: URL) { From cef4f7e6c86c8779ef869743603f1fa108f4fb55 Mon Sep 17 00:00:00 2001 From: Mattias Pfeiffer Date: Mon, 22 Apr 2024 10:36:34 +0200 Subject: [PATCH 3/4] Add delegate to signal cross-origin redirect --- Source/Session/Session.swift | 5 +---- Source/Session/SessionDelegate.swift | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/Session/Session.swift b/Source/Session/Session.swift index 3e47f37..5638b12 100644 --- a/Source/Session/Session.swift +++ b/Source/Session/Session.swift @@ -311,10 +311,7 @@ extension Session: WebViewDelegate { } func webView(_ bridge: WebViewBridge, didProposeVisitToCrossOriginRedirect location: URL) { - // Remove the current visitable from the backstack since it - // resulted in a visit failure due to a cross-origin redirect. - activatedVisitable?.visitableViewController.navigationController?.popViewController(animated: false) - openExternalURL(location) + delegate?.session(self, didProposeVisitToCrossOriginRedirect: location) } func webView(_ webView: WebViewBridge, didStartFormSubmissionToLocation location: URL) { diff --git a/Source/Session/SessionDelegate.swift b/Source/Session/SessionDelegate.swift index 6920e88..a09ee4f 100644 --- a/Source/Session/SessionDelegate.swift +++ b/Source/Session/SessionDelegate.swift @@ -2,6 +2,7 @@ import UIKit public protocol SessionDelegate: AnyObject { func session(_ session: Session, didProposeVisit proposal: VisitProposal) + func session(_ session: Session, didProposeVisitToCrossOriginRedirect url: URL) func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) func session(_ session: Session, openExternalURL url: URL) @@ -24,6 +25,8 @@ public extension SessionDelegate { func session(_ session: Session, openExternalURL url: URL) { UIApplication.shared.open(url) } + + func session(_ session: Session, didProposeVisitToCrossOriginRedirect url: URL) {} func sessionDidStartRequest(_ session: Session) {} func sessionDidFinishRequest(_ session: Session) {} From 9040abd0a9f5c09bb533de57b41daa7b2fa69f5e Mon Sep 17 00:00:00 2001 From: Mattias Pfeiffer Date: Mon, 22 Apr 2024 10:44:04 +0200 Subject: [PATCH 4/4] Document the `didProposeVisitToCrossOriginRedirect` delegate --- Docs/Overview.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Docs/Overview.md b/Docs/Overview.md index e4a98d9..94790f8 100644 --- a/Docs/Overview.md +++ b/Docs/Overview.md @@ -104,6 +104,15 @@ The default `Action` is `.advance`. In most cases you’ll respond to an advance When you follow a link annotated with `data-turbo-action="replace"`, the proposed Action will be `.replace`. Usually you’ll want to handle a replace visit by replacing the top-most visible view controller with a new one instead of pushing. +### Responding to cross-origin redirects + +When a Visit action results in a cross-origin redirect that Turbo iOS is not able to handle due to the cross-origin nature, your Session's delegate can handle this by implementing the `session:didProposeVisitToCrossOriginRedirect`. Depending on your navigation structure, you could choose to pop the view controller and open the URL in a an external browser, for example: + +```swift +navigationController.popViewController(animated: false) +UIApplication.shared.open(url) +``` + ## Handling Failed Requests Turbo iOS calls the `session:didFailRequestForVisitable:error:` method when a visit request fails. This might be because of a network error, or because the server returned an HTTP 4xx or 5xx status code. If it was a network error in the main cold boot visit, it will be the `NSError` returned by WebKit. If it was a HTTP error or a network error from a JavaScript visit the error will be a `TurboError` and you can retrieve the status code.