From 054c4ef4df2ba164bb85a8f74e9f6f266ffce295 Mon Sep 17 00:00:00 2001 From: lmarceau Date: Fri, 7 Feb 2025 17:03:22 -0500 Subject: [PATCH] Add FXIOS-11322 #24638 [WebEngine] Native error page handling (#24645) * 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 --- .../Common/Extensions/UIViewExtension.swift | 12 +++ .../Sources/WebEngine/EngineSession.swift | 2 +- .../WebEngine/EngineSessionDelegate.swift | 4 + .../WKWebview/Internal/InternalUtil.swift | 17 +++ .../Internal/WKAboutHomeHandler.swift | 25 +++++ .../Internal/WKErrorPageHandler.swift | 24 +++++ .../Internal/WKInternalSchemeHandler.swift | 60 +++++++++++ .../{ => Internal}/WKInternalURL.swift | 0 .../WebEngine/WKWebview/WKEngine.swift | 2 + .../WKEngineConfigurationProvider.swift | 12 +++ .../WebEngine/WKWebview/WKEngineSession.swift | 6 ++ .../WKWebview/WKNavigationHandler.swift | 46 +++++++- .../Mock/MockEngineSessionDelegate.swift | 7 ++ .../Mock/MockSchemeHandler.swift | 14 +++ .../Mock/MockSessionHandler.swift | 29 +++++ .../Mock/MockWKEngineWebView.swift | 2 + .../WebEngineTests/Mock/MockWKWebView.swift | 28 +++++ .../Mock/WKURLSchemeTaskMock.swift | 40 +++++++ .../WebEngineTests/WKEngineSessionTests.swift | 11 ++ .../WKInternalSchemeHandlerTests.swift | 88 +++++++++++++++ .../WKNavigationHandlerTests.swift | 100 ++++++++++++++++++ .../SampleBrowser.xcodeproj/project.pbxproj | 4 + .../Model/RootViewControllerModel.swift | 4 +- .../UI/Browser/BrowserViewController.swift | 9 ++ .../UI/Browser/ErrorPageViewController.swift | 25 +++++ .../Components/AddressToolbarContainer.swift | 4 +- .../AddressToolbarContainerModel.swift | 8 +- .../NavigationToolbarContainer.swift | 2 +- .../NavigationToolbarContainerModel.swift | 4 +- .../SampleBrowser/UI/RootViewController.swift | 28 +++++ firefox-ios/Client/Utils/Layout.swift | 14 --- 31 files changed, 603 insertions(+), 28 deletions(-) create mode 100644 BrowserKit/Sources/WebEngine/WKWebview/Internal/InternalUtil.swift create mode 100644 BrowserKit/Sources/WebEngine/WKWebview/Internal/WKAboutHomeHandler.swift create mode 100644 BrowserKit/Sources/WebEngine/WKWebview/Internal/WKErrorPageHandler.swift create mode 100644 BrowserKit/Sources/WebEngine/WKWebview/Internal/WKInternalSchemeHandler.swift rename BrowserKit/Sources/WebEngine/WKWebview/{ => Internal}/WKInternalURL.swift (100%) create mode 100644 BrowserKit/Tests/WebEngineTests/Mock/MockSchemeHandler.swift create mode 100644 BrowserKit/Tests/WebEngineTests/Mock/MockSessionHandler.swift create mode 100644 BrowserKit/Tests/WebEngineTests/Mock/MockWKWebView.swift create mode 100644 BrowserKit/Tests/WebEngineTests/Mock/WKURLSchemeTaskMock.swift create mode 100644 BrowserKit/Tests/WebEngineTests/WKInternalSchemeHandlerTests.swift create mode 100644 BrowserKit/Tests/WebEngineTests/WKNavigationHandlerTests.swift create mode 100644 SampleBrowser/SampleBrowser/UI/Browser/ErrorPageViewController.swift diff --git a/BrowserKit/Sources/Common/Extensions/UIViewExtension.swift b/BrowserKit/Sources/Common/Extensions/UIViewExtension.swift index 7edc97589ef8..a849b504a989 100644 --- a/BrowserKit/Sources/Common/Extensions/UIViewExtension.swift +++ b/BrowserKit/Sources/Common/Extensions/UIViewExtension.swift @@ -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 + } } diff --git a/BrowserKit/Sources/WebEngine/EngineSession.swift b/BrowserKit/Sources/WebEngine/EngineSession.swift index 967f4c1022c3..12c8116e8949 100644 --- a/BrowserKit/Sources/WebEngine/EngineSession.swift +++ b/BrowserKit/Sources/WebEngine/EngineSession.swift @@ -74,7 +74,7 @@ public protocol EngineSession { func updatePageZoom(_ change: ZoomChangeValue) } -extension EngineSession { +public extension EngineSession { func reload(bypassCache: Bool = false) { reload(bypassCache: bypassCache) } diff --git a/BrowserKit/Sources/WebEngine/EngineSessionDelegate.swift b/BrowserKit/Sources/WebEngine/EngineSessionDelegate.swift index c79f2f735699..f4a454ca110f 100644 --- a/BrowserKit/Sources/WebEngine/EngineSessionDelegate.swift +++ b/BrowserKit/Sources/WebEngine/EngineSessionDelegate.swift @@ -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 diff --git a/BrowserKit/Sources/WebEngine/WKWebview/Internal/InternalUtil.swift b/BrowserKit/Sources/WebEngine/WKWebview/Internal/InternalUtil.swift new file mode 100644 index 000000000000..834c5ad1a0f9 --- /dev/null +++ b/BrowserKit/Sources/WebEngine/WKWebview/Internal/InternalUtil.swift @@ -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 + } + } +} diff --git a/BrowserKit/Sources/WebEngine/WKWebview/Internal/WKAboutHomeHandler.swift b/BrowserKit/Sources/WebEngine/WKWebview/Internal/WKAboutHomeHandler.swift new file mode 100644 index 000000000000..76261eacf4ad --- /dev/null +++ b/BrowserKit/Sources/WebEngine/WKWebview/Internal/WKAboutHomeHandler.swift @@ -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 = """ + + + + + """ + guard let data = html.data(using: .utf8) else { return nil } + return (response, data) + } +} diff --git a/BrowserKit/Sources/WebEngine/WKWebview/Internal/WKErrorPageHandler.swift b/BrowserKit/Sources/WebEngine/WKWebview/Internal/WKErrorPageHandler.swift new file mode 100644 index 000000000000..5e51557bc25f --- /dev/null +++ b/BrowserKit/Sources/WebEngine/WKWebview/Internal/WKErrorPageHandler.swift @@ -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 = """ + + + + + """ + guard let data = html.data(using: .utf8) else { return nil } + return (response, data) + } +} diff --git a/BrowserKit/Sources/WebEngine/WKWebview/Internal/WKInternalSchemeHandler.swift b/BrowserKit/Sources/WebEngine/WKWebview/Internal/WKInternalSchemeHandler.swift new file mode 100644 index 000000000000..65903ae7f545 --- /dev/null +++ b/BrowserKit/Sources/WebEngine/WKWebview/Internal/WKInternalSchemeHandler.swift @@ -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) {} +} diff --git a/BrowserKit/Sources/WebEngine/WKWebview/WKInternalURL.swift b/BrowserKit/Sources/WebEngine/WKWebview/Internal/WKInternalURL.swift similarity index 100% rename from BrowserKit/Sources/WebEngine/WKWebview/WKInternalURL.swift rename to BrowserKit/Sources/WebEngine/WKWebview/Internal/WKInternalURL.swift diff --git a/BrowserKit/Sources/WebEngine/WKWebview/WKEngine.swift b/BrowserKit/Sources/WebEngine/WKWebview/WKEngine.swift index c34c89617e43..c61259f7ed26 100644 --- a/BrowserKit/Sources/WebEngine/WKWebview/WKEngine.swift +++ b/BrowserKit/Sources/WebEngine/WKWebview/WKEngine.swift @@ -13,6 +13,8 @@ public class WKEngine: Engine { init(userScriptManager: WKUserScriptManager = DefaultUserScriptManager()) { self.userScriptManager = userScriptManager + + InternalUtil().setUpInternalHandlers() } public func createView() -> EngineView { diff --git a/BrowserKit/Sources/WebEngine/WKWebview/WKEngineConfigurationProvider.swift b/BrowserKit/Sources/WebEngine/WKWebview/WKEngineConfigurationProvider.swift index 51e92baa01de..e0e5a57506fc 100644 --- a/BrowserKit/Sources/WebEngine/WKWebview/WKEngineConfigurationProvider.swift +++ b/BrowserKit/Sources/WebEngine/WKWebview/WKEngineConfigurationProvider.swift @@ -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) } } diff --git a/BrowserKit/Sources/WebEngine/WKWebview/WKEngineSession.swift b/BrowserKit/Sources/WebEngine/WKWebview/WKEngineSession.swift index 978c1ed42062..9c6bf0d4f368 100644 --- a/BrowserKit/Sources/WebEngine/WKWebview/WKEngineSession.swift +++ b/BrowserKit/Sources/WebEngine/WKWebview/WKEngineSession.swift @@ -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, @@ -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() { diff --git a/BrowserKit/Sources/WebEngine/WKWebview/WKNavigationHandler.swift b/BrowserKit/Sources/WebEngine/WKWebview/WKNavigationHandler.swift index c196b7bbb62d..8184d552a330 100644 --- a/BrowserKit/Sources/WebEngine/WKWebview/WKNavigationHandler.swift +++ b/BrowserKit/Sources/WebEngine/WKWebview/WKNavigationHandler.swift @@ -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 { @@ -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?) { @@ -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, @@ -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 + } } diff --git a/BrowserKit/Tests/WebEngineTests/Mock/MockEngineSessionDelegate.swift b/BrowserKit/Tests/WebEngineTests/Mock/MockEngineSessionDelegate.swift index 71ac7b9d3ff4..bdc1bc2cf434 100644 --- a/BrowserKit/Tests/WebEngineTests/Mock/MockEngineSessionDelegate.swift +++ b/BrowserKit/Tests/WebEngineTests/Mock/MockEngineSessionDelegate.swift @@ -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 @@ -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? @@ -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 diff --git a/BrowserKit/Tests/WebEngineTests/Mock/MockSchemeHandler.swift b/BrowserKit/Tests/WebEngineTests/Mock/MockSchemeHandler.swift new file mode 100644 index 000000000000..256656559270 --- /dev/null +++ b/BrowserKit/Tests/WebEngineTests/Mock/MockSchemeHandler.swift @@ -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 + } +} diff --git a/BrowserKit/Tests/WebEngineTests/Mock/MockSessionHandler.swift b/BrowserKit/Tests/WebEngineTests/Mock/MockSessionHandler.swift new file mode 100644 index 000000000000..93ce22e874b6 --- /dev/null +++ b/BrowserKit/Tests/WebEngineTests/Mock/MockSessionHandler.swift @@ -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 + } +} diff --git a/BrowserKit/Tests/WebEngineTests/Mock/MockWKEngineWebView.swift b/BrowserKit/Tests/WebEngineTests/Mock/MockWKEngineWebView.swift index 99c8301863f0..520746419cbb 100644 --- a/BrowserKit/Tests/WebEngineTests/Mock/MockWKEngineWebView.swift +++ b/BrowserKit/Tests/WebEngineTests/Mock/MockWKEngineWebView.swift @@ -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? diff --git a/BrowserKit/Tests/WebEngineTests/Mock/MockWKWebView.swift b/BrowserKit/Tests/WebEngineTests/Mock/MockWKWebView.swift new file mode 100644 index 000000000000..6836332106fb --- /dev/null +++ b/BrowserKit/Tests/WebEngineTests/Mock/MockWKWebView.swift @@ -0,0 +1,28 @@ +// 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 + +/// Used in some tests cases instead of `MockWKEngineWebView` since some delegate methods need a concrete `WKWebView` object +/// and an abstraction cannot be used. +class MockWKWebView: WKWebView { + var mockURL: URL? + var loadCalled = 0 + var reloadCalled = 0 + + override var url: URL? { + return mockURL + } + + override func load(_ request: URLRequest) -> WKNavigation? { + loadCalled += 1 + mockURL = request.url + return nil + } + + override func reload() -> WKNavigation? { + reloadCalled += 1 + return nil + } +} diff --git a/BrowserKit/Tests/WebEngineTests/Mock/WKURLSchemeTaskMock.swift b/BrowserKit/Tests/WebEngineTests/Mock/WKURLSchemeTaskMock.swift new file mode 100644 index 000000000000..21e118263aef --- /dev/null +++ b/BrowserKit/Tests/WebEngineTests/Mock/WKURLSchemeTaskMock.swift @@ -0,0 +1,40 @@ +// 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 +import WebKit + +class WKURLSchemeTaskMock: NSObject, WKURLSchemeTask { + var mockRequest: URLRequest + var didReceiveResponseCalled = 0 + var didReceiveDataCalled = 0 + var didFinishCalled = 0 + var didFailCalled = 0 + var didFailedWithError: Error? + + init(mockRequest: URLRequest) { + self.mockRequest = mockRequest + } + + var request: URLRequest { + return mockRequest + } + + func didReceive(_ response: URLResponse) { + didReceiveResponseCalled += 1 + } + + func didReceive(_ data: Data) { + didReceiveDataCalled += 1 + } + + func didFinish() { + didFinishCalled += 1 + } + + func didFailWithError(_ error: any Error) { + didFailedWithError = error + didFailCalled += 1 + } +} diff --git a/BrowserKit/Tests/WebEngineTests/WKEngineSessionTests.swift b/BrowserKit/Tests/WebEngineTests/WKEngineSessionTests.swift index 9f81b4687a5d..9fe34845931d 100644 --- a/BrowserKit/Tests/WebEngineTests/WKEngineSessionTests.swift +++ b/BrowserKit/Tests/WebEngineTests/WKEngineSessionTests.swift @@ -558,6 +558,17 @@ final class WKEngineSessionTests: XCTestCase { // XCTAssertEqual(metadataFetcher.savedURL, loadedURL) } + // MARK: Error page + + func testReceivedErrorGivenErrorThenCallsErrorDelegate() { + let subject = createSubject() + subject?.delegate = engineSessionDelegate + + subject?.received(error: NSError(), forURL: URL(string: "www.example.com")!) + + XCTAssertEqual(engineSessionDelegate.onErrorPageCalled, 1) + } + // MARK: User script manager func testUserScriptWhenSubjectCreatedThenInjectionIntoWebviewCalled() { diff --git a/BrowserKit/Tests/WebEngineTests/WKInternalSchemeHandlerTests.swift b/BrowserKit/Tests/WebEngineTests/WKInternalSchemeHandlerTests.swift new file mode 100644 index 000000000000..d84b95dabf61 --- /dev/null +++ b/BrowserKit/Tests/WebEngineTests/WKInternalSchemeHandlerTests.swift @@ -0,0 +1,88 @@ +// 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 XCTest +import WebKit +@testable import WebEngine + +final class WKInternalSchemeHandlerTests: XCTestCase { + func testSchemeStartIsCalledNonPrivilegedURL() throws { + let subject = createSubject() + let webview = WKWebView(frame: .zero) + let url = URL(string: "www.example.com")! + let request = URLRequest(url: url) + let task = WKURLSchemeTaskMock(mockRequest: request) + + subject.webView(webview, start: task) + + XCTAssertEqual(task.didFailCalled, 1) + let error = try XCTUnwrap(task.didFailedWithError as? WKInternalPageSchemeHandlerError) + XCTAssertEqual(error, WKInternalPageSchemeHandlerError.notAuthorized) + } + + func testSchemeStartIsCalledWithPrivilegedURLNoResponder() throws { + let subject = createSubject() + let webview = WKWebView(frame: .zero) + let url = URL(string: "internal://local/about/somethingElse")! + let privilegedURL = WKInternalURL(url)! + privilegedURL.authorize() + let request = URLRequest(url: privilegedURL.url) + let task = WKURLSchemeTaskMock(mockRequest: request) + + subject.webView(webview, start: task) + + XCTAssertEqual(task.didFailCalled, 1) + let error = try XCTUnwrap(task.didFailedWithError as? WKInternalPageSchemeHandlerError) + XCTAssertEqual(error, WKInternalPageSchemeHandlerError.noResponder) + } + + func testSchemeStartIsCalledWithPrivilegedURLWithWrongResponder() throws { + InternalUtil().setUpInternalHandlers() + let subject = createSubject() + let webview = WKWebView(frame: .zero) + let url = URL(string: "internal://local/about/somethingElse")! + let privilegedURL = WKInternalURL(url)! + privilegedURL.authorize() + let request = URLRequest(url: privilegedURL.url) + let task = WKURLSchemeTaskMock(mockRequest: request) + + subject.webView(webview, start: task) + + XCTAssertEqual(task.didFailCalled, 1) + let error = try XCTUnwrap(task.didFailedWithError as? WKInternalPageSchemeHandlerError) + XCTAssertEqual(error, WKInternalPageSchemeHandlerError.noResponder) + } + + func testSchemeStartIsCalledWithPrivilegedURLWithCorrectResponder() throws { + setupFakeInternalHandlers() + let subject = createSubject() + let webview = WKWebView(frame: .zero) + let url = URL(string: "internal://local/about/test")! + let privilegedURL = WKInternalURL(url)! + privilegedURL.authorize() + let request = URLRequest(url: privilegedURL.url) + let task = WKURLSchemeTaskMock(mockRequest: request) + + subject.webView(webview, start: task) + + XCTAssertEqual(task.didFailCalled, 1) + let error = try XCTUnwrap(task.didFailedWithError as? WKInternalPageSchemeHandlerError) + XCTAssertEqual(error, WKInternalPageSchemeHandlerError.responderUnableToHandle) + } + + // MARK: Helper + + func createSubject() -> WKInternalSchemeHandler { + let subject = WKInternalSchemeHandler() + trackForMemoryLeaks(subject) + return subject + } + + func setupFakeInternalHandlers() { + let responders: [(String, WKInternalSchemeResponse)] = [(MockSchemeHandler.path, MockSchemeHandler())] + responders.forEach { (path, responder) in + WKInternalSchemeHandler.responders[path] = responder + } + } +} diff --git a/BrowserKit/Tests/WebEngineTests/WKNavigationHandlerTests.swift b/BrowserKit/Tests/WebEngineTests/WKNavigationHandlerTests.swift new file mode 100644 index 000000000000..bc00d7bb6e69 --- /dev/null +++ b/BrowserKit/Tests/WebEngineTests/WKNavigationHandlerTests.swift @@ -0,0 +1,100 @@ +// 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 +import XCTest + +@testable import WebEngine + +final class WKNavigationHandlerTests: XCTestCase { + private var webView: MockWKWebView! + private var sessionHandler: MockSessionHandler! + override func setUp() { + super.setUp() + webView = MockWKWebView() + sessionHandler = MockSessionHandler() + } + + override func tearDown() { + webView = nil + sessionHandler = nil + super.tearDown() + } + + func testDidCommitCallsCommitURLChange() { + let subject = createSubject() + + subject.webView(webView, didCommit: nil) + + XCTAssertEqual(sessionHandler.commitURLChangeCalled, 1) + } + + func testDidFinishCallsFetchMetadata() { + let subject = createSubject() + webView.mockURL = URL(string: "www.example.com")! + + subject.webView(webView, didFinish: nil) + + XCTAssertEqual(sessionHandler.fetchMetadataCalled, 1) + } + + func testDidFailProvisionalNavigationWhenWebkitError() { + let subject = createSubject() + + let webKitError = NSError(domain: "WebKitErrorDomain", code: 102, userInfo: nil) + subject.webView(webView, didFailProvisionalNavigation: nil, withError: webKitError) + + XCTAssertEqual(sessionHandler.commitURLChangeCalled, 0) + XCTAssertEqual(sessionHandler.receivedErrorCalled, 0) + } + + func testDidFailProvisionalNavigationWhenWebviewCrashed() { + let subject = createSubject() + let webContentProcessTerminatedError = NSError( + domain: "WebKitErrorDomain", + code: WKError.webContentProcessTerminated.rawValue, + userInfo: nil + ) + + subject.webView(webView, didFailProvisionalNavigation: nil, withError: webContentProcessTerminatedError) + + XCTAssertEqual(webView.reloadCalled, 1) + } + + func testDidFailProvisionalNavigationWhenCFURLErrorCancelled() { + let subject = createSubject() + let cfurlErrorCancelledError = NSError( + domain: "SomeErrorDomain", + code: Int(CFNetworkErrors.cfurlErrorCancelled.rawValue), + userInfo: nil + ) + + subject.webView(webView, didFailProvisionalNavigation: nil, withError: cfurlErrorCancelledError) + + XCTAssertEqual(sessionHandler.commitURLChangeCalled, 1) + } + + func testDidFailProvisionalNavigationWhenHasFailingURL() { + let subject = createSubject() + let failingURL = URL(string: "https://www.example.com")! + let errorWithURL = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorTimedOut, // Example error code + userInfo: [NSURLErrorFailingURLErrorKey: failingURL] + ) + + subject.webView(webView, didFailProvisionalNavigation: nil, withError: errorWithURL) + + XCTAssertEqual(sessionHandler.receivedErrorCalled, 1) + } + + // MARK: Helper + + func createSubject() -> DefaultNavigationHandler { + let subject = DefaultNavigationHandler() + subject.session = sessionHandler + trackForMemoryLeaks(subject) + return subject + } +} diff --git a/SampleBrowser/SampleBrowser.xcodeproj/project.pbxproj b/SampleBrowser/SampleBrowser.xcodeproj/project.pbxproj index 9e50f2645f12..cc41bbaca489 100644 --- a/SampleBrowser/SampleBrowser.xcodeproj/project.pbxproj +++ b/SampleBrowser/SampleBrowser.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 8A46021E2B0FE50D00FFD17F /* UIView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A46021D2B0FE50D00FFD17F /* UIView+Extension.swift */; }; 8A4602202B0FE52F00FFD17F /* UISearchbar+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A46021F2B0FE52F00FFD17F /* UISearchbar+Extension.swift */; }; 8A4602222B0FE55300FFD17F /* UIViewController+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4602212B0FE55300FFD17F /* UIViewController+Extension.swift */; }; + 8A7DE5382D5671A900240CFE /* ErrorPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7DE5372D5671A700240CFE /* ErrorPageViewController.swift */; }; 8ADF72C62B73DB9700530E7A /* MainFrameAtDocumentStart.js in Resources */ = {isa = PBXBuildFile; fileRef = 8ADF72C12B73DB9700530E7A /* MainFrameAtDocumentStart.js */; }; 8ADF72C72B73DB9700530E7A /* AllFramesAtDocumentStart.js in Resources */ = {isa = PBXBuildFile; fileRef = 8ADF72C22B73DB9700530E7A /* AllFramesAtDocumentStart.js */; }; 8ADF72C82B73DB9700530E7A /* WebcompatAllFramesAtDocumentStart.js in Resources */ = {isa = PBXBuildFile; fileRef = 8ADF72C32B73DB9700530E7A /* WebcompatAllFramesAtDocumentStart.js */; }; @@ -88,6 +89,7 @@ 8A46021D2B0FE50D00FFD17F /* UIView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Extension.swift"; sourceTree = ""; }; 8A46021F2B0FE52F00FFD17F /* UISearchbar+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISearchbar+Extension.swift"; sourceTree = ""; }; 8A4602212B0FE55300FFD17F /* UIViewController+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Extension.swift"; sourceTree = ""; }; + 8A7DE5372D5671A700240CFE /* ErrorPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorPageViewController.swift; sourceTree = ""; }; 8ADF72C12B73DB9700530E7A /* MainFrameAtDocumentStart.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = MainFrameAtDocumentStart.js; sourceTree = ""; }; 8ADF72C22B73DB9700530E7A /* AllFramesAtDocumentStart.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = AllFramesAtDocumentStart.js; sourceTree = ""; }; 8ADF72C32B73DB9700530E7A /* WebcompatAllFramesAtDocumentStart.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = WebcompatAllFramesAtDocumentStart.js; sourceTree = ""; }; @@ -263,6 +265,7 @@ 8A4602232B0FE57C00FFD17F /* Browser */ = { isa = PBXGroup; children = ( + 8A7DE5372D5671A700240CFE /* ErrorPageViewController.swift */, 8A46021A2B0FE47C00FFD17F /* BrowserViewController.swift */, ); path = Browser; @@ -398,6 +401,7 @@ 8A4602172B0FE43A00FFD17F /* BrowserSearchBar.swift in Sources */, E1C525C62BCFF77900073A6D /* RootViewControllerModel.swift in Sources */, 8A46021E2B0FE50D00FFD17F /* UIView+Extension.swift in Sources */, + 8A7DE5382D5671A900240CFE /* ErrorPageViewController.swift in Sources */, 8A3DB3332B5AD3EC00F89705 /* SettingsCellViewModel.swift in Sources */, 8A46021B2B0FE47C00FFD17F /* BrowserViewController.swift in Sources */, 8A0F44752B56FD5300438589 /* SearchViewModel.swift in Sources */, diff --git a/SampleBrowser/SampleBrowser/Model/RootViewControllerModel.swift b/SampleBrowser/SampleBrowser/Model/RootViewControllerModel.swift index c95dac6e7203..e79543b7d237 100644 --- a/SampleBrowser/SampleBrowser/Model/RootViewControllerModel.swift +++ b/SampleBrowser/SampleBrowser/Model/RootViewControllerModel.swift @@ -100,7 +100,7 @@ class RootViewControllerModel { self.addressToolbarDelegate?.didTapMenu() })] - let locationViewState = LocationViewState( + let locationViewConfig = LocationViewConfiguration( searchEngineImageViewA11yId: "searchEngine", searchEngineImageViewA11yLabel: "Search engine icon", lockIconButtonA11yId: "lockButton", @@ -125,7 +125,7 @@ class RootViewControllerModel { toolbarPosition: .top, scrollY: 0, isPrivate: false, - locationViewState: locationViewState, + locationViewConfiguration: locationViewConfig, navigationActions: [], pageActions: pageActions, browserActions: browserActions) diff --git a/SampleBrowser/SampleBrowser/UI/Browser/BrowserViewController.swift b/SampleBrowser/SampleBrowser/UI/Browser/BrowserViewController.swift index 76a67d49e781..73a5cf665464 100644 --- a/SampleBrowser/SampleBrowser/UI/Browser/BrowserViewController.swift +++ b/SampleBrowser/SampleBrowser/UI/Browser/BrowserViewController.swift @@ -9,6 +9,7 @@ protocol NavigationDelegate: AnyObject { func onURLChange(url: String) func onLoadingStateChange(loading: Bool) func onNavigationStateChange(canGoBack: Bool, canGoForward: Bool) + func showErrorPage(page: ErrorPageViewController) func onFindInPage(selected: String) func onFindInPage(currentResult: Int) @@ -199,6 +200,14 @@ class BrowserViewController: UIViewController, // We currently do not handle favicons in SampleBrowser, so this is empty. } + func onErrorPageRequest(error: NSError) { + let errorPage = ErrorPageViewController() + let message = "Error \(String(error.code)) happened on domain \(error.domain): \(error.localizedDescription)" + errorPage.configure(errorMessage: message) + + navigationDelegate?.showErrorPage(page: errorPage) + } + func onProvideContextualMenu(linkURL: URL?) -> UIContextMenuConfiguration? { guard let url = linkURL else { return nil } diff --git a/SampleBrowser/SampleBrowser/UI/Browser/ErrorPageViewController.swift b/SampleBrowser/SampleBrowser/UI/Browser/ErrorPageViewController.swift new file mode 100644 index 000000000000..bbfa51aa8422 --- /dev/null +++ b/SampleBrowser/SampleBrowser/UI/Browser/ErrorPageViewController.swift @@ -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 Common +import UIKit + +class ErrorPageViewController: UIViewController { + private lazy var errorLabel: UILabel = .build { label in + label.numberOfLines = 0 + label.textColor = .black + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + view.addSubview(errorLabel) + + errorLabel.pinToSuperview() + } + + func configure(errorMessage: String) { + errorLabel.text = errorMessage + } +} diff --git a/SampleBrowser/SampleBrowser/UI/Components/AddressToolbarContainer.swift b/SampleBrowser/SampleBrowser/UI/Components/AddressToolbarContainer.swift index 2a160630cf3a..2f784bea225a 100644 --- a/SampleBrowser/SampleBrowser/UI/Components/AddressToolbarContainer.swift +++ b/SampleBrowser/SampleBrowser/UI/Components/AddressToolbarContainer.swift @@ -26,14 +26,14 @@ class AddressToolbarContainer: UIView, ThemeApplicable { func configure(_ model: AddressToolbarContainerModel, toolbarDelegate: AddressToolbarDelegate) { compactToolbar.configure( - state: model.state, + config: model.state, toolbarDelegate: toolbarDelegate, leadingSpace: 0, trailingSpace: 0, isUnifiedSearchEnabled: false ) regularToolbar.configure( - state: model.state, + config: model.state, toolbarDelegate: toolbarDelegate, leadingSpace: 0, trailingSpace: 0, diff --git a/SampleBrowser/SampleBrowser/UI/Components/AddressToolbarContainerModel.swift b/SampleBrowser/SampleBrowser/UI/Components/AddressToolbarContainerModel.swift index f39cef3e1c14..0bd27950c4e6 100644 --- a/SampleBrowser/SampleBrowser/UI/Components/AddressToolbarContainerModel.swift +++ b/SampleBrowser/SampleBrowser/UI/Components/AddressToolbarContainerModel.swift @@ -10,15 +10,15 @@ struct AddressToolbarContainerModel { let toolbarPosition: AddressToolbarPosition let scrollY: CGFloat let isPrivate: Bool - let locationViewState: LocationViewState + let locationViewConfiguration: LocationViewConfiguration let navigationActions: [ToolbarElement] let pageActions: [ToolbarElement] let browserActions: [ToolbarElement] var manager: ToolbarManager = DefaultToolbarManager() - var state: AddressToolbarState { - return AddressToolbarState( - locationViewState: locationViewState, + var state: AddressToolbarConfiguration { + return AddressToolbarConfiguration( + locationViewConfiguration: locationViewConfiguration, navigationActions: navigationActions, pageActions: pageActions, browserActions: browserActions, diff --git a/SampleBrowser/SampleBrowser/UI/Components/NavigationToolbarContainer.swift b/SampleBrowser/SampleBrowser/UI/Components/NavigationToolbarContainer.swift index a134c7d1e26f..d4b861db9c51 100644 --- a/SampleBrowser/SampleBrowser/UI/Components/NavigationToolbarContainer.swift +++ b/SampleBrowser/SampleBrowser/UI/Components/NavigationToolbarContainer.swift @@ -61,7 +61,7 @@ class NavigationToolbarContainer: UIView, } func configure(_ model: NavigationToolbarContainerModel) { - toolbar.configure(state: model.state, toolbarDelegate: self) + toolbar.configure(config: model.state, toolbarDelegate: self) } private func setupLayout() { diff --git a/SampleBrowser/SampleBrowser/UI/Components/NavigationToolbarContainerModel.swift b/SampleBrowser/SampleBrowser/UI/Components/NavigationToolbarContainerModel.swift index 90885770919f..eec93331e910 100644 --- a/SampleBrowser/SampleBrowser/UI/Components/NavigationToolbarContainerModel.swift +++ b/SampleBrowser/SampleBrowser/UI/Components/NavigationToolbarContainerModel.swift @@ -10,8 +10,8 @@ struct NavigationToolbarContainerModel { let actions: [ToolbarElement] var manager: ToolbarManager = DefaultToolbarManager() - var state: NavigationToolbarState { - return NavigationToolbarState(actions: actions, shouldDisplayBorder: shouldDisplayBorder) + var state: NavigationToolbarConfiguration { + return NavigationToolbarConfiguration(actions: actions, shouldDisplayBorder: shouldDisplayBorder) } private var shouldDisplayBorder: Bool { diff --git a/SampleBrowser/SampleBrowser/UI/RootViewController.swift b/SampleBrowser/SampleBrowser/UI/RootViewController.swift index d51e3ca22f2a..69a744e4856e 100644 --- a/SampleBrowser/SampleBrowser/UI/RootViewController.swift +++ b/SampleBrowser/SampleBrowser/UI/RootViewController.swift @@ -30,6 +30,7 @@ class RootViewController: UIViewController, private var browserVC: BrowserViewController private var searchVC: SearchViewController private var findInPageBar: FindInPageBar? + private var errorPage: ErrorPageViewController? private var model = RootViewControllerModel() @@ -185,6 +186,8 @@ class RootViewController: UIViewController, // MARK: - NavigationDelegate func onLoadingStateChange(loading: Bool) { + removeErrorPage() + model.updateReloadStopButton(loading: loading) updateNavigationToolbar() } @@ -295,6 +298,31 @@ class RootViewController: UIViewController, browserVC.scrollToTop() } + func showErrorPage(page: ErrorPageViewController) { + self.errorPage = page + addChild(page) + page.view.frame = view.bounds + page.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(page.view) + + NSLayoutConstraint.activate([ + page.view.topAnchor.constraint(equalTo: addressToolbarContainer.bottomAnchor), + page.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + page.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + page.view.bottomAnchor.constraint(equalTo: navigationToolbar.topAnchor) + ]) + + page.didMove(toParent: self) + } + + private func removeErrorPage() { + guard let errorPage else { return } + errorPage.willMove(toParent: nil) + errorPage.view.removeFromSuperview() + errorPage.removeFromParent() + self.errorPage = nil + } + func showFindInPage() { let findInPageBar = FindInPageBar() findInPageBar.translatesAutoresizingMaskIntoConstraints = false diff --git a/firefox-ios/Client/Utils/Layout.swift b/firefox-ios/Client/Utils/Layout.swift index e56af67bf17e..78c602a2f3eb 100644 --- a/firefox-ios/Client/Utils/Layout.swift +++ b/firefox-ios/Client/Utils/Layout.swift @@ -4,20 +4,6 @@ import UIKit -extension UIView { - /// Convenience utility for pinning a subview to the bounds of its superview. - 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 - } -} - extension NSLayoutConstraint { /// Builder function that return a new NSLayoutConstraints with the priority set. This is useful /// to inline constraint creation in a call to `NSLayoutConstraint.active()`.