Skip to content

Commit

Permalink
Add FXIOS-11322 #24638 [WebEngine] Native error page handling (#24645)
Browse files Browse the repository at this point in the history
* Add internal pages management in engine

* Handle error and surface error pages request to Client

* Try it out in SampleBrowser, fix SampleBrowser to build

* Show error page in sample browser

* Self review

* Add WKInternalSchemeHandlerTests

* Added unit tests

* Remove unused var

* Swiftlint

* Add explanations

* Use pinToSuperview
  • Loading branch information
lmarceau authored Feb 7, 2025
1 parent c2f5244 commit 054c4ef
Show file tree
Hide file tree
Showing 31 changed files with 603 additions and 28 deletions.
12 changes: 12 additions & 0 deletions BrowserKit/Sources/Common/Extensions/UIViewExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,16 @@ extension UIView {
public func addSubviews(_ views: UIView...) {
views.forEach(addSubview)
}

/// Convenience utility for pinning a subview to the bounds of its superview.
public func pinToSuperview() {
guard let parentView = superview else { return }
NSLayoutConstraint.activate([
topAnchor.constraint(equalTo: parentView.topAnchor),
leadingAnchor.constraint(equalTo: parentView.leadingAnchor),
trailingAnchor.constraint(equalTo: parentView.trailingAnchor),
bottomAnchor.constraint(equalTo: parentView.bottomAnchor)
])
translatesAutoresizingMaskIntoConstraints = false
}
}
2 changes: 1 addition & 1 deletion BrowserKit/Sources/WebEngine/EngineSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public protocol EngineSession {
func updatePageZoom(_ change: ZoomChangeValue)
}

