Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add FXIOS-11322 #24638 [WebEngine] Native error page handling #24645

Merged
merged 11 commits into from
Feb 7, 2025
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
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)
}
}
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
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