Skip to content
This repository has been archived by the owner on Oct 10, 2024. It is now read-only.

Commit

Permalink
Automatic error handling (#45)
Browse files Browse the repository at this point in the history
* Bump demo app to iOS 14

* Convert error view to SwiftUI

* Move ErrorPresenter in and make delegate optional

* reload -> retry

* Make ErrorPresenter public

* Document error handling

* [skip ci] Update CHANGELOG
  • Loading branch information
joemasilotti authored Sep 1, 2023
1 parent 847bc87 commit e069cfb
Show file tree
Hide file tree
Showing 8 changed files with 125 additions and 133 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Pass a configuration to the web view create block by @seanpdoyle [#41](https://github.com/joemasilotti/TurboNavigator/pull/41)
* Option to override `sessionDidLoadWebView(_:)` by @yanshiyason [#35](https://github.com/joemasilotti/TurboNavigator/pull/35)
* Handle `/resume_historical_location` route [#38](https://github.com/joemasilotti/TurboNavigator/pull/38)
* Automatically handle errors with option to override [#45](https://github.com/joemasilotti/TurboNavigator/pull/45)

### Breaking changes

Expand Down
6 changes: 2 additions & 4 deletions Demo/Demo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

/* Begin PBXBuildFile section */
8462832529B8C6B5004EFC9A /* TurboNavigator in Frameworks */ = {isa = PBXBuildFile; productRef = 8462832429B8C6B5004EFC9A /* TurboNavigator */; };
8462832729B90BB9004EFC9A /* ErrorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8462832629B90BB9004EFC9A /* ErrorPresenter.swift */; };
8484C2E929B132D20018596C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8484C2E829B132D20018596C /* AppDelegate.swift */; };
8484C2EB29B132D20018596C /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8484C2EA29B132D20018596C /* SceneDelegate.swift */; };
8484C2F229B132D30018596C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8484C2F129B132D30018596C /* Assets.xcassets */; };
Expand All @@ -17,7 +16,6 @@

/* Begin PBXFileReference section */
8462832329B8C6A7004EFC9A /* TurboNavigator */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TurboNavigator; path = ..; sourceTree = "<group>"; };
8462832629B90BB9004EFC9A /* ErrorPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorPresenter.swift; sourceTree = "<group>"; };
8484C2E529B132D20018596C /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; };
8484C2E829B132D20018596C /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
8484C2EA29B132D20018596C /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -61,7 +59,6 @@
children = (
8484C2F629B132D30018596C /* Info.plist */,
8484C2E829B132D20018596C /* AppDelegate.swift */,
8462832629B90BB9004EFC9A /* ErrorPresenter.swift */,
8484C2EA29B132D20018596C /* SceneDelegate.swift */,
8484C2F129B132D30018596C /* Assets.xcassets */,
8484C2F329B132D30018596C /* LaunchScreen.storyboard */,
Expand Down Expand Up @@ -161,7 +158,6 @@
files = (
8484C2E929B132D20018596C /* AppDelegate.swift in Sources */,
8484C2EB29B132D20018596C /* SceneDelegate.swift in Sources */,
8462832729B90BB9004EFC9A /* ErrorPresenter.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -306,6 +302,7 @@
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand Down Expand Up @@ -335,6 +332,7 @@
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand Down
115 changes: 0 additions & 115 deletions Demo/Demo/ErrorPresenter.swift

This file was deleted.

12 changes: 2 additions & 10 deletions Demo/Demo/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
private let baseURL = URL(string: "http://localhost:3000")!
private lazy var turboNavigator = TurboNavigator(delegate: self, pathConfiguration: pathConfiguration)
private lazy var pathConfiguration = PathConfiguration(sources: [
.server(baseURL.appending(path: "/configuration"))
.server(baseURL.appendingPathComponent("/configuration"))
])

private func createWindow(in windowScene: UIWindowScene) {
Expand All @@ -33,12 +33,4 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
}

extension SceneDelegate: TurboNavigationDelegate {
func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {
if let errorPresenter = visitable as? ErrorPresenter {
errorPresenter.presentError(error) {
session.reload()
}
}
}
}
extension SceneDelegate: TurboNavigationDelegate {}
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,23 @@ class MyCustomClass: TurboNavigationDelegate {
}
}
```

### Customized error handling

By default, Turbo Navigator will automatically handle any errors that occur when performing visits. The error's localized description and a button to retry the request is displayed.

You can customize the error handling by overriding the following delegate method.

```swift
extension MyCustomClass: TurboNavigationDelegate {
func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) {
if case let TurboError.http(statusCode) = error, statusCode == 401 {
// Custom error handling for 401 responses.
} else if let errorPresenter = visitable as? ErrorPresenter {
errorPresenter.presentError(error) {
retry()
}
}
}
}
```
83 changes: 83 additions & 0 deletions Sources/ErrorPresenter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import SwiftUI
import UIKit

public protocol ErrorPresenter: UIViewController {
typealias Handler = () -> Void

func presentError(_ error: Error, handler: @escaping Handler)
}

public extension ErrorPresenter {
func presentError(_ error: Error, handler: @escaping () -> Void) {
let errorView = ErrorView(error: error) { [unowned self] in
handler()
self.removeErrorViewController()
}

let controller = UIHostingController(rootView: errorView)
addChild(controller)
addFullScreenSubview(controller.view)
controller.didMove(toParent: self)
}

private func removeErrorViewController() {
if let child = children.first(where: { $0 is UIHostingController<ErrorView> }) {
child.willMove(toParent: nil)
child.view.removeFromSuperview()
child.removeFromParent()
}
}
}

extension UIViewController: ErrorPresenter {}

// MARK: Private

private struct ErrorView: View {
let error: Error
let handler: ErrorPresenter.Handler?

var body: some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 38, weight: .semibold))
.foregroundColor(.accentColor)

Text("Error loading page")
.font(.largeTitle)

Text(error.localizedDescription)
.font(.body)
.multilineTextAlignment(.center)

Button("Retry") {
handler?()
}
.font(.system(size: 17, weight: .bold))
}
.padding(32)
}
}

private struct ErrorView_Previews: PreviewProvider {
static var previews: some View {
return ErrorView(error: NSError(
domain: "com.example.error",
code: 1001,
userInfo: [NSLocalizedDescriptionKey: "Could not connect to the server."]
)) {}
}
}

private extension UIViewController {
func addFullScreenSubview(_ subview: UIView) {
view.addSubview(subview)
subview.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: view.leadingAnchor),
subview.trailingAnchor.constraint(equalTo: view.trailingAnchor),
subview.topAnchor.constraint(equalTo: view.topAnchor),
subview.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}
17 changes: 14 additions & 3 deletions Sources/TurboNavigationDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import WebKit
/// Implement to be notified when certain navigations are performed
/// or to render a native controller instead of a Turbo web visit.
public protocol TurboNavigationDelegate: AnyObject {
/// An error occurred loading the request, present it to the user.
/// Retry the request by calling `session.reload()`.
func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error)
typealias RetryBlock = () -> Void

/// Respond to authentication challenge presented by web servers behing basic auth.
func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
Expand All @@ -16,6 +14,11 @@ public protocol TurboNavigationDelegate: AnyObject {
/// Return `nil` to not display or route anything.
func controller(_ controller: VisitableViewController, forProposal proposal: VisitProposal) -> UIViewController?

/// Optional. An error occurred loading the request, present it to the user.
/// Retry the request by executing the closure.
/// If not implemented, will present the error's localized description and a Retry button.
func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock)

/// Optional. Implement to customize handling of external URLs.
/// If not implemented, will present `SFSafariViewController` as a modal and load the URL.
func openExternalURL(_ url: URL, from controller: UIViewController)
Expand All @@ -30,6 +33,14 @@ public extension TurboNavigationDelegate {
VisitableViewController(url: proposal.url)
}

func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) {
if let errorPresenter = visitable as? ErrorPresenter {
errorPresenter.presentError(error) {
retry()
}
}
}

func openExternalURL(_ url: URL, from controller: UIViewController) {
let safariViewController = SFSafariViewController(url: url)
safariViewController.modalPresentationStyle = .pageSheet
Expand Down
4 changes: 3 additions & 1 deletion Sources/TurboNavigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ extension TurboNavigator: SessionDelegate {
}

public func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {
delegate.session(session, didFailRequestForVisitable: visitable, error: error)
delegate.visitableDidFailRequest(visitable, error: error) {
session.reload()
}
}

public func sessionWebViewProcessDidTerminate(_ session: Session) {
Expand Down

0 comments on commit e069cfb

Please sign in to comment.