Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Malicious site protection navigation detection #3707

Open
wants to merge 16 commits into
base: alessandro/malicious-site-protection
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions DuckDuckGo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -761,14 +761,15 @@
9F254ACE2CF5D3540063B308 /* SpecialErrorPageNavigationHandlerIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ACD2CF5D3540063B308 /* SpecialErrorPageNavigationHandlerIntegrationTests.swift */; };
9F254AD22CF5D3A80063B308 /* MockSpecialErrorWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD12CF5D3A20063B308 /* MockSpecialErrorWebView.swift */; };
9F254AD32CF5D3A80063B308 /* MockSpecialErrorWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD12CF5D3A20063B308 /* MockSpecialErrorWebView.swift */; };
9F254AD52CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD42CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift */; };
9F254AD62CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD42CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift */; };
9F254AD52CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD42CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift */; };
9F254AD62CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD42CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift */; };
9F254AD82CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD72CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift */; };
9F254AD92CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD72CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift */; };
9F254ADB2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ADA2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift */; };
9F254ADC2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ADA2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift */; };
9F254ADE2CF636CF0063B308 /* DummyWKNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ADD2CF636CF0063B308 /* DummyWKNavigation.swift */; };
9F254ADF2CF636CF0063B308 /* DummyWKNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ADD2CF636CF0063B308 /* DummyWKNavigation.swift */; };
9F254AF12CF8D5250063B308 /* MaliciousSiteProtection in Frameworks */ = {isa = PBXBuildFile; productRef = 9F254AF02CF8D5250063B308 /* MaliciousSiteProtection */; };
9F254AFF2CF9FA1B0063B308 /* WebViewNavigationHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AFE2CF9FA1B0063B308 /* WebViewNavigationHandling.swift */; };
9F254B012CF9FA8D0063B308 /* SpecialErrorPageActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254B002CF9FA8D0063B308 /* SpecialErrorPageActionHandler.swift */; };
9F254B032CF9FB2E0063B308 /* SpecialErrorPageNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254B022CF9FB2E0063B308 /* SpecialErrorPageNavigationDelegate.swift */; };
Expand Down Expand Up @@ -819,6 +820,9 @@
9FB027192C26BC29009EA190 /* BrowsersComparisonModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB027182C26BC29009EA190 /* BrowsersComparisonModel.swift */; };
9FB0271B2C2927D0009EA190 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB0271A2C2927D0009EA190 /* OnboardingView.swift */; };
9FB0271D2C293619009EA190 /* OnboardingIntroViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB0271C2C293619009EA190 /* OnboardingIntroViewModel.swift */; };
9FBC76672CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBC76662CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift */; };
9FBC766A2CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBC76692CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift */; };
9FBC766B2CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBC76692CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift */; };
9FCFCD802C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD7F2C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift */; };
9FCFCD812C6B020D006EB7A0 /* LaunchOptionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD7D2C6AF52A006EB7A0 /* LaunchOptionsHandler.swift */; };
9FCFCD852C75C91A006EB7A0 /* ProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD842C75C91A006EB7A0 /* ProgressBarView.swift */; };
Expand Down Expand Up @@ -2632,7 +2636,7 @@
9F254ACA2CF5CDC60063B308 /* SpecialErrorPageNavigationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageNavigationHandlerTests.swift; sourceTree = "<group>"; };
9F254ACD2CF5D3540063B308 /* SpecialErrorPageNavigationHandlerIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageNavigationHandlerIntegrationTests.swift; sourceTree = "<group>"; };
9F254AD12CF5D3A20063B308 /* MockSpecialErrorWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSpecialErrorWebView.swift; sourceTree = "<group>"; };
9F254AD42CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DummyMaliciousSiteProtectionNavigationHandler.swift; sourceTree = "<group>"; };
9F254AD42CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMaliciousSiteProtectionNavigationHandler.swift; sourceTree = "<group>"; };
9F254AD72CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSSLErrorPageNavigationHandler.swift; sourceTree = "<group>"; };
9F254ADA2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSpecialErrorPageNavigationDelegate.swift; sourceTree = "<group>"; };
9F254ADD2CF636CF0063B308 /* DummyWKNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DummyWKNavigation.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2684,6 +2688,8 @@
9FB027182C26BC29009EA190 /* BrowsersComparisonModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowsersComparisonModel.swift; sourceTree = "<group>"; };
9FB0271A2C2927D0009EA190 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
9FB0271C2C293619009EA190 /* OnboardingIntroViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingIntroViewModel.swift; sourceTree = "<group>"; };
9FBC76662CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaliciousSiteProtectionNavigationHandlerTests.swift; sourceTree = "<group>"; };
9FBC76692CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMaliciousSiteProtectionManager.swift; sourceTree = "<group>"; };
9FCFCD7D2C6AF52A006EB7A0 /* LaunchOptionsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LaunchOptionsHandler.swift; path = ../DuckDuckGo/LaunchOptionsHandler.swift; sourceTree = "<group>"; };
9FCFCD7F2C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchOptionsHandlerTests.swift; sourceTree = "<group>"; };
9FCFCD842C75C91A006EB7A0 /* ProgressBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBarView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3234,6 +3240,7 @@
31E69A63280F4CB600478327 /* DuckUI in Frameworks */,
CB941A6E2B96AB08000F9E7A /* PrivacyDashboard in Frameworks */,
F42D541D29DCA40B004C4FF1 /* DesignResourcesKit in Frameworks */,
9F254AF12CF8D5250063B308 /* MaliciousSiteProtection in Frameworks */,
1E5918472CA422A7008ED2B3 /* Navigation in Frameworks */,
85875B6129912A9900115F05 /* SyncUI in Frameworks */,
F4D7F634298C00C3006C3AE9 /* FindInPageIOSJSSupport in Frameworks */,
Expand Down Expand Up @@ -5097,6 +5104,7 @@
CBC88EE22C7F8B1700F0F8C5 /* SSLErrorPageNavigationHandlerTests.swift */,
9F254ACA2CF5CDC60063B308 /* SpecialErrorPageNavigationHandlerTests.swift */,
9F254ACD2CF5D3540063B308 /* SpecialErrorPageNavigationHandlerIntegrationTests.swift */,
9FBC76662CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift */,
);
path = SpecialErrorPage;
sourceTree = "<group>";
Expand All @@ -5105,10 +5113,11 @@
isa = PBXGroup;
children = (
9F254AD12CF5D3A20063B308 /* MockSpecialErrorWebView.swift */,
9F254AD42CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift */,
9F254AD42CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift */,
9F254AD72CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift */,
9F254ADA2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift */,
9F254ADD2CF636CF0063B308 /* DummyWKNavigation.swift */,
9FBC76692CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift */,
);
path = TestDoubles;
sourceTree = "<group>";
Expand Down Expand Up @@ -6974,6 +6983,7 @@
9F96F73A2C9144D5009E45D5 /* Onboarding */,
1E5918462CA422A7008ED2B3 /* Navigation */,
315C77812CFA41A400699683 /* AIChat */,
9F254AF02CF8D5250063B308 /* MaliciousSiteProtection */,
);
productName = DuckDuckGo;
productReference = 84E341921E2F7EFB00BDBA6F /* DuckDuckGo.app */;
Expand Down Expand Up @@ -8457,7 +8467,7 @@
569437342BE4E41500C0881B /* SyncErrorHandlerSyncErrorsAlertsTests.swift in Sources */,
CBC88EE32C7F8B1700F0F8C5 /* SSLErrorPageNavigationHandlerTests.swift in Sources */,
85C11E4120904BBE00BFFEB4 /* VariantManagerTests.swift in Sources */,
9F254AD52CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift in Sources */,
9F254AD52CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift in Sources */,
F1134ECE1F40EA9C00B73467 /* AtbParserTests.swift in Sources */,
F189AEE41F18FDAF001EBAE1 /* LinkTests.swift in Sources */,
6F7FB8E12C660B3E00867DA7 /* NewTabPageFavoritesModelTests.swift in Sources */,
Expand Down Expand Up @@ -8497,6 +8507,7 @@
9F254ADF2CF636CF0063B308 /* DummyWKNavigation.swift in Sources */,
6F7BACD42CEE084B00F561D8 /* OmniBarEqualityCheckTests.swift in Sources */,
6F7FB8E72C66197E00867DA7 /* NewTabPageSectionsSettingsModelTests.swift in Sources */,
9FBC766A2CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift in Sources */,
851CD674244D7E6000331B98 /* UserDefaultsExtension.swift in Sources */,
569437362BE5160600C0881B /* SyncSettingsViewControllerErrorTests.swift in Sources */,
EEC02C162B065BE00045CE11 /* NetworkProtectionVPNLocationViewModelTests.swift in Sources */,
Expand Down Expand Up @@ -8570,6 +8581,7 @@
9F4CC51F2C48D758006A96EB /* ContextualDaxDialogsFactoryTests.swift in Sources */,
8521FDE6238D414B00A44CC3 /* FileStoreTests.swift in Sources */,
F14E491F1E391CE900DC037C /* URLExtensionTests.swift in Sources */,
9FBC76672CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift in Sources */,
9F23B8062C2BE22700950875 /* OnboardingIntroViewModelTests.swift in Sources */,
9F69331D2C5A191400CD6A5D /* MockTutorialSettings.swift in Sources */,
85D2187424BF25CD004373D2 /* FaviconsTests.swift in Sources */,
Expand Down Expand Up @@ -8671,8 +8683,9 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
9F254AD62CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift in Sources */,
9F254AD62CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift in Sources */,
85F21DB0210F5E32002631A6 /* AtbIntegrationTests.swift in Sources */,
9FBC766B2CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift in Sources */,
9F254AD22CF5D3A80063B308 /* MockSpecialErrorWebView.swift in Sources */,
9F254ADE2CF636CF0063B308 /* DummyWKNavigation.swift in Sources */,
8551912724746EDC0010FDD0 /* SnapshotHelper.swift in Sources */,
Expand Down Expand Up @@ -12114,6 +12127,10 @@
package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */;
productName = Bookmarks;
};
9F254AF02CF8D5250063B308 /* MaliciousSiteProtection */ = {
isa = XCSwiftPackageProductDependency;
productName = MaliciousSiteProtection;
};
9F8FE9482BAE50E50071E372 /* Lottie */ = {
isa = XCSwiftPackageProductDependency;
package = 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,38 +18,20 @@
//

import Foundation
import MaliciousSiteProtection

final class MaliciousSiteProtectionManager: MaliciousSiteDetecting {

func evaluate(_ url: URL) async -> ThreatKind? {
try? await Task.sleep(interval: 0.3)
return .none
}

}

// MARK: - To Remove

// These entities are copied from BSK and they will be used to mock the library
import SpecialErrorPages

protocol MaliciousSiteDetecting {
func evaluate(_ url: URL) async -> ThreatKind?
}

public enum ThreatKind: String, CaseIterable, CustomStringConvertible {
public var description: String { rawValue }

case phishing
case malware
}

public extension ThreatKind {

var errorPageType: SpecialErrorKind {
switch self {
case .malware: .phishing // WIP in BSK
case .phishing: .phishing
switch url.absoluteString {
case "http://privacy-test-pages.site/security/badware/phishing.html":
return .phishing
case "http://privacy-test-pages.site/security/badware/malware.html":
Comment on lines +29 to +31
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should it be http vs https?
also, we should be keeping all urls inside AppURLs

return .malware
default:
return .none
}
}

Expand Down
6 changes: 6 additions & 0 deletions DuckDuckGo/SpecialErrorPage/Model/SpecialErrorModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import Foundation
import SpecialErrorPages
import WebKit

struct SpecialErrorModel: Equatable {
let url: URL
Expand All @@ -29,3 +30,8 @@ struct SSLSpecialError {
let type: SSLErrorType
let error: SpecialErrorModel
}

struct MaliciousSiteDetectionNavigationResponse: Equatable {
let navigationAction: WKNavigationAction
let errorData: SpecialErrorData
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
//

import Foundation
import SpecialErrorPages

/// A type that defines actions for handling special error pages.
///
Expand All @@ -26,11 +27,14 @@ import Foundation
/// advanced information related to the error.
protocol SpecialErrorPageActionHandler {
/// Handles the action of navigating to the site associated with the error page
func visitSite()
@MainActor
func visitSite(url: URL, errorData: SpecialErrorData)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m a bit on the fence with this.
The idea was to have a generic protocol that all the sub-special error handlers should conform to.
For MaliciousSiteProtectionNavigationHandler I need to pass the URL and SpecialErrorData but I don’t need them for the SSL handler. So I feel I’m violating the Interface Segregation principle. what do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you perhaps create two funcs visitSite, one without params and one with params? Different handlers would be implementing only one function leaving other implementation empty? It's not ideal but the simplest

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, if we encounter more cases like this where we’re stretching these protocols, we might need to reconsider whether it’s appropriate to treat all special errors the same way. For example, SSL and malicious site errors already exhibit different behaviors. I’ll leave it up to you, but perhaps we should treat them differently (not under same logic) from the start. We can discuss further in 2025 :)


/// Handles the action of leaving the site associated with the error page
@MainActor
func leaveSite()

/// Handles the action of requesting more detailed information about the error
@MainActor
func advancedInfoPresented()
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ protocol SpecialErrorPageContextHandling: AnyObject {
/// The URL that failed to load, if any.
var failedURL: URL? { get }

/// A boolean value indicating whether the WebView request requires showing a special error page.
var isSpecialErrorPageRequest: Bool { get }

/// Attaches a web view to the special error page handling.
func attachWebView(_ webView: WKWebView)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import Foundation
import WebKit
import MaliciousSiteProtection

// MARK: - WebViewNavigation

Expand All @@ -41,15 +42,24 @@ protocol WebViewNavigationHandling: AnyObject {
/// - Parameters:
/// - navigationAction: Details about the action that triggered the navigation request.
/// - webView: The web view from which the navigation request began.
/// - Returns: A Boolean value that indicates whether the navigation action was handled.
func handleSpecialErrorNavigation(navigationAction: WKNavigationAction, webView: WKWebView) async -> Bool
@MainActor
func handleDecidePolicyFor(navigationAction: WKNavigationAction, webView: WKWebView)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be
func handleDecidePolicy(for navigationAction: WKNavigationAction, webView: WKWebView)


/// Decides whether to to navigate to new content after the response to the navigation request is known or cancel the navigation and show a special error page based on the specified action information.
/// - Parameters:
/// - navigationResponse: Descriptive information about the navigation response.
/// - webView: The web view from which the navigation request began.
/// - Returns: A Boolean value that indicates whether to cancel or allow the navigation.
@MainActor
func handleDecidePolicyfor(navigationResponse: WKNavigationResponse, webView: WKWebView) async -> Bool
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo, "for" is lowercased, but I'd refer to my previous comment ^


/// Handles authentication challenges received by the web view.
///
/// - Parameters:
/// - webView: The web view that receives the authentication challenge.
/// - challenge: The authentication challenge.
/// - completionHandler: A completion handler block to execute with the response.
@MainActor
func handleWebView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)

/// Handles failures during provisional navigation.
Expand All @@ -58,12 +68,14 @@ protocol WebViewNavigationHandling: AnyObject {
/// - webView: The `WKWebView` instance that failed the navigation.
/// - navigation: The navigation object for the operation.
/// - error: The error that occurred.
@MainActor
func handleWebView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WebViewNavigation, withError error: NSError)

/// Handles the successful completion of a navigation in the web view.
///
/// - Parameters:
/// - webView: The web view that loaded the content.
/// - navigation: The navigation object that finished.
@MainActor
func handleWebView(_ webView: WKWebView, didFinish navigation: WebViewNavigation)
}
Loading
Loading