From 3090a2cfc1b3b144c094546690ae95ac867b4dc9 Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Thu, 20 Feb 2025 13:00:21 -0500 Subject: [PATCH] [iOS] Move web view delegate logic into a shared components --- .../BraveSkus/BraveSkusAccountLink.swift | 10 +- .../Frontend/Browser/BrowserPrompts.swift | 24 +- .../BVC+ShareActivity.swift | 3 +- .../BVC+TabManagerDelegate.swift | 10 +- .../BVC+TabWebDelegate.swift | 508 +++++ .../BVC+TabWebNavigationDelegate.swift | 293 +++ .../BVC+TabWebPolicyDecider.swift | 1011 +++++++++ .../BVC+ToolbarDelegate.swift | 4 +- .../BVC+WKDownloadDelegate.swift | 2 +- .../BVC+WKNavigationDelegate.swift | 1816 ++--------------- .../BrowserViewController.swift | 34 +- .../UniversalLinkNavigationHelper.swift | 23 + .../Browser/LinkPreviewViewController.swift | 4 +- .../PlaylistCacheLoader.swift | 4 +- .../Browser/Search/BraveSearchManager.swift | 5 +- .../Sources/Brave/Frontend/Browser/Tab.swift | 11 +- .../Brave/Frontend/Browser/TabManager.swift | 90 +- .../Browser/TabManagerNavDelegate.swift | 223 -- .../Frontend/Reader/ReadabilityService.swift | 2 +- .../TabManagerNavDelegateTests.swift | 233 --- 20 files changed, 2108 insertions(+), 2202 deletions(-) create mode 100644 ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabWebDelegate.swift create mode 100644 ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabWebNavigationDelegate.swift create mode 100644 ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabWebPolicyDecider.swift create mode 100644 ios/brave-ios/Sources/Brave/Frontend/Browser/Helpers/UniversalLinkNavigationHelper.swift delete mode 100644 ios/brave-ios/Sources/Brave/Frontend/Browser/TabManagerNavDelegate.swift delete mode 100644 ios/brave-ios/Tests/ClientTests/TabManagerNavDelegateTests.swift diff --git a/ios/brave-ios/Sources/Brave/BraveSkus/BraveSkusAccountLink.swift b/ios/brave-ios/Sources/Brave/BraveSkus/BraveSkusAccountLink.swift index 14fc4dbdd17d..af0f202f60ca 100644 --- a/ios/brave-ios/Sources/Brave/BraveSkus/BraveSkusAccountLink.swift +++ b/ios/brave-ios/Sources/Brave/BraveSkus/BraveSkusAccountLink.swift @@ -44,17 +44,17 @@ class BraveSkusAccountLink { } @MainActor - static func injectLocalStorage(webView: WKWebView) async { + static func injectLocalStorage(tab: Tab) async { if let vpnSubscriptionProductId = Preferences.VPN.subscriptionProductId.value, let product = BraveStoreProduct(rawValue: vpnSubscriptionProductId) { - await BraveSkusAccountLink.injectLocalStorage(webView: webView, product: product) + await BraveSkusAccountLink.injectLocalStorage(tab: tab, product: product) } if let aiChatSubscriptionProductId = Preferences.AIChat.subscriptionProductId.value, let product = BraveStoreProduct(rawValue: aiChatSubscriptionProductId) { - await BraveSkusAccountLink.injectLocalStorage(webView: webView, product: product) + await BraveSkusAccountLink.injectLocalStorage(tab: tab, product: product) } } @@ -63,11 +63,11 @@ class BraveSkusAccountLink { /// - Parameter product: The product whose receipt information to inject @MainActor @discardableResult private static func injectLocalStorage( - webView: WKWebView, + tab: Tab, product: BraveStoreProduct ) async -> Bool { // The WebView has no URL so do nothing - guard let url = webView.url else { + guard let webView = tab.webView, let url = webView.url else { return false } diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserPrompts.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserPrompts.swift index a03cdfb4e5bc..96612dc506cc 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserPrompts.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserPrompts.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 https://mozilla.org/MPL/2.0/. +import BraveCore import Foundation import Shared import WebKit @@ -92,13 +93,13 @@ protocol JSAlertInfo { struct MessageAlert: JSAlertInfo { let message: String - let frame: WKFrameInfo + let origin: URLOrigin let completionHandler: () -> Void var suppressHandler: SuppressHandler? func alertController() -> JSPromptAlertController { let alertController = JSPromptAlertController( - title: titleForJavaScriptPanelInitiatedByFrame(frame), + title: origin.serialized, message: message, info: self, showCancel: false @@ -119,14 +120,14 @@ struct MessageAlert: JSAlertInfo { struct ConfirmPanelAlert: JSAlertInfo { let message: String - let frame: WKFrameInfo + let origin: URLOrigin let completionHandler: (Bool) -> Void var suppressHandler: SuppressHandler? func alertController() -> JSPromptAlertController { // Show JavaScript confirm dialogs. let alertController = JSPromptAlertController( - title: titleForJavaScriptPanelInitiatedByFrame(frame), + title: origin.serialized, message: message, info: self ) @@ -146,14 +147,14 @@ struct ConfirmPanelAlert: JSAlertInfo { struct TextInputAlert: JSAlertInfo { let message: String - let frame: WKFrameInfo + let origin: URLOrigin let completionHandler: (String?) -> Void let defaultText: String? var suppressHandler: SuppressHandler? func alertController() -> JSPromptAlertController { let alertController = JSPromptAlertController( - title: titleForJavaScriptPanelInitiatedByFrame(frame), + title: origin.serialized, message: message, info: self ) @@ -176,17 +177,6 @@ struct TextInputAlert: JSAlertInfo { } } -/// Show a title for a JavaScript Panel (alert) based on the WKFrameInfo. On iOS9 we will use the new securityOrigin -/// and on iOS 8 we will fall back to the request URL. If the request URL is nil, which happens for JavaScript pages, -/// we fall back to "JavaScript" as a title. -private func titleForJavaScriptPanelInitiatedByFrame(_ frame: WKFrameInfo) -> String { - var title = "\(frame.securityOrigin.`protocol`)://\(frame.securityOrigin.host)" - if frame.securityOrigin.port != 0 { - title += ":\(frame.securityOrigin.port)" - } - return title -} - /// A generic browser alert that will execute a closure if its dismissed and no actions were picked /// to allow us to call any neccessary completion handlers required by WebKit /// diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ShareActivity.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ShareActivity.swift index 45100016740b..1579035acd73 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ShareActivity.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ShareActivity.swift @@ -141,8 +141,7 @@ extension BrowserViewController { activityType: .pageZoom, callback: { [weak self] in guard let self = self else { return } - - self.displayPageZoom(visible: true) + self.displayPageZoomDialog() } ) ) diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift index 16daac302b03..faa2cf21e345 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift @@ -126,13 +126,15 @@ extension BrowserViewController: TabManagerDelegate { } } - displayPageZoom(visible: false) + clearPageZoomDialog() updateTabsBarVisibility() selected?.updatePullToRefreshVisibility() - topToolbar.locationView.loading = selected?.loading ?? false - updateBackForwardActionStatus(for: selected?.webView) - navigationToolbar.updateForwardStatus(selected?.canGoForward ?? false) + if let tab = selected { + topToolbar.locationView.loading = tab.loading + updateBackForwardActionStatus(for: tab) + navigationToolbar.updateForwardStatus(tab.canGoForward) + } let shouldShowPlaylistURLBarButton = selected?.url?.isPlaylistSupportedSiteURL == true diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabWebDelegate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabWebDelegate.swift new file mode 100644 index 000000000000..c252987bb649 --- /dev/null +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabWebDelegate.swift @@ -0,0 +1,508 @@ +// Copyright (c) 2025 The Brave Authors. All rights reserved. +// 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 https://mozilla.org/MPL/2.0/. + +import BraveCore +import Foundation +import Preferences +import Strings +import UIKit + +/// A protocol that tells an object about web UI related events happening +/// +/// `WKWebView` specific things should not be accessed from these methods, if you need to access +/// the underlying web view, you should only access it via `Tab` +protocol TabWebDelegate: AnyObject { + func tabWebViewDidClose(_ tab: Tab) + func tab( + _ tab: Tab, + contextMenuConfigurationForLinkURL linkURL: URL? + ) async -> UIContextMenuConfiguration? + func tab( + _ tab: Tab, + requestMediaCapturePermissionsFor type: WebMediaCaptureType + ) async -> WebPermissionDecision + func tab(_ tab: Tab, runJavaScriptAlertPanelWithMessage message: String, pageURL: URL) async + func tab( + _ tab: Tab, + runJavaScriptConfirmPanelWithMessage message: String, + pageURL: URL + ) async -> Bool + func tab( + _ tab: Tab, + runJavaScriptConfirmPanelWithPrompt prompt: String, + defaultText: String?, + pageURL: URL + ) async -> String? +} + +/// Media device capture types that a web page may request +enum WebMediaCaptureType { + case camera + case microphone + case cameraAndMicrophone +} + +/// Permission decisions for responding to various permission prompts +enum WebPermissionDecision { + case prompt + case grant + case deny +} + +extension TabWebDelegate { + func tabWebViewDidClose(_ tab: Tab) {} + + func tab( + _ tab: Tab, + contextMenuConfigurationForLinkURL linkURL: URL? + ) async -> UIContextMenuConfiguration? { + return nil + } + + func tab( + _ tab: Tab, + requestMediaCapturePermissionsFor type: WebMediaCaptureType + ) async -> WebPermissionDecision { + return .prompt + } + + func tab(_ tab: Tab, runJavaScriptAlertPanelWithMessage message: String, pageURL: URL) async {} + + func tab( + _ tab: Tab, + runJavaScriptConfirmPanelWithMessage message: String, + pageURL: URL + ) async -> Bool { + return false + } + + func tab( + _ tab: Tab, + runJavaScriptConfirmPanelWithPrompt prompt: String, + defaultText: String?, + pageURL: URL + ) async -> String? { + return + nil + } +} + +extension BrowserViewController: TabWebDelegate { + func tabWebViewDidClose(_ tab: Tab) { + tabManager.addTabToRecentlyClosed(tab) + tabManager.removeTab(tab) + } + + func tab( + _ tab: Tab, + contextMenuConfigurationForLinkURL linkURL: URL? + ) async -> UIContextMenuConfiguration? { + // Only show context menu for valid links such as `http`, `https`, `data`. Safari does not show it for anything else. + // This is because you cannot open `javascript:something` URLs in a new page, or share it, or anything else. + guard let url = linkURL, url.isWebPage() else { + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: nil) + } + + let actionProvider: UIContextMenuActionProvider = { _ -> UIMenu? in + var actions = [UIAction]() + + if let currentTab = self.tabManager.selectedTab { + let tabType = currentTab.type + + if !tabType.isPrivate { + let openNewTabAction = UIAction( + title: Strings.openNewTabButtonTitle, + image: UIImage(systemName: "plus") + ) { _ in + self.addTab(url: url, inPrivateMode: false, currentTab: currentTab) + } + + openNewTabAction.accessibilityLabel = "linkContextMenu.openInNewTab" + actions.append(openNewTabAction) + } + + let openNewPrivateTabAction = UIAction( + title: Strings.openNewPrivateTabButtonTitle, + image: UIImage(named: "private_glasses", in: .module, compatibleWith: nil)!.template + ) { _ in + if !tabType.isPrivate, Preferences.Privacy.privateBrowsingLock.value { + self.askForLocalAuthentication { [weak self] success, error in + if success { + self?.addTab(url: url, inPrivateMode: true, currentTab: currentTab) + } + } + } else { + self.addTab(url: url, inPrivateMode: true, currentTab: currentTab) + } + } + openNewPrivateTabAction.accessibilityLabel = "linkContextMenu.openInNewPrivateTab" + + actions.append(openNewPrivateTabAction) + + if UIApplication.shared.supportsMultipleScenes { + if !tabType.isPrivate { + let openNewWindowAction = UIAction( + title: Strings.openInNewWindowTitle, + image: UIImage(braveSystemNamed: "leo.window") + ) { _ in + self.openInNewWindow(url: url, isPrivate: false) + } + + openNewWindowAction.accessibilityLabel = "linkContextMenu.openInNewWindow" + actions.append(openNewWindowAction) + } + + let openNewPrivateWindowAction = UIAction( + title: Strings.openInNewPrivateWindowTitle, + image: UIImage(braveSystemNamed: "leo.window.tab-private") + ) { _ in + if !tabType.isPrivate, Preferences.Privacy.privateBrowsingLock.value { + self.askForLocalAuthentication { [weak self] success, error in + if success { + self?.openInNewWindow(url: url, isPrivate: true) + } + } + } else { + self.openInNewWindow(url: url, isPrivate: true) + } + } + + openNewPrivateWindowAction.accessibilityLabel = "linkContextMenu.openInNewPrivateWindow" + actions.append(openNewPrivateWindowAction) + } + + let copyAction = UIAction( + title: Strings.copyLinkActionTitle, + image: UIImage(systemName: "doc.on.doc"), + handler: UIAction.deferredActionHandler { _ in + UIPasteboard.general.url = url as URL + } + ) + copyAction.accessibilityLabel = "linkContextMenu.copyLink" + actions.append(copyAction) + + let copyCleanLinkAction = UIAction( + title: Strings.copyCleanLink, + image: UIImage(braveSystemNamed: "leo.broom"), + handler: UIAction.deferredActionHandler { _ in + let service = URLSanitizerServiceFactory.get(privateMode: currentTab.isPrivate) + let cleanedURL = service?.sanitizeURL(url) ?? url + UIPasteboard.general.url = cleanedURL + } + ) + copyCleanLinkAction.accessibilityLabel = "linkContextMenu.copyCleanLink" + actions.append(copyCleanLinkAction) + + if let braveWebView = tab.webView { + let shareAction = UIAction( + title: Strings.shareLinkActionTitle, + image: UIImage(systemName: "square.and.arrow.up") + ) { _ in + let touchPoint = braveWebView.lastHitPoint + let touchRect = CGRect(origin: touchPoint, size: .zero) + + // TODO: Find a way to add fixes #3323 and #2961 here: + // Normally we use `tab.temporaryDocument` for the downloaded file on the tab. + // `temporaryDocument` returns the downloaded file to disk on the current tab. + // Using a downloaded file url results in having functions like "Save to files" available. + // It also attaches the file (image, pdf, etc) and not the url to emails, slack, etc. + // Since this is **not** a tab but a standalone web view, the downloaded temporary file is **not** available. + // This results in the fixes for #3323 and #2961 not being included in this share scenario. + // This is not a regression, we simply never handled this scenario in both fixes. + // Some possibile fixes include: + // - Detect the file type and download it if necessary and don't rely on the `tab.temporaryDocument`. + // - Add custom "Save to file" functionality (needs investigation). + self.presentActivityViewController( + url, + sourceView: braveWebView, + sourceRect: touchRect, + arrowDirection: .any + ) + } + + shareAction.accessibilityLabel = "linkContextMenu.share" + + actions.append(shareAction) + } + + let linkPreview = Preferences.General.enableLinkPreview.value + + let linkPreviewTitle = + linkPreview ? Strings.hideLinkPreviewsActionTitle : Strings.showLinkPreviewsActionTitle + let linkPreviewAction = UIAction( + title: linkPreviewTitle, + image: UIImage(systemName: "eye.fill") + ) { _ in + Preferences.General.enableLinkPreview.value.toggle() + } + + actions.append(linkPreviewAction) + } + + return UIMenu(title: url.absoluteString.truncate(length: 100), children: actions) + } + + let linkPreview: UIContextMenuContentPreviewProvider? = { [unowned self, weak tab] in + guard let tab else { return nil } + return LinkPreviewViewController(url: url, for: tab, browserController: self) + } + + let linkPreviewProvider = Preferences.General.enableLinkPreview.value ? linkPreview : nil + return UIContextMenuConfiguration( + identifier: nil, + previewProvider: linkPreviewProvider, + actionProvider: actionProvider + ) + } + + func tab( + _ tab: Tab, + requestMediaCapturePermissionsFor type: WebMediaCaptureType + ) async -> WebPermissionDecision { + guard let origin = tab.committedURL?.origin else { return .deny } + + let presentAlert: (CheckedContinuation) -> Void = { + [weak self] contination in + guard let self = self else { return } + + let titleFormat: String = { + switch type { + case .camera: + return Strings.requestCameraPermissionPrompt + case .microphone: + return Strings.requestMicrophonePermissionPrompt + case .cameraAndMicrophone: + return Strings.requestCameraAndMicrophonePermissionPrompt + @unknown default: + return Strings.requestCaptureDevicePermissionPrompt + } + }() + let title = String.localizedStringWithFormat(titleFormat, origin.host) + let alertController = BrowserAlertController( + title: title, + message: nil, + preferredStyle: .alert + ) + tab.shownPromptAlert = alertController + + alertController.addAction( + .init( + title: Strings.requestCaptureDevicePermissionAllowButtonTitle, + style: .default, + handler: { _ in + contination.resume(returning: .grant) + } + ) + ) + alertController.addAction( + .init( + title: Strings.CancelString, + style: .cancel, + handler: { _ in + contination.resume(returning: .deny) + } + ) + ) + alertController.dismissedWithoutAction = { + contination.resume(returning: .prompt) + } + + self.present(alertController, animated: true) + } + + return await withCheckedContinuation { continuation in + if let presentedViewController = presentedViewController as? BrowserAlertController { + presentedViewController.dismiss(animated: true) { + presentAlert(continuation) + } + } else { + presentAlert(continuation) + } + } + } + + func tab(_ tab: Tab, runJavaScriptAlertPanelWithMessage message: String, pageURL: URL) async { + guard case let origin = pageURL.origin, !origin.isOpaque else { return } + await withCheckedContinuation { continuation in + let completionHandler: () -> Void = { + continuation.resume() + } + var messageAlert = MessageAlert( + message: message, + origin: origin, + completionHandler: completionHandler, + suppressHandler: nil + ) + handleAlert(tab: tab, origin: origin, alert: &messageAlert) { + completionHandler() + } + } + } + + func tab( + _ tab: Tab, + runJavaScriptConfirmPanelWithMessage message: String, + pageURL: URL + ) async -> Bool { + guard case let origin = pageURL.origin, !origin.isOpaque else { return false } + return await withCheckedContinuation { continuation in + let completionHandler: (Bool) -> Void = { result in + continuation.resume(returning: result) + } + var confirmAlert = ConfirmPanelAlert( + message: message, + origin: origin, + completionHandler: completionHandler, + suppressHandler: nil + ) + handleAlert(tab: tab, origin: origin, alert: &confirmAlert) { + completionHandler(false) + } + } + } + + func tab( + _ tab: Tab, + runJavaScriptConfirmPanelWithPrompt prompt: String, + defaultText: String?, + pageURL: URL + ) async -> String? { + guard case let origin = pageURL.origin, !origin.isOpaque else { return nil } + return await withCheckedContinuation { continuation in + let completionHandler: (String?) -> Void = { result in + continuation.resume(returning: result) + } + var textInputAlert = TextInputAlert( + message: prompt, + origin: origin, + completionHandler: completionHandler, + defaultText: defaultText, + suppressHandler: nil + ) + handleAlert(tab: tab, origin: origin, alert: &textInputAlert) { + completionHandler(nil) + } + } + } +} + +extension BrowserViewController { + fileprivate func addTab(url: URL, inPrivateMode: Bool, currentTab: Tab) { + let tab = self.tabManager.addTab( + URLRequest(url: url), + afterTab: currentTab, + isPrivate: inPrivateMode + ) + if inPrivateMode && !privateBrowsingManager.isPrivateBrowsing { + self.tabManager.selectTab(tab) + } else { + // We're not showing the top tabs; show a toast to quick switch to the fresh new tab. + let toast = ButtonToast( + labelText: Strings.contextMenuButtonToastNewTabOpenedLabelText, + buttonText: Strings.contextMenuButtonToastNewTabOpenedButtonText, + completion: { buttonPressed in + if buttonPressed { + self.tabManager.selectTab(tab) + } + } + ) + self.show(toast: toast) + } + self.toolbarVisibilityViewModel.toolbarState = .expanded + } + + func suppressJSAlerts(tab: Tab) { + let script = """ + window.alert=window.confirm=window.prompt=function(n){}, + [].slice.apply(document.querySelectorAll('iframe')).forEach(function(n){if(n.contentWindow != window){n.contentWindow.alert=n.contentWindow.confirm=n.contentWindow.prompt=function(n){}}}) + """ + tab.webView?.evaluateSafeJavaScript( + functionName: script, + contentWorld: .defaultClient, + asFunction: false + ) + } + + func handleAlert( + tab: Tab, + origin: URLOrigin, + alert: inout T, + completionHandler: @escaping () -> Void + ) { + if origin.isOpaque { + completionHandler() + return + } + + if tab.blockAllAlerts { + suppressJSAlerts(tab: tab) + tab.cancelQueuedAlerts() + completionHandler() + return + } + + tab.alertShownCount += 1 + let suppressBlock: JSAlertInfo.SuppressHandler = { [unowned self, weak tab] suppress in + guard let tab else { return } + if suppress { + func suppressDialogues(_: UIAlertAction) { + self.suppressJSAlerts(tab: tab) + tab.blockAllAlerts = true + tab.cancelQueuedAlerts() + completionHandler() + } + // Show confirm alert here. + let suppressSheet = UIAlertController( + title: nil, + message: Strings.suppressAlertsActionMessage, + preferredStyle: .actionSheet + ) + suppressSheet.addAction( + UIAlertAction( + title: Strings.suppressAlertsActionTitle, + style: .destructive, + handler: suppressDialogues + ) + ) + suppressSheet.addAction( + UIAlertAction( + title: Strings.cancelButtonTitle, + style: .cancel, + handler: { _ in + completionHandler() + } + ) + ) + if UIDevice.current.userInterfaceIdiom == .pad, + let popoverController = suppressSheet.popoverPresentationController + { + popoverController.sourceView = self.view + popoverController.sourceRect = CGRect( + x: self.view.bounds.midX, + y: self.view.bounds.midY, + width: 0, + height: 0 + ) + popoverController.permittedArrowDirections = [] + } + + tab.shownPromptAlert = suppressSheet + self.present(suppressSheet, animated: true) + } else { + completionHandler() + } + } + alert.suppressHandler = tab.alertShownCount > 1 ? suppressBlock : nil + if tabManager.selectedTab === tab { + let controller = alert.alertController() + controller.delegate = self + tab.shownPromptAlert = controller + + present(controller, animated: true) + } else { + tab.queueJavascriptAlertPrompt(alert) + } + } +} diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabWebNavigationDelegate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabWebNavigationDelegate.swift new file mode 100644 index 000000000000..babdd744668a --- /dev/null +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabWebNavigationDelegate.swift @@ -0,0 +1,293 @@ +// Copyright (c) 2025 The Brave Authors. All rights reserved. +// 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 https://mozilla.org/MPL/2.0/. + +import BraveShared +import BraveUI +import BraveWallet +import Foundation +import OSLog +import Preferences +import Shared + +/// A protocol that tells an object about web navigation related events happening +/// +/// `WKWebView` specific things should not be accessed from these methods, if you need to access +/// the underlying web view, you should only access it via `Tab` +protocol TabWebNavigationDelegate: AnyObject { + func tabDidStartWebViewNavigation(_ tab: Tab) + func tabDidCommitWebViewNavigation(_ tab: Tab) + func tabDidFinishWebViewNavigation(_ tab: Tab) + /// Called when a web navigation fails for some reason, return true if the error + /// has been handled and no further action is needed + func tab(_ tab: Tab, didFailWebViewNavigationWithError error: Error) -> Bool + func tab( + _ tab: Tab, + didRequestHTTPAuthFor protectionSpace: URLProtectionSpace, + proposedCredential credential: URLCredential?, + previousFailureCount: Int + ) async -> URLCredential? +} + +extension TabWebNavigationDelegate { + func tabDidStartWebViewNavigation(_ tab: Tab) {} + + func tabDidCommitWebViewNavigation(_ tab: Tab) {} + + func tabDidFinishWebViewNavigation(_ tab: Tab) {} + + func tab(_ tab: Tab, didFailWebViewNavigationWithError error: Error) -> Bool { + return false + } + + func tab( + _ tab: Tab, + didRequestHTTPAuthFor protectionSpace: URLProtectionSpace, + proposedCredential credential: URLCredential?, + previousFailureCount: Int + ) async -> URLCredential? { + return nil + } +} + +extension BrowserViewController: TabWebNavigationDelegate { + func tabDidStartWebViewNavigation(_ tab: Tab) { + tab.contentBlocker.clearPageStats() + + let visibleURL = tab.webView?.url + + if tab === tabManager.selectedTab { + toolbarVisibilityViewModel.toolbarState = .expanded + clearPageZoomDialog() + + // If we are going to navigate to a new page, hide the translate button. + topToolbar.updateTranslateButtonState(.unavailable) + + // If we are going to navigate to a new page, hide the reader mode button. Unless we + // are going to a about:reader page. Then we keep it on screen: it will change status + // (orange color) as soon as the page has loaded. + if let url = visibleURL { + if !url.isInternalURL(for: .readermode) { + topToolbar.updateReaderModeState(.unavailable) + hideReaderModeBar(animated: false) + } + } + } + + // check if web view is loading a different origin than the one currently loaded + if let selectedTab = tabManager.selectedTab, + selectedTab.url?.origin != visibleURL?.origin + { + // new site has a different origin, hide wallet icon. + tabManager.selectedTab?.isWalletIconVisible = false + // new site, reset connected addresses + tabManager.selectedTab?.clearSolanaConnectedAccounts() + // close wallet panel if it's open + if let popoverController = self.presentedViewController as? PopoverController, + popoverController.contentController is WalletPanelHostingController + { + self.dismiss(animated: true) + } + } + + hideToastsOnNavigationStartIfNeeded(tabManager) + + // Reset redirect chain + tab.redirectChain = [] + if let url = visibleURL { + tab.redirectChain.append(url) + } + } + + func tabDidCommitWebViewNavigation(_ tab: Tab) { + // Reset the stored http request now that load has committed. + tab.upgradedHTTPSRequest = nil + tab.upgradeHTTPSTimeoutTimer?.invalidate() + tab.upgradeHTTPSTimeoutTimer = nil + + // Need to evaluate Night mode script injection after url is set inside the Tab + tab.nightMode = Preferences.General.nightModeEnabled.value + tab.clearSolanaConnectedAccounts() + + // Dismiss any alerts that are showing on page navigation. + if let alert = tab.shownPromptAlert { + alert.dismiss(animated: false) + } + + // Providers need re-initialized when changing origin to align with desktop in + // `BraveContentBrowserClient::RegisterBrowserInterfaceBindersForFrame` + // https://github.com/brave/brave-core/blob/1.52.x/browser/brave_content_browser_client.cc#L608 + if let provider = braveCore.braveWalletAPI.ethereumProvider( + with: tab, + isPrivateBrowsing: tab.isPrivate + ) { + // The Ethereum provider will fetch allowed accounts from it's delegate (the tab) + // on initialization. Fetching allowed accounts requires the origin; so we need to + // initialize after `commitedURL` / `url` are updated above + tab.walletEthProvider = provider + tab.walletEthProvider?.initialize(eventsListener: tab) + } + if let provider = braveCore.braveWalletAPI.solanaProvider( + with: tab, + isPrivateBrowsing: tab.isPrivate + ) { + tab.walletSolProvider = provider + tab.walletSolProvider?.initialize(eventsListener: tab) + } + + // Notify of tab changes after navigation completes but before notifying that + // the tab has loaded, so that any listeners can process the tab changes + // before the tab is considered loaded. + rewards.maybeNotifyTabDidChange( + tab: tab, + isSelected: tabManager.selectedTab == tab + ) + rewards.maybeNotifyTabDidLoad(tab: tab) + + // The toolbar and url bar changes can not be + // on different tab than selected. Or the webview + // previews and etc will effect the status + guard tabManager.selectedTab === tab else { + return + } + + updateUIForReaderHomeStateForTab(tab) + updateBackForwardActionStatus(for: tab) + } + + func tabDidFinishWebViewNavigation(_ tab: Tab) { + if !Preferences.Privacy.privateBrowsingOnly.value + && (!tab.isPrivate || Preferences.Privacy.persistentPrivateBrowsing.value) + { + tabManager.preserveScreenshot(for: tab) + tabManager.saveTab(tab) + } + + // Inject app's IAP receipt for Brave SKUs if necessary + if !tab.isPrivate { + Task { @MainActor in + await BraveSkusAccountLink.injectLocalStorage(tab: tab) + } + } + + // Second attempt to inject results to the BraveSearch. + // This will be called if we got fallback results faster than + // the page navigation. + if let braveSearchManager = tab.braveSearchManager { + // Fallback results are ready before navigation finished, + // they must be injected here. + if !braveSearchManager.fallbackQueryResultsPending { + tab.injectResults() + } + } else { + // If not applicable, null results must be injected regardless. + // The website waits on us until this is called with either results or null. + tab.injectResults() + } + + navigateInTab(tab: tab) + rewards.reportTabUpdated( + tab: tab, + isSelected: tabManager.selectedTab == tab, + isPrivate: privateBrowsingManager.isPrivateBrowsing + ) + tab.reportPageLoad(to: rewards, redirectChain: tab.redirectChain) + // Reset `rewardsReportingState` tab property so that listeners + // can be notified of tab changes when a new navigation happens. + tab.rewardsReportingState = RewardsTabChangeReportingState() + + Task { + await tab.updateEthereumProperties() + await tab.updateSolanaProperties() + } + + if tab.url?.isLocal == false { + // Set rewards inter site url as new page load url. + tab.rewardsXHRLoadURL = tab.url + } + + if tab.walletEthProvider != nil { + tab.emitEthereumEvent(.connect) + } + + if let lastCommittedURL = tab.committedURL { + maybeRecordBraveSearchDailyUsage(url: lastCommittedURL) + } + + // Added this method to determine long press menu actions better + // Since these actions are depending on tabmanager opened WebsiteCount + updateToolbarUsingTabManager(tabManager) + + recordFinishedPageLoadP3A() + } + + func tab(_ tab: Tab, didFailWebViewNavigationWithError error: Error) -> Bool { + let error = error as NSError + if error.code == Int(CFNetworkErrors.cfurlErrorCancelled.rawValue) { + // load cancelled / user stopped load. Cancel https upgrade fallback timer. + tab.upgradedHTTPSRequest = nil + tab.upgradeHTTPSTimeoutTimer?.invalidate() + tab.upgradeHTTPSTimeoutTimer = nil + + if tab === tabManager.selectedTab { + updateToolbarCurrentURL(tab.url?.displayURL) + updateWebViewPageZoom(tab: tab) + } + return true + } + + return false + } + + func tab( + _ tab: Tab, + didRequestHTTPAuthFor protectionSpace: URLProtectionSpace, + proposedCredential credential: URLCredential?, + previousFailureCount: Int + ) async -> URLCredential? { + let host = protectionSpace.host + let origin = "\(host):\(protectionSpace.port)" + + // The challenge may come from a background tab, so ensure it's the one visible. + tabManager.selectTab(tab) + tab.isDisplayingBasicAuthPrompt = true + defer { tab.isDisplayingBasicAuthPrompt = false } + + if let webView = tab.webView { + let isHidden = webView.isHidden + defer { webView.isHidden = isHidden } + + // Manually trigger a `url` change notification + if host != tab.url?.host { + webView.isHidden = true + + if tabManager.selectedTab === tab { + updateToolbarCurrentURL( + URL(string: "\(InternalURL.baseUrl)/\(InternalURL.Path.basicAuth.rawValue)") + ) + } + } + } + + do { + let credentials = try await Authenticator.handleAuthRequest( + self, + credential: credential, + protectionSpace: protectionSpace, + previousFailureCount: previousFailureCount + ).credentials + + if BasicAuthCredentialsManager.validDomains.contains(host) { + BasicAuthCredentialsManager.setCredential( + origin: origin, + credential: credentials + ) + } + + return credential + } catch { + return nil + } + } +} diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabWebPolicyDecider.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabWebPolicyDecider.swift new file mode 100644 index 000000000000..4d7d013f1f87 --- /dev/null +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabWebPolicyDecider.swift @@ -0,0 +1,1011 @@ +// Copyright (c) 2025 The Brave Authors. All rights reserved. +// 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 https://mozilla.org/MPL/2.0/. + +import BraveCore +import BraveUI +import Data +import Foundation +import Growth +import MarketplaceKit +import OSLog +import Preferences +import SafariServices +import Shared +import UIKit +import UniformTypeIdentifiers + +/// Decides the navigation policy for a tab's navigations +protocol TabWebPolicyDecider: AnyObject { + /// Decide whether or not a request should be allowed + func tab( + _ tab: Tab, + shouldAllowRequest request: URLRequest, + requestInfo: WebRequestInfo + ) async -> WebPolicyDecision + /// Decide whether or not a response should be allowed + func tab( + _ tab: Tab, + shouldAllowResponse response: URLResponse, + responseInfo: WebResponseInfo + ) async -> WebPolicyDecision +} + +extension TabWebPolicyDecider { + func tab( + _ tab: Tab, + shouldAllowRequest: URLRequest, + requestInfo: WebRequestInfo + ) async -> WebPolicyDecision { + return .allow + } + + func tab( + _ tab: Tab, + shouldAllowResponse: URLResponse, + responseInfo: WebResponseInfo + ) async -> WebPolicyDecision { + return .allow + } +} + +/// The policy to pass back to a policy decider +enum WebPolicyDecision { + case allow + case cancel +} + +/// The type of action triggering a navigation +enum WebNavigationType: Int { + case linkActivated + case formSubmitted + case backForward + case reload + case formResubmitted + case other = -1 +} + +/// Information about an action that may trigger a navigation, which can be used to make policy +/// decisions. +struct WebRequestInfo { + var navigationType: WebNavigationType + var isMainFrame: Bool + var isNewWindow: Bool + var isUserInitiated: Bool +} + +/// Information about a navigation response that can be used to make policy decisions +struct WebResponseInfo { + var isForMainFrame: Bool +} + +extension BrowserViewController: TabWebPolicyDecider { + func tab( + _ tab: Tab, + shouldAllowResponse response: URLResponse, + responseInfo: WebResponseInfo + ) async -> WebPolicyDecision { + let isPrivateBrowsing = privateBrowsingManager.isPrivateBrowsing + let responseURL = response.url + + // Store the response in the tab + if let responseURL = responseURL { + tab.responses[responseURL] = response + } + + // Check if we upgraded to https and if so we need to update the url of frame evaluations + if let responseURL = responseURL, + let domain = tab.currentPageData?.domain(persistent: !isPrivateBrowsing), + tab.currentPageData?.upgradeFrameURL( + forResponseURL: responseURL, + isForMainFrame: responseInfo.isForMainFrame + ) == true + { + let scriptTypes = + await tab.currentPageData?.makeUserScriptTypes( + domain: domain, + isDeAmpEnabled: braveCore.deAmpPrefs.isDeAmpEnabled + ) ?? [] + tab.setCustomUserScript(scripts: scriptTypes) + } + + if let responseURL = responseURL, + let response = response as? HTTPURLResponse + { + let internalUrl = InternalURL(responseURL) + + tab.rewardsReportingState.httpStatusCode = response.statusCode + + if !tab.rewardsReportingState.wasRestored { + tab.rewardsReportingState.wasRestored = internalUrl?.isSessionRestore == true + } + } + + let request = response.url.flatMap { pendingRequests[$0.absoluteString] } + + /// Safari Controller + let mimeTypesThatRequireSFSafariViewControllerHandling: [UTType] = [ + .textCalendar, + .mobileConfiguration, + ] + + let mimeTypesThatRequireSFSafariViewControllerHandlingTexts: [UTType: (String, String)] = [ + .textCalendar: ( + Strings.openTextCalendarAlertTitle, Strings.openTextCalendarAlertDescription + ), + .mobileConfiguration: ( + Strings.openMobileConfigurationAlertTitle, Strings.openMobileConfigurationAlertDescription + ), + ] + + // SFSafariViewController only supports http/https links + if responseInfo.isForMainFrame, let url = responseURL, + url.isWebPage(includeDataURIs: false), + tab === tabManager.selectedTab, + let mimeType = response.mimeType.flatMap({ UTType(mimeType: $0) }), + mimeTypesThatRequireSFSafariViewControllerHandling.contains(mimeType), + let (alertTitle, alertMessage) = mimeTypesThatRequireSFSafariViewControllerHandlingTexts[ + mimeType + ] + { + // Do what Chromium does: https://source.chromium.org/chromium/chromium/src/+/main:ios/chrome/browser/download/ui_bundled/safari_download_coordinator.mm;l=100;bpv=1;bpt=1?q=presentMobileConfigAlertFromURL&ss=chromium%2Fchromium%2Fsrc + // and present an alert before showing the Safari View Controller + let alert = UIAlertController( + title: alertTitle, + message: String.init( + format: alertMessage, + url.absoluteString + ), + preferredStyle: .alert + ) + alert.addAction( + UIAlertAction(title: Strings.OBContinueButton, style: .default) { [weak self] _ in + self?.handleLinkWithSafariViewController(url, tab: tab) + } + ) + + alert.addAction(UIAlertAction(title: Strings.cancelButtonTitle, style: .cancel)) + present(alert, animated: true) + + return .cancel + } + + // If the content type is not HTML, create a temporary document so it can be downloaded and + // shared to external applications later. Otherwise, clear the old temporary document. + if responseInfo.isForMainFrame { + if response.mimeType?.isKindOfHTML == false, let request { + tab.temporaryDocument = TemporaryDocument( + preflightResponse: response, + request: request, + tab: tab + ) + } else { + tab.temporaryDocument = nil + } + + tab.mimeType = response.mimeType + } + + return .allow + } + + func tab( + _ tab: Tab, + shouldAllowRequest request: URLRequest, + requestInfo: WebRequestInfo + ) async -> WebPolicyDecision { + guard let requestURL = request.url else { + return .cancel + } + let isPrivateBrowsing = privateBrowsingManager.isPrivateBrowsing + + if tab.isExternalAppAlertPresented == true { + tab.externalAppPopup?.dismissWithType(dismissType: .noAnimation) + tab.externalAppPopupContinuation?.resume(with: .success(false)) + tab.externalAppPopupContinuation = nil + tab.externalAppPopup = nil + } + + // Handle internal:// urls + if InternalURL.isValid(url: requestURL) { + if requestInfo.navigationType != .backForward, request.isInternalUnprivileged { + Logger.module.warning("Denying unprivileged request: \(request)") + return .cancel + } + return .allow + } + + if requestURL.scheme == "about" { + return .allow + } + + if requestURL.isBookmarklet { + return .cancel + } + + // Universal links do not work if the request originates from the app, manual handling is required. + if let mainDocURL = request.mainDocumentURL, + let universalLink = UniversalLinkManager.universalLinkType(for: mainDocURL, checkPath: true), + universalLink == .buyVPN + { + presentCorrespondingVPNViewController() + return .cancel + } + + // First special case are some schemes that are about Calling. We prompt the user to confirm this action. This + // gives us the exact same behaviour as Safari. + // tel:, facetime:, facetime-audio:, already has its own native alert displayed by the OS! + if ["sms", "mailto"].contains(requestURL.scheme) { + let shouldOpen = await handleExternalURL( + requestURL, + tab: tab, + requestInfo: requestInfo + ) + return (shouldOpen ? .allow : .cancel) + } + + // Let the system's prompt handle these. We can't let these cases fall-through, as the last check in this file will + // assume it's an external app prompt + if ["tel", "facetime", "facetime-audio"].contains(requestURL.scheme) { + let shouldOpen = await withCheckedContinuation { continuation in + UIApplication.shared.open(requestURL, options: [:]) { didOpen in + continuation.resume(returning: didOpen) + } + } + return shouldOpen ? .allow : .cancel + } + + // Second special case are a set of URLs that look like regular http links, but should be handed over to iOS + // instead of being loaded in the webview. + // In addition we are exchaging actual scheme with "maps" scheme + // So the Apple maps URLs will open properly + if let mapsURL = isAppleMapsURL(requestURL), mapsURL.enabled { + let shouldOpen = await handleExternalURL( + mapsURL.url, + tab: tab, + requestInfo: requestInfo + ) + return shouldOpen ? .allow : .cancel + } + + if #available(iOS 17.4, *), !ProcessInfo.processInfo.isiOSAppOnVisionOS { + // Accessing `MarketplaceKitURIScheme` on Vision OS results in a crash + if requestURL.scheme == MarketplaceKitURIScheme { + if let queryItems = URLComponents(url: requestURL, resolvingAgainstBaseURL: false)? + .queryItems, + let adpURL = queryItems.first(where: { + $0.name.caseInsensitiveCompare("alternativeDistributionPackage") == .orderedSame + })?.value?.asURL, + requestInfo.isMainFrame, + adpURL.baseDomain == request.url?.baseDomain + { + return .allow + } + return .cancel + } + } + + if isStoreURL(requestURL) { + let shouldOpen = await handleExternalURL( + requestURL, + tab: tab, + requestInfo: requestInfo + ) + return shouldOpen ? .allow : .cancel + } + + // handles Decentralized DNS + if let decentralizedDNSHelper = self.decentralizedDNSHelperFor(url: requestURL), + requestInfo.isMainFrame + { + topToolbar.locationView.loading = true + let result = await decentralizedDNSHelper.lookup( + domain: requestURL.schemelessAbsoluteDisplayString + ) + topToolbar.locationView.loading = tabManager.selectedTab?.loading ?? false + guard !Task.isCancelled else { // user pressed stop, or typed new url + return .cancel + } + switch result { + case .loadInterstitial(let service): + showWeb3ServiceInterstitialPage(service: service, originalURL: requestURL) + return .cancel + case .load(let resolvedURL): + if resolvedURL.isIPFSScheme, + let resolvedIPFSURL = braveCore.ipfsAPI.resolveGatewayUrl(for: resolvedURL) + { + // FIXME: This should cancel & load the resolvedIPFSURL + } else { + // FIXME: This should cancel & load the resolvedURL + } + case .none: + break + } + } + + tab.rewardsReportingState.isNewNavigation = + requestInfo.navigationType != .backForward && requestInfo.navigationType != .reload + tab.currentRequestURL = requestURL + + // Website redirection logic + if requestURL.isWebPage(includeDataURIs: false), + requestInfo.isMainFrame, + let redirectURL = WebsiteRedirects.redirect(for: requestURL) + { + + tab.loadRequest(URLRequest(url: redirectURL)) + return .cancel + } + + // Shields + + // before loading any ad-block scripts + // await the preparation of the ad-block services + await LaunchHelper.shared.prepareAdBlockServices( + adBlockService: self.braveCore.adblockService + ) + + if let mainDocumentURL = request.mainDocumentURL { + if mainDocumentURL != tab.currentPageData?.mainFrameURL { + // Clear the current page data if the page changes. + // Do this before anything else so that we have a clean slate. + tab.currentPageData = PageData(mainFrameURL: mainDocumentURL) + } + + // Handle the "forget me" feature on navigation + if requestInfo.isMainFrame { + // Cancel any forget data requests + tabManager.cancelForgetData(for: mainDocumentURL, in: tab) + + // Forget any websites that have "forget me" enabled + // if we navigated away from the previous domain + if let currentURL = tab.url, + !InternalURL.isValid(url: currentURL), + let currentETLDP1 = currentURL.baseDomain, + mainDocumentURL.baseDomain != currentETLDP1 + { + tabManager.forgetDataIfNeeded(for: currentURL, in: tab) + } + } + + let domainForMainFrame = Domain.getOrCreate( + forUrl: mainDocumentURL, + persistent: !isPrivateBrowsing + ) + + if let modifiedRequest = getInternalRedirect( + from: request, + isMainFrame: requestInfo.isMainFrame, + in: tab, + domainForMainFrame: domainForMainFrame + ) { + tab.isInternalRedirect = true + tab.loadRequest(modifiedRequest) + + if let url = modifiedRequest.url { + Logger.module.debug( + "Redirected to `\(url.absoluteString, privacy: .private)`" + ) + } + + return .cancel + } else { + tab.isInternalRedirect = false + } + + // Set some additional user scripts + if requestInfo.isMainFrame { + tab.setScripts(scripts: [ + // Add de-amp script + // The user script manager will take care to not reload scripts if this value doesn't change + .deAmp: braveCore.deAmpPrefs.isDeAmpEnabled, + + // Add request blocking script + // This script will block certian `xhr` and `window.fetch()` requests + .requestBlocking: requestURL.isWebPage(includeDataURIs: false) + && domainForMainFrame.globalBlockAdsAndTrackingLevel.isEnabled, + + // The tracker protection script + // This script will track what is blocked and increase stats + .trackerProtectionStats: requestURL.isWebPage(includeDataURIs: false) + && domainForMainFrame.globalBlockAdsAndTrackingLevel.isEnabled, + + // Add Brave search result ads processing script + // This script will process search result ads on the Brave search page. + .searchResultAd: BraveAds.shouldSupportSearchResultAds() + && BraveSearchManager.isValidURL(requestURL) && !isPrivateBrowsing, + ]) + } + + if !requestInfo.isNewWindow { + // Check if custom user scripts must be added to or removed from the web view. + tab.currentPageData?.addSubframeURL( + forRequestURL: requestURL, + isForMainFrame: requestInfo.isMainFrame + ) + let scriptTypes = + await tab.currentPageData?.makeUserScriptTypes( + domain: domainForMainFrame, + isDeAmpEnabled: braveCore.deAmpPrefs.isDeAmpEnabled + ) ?? [] + tab.setCustomUserScript(scripts: scriptTypes) + } + + // Brave Search logic. + + if requestInfo.isMainFrame, + BraveSearchManager.isValidURL(requestURL) + { + let domain = Domain.getOrCreate(forUrl: requestURL, persistent: !isPrivateBrowsing) + let adsBlockingShieldUp = domain.globalBlockAdsAndTrackingLevel.isEnabled + let isAggressiveAdsBlocking = + domain.globalBlockAdsAndTrackingLevel.isAggressive + && adsBlockingShieldUp + + if BraveSearchResultAdManager.shouldTriggerSearchResultAdClickedEvent( + requestURL, + isPrivateBrowsing: isPrivateBrowsing, + isAggressiveAdsBlocking: isAggressiveAdsBlocking + ) { + // Ensure the webView is not a link preview popup. + if self.presentedViewController == nil { + let showSearchResultAdClickedPrivacyNotice = + rewards.ads.shouldShowSearchResultAdClickedInfoBar() + BraveSearchResultAdManager.maybeTriggerSearchResultAdClickedEvent( + requestURL, + rewards: rewards, + completion: { [weak self] success in + guard let self, success, showSearchResultAdClickedPrivacyNotice else { + return + } + let searchResultClickedInfobar = SearchResultAdClickedInfoBar( + tabManager: self.tabManager + ) + self.show(toast: searchResultClickedInfobar, duration: nil) + } + ) + } + } else { + // The Brave-Search-Ads header should be added with a negative value when all + // of the following conditions are met: + // - The current tab is not a Private tab + // - Brave Rewards is enabled. + // - The "Search Ads" is opted-out. + // - The requested URL host is one of the Brave Search domains. + if !isPrivateBrowsing && rewards.isEnabled + && !rewards.ads.isOptedInToSearchResultAds() + && request.allHTTPHeaderFields?["Brave-Search-Ads"] == nil + { + var modifiedRequest = URLRequest(url: requestURL) + modifiedRequest.setValue("?0", forHTTPHeaderField: "Brave-Search-Ads") + tab.loadRequest(modifiedRequest) + return .cancel + } + + tab.braveSearchResultAdManager = BraveSearchResultAdManager( + url: requestURL, + rewards: rewards, + isPrivateBrowsing: isPrivateBrowsing, + isAggressiveAdsBlocking: isAggressiveAdsBlocking + ) + } + + if let braveSearchManager = tab.braveSearchManager { + braveSearchManager.fallbackQueryResultsPending = true + braveSearchManager.shouldUseFallback { backupQuery in + guard let query = backupQuery else { + braveSearchManager.fallbackQueryResultsPending = false + return + } + + if query.found { + braveSearchManager.fallbackQueryResultsPending = false + } else { + braveSearchManager.backupSearch(with: query) { completion in + braveSearchManager.fallbackQueryResultsPending = false + tab.injectResults() + } + } + } + } + } else { + tab.braveSearchResultAdManager = nil + } + } + + // This is the normal case, opening a http or https url, which we handle by loading them in this WKWebView. We + // always allow this. Additionally, data URIs are also handled just like normal web pages. + + if ["http", "https", "data", "blob", "file"].contains(requestURL.scheme) { + pendingRequests[requestURL.absoluteString] = request + + if let etldP1 = requestURL.baseDomain, + tab.proceedAnywaysDomainList.contains(etldP1) == false + { + let domain = Domain.getOrCreate(forUrl: requestURL, persistent: !isPrivateBrowsing) + + let shouldBlock = await AdBlockGroupsManager.shared.shouldBlock( + requestURL: requestURL, + sourceURL: requestURL, + resourceType: .document, + domain: domain + ) + + if shouldBlock, let url = requestURL.encodeEmbeddedInternalURL(for: .blocked) { + let request = PrivilegedRequest(url: url) as URLRequest + tab.loadRequest(request) + return .cancel + } + } + + // Adblock logic, + // Only use main document URL, not the request URL + // If an iFrame is loaded, shields depending on the main frame, not the iFrame request + + // Weird behavior here with `targetFram` and `sourceFrame`, on refreshing page `sourceFrame` is not nil (it is non-optional) + // however, it is still an uninitialized object, making it an unreliable source to compare `isMainFrame` against. + // Rather than using `sourceFrame.isMainFrame` or even comparing `sourceFrame == targetFrame`, a simple URL check is used. + // No adblocking logic is be used on session restore urls. It uses javascript to retrieve the + // request then the page is reloaded with a proper url and adblocking rules are applied. + if let mainDocumentURL = request.mainDocumentURL, + mainDocumentURL.schemelessAbsoluteString == requestURL.schemelessAbsoluteString, + !(InternalURL(requestURL)?.isSessionRestore ?? false), + requestInfo.isMainFrame + { + // Identify specific block lists that need to be applied to the requesting domain + let domainForShields = Domain.getOrCreate( + forUrl: mainDocumentURL, + persistent: !isPrivateBrowsing + ) + + // Load rule lists + let ruleLists = await AdBlockGroupsManager.shared.ruleLists(for: domainForShields) + tab.contentBlocker.set(ruleLists: ruleLists) + } + + // Cookie Blocking code below + tab.setScript(script: .cookieBlocking, enabled: Preferences.Privacy.blockAllCookies.value) + + // Reset the block alert bool on new host. + if let newHost: String = requestURL.host, let oldHost: String = tab.url?.host, + newHost != oldHost + { + self.tabManager.selectedTab?.alertShownCount = 0 + self.tabManager.selectedTab?.blockAllAlerts = false + } + + return .allow + } + + // Standard schemes are handled in previous if-case. + // This check handles custom app schemes to open external apps. + // Our own 'brave' scheme does not require the switch-app prompt. + if requestURL.scheme?.contains("brave") == false { + // Do not allow opening external URLs from child tabs + let shouldOpen = await handleExternalURL( + requestURL, + tab: tab, + requestInfo: requestInfo + ) + let isSyntheticClick = !requestInfo.isUserInitiated + + // Do not show error message for JS navigated links or redirect + // as it's not the result of a user action. + if !shouldOpen, requestInfo.navigationType == .linkActivated && !isSyntheticClick { + if self.presentedViewController == nil && self.presentingViewController == nil + && !tab.isExternalAppAlertPresented && !tab.isExternalAppAlertSuppressed + { + return await withCheckedContinuation { continuation in + // This alert does not need to be a BrowserAlertController because we return a policy + // without waiting for user action + let alert = UIAlertController( + title: Strings.unableToOpenURLErrorTitle, + message: Strings.unableToOpenURLError, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: Strings.OKString, style: .default, handler: nil)) + self.present(alert, animated: true) { + continuation.resume(returning: shouldOpen ? .allow : .cancel) + } + } + } + } + + return shouldOpen ? .allow : .cancel + } + + return .cancel + } +} + +extension BrowserViewController { + /// Handles a link by opening it in an SFSafariViewController and presenting it on the BVC. + /// + /// This is unfortunately neccessary to handle certain downloads natively such as ics/calendar invites and + /// mobileconfiguration files. + /// + /// The user unfortunately has to dismiss it manually after they have handled the file. + /// Chrome iOS does the same + private func handleLinkWithSafariViewController(_ url: URL, tab: Tab) { + let vc = SFSafariViewController(url: url, configuration: .init()) + vc.modalPresentationStyle = .formSheet + self.present(vc, animated: true) + + // If the website opened this URL in a separate tab, remove the empty tab + if tab.url == nil || tab.url?.absoluteString == "about:blank" { + tabManager.removeTab(tab) + } + } + + // Recognize an Apple Maps URL. This will trigger the native app. But only if a search query is present. + // Otherwise it could just be a visit to a regular page on maps.apple.com. + // Exchaging https/https scheme with maps in order to open URLS properly on Apple Maps + fileprivate func isAppleMapsURL(_ url: URL) -> (enabled: Bool, url: URL)? { + if url.scheme == "http" || url.scheme == "https" { + if url.host == "maps.apple.com" && url.query != nil { + guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + urlComponents.scheme = "maps" + + if let url = urlComponents.url { + return (true, url) + } + return nil + } + } + return (false, url) + } + + // Recognize a iTunes Store URL. These all trigger the native apps. Note that appstore.com and phobos.apple.com + // used to be in this list. I have removed them because they now redirect to itunes.apple.com. If we special case + // them then iOS will actually first open Safari, which then redirects to the app store. This works but it will + // leave a 'Back to Safari' button in the status bar, which we do not want. + fileprivate func isStoreURL(_ url: URL) -> Bool { + let isStoreScheme = ["itms-apps", "itms-appss", "itmss"].contains(url.scheme) + if isStoreScheme { + return true + } + + let isHttpScheme = ["http", "https"].contains(url.scheme) + let isAppStoreHost = ["itunes.apple.com", "apps.apple.com", "appsto.re"].contains(url.host) + return isHttpScheme && isAppStoreHost + } + + /// Get a possible redirect request from debouncing or query param stripping + func getInternalRedirect( + from request: URLRequest, + isMainFrame: Bool, + in tab: Tab, + domainForMainFrame: Domain + ) -> URLRequest? { + guard let requestURL = request.url else { return nil } + + // For main frame only and if shields are enabled + guard requestURL.isWebPage(includeDataURIs: false), + domainForMainFrame.globalBlockAdsAndTrackingLevel.isEnabled, + isMainFrame + else { return nil } + + // Handle Debounce + // Only if the site (etld+1) changes + // We also only handle `http` and `https` requests + // Lets get the redirect chain. + // Then we simply get all elements up until the user allows us to redirect + // (i.e. appropriate settings are enabled for that redirect rule) + if let debounceService = DebounceServiceFactory.get(privateMode: tab.isPrivate), + debounceService.isEnabled, + let currentURL = tab.webView?.url, + currentURL.baseDomain != requestURL.baseDomain + { + if let redirectURL = debounceService.debounce(requestURL) { + // For now we only allow the `Referer`. The browser will add other headers during navigation. + var modifiedRequest = URLRequest(url: redirectURL) + + // Also strip query params if debouncing + modifiedRequest = + modifiedRequest.stripQueryParams( + initiatorURL: tab.committedURL, + redirectSourceURL: requestURL, + isInternalRedirect: false + ) ?? modifiedRequest + + for (headerKey, headerValue) in request.allHTTPHeaderFields ?? [:] { + guard headerKey == "Referer" else { continue } + modifiedRequest.setValue(headerValue, forHTTPHeaderField: headerKey) + } + + Logger.module.debug( + "Debouncing `\(requestURL.absoluteString)`" + ) + + return modifiedRequest + } + } + + // Handle query param stripping + if let request = request.stripQueryParams( + initiatorURL: tab.committedURL, + redirectSourceURL: tab.redirectSourceURL, + isInternalRedirect: tab.isInternalRedirect + ) { + Logger.module.debug( + "Stripping query params for `\(requestURL.absoluteString)`" + ) + return request + } + + // HTTPS by Default + if shouldUpgradeToHttps(url: requestURL, isPrivate: tab.isPrivate), + var urlComponents = URLComponents(url: requestURL, resolvingAgainstBaseURL: true) + { + if let existingUpgradeRequestURL = tab.upgradedHTTPSRequest?.url, + existingUpgradeRequestURL == requestURL + { + // if server redirected https -> http, https load never fails. + // `webView(_:decidePolicyFor:preferences:)` will be called before + // `webView(_:didReceiveServerRedirectForProvisionalNavigation:)` + // so we must prevent upgrade loop. + return handleInvalidHTTPSUpgrade(tab: tab, responseURL: requestURL) + } + // Attempt to upgrade to HTTPS + urlComponents.scheme = "https" + if let upgradedURL = urlComponents.url { + Logger.module.debug( + "Upgrading `\(requestURL.absoluteString)` to HTTPS" + ) + tab.upgradedHTTPSRequest = request + tab.upgradeHTTPSTimeoutTimer?.invalidate() + var modifiedRequest = request + modifiedRequest.url = upgradedURL + + tab.upgradeHTTPSTimeoutTimer = Timer.scheduledTimer( + withTimeInterval: 3.seconds, + repeats: false, + block: { [weak tab, weak self] timer in + guard let self, let tab else { return } + if let url = modifiedRequest.url, + let request = handleInvalidHTTPSUpgrade(tab: tab, responseURL: url) + { + tab.webView?.stopLoading() + tab.webView?.load(request) + } + } + ) + return modifiedRequest + } + } + + return nil + } + + func handleExternalURL( + _ url: URL, + tab: Tab, + requestInfo: WebRequestInfo + ) async -> Bool { + // Do not open external links for child tabs automatically + // The user must tap on the link to open it. + if tab.parent != nil && requestInfo.navigationType != .linkActivated { + return false + } + + // Check if the current url of the caller has changed + if let domain = tab.url?.baseDomain, + domain != tab.externalAppURLDomain + { + tab.externalAppAlertCounter = 0 + tab.isExternalAppAlertSuppressed = false + } + + tab.externalAppURLDomain = tab.url?.baseDomain + + // Do not try to present over existing warning + if tab.isExternalAppAlertPresented || tab.isExternalAppAlertSuppressed { + return false + } + + // External dialog should not be shown for non-active tabs #6687 - #7835 + let isVisibleTab = tab.isTabVisible() + + if !isVisibleTab { + return false + } + + var alertTitle = Strings.openExternalAppURLGenericTitle + + if case let origin = URLOrigin(url: url), !origin.isOpaque { + let displayHost = + "\(origin.scheme)://\(origin.host):\(origin.port)" + alertTitle = String(format: Strings.openExternalAppURLTitle, displayHost) + } else if let displayHost = tab.url?.withoutWWW.host { + alertTitle = String(format: Strings.openExternalAppURLTitle, displayHost) + } + + // Handling condition when Tab is empty when handling an external URL we should remove the tab once the user decides + let removeTabIfEmpty = { [weak self] in + if tab.url == nil { + self?.tabManager.removeTab(tab) + } + } + + // Show the external sceheme invoke alert + @MainActor + func showExternalSchemeAlert( + for tab: Tab, + isSuppressActive: Bool, + openedURLCompletionHandler: @escaping (Bool) -> Void + ) { + // Check if active controller is bvc otherwise do not show show external sceheme alerts + guard shouldShowExternalSchemeAlert() else { + openedURLCompletionHandler(false) + return + } + + view.endEditing(true) + tab.isExternalAppAlertPresented = true + + let popup = AlertPopupView( + imageView: nil, + title: alertTitle, + message: String(format: Strings.openExternalAppURLMessage, url.relativeString), + titleWeight: .semibold, + titleSize: 21 + ) + + tab.externalAppPopup = popup + + if isSuppressActive { + popup.addButton(title: Strings.suppressAlertsActionTitle, type: .destructive) { + [weak tab] () -> PopupViewDismissType in + openedURLCompletionHandler(false) + tab?.isExternalAppAlertSuppressed = true + return .flyDown + } + } else { + popup.addButton(title: Strings.openExternalAppURLDontAllow) { + [weak tab] () -> PopupViewDismissType in + openedURLCompletionHandler(false) + removeTabIfEmpty() + tab?.isExternalAppAlertPresented = false + return .flyDown + } + } + popup.addButton(title: Strings.openExternalAppURLAllow, type: .primary) { + [weak tab] () -> PopupViewDismissType in + UIApplication.shared.open(url, options: [:]) { didOpen in + openedURLCompletionHandler(!didOpen) + } + removeTabIfEmpty() + tab?.isExternalAppAlertPresented = false + return .flyDown + } + popup.showWithType(showType: .flyUp) + } + + func shouldShowExternalSchemeAlert() -> Bool { + guard let rootVC = currentScene?.browserViewController else { + return false + } + + func topViewController(startingFrom viewController: UIViewController) -> UIViewController { + var top = viewController + if let navigationController = top as? UINavigationController, + let vc = navigationController.visibleViewController + { + return topViewController(startingFrom: vc) + } + if let tabController = top as? UITabBarController, + let vc = tabController.selectedViewController + { + return topViewController(startingFrom: vc) + } + while let next = top.presentedViewController { + top = next + } + return top + } + + let isTopController = self == topViewController(startingFrom: rootVC) + let isTopWindow = view.window?.isKeyWindow == true + return isTopController && isTopWindow + } + + tab.externalAppAlertCounter += 1 + + return await withTaskCancellationHandler { + return await withCheckedContinuation { [weak tab] continuation in + guard let tab else { + continuation.resume(returning: false) + return + } + tab.externalAppPopupContinuation = continuation + showExternalSchemeAlert(for: tab, isSuppressActive: tab.externalAppAlertCounter > 2) { + [weak tab] in + tab?.externalAppPopupContinuation = nil + continuation.resume(with: .success($0)) + } + } + } onCancel: { [weak tab] in + tab?.externalAppPopupContinuation?.resume(with: .success(false)) + tab?.externalAppPopupContinuation = nil + } + } + + func recordFinishedPageLoadP3A() { + var storage = P3ATimedStorage.pagesLoadedStorage + storage.add(value: 1, to: Date()) + UmaHistogramRecordValueToBucket( + "Brave.Core.PagesLoaded", + buckets: [ + 0, + .r(1...10), + .r(11...50), + .r(51...100), + .r(101...500), + .r(501...1000), + .r(1001...), + ], + value: storage.combinedValue + ) + } +} + +extension P3ATimedStorage where Value == Int { + fileprivate static var pagesLoadedStorage: Self { .init(name: "paged-loaded", lifetimeInDays: 7) } +} + +extension UTType { + static let textCalendar = UTType(mimeType: "text/calendar")! // Not the same as `calendarEvent` + static let mobileConfiguration = UTType(mimeType: "application/x-apple-aspen-config")! +} + +extension URLRequest { + /// Strip any query params in the request and return a new request if anything is stripped. + /// + /// The `isInternalRedirect` is a true value whenever we redirected the user for debouncing or query-stripping. + /// It's an optimization because we assume that we stripped and debounced the user fully so there should be no further stripping on the next iteration. + /// + /// - Parameters: + /// - initiatorURL: The url page the user is coming from before any redirects + /// - redirectSourceURL: The last redirect url that happened (the true page the user is coming from) + /// - isInternalRedirect: Identifies if we have internally redirected or not. More info in the description + /// - Returns: A modified request if any stripping is to occur. + fileprivate func stripQueryParams( + initiatorURL: URL?, + redirectSourceURL: URL?, + isInternalRedirect: Bool + ) -> URLRequest? { + guard let requestURL = url, + let requestMethod = httpMethod + else { return nil } + + guard + let strippedURL = (requestURL as NSURL).applyingQueryFilter( + initiatorURL: initiatorURL, + redirectSourceURL: redirectSourceURL, + requestMethod: requestMethod, + isInternalRedirect: isInternalRedirect + ) + else { return nil } + + var modifiedRequest = self + modifiedRequest.url = strippedURL + return modifiedRequest + } + + /// Allow local requests only if the request is privileged. + /// If the request is internal or unprivileged, we should deny it. + var isInternalUnprivileged: Bool { + guard let url = url else { + return true + } + + if let url = InternalURL(url) { + return !url.isAuthorized + } else { + return false + } + } +} diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift index c3465394c620..0cb5025b4436 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift @@ -31,7 +31,7 @@ extension BrowserViewController: TopToolbarDelegate { if tabManager.tabsForCurrentMode.isEmpty { return } - displayPageZoom(visible: false) + clearPageZoomDialog() if tabManager.selectedTab == nil { tabManager.selectTab(tabManager.tabsForCurrentMode.first) @@ -940,7 +940,7 @@ extension BrowserViewController: TopToolbarDelegate { /// The selected tab's url let selectedTabOriginalURL = tabManager.selectedTab?.url - displayPageZoom(visible: false) + clearPageZoomDialog() var activities: [UIActivity] = [] if let url = selectedTabURL, let tab = tabManager.selectedTab { diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKDownloadDelegate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKDownloadDelegate.swift index e72641b519f0..a0226cad7c70 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKDownloadDelegate.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKDownloadDelegate.swift @@ -154,7 +154,7 @@ extension BrowserViewController: WKDownloadDelegate { } // Never present the download alert on a tab that isn't visible - guard let webView = download.webView, let tab = tabManager.tabForWebView(webView), + guard let webView = download.webView, let tab = tabManager[webView], tab === tabManager.selectedTab else { return false diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift index 6349a24a22cf..8aae31d37e43 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift @@ -3,165 +3,38 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import BraveCore -import BraveShared import BraveShields -import BraveUI -import BraveWallet import CertificateUtilities import Data -import Favicon import Foundation -import Growth -import LocalAuthentication -import MarketplaceKit import Preferences -import SafariServices import Shared -import UniformTypeIdentifiers import WebKit import os.log -extension WKNavigationAction { - /// Allow local requests only if the request is privileged. - /// If the request is internal or unprivileged, we should deny it. - var isInternalUnprivileged: Bool { - guard let url = request.url else { - return true - } - - if let url = InternalURL(url) { - return !url.isAuthorized - } else { - return false - } - } -} - -extension WKNavigationType: CustomDebugStringConvertible { - public var debugDescription: String { - switch self { - case .linkActivated: return "linkActivated" - case .formResubmitted: return "formResubmitted" - case .backForward: return "backForward" - case .formSubmitted: return "formSubmitted" - case .other: return "other" - case .reload: return "reload" - @unknown default: - return "Unknown(\(rawValue))" - } - } -} - -extension UTType { - static let textCalendar = UTType(mimeType: "text/calendar")! // Not the same as `calendarEvent` - static let mobileConfiguration = UTType(mimeType: "application/x-apple-aspen-config")! -} - -// MARK: WKNavigationDelegate extension BrowserViewController: WKNavigationDelegate { - private static let log = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "navigation") - public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - if tabManager.selectedTab?.webView !== webView { - return - } - toolbarVisibilityViewModel.toolbarState = .expanded - - // check if web view is loading a different origin than the one currently loaded - if let selectedTab = tabManager.selectedTab, - selectedTab.url?.origin != webView.url?.origin - { - - // new site has a different origin, hide wallet icon. - tabManager.selectedTab?.isWalletIconVisible = false - // new site, reset connected addresses - tabManager.selectedTab?.clearSolanaConnectedAccounts() - // close wallet panel if it's open - if let popoverController = self.presentedViewController as? PopoverController, - popoverController.contentController is WalletPanelHostingController - { - self.dismiss(animated: true) - } - } - - displayPageZoom(visible: false) - - // If we are going to navigate to a new page, hide the reader mode button. Unless we - // are going to a about:reader page. Then we keep it on screen: it will change status - // (orange color) as soon as the page has loaded. - if let url = webView.url { - if !url.isInternalURL(for: .readermode) { - topToolbar.updateReaderModeState(.unavailable) - hideReaderModeBar(animated: false) - } - } - - hideToastsOnNavigationStartIfNeeded(tabManager) - - // If we are going to navigate to a new page, hide the translate button. - topToolbar.updateTranslateButtonState(.unavailable) - resetRedirectChain(webView) - - // Append source URL to redirect chain - appendUrlToRedirectChain(webView) - } - - fileprivate func resetRedirectChain(_ webView: WKWebView) { - if let tab = tab(for: webView) { - tab.redirectChain = [] - } - } - - fileprivate func appendUrlToRedirectChain(_ webView: WKWebView) { - // The redirect chain MUST be sorted by the order of redirects with the - // first URL being the source URL. - if let tab = tab(for: webView), let url = webView.url { - tab.redirectChain.append(url) - } - } - - // Recognize an Apple Maps URL. This will trigger the native app. But only if a search query is present. - // Otherwise it could just be a visit to a regular page on maps.apple.com. - // Exchaging https/https scheme with maps in order to open URLS properly on Apple Maps - fileprivate func isAppleMapsURL(_ url: URL) -> (enabled: Bool, url: URL)? { - if url.scheme == "http" || url.scheme == "https" { - if url.host == "maps.apple.com" && url.query != nil { - guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return nil - } - urlComponents.scheme = "maps" - - if let url = urlComponents.url { - return (true, url) - } - return nil - } - } - return (false, url) + guard let tab = tabManager[webView] else { return } + self.tabDidStartWebViewNavigation(tab) } - // Recognize a iTunes Store URL. These all trigger the native apps. Note that appstore.com and phobos.apple.com - // used to be in this list. I have removed them because they now redirect to itunes.apple.com. If we special case - // them then iOS will actually first open Safari, which then redirects to the app store. This works but it will - // leave a 'Back to Safari' button in the status bar, which we do not want. - fileprivate func isStoreURL(_ url: URL) -> Bool { - let isStoreScheme = ["itms-apps", "itms-appss", "itmss"].contains(url.scheme) - if isStoreScheme { - return true + private func defaultAllowPolicy( + for navigationAction: WKNavigationAction + ) -> WKNavigationActionPolicy { + let isPrivateBrowsing = tabManager.privateBrowsingManager.isPrivateBrowsing + if shouldBlockUniversalLinksFor( + request: navigationAction.request, + isPrivateBrowsing: isPrivateBrowsing + ) { + // Stop Brave from opening universal links by using the private enum value + // `_WKNavigationActionPolicyAllowWithoutTryingAppLink` which is defined here: + // https://github.com/WebKit/WebKit/blob/main/Source/WebKit/UIProcess/API/Cocoa/WKNavigationDelegatePrivate.h#L62 + let allowDecision = + WKNavigationActionPolicy(rawValue: WKNavigationActionPolicy.allow.rawValue + 2) ?? .allow + return allowDecision } - - let isHttpScheme = ["http", "https"].contains(url.scheme) - let isAppStoreHost = ["itunes.apple.com", "apps.apple.com", "appsto.re"].contains(url.host) - return isHttpScheme && isAppStoreHost - } - - // This is the place where we decide what to do with a new navigation action. There are a number of special schemes - // and http(s) urls that need to be handled in a different way. All the logic for that is inside this delegate - // method. - - fileprivate func isUpholdOAuthAuthorization(_ url: URL) -> Bool { - return url.scheme == "rewards" && url.host == "uphold" + return .allow } @MainActor @@ -170,516 +43,91 @@ extension BrowserViewController: WKNavigationDelegate { decidePolicyFor navigationAction: WKNavigationAction, preferences: WKWebpagePreferences ) async -> (WKNavigationActionPolicy, WKWebpagePreferences) { - guard var requestURL = navigationAction.request.url else { - return (.cancel, preferences) - } - - let tab = tab(for: webView) - if tab?.isExternalAppAlertPresented == true { - tab?.externalAppPopup?.dismissWithType(dismissType: .noAnimation) - tab?.externalAppPopupContinuation?.resume(with: .success(false)) - tab?.externalAppPopupContinuation = nil - tab?.externalAppPopup = nil - } - - if InternalURL.isValid(url: requestURL) { - if navigationAction.navigationType != .backForward, navigationAction.isInternalUnprivileged, - navigationAction.sourceFrame != nil || navigationAction.targetFrame?.isMainFrame == false - || navigationAction.request.cachePolicy == .useProtocolCachePolicy - { - Logger.module.warning("Denying unprivileged request: \(navigationAction.request)") - return (.cancel, preferences) - } - - return (.allow, preferences) - } - - if requestURL.scheme == "about" { - return (.allow, preferences) - } - - if requestURL.isBookmarklet { - return (.cancel, preferences) - } - - // Universal links do not work if the request originates from the app, manual handling is required. - if let mainDocURL = navigationAction.request.mainDocumentURL, - let universalLink = UniversalLinkManager.universalLinkType(for: mainDocURL, checkPath: true) - { - switch universalLink { - case .buyVPN: - presentCorrespondingVPNViewController() - return (.cancel, preferences) - } - } - - // First special case are some schemes that are about Calling. We prompt the user to confirm this action. This - // gives us the exact same behaviour as Safari. - // tel:, facetime:, facetime-audio:, already has its own native alert displayed by the OS! - if ["sms", "mailto"].contains(requestURL.scheme) { - let shouldOpen = await handleExternalURL( - requestURL, - tab: tab, - navigationAction: navigationAction - ) - return (shouldOpen ? .allow : .cancel, preferences) - } - - // Let the system's prompt handle these. We can't let these cases fall-through, as the last check in this file will - // assume it's an external app prompt - if ["tel", "facetime", "facetime-audio"].contains(requestURL.scheme) { - let shouldOpen = await withCheckedContinuation { continuation in - UIApplication.shared.open(requestURL, options: [:]) { didOpen in - continuation.resume(returning: didOpen) - } - } - return (shouldOpen ? .allow : .cancel, preferences) - } - - // Second special case are a set of URLs that look like regular http links, but should be handed over to iOS - // instead of being loaded in the webview. - // In addition we are exchaging actual scheme with "maps" scheme - // So the Apple maps URLs will open properly - if let mapsURL = isAppleMapsURL(requestURL), mapsURL.enabled { - let shouldOpen = await handleExternalURL( - mapsURL.url, - tab: tab, - navigationAction: navigationAction - ) - return (shouldOpen ? .allow : .cancel, preferences) - } - - if #available(iOS 17.4, *), !ProcessInfo.processInfo.isiOSAppOnVisionOS { - // Accessing `MarketplaceKitURIScheme` on Vision OS results in a crash - if requestURL.scheme == MarketplaceKitURIScheme { - if let queryItems = URLComponents(url: requestURL, resolvingAgainstBaseURL: false)? - .queryItems, - let adpURL = queryItems.first(where: { - $0.name.caseInsensitiveCompare("alternativeDistributionPackage") == .orderedSame - })?.value?.asURL, - navigationAction.sourceFrame.isMainFrame, - adpURL.baseDomain == navigationAction.sourceFrame.request.url?.baseDomain - { - return (.allow, preferences) - } - return (.cancel, preferences) - } - } - - if isStoreURL(requestURL) { - let shouldOpen = await handleExternalURL( - requestURL, - tab: tab, - navigationAction: navigationAction - ) - return (shouldOpen ? .allow : .cancel, preferences) - } - - // handles Decentralized DNS - if let decentralizedDNSHelper = self.decentralizedDNSHelperFor(url: requestURL), - navigationAction.targetFrame?.isMainFrame == true - { - topToolbar.locationView.loading = true - let result = await decentralizedDNSHelper.lookup( - domain: requestURL.schemelessAbsoluteDisplayString - ) - topToolbar.locationView.loading = tabManager.selectedTab?.loading ?? false - guard !Task.isCancelled else { // user pressed stop, or typed new url - return (.cancel, preferences) - } - switch result { - case .loadInterstitial(let service): - showWeb3ServiceInterstitialPage(service: service, originalURL: requestURL) - return (.cancel, preferences) - case .load(let resolvedURL): - if resolvedURL.isIPFSScheme, - let resolvedIPFSURL = braveCore.ipfsAPI.resolveGatewayUrl(for: resolvedURL) - { - requestURL = resolvedIPFSURL - } else { - requestURL = resolvedURL - } - case .none: - break - } - } - - let isPrivateBrowsing = privateBrowsingManager.isPrivateBrowsing - tab?.rewardsReportingState.isNewNavigation = - navigationAction.navigationType != WKNavigationType.backForward - && navigationAction.navigationType != WKNavigationType.reload - tab?.currentRequestURL = requestURL - - // Website redirection logic - if requestURL.isWebPage(includeDataURIs: false), - navigationAction.targetFrame?.isMainFrame == true, - let redirectURL = WebsiteRedirects.redirect(for: requestURL) - { - - tab?.loadRequest(URLRequest(url: redirectURL)) + guard let tab = tabManager[webView], + let requestURL = navigationAction.request.url + else { return (.cancel, preferences) } + let isMainFrame = + navigationAction.targetFrame?.isMainFrame ?? navigationAction.sourceFrame.isMainFrame - let signpostID = ContentBlockerManager.signpost.makeSignpostID() - let state = ContentBlockerManager.signpost.beginInterval("decidePolicyFor", id: signpostID) - - // before loading any ad-block scripts - // await the preparation of the ad-block services - await LaunchHelper.shared.prepareAdBlockServices( - adBlockService: self.braveCore.adblockService - ) - - if let mainDocumentURL = navigationAction.request.mainDocumentURL { - if mainDocumentURL != tab?.currentPageData?.mainFrameURL { - // Clear the current page data if the page changes. - // Do this before anything else so that we have a clean slate. - tab?.currentPageData = PageData(mainFrameURL: mainDocumentURL) - } - - // Handle the "forget me" feature on navigation - if let tab = tab, navigationAction.targetFrame?.isMainFrame == true { - // Cancel any forget data requests - tabManager.cancelForgetData(for: mainDocumentURL, in: tab) - - // Forget any websites that have "forget me" enabled - // if we navigated away from the previous domain - if let currentURL = tab.url, - !InternalURL.isValid(url: currentURL), - let currentETLDP1 = currentURL.baseDomain, - mainDocumentURL.baseDomain != currentETLDP1 - { - tabManager.forgetDataIfNeeded(for: currentURL, in: tab) - } - } - - let domainForMainFrame = Domain.getOrCreate( - forUrl: mainDocumentURL, - persistent: !isPrivateBrowsing - ) - - if let tab = tab, - let modifiedRequest = getInternalRedirect( - from: navigationAction, - in: tab, - domainForMainFrame: domainForMainFrame - ) - { - tab.isInternalRedirect = true - tab.loadRequest(modifiedRequest) - - if let url = modifiedRequest.url { - Self.log.debug( - "Redirected to `\(url.absoluteString, privacy: .private)`" - ) - } - - ContentBlockerManager.signpost.endInterval( - "decidePolicyFor", - state, - "Redirected navigation" - ) - return (.cancel, preferences) - } else { - tab?.isInternalRedirect = false - } - - // Set some additional user scripts - if navigationAction.targetFrame?.isMainFrame == true { - tab?.setScripts(scripts: [ - // Add de-amp script - // The user script manager will take care to not reload scripts if this value doesn't change - .deAmp: braveCore.deAmpPrefs.isDeAmpEnabled, - - // Add request blocking script - // This script will block certian `xhr` and `window.fetch()` requests - .requestBlocking: requestURL.isWebPage(includeDataURIs: false) - && domainForMainFrame.globalBlockAdsAndTrackingLevel.isEnabled, - - // The tracker protection script - // This script will track what is blocked and increase stats - .trackerProtectionStats: requestURL.isWebPage(includeDataURIs: false) - && domainForMainFrame.globalBlockAdsAndTrackingLevel.isEnabled, - - // Add Brave search result ads processing script - // This script will process search result ads on the Brave search page. - .searchResultAd: BraveAds.shouldSupportSearchResultAds() - && BraveSearchManager.isValidURL(requestURL) && !isPrivateBrowsing, - ]) - } - - // Check if custom user scripts must be added to or removed from the web view. - if let targetFrame = navigationAction.targetFrame { - tab?.currentPageData?.addSubframeURL( - forRequestURL: requestURL, - isForMainFrame: targetFrame.isMainFrame - ) - let scriptTypes = - await tab?.currentPageData?.makeUserScriptTypes( - domain: domainForMainFrame, - isDeAmpEnabled: braveCore.deAmpPrefs.isDeAmpEnabled - ) ?? [] - tab?.setCustomUserScript(scripts: scriptTypes) - } - } - - // Brave Search logic. - - if navigationAction.targetFrame?.isMainFrame == true, - BraveSearchManager.isValidURL(requestURL) - { - let domain = Domain.getOrCreate(forUrl: requestURL, persistent: !isPrivateBrowsing) - let adsBlockingShieldUp = domain.globalBlockAdsAndTrackingLevel.isEnabled - let isAggressiveAdsBlocking = - domain.globalBlockAdsAndTrackingLevel.isAggressive - && adsBlockingShieldUp - - if BraveSearchResultAdManager.shouldTriggerSearchResultAdClickedEvent( - requestURL, - isPrivateBrowsing: isPrivateBrowsing, - isAggressiveAdsBlocking: isAggressiveAdsBlocking - ) { - // Ensure the webView is not a link preview popup. - if self.presentedViewController == nil { - let showSearchResultAdClickedPrivacyNotice = - rewards.ads.shouldShowSearchResultAdClickedInfoBar() - BraveSearchResultAdManager.maybeTriggerSearchResultAdClickedEvent( - requestURL, - rewards: rewards, - completion: { [weak self] success in - guard let self, success, showSearchResultAdClickedPrivacyNotice else { - return - } - let searchResultClickedInfobar = SearchResultAdClickedInfoBar( - tabManager: self.tabManager - ) - self.show(toast: searchResultClickedInfobar, duration: nil) - } - ) - } - } else { - // The Brave-Search-Ads header should be added with a negative value when all - // of the following conditions are met: - // - The current tab is not a Private tab - // - Brave Rewards is enabled. - // - The "Search Ads" is opted-out. - // - The requested URL host is one of the Brave Search domains. - if !isPrivateBrowsing && rewards.isEnabled - && !rewards.ads.isOptedInToSearchResultAds() - && navigationAction.request.allHTTPHeaderFields?["Brave-Search-Ads"] == nil - { - var modifiedRequest = URLRequest(url: requestURL) - modifiedRequest.setValue("?0", forHTTPHeaderField: "Brave-Search-Ads") - tab?.loadRequest(modifiedRequest) - ContentBlockerManager.signpost.endInterval( - "decidePolicyFor", - state, - "Redirected to search" - ) - return (.cancel, preferences) - } - - tab?.braveSearchResultAdManager = BraveSearchResultAdManager( - url: requestURL, - rewards: rewards, - isPrivateBrowsing: isPrivateBrowsing, - isAggressiveAdsBlocking: isAggressiveAdsBlocking - ) - } - + // Setup braveSearchManager on the tab as it requires accessing WKWebView cookies + if isMainFrame, BraveSearchManager.isValidURL(requestURL) { // We fetch cookies to determine if backup search was enabled on the website. - let profile = self.profile + let cookies = await webView.configuration.websiteDataStore.httpCookieStore.allCookies() - tab?.braveSearchManager = BraveSearchManager( - profile: profile, + tab.braveSearchManager = BraveSearchManager( url: requestURL, cookies: cookies ) - if let braveSearchManager = tab?.braveSearchManager { - braveSearchManager.fallbackQueryResultsPending = true - braveSearchManager.shouldUseFallback { backupQuery in - guard let query = backupQuery else { - braveSearchManager.fallbackQueryResultsPending = false - return - } - - if query.found { - braveSearchManager.fallbackQueryResultsPending = false - } else { - braveSearchManager.backupSearch(with: query) { completion in - braveSearchManager.fallbackQueryResultsPending = false - tab?.injectResults() - } - } - } - } } else { - tab?.braveSearchResultAdManager = nil - tab?.braveSearchManager = nil - } + tab.braveSearchManager = nil + } + + let navigationType: WebNavigationType = + switch navigationAction.navigationType { + case .backForward: .backForward + case .formSubmitted: .formSubmitted + case .formResubmitted: .formResubmitted + case .linkActivated: .linkActivated + case .other: .other + case .reload: .reload + @unknown default: .other + } + + let isSyntheticClick = + navigationAction.responds(to: Selector(("_syntheticClickType"))) + && navigationAction.value(forKey: "syntheticClickType") as? Int == 0 + + let policy = await self.tab( + tab, + shouldAllowRequest: navigationAction.request, + requestInfo: .init( + navigationType: navigationType, + isMainFrame: isMainFrame, + isNewWindow: navigationAction.targetFrame == nil, + isUserInitiated: !isSyntheticClick + ) + ) - // This is the normal case, opening a http or https url, which we handle by loading them in this WKWebView. We - // always allow this. Additionally, data URIs are also handled just like normal web pages. + if policy == .cancel { + return (.cancel, preferences) + } if ["http", "https", "data", "blob", "file"].contains(requestURL.scheme) { + // Handle updating the user agent if navigationAction.targetFrame?.isMainFrame == true { - tab?.updateUserAgent(webView, newURL: requestURL) - - if let etldP1 = requestURL.baseDomain, - tab?.proceedAnywaysDomainList.contains(etldP1) == false - { - let domain = Domain.getOrCreate(forUrl: requestURL, persistent: !isPrivateBrowsing) - - let shouldBlock = await AdBlockGroupsManager.shared.shouldBlock( - requestURL: requestURL, - sourceURL: requestURL, - resourceType: .document, - domain: domain - ) - - if shouldBlock, let url = requestURL.encodeEmbeddedInternalURL(for: .blocked) { - let request = PrivilegedRequest(url: url) as URLRequest - tab?.loadRequest(request) - ContentBlockerManager.signpost.endInterval( - "decidePolicyFor", - state, - "Blocked navigation" - ) - return (.cancel, preferences) - } - } - } - - pendingRequests[requestURL.absoluteString] = navigationAction.request - - // Adblock logic, - // Only use main document URL, not the request URL - // If an iFrame is loaded, shields depending on the main frame, not the iFrame request - - // Weird behavior here with `targetFram` and `sourceFrame`, on refreshing page `sourceFrame` is not nil (it is non-optional) - // however, it is still an uninitialized object, making it an unreliable source to compare `isMainFrame` against. - // Rather than using `sourceFrame.isMainFrame` or even comparing `sourceFrame == targetFrame`, a simple URL check is used. - // No adblocking logic is be used on session restore urls. It uses javascript to retrieve the - // request then the page is reloaded with a proper url and adblocking rules are applied. - if let mainDocumentURL = navigationAction.request.mainDocumentURL, - mainDocumentURL.schemelessAbsoluteString == requestURL.schemelessAbsoluteString, - !(InternalURL(requestURL)?.isSessionRestore ?? false), - navigationAction.sourceFrame.isMainFrame - || navigationAction.targetFrame?.isMainFrame == true - { - // Identify specific block lists that need to be applied to the requesting domain - let domainForShields = Domain.getOrCreate( - forUrl: mainDocumentURL, - persistent: !isPrivateBrowsing - ) - - // Load rule lists - let ruleLists = await AdBlockGroupsManager.shared.ruleLists(for: domainForShields) - tab?.contentBlocker.set(ruleLists: ruleLists) - } - - let documentTargetURL: URL? = - navigationAction.request.mainDocumentURL ?? navigationAction.targetFrame?.request - .mainDocumentURL ?? requestURL // Should be the same as the sourceFrame URL - if let documentTargetURL = documentTargetURL { - let domainForShields = Domain.getOrCreate( - forUrl: documentTargetURL, - persistent: !isPrivateBrowsing - ) - let isScriptsEnabled = !domainForShields.isShieldExpected( - .noScript, - considerAllShieldsOption: true - ) - - // Due to a bug in iOS WKWebpagePreferences.allowsContentJavaScript does NOT work! - // https://github.com/brave/brave-ios/issues/8585 - // - // However, the deprecated API WKWebViewConfiguration.preferences.javaScriptEnabled DOES work! - // Even though `configuration` is @NSCopying, somehow this actually updates the preferences LIVE!! - // This follows the same behaviour as Safari - // - // - Brandon T. - // - preferences.allowsContentJavaScript = isScriptsEnabled - webView.configuration.preferences.javaScriptEnabled = isScriptsEnabled - } - - // Cookie Blocking code below - if let tab = tab { - tab.setScript(script: .cookieBlocking, enabled: Preferences.Privacy.blockAllCookies.value) - } - - // Reset the block alert bool on new host. - if let newHost: String = requestURL.host, let oldHost: String = webView.url?.host, - newHost != oldHost - { - self.tabManager.selectedTab?.alertShownCount = 0 - self.tabManager.selectedTab?.blockAllAlerts = false + tab.updateUserAgent(webView, newURL: requestURL) } - self.shouldDownloadNavigationResponse = navigationAction.shouldPerformDownload - ContentBlockerManager.signpost.endInterval("decidePolicyFor", state) - return (.allow, preferences) - } - - // Standard schemes are handled in previous if-case. - // This check handles custom app schemes to open external apps. - // Our own 'brave' scheme does not require the switch-app prompt. - if requestURL.scheme?.contains("brave") == false { - // Do not allow opening external URLs from child tabs - let shouldOpen = await handleExternalURL( - requestURL, - tab: tab, - navigationAction: navigationAction + // Handle blocking JS + let documentTargetURL = navigationAction.request.mainDocumentURL ?? requestURL + let domainForShields = Domain.getOrCreate( + forUrl: documentTargetURL, + persistent: !privateBrowsingManager.isPrivateBrowsing + ) + let isScriptsEnabled = !domainForShields.isShieldExpected( + .noScript, + considerAllShieldsOption: true ) - let isSyntheticClick = - navigationAction.responds(to: Selector(("_syntheticClickType"))) - && navigationAction.value(forKey: "syntheticClickType") as? Int == 0 - - // Do not show error message for JS navigated links or redirect - // as it's not the result of a user action. - if !shouldOpen, navigationAction.navigationType == .linkActivated && !isSyntheticClick { - if self.presentedViewController == nil && self.presentingViewController == nil - && tab?.isExternalAppAlertPresented == false && tab?.isExternalAppAlertSuppressed == false - { - - return await withCheckedContinuation { continuation in - // This alert does not need to be a BrowserAlertController because we return a policy - // without waiting for user action - let alert = UIAlertController( - title: Strings.unableToOpenURLErrorTitle, - message: Strings.unableToOpenURLError, - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: Strings.OKString, style: .default, handler: nil)) - self.present(alert, animated: true) { - continuation.resume(returning: (shouldOpen ? .allow : .cancel, preferences)) - } - } - } - } - return (shouldOpen ? .allow : .cancel, preferences) + // Due to a bug in iOS WKWebpagePreferences.allowsContentJavaScript does NOT work! + // https://github.com/brave/brave-ios/issues/8585 + // + // However, the deprecated API WKWebViewConfiguration.preferences.javaScriptEnabled DOES work! + // Even though `configuration` is @NSCopying, somehow this actually updates the preferences LIVE!! + // This follows the same behaviour as Safari + // + // - Brandon T. + // + preferences.allowsContentJavaScript = isScriptsEnabled + webView.configuration.preferences.javaScriptEnabled = isScriptsEnabled + + // Downloads + self.shouldDownloadNavigationResponse = navigationAction.shouldPerformDownload } - return (.cancel, preferences) - } - - /// Handles a link by opening it in an SFSafariViewController and presenting it on the BVC. - /// - /// This is unfortunately neccessary to handle certain downloads natively such as ics/calendar invites and - /// mobileconfiguration files. - /// - /// The user unfortunately has to dismiss it manually after they have handled the file. - /// Chrome iOS does the same - private func handleLinkWithSafariViewController(_ url: URL, tab: Tab) { - let vc = SFSafariViewController(url: url, configuration: .init()) - vc.modalPresentationStyle = .formSheet - self.present(vc, animated: true) - - // If the website opened this URL in a separate tab, remove the empty tab - if tab.url == nil || tab.url?.absoluteString == "about:blank" { - tabManager.removeTab(tab) - } + return (defaultAllowPolicy(for: navigationAction), preferences) } @MainActor @@ -687,97 +135,31 @@ extension BrowserViewController: WKNavigationDelegate { _ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse ) async -> WKNavigationResponsePolicy { - let isPrivateBrowsing = privateBrowsingManager.isPrivateBrowsing + guard let tab = tabManager[webView] else { return .cancel } + let responseURL = navigationResponse.response.url + let request = responseURL.flatMap { pendingRequests[$0.absoluteString] } let response = navigationResponse.response - let responseURL = response.url - let tab = tab(for: webView) - - // Store the response in the tab - if let responseURL = responseURL { - tab?.responses[responseURL] = response - } - - // Check if we upgraded to https and if so we need to update the url of frame evaluations - if let responseURL = responseURL, - let domain = tab?.currentPageData?.domain(persistent: !isPrivateBrowsing), - tab?.currentPageData?.upgradeFrameURL( - forResponseURL: responseURL, - isForMainFrame: navigationResponse.isForMainFrame - ) == true - { - let scriptTypes = - await tab?.currentPageData?.makeUserScriptTypes( - domain: domain, - isDeAmpEnabled: braveCore.deAmpPrefs.isDeAmpEnabled - ) ?? [] - tab?.setCustomUserScript(scripts: scriptTypes) - } - - if let tab = tab, let responseURL = responseURL, - let response = response as? HTTPURLResponse - { - let internalUrl = InternalURL(responseURL) - - tab.rewardsReportingState.httpStatusCode = response.statusCode - - if !tab.rewardsReportingState.wasRestored { - tab.rewardsReportingState.wasRestored = internalUrl?.isSessionRestore == true + defer { + if let responseURL { + pendingRequests.removeValue(forKey: responseURL.absoluteString) } } + let decision: WebPolicyDecision = await self.tab( + tab, + shouldAllowResponse: navigationResponse.response, + responseInfo: .init(isForMainFrame: navigationResponse.isForMainFrame) + ) - var request: URLRequest? - if let url = responseURL { - request = pendingRequests.removeValue(forKey: url.absoluteString) + if decision == .cancel { + return .cancel } + /// WebKit Download handling + // We can only show this content in the web view if this web view is not pending // download via the context menu. let canShowInWebView = navigationResponse.canShowMIMEType - let mimeTypesThatRequireSFSafariViewControllerHandling: [UTType] = [ - .textCalendar, - .mobileConfiguration, - ] - - let mimeTypesThatRequireSFSafariViewControllerHandlingTexts: [UTType: (String, String)] = [ - .textCalendar: (Strings.openTextCalendarAlertTitle, Strings.openTextCalendarAlertDescription), - .mobileConfiguration: ( - Strings.openMobileConfigurationAlertTitle, Strings.openMobileConfigurationAlertDescription - ), - ] - - // SFSafariViewController only supports http/https links - if navigationResponse.isForMainFrame, let url = responseURL, - url.isWebPage(includeDataURIs: false), - let tab, tab === tabManager.selectedTab, - let mimeType = response.mimeType.flatMap({ UTType(mimeType: $0) }), - mimeTypesThatRequireSFSafariViewControllerHandling.contains(mimeType), - let (alertTitle, alertMessage) = mimeTypesThatRequireSFSafariViewControllerHandlingTexts[ - mimeType - ] - { - // Do what Chromium does: https://source.chromium.org/chromium/chromium/src/+/main:ios/chrome/browser/download/ui_bundled/safari_download_coordinator.mm;l=100;bpv=1;bpt=1?q=presentMobileConfigAlertFromURL&ss=chromium%2Fchromium%2Fsrc - // and present an alert before showing the Safari View Controller - let alert = UIAlertController( - title: alertTitle, - message: String.init( - format: alertMessage, - url.absoluteString - ), - preferredStyle: .alert - ) - alert.addAction( - UIAlertAction(title: Strings.OBContinueButton, style: .default) { [weak self] _ in - self?.handleLinkWithSafariViewController(url, tab: tab) - } - ) - - alert.addAction(UIAlertAction(title: Strings.cancelButtonTitle, style: .cancel)) - present(alert, animated: true) - - return .cancel - } - // If the response has the attachment content-disposition, download it let mimeType = response.mimeType ?? MIMEType.octetStream let isAttachment = @@ -799,22 +181,6 @@ extension BrowserViewController: WKNavigationDelegate { return .download } - // If the content type is not HTML, create a temporary document so it can be downloaded and - // shared to external applications later. Otherwise, clear the old temporary document. - if let tab = tab, navigationResponse.isForMainFrame { - if response.mimeType?.isKindOfHTML == false, let request = request { - tab.temporaryDocument = TemporaryDocument( - preflightResponse: response, - request: request, - tab: tab - ) - } else { - tab.temporaryDocument = nil - } - - tab.mimeType = response.mimeType - } - if canShowInWebView { return .allow } @@ -934,7 +300,7 @@ extension BrowserViewController: WKNavigationDelegate { ) // Handle the error later in `didFailProvisionalNavigation` - self.tab(for: webView)?.sslPinningError = error + tabManager[webView]?.sslPinningError = error return (.cancelAuthenticationChallenge, nil) } @@ -949,59 +315,27 @@ extension BrowserViewController: WKNavigationDelegate { protectionSpace.authenticationMethod == NSURLAuthenticationMethodHTTPBasic || protectionSpace.authenticationMethod == NSURLAuthenticationMethodHTTPDigest || protectionSpace.authenticationMethod == NSURLAuthenticationMethodNTLM, - let tab = tab(for: webView) + let tab = tabManager[webView] else { return (.performDefaultHandling, nil) } - // The challenge may come from a background tab, so ensure it's the one visible. - tabManager.selectTab(tab) - tab.isDisplayingBasicAuthPrompt = true - defer { tab.isDisplayingBasicAuthPrompt = false } - - let isHidden = webView.isHidden - defer { webView.isHidden = isHidden } - - // Manually trigger a `url` change notification - if host != tab.url?.host { - webView.isHidden = true - - observeValue( - forKeyPath: KVOConstants.url.keyPath, - of: webView, - change: [.newKey: webView.url as Any, .kindKey: 1], - context: nil - ) - } - - do { - let credentials = try await Authenticator.handleAuthRequest( - self, - credential: credential, - protectionSpace: protectionSpace, - previousFailureCount: previousFailureCount - ) - - if BasicAuthCredentialsManager.validDomains.contains(host) { - BasicAuthCredentialsManager.setCredential( - origin: origin, - credential: credentials.credentials - ) - } + let resolvedCredential = await self.tab( + tab, + didRequestHTTPAuthFor: protectionSpace, + proposedCredential: credential, + previousFailureCount: previousFailureCount + ) - return (.useCredential, credentials.credentials) - } catch { + if let resolvedCredential { + return (.useCredential, resolvedCredential) + } else { return (.rejectProtectionSpace, nil) } } public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - guard let tab = tab(for: webView) else { return } - - // Reset the stored http request now that load has committed. - tab.upgradedHTTPSRequest = nil - tab.upgradeHTTPSTimeoutTimer?.invalidate() - tab.upgradeHTTPSTimeoutTimer = nil + guard let tab = tabManager[webView] else { return } // Set the committed url which will also set tab.url tab.committedURL = webView.url @@ -1018,122 +352,20 @@ extension BrowserViewController: WKNavigationDelegate { context: nil ) - // Need to evaluate Night mode script injection after url is set inside the Tab - tab.nightMode = Preferences.General.nightModeEnabled.value - tab.clearSolanaConnectedAccounts() - - // Dismiss any alerts that are showing on page navigation. - if let alert = tab.shownPromptAlert { - alert.dismiss(animated: false) - } - - // Providers need re-initialized when changing origin to align with desktop in - // `BraveContentBrowserClient::RegisterBrowserInterfaceBindersForFrame` - // https://github.com/brave/brave-core/blob/1.52.x/browser/brave_content_browser_client.cc#L608 - if let provider = braveCore.braveWalletAPI.ethereumProvider( - with: tab, - isPrivateBrowsing: tab.isPrivate - ) { - // The Ethereum provider will fetch allowed accounts from it's delegate (the tab) - // on initialization. Fetching allowed accounts requires the origin; so we need to - // initialize after `commitedURL` / `url` are updated above - tab.walletEthProvider = provider - tab.walletEthProvider?.initialize(eventsListener: tab) - } - if let provider = braveCore.braveWalletAPI.solanaProvider( - with: tab, - isPrivateBrowsing: tab.isPrivate - ) { - tab.walletSolProvider = provider - tab.walletSolProvider?.initialize(eventsListener: tab) - } - - // Notify of tab changes after navigation completes but before notifying that - // the tab has loaded, so that any listeners can process the tab changes - // before the tab is considered loaded. - rewards.maybeNotifyTabDidChange( - tab: tab, - isSelected: tabManager.selectedTab == tab - ) - rewards.maybeNotifyTabDidLoad(tab: tab) - - // The toolbar and url bar changes can not be - // on different tab than selected. Or the webview - // previews and etc will effect the status - guard tabManager.selectedTab === tab else { - return - } - - updateUIForReaderHomeStateForTab(tab) - updateBackForwardActionStatus(for: webView) + self.tabDidCommitWebViewNavigation(tab) } public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - if let tab = tabManager[webView] { - // Inject app's IAP receipt for Brave SKUs if necessary - if !tab.isPrivate { - Task { @MainActor in - await BraveSkusAccountLink.injectLocalStorage(webView: webView) - } - } - - // Second attempt to inject results to the BraveSearch. - // This will be called if we got fallback results faster than - // the page navigation. - if let braveSearchManager = tab.braveSearchManager { - // Fallback results are ready before navigation finished, - // they must be injected here. - if !braveSearchManager.fallbackQueryResultsPending { - tab.injectResults() - } - } else { - // If not applicable, null results must be injected regardless. - // The website waits on us until this is called with either results or null. - tab.injectResults() - } - - navigateInTab(tab: tab, to: navigation) - rewards.reportTabUpdated( - tab: tab, - isSelected: tabManager.selectedTab == tab, - isPrivate: privateBrowsingManager.isPrivateBrowsing - ) - tab.reportPageLoad(to: rewards, redirectChain: tab.redirectChain) - // Reset `rewardsReportingState` tab property so that listeners - // can be notified of tab changes when a new navigation happens. - tab.rewardsReportingState = RewardsTabChangeReportingState() - - Task { - await tab.updateEthereumProperties() - await tab.updateSolanaProperties() - } - - if webView.url?.isLocal == false { - // Set rewards inter site url as new page load url. - tab.rewardsXHRLoadURL = webView.url - } - - if tab.walletEthProvider != nil { - tab.emitEthereumEvent(.connect) - } - - if let lastCommittedURL = tab.committedURL { - maybeRecordBraveSearchDailyUsage(url: lastCommittedURL) - } - } - - // Added this method to determine long press menu actions better - // Since these actions are depending on tabmanager opened WebsiteCount - updateToolbarUsingTabManager(tabManager) - - recordFinishedPageLoadP3A() + guard let tab = tabManager[webView] else { return } + self.tabDidFinishWebViewNavigation(tab) } public func webView( _ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation! ) { - appendUrlToRedirectChain(webView) + guard let tab = tabManager[webView], let url = webView.url else { return } + tab.redirectChain.append(url) } /// Invoked when an error occurs while starting to load data for the main frame. @@ -1142,18 +374,26 @@ extension BrowserViewController: WKNavigationDelegate { didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error ) { - guard let tab = tab(for: webView) else { return } + guard let tab = tabManager[webView] else { return } - // 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. var error = error as NSError - if error.domain == "WebKitErrorDomain" && error.code == 102 { - return + if error.domain == "WebKitErrorDomain" { + if error.code == 102 { + // 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. + return + } + if error.code == WKError.webContentProcessTerminated.rawValue { + Logger.module.warning("WebContent process has crashed. Trying to reload to restart it.") + tab.reload() + return + } } - if checkIfWebContentProcessHasCrashed(webView, error: error) { + if self.tab(tab, didFailWebViewNavigationWithError: error) { + // Handled in shared code return } @@ -1161,19 +401,6 @@ extension BrowserViewController: WKNavigationDelegate { error = sslPinningError as NSError } - if error.code == Int(CFNetworkErrors.cfurlErrorCancelled.rawValue) { - // load cancelled / user stopped load. Cancel https upgrade fallback timer. - tab.upgradedHTTPSRequest = nil - tab.upgradeHTTPSTimeoutTimer?.invalidate() - tab.upgradeHTTPSTimeoutTimer = nil - - if tab === tabManager.selectedTab { - updateToolbarCurrentURL(tab.url?.displayURL) - updateWebViewPageZoom(tab: tab) - } - return - } - if let url = error.userInfo[NSURLErrorFailingURLErrorKey] as? URL { // Check for invalid upgrade to https if url.scheme == "https", // verify failing url was https @@ -1206,179 +433,6 @@ extension BrowserViewController: WKNavigationDelegate { } } -// MARK: WKNavigationDelegateHelper - -extension BrowserViewController { - fileprivate func recordFinishedPageLoadP3A() { - var storage = P3ATimedStorage.pagesLoadedStorage - storage.add(value: 1, to: Date()) - UmaHistogramRecordValueToBucket( - "Brave.Core.PagesLoaded", - buckets: [ - 0, - .r(1...10), - .r(11...50), - .r(51...100), - .r(101...500), - .r(501...1000), - .r(1001...), - ], - value: storage.combinedValue - ) - } - - private func tab(for webView: WKWebView) -> Tab? { - tabManager[webView] ?? (webView as? TabWebView)?.tab - } - - private func handleExternalURL( - _ url: URL, - tab: Tab?, - navigationAction: WKNavigationAction - ) async -> Bool { - // Do not open external links for child tabs automatically - // The user must tap on the link to open it. - if tab?.parent != nil && navigationAction.navigationType != .linkActivated { - return false - } - - // Check if the current url of the caller has changed - if let domain = tab?.url?.baseDomain, - domain != tab?.externalAppURLDomain - { - tab?.externalAppAlertCounter = 0 - tab?.isExternalAppAlertSuppressed = false - } - - tab?.externalAppURLDomain = tab?.url?.baseDomain - - // Do not try to present over existing warning - if tab?.isExternalAppAlertPresented == true || tab?.isExternalAppAlertSuppressed == true { - return false - } - - // External dialog should not be shown for non-active tabs #6687 - #7835 - let isVisibleTab = tab?.isTabVisible() == true - - if !isVisibleTab { - return false - } - - var alertTitle = Strings.openExternalAppURLGenericTitle - - if navigationAction.sourceFrame != nil { - let displayHost = - "\(navigationAction.sourceFrame.securityOrigin.protocol)://\(navigationAction.sourceFrame.securityOrigin.host):\(navigationAction.sourceFrame.securityOrigin.port)" - alertTitle = String(format: Strings.openExternalAppURLTitle, displayHost) - } else if let displayHost = tab?.url?.withoutWWW.host { - alertTitle = String(format: Strings.openExternalAppURLTitle, displayHost) - } - - // Handling condition when Tab is empty when handling an external URL we should remove the tab once the user decides - let removeTabIfEmpty = { [weak self] in - if let tab = tab, tab.url == nil { - self?.tabManager.removeTab(tab) - } - } - - // Show the external sceheme invoke alert - @MainActor - func showExternalSchemeAlert( - isSuppressActive: Bool, - openedURLCompletionHandler: @escaping (Bool) -> Void - ) { - // Check if active controller is bvc otherwise do not show show external sceheme alerts - guard shouldShowExternalSchemeAlert() else { - openedURLCompletionHandler(false) - return - } - - view.endEditing(true) - tab?.isExternalAppAlertPresented = true - - let popup = AlertPopupView( - imageView: nil, - title: alertTitle, - message: String(format: Strings.openExternalAppURLMessage, url.relativeString), - titleWeight: .semibold, - titleSize: 21 - ) - - tab?.externalAppPopup = popup - - if isSuppressActive { - popup.addButton(title: Strings.suppressAlertsActionTitle, type: .destructive) { - [weak tab] () -> PopupViewDismissType in - openedURLCompletionHandler(false) - tab?.isExternalAppAlertSuppressed = true - return .flyDown - } - } else { - popup.addButton(title: Strings.openExternalAppURLDontAllow) { - [weak tab] () -> PopupViewDismissType in - openedURLCompletionHandler(false) - removeTabIfEmpty() - tab?.isExternalAppAlertPresented = false - return .flyDown - } - } - popup.addButton(title: Strings.openExternalAppURLAllow, type: .primary) { - [weak tab] () -> PopupViewDismissType in - UIApplication.shared.open(url, options: [:]) { didOpen in - openedURLCompletionHandler(!didOpen) - } - removeTabIfEmpty() - tab?.isExternalAppAlertPresented = false - return .flyDown - } - popup.showWithType(showType: .flyUp) - } - - func shouldShowExternalSchemeAlert() -> Bool { - guard let rootVC = currentScene?.browserViewController else { - return false - } - - func topViewController(startingFrom viewController: UIViewController) -> UIViewController { - var top = viewController - if let navigationController = top as? UINavigationController, - let vc = navigationController.visibleViewController - { - return topViewController(startingFrom: vc) - } - if let tabController = top as? UITabBarController, - let vc = tabController.selectedViewController - { - return topViewController(startingFrom: vc) - } - while let next = top.presentedViewController { - top = next - } - return top - } - - let isTopController = self == topViewController(startingFrom: rootVC) - let isTopWindow = view.window?.isKeyWindow == true - return isTopController && isTopWindow - } - - tab?.externalAppAlertCounter += 1 - - return await withTaskCancellationHandler { - return await withCheckedContinuation { [weak tab] continuation in - tab?.externalAppPopupContinuation = continuation - showExternalSchemeAlert(isSuppressActive: tab?.externalAppAlertCounter ?? 0 > 2) { - tab?.externalAppPopupContinuation = nil - continuation.resume(with: .success($0)) - } - } - } onCancel: { [weak tab] in - tab?.externalAppPopupContinuation?.resume(with: .success(false)) - tab?.externalAppPopupContinuation = nil - } - } -} - // MARK: WKUIDelegate extension BrowserViewController: WKUIDelegate { @@ -1390,7 +444,7 @@ extension BrowserViewController: WKUIDelegate { ) -> WKWebView? { guard let parentTab = tabManager[webView] else { return nil } - guard !navigationAction.isInternalUnprivileged, + guard !navigationAction.request.isInternalUnprivileged, let navigationURL = navigationAction.request.url, navigationURL.shouldRequestBeOpenedAsPopup() else { @@ -1429,8 +483,7 @@ extension BrowserViewController: WKUIDelegate { public func webViewDidClose(_ webView: WKWebView) { guard let tab = tabManager[webView] else { return } - tabManager.addTabToRecentlyClosed(tab) - tabManager.removeTab(tab) + tabWebViewDidClose(tab) } public func webView( @@ -1440,94 +493,41 @@ extension BrowserViewController: WKUIDelegate { type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void ) { - if frame.securityOrigin.protocol.isEmpty || frame.securityOrigin.host.isEmpty { + guard let tab = tabManager[webView], let captureType = WebMediaCaptureType(type) else { decisionHandler(.deny) return } - let presentAlert = { [weak self] in - guard let self = self else { return } - - let titleFormat: String = { - switch type { - case .camera: - return Strings.requestCameraPermissionPrompt - case .microphone: - return Strings.requestMicrophonePermissionPrompt - case .cameraAndMicrophone: - return Strings.requestCameraAndMicrophonePermissionPrompt - @unknown default: - return Strings.requestCaptureDevicePermissionPrompt - } - }() - let title = String.localizedStringWithFormat(titleFormat, origin.host) - let alertController = BrowserAlertController( - title: title, - message: nil, - preferredStyle: .alert - ) - alertController.addAction( - .init( - title: Strings.requestCaptureDevicePermissionAllowButtonTitle, - style: .default, - handler: { _ in - decisionHandler(.grant) - } - ) - ) - alertController.addAction( - .init( - title: Strings.CancelString, - style: .cancel, - handler: { _ in - decisionHandler(.deny) - } - ) - ) - alertController.dismissedWithoutAction = { - decisionHandler(.prompt) - } - - tabManager.tabForWebView(webView)?.shownPromptAlert = alertController + if frame.securityOrigin.protocol.isEmpty || frame.securityOrigin.host.isEmpty { + decisionHandler(.deny) + return + } - if webView.fullscreenState == .inFullscreen || webView.fullscreenState == .enteringFullscreen - { - webView.closeAllMediaPresentations { - self.present(alertController, animated: true) - } - return + let requestMediaPermissions: () -> Void = { + Task { + let permission = await self.tab(tab, requestMediaCapturePermissionsFor: captureType) + decisionHandler(.init(permission)) } - self.present(alertController, animated: true) } - if let presentedViewController = presentedViewController as? BrowserAlertController { - presentedViewController.dismiss(animated: true) { - presentAlert() + if webView.fullscreenState == .inFullscreen || webView.fullscreenState == .enteringFullscreen { + webView.closeAllMediaPresentations { + requestMediaPermissions() } } else { - presentAlert() + requestMediaPermissions() } } - fileprivate func shouldDisplayJSAlertForWebView(_ webView: WKWebView) -> Bool { - // Only display a JS Alert if we are selected and there isn't anything being shown - return ((tabManager.selectedTab == nil ? false : tabManager.selectedTab!.webView == webView)) - && (self.presentedViewController == nil) - } - public func webView( _ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void ) { - var messageAlert = MessageAlert( - message: message, - frame: frame, - completionHandler: completionHandler, - suppressHandler: nil - ) - handleAlert(webView: webView, frame: frame, alert: &messageAlert) { + guard let tab = tabManager[webView], let url = frame.origin?.url else { return } + Task { + await self.tab(tab, runJavaScriptAlertPanelWithMessage: message, pageURL: url) completionHandler() } } @@ -1538,14 +538,10 @@ extension BrowserViewController: WKUIDelegate { initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void ) { - var confirmAlert = ConfirmPanelAlert( - message: message, - frame: frame, - completionHandler: completionHandler, - suppressHandler: nil - ) - handleAlert(webView: webView, frame: frame, alert: &confirmAlert) { - completionHandler(false) + guard let tab = tabManager[webView], let url = frame.origin?.url else { return } + Task { + let result = await self.tab(tab, runJavaScriptConfirmPanelWithMessage: message, pageURL: url) + completionHandler(result) } } @@ -1556,284 +552,26 @@ extension BrowserViewController: WKUIDelegate { initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void ) { - var textInputAlert = TextInputAlert( - message: prompt, - frame: frame, - completionHandler: completionHandler, - defaultText: defaultText, - suppressHandler: nil - ) - handleAlert(webView: webView, frame: frame, alert: &textInputAlert) { - completionHandler(nil) - } - } - - func suppressJSAlerts(webView: WKWebView) { - let script = """ - window.alert=window.confirm=window.prompt=function(n){}, - [].slice.apply(document.querySelectorAll('iframe')).forEach(function(n){if(n.contentWindow != window){n.contentWindow.alert=n.contentWindow.confirm=n.contentWindow.prompt=function(n){}}}) - """ - webView.evaluateSafeJavaScript( - functionName: script, - contentWorld: .defaultClient, - asFunction: false - ) - } - - func handleAlert( - webView: WKWebView, - frame: WKFrameInfo, - alert: inout T, - completionHandler: @escaping () -> Void - ) { - if frame.securityOrigin.protocol.isEmpty || frame.securityOrigin.host.isEmpty { - completionHandler() - return - } - - guard let promptingTab = tabManager[webView], !promptingTab.blockAllAlerts else { - suppressJSAlerts(webView: webView) - tabManager[webView]?.cancelQueuedAlerts() - completionHandler() - return - } - - promptingTab.alertShownCount += 1 - let suppressBlock: JSAlertInfo.SuppressHandler = { [unowned self, weak promptingTab] suppress in - guard let promptingTab else { return } - if suppress { - func suppressDialogues(_: UIAlertAction) { - self.suppressJSAlerts(webView: webView) - promptingTab.blockAllAlerts = true - self.tabManager[webView]?.cancelQueuedAlerts() - completionHandler() - } - // Show confirm alert here. - let suppressSheet = UIAlertController( - title: nil, - message: Strings.suppressAlertsActionMessage, - preferredStyle: .actionSheet - ) - suppressSheet.addAction( - UIAlertAction( - title: Strings.suppressAlertsActionTitle, - style: .destructive, - handler: suppressDialogues - ) - ) - suppressSheet.addAction( - UIAlertAction( - title: Strings.cancelButtonTitle, - style: .cancel, - handler: { _ in - completionHandler() - } - ) - ) - if UIDevice.current.userInterfaceIdiom == .pad, - let popoverController = suppressSheet.popoverPresentationController - { - popoverController.sourceView = self.view - popoverController.sourceRect = CGRect( - x: self.view.bounds.midX, - y: self.view.bounds.midY, - width: 0, - height: 0 - ) - popoverController.permittedArrowDirections = [] - } - - promptingTab.shownPromptAlert = suppressSheet - self.present(suppressSheet, animated: true) - } else { - completionHandler() - } - } - alert.suppressHandler = promptingTab.alertShownCount > 1 ? suppressBlock : nil - if shouldDisplayJSAlertForWebView(webView) { - let controller = alert.alertController() - controller.delegate = self - promptingTab.shownPromptAlert = controller - - present(controller, animated: true) - } else { - promptingTab.queueJavascriptAlertPrompt(alert) - } - } - - func checkIfWebContentProcessHasCrashed(_ webView: WKWebView, error: NSError) -> Bool { - if error.code == WKError.webContentProcessTerminated.rawValue - && error.domain == "WebKitErrorDomain" - { - print("WebContent process has crashed. Trying to reload to restart it.") - webView.reload() - return true + guard let tab = tabManager[webView], let url = frame.origin?.url else { return } + Task { + let result = await self.tab( + tab, + runJavaScriptConfirmPanelWithPrompt: prompt, + defaultText: defaultText, + pageURL: url + ) + completionHandler(result) } - - return false } @MainActor public func webView( _ webView: WKWebView, contextMenuConfigurationFor elementInfo: WKContextMenuElementInfo ) async -> UIContextMenuConfiguration? { - // Only show context menu for valid links such as `http`, `https`, `data`. Safari does not show it for anything else. - // This is because you cannot open `javascript:something` URLs in a new page, or share it, or anything else. - guard let url = elementInfo.linkURL, url.isWebPage() else { - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: nil) - } - - let actionProvider: UIContextMenuActionProvider = { _ -> UIMenu? in - var actions = [UIAction]() - - if let currentTab = self.tabManager.selectedTab { - let tabType = currentTab.type - - if !tabType.isPrivate { - let openNewTabAction = UIAction( - title: Strings.openNewTabButtonTitle, - image: UIImage(systemName: "plus") - ) { _ in - self.addTab(url: url, inPrivateMode: false, currentTab: currentTab) - } - - openNewTabAction.accessibilityLabel = "linkContextMenu.openInNewTab" - actions.append(openNewTabAction) - } - - let openNewPrivateTabAction = UIAction( - title: Strings.openNewPrivateTabButtonTitle, - image: UIImage(named: "private_glasses", in: .module, compatibleWith: nil)!.template - ) { _ in - if !tabType.isPrivate, Preferences.Privacy.privateBrowsingLock.value { - self.askForLocalAuthentication { [weak self] success, error in - if success { - self?.addTab(url: url, inPrivateMode: true, currentTab: currentTab) - } - } - } else { - self.addTab(url: url, inPrivateMode: true, currentTab: currentTab) - } - } - openNewPrivateTabAction.accessibilityLabel = "linkContextMenu.openInNewPrivateTab" - - actions.append(openNewPrivateTabAction) - - if UIApplication.shared.supportsMultipleScenes { - if !tabType.isPrivate { - let openNewWindowAction = UIAction( - title: Strings.openInNewWindowTitle, - image: UIImage(braveSystemNamed: "leo.window") - ) { _ in - self.openInNewWindow(url: url, isPrivate: false) - } - - openNewWindowAction.accessibilityLabel = "linkContextMenu.openInNewWindow" - actions.append(openNewWindowAction) - } - - let openNewPrivateWindowAction = UIAction( - title: Strings.openInNewPrivateWindowTitle, - image: UIImage(braveSystemNamed: "leo.window.tab-private") - ) { _ in - if !tabType.isPrivate, Preferences.Privacy.privateBrowsingLock.value { - self.askForLocalAuthentication { [weak self] success, error in - if success { - self?.openInNewWindow(url: url, isPrivate: true) - } - } - } else { - self.openInNewWindow(url: url, isPrivate: true) - } - } - - openNewPrivateWindowAction.accessibilityLabel = "linkContextMenu.openInNewPrivateWindow" - actions.append(openNewPrivateWindowAction) - } - - let copyAction = UIAction( - title: Strings.copyLinkActionTitle, - image: UIImage(systemName: "doc.on.doc"), - handler: UIAction.deferredActionHandler { _ in - UIPasteboard.general.url = url as URL - } - ) - copyAction.accessibilityLabel = "linkContextMenu.copyLink" - actions.append(copyAction) - - let copyCleanLinkAction = UIAction( - title: Strings.copyCleanLink, - image: UIImage(braveSystemNamed: "leo.broom"), - handler: UIAction.deferredActionHandler { _ in - let service = URLSanitizerServiceFactory.get(privateMode: currentTab.isPrivate) - let cleanedURL = service?.sanitizeURL(url) ?? url - UIPasteboard.general.url = cleanedURL - } - ) - copyCleanLinkAction.accessibilityLabel = "linkContextMenu.copyCleanLink" - actions.append(copyCleanLinkAction) - - if let braveWebView = webView as? BraveWebView { - let shareAction = UIAction( - title: Strings.shareLinkActionTitle, - image: UIImage(systemName: "square.and.arrow.up") - ) { _ in - let touchPoint = braveWebView.lastHitPoint - let touchRect = CGRect(origin: touchPoint, size: .zero) - - // TODO: Find a way to add fixes #3323 and #2961 here: - // Normally we use `tab.temporaryDocument` for the downloaded file on the tab. - // `temporaryDocument` returns the downloaded file to disk on the current tab. - // Using a downloaded file url results in having functions like "Save to files" available. - // It also attaches the file (image, pdf, etc) and not the url to emails, slack, etc. - // Since this is **not** a tab but a standalone web view, the downloaded temporary file is **not** available. - // This results in the fixes for #3323 and #2961 not being included in this share scenario. - // This is not a regression, we simply never handled this scenario in both fixes. - // Some possibile fixes include: - // - Detect the file type and download it if necessary and don't rely on the `tab.temporaryDocument`. - // - Add custom "Save to file" functionality (needs investigation). - self.presentActivityViewController( - url, - sourceView: braveWebView, - sourceRect: touchRect, - arrowDirection: .any - ) - } - - shareAction.accessibilityLabel = "linkContextMenu.share" - - actions.append(shareAction) - } - - let linkPreview = Preferences.General.enableLinkPreview.value - - let linkPreviewTitle = - linkPreview ? Strings.hideLinkPreviewsActionTitle : Strings.showLinkPreviewsActionTitle - let linkPreviewAction = UIAction( - title: linkPreviewTitle, - image: UIImage(systemName: "eye.fill") - ) { _ in - Preferences.General.enableLinkPreview.value.toggle() - } - - actions.append(linkPreviewAction) - } - - return UIMenu(title: url.absoluteString.truncate(length: 100), children: actions) - } - - let linkPreview: UIContextMenuContentPreviewProvider? = { [unowned self] in - if let tab = tabManager.tabForWebView(webView) { - return LinkPreviewViewController(url: url, for: tab, browserController: self) - } - return nil - } - - let linkPreviewProvider = Preferences.General.enableLinkPreview.value ? linkPreview : nil - return UIContextMenuConfiguration( - identifier: nil, - previewProvider: linkPreviewProvider, - actionProvider: actionProvider + guard let tab = tabManager[webView] else { return nil } + return await self.tab( + tab, + contextMenuConfigurationForLinkURL: elementInfo.linkURL ) } @@ -1845,139 +583,11 @@ extension BrowserViewController: WKUIDelegate { guard let url = elementInfo.linkURL else { return } webView.load(URLRequest(url: url)) } +} - fileprivate func addTab(url: URL, inPrivateMode: Bool, currentTab: Tab) { - let tab = self.tabManager.addTab( - URLRequest(url: url), - afterTab: currentTab, - isPrivate: inPrivateMode - ) - if inPrivateMode && !privateBrowsingManager.isPrivateBrowsing { - self.tabManager.selectTab(tab) - } else { - // We're not showing the top tabs; show a toast to quick switch to the fresh new tab. - let toast = ButtonToast( - labelText: Strings.contextMenuButtonToastNewTabOpenedLabelText, - buttonText: Strings.contextMenuButtonToastNewTabOpenedButtonText, - completion: { buttonPressed in - if buttonPressed { - self.tabManager.selectTab(tab) - } - } - ) - self.show(toast: toast) - } - self.toolbarVisibilityViewModel.toolbarState = .expanded - } - - /// Get a possible redirect request from debouncing or query param stripping - private func getInternalRedirect( - from navigationAction: WKNavigationAction, - in tab: Tab, - domainForMainFrame: Domain - ) -> URLRequest? { - guard let requestURL = navigationAction.request.url else { return nil } - - // For main frame only and if shields are enabled - guard requestURL.isWebPage(includeDataURIs: false), - domainForMainFrame.globalBlockAdsAndTrackingLevel.isEnabled, - navigationAction.targetFrame?.isMainFrame == true - else { return nil } - - // Handle Debounce - // Only if the site (etld+1) changes - // We also only handle `http` and `https` requests - // Lets get the redirect chain. - // Then we simply get all elements up until the user allows us to redirect - // (i.e. appropriate settings are enabled for that redirect rule) - if let debounceService = DebounceServiceFactory.get(privateMode: tab.isPrivate), - debounceService.isEnabled, - let currentURL = tab.webView?.url, - currentURL.baseDomain != requestURL.baseDomain - { - if let redirectURL = debounceService.debounce(requestURL) { - // For now we only allow the `Referer`. The browser will add other headers during navigation. - var modifiedRequest = URLRequest(url: redirectURL) - - // Also strip query params if debouncing - modifiedRequest = - modifiedRequest.stripQueryParams( - initiatorURL: tab.committedURL, - redirectSourceURL: requestURL, - isInternalRedirect: false - ) ?? modifiedRequest - - for (headerKey, headerValue) in navigationAction.request.allHTTPHeaderFields ?? [:] { - guard headerKey == "Referer" else { continue } - modifiedRequest.setValue(headerValue, forHTTPHeaderField: headerKey) - } - - Self.log.debug( - "Debouncing `\(requestURL.absoluteString)`" - ) - - return modifiedRequest - } - } - - // Handle query param stripping - if let request = navigationAction.request.stripQueryParams( - initiatorURL: tab.committedURL, - redirectSourceURL: tab.redirectSourceURL, - isInternalRedirect: tab.isInternalRedirect - ) { - Self.log.debug( - "Stripping query params for `\(requestURL.absoluteString)`" - ) - return request - } - - // HTTPS by Default - if shouldUpgradeToHttps(url: requestURL, isPrivate: tab.isPrivate), - var urlComponents = URLComponents(url: requestURL, resolvingAgainstBaseURL: true) - { - if let existingUpgradeRequestURL = tab.upgradedHTTPSRequest?.url, - existingUpgradeRequestURL == requestURL - { - // if server redirected https -> http, https load never fails. - // `webView(_:decidePolicyFor:preferences:)` will be called before - // `webView(_:didReceiveServerRedirectForProvisionalNavigation:)` - // so we must prevent upgrade loop. - return handleInvalidHTTPSUpgrade(tab: tab, responseURL: requestURL) - } - // Attempt to upgrade to HTTPS - urlComponents.scheme = "https" - if let upgradedURL = urlComponents.url { - Self.log.debug( - "Upgrading `\(requestURL.absoluteString)` to HTTPS" - ) - tab.upgradedHTTPSRequest = navigationAction.request - tab.upgradeHTTPSTimeoutTimer?.invalidate() - var request = navigationAction.request - request.url = upgradedURL - - tab.upgradeHTTPSTimeoutTimer = Timer.scheduledTimer( - withTimeInterval: 3.seconds, - repeats: false, - block: { [weak tab, weak self] timer in - guard let self, let tab else { return } - if let url = request.url, - let request = handleInvalidHTTPSUpgrade(tab: tab, responseURL: url) - { - tab.webView?.stopLoading() - tab.webView?.load(request) - } - } - ) - return request - } - } - - return nil - } - +extension BrowserViewController { /// Determines if the given url should be upgraded from http to https. - private func shouldUpgradeToHttps(url: URL, isPrivate: Bool) -> Bool { + func shouldUpgradeToHttps(url: URL, isPrivate: Bool) -> Bool { guard FeatureList.kBraveHttpsByDefault.enabled, let httpUpgradeService = HttpsUpgradeServiceFactory.get(privateMode: isPrivate), url.scheme == "http", let host = url.host @@ -2003,7 +613,7 @@ extension BrowserViewController: WKUIDelegate { /// Upon an invalid response, check that we need to roll back any HTTPS upgrade /// or show the interstitial page - private func handleInvalidHTTPSUpgrade(tab: Tab, responseURL: URL) -> URLRequest? { + func handleInvalidHTTPSUpgrade(tab: Tab, responseURL: URL) -> URLRequest? { // Handle invalid upgrade to https guard let originalRequest = tab.upgradedHTTPSRequest, let originalURL = originalRequest.url, @@ -2015,14 +625,14 @@ extension BrowserViewController: WKUIDelegate { if ShieldPreferences.httpsUpgradeLevel.isStrict, let url = originalURL.encodeEmbeddedInternalURL(for: .httpBlocked) { - Self.log.debug( + Logger.module.debug( "Show http blocked interstitial for `\(originalURL.absoluteString)`" ) let request = PrivilegedRequest(url: url) as URLRequest return request } else { - Self.log.debug( + Logger.module.debug( "Revert HTTPS upgrade for `\(originalURL.absoluteString)`" ) @@ -2039,41 +649,59 @@ extension BrowserViewController: WKUIDelegate { } } -extension P3ATimedStorage where Value == Int { - fileprivate static var pagesLoadedStorage: Self { .init(name: "paged-loaded", lifetimeInDays: 7) } +extension WebMediaCaptureType { + init?(_ captureType: WKMediaCaptureType) { + switch captureType { + case .camera: + self = .camera + case .microphone: + self = .microphone + case .cameraAndMicrophone: + self = .cameraAndMicrophone + @unknown default: + return nil + } + } } -extension URLRequest { - /// Strip any query params in the request and return a new request if anything is stripped. - /// - /// The `isInternalRedirect` is a true value whenever we redirected the user for debouncing or query-stripping. - /// It's an optimization because we assume that we stripped and debounced the user fully so there should be no further stripping on the next iteration. - /// - /// - Parameters: - /// - initiatorURL: The url page the user is coming from before any redirects - /// - redirectSourceURL: The last redirect url that happened (the true page the user is coming from) - /// - isInternalRedirect: Identifies if we have internally redirected or not. More info in the description - /// - Returns: A modified request if any stripping is to occur. - fileprivate func stripQueryParams( - initiatorURL: URL?, - redirectSourceURL: URL?, - isInternalRedirect: Bool - ) -> URLRequest? { - guard let requestURL = url, - let requestMethod = httpMethod - else { return nil } +extension WKPermissionDecision { + init(_ permission: WebPermissionDecision) { + switch permission { + case .prompt: + self = .prompt + case .grant: + self = .grant + case .deny: + self = .deny + } + } +} - guard - let strippedURL = (requestURL as NSURL).applyingQueryFilter( - initiatorURL: initiatorURL, - redirectSourceURL: redirectSourceURL, - requestMethod: requestMethod, - isInternalRedirect: isInternalRedirect - ) - else { return nil } +extension WKFrameInfo { + fileprivate var origin: URLOrigin? { + if securityOrigin.protocol.isEmpty || securityOrigin.host.isEmpty { + return nil + } + let defaultPort: UInt16 = securityOrigin.protocol == "https" ? 443 : 80 + return URLOrigin( + scheme: securityOrigin.protocol, + host: securityOrigin.host, + port: securityOrigin.port == 0 ? defaultPort : UInt16(securityOrigin.port) + ) + } +} - var modifiedRequest = self - modifiedRequest.url = strippedURL - return modifiedRequest +extension WKNavigationType: @retroactive CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .linkActivated: return "linkActivated" + case .formResubmitted: return "formResubmitted" + case .backForward: return "backForward" + case .formSubmitted: return "formSubmitted" + case .other: return "other" + case .reload: return "reload" + @unknown default: + return "Unknown(\(rawValue))" + } } } diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift index dd8a0c0c4de4..5d9f4b242155 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift @@ -487,7 +487,6 @@ public class BrowserViewController: UIViewController { fileprivate func didInit() { updateApplicationShortcuts() tabManager.addDelegate(self) - tabManager.addNavigationDelegate(self) UserScriptManager.shared.fetchWalletScripts(from: braveCore.braveWalletAPI) downloadQueue.delegate = self @@ -697,11 +696,9 @@ public class BrowserViewController: UIViewController { updateToolbarUsingTabManager(tabManager) updateUsingBottomBar(using: newCollection) - if let tab = tabManager.selectedTab, - let webView = tab.webView - { + if let tab = tabManager.selectedTab { updateURLBar() - updateBackForwardActionStatus(for: webView) + updateBackForwardActionStatus(for: tab) topToolbar.locationView.loading = tab.loading } @@ -1973,7 +1970,7 @@ public class BrowserViewController: UIViewController { break } - updateBackForwardActionStatus(for: webView) + updateBackForwardActionStatus(for: tab) case .hasOnlySecureContent: Task { await tab.updateSecureContentState() @@ -2030,8 +2027,8 @@ public class BrowserViewController: UIViewController { DebugLogger.log(for: .secureState, text: text) } - func updateBackForwardActionStatus(for webView: WKWebView?) { - guard let webView = webView else { return } + func updateBackForwardActionStatus(for tab: Tab) { + guard let webView = tab.webView else { return } if let forwardListItem = webView.backForwardList.forwardList.first, forwardListItem.url.isInternalURL(for: .readermode) @@ -2267,18 +2264,16 @@ public class BrowserViewController: UIViewController { } } - func displayPageZoom(visible: Bool) { - if !visible || pageZoomBar != nil { - pageZoomBar?.view.removeFromSuperview() - updateViewConstraints() - pageZoomBar = nil - - return - } + func clearPageZoomDialog() { + pageZoomBar?.view.removeFromSuperview() + updateViewConstraints() + pageZoomBar = nil + } - guard let selectTab = tabManager.selectedTab else { return } + func displayPageZoomDialog() { + guard let tab = tabManager.selectedTab else { return } let zoomHandler = PageZoomHandler( - tab: selectTab, + tab: tab, isPrivateBrowsing: privateBrowsingManager.isPrivateBrowsing ) let pageZoomBar = UIHostingController(rootView: PageZoomView(zoomHandler: zoomHandler)) @@ -2338,7 +2333,7 @@ public class BrowserViewController: UIViewController { statusBarOverlay.backgroundColor = color } - func navigateInTab(tab: Tab, to navigation: WKNavigation? = nil) { + func navigateInTab(tab: Tab) { tabManager.expireSnackbars() guard let webView = tab.webView else { @@ -2625,6 +2620,7 @@ extension BrowserViewController: TabDelegate { // Observers that live as long as the tab. Make sure these are all cleared in willDeleteWebView below! KVOs.forEach { webView.addObserver(self, forKeyPath: $0.keyPath, options: .new, context: nil) } + webView.navigationDelegate = self webView.uiDelegate = self var injectedScripts: [TabContentScript] = [ diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Helpers/UniversalLinkNavigationHelper.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Helpers/UniversalLinkNavigationHelper.swift new file mode 100644 index 000000000000..e78c1ffc975e --- /dev/null +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Helpers/UniversalLinkNavigationHelper.swift @@ -0,0 +1,23 @@ +// Copyright (c) 2025 The Brave Authors. All rights reserved. +// 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 https://mozilla.org/MPL/2.0/. + +import Foundation +import Preferences + +func shouldBlockUniversalLinksFor(request: URLRequest, isPrivateBrowsing: Bool) -> Bool { + func isYouTubeLoad() -> Bool { + guard let domain = request.mainDocumentURL?.baseDomain else { + return false + } + let domainsWithUniversalLinks: Set = ["youtube.com", "youtu.be"] + return domainsWithUniversalLinks.contains(domain) + } + if isPrivateBrowsing || !Preferences.General.followUniversalLinks.value + || (Preferences.General.keepYouTubeInBrave.value && isYouTubeLoad()) + { + return true + } + return false +} diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/LinkPreviewViewController.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/LinkPreviewViewController.swift index 32b4083e8c9d..6036dbc3fd3f 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/LinkPreviewViewController.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/LinkPreviewViewController.swift @@ -37,8 +37,8 @@ class LinkPreviewViewController: UIViewController { tabGeneratorAPI: nil ).then { $0.tabDelegate = browserController - $0.navigationDelegate = browserController $0.createWebview() + $0.webView?.navigationDelegate = browserController $0.webView?.scrollView.layer.masksToBounds = true } @@ -65,7 +65,7 @@ class LinkPreviewViewController: UIViewController { } deinit { - self.currentTab?.navigationDelegate = nil + self.currentTab?.webView?.navigationDelegate = nil self.currentTab = nil } } diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCacheLoader.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCacheLoader.swift index 585d2c620ccb..cdfd5f81436a 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCacheLoader.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCacheLoader.swift @@ -333,7 +333,9 @@ extension LivePlaylistWebLoader: WKNavigationDelegate { return (.cancel, preferences) } - if navigationAction.isInternalUnprivileged && navigationAction.navigationType != .backForward { + if navigationAction.request.isInternalUnprivileged + && navigationAction.navigationType != .backForward + { return (.cancel, preferences) } diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Search/BraveSearchManager.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Search/BraveSearchManager.swift index 1e82d9b25931..6a8f5f4cebcc 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Search/BraveSearchManager.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Search/BraveSearchManager.swift @@ -32,8 +32,6 @@ class BraveSearchManager: NSObject { /// such as whether to use search fallback, should safe search be performed etc. private let domainCookies: [HTTPCookie] - private let profile: Profile - /// The result we got from querying the fallback search engine. var fallbackQueryResult: String? /// Whether the call to the fallback search engine is pending. @@ -65,7 +63,7 @@ class BraveSearchManager: NSObject { private var callbackLog: BraveSearchLogEntry.FallbackLogEntry? - init?(profile: Profile, url: URL, cookies: [HTTPCookie]) { + init?(url: URL, cookies: [HTTPCookie]) { if !Self.isValidURL(url) { return nil } @@ -75,7 +73,6 @@ class BraveSearchManager: NSObject { let queryItem = components.valueForQuery("q") else { return nil } - self.profile = profile self.url = url self.query = queryItem self.domainCookies = cookies.filter { $0.domain == url.host } diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Tab.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Tab.swift index 07bc13c795c3..4a030336731a 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Tab.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Tab.swift @@ -475,14 +475,6 @@ class Tab: NSObject { self.contentScriptManager.tab = self } - weak var navigationDelegate: WKNavigationDelegate? { - didSet { - if let webView = webView { - webView.navigationDelegate = navigationDelegate - } - } - } - /// A helper property that handles native to Brave Search communication. var braveSearchManager: BraveSearchManager? @@ -528,7 +520,6 @@ class Tab: NSObject { // Turning off masking allows the web content to flow outside of the scrollView's frame // which allows the content appear beneath the toolbars in the BrowserViewController webView.scrollView.layer.masksToBounds = false - webView.navigationDelegate = navigationDelegate restore(webView, restorationData: self.sessionData) @@ -1000,7 +991,7 @@ class Tab: NSObject { change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer? ) { - guard let webView = object as? BraveWebView, webView == self.webView, + guard let webView = object as? TabWebView, webView == self.webView, let path = keyPath, path == KVOConstants.url.keyPath else { return assertionFailure("Unhandled KVO key: \(keyPath ?? "nil")") diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/TabManager.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/TabManager.swift index cab60c0051af..2af993992c9e 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/TabManager.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/TabManager.swift @@ -74,7 +74,6 @@ class TabManager: NSObject { private(set) var allTabs = [Tab]() private var _selectedIndex = -1 - private let navDelegate: TabManagerNavDelegate private(set) var isRestoring = false private(set) var isBulkDeleting = false @@ -83,6 +82,11 @@ class TabManager: NSObject { return TabManager.getNewConfiguration() }() + private var policyDeciders: [any TabWebPolicyDecider] = [] + func addPolicyDecider(_ policyDecider: any TabWebPolicyDecider) { + policyDeciders.append(policyDecider) + } + fileprivate let prefs: Prefs var selectedIndex: Int { return _selectedIndex @@ -126,7 +130,6 @@ class TabManager: NSObject { self.windowId = windowId self.prefs = prefs - self.navDelegate = TabManagerNavDelegate() self.rewards = rewards self.tabGeneratorAPI = tabGeneratorAPI self.historyAPI = historyAPI @@ -134,9 +137,6 @@ class TabManager: NSObject { self.tabEventHandlers = TabEventHandlers.create(with: prefs) super.init() - self.navDelegate.tabManager = self - addNavigationDelegate(self) - Preferences.Shields.blockImages.observe(from: self) Preferences.General.blockPopups.observe(from: self) Preferences.General.nightModeEnabled.observe(from: self) @@ -164,12 +164,6 @@ class TabManager: NSObject { syncTabsTask?.cancel() } - func addNavigationDelegate(_ delegate: WKNavigationDelegate) { - assert(Thread.isMainThread) - - self.navDelegate.insert(delegate) - } - var count: Int { assert(Thread.isMainThread) @@ -657,7 +651,6 @@ class TabManager: NSObject { if !zombie { tab.createWebview() } - tab.navigationDelegate = self.navDelegate if let request = request { tab.loadRequest(request) @@ -1037,9 +1030,6 @@ class TabManager: NSObject { assert(count == prevCount - 1, "Make sure the tab count was actually removed") - // There's still some time between this and the webView being destroyed. We don't want to pick up any stray events. - tab.webView?.navigationDelegate = nil - delegates.forEach { $0.get()?.tabManager(self, didRemoveTab: tab) } TabEvent.post(.didClose, for: tab) @@ -1220,7 +1210,7 @@ class TabManager: NSObject { configuration.processPool = WKProcessPool() } - private func preserveScreenshot(for tab: Tab) { + func preserveScreenshot(for tab: Tab) { assert(Thread.isMainThread) if isRestoring { return } @@ -1343,10 +1333,6 @@ class TabManager: NSObject { func restoreTab(_ tab: Tab) { guard let webView = tab.webView else { return } guard let sessionTab = SessionTab.from(tabId: tab.id) else { - - // Restore Tab with its Last-Request URL - tab.navigationDelegate = navDelegate - var sessionData: (String, URLRequest)? if let tabURL = tab.url { @@ -1367,8 +1353,6 @@ class TabManager: NSObject { // Tab was created with no active webview session data. // Restore tab data from Core-Data URL, and configure it. if sessionTab.interactionState.isEmpty { - tab.navigationDelegate = navDelegate - if let tabURL = sessionTab.url { let request = InternalURL.isValid(url: tabURL) @@ -1390,8 +1374,6 @@ class TabManager: NSObject { return } - // Restore tab data from Core-Data, and configure it. - tab.navigationDelegate = navDelegate tab.restore( webView, restorationData: (sessionTab.title, sessionTab.interactionState) @@ -1420,7 +1402,6 @@ class TabManager: NSObject { isRestoring = true for tab in savedTabs { allTabs.append(tab) - tab.navigationDelegate = self.navDelegate for delegate in delegates { delegate.get()?.tabManager(self, didAddTab: tab) } @@ -1494,7 +1475,6 @@ class TabManager: NSObject { guard let webView = tab.webView else { return } if let interactionState = recentlyClosed.interactionState, !interactionState.isEmpty { - tab.navigationDelegate = navDelegate tab.restore(webView, restorationData: (recentlyClosed.title ?? "", interactionState)) } @@ -1541,64 +1521,6 @@ class TabManager: NSObject { } } -extension TabManager: WKNavigationDelegate { - - // Note the main frame JSContext (i.e. document, window) is not available yet. - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - if let tab = self[webView] { - tab.contentBlocker.clearPageStats() - } - } - - // The main frame JSContext is available, and DOM parsing has begun. - // Do not excute JS at this point that requires running prior to DOM parsing. - func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - // only store changes if this is not an error page - // as we current handle tab restore as error page redirects then this ensures that we don't - // call storeChanges unnecessarily on startup - - if let url = webView.url { - // tab restore uses internal pages, - // so don't call storeChanges unnecessarily on startup - if InternalURL(url)?.isSessionRestore == true { - return - } - - if let tab = tabForWebView(webView) { - if Preferences.Privacy.privateBrowsingOnly.value - || (tab.isPrivate && !Preferences.Privacy.persistentPrivateBrowsing.value) - { - return - } - - preserveScreenshot(for: tab) - saveTab(tab) - } - } - } - - func tabForWebView(_ webView: WKWebView) -> Tab? { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - - return allTabs.first(where: { $0.webView === webView }) - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - } - - /// Called when the WKWebView's content process has gone away. If this happens for the currently selected tab - /// then we immediately reload it. - func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - if let tab = selectedTab, tab.webView == webView { - webView.reload() - } - } -} - // MARK: - TabManagerDelegate optional methods. extension TabManagerDelegate { func tabManager(_ tabManager: TabManager, willAddTab tab: Tab) {} diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/TabManagerNavDelegate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/TabManagerNavDelegate.swift deleted file mode 100644 index 898693ff949a..000000000000 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/TabManagerNavDelegate.swift +++ /dev/null @@ -1,223 +0,0 @@ -import Preferences -import Shared -import WebKit - -// WKNavigationDelegates must implement NSObjectProtocol -class TabManagerNavDelegate: NSObject, WKNavigationDelegate { - private var delegates = WeakList() - weak var tabManager: TabManager? - - func insert(_ delegate: WKNavigationDelegate) { - delegates.insert(delegate) - } - - func webView(_ webView: WKWebView, didCommit navigation: WKNavigation) { - for delegate in delegates { - delegate.webView?(webView, didCommit: navigation) - } - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation, withError error: Error) { - for delegate in delegates { - delegate.webView?(webView, didFail: navigation, withError: error) - } - } - - func webView( - _ webView: WKWebView, - didFailProvisionalNavigation navigation: WKNavigation, - withError error: Error - ) { - for delegate in delegates { - delegate.webView?(webView, didFailProvisionalNavigation: navigation, withError: error) - } - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation) { - for delegate in delegates { - delegate.webView?(webView, didFinish: navigation) - } - } - - func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - for delegate in delegates { - delegate.webViewWebContentProcessDidTerminate?(webView) - } - } - - func webView( - _ webView: WKWebView, - authenticationChallenge challenge: URLAuthenticationChallenge, - shouldAllowDeprecatedTLS decisionHandler: @escaping @MainActor (Bool) -> Void - ) { - decisionHandler(false) - } - - func webView( - _ webView: WKWebView, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void - ) { - let authenticatingDelegates = delegates.filter { wv in - return wv.responds( - to: #selector(WKNavigationDelegate.webView(_:didReceive:completionHandler:)) - ) - } - - guard let firstAuthenticatingDelegate = authenticatingDelegates.first else { - completionHandler(.performDefaultHandling, nil) - return - } - - firstAuthenticatingDelegate.webView?( - webView, - didReceive: challenge, - completionHandler: completionHandler - ) - } - - func webView( - _ webView: WKWebView, - didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation! - ) { - for delegate in delegates { - delegate.webView?(webView, didReceiveServerRedirectForProvisionalNavigation: navigation) - } - } - - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - for delegate in delegates { - delegate.webView?(webView, didStartProvisionalNavigation: navigation) - } - } - - private func defaultAllowPolicy( - for navigationAction: WKNavigationAction - ) -> WKNavigationActionPolicy { - let isPrivateBrowsing = tabManager?.privateBrowsingManager.isPrivateBrowsing == true - func isYouTubeLoad() -> Bool { - guard let domain = navigationAction.request.mainDocumentURL?.baseDomain else { - return false - } - let domainsWithUniversalLinks: Set = ["youtube.com", "youtu.be"] - return domainsWithUniversalLinks.contains(domain) - } - if isPrivateBrowsing || !Preferences.General.followUniversalLinks.value - || (Preferences.General.keepYouTubeInBrave.value && isYouTubeLoad()) - { - // Stop Brave from opening universal links by using the private enum value - // `_WKNavigationActionPolicyAllowWithoutTryingAppLink` which is defined here: - // https://github.com/WebKit/WebKit/blob/main/Source/WebKit/UIProcess/API/Cocoa/WKNavigationDelegatePrivate.h#L62 - let allowDecision = - WKNavigationActionPolicy(rawValue: WKNavigationActionPolicy.allow.rawValue + 2) ?? .allow - return allowDecision - } - return .allow - } - - func webView( - _ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - preferences: WKWebpagePreferences, - decisionHandler: @escaping (WKNavigationActionPolicy, WKWebpagePreferences) -> Void - ) { - var res = defaultAllowPolicy(for: navigationAction) - var pref = preferences - - let group = DispatchGroup() - - for delegate in delegates { - if !delegate.responds(to: #selector(webView(_:decidePolicyFor:preferences:decisionHandler:))) - { - continue - } - group.enter() - delegate.webView?( - webView, - decidePolicyFor: navigationAction, - preferences: pref, - decisionHandler: { policy, preferences in - if policy == .cancel { - res = policy - } - - if policy == .download { - res = policy - } - - pref = preferences - - group.leave() - } - ) - } - - group.notify(queue: .main) { - decisionHandler(res, pref) - } - } - - func webView( - _ webView: WKWebView, - decidePolicyFor navigationResponse: WKNavigationResponse, - decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void - ) { - var res = WKNavigationResponsePolicy.allow - let group = DispatchGroup() - for delegate in delegates { - if !delegate.responds(to: #selector(webView(_:decidePolicyFor:decisionHandler:))) { - continue - } - group.enter() - delegate.webView?( - webView, - decidePolicyFor: navigationResponse, - decisionHandler: { policy in - if policy == .cancel { - res = policy - } - - if policy == .download { - res = policy - } - group.leave() - } - ) - } - - if res == .allow { - let tab = tabManager?[webView] - tab?.mimeType = navigationResponse.response.mimeType - } - - group.notify(queue: .main) { - decisionHandler(res) - } - } - - func webView( - _ webView: WKWebView, - navigationAction: WKNavigationAction, - didBecome download: WKDownload - ) { - for delegate in delegates { - delegate.webView?(webView, navigationAction: navigationAction, didBecome: download) - if download.delegate != nil { - return - } - } - } - - func webView( - _ webView: WKWebView, - navigationResponse: WKNavigationResponse, - didBecome download: WKDownload - ) { - for delegate in delegates { - delegate.webView?(webView, navigationResponse: navigationResponse, didBecome: download) - if download.delegate != nil { - return - } - } - } -} diff --git a/ios/brave-ios/Sources/Brave/Frontend/Reader/ReadabilityService.swift b/ios/brave-ios/Sources/Brave/Frontend/Reader/ReadabilityService.swift index 839592140cc9..6d1c6cb2dd7f 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Reader/ReadabilityService.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Reader/ReadabilityService.swift @@ -36,7 +36,7 @@ class ReadabilityOperation: Operation { let configuration = WKWebViewConfiguration() self.tab = Tab(configuration: configuration) self.tab.createWebview() - self.tab.navigationDelegate = self + self.tab.webView?.navigationDelegate = self let readerMode = ReaderModeScriptHandler() readerMode.delegate = self diff --git a/ios/brave-ios/Tests/ClientTests/TabManagerNavDelegateTests.swift b/ios/brave-ios/Tests/ClientTests/TabManagerNavDelegateTests.swift deleted file mode 100644 index e09f74f8d85d..000000000000 --- a/ios/brave-ios/Tests/ClientTests/TabManagerNavDelegateTests.swift +++ /dev/null @@ -1,233 +0,0 @@ -// Copyright 2022 The Brave Authors. All rights reserved. -// 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 https://mozilla.org/MPL/2.0/. - -import WebKit -import XCTest - -@testable import Brave - -@MainActor class TabManagerNavDelegateTests: XCTestCase { - var navigation: WKNavigation! - override func setUp() { - super.setUp() - navigation = WKNavigation() - } - - func test_webViewDidCommit_sendsCorrectMessage() { - let sut = TabManagerNavDelegate() - let delegate1 = WKNavigationDelegateSpy() - let delegate2 = WKNavigationDelegateSpy() - - sut.insert(delegate1) - sut.insert(delegate2) - sut.webView(anyWebView(), didCommit: navigation) - - XCTAssertEqual(delegate1.receivedMessages, [.webViewDidCommit]) - XCTAssertEqual(delegate2.receivedMessages, [.webViewDidCommit]) - } - - func test_webViewDidFail_sendsCorrectMessage() { - let sut = TabManagerNavDelegate() - let delegate1 = WKNavigationDelegateSpy() - let delegate2 = WKNavigationDelegateSpy() - - sut.insert(delegate1) - sut.insert(delegate2) - sut.webView(anyWebView(), didFail: navigation, withError: anyError()) - - XCTAssertEqual(delegate1.receivedMessages, [.webViewDidFail]) - XCTAssertEqual(delegate2.receivedMessages, [.webViewDidFail]) - } - - func test_webViewDidFailProvisionalNavigation_sendsCorrectMessage() { - let sut = TabManagerNavDelegate() - let delegate1 = WKNavigationDelegateSpy() - let delegate2 = WKNavigationDelegateSpy() - - sut.insert(delegate1) - sut.insert(delegate2) - sut.webView(anyWebView(), didFailProvisionalNavigation: navigation, withError: anyError()) - - XCTAssertEqual(delegate1.receivedMessages, [.webViewDidFailProvisionalNavigation]) - XCTAssertEqual(delegate2.receivedMessages, [.webViewDidFailProvisionalNavigation]) - } - - func test_webViewDidFinish_sendsCorrectMessage() { - let sut = TabManagerNavDelegate() - let delegate1 = WKNavigationDelegateSpy() - let delegate2 = WKNavigationDelegateSpy() - - sut.insert(delegate1) - sut.insert(delegate2) - sut.webView(anyWebView(), didFinish: navigation) - - XCTAssertEqual(delegate1.receivedMessages, [.webViewDidFinish]) - XCTAssertEqual(delegate2.receivedMessages, [.webViewDidFinish]) - } - - func test_webViewWebContentProcessDidTerminate_sendsCorrectMessage() { - let sut = TabManagerNavDelegate() - let delegate1 = WKNavigationDelegateSpy() - let delegate2 = WKNavigationDelegateSpy() - - sut.insert(delegate1) - sut.insert(delegate2) - sut.webViewWebContentProcessDidTerminate(anyWebView()) - - XCTAssertEqual(delegate1.receivedMessages, [.webViewWebContentProcessDidTerminate]) - XCTAssertEqual(delegate2.receivedMessages, [.webViewWebContentProcessDidTerminate]) - } - - func test_webViewDidReceiveServerRedirectForProvisionalNavigation_sendsCorrectMessage() { - let sut = TabManagerNavDelegate() - let delegate1 = WKNavigationDelegateSpy() - let delegate2 = WKNavigationDelegateSpy() - - sut.insert(delegate1) - sut.insert(delegate2) - sut.webView(anyWebView(), didReceiveServerRedirectForProvisionalNavigation: navigation) - - XCTAssertEqual( - delegate1.receivedMessages, - [.webViewDidReceiveServerRedirectForProvisionalNavigation] - ) - XCTAssertEqual( - delegate2.receivedMessages, - [.webViewDidReceiveServerRedirectForProvisionalNavigation] - ) - } - - func test_webViewDidStartProvisionalNavigation_sendsCorrectMessage() { - let sut = TabManagerNavDelegate() - let delegate1 = WKNavigationDelegateSpy() - let delegate2 = WKNavigationDelegateSpy() - - sut.insert(delegate1) - sut.insert(delegate2) - sut.webView(anyWebView(), didStartProvisionalNavigation: navigation) - - XCTAssertEqual(delegate1.receivedMessages, [.webViewDidStartProvisionalNavigation]) - XCTAssertEqual(delegate2.receivedMessages, [.webViewDidStartProvisionalNavigation]) - } - - @MainActor - func test_webViewDecidePolicyFor_actionPolicy_sendsCorrectMessage() async { - let sut = TabManagerNavDelegate() - let delegate1 = WKNavigationDelegateSpy() - let delegate2 = WKNavigationDelegateSpy() - - sut.insert(delegate1) - sut.insert(delegate2) - let e = expectation(description: "decisionHandler") - sut.webView( - anyWebView(), - decidePolicyFor: WKNavigationAction(), - preferences: WKWebpagePreferences(), - decisionHandler: { _, _ in e.fulfill() } - ) - await fulfillment(of: [e]) - - XCTAssertEqual(delegate1.receivedMessages, [.webViewDecidePolicyWithActionPolicy]) - XCTAssertEqual(delegate2.receivedMessages, [.webViewDecidePolicyWithActionPolicy]) - } - - @MainActor - func test_webViewDecidePolicyFor_responsePolicy_sendsCorrectMessage() async { - let sut = TabManagerNavDelegate() - let delegate1 = WKNavigationDelegateSpy() - let delegate2 = WKNavigationDelegateSpy() - - sut.insert(delegate1) - sut.insert(delegate2) - let e = expectation(description: "decisionHandler") - sut.webView( - anyWebView(), - decidePolicyFor: WKNavigationResponse(), - decisionHandler: { _ in e.fulfill() } - ) - await fulfillment(of: [e]) - - XCTAssertEqual(delegate1.receivedMessages, [.webViewDecidePolicyWithResponsePolicy]) - XCTAssertEqual(delegate2.receivedMessages, [.webViewDecidePolicyWithResponsePolicy]) - } -} - -// MARK: - Helpers - -private func anyWebView() -> WKWebView { - return WKWebView(frame: CGRect(width: 100, height: 100)) -} - -private func anyError() -> NSError { - return NSError(domain: "any error", code: 0) -} - -private class WKNavigationDelegateSpy: NSObject, WKNavigationDelegate { - enum Message { - case webViewDidCommit - case webViewDidFail - case webViewDidFailProvisionalNavigation - case webViewDidFinish - case webViewWebContentProcessDidTerminate - case webViewDidReceiveServerRedirectForProvisionalNavigation - case webViewDidStartProvisionalNavigation - case webViewDecidePolicyWithActionPolicy - case webViewDecidePolicyWithResponsePolicy - } - - var receivedMessages = [Message]() - - func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - receivedMessages.append(.webViewDidCommit) - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - receivedMessages.append(.webViewDidFail) - } - - func webView( - _ webView: WKWebView, - didFailProvisionalNavigation navigation: WKNavigation!, - withError error: Error - ) { - receivedMessages.append(.webViewDidFailProvisionalNavigation) - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - receivedMessages.append(.webViewDidFinish) - } - - func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - receivedMessages.append(.webViewWebContentProcessDidTerminate) - } - - func webView( - _ webView: WKWebView, - didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation! - ) { - receivedMessages.append(.webViewDidReceiveServerRedirectForProvisionalNavigation) - } - - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - receivedMessages.append(.webViewDidStartProvisionalNavigation) - } - - func webView( - _ webView: WKWebView, - decidePolicyFor navigationResponse: WKNavigationResponse - ) async -> WKNavigationResponsePolicy { - receivedMessages.append(.webViewDecidePolicyWithResponsePolicy) - return .allow - } - - func webView( - _ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - preferences: WKWebpagePreferences - ) async -> (WKNavigationActionPolicy, WKWebpagePreferences) { - receivedMessages.append(.webViewDecidePolicyWithActionPolicy) - return (.allow, preferences) - } -}