diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 5f450c2359..60913ab267 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -527,7 +527,6 @@ extension Pixel { case cachedTabPreviewRemovalError case missingDownloadedFile - case unhandledDownload case compilationResult(result: CompileRulesResult, waitTime: BucketAggregation, appState: AppState) @@ -1329,7 +1328,6 @@ extension Pixel.Event { case .cachedTabPreviewRemovalError: return "m_d_tpre" case .missingDownloadedFile: return "m_d_missing_downloaded_file" - case .unhandledDownload: return "m_d_unhandled_download" case .compilationResult(result: let result, waitTime: let waitTime, appState: let appState): return "m_compilation_result_\(result)_time_\(waitTime)_state_\(appState)" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index fec6e2b7fe..84196a4f04 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -74,6 +74,7 @@ 1E4FAA6427D8DFB900ADC5B3 /* OngoingDownloadRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4FAA6327D8DFB900ADC5B3 /* OngoingDownloadRowViewModel.swift */; }; 1E4FAA6627D8DFC800ADC5B3 /* CompleteDownloadRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4FAA6527D8DFC800ADC5B3 /* CompleteDownloadRowViewModel.swift */; }; 1E53508F2C7C9A1F00818DAA /* DefaultSubscriptionManager+AccountManagerKeychainAccessDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E53508E2C7C9A1F00818DAA /* DefaultSubscriptionManager+AccountManagerKeychainAccessDelegate.swift */; }; + 1E5918472CA422A7008ED2B3 /* Navigation in Frameworks */ = {isa = PBXBuildFile; productRef = 1E5918462CA422A7008ED2B3 /* Navigation */; }; 1E60989B290009C700A508F9 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 1E7060BD28F88EE200E4CCDB /* Common */; }; 1E60989D290011E600A508F9 /* ContentBlocking in Frameworks */ = {isa = PBXBuildFile; productRef = 1E60989C290011E600A508F9 /* ContentBlocking */; }; 1E6098A1290011E600A508F9 /* UserScript in Frameworks */ = {isa = PBXBuildFile; productRef = 1E6098A0290011E600A508F9 /* UserScript */; }; @@ -820,7 +821,6 @@ B6BA95C328891E33004ABA20 /* BrowsingMenuAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BA95C228891E33004ABA20 /* BrowsingMenuAnimator.swift */; }; B6BA95C528894A28004ABA20 /* BrowsingMenuViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B6BA95C428894A28004ABA20 /* BrowsingMenuViewController.storyboard */; }; B6BA95E828924730004ABA20 /* JSAlertController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B6BA95E728924730004ABA20 /* JSAlertController.storyboard */; }; - B6CB93E5286445AB0090FEB4 /* Base64DownloadSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CB93E4286445AB0090FEB4 /* Base64DownloadSession.swift */; }; BBFF18B12C76448100C48D7D /* QuerySubmittedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFF18B02C76448100C48D7D /* QuerySubmittedTests.swift */; }; BD10B8AA2C7629740033115D /* Logger+Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD10B8A92C7629740033115D /* Logger+Subscription.swift */; }; BD15DB852B959CFD00821457 /* BundleExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD15DB842B959CFD00821457 /* BundleExtension.swift */; }; @@ -2626,7 +2626,6 @@ B6BA95C228891E33004ABA20 /* BrowsingMenuAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowsingMenuAnimator.swift; sourceTree = ""; }; B6BA95C428894A28004ABA20 /* BrowsingMenuViewController.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = BrowsingMenuViewController.storyboard; sourceTree = ""; }; B6BA95E728924730004ABA20 /* JSAlertController.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = JSAlertController.storyboard; sourceTree = ""; }; - B6CB93E4286445AB0090FEB4 /* Base64DownloadSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Base64DownloadSession.swift; sourceTree = ""; }; B6DFE6CF2BC7E47500A9CE59 /* SwiftLintTool.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftLintTool.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; B6DFE6D92BC7E61B00A9CE59 /* SwiftLintToolBundleConfiguration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SwiftLintToolBundleConfiguration.xcconfig; sourceTree = ""; }; BBFF18B02C76448100C48D7D /* QuerySubmittedTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuerySubmittedTests.swift; sourceTree = ""; }; @@ -3052,6 +3051,7 @@ 31E69A63280F4CB600478327 /* DuckUI in Frameworks */, CB941A6E2B96AB08000F9E7A /* PrivacyDashboard in Frameworks */, F42D541D29DCA40B004C4FF1 /* DesignResourcesKit in Frameworks */, + 1E5918472CA422A7008ED2B3 /* Navigation in Frameworks */, 85875B6129912A9900115F05 /* SyncUI in Frameworks */, F4D7F634298C00C3006C3AE9 /* FindInPageIOSJSSupport in Frameworks */, 9F96F73B2C9144D5009E45D5 /* Onboarding in Frameworks */, @@ -3494,7 +3494,6 @@ B623C1C12862CA9E0043013E /* DownloadSession.swift */, 31C138A727A3E9C900FFD4B2 /* URLDownloadSession.swift */, B623C1C32862CD670043013E /* WKDownloadSession.swift */, - B6CB93E4286445AB0090FEB4 /* Base64DownloadSession.swift */, B609D5512862EAFF0088CAC2 /* InlineWKDownloadDelegate.swift */, 310D09202799FD1A00DC0060 /* MIMEType.swift */, 3161D13127AC161B00285CF6 /* DownloadMetadata.swift */, @@ -6532,6 +6531,7 @@ F1D43AF92B99C1D300BAB743 /* BareBonesBrowserKit */, 9F8FE9482BAE50E50071E372 /* Lottie */, 9F96F73A2C9144D5009E45D5 /* Onboarding */, + 1E5918462CA422A7008ED2B3 /* Navigation */, ); productName = DuckDuckGo; productReference = 84E341921E2F7EFB00BDBA6F /* DuckDuckGo.app */; @@ -7736,7 +7736,6 @@ EE01EB432AFC1E0A0096AAC9 /* NetworkProtectionVPNLocationView.swift in Sources */, 7BC571202BDBB877003B0CCE /* VPNActivationDateStore.swift in Sources */, 9820EAF522613CD30089094D /* WebProgressWorker.swift in Sources */, - B6CB93E5286445AB0090FEB4 /* Base64DownloadSession.swift in Sources */, 1EEF387D285B1A1100383393 /* TrackerImageCache.swift in Sources */, 3151F0EC27357FEE00226F58 /* VoiceSearchFeedbackViewModel.swift in Sources */, 1E53508F2C7C9A1F00818DAA /* DefaultSubscriptionManager+AccountManagerKeychainAccessDelegate.swift in Sources */, @@ -11040,6 +11039,11 @@ package = F486D2FD25069744002D07D7 /* XCRemoteSwiftPackageReference "OHHTTPStubs" */; productName = OHHTTPStubsSwift; }; + 1E5918462CA422A7008ED2B3 /* Navigation */ = { + isa = XCSwiftPackageProductDependency; + package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; + productName = Navigation; + }; 1E60989C290011E600A508F9 /* ContentBlocking */ = { isa = XCSwiftPackageProductDependency; package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; diff --git a/DuckDuckGo/Base64DownloadSession.swift b/DuckDuckGo/Base64DownloadSession.swift deleted file mode 100644 index 6796aa95d9..0000000000 --- a/DuckDuckGo/Base64DownloadSession.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// Base64DownloadSession.swift -// DuckDuckGo -// -// Copyright © 2022 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation - -final class Base64DownloadSession: DownloadSession { - private var base64: String? - weak var delegate: DownloadSessionDelegate? - private(set) var isRunning: Bool = false - - init(base64: String) { - self.base64 = base64 - } - - func start() { - guard let base64 = base64 else { - self.delegate?.downloadSession(self, didFinishWith: .failure(CancellationError())) - return - } - self.isRunning = true - self.base64 = nil - - DispatchQueue.global().async { [self] in - do { - guard let data = Data(base64Encoded: base64) else { throw CocoaError(.fileReadCorruptFile) } - let localURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - - try data.write(to: localURL) - - DispatchQueue.main.async { - self.delegate?.downloadSession(self, didFinishWith: .success(localURL)) - self.isRunning = false - } - } catch { - DispatchQueue.main.async { - self.delegate?.downloadSession(self, didFinishWith: .failure(error)) - self.isRunning = false - } - } - } - } - - func cancel() { - self.base64 = nil - } - -} diff --git a/DuckDuckGo/DownloadMetadata.swift b/DuckDuckGo/DownloadMetadata.swift index 0b793ee795..e5dc290a38 100644 --- a/DuckDuckGo/DownloadMetadata.swift +++ b/DuckDuckGo/DownloadMetadata.swift @@ -32,7 +32,7 @@ struct DownloadMetadata { self.filename = filename self.expectedContentLength = response.expectedContentLength self.mimeTypeSource = response.mimeType ?? "" - self.mimeType = MIMEType(from: response.mimeType) + self.mimeType = MIMEType(from: response.mimeType, fileExtension: filename.pathExtension) self.url = url } } diff --git a/DuckDuckGo/MIMEType.swift b/DuckDuckGo/MIMEType.swift index 29a7c47813..93a0c8b4bf 100644 --- a/DuckDuckGo/MIMEType.swift +++ b/DuckDuckGo/MIMEType.swift @@ -33,7 +33,17 @@ enum MIMEType: String { init(from string: String?) { self = MIMEType(rawValue: string ?? "") ?? .unknown } - + + init(from string: String?, fileExtension: String?) { + let initialMIMEType = MIMEType(from: string) + + switch (initialMIMEType, fileExtension) { + case (.octetStream, "pkpass"): self = .passbook + case (.octetStream, "pkpasses"): self = .multipass + default: self = initialMIMEType + } + } + var isHTML: Bool { switch self { case .html, .xhtml: diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 57ccefbb33..8261602a70 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -40,6 +40,7 @@ import SpecialErrorPages import NetworkProtection import Onboarding import os.log +import Navigation class TabViewController: UIViewController { @@ -167,6 +168,9 @@ class TabViewController: UIViewController { var mostRecentAutoPreviewDownloadID: UUID? private var blobDownloadTargetFrame: WKFrameInfo? + // Recent request's URL if its WKNavigationAction had shouldPerformDownload set to true + private var recentNavigationActionShouldPerformDownloadURL: URL? + let userAgentManager: UserAgentManager = DefaultUserAgentManager.shared let bookmarksDatabase: CoreDataDatabase @@ -1263,7 +1267,7 @@ extension TabViewController: WKNavigationDelegate { private func handleServerTrustChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { guard shouldBypassSSLError, - let credential = urlCredentialCreator.urlCredentialFrom(trust: challenge.protectionSpace.serverTrust) else { + let credential = urlCredentialCreator.urlCredentialFrom(trust: challenge.protectionSpace.serverTrust) else { completionHandler(.performDefaultHandling, nil) return } @@ -1313,7 +1317,8 @@ extension TabViewController: WKNavigationDelegate { decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { - let mimeType = MIMEType(from: navigationResponse.response.mimeType) + let mimeType = MIMEType(from: navigationResponse.response.mimeType, fileExtension: navigationResponse.response.url?.pathExtension) + let urlSchemeType = navigationResponse.response.url.map { SchemeHandler.schemeType(for: $0) } ?? .unknown let httpResponse = navigationResponse.response as? HTTPURLResponse let isSuccessfulResponse = httpResponse?.isSuccessfulResponse ?? false @@ -1325,11 +1330,39 @@ extension TabViewController: WKNavigationDelegate { NotificationCenter.default.post(Notification(name: AppUserDefaults.Notifications.didVerifyInternalUser)) } - if navigationResponse.canShowMIMEType && !FilePreviewHelper.canAutoPreviewMIMEType(mimeType) { + // Important: Order of these checks matter! + if urlSchemeType == .blob { + // 1. To properly handle BLOB we need to trigger its download, if temporaryDownloadForPreviewedFile is set we allow its load in the web view + if let temporaryDownloadForPreviewedFile, temporaryDownloadForPreviewedFile.url == navigationResponse.response.url { + // BLOB already has a temporary downloaded so and we can allow loading it + blobDownloadTargetFrame = nil + decisionHandler(.allow) + } else { + // First we need to trigger download to handle it then in webView:navigationAction:didBecomeDownload + decisionHandler(.download) + } + } else if FilePreviewHelper.canAutoPreviewMIMEType(mimeType) { + // 2. For this MIME type we are able to provide a better custom preview via FilePreviewHelper so it takes priority + let download = self.startDownload(with: navigationResponse, decisionHandler: decisionHandler) + mostRecentAutoPreviewDownloadID = download?.id + Pixel.fire(pixel: .downloadStarted, + withAdditionalParameters: [PixelParameters.canAutoPreviewMIMEType: "1"]) + } else if shouldTriggerDownloadAction(for: navigationResponse), + let downloadMetadata = AppDependencyProvider.shared.downloadManager.downloadMetaData(for: navigationResponse.response) { + // 3. We know the response should trigger the file download prompt + self.presentSaveToDownloadsAlert(with: downloadMetadata) { + self.startDownload(with: navigationResponse, decisionHandler: decisionHandler) + } cancelHandler: { + decisionHandler(.cancel) + } + } else if navigationResponse.canShowMIMEType { + // 4. WebView can preview the MIME type and it is not to be handled by our custom FilePreviewHelper url = webView.url if navigationResponse.isForMainFrame, let decision = setupOrClearTemporaryDownload(for: navigationResponse.response) { + // Loading a file preview in web view decisionHandler(decision) } else { + // Loading HTML if navigationResponse.isForMainFrame && isSuccessfulResponse { adClickAttributionDetection.on2XXResponse(url: url) } @@ -1337,40 +1370,28 @@ extension TabViewController: WKNavigationDelegate { decisionHandler(.allow) } } - } else if isSuccessfulResponse { - if FilePreviewHelper.canAutoPreviewMIMEType(mimeType) { - let download = self.startDownload(with: navigationResponse, decisionHandler: decisionHandler) - mostRecentAutoPreviewDownloadID = download?.id - Pixel.fire(pixel: .downloadStarted, - withAdditionalParameters: [PixelParameters.canAutoPreviewMIMEType: "1"]) - } else if let url = navigationResponse.response.url, - case .blob = SchemeHandler.schemeType(for: url) { - decisionHandler(.download) - - } else if let downloadMetadata = AppDependencyProvider.shared.downloadManager - .downloadMetaData(for: navigationResponse.response) { - if view.window == nil { - decisionHandler(.cancel) - } else { - self.presentSaveToDownloadsAlert(with: downloadMetadata) { - self.startDownload(with: navigationResponse, decisionHandler: decisionHandler) - } cancelHandler: { - decisionHandler(.cancel) - } - // Rewrite the current URL to prevent spoofing from download URLs - self.chromeDelegate?.omniBar.textField.text = "about:blank" - } - } else { - Pixel.fire(pixel: .unhandledDownload) - decisionHandler(.cancel) - } - } else { - // MIME type should trigger download but response has no 2xx status code + // Fallback decisionHandler(.allow) } } + private func shouldTriggerDownloadAction(for navigationResponse: WKNavigationResponse) -> Bool { + let mimeType = MIMEType(from: navigationResponse.response.mimeType, fileExtension: navigationResponse.response.url?.pathExtension) + let httpResponse = navigationResponse.response as? HTTPURLResponse + + // HTTP response has "Content-Disposition: attachment" header + let hasContentDispositionAttachment = httpResponse?.shouldDownload ?? false + + // If preceding WKNavigationAction requested to start the download (e.g. link `download` attribute or BLOB object) + let hasNavigationActionRequestedDownload = (recentNavigationActionShouldPerformDownloadURL != nil) && recentNavigationActionShouldPerformDownloadURL == navigationResponse.response.url + + // File can be rendered by web view or in custom preview handled by FilePreviewHelper + let canLoadOrPreviewTheFile = navigationResponse.canShowMIMEType || FilePreviewHelper.canAutoPreviewMIMEType(mimeType) + + return hasContentDispositionAttachment || hasNavigationActionRequestedDownload || !canLoadOrPreviewTheFile + } + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { lastError = nil lastRenderedURL = webView.url @@ -1754,6 +1775,8 @@ extension TabViewController: WKNavigationDelegate { } if navigationAction.isTargetingMainFrame(), + !navigationAction.isSameDocumentNavigation, + !navigationAction.shouldDownload, !(navigationAction.request.url?.isCustomURLScheme() ?? false), navigationAction.navigationType != .backForward, let request = requestForDoNotSell(basedOn: navigationAction.request) { @@ -1828,6 +1851,8 @@ extension TabViewController: WKNavigationDelegate { let tld = storageCache.tld + // If WKNavigationAction requests to shouldPerformDownload prepare for handling it in decidePolicyFor:navigationResponse: + recentNavigationActionShouldPerformDownloadURL = navigationAction.shouldPerformDownload ? navigationAction.request.url : nil if navigationAction.isTargetingMainFrame() && tld.domain(navigationAction.request.mainDocumentURL?.host) != tld.domain(lastUpgradedURL?.host) { @@ -1865,7 +1890,7 @@ extension TabViewController: WKNavigationDelegate { } } - + let schemeType = SchemeHandler.schemeType(for: url) self.blobDownloadTargetFrame = nil switch schemeType { @@ -2117,17 +2142,6 @@ extension TabViewController { temporaryDownloadForPreviewedFile = nil return nil } - guard SchemeHandler.schemeType(for: url) != .blob else { - // suggestedFilename is empty for blob: downloads unless handled via completion(.download) - // WKNavigationResponse._downloadAttribute private API could be used instead of it :( - if self.temporaryDownloadForPreviewedFile?.url != url { // if temporary download not setup yet, preview otherwise - // calls webView:navigationAction:didBecomeDownload: - return .download - } else { - self.blobDownloadTargetFrame = nil - return .allow - } - } let cookieStore = webView.configuration.websiteDataStore.httpCookieStore temporaryDownloadForPreviewedFile = downloadManager.makeDownload(response: response, @@ -2152,31 +2166,33 @@ extension TabViewController { return } - let isTemporary = navigationResponse.canShowMIMEType - || FilePreviewHelper.canAutoPreviewMIMEType(downloadMetadata.mimeType) - if isTemporary { - // restart blob request loading for preview that was interrupted by .download callback - if navigationResponse.canShowMIMEType { - self.webView.load(navigationResponse.response.url!, in: self.blobDownloadTargetFrame) - } - callback(self.transfer(download, - to: downloadManager, - with: navigationResponse.response, - suggestedFilename: suggestedFilename, - isTemporary: isTemporary)) - - } else { + if self.shouldTriggerDownloadAction(for: navigationResponse) { + // Show alert to the file download self.presentSaveToDownloadsAlert(with: downloadMetadata) { callback(self.transfer(download, to: downloadManager, with: navigationResponse.response, suggestedFilename: suggestedFilename, - isTemporary: isTemporary)) + isTemporary: false)) } cancelHandler: { callback(nil) } self.temporaryDownloadForPreviewedFile = nil + } else { + // Showing file in the webview or in preview view + if FilePreviewHelper.canAutoPreviewMIMEType(downloadMetadata.mimeType) { + // If FilePreviewHelper can handle format we do not need to load as it will be handled by setting + // temporaryDownloadForPreviewedFile and mostRecentAutoPreviewDownloadID + } else if navigationResponse.canShowMIMEType { + // To load BLOB in web view we need to restart the request loading as it was interrupted by .download callback + self.webView.load(navigationResponse.response.url!, in: self.blobDownloadTargetFrame) + } + callback(self.transfer(download, + to: downloadManager, + with: navigationResponse.response, + suggestedFilename: suggestedFilename, + isTemporary: true)) } delegate.decideDestinationCallback = nil @@ -2207,6 +2223,7 @@ extension TabViewController { temporary: isTemporary) self.temporaryDownloadForPreviewedFile = isTemporary ? download : nil + self.mostRecentAutoPreviewDownloadID = isTemporary ? download?.id : nil if let download = download { downloadManager.startDownload(download) } @@ -2350,7 +2367,7 @@ extension TabViewController: WKUIDelegate { return } - let alert = WebJSAlert(domain: frame.request.url?.host + let alert = WebJSAlert(domain: frame.safeRequest?.url?.host // in case the web view is navigating to another host ?? webView.backForwardList.currentItem?.url.host ?? self.url?.absoluteString @@ -2370,7 +2387,7 @@ extension TabViewController: WKUIDelegate { return } - let alert = WebJSAlert(domain: frame.request.url?.host + let alert = WebJSAlert(domain: frame.safeRequest?.url?.host // in case the web view is navigating to another host ?? webView.backForwardList.currentItem?.url.host ?? self.url?.absoluteString