From 8acf064e556d9dcc2edf9f44dc60dbd5ff518c23 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Sat, 19 Oct 2024 00:58:10 +0500 Subject: [PATCH] Display Fire window downloads per Fire Window (#3250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1199230911884351/1207052331808912/f **Description**: - Separate downloads from Fire window and Regular windows **Steps to test this PR**: 1. Start a download in a regular window 2. Open a Fire Window, validate its downloads list is empty 3. Start a download in the Fire Window; Validate the download stays in the list after it‘s completed. 4. Close the Fire Window with active downloads; Validate the alert is displayed (with "this file" or "these files" for 1 or many files downloaded) 5. Close the Fire Window, validate the downloads aren‘t displayed in the Regular window **Definition of Done**: * [ ] Does this PR satisfy our [Definition of Done](https://app.asana.com/0/1202500774821704/1207634633537039/f)? --- ###### Internal references: [Pull Request Review Checklist](https://app.asana.com/0/1202500774821704/1203764234894239/f) [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) [Pull Request Documentation](https://app.asana.com/0/1202500774821704/1204012835277482/f) --------- Co-authored-by: Juan Manuel Pereira --- DuckDuckGo.xcodeproj/project.pbxproj | 6 + DuckDuckGo/Application/AppDelegate.swift | 11 +- .../Common/Extensions/NSAlertExtension.swift | 11 + .../Common/Extensions/NSEventExtension.swift | 7 + .../Common/Extensions/StringExtension.swift | 9 + DuckDuckGo/Common/Localizables/UserText.swift | 7 +- .../FileDownload/Model/DownloadListItem.swift | 5 +- .../Model/DownloadListViewModel.swift | 16 +- .../Model/FileDownloadManager.swift | 31 ++- .../Model/WebKitDownloadTask.swift | 21 +- .../Services/DownloadListCoordinator.swift | 65 ++++- .../Services/DownloadListStore.swift | 2 +- .../FileDownload/View/DownloadsCellView.swift | 12 +- .../FileDownload/View/DownloadsPopover.swift | 8 +- .../View/DownloadsViewController.swift | 9 +- .../NSAlert+ActiveDownloadsTermination.swift | 28 +- DuckDuckGo/Fire/Model/Fire.swift | 2 +- DuckDuckGo/Localizable.xcstrings | 262 +++++++++++++++++- DuckDuckGo/MainWindow/FireWindowSession.swift | 103 +++++++ .../MainWindow/MainWindowController.swift | 63 ++++- .../View/NavigationBarPopovers.swift | 8 +- .../View/NavigationBarViewController.swift | 58 ++-- .../TabExtensions/DownloadsTabExtension.swift | 6 +- DuckDuckGo/Windows/View/WindowsManager.swift | 7 +- .../DownloadListCoordinatorTests.swift | 41 +-- .../FileDownload/DownloadListStoreTests.swift | 6 +- .../FileDownloadManagerTests.swift | 20 +- .../Helpers/FileDownloadManagerMock.swift | 2 +- .../View/NavigationBarPopoversTests.swift | 2 +- 29 files changed, 694 insertions(+), 134 deletions(-) create mode 100644 DuckDuckGo/MainWindow/FireWindowSession.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index c418156c0c..c517e41031 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1810,6 +1810,8 @@ 843D73BC2C786E5400E4F9DC /* BookmarkListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F1C8DA2C774CA900716446 /* BookmarkListPopover.swift */; }; 844D7DA42C9443EA00BE61D4 /* NSPrintInfoExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844D7DA32C9443E500BE61D4 /* NSPrintInfoExtension.swift */; }; 844D7DA52C9443EA00BE61D4 /* NSPrintInfoExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844D7DA32C9443E500BE61D4 /* NSPrintInfoExtension.swift */; }; + 84537A032C998C28008723BC /* FireWindowSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84537A022C998C24008723BC /* FireWindowSession.swift */; }; + 84537A042C998C28008723BC /* FireWindowSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84537A022C998C24008723BC /* FireWindowSession.swift */; }; 84537A082C99C203008723BC /* DeallocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C2C9EE276081AB005B7F0A /* DeallocationTests.swift */; }; 84537A092C99C203008723BC /* DeallocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C2C9EE276081AB005B7F0A /* DeallocationTests.swift */; }; 848648A12C76F4B20082282D /* BookmarksBarMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848648A02C76F4B20082282D /* BookmarksBarMenuViewController.swift */; }; @@ -3897,6 +3899,7 @@ 843965112C6F2FFE004C8899 /* NSDragOperationExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSDragOperationExtension.swift; sourceTree = ""; }; 843965142C737022004C8899 /* NSPasteboardExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSPasteboardExtension.swift; sourceTree = ""; }; 844D7DA32C9443E500BE61D4 /* NSPrintInfoExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSPrintInfoExtension.swift; sourceTree = ""; }; + 84537A022C998C24008723BC /* FireWindowSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FireWindowSession.swift; sourceTree = ""; }; 848648A02C76F4B20082282D /* BookmarksBarMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarMenuViewController.swift; sourceTree = ""; }; 84DC71582C1C1E8A00033B8C /* UserDefaultsWrapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsWrapperTests.swift; sourceTree = ""; }; 84DDB9092C92B667008C997B /* WKVisitedLinkStoreWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKVisitedLinkStoreWrapper.swift; sourceTree = ""; }; @@ -7557,6 +7560,7 @@ AA585DB02490E6FA00E9A3E2 /* MainWindow */ = { isa = PBXGroup; children = ( + 84537A022C998C24008723BC /* FireWindowSession.swift */, AA7412BC24D2BEEE00D22FE0 /* MainWindow.swift */, AA7412B424D1536B00D22FE0 /* MainWindowController.swift */, AA585DAE2490E6E600E9A3E2 /* MainViewController.swift */, @@ -10896,6 +10900,7 @@ 3706FB62293F65D500E42796 /* CSVParser.swift in Sources */, 31C26A0E2CBE9DFE00FFF462 /* AIChatPreferences.swift in Sources */, B626A75B29921FAA00053070 /* NavigationActionPolicyExtension.swift in Sources */, + 84537A032C998C28008723BC /* FireWindowSession.swift in Sources */, 4B9DB02A2A983B24000927DB /* WaitlistStorage.swift in Sources */, BB470EBC2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift in Sources */, 3706FB65293F65D500E42796 /* PrivacyDashboardWebView.swift in Sources */, @@ -12120,6 +12125,7 @@ B60C6F8D29B200AB007BFAA8 /* SavePanelAccessoryView.swift in Sources */, F1D042992BFBABA100A31506 /* SubscriptionManager+StandardConfiguration.swift in Sources */, 37534CA3281132CB002621E7 /* TabLazyLoaderDataSource.swift in Sources */, + 84537A042C998C28008723BC /* FireWindowSession.swift in Sources */, 3158B15C2B0BF76D00AF130C /* DataBrokerProtectionAppEvents.swift in Sources */, 4B723E0E26B0006300E14D75 /* LoginImport.swift in Sources */, 4B9DB03E2A983B24000927DB /* JoinWaitlistView.swift in Sources */, diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index b2aaac1dd1..b7fa9ca764 100644 --- a/DuckDuckGo/Application/AppDelegate.swift +++ b/DuckDuckGo/Application/AppDelegate.swift @@ -457,9 +457,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { if !FileDownloadManager.shared.downloads.isEmpty { // if there‘re downloads without location chosen yet (save dialog should display) - ignore them - if FileDownloadManager.shared.downloads.contains(where: { $0.state.isDownloading }) { + let activeDownloads = Set(FileDownloadManager.shared.downloads.filter { $0.state.isDownloading }) + if !activeDownloads.isEmpty { let alert = NSAlert.activeDownloadsTerminationAlert(for: FileDownloadManager.shared.downloads) - if alert.runModal() == .cancel { + let downloadsFinishedCancellable = FileDownloadManager.observeDownloadsFinished(activeDownloads) { + // close alert and burn the window when all downloads finished + NSApp.stopModal(withCode: .OK) + } + let response = alert.runModal() + downloadsFinishedCancellable.cancel() + if response == .cancel { return .terminateCancel } } diff --git a/DuckDuckGo/Common/Extensions/NSAlertExtension.swift b/DuckDuckGo/Common/Extensions/NSAlertExtension.swift index 434b474796..a234b8cecb 100644 --- a/DuckDuckGo/Common/Extensions/NSAlertExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSAlertExtension.swift @@ -21,6 +21,17 @@ import Cocoa extension NSAlert { + @discardableResult + func addButton(withTitle title: String, response: NSApplication.ModalResponse, keyEquivalent: NSEvent.KeyEquivalent? = nil) -> NSButton { + let button = addButton(withTitle: title) + button.tag = response.rawValue + if let keyEquivalent { + button.keyEquivalent = keyEquivalent.charCode + button.keyEquivalentModifierMask = keyEquivalent.modifierMask + } + return button + } + static func fireproofAlert(with domain: String) -> NSAlert { let alert = NSAlert() alert.messageText = UserText.fireproofConfirmationTitle(domain: domain) diff --git a/DuckDuckGo/Common/Extensions/NSEventExtension.swift b/DuckDuckGo/Common/Extensions/NSEventExtension.swift index 5df268c97f..cfbd091cd2 100644 --- a/DuckDuckGo/Common/Extensions/NSEventExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSEventExtension.swift @@ -104,6 +104,7 @@ enum KeyEquivalentElement: ExpressibleByStringLiteral, Hashable { static let tab = KeyEquivalentElement.charCode("\t") static let left = KeyEquivalentElement.charCode("\u{2190}") static let right = KeyEquivalentElement.charCode("\u{2192}") + static let escape = KeyEquivalentElement.charCode("\u{1B}") init(stringLiteral value: String) { self = .charCode(value) @@ -113,6 +114,12 @@ enum KeyEquivalentElement: ExpressibleByStringLiteral, Hashable { extension NSEvent.KeyEquivalent: ExpressibleByStringLiteral, ExpressibleByUnicodeScalarLiteral, ExpressibleByExtendedGraphemeClusterLiteral { public typealias StringLiteralType = String + static let backspace: Self = [.backspace] + static let tab: Self = [.tab] + static let left: Self = [.left] + static let right: Self = [.right] + static let escape: Self = [.escape] + public init(stringLiteral value: String) { self = [.charCode(value)] } diff --git a/DuckDuckGo/Common/Extensions/StringExtension.swift b/DuckDuckGo/Common/Extensions/StringExtension.swift index 4e27629748..34a321f2f7 100644 --- a/DuckDuckGo/Common/Extensions/StringExtension.swift +++ b/DuckDuckGo/Common/Extensions/StringExtension.swift @@ -29,6 +29,15 @@ extension String { return (self.count > length) ? self.prefix(length) + trailing : self } + func truncated(length: Int, middle: String) -> String { + guard self.count > length else { return self } + + let halfLength = length / 2 + let start = self.prefix(halfLength).trimmingCharacters(in: .whitespaces) + let end = self.suffix(halfLength).trimmingCharacters(in: .whitespaces) + return "\(start)\(middle)\(end)" + } + func escapedJavaScriptString() -> String { self.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 7b33cf1bd9..1f96dc801b 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -34,6 +34,7 @@ struct UserText { static let neverForThisSite = NSLocalizedString("never.for.this.site", value: "Never Ask for This Site", comment: "Never ask to save login credentials for this site button") static let open = NSLocalizedString("open", value: "Open", comment: "Open button") static let close = NSLocalizedString("close", value: "Close", comment: "Close button") + static let dontClose = NSLocalizedString("dont.close", value: "Don’t Close", comment: "Don’t Close the window button title") static let save = NSLocalizedString("save", value: "Save", comment: "Save button") static let dontSave = NSLocalizedString("dont.save", value: "Don't Save", comment: "Don't Save button") static let update = NSLocalizedString("update", value: "Update", comment: "Update button") @@ -809,8 +810,12 @@ struct UserText { static let revealToolTip = NSLocalizedString("downloads.tooltip.reveal", value: "Show in Finder", comment: "Mouse-over tooltip for Show in Finder button") static let downloadsActiveAlertTitle = NSLocalizedString("downloads.active.alert.title", value: "A download is in progress.", comment: "Alert title when trying to quit application while files are being downloaded") - static let downloadsActiveAlertMessageFormat = NSLocalizedString("downloads.active.alert.message.format", value: "Are you sure you want to quit? DuckDuckGo Privacy Browser is currently downloading “%@”%@. If you quit now DuckDuckGo Privacy Browser won’t finish downloading this file.", comment: "Alert text format when trying to quit application while file “filename”[, and others] are being downloaded") + static let downloadsActiveAlertMessageFormat = NSLocalizedString("downloads.active.alert.message.format", value: "Are you sure you want to quit?\n\nDuckDuckGo is currently downloading “%@”%@. If you quit now, DuckDuckGo won‘t finish downloading %@.", comment: "Alert text format when trying to quit application while file “filename (%@)”[, and others (%@)] are being downloaded; If you quit now, DuckDuckGo won‘t finish downloading [this file|these files](%@).") static let downloadsActiveAlertMessageAndOthers = NSLocalizedString("downloads.active.alert.message.and.others", value: ", and other files", comment: "Alert text format element for “, and other files”") + static let downloadsActiveAlertMessageThisFile = NSLocalizedString("downloads.active.alert.message.this.file", value: "this file", comment: "Alert text format element for “DuckDuckGo won‘t finish downloading ->this file<-”") + static let downloadsActiveAlertMessageTheseFiles = NSLocalizedString("downloads.active.alert.message.these.files", value: "these files", comment: "Alert text format element for “DuckDuckGo won‘t finish downloading ->these file<-”") + + static let downloadsActiveInFireWindowAlertMessageFormat = NSLocalizedString("fire-window.downloads.active.alert.message.format", value: "Are you sure you want to close the Fire Window?\n\nDuckDuckGo is currently downloading “%@”%@. If you close the Fire Window, DuckDuckGo will delete %@.", comment: "Alert text format when trying to close a Fire Window while file “filename (%@)”[, and others (%@)] are being downloaded in it. If you close the Fire Window, DuckDuckGo will delete [this file|these files](%@).") static let exportLoginsFailedMessage = NSLocalizedString("export.logins.failed.message", value: "Failed to Export Passwords", comment: "Alert title when exporting login data fails") static let exportLoginsFailedInformative = NSLocalizedString("export.logins.failed.informative", value: "Please check that no file exists at the location you selected.", comment: "Alert message when exporting login data fails") diff --git a/DuckDuckGo/FileDownload/Model/DownloadListItem.swift b/DuckDuckGo/FileDownload/Model/DownloadListItem.swift index 1b6cdc515b..f4b8041822 100644 --- a/DuckDuckGo/FileDownload/Model/DownloadListItem.swift +++ b/DuckDuckGo/FileDownload/Model/DownloadListItem.swift @@ -42,7 +42,10 @@ struct DownloadListItem: Equatable { } } - let isBurner: Bool + let fireWindowSession: FireWindowSessionRef? + var isBurner: Bool { + fireWindowSession != nil + } /// final download destination url, will match actual file url when download is finished var destinationURL: URL? { diff --git a/DuckDuckGo/FileDownload/Model/DownloadListViewModel.swift b/DuckDuckGo/FileDownload/Model/DownloadListViewModel.swift index 64a62c902d..fd77921622 100644 --- a/DuckDuckGo/FileDownload/Model/DownloadListViewModel.swift +++ b/DuckDuckGo/FileDownload/Model/DownloadListViewModel.swift @@ -24,16 +24,20 @@ import os.log @MainActor final class DownloadListViewModel { + private let fireWindowSession: FireWindowSessionRef? private let coordinator: DownloadListCoordinator private var viewModels: [UUID: DownloadViewModel] private var cancellable: AnyCancellable? @Published private(set) var items: [DownloadViewModel] - init(coordinator: DownloadListCoordinator = DownloadListCoordinator.shared) { + init(fireWindowSession: FireWindowSessionRef?, coordinator: DownloadListCoordinator = DownloadListCoordinator.shared) { + self.fireWindowSession = fireWindowSession self.coordinator = coordinator - let items = coordinator.downloads(sortedBy: \.added, ascending: false).map(DownloadViewModel.init) + let items = coordinator.downloads(sortedBy: \.added, ascending: false) + .filter { $0.fireWindowSession == fireWindowSession } + .map(DownloadViewModel.init) self.items = items self.viewModels = items.reduce(into: [:]) { $0[$1.id] = $1 } cancellable = coordinator.updates.receive(on: DispatchQueue.main).sink { [weak self] update in @@ -47,22 +51,22 @@ final class DownloadListViewModel { dispatchPrecondition(condition: .onQueue(.main)) switch kind { case .added: + guard item.fireWindowSession == self.fireWindowSession else { return } + let viewModel = DownloadViewModel(item: item) self.viewModels[item.identifier] = viewModel self.items.insert(viewModel, at: 0) case .updated: self.viewModels[item.identifier]?.update(with: item) case .removed: - guard let index = self.items.firstIndex(where: { $0.id == item.identifier }) else { - return - } + guard let index = self.items.firstIndex(where: { $0.id == item.identifier }) else { return } self.viewModels[item.identifier] = nil self.items.remove(at: index) } } func cleanupInactiveDownloads() { - coordinator.cleanupInactiveDownloads() + coordinator.cleanupInactiveDownloads(for: fireWindowSession) } func cancelDownload(at index: Int) { diff --git a/DuckDuckGo/FileDownload/Model/FileDownloadManager.swift b/DuckDuckGo/FileDownload/Model/FileDownloadManager.swift index fe9bb4f873..c3b2a6ab32 100644 --- a/DuckDuckGo/FileDownload/Model/FileDownloadManager.swift +++ b/DuckDuckGo/FileDownload/Model/FileDownloadManager.swift @@ -29,7 +29,7 @@ protocol FileDownloadManagerProtocol: AnyObject { @discardableResult @MainActor - func add(_ download: WebKitDownload, fromBurnerWindow: Bool, delegate: DownloadTaskDelegate?, destination: WebKitDownloadTask.DownloadDestination) -> WebKitDownloadTask + func add(_ download: WebKitDownload, fireWindowSession: FireWindowSessionRef?, delegate: DownloadTaskDelegate?, destination: WebKitDownloadTask.DownloadDestination) -> WebKitDownloadTask func cancelAll(waitUntilDone: Bool) } @@ -38,8 +38,8 @@ extension FileDownloadManagerProtocol { @discardableResult @MainActor - func add(_ download: WebKitDownload, fromBurnerWindow: Bool, destination: WebKitDownloadTask.DownloadDestination) -> WebKitDownloadTask { - add(download, fromBurnerWindow: fromBurnerWindow, delegate: nil, destination: destination) + func add(_ download: WebKitDownload, fireWindowSession: FireWindowSessionRef?, destination: WebKitDownloadTask.DownloadDestination) -> WebKitDownloadTask { + add(download, fireWindowSession: fireWindowSession, delegate: nil, destination: destination) } } @@ -63,7 +63,7 @@ final class FileDownloadManager: FileDownloadManagerProtocol { @discardableResult @MainActor - func add(_ download: WebKitDownload, fromBurnerWindow: Bool, delegate: DownloadTaskDelegate?, destination: WebKitDownloadTask.DownloadDestination) -> WebKitDownloadTask { + func add(_ download: WebKitDownload, fireWindowSession: FireWindowSessionRef?, delegate: DownloadTaskDelegate?, destination: WebKitDownloadTask.DownloadDestination) -> WebKitDownloadTask { dispatchPrecondition(condition: .onQueue(.main)) var destination = destination @@ -71,7 +71,7 @@ final class FileDownloadManager: FileDownloadManagerProtocol { if download.originalRequest?.url?.isFileURL ?? true, case .auto = destination { destination = .prompt } - let task = WebKitDownloadTask(download: download, destination: destination, isBurner: fromBurnerWindow) + let task = WebKitDownloadTask(download: download, destination: destination, fireWindowSession: fireWindowSession) Logger.fileDownload.debug("add \(String(describing: download)): \(download.originalRequest?.url?.absoluteString ?? "") -> \(destination.debugDescription): \(task)") let shouldCancelDownloadIfDelegateIsGone = delegate != nil @@ -247,6 +247,27 @@ extension FileDownloadManager: WebKitDownloadTaskDelegate { } +extension FileDownloadManager { + + static func observeDownloadsFinished(_ downloads: Set, callback: @escaping () -> Void) -> AnyCancellable { + var cancellables = [WebKitDownloadTask: AnyCancellable]() + for download in downloads { + cancellables[download] = download.$state.sink { + if !$0.isDownloading { + cancellables[download] = nil + if cancellables.isEmpty { + callback() + } + } + } + } + return AnyCancellable { + cancellables.removeAll() + } + } + +} + protocol DownloadTaskDelegate: AnyObject { @MainActor diff --git a/DuckDuckGo/FileDownload/Model/WebKitDownloadTask.swift b/DuckDuckGo/FileDownload/Model/WebKitDownloadTask.swift index 54beae31f0..5096a9ce1b 100644 --- a/DuckDuckGo/FileDownload/Model/WebKitDownloadTask.swift +++ b/DuckDuckGo/FileDownload/Model/WebKitDownloadTask.swift @@ -100,8 +100,8 @@ final class WebKitDownloadTask: NSObject, ProgressReporting, @unchecked Sendable } } - /// downloads initiated from a Burner Window won‘t stay in the Downloads panel after completion - let isBurner: Bool + /// downloads initiated from a Burner Window will be kept in the window + let fireWindowSession: FireWindowSessionRef? private weak var delegate: WebKitDownloadTaskDelegate? @@ -131,12 +131,12 @@ final class WebKitDownloadTask: NSObject, ProgressReporting, @unchecked Sendable } @MainActor(unsafe) - init(download: WebKitDownload, destination: DownloadDestination, isBurner: Bool) { + init(download: WebKitDownload, destination: DownloadDestination, fireWindowSession: FireWindowSessionRef?) { self.download = download self.progress = DownloadProgress(download: download) self.fileProgressPresenter = FileProgressPresenter(progress: progress) self.state = .initial(destination) - self.isBurner = isBurner + self.fireWindowSession = fireWindowSession super.init() progress.cancellationHandler = { [weak self, taskDescr=self.debugDescription] in @@ -512,12 +512,15 @@ final class WebKitDownloadTask: NSObject, ProgressReporting, @unchecked Sendable return } - let tempURL = tempFile.url - // disable retrying download for user-removed/trashed files - let isRetryable = if tempURL == nil || tempURL.map({ !FileManager.default.fileExists(atPath: $0.path) || FileManager.default.isInTrash($0) }) == true { - false + // disable retrying download for user-removed/trashed files or fire windows downloads + let isRetryable: Bool + if let url = tempFile.url { + let fileExists = FileManager.default.fileExists(atPath: url.path) + let isInTrash = FileManager.default.isInTrash(url) + let isFromFireWindow = fireWindowSession != nil + isRetryable = fileExists && !isInTrash && !isFromFireWindow } else { - true + isRetryable = false } Logger.fileDownload.debug("❗️ downloadDidFail \(self): \(error), retryable: \(isRetryable)") diff --git a/DuckDuckGo/FileDownload/Services/DownloadListCoordinator.swift b/DuckDuckGo/FileDownload/Services/DownloadListCoordinator.swift index f645d9e60f..76e3ecde7c 100644 --- a/DuckDuckGo/FileDownload/Services/DownloadListCoordinator.swift +++ b/DuckDuckGo/FileDownload/Services/DownloadListCoordinator.swift @@ -49,6 +49,7 @@ final class DownloadListCoordinator { private var downloadsCancellable: AnyCancellable? private var downloadTaskCancellables = [WebKitDownloadTask: AnyCancellable]() private var taskProgressCancellables = [WebKitDownloadTask: Set]() + @MainActor private var fireWindowSessions = Set() private var filePresenters = [UUID: (destination: FilePresenter?, tempFile: FilePresenter?)]() private var filePresenterCancellables = [UUID: Set]() @@ -64,7 +65,8 @@ final class DownloadListCoordinator { } private let updatesSubject = PassthroughSubject() - let progress = Progress() + private let regularWindowDownloadProgress = Progress() + @MainActor private var fireWindowSessionsProgress = [FireWindowSessionRef: Progress]() init(store: DownloadListStoring = DownloadListStore(), downloadManager: FileDownloadManagerProtocol = FileDownloadManager.shared, @@ -230,6 +232,14 @@ final class DownloadListCoordinator { } } + if let fireWindowSession = task.fireWindowSession, + case (inserted: true, _) = self.fireWindowSessions.insert(fireWindowSession) { + // remove inactive items when the burner window is closed + fireWindowSession.onBurn { [weak self] in + self?.clearBurnerWindowItems(for: fireWindowSession) + } + } + self.subscribeToProgress(of: task) } @@ -310,15 +320,28 @@ final class DownloadListCoordinator { return false } + @MainActor + func combinedDownloadProgressCreatingIfNeeded(for fireWindowSession: FireWindowSessionRef?) -> Progress { + guard let fireWindowSession else { return regularWindowDownloadProgress } + + return fireWindowSessionsProgress[fireWindowSession] ?? { + let progress = Progress() + fireWindowSessionsProgress[fireWindowSession] = progress + return progress + }() + } + + @MainActor private func subscribeToProgress(of task: WebKitDownloadTask) { dispatchPrecondition(condition: .onQueue(.main)) var lastKnownProgress = (total: Int64(0), completed: Int64(0)) + let progress = self.combinedDownloadProgressCreatingIfNeeded(for: task.fireWindowSession) task.progress.publisher(for: \.totalUnitCount) .combineLatest(task.progress.publisher(for: \.completedUnitCount)) .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] (total, completed) in - guard let self = self, total > 0, completed > 0 else { return } + .sink { (total, completed) in + guard total > 0, completed > 0 else { return } progress.totalUnitCount += (total - lastKnownProgress.total) progress.completedUnitCount += (completed - lastKnownProgress.completed) @@ -345,7 +368,9 @@ final class DownloadListCoordinator { self.downloadTaskCancellables[task] = nil return updateItem(withId: initialItem.identifier) { item in - if item?.isBurner ?? false { + if let fireWindowSession = (item ?? initialItem).fireWindowSession, + !fireWindowSession.isActive { + // remove finished downloads from the list instantly for downloads in already closed burner window item = nil return } @@ -436,7 +461,7 @@ final class DownloadListCoordinator { } else { .auto } - let task = self.downloadManager.add(download, fromBurnerWindow: item.isBurner, destination: destination) + let task = self.downloadManager.add(download, fireWindowSession: item.fireWindowSession, destination: destination) self.subscribeToDownloadTask(task, updating: item) } } @@ -444,12 +469,17 @@ final class DownloadListCoordinator { // MARK: interface - var hasActiveDownloads: Bool { - !downloadTaskCancellables.isEmpty + func hasActiveDownloads(for fireWindowSession: FireWindowSessionRef?) -> Bool { + for task in downloadTaskCancellables.keys where task.fireWindowSession == fireWindowSession { + return true + } + return false } - var isEmpty: Bool { - items.isEmpty + func hasDownloads(for fireWindowSession: FireWindowSessionRef?) -> Bool { + return items.contains { _, item in + item.fireWindowSession == fireWindowSession + } } @MainActor @@ -503,12 +533,23 @@ final class DownloadListCoordinator { } @MainActor - func cleanupInactiveDownloads() { + func cleanupInactiveDownloads(for fireWindowSession: FireWindowSessionRef?) { Logger.fileDownload.debug("coordinator: cleanupInactiveDownloads") - for (id, item) in self.items where item.progress == nil { + for (id, item) in self.items where item.fireWindowSession == fireWindowSession && item.progress == nil { + remove(downloadWithIdentifier: id) + } + } + + @MainActor + private func clearBurnerWindowItems(for fireWindowSession: FireWindowSessionRef) { + Logger.fileDownload.debug("coordinator: cleanup Fire Window Downloads") + + for (id, item) in self.items where item.fireWindowSession == fireWindowSession { + item.progress?.cancel() remove(downloadWithIdentifier: id) } + fireWindowSessionsProgress[fireWindowSession] = nil } @MainActor @@ -562,7 +603,7 @@ private extension DownloadListItem { websiteURL: task.originalRequest?.mainDocumentURL, fileName: "", progress: task.progress, - isBurner: task.isBurner, + fireWindowSession: task.fireWindowSession, error: nil) } diff --git a/DuckDuckGo/FileDownload/Services/DownloadListStore.swift b/DuckDuckGo/FileDownload/Services/DownloadListStore.swift index 5d3670f07d..b6494f979e 100644 --- a/DuckDuckGo/FileDownload/Services/DownloadListStore.swift +++ b/DuckDuckGo/FileDownload/Services/DownloadListStore.swift @@ -208,7 +208,7 @@ extension DownloadListItem { downloadURL: url, websiteURL: managedObject.websiteURLEncrypted as? URL, fileName: managedObject.filenameEncrypted as? String ?? destinationURL?.lastPathComponent ?? "", - isBurner: false, + fireWindowSession: nil, // burner items aren‘t stored destinationURL: destinationURL, destinationFileBookmarkData: managedObject.destinationFileBookmarkDataEncrypted as? Data, tempURL: managedObject.tempURLEncrypted as? URL, diff --git a/DuckDuckGo/FileDownload/View/DownloadsCellView.swift b/DuckDuckGo/FileDownload/View/DownloadsCellView.swift index 7b572bdb62..a8a608c8d9 100644 --- a/DuckDuckGo/FileDownload/View/DownloadsCellView.swift +++ b/DuckDuckGo/FileDownload/View/DownloadsCellView.swift @@ -521,12 +521,12 @@ extension DownloadsCellView.DownloadError: LocalizedError { DownloadsCellView.PreviewView() } @available(macOS 14.0, *) -let previewDownloadListItems = [ - DownloadListItem(identifier: .init(), added: .now, modified: .now, downloadURL: .empty, websiteURL: nil, fileName: "Indefinite progress download with long filename for clipping.zip", progress: Progress(totalUnitCount: -1), isBurner: false, destinationURL: URL(fileURLWithPath: "\(#file)"), destinationFileBookmarkData: nil, tempURL: URL(fileURLWithPath: "\(#file)"), tempFileBookmarkData: nil, error: nil), - DownloadListItem(identifier: .init(), added: .now, modified: .now, downloadURL: .empty, websiteURL: nil, fileName: "Active download.pdf", progress: Progress(totalUnitCount: 100, completedUnitCount: 42), isBurner: false, destinationURL: URL(fileURLWithPath: "\(#file)"), destinationFileBookmarkData: nil, tempURL: URL(fileURLWithPath: "\(#file)"), tempFileBookmarkData: nil, error: nil), - DownloadListItem(identifier: .init(), added: .now, modified: .now, downloadURL: .empty, websiteURL: nil, fileName: "Completed download.dmg", progress: nil, isBurner: false, destinationURL: URL(fileURLWithPath: "\(#file)"), destinationFileBookmarkData: nil, tempURL: nil, tempFileBookmarkData: nil, error: nil), - DownloadListItem(identifier: .init(), added: .now, modified: .now, downloadURL: .empty, websiteURL: nil, fileName: "Non-retryable download.txt", progress: nil, isBurner: false, destinationURL: URL(fileURLWithPath: "\(#file)"), destinationFileBookmarkData: nil, tempURL: URL(fileURLWithPath: "\(#file)"), tempFileBookmarkData: nil, error: nil), - DownloadListItem(identifier: .init(), added: .now, modified: .now, downloadURL: .empty, websiteURL: nil, fileName: "Retryable download.rtf", progress: nil, isBurner: false, destinationURL: URL(fileURLWithPath: "\(#file)"), destinationFileBookmarkData: nil, tempURL: URL(fileURLWithPath: "\(#file)"), tempFileBookmarkData: nil, error: FileDownloadError(URLError(.networkConnectionLost, userInfo: ["isRetryable": true]) as NSError)), +let previewDownloadListItems: [DownloadListItem] = [ + DownloadListItem(identifier: .init(), added: .now, modified: .now, downloadURL: .empty, websiteURL: nil, fileName: "Indefinite progress download with long filename for clipping.zip", progress: Progress(totalUnitCount: -1), fireWindowSession: nil, destinationURL: URL(fileURLWithPath: "\(#file)"), destinationFileBookmarkData: nil, tempURL: URL(fileURLWithPath: "\(#file)"), tempFileBookmarkData: nil, error: nil), + DownloadListItem(identifier: .init(), added: .now, modified: .now, downloadURL: .empty, websiteURL: nil, fileName: "Active download.pdf", progress: Progress(totalUnitCount: 100, completedUnitCount: 42), fireWindowSession: nil, destinationURL: URL(fileURLWithPath: "\(#file)"), destinationFileBookmarkData: nil, tempURL: URL(fileURLWithPath: "\(#file)"), tempFileBookmarkData: nil, error: nil), + DownloadListItem(identifier: .init(), added: .now, modified: .now, downloadURL: .empty, websiteURL: nil, fileName: "Completed download.dmg", progress: nil, fireWindowSession: nil, destinationURL: URL(fileURLWithPath: "\(#file)"), destinationFileBookmarkData: nil, tempURL: nil, tempFileBookmarkData: nil, error: nil), + DownloadListItem(identifier: .init(), added: .now, modified: .now, downloadURL: .empty, websiteURL: nil, fileName: "Non-retryable download.txt", progress: nil, fireWindowSession: nil, destinationURL: URL(fileURLWithPath: "\(#file)"), destinationFileBookmarkData: nil, tempURL: URL(fileURLWithPath: "\(#file)"), tempFileBookmarkData: nil, error: nil), + DownloadListItem(identifier: .init(), added: .now, modified: .now, downloadURL: .empty, websiteURL: nil, fileName: "Retryable download.rtf", progress: nil, fireWindowSession: nil, destinationURL: URL(fileURLWithPath: "\(#file)"), destinationFileBookmarkData: nil, tempURL: URL(fileURLWithPath: "\(#file)"), tempFileBookmarkData: nil, error: FileDownloadError(URLError(.networkConnectionLost, userInfo: ["isRetryable": true]) as NSError)), ] @available(macOS 14.0, *) extension DownloadsCellView { diff --git a/DuckDuckGo/FileDownload/View/DownloadsPopover.swift b/DuckDuckGo/FileDownload/View/DownloadsPopover.swift index b41bf222c3..bce6f31fe9 100644 --- a/DuckDuckGo/FileDownload/View/DownloadsPopover.swift +++ b/DuckDuckGo/FileDownload/View/DownloadsPopover.swift @@ -20,13 +20,13 @@ import AppKit final class DownloadsPopover: NSPopover { - override init() { + init(fireWindowSession: FireWindowSessionRef?) { super.init() self.animates = false self.behavior = .semitransient - setupContentController() + setupContentController(fireWindowSession: fireWindowSession) } required init?(coder: NSCoder) { @@ -37,8 +37,8 @@ final class DownloadsPopover: NSPopover { var viewController: DownloadsViewController { contentViewController as! DownloadsViewController } // swiftlint:enable force_cast - private func setupContentController() { - let controller = DownloadsViewController() + private func setupContentController(fireWindowSession: FireWindowSessionRef?) { + let controller = DownloadsViewController(viewModel: DownloadListViewModel(fireWindowSession: fireWindowSession)) contentViewController = controller } diff --git a/DuckDuckGo/FileDownload/View/DownloadsViewController.swift b/DuckDuckGo/FileDownload/View/DownloadsViewController.swift index 85d8e2b4cb..3a661a5ad9 100644 --- a/DuckDuckGo/FileDownload/View/DownloadsViewController.swift +++ b/DuckDuckGo/FileDownload/View/DownloadsViewController.swift @@ -42,14 +42,13 @@ final class DownloadsViewController: NSViewController { private let viewModel: DownloadListViewModel private var downloadsCancellable: AnyCancellable? - init(viewModel: DownloadListViewModel? = nil) { - self.viewModel = viewModel ?? DownloadListViewModel() + init(viewModel: DownloadListViewModel) { + self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { - self.viewModel = DownloadListViewModel() - super.init(coder: coder) + fatalError("\(Self.self): Bad initializer") } override func loadView() { @@ -487,7 +486,7 @@ extension DownloadsViewController: NSTableViewDataSource, NSTableViewDelegate { store.fetchBlock = { completion in completion(.success(previewDownloadListItems)) } - let viewModel = DownloadListViewModel(coordinator: DownloadListCoordinator(store: store)) + let viewModel = DownloadListViewModel(fireWindowSession: nil, coordinator: DownloadListCoordinator(store: store)) return DownloadsViewController(viewModel: viewModel) }() } #endif diff --git a/DuckDuckGo/FileDownload/View/NSAlert+ActiveDownloadsTermination.swift b/DuckDuckGo/FileDownload/View/NSAlert+ActiveDownloadsTermination.swift index 876027b225..422eb4b2de 100644 --- a/DuckDuckGo/FileDownload/View/NSAlert+ActiveDownloadsTermination.swift +++ b/DuckDuckGo/FileDownload/View/NSAlert+ActiveDownloadsTermination.swift @@ -24,14 +24,34 @@ extension NSAlert { assert(!downloads.isEmpty) let activeDownload = downloads.first(where: { $0.state.isDownloading }) - let firstFileName = activeDownload?.state.destinationFilePresenter?.url?.lastPathComponent ?? "" + let firstFileName = activeDownload?.state.destinationFilePresenter?.url?.lastPathComponent + .truncated(length: MainMenu.Constants.maxTitleLength, middle: "…") ?? "" let andOthers = downloads.count > 1 ? UserText.downloadsActiveAlertMessageAndOthers : "" + let thisTheseFiles = downloads.count > 1 ? UserText.downloadsActiveAlertMessageTheseFiles : UserText.downloadsActiveAlertMessageThisFile let alert = NSAlert() alert.messageText = UserText.downloadsActiveAlertTitle - alert.informativeText = String(format: UserText.downloadsActiveAlertMessageFormat, firstFileName, andOthers) - alert.addButton(withTitle: UserText.quit).tag = NSApplication.ModalResponse.OK.rawValue - alert.addButton(withTitle: UserText.dontQuit).tag = NSApplication.ModalResponse.cancel.rawValue + alert.informativeText = String(format: UserText.downloadsActiveAlertMessageFormat, firstFileName, andOthers, thisTheseFiles) + alert.addButton(withTitle: UserText.quit, response: .OK) + alert.addButton(withTitle: UserText.dontQuit, response: .cancel, keyEquivalent: .escape) + + return alert + } + + static func activeDownloadsFireWindowClosingAlert(for downloads: Set) -> NSAlert { + assert(!downloads.isEmpty) + + let activeDownload = downloads.first(where: { $0.state.isDownloading }) + let firstFileName = activeDownload?.state.destinationFilePresenter?.url?.lastPathComponent + .truncated(length: MainMenu.Constants.maxTitleLength, middle: "…") ?? "" + let andOthers = downloads.count > 1 ? UserText.downloadsActiveAlertMessageAndOthers : "" + let thisTheseFiles = downloads.count > 1 ? UserText.downloadsActiveAlertMessageTheseFiles : UserText.downloadsActiveAlertMessageThisFile + + let alert = NSAlert() + alert.messageText = UserText.downloadsActiveAlertTitle + alert.informativeText = String(format: UserText.downloadsActiveInFireWindowAlertMessageFormat, firstFileName, andOthers, thisTheseFiles) + alert.addButton(withTitle: UserText.close, response: .OK) + alert.addButton(withTitle: UserText.dontClose, response: .cancel, keyEquivalent: .escape) return alert } diff --git a/DuckDuckGo/Fire/Model/Fire.swift b/DuckDuckGo/Fire/Model/Fire.swift index 90399af9e4..a376b395c0 100644 --- a/DuckDuckGo/Fire/Model/Fire.swift +++ b/DuckDuckGo/Fire/Model/Fire.swift @@ -431,7 +431,7 @@ final class Fire { @MainActor private func burnDownloads() { - self.downloadListCoordinator.cleanupInactiveDownloads() + self.downloadListCoordinator.cleanupInactiveDownloads(for: nil) } @MainActor diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 3f89c38dfe..e490fa88b9 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -17472,6 +17472,66 @@ } } }, + "dont.close" : { + "comment" : "Don’t Close the window button title", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Don’t Close" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No cerrar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ne pas fermer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non chiudere" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niet sluiten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie zamykaj" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não fechar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не закрывать" + } + } + } + }, "dont.quit" : { "comment" : "Don’t Quit button", "extractionState" : "extracted_with_value", @@ -17893,61 +17953,181 @@ } }, "downloads.active.alert.message.format" : { - "comment" : "Alert text format when trying to quit application while file “filename”[, and others] are being downloaded", + "comment" : "Alert text format when trying to quit application while file “filename (%@)”[, and others (%@)] are being downloaded; If you quit now, DuckDuckGo won‘t finish downloading [this file|these files](%@).", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Möchtest du wirklich beenden?\n\nDuckDuckGo lädt derzeit „%@“%@ herunter. Wenn du jetzt aufhörst, wird DuckDuckGo den Download %@ nicht beenden." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to quit?\n\nDuckDuckGo is currently downloading “%1$@”%2$@. If you quit now, DuckDuckGo won‘t finish downloading %3$@." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Seguro que quieres salir?\n\nDuckDuckGo está descargando \"%@\"%@. Si sales ahora, DuckDuckGo no terminará de descargar %@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voulez-vous vraiment abandonner ?\n\nDuckDuckGo télécharge actuellement “%@”%@. Si vous abandonnez maintenant, DuckDuckGo ne terminera pas le téléchargement de %@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vuoi davvero uscire?\n\nDuckDuckGo sta attualmente scaricando “%@”%@. Se esci ora, DuckDuckGo non completerà il download di %@." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weet je zeker dat je wilt stoppen?\n\nDuckDuckGo downloadt op dit moment \"%@\"%@. Als je nu stopt, wordt het downloaden van %@ niet voltooid." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czy na pewno chcesz zrezygnować?\n\nDuckDuckGo aktualnie pobiera „%@”%@. Jeśli teraz wyjdziesz, DuckDuckGo nie dokończy pobierania %@." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tens a certeza de que pretendes sair?\n\nO DuckDuckGo está a transferir “%@”%@. Se saíres agora, o DuckDuckGo não vai transferir %@ até ao fim." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Действительно закрыть приложение?\n\nDuckDuckGo сейчас загружает «%@»%@. Если закрыть приложение, DuckDuckGo не загрузит %@." + } + } + } + }, + "downloads.active.alert.message.these.files" : { + "comment" : "Alert text format element for “DuckDuckGo won‘t finish downloading ->these file<-”", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Möchtest du wirklich aufhören? Der DuckDuckGo Privacy Browser lädt gerade „%1$@“%2$@ herunter. Wenn du ihn jetzt verlässt, wird DuckDuckGo Privacy Browser den Download nicht beenden." + "value" : "diese Dateien" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Are you sure you want to quit? DuckDuckGo Privacy Browser is currently downloading “%1$@”%2$@. If you quit now DuckDuckGo Privacy Browser won’t finish downloading this file." + "value" : "these files" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "¿Seguro que quieres salir? DuckDuckGo Privacy Browser está descargando \"%1$@\"%2$@. Si sales ahora, DuckDuckGo Privacy Browser no terminará de descargar este archivo." + "value" : "estos archivos" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Voulez-vous vraiment abandonner ? DuckDuckGo Privacy Browser télécharge actuellement « %1$@”%2$@ ». Si vous abandonnez maintenant, le navigateur ne terminera pas le téléchargement de ce fichier." + "value" : "ces fichiers" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Uscire? DuckDuckGo Privacy Browser sta attualmente scaricando \"%1$@\"%2$@. Se esci ora, DuckDuckGo Privacy Browser non completerà il download di questo file." + "value" : "questi file" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Weet je zeker dat je wilt afsluiten? DuckDuckGo Privacy Browser downloadt momenteel \"%1$@\"%2$@. Als je nu afsluit, zal DuckDuckGo Privacy Browser dit bestand niet verder downloaden." + "value" : "deze bestanden" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Czy na pewno chcesz wyjść? DuckDuckGo Privacy Browser obecnie pobiera „%1$@”%2$@. Jeśli teraz wyjdziesz, DuckDuckGo Privacy Browser nie zakończy pobierania tego pliku." + "value" : "tych plików" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Tens a certeza de que pretendes sair? O DuckDuckGo Privacy Browser está a transferir “%1$@”%2$@. Se saíres agora, o DuckDuckGo Privacy Browser não irá concluir a transferência deste ficheiro." + "value" : "estes ficheiros" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Действительно закрыть приложение? DuckDuckGo Privacy Browser загружает «%1$@» %2$@. Если вы закроете его, прервется загрузка файла." + "value" : "эти файлы" + } + } + } + }, + "downloads.active.alert.message.this.file" : { + "comment" : "Alert text format element for “DuckDuckGo won‘t finish downloading ->this file<-”", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "diese Datei" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "this file" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "este archivo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ce fichier" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "questo file" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dit bestand" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tego pliku" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "este ficheiro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "этот файл" } } } @@ -22760,6 +22940,66 @@ } } }, + "fire-window.downloads.active.alert.message.format" : { + "comment" : "Alert text format when trying to close a Fire Window while file “filename (%@)”[, and others (%@)] are being downloaded in it. If you close the Fire Window, DuckDuckGo will delete [this file|these files](%@).", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bist du sicher, dass du das Fire Window schließen willst?\n\nDuckDuckGo lädt gerade „%@“%@ herunter. Wenn du das Fire Window schließt, löscht DuckDuckGo %@." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to close the Fire Window?\n\nDuckDuckGo is currently downloading “%1$@”%2$@. If you close the Fire Window, DuckDuckGo will delete %3$@." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Seguro que quieres cerrar la Fire Window?\n\nDuckDuckGo está descargando \"%@\"%@. Si cierras la Fire Window, DuckDuckGo eliminará %@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voulez-vous vraiment fermer la Fire Window ?\n\nDuckDuckGo télécharge actuellement “%@”%@. Si vous fermez la Fire Window, DuckDuckGo supprimera %@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vuoi davvero chiudere la Fire Window?\n\nDuckDuckGo sta attualmente scaricando “%@”%@. Se la chiudi, DuckDuckGo eliminerà %@." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weet je zeker dat je het Fire Window wilt sluiten?\n\nDuckDuckGo downloadt op dit moment \"%@\"%@. Als je het Fire Window sluit, wordt %@ verwijderd." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czy na pewno chcesz zamknąć okno Fire Window?\n\nDuckDuckGo aktualnie pobiera „%@”%@. Jeśli zamkniesz okno Fire Window, DuckDuckGo usunie %@." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tens a certeza de que pretendes fechar a Fire Window?\n\nO DuckDuckGo está a transferir “%@”%@. Se fechares a Fire Window, o DuckDuckGo vai eliminar %@." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Действительно закрыть окно Fire Window?\n\nDuckDuckGo сейчас загружает «%@»%@. В случае закрытия DuckDuckGo удалит %@." + } + } + } + }, "fire.active-tabs-info" : { "comment" : "Info in the Fire Button popover", "extractionState" : "extracted_with_value", @@ -32038,7 +32278,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Завершить DuckDuckGo" + "value" : "Закрыть DuckDuckGo" } } } diff --git a/DuckDuckGo/MainWindow/FireWindowSession.swift b/DuckDuckGo/MainWindow/FireWindowSession.swift new file mode 100644 index 0000000000..44b87a859f --- /dev/null +++ b/DuckDuckGo/MainWindow/FireWindowSession.swift @@ -0,0 +1,103 @@ +// +// FireWindowSession.swift +// +// Copyright © 2024 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. +// + +/// Represents a “Fire Window Session” tracking lifetime of the owning Fire Window and Popup windows created from it. +/// Used to detect active downloads within a Fire Window Session. +/// Deinitialized when the last window from the session is closed calling the `deinitObservers`. +@MainActor final class FireWindowSession { + private struct WindowRef: Hashable { + weak var window: NSWindow? + private let identifier: ObjectIdentifier + + init(window: NSWindow) { + self.window = window + self.identifier = ObjectIdentifier(window) + } + + static func == (lhs: WindowRef, rhs: WindowRef) -> Bool { + lhs.identifier == rhs.identifier + } + + func hash(into hasher: inout Hasher) { + hasher.combine(identifier) + } + } + private var windowRefs: Set = [] + private var deinitObservers: [() -> Void] = [] + + var windows: [NSWindow] { + windowRefs.reduce(into: []) { result, windowRef in + guard let window = windowRef.window else { return } + result.append(window) + } + } + + var isActive: Bool { + windowRefs.contains(where: { $0.window?.isVisible == true }) + } + + func addWindow(_ window: NSWindow) { + windowRefs.insert(WindowRef(window: window)) + } + + public func onDeinit(_ onDeinit: @escaping () -> Void) { + deinitObservers.append(onDeinit) + } + + deinit { + for deinitObserver in deinitObservers { + deinitObserver() + } + } +} + +struct FireWindowSessionRef: Hashable { + + private(set) weak var fireWindowSession: FireWindowSession? + private let identifier: ObjectIdentifier + + @MainActor + var isActive: Bool { + fireWindowSession?.isActive ?? false + } + + @MainActor + init?(window: NSWindow?) { + guard let window else { return nil } + guard let mainWindowController = window.windowController as? MainWindowController else { + assertionFailure("\(window) has no MainWindowController") + return nil + } + guard let fireWindowSession = mainWindowController.fireWindowSession else { return nil } + self.fireWindowSession = fireWindowSession + self.identifier = ObjectIdentifier(fireWindowSession) + } + + @MainActor + public func onBurn(_ burnHandler: @escaping () -> Void) { + fireWindowSession?.onDeinit(burnHandler) ?? burnHandler() + } + + static func == (lhs: FireWindowSessionRef, rhs: FireWindowSessionRef) -> Bool { + lhs.identifier == rhs.identifier + } + + func hash(into hasher: inout Hasher) { + hasher.combine(identifier) + } +} diff --git a/DuckDuckGo/MainWindow/MainWindowController.swift b/DuckDuckGo/MainWindow/MainWindowController.swift index a3b37719c5..5911d9c289 100644 --- a/DuckDuckGo/MainWindow/MainWindowController.swift +++ b/DuckDuckGo/MainWindow/MainWindowController.swift @@ -25,6 +25,7 @@ final class MainWindowController: NSWindowController { private var fireViewModel: FireViewModel private static var knownFullScreenMouseDetectionWindows = Set() + let fireWindowSession: FireWindowSession? var mainViewController: MainViewController { // swiftlint:disable force_cast @@ -36,7 +37,7 @@ final class MainWindowController: NSWindowController { return window?.standardWindowButton(.closeButton)?.superview } - init(mainViewController: MainViewController, popUp: Bool, fireViewModel: FireViewModel? = nil) { + init(mainViewController: MainViewController, popUp: Bool, fireWindowSession: FireWindowSession? = nil, fireViewModel: FireViewModel? = nil) { let size = mainViewController.view.frame.size let moveToCenter = CGAffineTransform(translationX: ((NSScreen.main?.frame.width ?? 1024) - size.width) / 2, y: ((NSScreen.main?.frame.height ?? 790) - size.height) / 2) @@ -47,6 +48,10 @@ final class MainWindowController: NSWindowController { window.contentViewController = mainViewController self.fireViewModel = fireViewModel ?? FireCoordinator.fireViewModel + assert(!mainViewController.isBurner || fireWindowSession != nil) + self.fireWindowSession = fireWindowSession + fireWindowSession?.addWindow(window) + super.init(window: window) setupWindow(window) @@ -284,17 +289,61 @@ extension MainWindowController: NSWindowDelegate { WindowControllersManager.shared.unregister(self) } - func windowShouldClose(_ sender: NSWindow) -> Bool { - // Animate fire for Burner Window when closing - guard mainViewController.tabCollectionViewModel.isBurner && !sender.isPopUpWindow else { - return true + func windowShouldClose(_ window: NSWindow) -> Bool { + guard mainViewController.tabCollectionViewModel.isBurner else { return true } + + if showAlertIfActiveDownloadsPresent(in: window) { + return false + } + + animateBurningIfNeededAndClose(window) + return false + } + + private func showAlertIfActiveDownloadsPresent(in window: NSWindow) -> Bool { + guard let fireWindowSessionRef = FireWindowSessionRef(window: window), + let fireWindowSession = fireWindowSessionRef.fireWindowSession else { + assertionFailure("No FireWindowSession in Fire Window \(window)") + return false + } + // only check if it‘s the last Fire Window from the Burner Session + guard fireWindowSession.windows == [window] else { return false } + let fireWindowDownloads = Set(FileDownloadManager.shared.downloads.filter { $0.fireWindowSession == fireWindowSessionRef && $0.state.isDownloading }) + guard !fireWindowDownloads.isEmpty else { return false } + + let alert = NSAlert.activeDownloadsFireWindowClosingAlert(for: fireWindowDownloads) + let downloadsFinishedCancellable = FileDownloadManager.observeDownloadsFinished(fireWindowDownloads) { + // close alert and burn the window when all downloads finished + window.endSheet(alert.window, returnCode: .OK) + } + alert.beginSheetModal(for: window) { response in + downloadsFinishedCancellable.cancel() + if response == .OK { + fireWindowDownloads.forEach { download in + download.cancel() + } + self.animateBurningIfNeededAndClose(window) + return + } else if self.mainViewController.tabCollectionViewModel.tabs.isEmpty { + // reopen last closed tab if the window stays open + DispatchQueue.main.async { + self.mainViewController.browserTabViewController.openNewTab(with: .newtab) + } + } + } + return true + } + + private func animateBurningIfNeededAndClose(_ window: NSWindow) { + guard !window.isPopUpWindow else { + window.close() + return } Task { moveTabBarView(toTitlebarView: false) await mainViewController.fireViewController.animateFireWhenClosing() - sender.close() + window.close() } - return false } } diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift index b92afe8eac..f2a2e78eac 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift @@ -70,12 +70,14 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { private weak var zoomPopoverDelegate: NSPopoverDelegate? private let networkProtectionPopoverManager: NetPPopoverManager + private let isBurner: Bool private var popoverIsShownCancellables = Set() - init(networkProtectionPopoverManager: NetPPopoverManager, autofillPopoverPresenter: AutofillPopoverPresenter) { + init(networkProtectionPopoverManager: NetPPopoverManager, autofillPopoverPresenter: AutofillPopoverPresenter, isBurner: Bool) { self.networkProtectionPopoverManager = networkProtectionPopoverManager self.autofillPopoverPresenter = autofillPopoverPresenter + self.isBurner = isBurner } var passwordManagementDomain: String? { @@ -141,6 +143,7 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { } } + @MainActor func toggleDownloadsPopover(from button: MouseOverButton, popoverDelegate: NSPopoverDelegate, downloadsDelegate: DownloadsViewControllerDelegate) { if downloadsPopover?.isShown ?? false { downloadsPopover?.close() @@ -149,7 +152,7 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { guard closeTransientPopovers(), button.window != nil else { return } - let popover = DownloadsPopover() + let popover = DownloadsPopover(fireWindowSession: FireWindowSessionRef(window: button.window)) popover.delegate = popoverDelegate popover.viewController.delegate = downloadsDelegate downloadsPopover = popover @@ -166,6 +169,7 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { } private var downloadsPopoverTimer: Timer? + @MainActor func showDownloadsPopoverAndAutoHide(from button: MouseOverButton, popoverDelegate: NSPopoverDelegate, downloadsDelegate: DownloadsViewControllerDelegate) { let timerBlock: (Timer) -> Void = { [weak self] _ in self?.downloadsPopoverTimer?.invalidate() diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index 34dbc05f58..4975ae2f45 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -131,7 +131,7 @@ final class NavigationBarViewController: NSViewController { init?(coder: NSCoder, tabCollectionViewModel: TabCollectionViewModel, isBurner: Bool, networkProtectionFeatureActivation: NetworkProtectionFeatureActivation, downloadListCoordinator: DownloadListCoordinator, dragDropManager: BookmarkDragDropManager, networkProtectionPopoverManager: NetPPopoverManager, networkProtectionStatusReporter: NetworkProtectionStatusReporter, autofillPopoverPresenter: AutofillPopoverPresenter) { - self.popovers = NavigationBarPopovers(networkProtectionPopoverManager: networkProtectionPopoverManager, autofillPopoverPresenter: autofillPopoverPresenter) + self.popovers = NavigationBarPopovers(networkProtectionPopoverManager: networkProtectionPopoverManager, autofillPopoverPresenter: autofillPopoverPresenter, isBurner: isBurner) self.tabCollectionViewModel = tabCollectionViewModel self.networkProtectionButtonModel = NetworkProtectionNavBarButtonModel(popoverManager: networkProtectionPopoverManager, statusReporter: networkProtectionStatusReporter) self.isBurner = isBurner @@ -156,13 +156,6 @@ final class NavigationBarViewController: NSViewController { addressBarContainer.layer?.masksToBounds = false setupNavigationButtonMenus() - subscribeToSelectedTabViewModel() - listenToVPNToggleNotifications() - listenToPasswordManagerNotifications() - listenToPinningManagerNotifications() - listenToMessageNotifications() - listenToFeedbackFormNotifications() - subscribeToDownloads() addContextMenu() optionsButton.sendAction(on: .leftMouseDown) @@ -187,7 +180,15 @@ final class NavigationBarViewController: NSViewController { } override func viewWillAppear() { - updateDownloadsButton() + subscribeToSelectedTabViewModel() + listenToVPNToggleNotifications() + listenToPasswordManagerNotifications() + listenToPinningManagerNotifications() + listenToMessageNotifications() + listenToFeedbackFormNotifications() + subscribeToDownloads() + + updateDownloadsButton(source: .default) updatePasswordManagementButton() updateBookmarksButton() updateHomeButton() @@ -360,7 +361,7 @@ final class NavigationBarViewController: NSViewController { case .bookmarks: self.updateBookmarksButton() case .downloads: - self.updateDownloadsButton(updatingFromPinnedViewsNotification: true) + self.updateDownloadsButton(source: .pinnedViewsNotification) case .homeButton: self.updateHomeButton() case .networkProtection: @@ -693,14 +694,16 @@ final class NavigationBarViewController: NSViewController { // update Downloads button visibility and state downloadListCoordinator.updates .throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] _ in - self?.updateDownloadsButton() + .sink { [weak self] update in + guard let self, self.view.window?.isVisible == true else { return } + self.updateDownloadsButton(source: .update(update)) } .store(in: &downloadsCancellables) // update Downloads button total progress indicator - downloadListCoordinator.progress.publisher(for: \.totalUnitCount) - .combineLatest(downloadListCoordinator.progress.publisher(for: \.completedUnitCount)) + let combinedDownloadProgress = downloadListCoordinator.combinedDownloadProgressCreatingIfNeeded(for: FireWindowSessionRef(window: view.window)) + combinedDownloadProgress.publisher(for: \.totalUnitCount) + .combineLatest(combinedDownloadProgress.publisher(for: \.completedUnitCount)) .map { (total, completed) -> Double? in guard total > 0, completed < total else { return nil } return Double(completed) / Double(total) @@ -788,7 +791,13 @@ final class NavigationBarViewController: NSViewController { } } - private func updateDownloadsButton(updatingFromPinnedViewsNotification: Bool = false) { + private enum DownloadsButtonUpdateSource { + case pinnedViewsNotification + case popoverDidClose + case update(DownloadListCoordinator.Update) + case `default` + } + private func updateDownloadsButton(source: DownloadsButtonUpdateSource) { downloadsButton.menu = NSMenu { NSMenuItem(title: LocalPinningManager.shared.shortcutTitle(for: .downloads), action: #selector(toggleDownloadsPanelPinning(_:)), @@ -801,16 +810,24 @@ final class NavigationBarViewController: NSViewController { return } - let hasActiveDownloads = downloadListCoordinator.hasActiveDownloads + let fireWindowSession = FireWindowSessionRef(window: view.window) + let hasActiveDownloads = downloadListCoordinator.hasActiveDownloads(for: fireWindowSession) downloadsButton.image = hasActiveDownloads ? .downloadsActive : .downloads - if downloadListCoordinator.isEmpty { + let hasDownloads = downloadListCoordinator.hasDownloads(for: fireWindowSession) + if !hasDownloads { invalidateDownloadButtonHidingTimer() } let isTimerActive = downloadsButtonHidingTimer != nil downloadsButton.isShown = if popovers.isDownloadsPopoverShown { true + } else if case .popoverDidClose = source, hasDownloads { + true + } else if hasDownloads, case .update(let update) = source, + update.item.fireWindowSession == fireWindowSession, + update.item.added.addingTimeInterval(Constants.downloadsButtonAutoHidingInterval) > Date() { + true } else { hasActiveDownloads || isTimerActive } @@ -821,7 +838,7 @@ final class NavigationBarViewController: NSViewController { // If the user has selected Hide Downloads from the navigation bar context menu, and no downloads are active, then force it to be hidden // even if the timer is active. - if updatingFromPinnedViewsNotification { + if case .pinnedViewsNotification = source { if !LocalPinningManager.shared.isPinned(.downloads) { invalidateDownloadButtonHidingTimer() downloadsButton.isShown = hasActiveDownloads @@ -852,7 +869,7 @@ final class NavigationBarViewController: NSViewController { private func hideDownloadButtonIfPossible() { if LocalPinningManager.shared.isPinned(.downloads) || - downloadListCoordinator.hasActiveDownloads || + downloadListCoordinator.hasActiveDownloads(for: FireWindowSessionRef(window: view.window)) || popovers.isDownloadsPopoverShown { return } downloadsButton.isHidden = true @@ -1117,9 +1134,10 @@ extension NavigationBarViewController: NSPopoverDelegate { /// We check references here because these popovers might be on other windows. func popoverDidClose(_ notification: Notification) { + guard view.window?.isVisible == true else { return } if let popover = popovers.downloadsPopover, notification.object as AnyObject? === popover { popovers.downloadsPopoverClosed() - updateDownloadsButton() + updateDownloadsButton(source: .popoverDidClose) } else if let popover = popovers.bookmarkListPopover, notification.object as AnyObject? === popover { popovers.bookmarkListPopoverClosed() updateBookmarksButton() diff --git a/DuckDuckGo/Tab/TabExtensions/DownloadsTabExtension.swift b/DuckDuckGo/Tab/TabExtensions/DownloadsTabExtension.swift index 19b68939d4..d4f62564ef 100644 --- a/DuckDuckGo/Tab/TabExtensions/DownloadsTabExtension.swift +++ b/DuckDuckGo/Tab/TabExtensions/DownloadsTabExtension.swift @@ -107,7 +107,7 @@ final class DownloadsTabExtension: NSObject { let destination = self.downloadDestination(for: location, suggestedFilename: webView.suggestedFilename ?? "") let download = await webView.startDownload(using: URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad)) - self.downloadManager.add(download, fromBurnerWindow: self.isBurner, delegate: self, destination: destination) + self.downloadManager.add(download, fireWindowSession: FireWindowSessionRef(window: webView.window), delegate: self, destination: destination) } } @@ -222,7 +222,7 @@ extension DownloadsTabExtension: NavigationResponder { @MainActor func enqueueDownload(_ download: WebKitDownload, withNavigationAction navigationAction: NavigationAction?) { - let task = downloadManager.add(download, fromBurnerWindow: self.isBurner, delegate: self, destination: .auto) + let task = downloadManager.add(download, fireWindowSession: FireWindowSessionRef(window: download.webView?.window), delegate: self, destination: .auto) guard let webView = download.webView else { return } var shouldCloseTabOnDownloadStart: Bool { @@ -271,7 +271,7 @@ extension DownloadsTabExtension: WKNavigationDelegate { @objc(_webView:contextMenuDidCreateDownload:) func webView(_ webView: WKWebView, contextMenuDidCreate download: WebKitDownload) { // to do: url should be cleaned up before launching download - downloadManager.add(download, fromBurnerWindow: isBurner, delegate: self, destination: .prompt) + downloadManager.add(download, fireWindowSession: FireWindowSessionRef(window: webView.window), delegate: self, destination: .prompt) } } diff --git a/DuckDuckGo/Windows/View/WindowsManager.swift b/DuckDuckGo/Windows/View/WindowsManager.swift index d9e0c66b22..6dcc6ead90 100644 --- a/DuckDuckGo/Windows/View/WindowsManager.swift +++ b/DuckDuckGo/Windows/View/WindowsManager.swift @@ -181,7 +181,12 @@ final class WindowsManager { contentSize.height = min(NSScreen.main?.frame.size.height ?? 790, max(contentSize.height, 300)) mainViewController.view.frame = NSRect(origin: .zero, size: contentSize) - return MainWindowController(mainViewController: mainViewController, popUp: popUp) + let fireWindowSession = if case .burner = burnerMode { + WindowControllersManager.shared.mainWindowControllers.first(where: { + $0.mainViewController.tabCollectionViewModel.burnerMode == burnerMode + })?.fireWindowSession ?? FireWindowSession() + } else { FireWindowSession?.none } + return MainWindowController(mainViewController: mainViewController, popUp: popUp, fireWindowSession: fireWindowSession) } } diff --git a/UnitTests/FileDownload/DownloadListCoordinatorTests.swift b/UnitTests/FileDownload/DownloadListCoordinatorTests.swift index c78be9bf91..1337368fbe 100644 --- a/UnitTests/FileDownload/DownloadListCoordinatorTests.swift +++ b/UnitTests/FileDownload/DownloadListCoordinatorTests.swift @@ -61,7 +61,6 @@ final class DownloadListCoordinatorTests: XCTestCase { @MainActor func addDownload(tempURL: URL? = nil, destURL: URL? = nil, isBurner: Bool = false) -> (WKDownloadMock, WebKitDownloadTask, UUID) { let download = WKDownloadMock(url: .duckDuckGo) - let fm = FileManager.default let destURL = destURL ?? self.destURL! XCTAssertTrue(fm.createFile(atPath: destURL.path, contents: nil)) @@ -70,7 +69,13 @@ final class DownloadListCoordinatorTests: XCTestCase { XCTAssertTrue(fm.createFile(atPath: tempURL.path, contents: "test".utf8data)) let tempFile = try! FilePresenter(url: tempURL) - let task = WebKitDownloadTask(download: download, destination: .resume(destination: destFile, tempFile: tempFile), isBurner: isBurner) + var fireWindowSession: FireWindowSessionRef? + if isBurner { + let mainViewController = MainViewController(tabCollectionViewModel: TabCollectionViewModel(tabCollection: TabCollection(tabs: []), burnerMode: .init(isBurner: true)), autofillPopoverPresenter: DefaultAutofillPopoverPresenter()) + let mainWindowController = MainWindowController(mainViewController: mainViewController, popUp: false, fireWindowSession: .init()) + fireWindowSession = FireWindowSessionRef(window: mainWindowController.window) + } + let task = WebKitDownloadTask(download: download, destination: .resume(destination: destFile, tempFile: tempFile), fireWindowSession: fireWindowSession) let e = expectation(description: "download added") var id: UUID! @@ -179,7 +184,7 @@ final class DownloadListCoordinatorTests: XCTestCase { let destURL = fm.temporaryDirectory.appendingPathComponent("test file.pdf") let tempURL = fm.temporaryDirectory.appendingPathComponent("test file.duckload") let download = WKDownloadMock(url: .duckDuckGo) - let task = WebKitDownloadTask(download: download, destination: .preset(destURL), isBurner: false) + let task = WebKitDownloadTask(download: download, destination: .preset(destURL), fireWindowSession: nil) let e1 = expectation(description: "download added") let e2 = expectation(description: "download updated") @@ -187,7 +192,7 @@ final class DownloadListCoordinatorTests: XCTestCase { switch update.kind { case .added: XCTAssertEqual(update.item.progress, task.progress) - XCTAssertTrue(coordinator!.hasActiveDownloads) + XCTAssertTrue(coordinator!.hasActiveDownloads(for: nil)) e1.fulfill() case .updated: guard let tempUrlValue = update.item.tempURL else { return } @@ -208,7 +213,7 @@ final class DownloadListCoordinatorTests: XCTestCase { await fulfillment(of: [e1, e2], timeout: 5.0) withExtendedLifetime(c) {} - XCTAssertTrue(coordinator.hasActiveDownloads) + XCTAssertTrue(coordinator.hasActiveDownloads(for: nil)) } @MainActor @@ -233,7 +238,7 @@ final class DownloadListCoordinatorTests: XCTestCase { waitForExpectations(timeout: 1) c = nil - XCTAssertFalse(coordinator.hasActiveDownloads) + XCTAssertFalse(coordinator.hasActiveDownloads(for: nil)) } @MainActor @@ -258,7 +263,7 @@ final class DownloadListCoordinatorTests: XCTestCase { withExtendedLifetime(c) { waitForExpectations(timeout: 1) } - XCTAssertFalse(coordinator.hasActiveDownloads) + XCTAssertFalse(coordinator.hasActiveDownloads(for: nil)) } @MainActor @@ -287,7 +292,7 @@ final class DownloadListCoordinatorTests: XCTestCase { waitForExpectations(timeout: 1) c = nil - XCTAssertFalse(coordinator.hasActiveDownloads) + XCTAssertFalse(coordinator.hasActiveDownloads(for: nil)) } @MainActor @@ -319,7 +324,7 @@ final class DownloadListCoordinatorTests: XCTestCase { let downloadAdded = expectation(description: "download addeed") downloadManager.addDownloadBlock = { [unowned self] download, _, destination in downloadAdded.fulfill() - let task = WebKitDownloadTask(download: download, destination: destination, isBurner: false) + let task = WebKitDownloadTask(download: download, destination: destination, fireWindowSession: nil) if case .resume(destination: let dest, tempFile: let temp) = destination { XCTAssertEqual(dest.url, destURL) XCTAssertEqual(temp.url, tempURL) @@ -377,7 +382,7 @@ final class DownloadListCoordinatorTests: XCTestCase { let downloadAdded = expectation(description: "download addeed") downloadManager.addDownloadBlock = { [unowned self] download, _, destination in downloadAdded.fulfill() - let task = WebKitDownloadTask(download: download, destination: destination, isBurner: false) + let task = WebKitDownloadTask(download: download, destination: destination, fireWindowSession: nil) self.downloadManager.downloadAddedSubject.send(task) task.start(delegate: self.downloadManager) if case .resume(destination: let dest, tempFile: let temp) = destination { @@ -437,7 +442,7 @@ final class DownloadListCoordinatorTests: XCTestCase { let downloadAdded = expectation(description: "download addeed") downloadManager.addDownloadBlock = { [unowned self] download, _, destination in downloadAdded.fulfill() - let task = WebKitDownloadTask(download: download, destination: destination, isBurner: false) + let task = WebKitDownloadTask(download: download, destination: destination, fireWindowSession: nil) self.downloadManager.downloadAddedSubject.send(task) task.start(delegate: self.downloadManager) if case .resume(destination: let dest, tempFile: let temp) = destination { @@ -525,13 +530,13 @@ final class DownloadListCoordinatorTests: XCTestCase { XCTAssertNotEqual(update.item.identifier, keptId) } - coordinator.cleanupInactiveDownloads() + coordinator.cleanupInactiveDownloads(for: nil) withExtendedLifetime(c) { waitForExpectations(timeout: 1) } - XCTAssertTrue(coordinator.hasActiveDownloads) + XCTAssertTrue(coordinator.hasActiveDownloads(for: nil)) XCTAssertEqual(coordinator.downloads(sortedBy: \.modified, ascending: true).count, 1) task1.download(download1.asWKDownload(), didFailWithError: TestError(), resumeData: nil) @@ -622,7 +627,7 @@ private extension DownloadListItem { websiteURL: .duckDuckGo, fileName: "testItem.pdf", progress: nil, - isBurner: false, + fireWindowSession: nil, destinationURL: FileManager.default.temporaryDirectory.appendingPathComponent("testItem.pdf"), destinationFileBookmarkData: nil, tempURL: nil, @@ -636,7 +641,7 @@ private extension DownloadListItem { websiteURL: .duckDuckGo, fileName: "oldItem.pdf", progress: nil, - isBurner: false, + fireWindowSession: nil, destinationURL: FileManager.default.temporaryDirectory.appendingPathComponent("oldItem.pdf"), destinationFileBookmarkData: nil, tempURL: nil, @@ -650,7 +655,7 @@ private extension DownloadListItem { websiteURL: nil, fileName: "outdated_fileName", progress: nil, - isBurner: false, + fireWindowSession: nil, destinationURL: FileManager.default.temporaryDirectory.appendingPathComponent("olderItem.pdf"), destinationFileBookmarkData: nil, tempURL: nil, @@ -664,7 +669,7 @@ private extension DownloadListItem { websiteURL: .duckDuckGo, fileName: "fileName", progress: nil, - isBurner: false, + fireWindowSession: nil, destinationURL: URL(fileURLWithPath: "/test/path"), destinationFileBookmarkData: nil, tempURL: URL(fileURLWithPath: "/temp/file/path"), @@ -678,7 +683,7 @@ private extension DownloadListItem { websiteURL: .duckDuckGo, fileName: "testFailedItem.pdf", progress: nil, - isBurner: false, + fireWindowSession: nil, destinationURL: FileManager.default.temporaryDirectory.appendingPathComponent("testFailedItem.pdf"), destinationFileBookmarkData: nil, tempURL: FileManager.default.temporaryDirectory.appendingPathComponent("testFailedItem.duckload"), diff --git a/UnitTests/FileDownload/DownloadListStoreTests.swift b/UnitTests/FileDownload/DownloadListStoreTests.swift index c87d347e07..687c062622 100644 --- a/UnitTests/FileDownload/DownloadListStoreTests.swift +++ b/UnitTests/FileDownload/DownloadListStoreTests.swift @@ -94,7 +94,7 @@ private extension DownloadListItem { websiteURL: .duckDuckGo, fileName: "fileName", progress: nil, - isBurner: false, + fireWindowSession: nil, destinationURL: URL(fileURLWithPath: "/test/path"), destinationFileBookmarkData: nil, tempURL: URL(fileURLWithPath: "/temp/file/path"), @@ -107,7 +107,7 @@ private extension DownloadListItem { websiteURL: .duckDuckGo, fileName: "fileName", progress: nil, - isBurner: false, + fireWindowSession: nil, destinationURL: URL(fileURLWithPath: "/test/path"), destinationFileBookmarkData: nil, tempURL: nil, @@ -120,7 +120,7 @@ private extension DownloadListItem { websiteURL: nil, fileName: "fileName", progress: nil, - isBurner: false, + fireWindowSession: nil, destinationURL: URL(fileURLWithPath: "/test/path.jpeg"), destinationFileBookmarkData: nil, tempURL: nil, diff --git a/UnitTests/FileDownload/FileDownloadManagerTests.swift b/UnitTests/FileDownload/FileDownloadManagerTests.swift index 0882a65371..0b43111403 100644 --- a/UnitTests/FileDownload/FileDownloadManagerTests.swift +++ b/UnitTests/FileDownload/FileDownloadManagerTests.swift @@ -69,7 +69,7 @@ final class FileDownloadManagerTests: XCTestCase { } let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: nil, destination: .auto) + dm.add(download, fireWindowSession: nil, delegate: nil, destination: .auto) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 0.3) @@ -84,7 +84,7 @@ final class FileDownloadManagerTests: XCTestCase { } let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: nil, destination: .auto) + dm.add(download, fireWindowSession: nil, delegate: nil, destination: .auto) struct TestError: Error {} download.delegate?.download!(download.asWKDownload(), didFailWithError: TestError(), resumeData: nil) @@ -104,7 +104,7 @@ final class FileDownloadManagerTests: XCTestCase { let download = WKDownloadMock(url: .duckDuckGo) let tempURL = fm.temporaryDirectory.appendingPathComponent("download") - dm.add(download, fromBurnerWindow: false, delegate: nil, destination: .preset(tempURL)) + dm.add(download, fireWindowSession: nil, delegate: nil, destination: .preset(tempURL)) let url = await download.delegate?.download(download.asWKDownload(), decideDestinationUsing: URLResponse(url: .duckDuckGo, mimeType: nil, expectedContentLength: 1, textEncodingName: nil), suggestedFilename: "sf") XCTAssertNotNil(url) XCTAssertTrue(fm.createFile(atPath: url?.path ?? "", contents: nil)) @@ -140,7 +140,7 @@ final class FileDownloadManagerTests: XCTestCase { } let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: self, destination: .prompt) + dm.add(download, fireWindowSession: nil, delegate: self, destination: .prompt) let url = URL(string: "https://duckduckgo.com/somefile.html")! let response = URLResponse(url: url, mimeType: UTType.pdf.preferredMIMEType, expectedContentLength: 1, textEncodingName: "utf-8") @@ -174,7 +174,7 @@ final class FileDownloadManagerTests: XCTestCase { } let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: self, destination: .auto) + dm.add(download, fireWindowSession: nil, delegate: self, destination: .auto) let url = URL(string: "https://duckduckgo.com/somefile.html")! let response = URLResponse(url: url, mimeType: UTType.html.preferredMIMEType, expectedContentLength: 1, textEncodingName: "utf-8") @@ -196,7 +196,7 @@ final class FileDownloadManagerTests: XCTestCase { XCTAssertTrue(fm.createFile(atPath: localURL.path, contents: nil, attributes: nil)) let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: self, destination: .prompt) + dm.add(download, fireWindowSession: nil, delegate: self, destination: .prompt) self.chooseDestination = { _, _, callback in callback(localURL, nil) } @@ -228,7 +228,7 @@ final class FileDownloadManagerTests: XCTestCase { callback(localURL, .html) } - dm.add(download, fromBurnerWindow: false, delegate: self, destination: .auto) + dm.add(download, fireWindowSession: nil, delegate: self, destination: .auto) let e2 = expectation(description: "WKDownload called") download.delegate?.download(download.asWKDownload(), decideDestinationUsing: response, suggestedFilename: testFile) { url in @@ -245,7 +245,7 @@ final class FileDownloadManagerTests: XCTestCase { preferences.selectedDownloadLocation = downloadsURL let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: self, destination: .auto) + dm.add(download, fireWindowSession: nil, delegate: self, destination: .auto) self.chooseDestination = { _, _, _ in XCTFail("Unpected chooseDestination call") } @@ -265,7 +265,7 @@ final class FileDownloadManagerTests: XCTestCase { preferences.selectedDownloadLocation = downloadsURL let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: self, destination: .auto) + dm.add(download, fireWindowSession: nil, delegate: self, destination: .auto) self.chooseDestination = { _, _, _ in XCTFail("Unpected chooseDestination call") } @@ -288,7 +288,7 @@ final class FileDownloadManagerTests: XCTestCase { } let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: self, destination: .auto) + dm.add(download, fireWindowSession: nil, delegate: self, destination: .auto) let e1 = expectation(description: "download location requested") self.chooseDestination = { suggestedFilename, fileTypes, callback in diff --git a/UnitTests/FileDownload/Helpers/FileDownloadManagerMock.swift b/UnitTests/FileDownload/Helpers/FileDownloadManagerMock.swift index a8d1432629..0e15e03b78 100644 --- a/UnitTests/FileDownload/Helpers/FileDownloadManagerMock.swift +++ b/UnitTests/FileDownload/Helpers/FileDownloadManagerMock.swift @@ -35,7 +35,7 @@ final class FileDownloadManagerMock: FileDownloadManagerProtocol, WebKitDownload var addDownloadBlock: ((WebKitDownload, DownloadTaskDelegate?, WebKitDownloadTask.DownloadDestination) -> WebKitDownloadTask)? - func add(_ download: WebKitDownload, fromBurnerWindow: Bool, delegate: DownloadTaskDelegate?, destination: WebKitDownloadTask.DownloadDestination) -> WebKitDownloadTask { + func add(_ download: any WebKitDownload, fireWindowSession: DuckDuckGo_Privacy_Browser.FireWindowSessionRef?, delegate: (any DuckDuckGo_Privacy_Browser.DownloadTaskDelegate)?, destination: DuckDuckGo_Privacy_Browser.WebKitDownloadTask.DownloadDestination) -> DuckDuckGo_Privacy_Browser.WebKitDownloadTask { addDownloadBlock!(download, delegate, destination) } diff --git a/UnitTests/NavigationBar/View/NavigationBarPopoversTests.swift b/UnitTests/NavigationBar/View/NavigationBarPopoversTests.swift index b1b050b5e8..5a6d065a06 100644 --- a/UnitTests/NavigationBar/View/NavigationBarPopoversTests.swift +++ b/UnitTests/NavigationBar/View/NavigationBarPopoversTests.swift @@ -30,7 +30,7 @@ final class NavigationBarPopoversTests: XCTestCase { @MainActor override func setUpWithError() throws { autofillPopoverPresenter = MockAutofillPopoverPresenter() - sut = NavigationBarPopovers(networkProtectionPopoverManager: NetPPopoverManagerMock(), autofillPopoverPresenter: autofillPopoverPresenter) + sut = NavigationBarPopovers(networkProtectionPopoverManager: NetPPopoverManagerMock(), autofillPopoverPresenter: autofillPopoverPresenter, isBurner: false) } func testSetsPasswordPopoverDomainOnPopover() throws {