extension EngineSession {
public extension EngineSession {
func reload(bypassCache: Bool = false) {
reload(bypassCache: bypassCache)
}
Expand Down
4 changes: 4 additions & 0 deletions BrowserKit/Sources/WebEngine/EngineSessionDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ public protocol EngineSessionDelegate: AnyObject {
/// Event to indicate that the page metadata was loaded or updated
func didLoad(pageMetadata: EnginePageMetadata)

/// Event to indicate the session encountered an error and a corresponding error page should be shown to the user
/// - Parameter error: The error the webpage encountered
func onErrorPageRequest(error: NSError)

// MARK: Menu items
/// Relates to adding native `UIMenuController.shared.menuItems` in webview textfields

Expand Down
17 changes: 17 additions & 0 deletions BrowserKit/Sources/WebEngine/WKWebview/Internal/InternalUtil.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import Foundation

/// Used to setup internal scheme handlers
struct InternalUtil {
func setUpInternalHandlers() {
let responders: [(String, WKInternalSchemeResponse)] =
[(WKAboutHomeHandler.path, WKAboutHomeHandler()),
(WKErrorPageHandler.path, WKErrorPageHandler())]
responders.forEach { (path, responder) in
WKInternalSchemeHandler.responders[path] = responder
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import WebKit

class WKAboutHomeHandler: WKInternalSchemeResponse {
static let path = "about/home"

// Return a blank page, the webview delegate will look at the current URL and load the home panel based on that
func response(forRequest request: URLRequest) -> (URLResponse, Data)? {
guard let url = request.url else { return nil }
let response = WKInternalSchemeHandler.response(forUrl: url)
// Blank page with a color matching the background of the panels which
// is displayed for a split-second until the panel shows.
let html = """
<!DOCTYPE html>
<html>
<body></body>
</html>
"""
guard let data = html.data(using: .utf8) else { return nil }
return (response, data)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import WebKit

class WKErrorPageHandler: WKInternalSchemeResponse {
static let path = WKInternalURL.Path.errorpage.rawValue

func response(forRequest request: URLRequest) -> (URLResponse, Data)? {
guard let url = request.url else { return nil }
let response = WKInternalSchemeHandler.response(forUrl: url)
// Blank page with a color matching the background of the panels which
// is displayed for a split-second until the panel shows.
let html = """
<!DOCTYPE html>
<html>
<body></body>
</html>
"""
guard let data = html.data(using: .utf8) else { return nil }
return (response, data)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import WebKit

enum WKInternalPageSchemeHandlerError: Error {
case badURL
case noResponder
case responderUnableToHandle
case notAuthorized
}

protocol WKInternalSchemeResponse {
func response(forRequest: URLRequest) -> (URLResponse, Data)?
}

/// Will load resources with URL schemes that WebKit doesn’t handle like homepage and error page.
class WKInternalSchemeHandler: NSObject, WKURLSchemeHandler {
public static let scheme = "internal"

static func response(forUrl url: URL) -> URLResponse {
return URLResponse(url: url, mimeType: "text/html", expectedContentLength: -1, textEncodingName: "utf-8")
}

// Responders are looked up based on the path component, for instance
// responder["about/home"] is used for 'internal://local/about/home'
static var responders = [String: WKInternalSchemeResponse]()

func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
guard let url = urlSchemeTask.request.url else {
urlSchemeTask.didFailWithError(WKInternalPageSchemeHandlerError.badURL)
return
}

let path = url.path.starts(with: "/") ? String(url.path.dropFirst()) : url.path

// If this is not a homepage or error page
if !urlSchemeTask.request.isPrivileged {
urlSchemeTask.didFailWithError(WKInternalPageSchemeHandlerError.notAuthorized)
return
}

guard let responder = WKInternalSchemeHandler.responders[path] else {
urlSchemeTask.didFailWithError(WKInternalPageSchemeHandlerError.noResponder)
return
}

guard let (urlResponse, data) = responder.response(forRequest: urlSchemeTask.request) else {
urlSchemeTask.didFailWithError(WKInternalPageSchemeHandlerError.responderUnableToHandle)
return
}

urlSchemeTask.didReceive(urlResponse)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}

func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {}
}
2 changes: 2 additions & 0 deletions BrowserKit/Sources/WebEngine/WKWebview/WKEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public class WKEngine: Engine {

init(userScriptManager: WKUserScriptManager = DefaultUserScriptManager()) {
self.userScriptManager = userScriptManager

InternalUtil().setUpInternalHandlers()
}

public func createView() -> EngineView {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,21 @@ protocol WKEngineConfigurationProvider {
struct DefaultWKEngineConfigurationProvider: WKEngineConfigurationProvider {
func createConfiguration() -> WKEngineConfiguration {
let configuration = WKWebViewConfiguration()
configuration.processPool = WKProcessPool()
// TODO: FXIOS-11324 Configure KeyBlockPopups
// let blockPopups = prefs?.boolForKey(PrefsKeys.KeyBlockPopups) ?? true
// configuration.preferences.javaScriptCanOpenWindowsAutomatically = !blockPopups
configuration.userContentController = WKUserContentController()
configuration.allowsInlineMediaPlayback = true
// TODO: FXIOS-11324 Configure isPrivate
// if isPrivate {
// configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent()
// } else {
// configuration.websiteDataStore = WKWebsiteDataStore.default()
// }

configuration.setURLSchemeHandler(WKInternalSchemeHandler(),
forURLScheme: WKInternalSchemeHandler.scheme)
return DefaultEngineConfiguration(webViewConfiguration: configuration)
}
}
6 changes: 6 additions & 0 deletions BrowserKit/Sources/WebEngine/WKWebview/WKEngineSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Foundation
protocol SessionHandler: AnyObject {
func commitURLChange()
func fetchMetadata(withURL url: URL)
func received(error: NSError, forURL url: URL)
}

class WKEngineSession: NSObject,
Expand Down Expand Up @@ -353,6 +354,11 @@ class WKEngineSession: NSObject,
metadataFetcher.fetch(fromSession: self, url: url)
}

func received(error: NSError, forURL url: URL) {
telemetryProxy?.handleTelemetry(event: .showErrorPage(errorCode: error.code))
delegate?.onErrorPageRequest(error: error)
}

// MARK: - Content scripts

private func addContentScripts() {
Expand Down
46 changes: 44 additions & 2 deletions BrowserKit/Sources/WebEngine/WKWebview/WKNavigationHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import Common
import WebKit

protocol WKNavigationHandler: WKNavigationDelegate {
Expand Down Expand Up @@ -51,6 +52,7 @@ protocol WKNavigationHandler: WKNavigationDelegate {
class DefaultNavigationHandler: NSObject, WKNavigationHandler {
weak var session: SessionHandler?
weak var telemetryProxy: EngineTelemetryProxy?
var logger: Logger = DefaultLogger.shared

func webView(_ webView: WKWebView,
didCommit navigation: WKNavigation?) {
Expand All @@ -74,17 +76,43 @@ class DefaultNavigationHandler: NSObject, WKNavigationHandler {
func webView(_ webView: WKWebView,
didFail navigation: WKNavigation?,
withError error: Error) {
logger.log("Error occurred during navigation.",
level: .warning,
category: .webview)

telemetryProxy?.handleTelemetry(event: .didFailNavigation)
telemetryProxy?.handleTelemetry(event: .pageLoadCancelled)
// TODO: FXIOS-8277 - Determine navigation calls with EngineSessionDelegate
}

func webView(_ webView: WKWebView,
didFailProvisionalNavigation navigation: WKNavigation?,
withError error: Error) {
logger.log("Error occurred during the early navigation process.",
level: .warning,
category: .webview)

telemetryProxy?.handleTelemetry(event: .didFailProvisionalNavigation)
telemetryProxy?.handleTelemetry(event: .pageLoadCancelled)
// TODO: FXIOS-8277 - Determine navigation calls with EngineSessionDelegate

// Ignore the "Frame load interrupted" error that is triggered when we cancel a request
// to open an external application and hand it over to UIApplication.openURL(). The result
// will be that we switch to the external app, for example the app store, while keeping the
// original web page in the tab instead of replacing it with an error page.
let error = error as NSError
if error.domain == "WebKitErrorDomain" && error.code == 102 {
return
}

guard !checkIfWebContentProcessHasCrashed(webView, error: error as NSError) else { return }

if error.code == Int(CFNetworkErrors.cfurlErrorCancelled.rawValue) {
session?.commitURLChange()
return
}

if let url = error.userInfo[NSURLErrorFailingURLErrorKey] as? URL {
session?.received(error: error, forURL: url)
}
}

func webView(_ webView: WKWebView,
Expand Down Expand Up @@ -124,4 +152,18 @@ class DefaultNavigationHandler: NSObject, WKNavigationHandler {
// TODO: FXIOS-8276 - Handle didReceive challenge: URLAuthenticationChallenge (epic part 3)
completionHandler(.performDefaultHandling, nil)
}

// MARK: - Helper methods

private func checkIfWebContentProcessHasCrashed(_ webView: WKWebView, error: NSError) -> Bool {
if error.code == WKError.webContentProcessTerminated.rawValue && error.domain == "WebKitErrorDomain" {
logger.log("WebContent process has crashed. Trying to reload to restart it.",
level: .warning,
category: .webview)
webView.reload()
return true
}

return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class MockEngineSessionDelegate: EngineSessionDelegate {
var onLocationChangedCalled = 0
var onHasOnlySecureContentCalled = 0
var didLoadPagemetaDataCalled = 0
var onErrorPageCalled = 0
var findInPageCalled = 0
var searchCalled = 0
var onProvideContextualMenuCalled = 0
Expand All @@ -27,6 +28,7 @@ class MockEngineSessionDelegate: EngineSessionDelegate {
var savedCanGoForward: Bool?
var savedLoading: Bool?
var savedPagemetaData: EnginePageMetadata?
var savedError: NSError?
var savedFindInPageSelection: String?
var savedSearchSelection: String?

Expand Down Expand Up @@ -66,6 +68,11 @@ class MockEngineSessionDelegate: EngineSessionDelegate {
savedPagemetaData = pageMetadata
}

func onErrorPageRequest(error: NSError) {
savedError = error
onErrorPageCalled += 1
}

func findInPage(with selection: String) {
findInPageCalled += 1
savedFindInPageSelection = selection
Expand Down
14 changes: 14 additions & 0 deletions BrowserKit/Tests/WebEngineTests/Mock/MockSchemeHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import Foundation
@testable import WebEngine

class MockSchemeHandler: WKInternalSchemeResponse {
static let path = "about/test"

func response(forRequest request: URLRequest) -> (URLResponse, Data)? {
return nil
}
}
29 changes: 29 additions & 0 deletions BrowserKit/Tests/WebEngineTests/Mock/MockSessionHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import Foundation
@testable import WebEngine

class MockSessionHandler: SessionHandler {
var commitURLChangeCalled = 0
var fetchMetadataCalled = 0
var receivedErrorCalled = 0
var savedError: NSError?
var savedURL: URL?

func commitURLChange() {
commitURLChangeCalled += 1
}

func fetchMetadata(withURL url: URL) {
savedURL = url
fetchMetadataCalled += 1
}

func received(error: NSError, forURL url: URL) {
savedError = error
savedURL = url
receivedErrorCalled += 1
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import UIKit
import WebKit
@testable import WebEngine

/// Necessary since some methods of `WKWebView` cannot be overriden. An abstraction need to be used to be able
/// to mock all methods.
class MockWKEngineWebView: UIView, WKEngineWebView {
var delegate: WKEngineWebViewDelegate?
var uiDelegate: WKUIDelegate?
Expand Down
Loading

0 comments on commit 054c4ef

Please sign in to comment.