diff --git a/Configuration/Tests/SandboxTestTool.xcconfig b/Configuration/Tests/SandboxTestTool.xcconfig new file mode 100644 index 0000000000..16af0ecc5b --- /dev/null +++ b/Configuration/Tests/SandboxTestTool.xcconfig @@ -0,0 +1,38 @@ + +// 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. +// + +#include "../Common.xcconfig" +#include "../App/AppTargetsBase.xcconfig" +#include "../AppStore.xcconfig" + +PRODUCT_BUNDLE_IDENTIFIER = com.duckduckgo.sandbox-test-tool + +CODE_SIGN_ENTITLEMENTS = sandbox-test-tool/sandbox_test_tool.entitlements + +CODE_SIGN_IDENTITY[sdk=macosx*] = 3rd Party Mac Developer Application +CODE_SIGN_IDENTITY[config=Debug][sdk=macosx*] = Apple Development +CODE_SIGN_IDENTITY[config=CI][sdk=macosx*] = + +PROVISIONING_PROFILE_SPECIFIER[config=Debug][sdk=macosx*] = + +ENABLE_APP_SANDBOX = YES +PRODUCT_NAME = $(TARGET_NAME); + +INFOPLIST_FILE = sandbox-test-tool/Info.plist +INFOPLIST_KEY_NSPrincipalClass = SandboxTestToolApp + +SWIFT_OPTIMIZATION_LEVEL[config=*][arch=*][sdk=*] = -Onone +FEATURE_FLAGS[arch=*][sdk=*] = SANDBOX_TEST_TOOL diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index c4c6c26cae..03a5ebbafc 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2760,6 +2760,9 @@ B60C6F8E29B200AB007BFAA8 /* SavePanelAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60C6F8C29B200AB007BFAA8 /* SavePanelAccessoryView.swift */; }; B60D64492AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60D64482AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift */; }; B60D644A2AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60D64482AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift */; }; + B6104E9B2BA9C173008636B2 /* DownloadResumeData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6104E9A2BA9C173008636B2 /* DownloadResumeData.swift */; }; + B6104E9C2BA9C173008636B2 /* DownloadResumeData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6104E9A2BA9C173008636B2 /* DownloadResumeData.swift */; }; + B6104E9D2BA9C174008636B2 /* DownloadResumeData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6104E9A2BA9C173008636B2 /* DownloadResumeData.swift */; }; B6106BA026A7BE0B0013B453 /* PermissionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6106B9F26A7BE0B0013B453 /* PermissionManagerTests.swift */; }; B6106BA726A7BECC0013B453 /* PermissionAuthorizationQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6106BA526A7BEC80013B453 /* PermissionAuthorizationQuery.swift */; }; B6106BAB26A7BF1D0013B453 /* PermissionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6106BAA26A7BF1D0013B453 /* PermissionType.swift */; }; @@ -3113,11 +3116,19 @@ B6C2C9EF276081AB005B7F0A /* DeallocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C2C9EE276081AB005B7F0A /* DeallocationTests.swift */; }; B6C2C9F62760B659005B7F0A /* TestDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = B6C2C9F42760B659005B7F0A /* TestDataModel.xcdatamodeld */; }; B6C416A7294A4AE500C4F2E7 /* DuckPlayerTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C416A6294A4AE500C4F2E7 /* DuckPlayerTabExtension.swift */; }; + B6C843DA2BA1CAB6006FDEC3 /* FilePresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C843D92BA1CAB6006FDEC3 /* FilePresenterTests.swift */; }; + B6C843DB2BA1CAB6006FDEC3 /* FilePresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C843D92BA1CAB6006FDEC3 /* FilePresenterTests.swift */; }; B6C8CAA72AD010DD0060E1CD /* YandexDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CAA62AD010DD0060E1CD /* YandexDataImporter.swift */; }; B6C8CAA82AD010DD0060E1CD /* YandexDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CAA62AD010DD0060E1CD /* YandexDataImporter.swift */; }; B6C8CAAA2AD010DD0060E1CD /* YandexDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C8CAA62AD010DD0060E1CD /* YandexDataImporter.swift */; }; B6CA4824298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CA4823298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift */; }; B6CA4825298CE4B70067ECCE /* AdClickAttributionTabExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CA4823298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift */; }; + B6CC26682BAD959500F53F8D /* DownloadProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CC26672BAD959500F53F8D /* DownloadProgress.swift */; }; + B6CC26692BAD959500F53F8D /* DownloadProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CC26672BAD959500F53F8D /* DownloadProgress.swift */; }; + B6CC266A2BAD959500F53F8D /* DownloadProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CC26672BAD959500F53F8D /* DownloadProgress.swift */; }; + B6CC266C2BAD9CD800F53F8D /* FileProgressPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CC266B2BAD9CD800F53F8D /* FileProgressPresenter.swift */; }; + B6CC266D2BAD9CD800F53F8D /* FileProgressPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CC266B2BAD9CD800F53F8D /* FileProgressPresenter.swift */; }; + B6CC266E2BAD9CD800F53F8D /* FileProgressPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CC266B2BAD9CD800F53F8D /* FileProgressPresenter.swift */; }; B6D574B429472253008ED1B6 /* FBProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D574B329472253008ED1B6 /* FBProtectionTabExtension.swift */; }; B6D6A5DD2982A4CE001F5F11 /* Tab+Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B61E2CD4294346C000773D8A /* Tab+Navigation.swift */; }; B6DA06E12913AEDC00225DE2 /* TestNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DA06E02913AEDB00225DE2 /* TestNavigationDelegate.swift */; }; @@ -3143,6 +3154,22 @@ B6E1491129A5C30A00AAFBE8 /* FBProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D574B329472253008ED1B6 /* FBProtectionTabExtension.swift */; }; B6E319382953446000DD3BCF /* Assertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E319372953446000DD3BCF /* Assertions.swift */; }; B6E61EE3263AC0C8004E11AB /* FileManagerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E61EE2263AC0C8004E11AB /* FileManagerExtension.swift */; }; + B6E6B9E32BA1F5F1008AA7E1 /* FilePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E6B9E22BA1F5F1008AA7E1 /* FilePresenter.swift */; }; + B6E6B9E42BA1F5F1008AA7E1 /* FilePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E6B9E22BA1F5F1008AA7E1 /* FilePresenter.swift */; }; + B6E6B9E52BA1F5F1008AA7E1 /* FilePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E6B9E22BA1F5F1008AA7E1 /* FilePresenter.swift */; }; + B6E6B9F62BA1FD90008AA7E1 /* SandboxTestTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E6B9F52BA1FD90008AA7E1 /* SandboxTestTool.swift */; }; + B6E6BA042BA1FE05008AA7E1 /* FilePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E6B9E22BA1F5F1008AA7E1 /* FilePresenter.swift */; }; + B6E6BA052BA1FE09008AA7E1 /* URLExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8EDF2324923E980071C2E8 /* URLExtension.swift */; }; + B6E6BA062BA1FE10008AA7E1 /* NSApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C8F622591021700748EB7 /* NSApplicationExtension.swift */; }; + B6E6BA082BA1FE24008AA7E1 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = B6E6BA072BA1FE24008AA7E1 /* Common */; }; + B6E6BA0A2BA1FE28008AA7E1 /* BrowserServicesKit in Frameworks */ = {isa = PBXBuildFile; productRef = B6E6BA092BA1FE28008AA7E1 /* BrowserServicesKit */; }; + B6E6BA142BA2CDD6008AA7E1 /* SandboxTestToolNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E6BA132BA2CDD6008AA7E1 /* SandboxTestToolNotifications.swift */; }; + B6E6BA162BA2CF5F008AA7E1 /* SandboxTestToolNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E6BA132BA2CDD6008AA7E1 /* SandboxTestToolNotifications.swift */; }; + B6E6BA172BA2CF60008AA7E1 /* SandboxTestToolNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E6BA132BA2CDD6008AA7E1 /* SandboxTestToolNotifications.swift */; }; + B6E6BA202BA2E462008AA7E1 /* CollectionExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4F72EB266B2ED300814C60 /* CollectionExtension.swift */; }; + B6E6BA232BA2EDDE008AA7E1 /* FileReadResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E6BA222BA2EDDE008AA7E1 /* FileReadResult.swift */; }; + B6E6BA242BA2EDDE008AA7E1 /* FileReadResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E6BA222BA2EDDE008AA7E1 /* FileReadResult.swift */; }; + B6E6BA252BA2EDDE008AA7E1 /* FileReadResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E6BA222BA2EDDE008AA7E1 /* FileReadResult.swift */; }; B6EC37DE29B5D05A001ACE79 /* DownloadsIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6EC37DD29B5D05A001ACE79 /* DownloadsIntegrationTests.swift */; }; B6EC37DF29B5D05A001ACE79 /* DownloadsIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6EC37DD29B5D05A001ACE79 /* DownloadsIntegrationTests.swift */; }; B6EC37EB29B5DA2A001ACE79 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6EC37EA29B5DA2A001ACE79 /* main.swift */; }; @@ -3377,6 +3404,13 @@ remoteGlobalIDString = 4B2537592A11BE7300610219; remoteInfo = NetworkProtectionSystemExtension; }; + B6AEB5542BA3042300781A09 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AA585D76248FD31100E9A3E2 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B6E6B9F22BA1FD90008AA7E1; + remoteInfo = "sandbox-test-tool"; + }; B6CAC23C2B8F0EC6006CD402 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = AA585D76248FD31100E9A3E2 /* Project object */; @@ -3391,6 +3425,13 @@ remoteGlobalIDString = AA585D7D248FD31100E9A3E2; remoteInfo = "DuckDuckGo Privacy Browser"; }; + B6E6BA1C2BA2E049008AA7E1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AA585D76248FD31100E9A3E2 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B6E6B9F22BA1FD90008AA7E1; + remoteInfo = "sandbox-test-tool"; + }; B6EC37F129B5DA8F001ACE79 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = AA585D76248FD31100E9A3E2 /* Project object */; @@ -4374,6 +4415,7 @@ B60C6F8329B1BAD3007BFAA8 /* FileManagerTempDirReplacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerTempDirReplacement.swift; sourceTree = ""; }; B60C6F8C29B200AB007BFAA8 /* SavePanelAccessoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavePanelAccessoryView.swift; sourceTree = ""; }; B60D64482AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressBarTextSelectionNavigation.swift; sourceTree = ""; }; + B6104E9A2BA9C173008636B2 /* DownloadResumeData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadResumeData.swift; sourceTree = ""; }; B6106B9D26A565DA0013B453 /* BundleExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleExtension.swift; sourceTree = ""; }; B6106B9F26A7BE0B0013B453 /* PermissionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManagerTests.swift; sourceTree = ""; }; B6106BA526A7BEC80013B453 /* PermissionAuthorizationQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionAuthorizationQuery.swift; sourceTree = ""; }; @@ -4562,6 +4604,7 @@ B6ABC5952B4861D4008343B9 /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = ""; }; B6AE39F029373AF200C37AA4 /* EmptyAttributionRulesProver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyAttributionRulesProver.swift; sourceTree = ""; }; B6AE74332609AFCE005B9B1A /* ProgressEstimationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressEstimationTests.swift; sourceTree = ""; }; + B6B040072B95C4C80085279D /* Downloads 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Downloads 2.xcdatamodel"; sourceTree = ""; }; B6B140872ABDBCC1004F8E85 /* HoverTrackingArea.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverTrackingArea.swift; sourceTree = ""; }; B6B1E87A26D381710062C350 /* DownloadListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadListCoordinator.swift; sourceTree = ""; }; B6B1E87D26D5DA0E0062C350 /* DownloadsPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsPopover.swift; sourceTree = ""; }; @@ -4609,8 +4652,11 @@ B6C2C9EE276081AB005B7F0A /* DeallocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeallocationTests.swift; sourceTree = ""; }; B6C2C9F52760B659005B7F0A /* Permissions.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Permissions.xcdatamodel; sourceTree = ""; }; B6C416A6294A4AE500C4F2E7 /* DuckPlayerTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerTabExtension.swift; sourceTree = ""; }; + B6C843D92BA1CAB6006FDEC3 /* FilePresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePresenterTests.swift; sourceTree = ""; }; B6C8CAA62AD010DD0060E1CD /* YandexDataImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YandexDataImporter.swift; sourceTree = ""; }; B6CA4823298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdClickAttributionTabExtensionTests.swift; sourceTree = ""; }; + B6CC26672BAD959500F53F8D /* DownloadProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadProgress.swift; sourceTree = ""; }; + B6CC266B2BAD9CD800F53F8D /* FileProgressPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProgressPresenter.swift; sourceTree = ""; }; B6D574B12947224C008ED1B6 /* ContentBlockingTabExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentBlockingTabExtension.swift; sourceTree = ""; }; B6D574B329472253008ED1B6 /* FBProtectionTabExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FBProtectionTabExtension.swift; sourceTree = ""; }; B6DA06E02913AEDB00225DE2 /* TestNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestNavigationDelegate.swift; sourceTree = ""; }; @@ -4628,6 +4674,15 @@ B6DE57F52B05EA9000CD54B9 /* SheetHostingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetHostingWindow.swift; sourceTree = ""; }; B6E319372953446000DD3BCF /* Assertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assertions.swift; sourceTree = ""; }; B6E61EE2263AC0C8004E11AB /* FileManagerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerExtension.swift; sourceTree = ""; }; + B6E6B9E22BA1F5F1008AA7E1 /* FilePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePresenter.swift; sourceTree = ""; }; + B6E6B9E82BA1FA1C008AA7E1 /* SandboxTestTool.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SandboxTestTool.xcconfig; sourceTree = ""; }; + B6E6B9ED2BA1FB4D008AA7E1 /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; + B6E6B9F32BA1FD90008AA7E1 /* sandbox-test-tool.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "sandbox-test-tool.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + B6E6B9F52BA1FD90008AA7E1 /* SandboxTestTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxTestTool.swift; sourceTree = ""; }; + B6E6B9FE2BA1FD91008AA7E1 /* sandbox_test_tool.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = sandbox_test_tool.entitlements; sourceTree = ""; }; + B6E6BA132BA2CDD6008AA7E1 /* SandboxTestToolNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxTestToolNotifications.swift; sourceTree = ""; }; + B6E6BA212BA2E4FB008AA7E1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B6E6BA222BA2EDDE008AA7E1 /* FileReadResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileReadResult.swift; sourceTree = ""; }; B6EC37DD29B5D05A001ACE79 /* DownloadsIntegrationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadsIntegrationTests.swift; sourceTree = ""; }; B6EC37E829B5DA2A001ACE79 /* tests-server */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "tests-server"; sourceTree = BUILT_PRODUCTS_DIR; }; B6EC37EA29B5DA2A001ACE79 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; @@ -4979,6 +5034,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B6E6B9F02BA1FD90008AA7E1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B6E6BA0A2BA1FE28008AA7E1 /* BrowserServicesKit in Frameworks */, + B6E6BA082BA1FE24008AA7E1 /* Common in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; B6EC37E529B5DA2A001ACE79 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -5376,6 +5440,7 @@ 37E75733296F4F0500E1C162 /* UnitTestsAppStore.xcconfig */, 37E75734296F4F0500E1C162 /* IntegrationTestsAppStore.xcconfig */, B6EC37FA29B6447F001ACE79 /* TestsServer.xcconfig */, + B6E6B9E82BA1FA1C008AA7E1 /* SandboxTestTool.xcconfig */, ); path = Tests; sourceTree = ""; @@ -6546,6 +6611,7 @@ 9F3910612B68C35600CB5112 /* DownloadsTabExtensionTests.swift */, 9F180D0E2B69C553000D695F /* Tab+WKUIDelegateTests.swift */, 9F3910682B68D87B00CB5112 /* ProgressExtensionTests.swift */, + B6C843D92BA1CAB6006FDEC3 /* FilePresenterTests.swift */, ); path = FileDownload; sourceTree = ""; @@ -6564,12 +6630,16 @@ 8556A615256C15E10092FA9D /* Model */ = { isa = PBXGroup; children = ( - 856C98DE257014BD00A22F1F /* FileDownloadManager.swift */, B6C0B23526E732000031CB7F /* DownloadListItem.swift */, - B6A924D82664C72D001A28CA /* WebKitDownloadTask.swift */, - B6C0B22D26E61CE70031CB7F /* DownloadViewModel.swift */, B6C0B23D26E8BF1F0031CB7F /* DownloadListViewModel.swift */, + B6CC26672BAD959500F53F8D /* DownloadProgress.swift */, + B6104E9A2BA9C173008636B2 /* DownloadResumeData.swift */, + B6C0B22D26E61CE70031CB7F /* DownloadViewModel.swift */, B6C0B23826E742610031CB7F /* FileDownloadError.swift */, + 856C98DE257014BD00A22F1F /* FileDownloadManager.swift */, + B6E6B9E22BA1F5F1008AA7E1 /* FilePresenter.swift */, + B6A924D82664C72D001A28CA /* WebKitDownloadTask.swift */, + B6CC266B2BAD9CD800F53F8D /* FileProgressPresenter.swift */, ); path = Model; sourceTree = ""; @@ -6716,6 +6786,7 @@ 85AE2FF024A33A2D002D507F /* Frameworks */ = { isa = PBXGroup; children = ( + B6E6B9ED2BA1FB4D008AA7E1 /* AppKit.framework */, B6F7128029F681EB00594A45 /* QuickLookUI.framework */, 85AE2FF124A33A2D002D507F /* WebKit.framework */, 4B4D603E2A0B290200BCD287 /* NetworkExtension.framework */, @@ -7045,6 +7116,7 @@ AA585D80248FD31100E9A3E2 /* DuckDuckGo */, 4B4BEC312A11B509001D9AC5 /* DuckDuckGoNotifications */, AA585D93248FD31400E9A3E2 /* UnitTests */, + B6E6B9F42BA1FD90008AA7E1 /* sandbox-test-tool */, 4B1AD89E25FC27E200261379 /* IntegrationTests */, 7B4CE8DB26F02108009134B1 /* UITests */, B6EC37E929B5DA2A001ACE79 /* tests-server */, @@ -7083,6 +7155,7 @@ 565E46DD2B2725DC0013AC2A /* SyncE2EUITests.xctest */, 376113D42B29CD5B00E794BB /* SyncE2EUITests App Store.xctest */, 7BDA36E52B7E037100AD5388 /* VPNProxyExtension.appex */, + B6E6B9F32BA1FD90008AA7E1 /* sandbox-test-tool.app */, ); name = Products; sourceTree = ""; @@ -8524,6 +8597,18 @@ path = Statistics; sourceTree = ""; }; + B6E6B9F42BA1FD90008AA7E1 /* sandbox-test-tool */ = { + isa = PBXGroup; + children = ( + B6E6BA212BA2E4FB008AA7E1 /* Info.plist */, + B6E6B9F52BA1FD90008AA7E1 /* SandboxTestTool.swift */, + B6E6BA222BA2EDDE008AA7E1 /* FileReadResult.swift */, + B6E6BA132BA2CDD6008AA7E1 /* SandboxTestToolNotifications.swift */, + B6E6B9FE2BA1FD91008AA7E1 /* sandbox_test_tool.entitlements */, + ); + path = "sandbox-test-tool"; + sourceTree = ""; + }; B6EC37DC29B5D05A001ACE79 /* Downloads */ = { isa = PBXGroup; children = ( @@ -8782,7 +8867,7 @@ buildRules = ( ); dependencies = ( - B69D06182A4C0AD20032D14D /* PBXTargetDependency */, + B6AEB5552BA3042300781A09 /* PBXTargetDependency */, 37079A93294236F20031BB3C /* PBXTargetDependency */, ); name = "Unit Tests App Store"; @@ -9244,6 +9329,7 @@ buildRules = ( ); dependencies = ( + B6E6BA1D2BA2E049008AA7E1 /* PBXTargetDependency */, B6CAC23D2B8F0EC6006CD402 /* PBXTargetDependency */, B69D06142A4C0AC50032D14D /* PBXTargetDependency */, ); @@ -9258,6 +9344,27 @@ productReference = AA585D90248FD31400E9A3E2 /* Unit Tests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + B6E6B9F22BA1FD90008AA7E1 /* sandbox-test-tool */ = { + isa = PBXNativeTarget; + buildConfigurationList = B6E6B9FF2BA1FD91008AA7E1 /* Build configuration list for PBXNativeTarget "sandbox-test-tool" */; + buildPhases = ( + B6E6B9EF2BA1FD90008AA7E1 /* Sources */, + B6E6B9F02BA1FD90008AA7E1 /* Frameworks */, + B6AEB5532BA3029B00781A09 /* Cleanup entitlements */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "sandbox-test-tool"; + packageProductDependencies = ( + B6E6BA072BA1FE24008AA7E1 /* Common */, + B6E6BA092BA1FE28008AA7E1 /* BrowserServicesKit */, + ); + productName = "sandbox-test-tool"; + productReference = B6E6B9F32BA1FD90008AA7E1 /* sandbox-test-tool.app */; + productType = "com.apple.product-type.application"; + }; B6EC37E729B5DA2A001ACE79 /* tests-server */ = { isa = PBXNativeTarget; buildConfigurationList = B6EC37EC29B5DA2A001ACE79 /* Build configuration list for PBXNativeTarget "tests-server" */; @@ -9336,6 +9443,9 @@ CreatedOnToolsVersion = 11.5; TestTargetID = AA585D7D248FD31100E9A3E2; }; + B6E6B9F22BA1FD90008AA7E1 = { + CreatedOnToolsVersion = 15.3; + }; B6EC37E729B5DA2A001ACE79 = { CreatedOnToolsVersion = 14.2; }; @@ -9392,6 +9502,7 @@ 9D9AE8B22AAA39A70026E7DC /* DuckDuckGoDBPBackgroundAgent */, 9D9AE8D32AAA39D30026E7DC /* DuckDuckGoDBPBackgroundAgentAppStore */, 4B9579252AC7AE700062CA31 /* DuckDuckGo Privacy Pro */, + B6E6B9F22BA1FD90008AA7E1 /* sandbox-test-tool */, ); }; /* End PBXProject section */ @@ -9990,6 +10101,25 @@ shellPath = /bin/sh; shellScript = "# We had issues where the Swift Package resources were not being added to the Agent Apps,\n# so we're manually coping them here.\n# It seems to be a known issue: https://forums.swift.org/t/swift-packages-resource-bundle-not-present-in-xcarchive-when-framework-using-said-package-is-archived/50084/2\ncp -RL \"${BUILT_PRODUCTS_DIR}\"/ContentScopeScripts_ContentScopeScripts.bundle \"${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\ncp -RL \"${BUILT_PRODUCTS_DIR}\"/DataBrokerProtection_DataBrokerProtection.bundle \"${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\n"; }; + B6AEB5532BA3029B00781A09 /* Cleanup entitlements */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Cleanup entitlements"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# remove full disk access entitlement auto-added for Unit Tests dependency target\nxcent=${STRINGSDATA_ROOT}/${FULL_PRODUCT_NAME}.xcent\n\n/usr/libexec/PlistBuddy -c \"Delete :com.apple.security.temporary-exception.files.absolute-path.read-only\" \"$xcent\" || :\n/usr/libexec/PlistBuddy -c \"Delete :com.apple.security.temporary-exception.mach-lookup.global-name\" \"$xcent\" || :\n"; + }; B6BD8F0A2A260E5900B6A41F /* embed libswift_Concurrency.dylib */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -10198,6 +10328,7 @@ 3706FAE5293F65D500E42796 /* BackForwardListItemViewModel.swift in Sources */, 3706FAE6293F65D500E42796 /* BWNotRespondingAlert.swift in Sources */, 3706FAE7293F65D500E42796 /* DebugUserScript.swift in Sources */, + B6CC26692BAD959500F53F8D /* DownloadProgress.swift in Sources */, 3706FAE8293F65D500E42796 /* RecentlyClosedTab.swift in Sources */, 1D36E65C298ACD2900AA485D /* AppIconChanger.swift in Sources */, 3706FAE9293F65D500E42796 /* PDFSearchTextMenuItemHandler.swift in Sources */, @@ -10205,6 +10336,7 @@ 3706FAEC293F65D500E42796 /* ContentScopeFeatureFlagging.swift in Sources */, 3706FAED293F65D500E42796 /* OnboardingButtonStyles.swift in Sources */, 3706FAEE293F65D500E42796 /* SaveIdentityPopover.swift in Sources */, + B6E6B9E42BA1F5F1008AA7E1 /* FilePresenter.swift in Sources */, 3706FAF0293F65D500E42796 /* YoutubePlayerNavigationHandler.swift in Sources */, 37197EA32942441D00394917 /* UserDialogRequest.swift in Sources */, 3706FAF1293F65D500E42796 /* PreferencesAboutView.swift in Sources */, @@ -10487,6 +10619,7 @@ 3706FBB7293F65D500E42796 /* FirePopoverViewController.swift in Sources */, 3706FBB8293F65D500E42796 /* SavePaymentMethodPopover.swift in Sources */, 4BCF15EF2ABBDBFF0083F6DF /* NetworkProtectionRemoteMessaging.swift in Sources */, + B6CC266D2BAD9CD800F53F8D /* FileProgressPresenter.swift in Sources */, 3706FBB9293F65D500E42796 /* FindInPageViewController.swift in Sources */, 3706FBBA293F65D500E42796 /* Cryptography.swift in Sources */, 3706FBBC293F65D500E42796 /* NSViewExtension.swift in Sources */, @@ -10710,6 +10843,7 @@ 3706FC52293F65D500E42796 /* MouseOverAnimationButton.swift in Sources */, B60293E72BA19ECD0033186B /* NetPPopoverManagerMock.swift in Sources */, 3706FC53293F65D500E42796 /* TabBarScrollView.swift in Sources */, + B6104E9C2BA9C173008636B2 /* DownloadResumeData.swift in Sources */, 3706FC54293F65D500E42796 /* BookmarkListTreeControllerDataSource.swift in Sources */, 3706FC55293F65D500E42796 /* AddressBarViewController.swift in Sources */, 3706FC56293F65D500E42796 /* Permissions.swift in Sources */, @@ -10919,6 +11053,7 @@ 3706FE1F293F661700E42796 /* AppStateChangePublisherTests.swift in Sources */, 3706FE20293F661700E42796 /* CLLocationManagerMock.swift in Sources */, B6656E0E2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */, + B6C843DB2BA1CAB6006FDEC3 /* FilePresenterTests.swift in Sources */, B6619EF72B10DFF700CD9186 /* InstructionsFormatParserTests.swift in Sources */, 3706FE21293F661700E42796 /* DownloadsPreferencesTests.swift in Sources */, 3706FE22293F661700E42796 /* FireproofDomainsTests.swift in Sources */, @@ -10970,6 +11105,7 @@ 986189E72A7CFB3E001B4519 /* LocalBookmarkStoreSavingTests.swift in Sources */, 3706FE42293F661700E42796 /* BWMessageIdGeneratorTests.swift in Sources */, 3706FE43293F661700E42796 /* TestDataModel.xcdatamodeld in Sources */, + B6E6BA242BA2EDDE008AA7E1 /* FileReadResult.swift in Sources */, 3706FE44293F661700E42796 /* GeolocationServiceTests.swift in Sources */, 1DA6D1032A1FFA3B00540406 /* HTTPCookieTests.swift in Sources */, 3706FE45293F661700E42796 /* ProgressEstimationTests.swift in Sources */, @@ -10999,6 +11135,7 @@ 3706FE53293F661700E42796 /* CoreDataEncryptionTests.swift in Sources */, 3706FE54293F661700E42796 /* PasteboardBookmarkTests.swift in Sources */, 3706FE55293F661700E42796 /* CBRCompileTimeReporterTests.swift in Sources */, + B6E6BA172BA2CF60008AA7E1 /* SandboxTestToolNotifications.swift in Sources */, 566B196429CDB824007E38F4 /* MoreOptionsMenuTests.swift in Sources */, 9F0A2CF92B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift in Sources */, 3706FE56293F661700E42796 /* FaviconManagerMock.swift in Sources */, @@ -11386,6 +11523,7 @@ 4B9579962AC7AE700062CA31 /* FeatureFlag.swift in Sources */, B6B4D1C82B0B3B5400C26286 /* DataImportReportModel.swift in Sources */, 4B9579972AC7AE700062CA31 /* FeedbackViewController.swift in Sources */, + B6104E9D2BA9C174008636B2 /* DownloadResumeData.swift in Sources */, 4B9579982AC7AE700062CA31 /* FaviconSelector.swift in Sources */, 4B95799A2AC7AE700062CA31 /* PrintingUserScript.swift in Sources */, 4B95799B2AC7AE700062CA31 /* ConnectBitwardenViewController.swift in Sources */, @@ -11502,6 +11640,7 @@ 4B9579FB2AC7AE700062CA31 /* ConnectBitwardenViewModel.swift in Sources */, 4B9579FC2AC7AE700062CA31 /* NSNotificationName+DataImport.swift in Sources */, 4B9579FD2AC7AE700062CA31 /* StoredPermission.swift in Sources */, + B6CC266A2BAD959500F53F8D /* DownloadProgress.swift in Sources */, 4B9579FE2AC7AE700062CA31 /* FirePopoverCollectionViewHeader.swift in Sources */, 4B9579FF2AC7AE700062CA31 /* FireViewController.swift in Sources */, 4B957A002AC7AE700062CA31 /* OutlineSeparatorViewCell.swift in Sources */, @@ -11702,6 +11841,7 @@ 4B957AAA2AC7AE700062CA31 /* TabShadowView.swift in Sources */, 4B957AAB2AC7AE700062CA31 /* BWMessageIdGenerator.swift in Sources */, B65E5DB12B74E6AA00480415 /* TrackerNetwork.swift in Sources */, + B6E6B9E52BA1F5F1008AA7E1 /* FilePresenter.swift in Sources */, 31F2D2022AF026D800BF0144 /* WaitlistTermsAndConditionsActionHandler.swift in Sources */, 4B957AAC2AC7AE700062CA31 /* EncryptedValueTransformer.swift in Sources */, 4B957AAD2AC7AE700062CA31 /* Tab+Dialogs.swift in Sources */, @@ -11784,6 +11924,7 @@ 4B957AF22AC7AE700062CA31 /* DailyPixel.swift in Sources */, 9FDA6C232B79A59D00E099A9 /* BookmarkFavoriteView.swift in Sources */, 4B957AF32AC7AE700062CA31 /* NavigationHotkeyHandler.swift in Sources */, + B6CC266E2BAD9CD800F53F8D /* FileProgressPresenter.swift in Sources */, 4B957AF42AC7AE700062CA31 /* ClickToLoadUserScript.swift in Sources */, 4B957AF52AC7AE700062CA31 /* WindowControllersManager.swift in Sources */, 4B957AF62AC7AE700062CA31 /* FireAnimationView.swift in Sources */, @@ -12204,6 +12345,7 @@ 4B92929B26670D2A00AD2C21 /* BookmarkOutlineViewDataSource.swift in Sources */, 56D145EB29E6C99B00E3488A /* DataImportStatusProviding.swift in Sources */, 31D5375C291D944100407A95 /* PasswordManagementBitwardenItemView.swift in Sources */, + B6104E9B2BA9C173008636B2 /* DownloadResumeData.swift in Sources */, 85D885B026A590A90077C374 /* NSNotificationName+PasswordManager.swift in Sources */, B610F2BB27A145C500FCEBE9 /* RulesCompilationMonitor.swift in Sources */, B6D574B429472253008ED1B6 /* FBProtectionTabExtension.swift in Sources */, @@ -12516,6 +12658,8 @@ 7B1E819E27C8874900FF0E60 /* ContentOverlayPopover.swift in Sources */, 3154FD1428E6011A00909769 /* TabShadowView.swift in Sources */, 1D43EB3C292B664A0065E5D6 /* BWMessageIdGenerator.swift in Sources */, + B6E6B9E32BA1F5F1008AA7E1 /* FilePresenter.swift in Sources */, + B6CC266C2BAD9CD800F53F8D /* FileProgressPresenter.swift in Sources */, 3158B14D2B0BF74D00AF130C /* DataBrokerProtectionManager.swift in Sources */, 4BA1A6A5258B07DF00F6F690 /* EncryptedValueTransformer.swift in Sources */, B634DBE1293C8FD500C3C99E /* Tab+Dialogs.swift in Sources */, @@ -12635,6 +12779,7 @@ 4BE5336C286912D40019DBFD /* BookmarksBarCollectionViewItem.swift in Sources */, B6C0B23926E742610031CB7F /* FileDownloadError.swift in Sources */, 85589EA027BFE60E0038AD11 /* MoreOrLessView.swift in Sources */, + B6CC26682BAD959500F53F8D /* DownloadProgress.swift in Sources */, AAE7527A263B046100B973F8 /* History.xcdatamodeld in Sources */, B64C853D26944B940048FEBE /* PermissionStore.swift in Sources */, AA75A0AE26F3500C0086B667 /* PrivacyIconViewModel.swift in Sources */, @@ -12968,6 +13113,7 @@ B68412202B6A30680092F66A /* StringExtensionTests.swift in Sources */, B662D3D92755D7AD0035D4D6 /* PixelStoreTests.swift in Sources */, B6106BB526A809E60013B453 /* GeolocationProviderTests.swift in Sources */, + B6E6BA232BA2EDDE008AA7E1 /* FileReadResult.swift in Sources */, B6A5A2A025B96E8300AA7ADA /* AppStateChangePublisherTests.swift in Sources */, B63ED0E326B3E7FA00A9DAD1 /* CLLocationManagerMock.swift in Sources */, 37CD54BB27F25A4000F1F7B9 /* DownloadsPreferencesTests.swift in Sources */, @@ -12977,6 +13123,7 @@ 4B9DB0562A983B55000927DB /* MockNotificationService.swift in Sources */, 4B02199C25E063DE00ED7DEA /* FireproofDomainsTests.swift in Sources */, AA0F3DB7261A566C0077F2D9 /* SuggestionLoadingMock.swift in Sources */, + B6E6BA162BA2CF5F008AA7E1 /* SandboxTestToolNotifications.swift in Sources */, B60C6F8129B1B4AD007BFAA8 /* TestRunHelper.swift in Sources */, 4B9DB0582A983B55000927DB /* MockNetworkProtectionCodeRedeemer.swift in Sources */, 9F872DA02B90644800138637 /* ContextualMenuTests.swift in Sources */, @@ -13114,6 +13261,7 @@ 56D145F129E6F06D00E3488A /* MockBookmarkManager.swift in Sources */, 562984712AC469E400AC20EB /* SyncPreferencesTests.swift in Sources */, 317295D22AF058D3002C3206 /* MockWaitlistTermsAndConditionsActionHandler.swift in Sources */, + B6C843DA2BA1CAB6006FDEC3 /* FilePresenterTests.swift in Sources */, B693956326F1C2A40015B914 /* FileDownloadManagerMock.swift in Sources */, B6C2C9EF276081AB005B7F0A /* DeallocationTests.swift in Sources */, B63ED0D826AE729600A9DAD1 /* PermissionModelTests.swift in Sources */, @@ -13127,6 +13275,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B6E6B9EF2BA1FD90008AA7E1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B6E6BA142BA2CDD6008AA7E1 /* SandboxTestToolNotifications.swift in Sources */, + B6E6BA042BA1FE05008AA7E1 /* FilePresenter.swift in Sources */, + B6E6BA062BA1FE10008AA7E1 /* NSApplicationExtension.swift in Sources */, + B6E6B9F62BA1FD90008AA7E1 /* SandboxTestTool.swift in Sources */, + B6E6BA252BA2EDDE008AA7E1 /* FileReadResult.swift in Sources */, + B6E6BA052BA1FE09008AA7E1 /* URLExtension.swift in Sources */, + B6E6BA202BA2E462008AA7E1 /* CollectionExtension.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; B6EC37E429B5DA2A001ACE79 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -13249,10 +13411,6 @@ isa = PBXTargetDependency; productRef = B6F997BA2B8F353F00476735 /* SwiftLintPlugin */; }; - B69D06182A4C0AD20032D14D /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - productRef = B6F997BA2B8F353F00476735 /* SwiftLintPlugin */; - }; B69D061A2A4C0AD80032D14D /* PBXTargetDependency */ = { isa = PBXTargetDependency; productRef = B6F997BA2B8F353F00476735 /* SwiftLintPlugin */; @@ -13261,6 +13419,11 @@ isa = PBXTargetDependency; productRef = B6F997BA2B8F353F00476735 /* SwiftLintPlugin */; }; + B6AEB5552BA3042300781A09 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B6E6B9F22BA1FD90008AA7E1 /* sandbox-test-tool */; + targetProxy = B6AEB5542BA3042300781A09 /* PBXContainerItemProxy */; + }; B6CAC23D2B8F0EC6006CD402 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = AA585D7D248FD31100E9A3E2 /* DuckDuckGo Privacy Browser */; @@ -13271,6 +13434,11 @@ target = AA585D7D248FD31100E9A3E2 /* DuckDuckGo Privacy Browser */; targetProxy = B6CAC23E2B8F0ECA006CD402 /* PBXContainerItemProxy */; }; + B6E6BA1D2BA2E049008AA7E1 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B6E6B9F22BA1FD90008AA7E1 /* sandbox-test-tool */; + targetProxy = B6E6BA1C2BA2E049008AA7E1 /* PBXContainerItemProxy */; + }; B6EC37F229B5DA8F001ACE79 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = B6EC37E729B5DA2A001ACE79 /* tests-server */; @@ -13848,6 +14016,34 @@ }; name = Review; }; + B6E6BA002BA1FD91008AA7E1 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B6E6B9E82BA1FA1C008AA7E1 /* SandboxTestTool.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + B6E6BA012BA1FD91008AA7E1 /* CI */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B6E6B9E82BA1FA1C008AA7E1 /* SandboxTestTool.xcconfig */; + buildSettings = { + }; + name = CI; + }; + B6E6BA022BA1FD91008AA7E1 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B6E6B9E82BA1FA1C008AA7E1 /* SandboxTestTool.xcconfig */; + buildSettings = { + }; + name = Release; + }; + B6E6BA032BA1FD91008AA7E1 /* Review */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B6E6B9E82BA1FA1C008AA7E1 /* SandboxTestTool.xcconfig */; + buildSettings = { + }; + name = Review; + }; B6EC37ED29B5DA2A001ACE79 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = B6EC37FA29B6447F001ACE79 /* TestsServer.xcconfig */; @@ -14088,6 +14284,17 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B6E6B9FF2BA1FD91008AA7E1 /* Build configuration list for PBXNativeTarget "sandbox-test-tool" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B6E6BA002BA1FD91008AA7E1 /* Debug */, + B6E6BA012BA1FD91008AA7E1 /* CI */, + B6E6BA022BA1FD91008AA7E1 /* Release */, + B6E6BA032BA1FD91008AA7E1 /* Review */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; B6EC37EC29B5DA2A001ACE79 /* Build configuration list for PBXNativeTarget "tests-server" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -14866,6 +15073,16 @@ package = B6DA44152616C13800DD1EC2 /* XCRemoteSwiftPackageReference "OHHTTPStubs" */; productName = OHHTTPStubsSwift; }; + B6E6BA072BA1FE24008AA7E1 /* Common */ = { + isa = XCSwiftPackageProductDependency; + package = 9807F643278CA16F00E1547B /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; + productName = Common; + }; + B6E6BA092BA1FE28008AA7E1 /* BrowserServicesKit */ = { + isa = XCSwiftPackageProductDependency; + package = 9807F643278CA16F00E1547B /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; + productName = BrowserServicesKit; + }; B6EC37F829B5DAD7001ACE79 /* Swifter */ = { isa = XCSwiftPackageProductDependency; package = B6EC37F529B5DAAC001ACE79 /* XCRemoteSwiftPackageReference "swifter" */; @@ -15034,9 +15251,10 @@ B6C0B23226E71BCD0031CB7F /* Downloads.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + B6B040072B95C4C80085279D /* Downloads 2.xcdatamodel */, B6C0B23326E71BCD0031CB7F /* Downloads.xcdatamodel */, ); - currentVersion = B6C0B23326E71BCD0031CB7F /* Downloads.xcdatamodel */; + currentVersion = B6B040072B95C4C80085279D /* Downloads 2.xcdatamodel */; path = Downloads.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme index 00cc0d9f02..14f7550826 100644 --- a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme +++ b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme @@ -61,7 +61,7 @@ ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction"> + scriptText = "killall tests-server killall sandbox-test-tool pushd "${METAL_LIBRARY_OUTPUT_DIR}" "${BUILT_PRODUCTS_DIR}/tests-server" & popd "> + scriptText = "killall tests-server killall sandbox-test-tool "> diff --git a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser.xcscheme b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser.xcscheme index 0224eeaec8..d683d41f9e 100644 --- a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser.xcscheme +++ b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser.xcscheme @@ -75,7 +75,7 @@ ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction"> + scriptText = "killall tests-server killall sandbox-test-tool # integration tests resources dir pushd "${METAL_LIBRARY_OUTPUT_DIR}" "${BUILT_PRODUCTS_DIR}/tests-server" & popd "> + scriptText = "killall tests-server killall sandbox-test-tool "> diff --git a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/sandbox-test-tool.xcscheme b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/sandbox-test-tool.xcscheme new file mode 100644 index 0000000000..41730d7069 --- /dev/null +++ b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/sandbox-test-tool.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index a76b942094..ccb33dd466 100644 --- a/DuckDuckGo/Application/AppDelegate.swift +++ b/DuckDuckGo/Application/AppDelegate.swift @@ -41,7 +41,7 @@ import Subscription #endif @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, FileDownloadManagerDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate { #if DEBUG let disableCVDisplayLinkLogs: Void = { @@ -225,7 +225,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FileDownloadManagerDel FaviconManager.shared.loadFavicons() } ConfigurationManager.shared.start() - FileDownloadManager.shared.delegate = self _ = DownloadListCoordinator.shared _ = RecentlyClosedCoordinator.shared @@ -335,7 +334,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FileDownloadManagerDel 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.location.destinationURL != nil }) { + if FileDownloadManager.shared.downloads.contains(where: { $0.state.isDownloading }) { let alert = NSAlert.activeDownloadsTerminationAlert(for: FileDownloadManager.shared.downloads) if alert.runModal() == .cancel { return .terminateCancel @@ -349,20 +348,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FileDownloadManagerDel return .terminateNow } - func askUserToGrantAccessToDestination(_ folderUrl: URL) { - if FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first?.lastPathComponent == folderUrl.lastPathComponent { - let alert = NSAlert.noAccessToDownloads() - if alert.runModal() != .cancel { - let preferencesLink = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_DownloadsFolder")! - NSWorkspace.shared.open(preferencesLink) - return - } - } else { - let alert = NSAlert.noAccessToSelectedFolder() - alert.runModal() - } - } - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { if WindowControllersManager.shared.mainWindowControllers.isEmpty, case .normal = sender.runType { diff --git a/DuckDuckGo/Application/URLEventHandler.swift b/DuckDuckGo/Application/URLEventHandler.swift index 610fe0f085..7ae97a807f 100644 --- a/DuckDuckGo/Application/URLEventHandler.swift +++ b/DuckDuckGo/Application/URLEventHandler.swift @@ -116,6 +116,21 @@ final class URLEventHandler { } #endif + if url.isFileURL && url.pathExtension == WebKitDownloadTask.downloadExtension { + guard let mainViewController = { + if let mainWindowController = WindowControllersManager.shared.lastKeyMainWindowController { + return mainWindowController.mainViewController + } + return WindowsManager.openNewWindow(with: .newtab, source: .ui, isBurner: false)?.contentViewController as? MainViewController + }() else { return } + + if !mainViewController.navigationBarViewController.isDownloadsPopoverShown { + mainViewController.navigationBarViewController.toggleDownloadsPopover(keepButtonVisible: false) + } + + return + } + #if NETWORK_PROTECTION || DBP if url.scheme?.isNetworkProtectionScheme == false && url.scheme?.isDataBrokerProtectionScheme == false { WaitlistModalDismisser.dismissWaitlistModalViewControllerIfNecessary(url) diff --git a/DuckDuckGo/Common/Extensions/DispatchQueueExtensions.swift b/DuckDuckGo/Common/Extensions/DispatchQueueExtensions.swift index 8b41964a6f..b6faead85e 100644 --- a/DuckDuckGo/Common/Extensions/DispatchQueueExtensions.swift +++ b/DuckDuckGo/Common/Extensions/DispatchQueueExtensions.swift @@ -28,4 +28,42 @@ extension DispatchQueue { } } + /// executes the work item synchronously when running on the main thread, otherwise - schedules asynchronous dispatch + func asyncOrNow(execute workItem: @escaping @MainActor () -> Void) { + assert(self == .main) + if Thread.isMainThread { + MainActor.assumeIsolated(workItem) + } else { + DispatchQueue.main.async { + workItem() + } + } + } + +} + +#if swift(<5.10) +private protocol MainActorPerformer { + func perform(_ operation: @MainActor () throws -> T) rethrows -> T +} +private struct OnMainActor: MainActorPerformer { + private init() {} + static func instance() -> MainActorPerformer { OnMainActor() } + + @MainActor(unsafe) + func perform(_ operation: @MainActor () throws -> T) rethrows -> T { + try operation() + } +} +extension MainActor { + static func assumeIsolated(_ operation: @MainActor () throws -> T) rethrows -> T { + if #available(macOS 14.0, *) { + return try assumeIsolated(operation, file: #fileID, line: #line) + } + dispatchPrecondition(condition: .onQueue(.main)) + return try OnMainActor.instance().perform(operation) + } } +#else + #warning("This needs to be removed as it‘s no longer necessary.") +#endif diff --git a/DuckDuckGo/Common/Extensions/FileManagerExtension.swift b/DuckDuckGo/Common/Extensions/FileManagerExtension.swift index 4705706483..c524d39c8c 100644 --- a/DuckDuckGo/Common/Extensions/FileManagerExtension.swift +++ b/DuckDuckGo/Common/Extensions/FileManagerExtension.swift @@ -23,62 +23,82 @@ extension FileManager { @discardableResult func moveItem(at srcURL: URL, to destURL: URL, incrementingIndexIfExists flag: Bool, pathExtension: String? = nil) throws -> URL { - return try self.perform(self.moveItem, from: srcURL, to: destURL, incrementingIndexIfExists: flag, pathExtension: pathExtension) + guard srcURL != destURL else { return destURL } + guard flag else { + try moveItem(at: srcURL, to: destURL) + return destURL + } + return try withNonExistentUrl(for: destURL, incrementingIndexIfExistsUpTo: 10000, pathExtension: pathExtension) { url in + try moveItem(at: srcURL, to: url) + return url + } } @discardableResult func copyItem(at srcURL: URL, to destURL: URL, incrementingIndexIfExists flag: Bool, pathExtension: String? = nil) throws -> URL { - return try self.perform(self.copyItem, from: srcURL, to: destURL, incrementingIndexIfExists: flag, pathExtension: pathExtension) - } - - private func perform(_ operation: (URL, URL) throws -> Void, - from srcURL: URL, - to destURL: URL, - incrementingIndexIfExists: Bool, - pathExtension: String?) throws -> URL { - - guard incrementingIndexIfExists else { - try operation(srcURL, destURL) + guard srcURL != destURL else { return destURL } + guard flag else { + try moveItem(at: srcURL, to: destURL) return destURL } + return try withNonExistentUrl(for: destURL, incrementingIndexIfExistsUpTo: flag ? 10000 : 0, pathExtension: pathExtension) { url in + try copyItem(at: srcURL, to: url) + return url + } + } + + func withNonExistentUrl(for desiredURL: URL, + incrementingIndexIfExistsUpTo limit: UInt, + pathExtension: String? = nil, + continueOn shouldContinue: (Error) -> Bool = { ($0 as? CocoaError)?.code == .fileWriteFileExists }, + perform operation: (URL) throws -> T) throws -> T { - var suffix = pathExtension ?? destURL.pathExtension + var suffix = pathExtension ?? desiredURL.pathExtension if !suffix.hasPrefix(".") { suffix = "." + suffix } - if !destURL.pathExtension.isEmpty { - if !destURL.path.hasSuffix(suffix) { - suffix = "." + destURL.pathExtension + if !desiredURL.pathExtension.isEmpty { + if !desiredURL.path.hasSuffix(suffix) { + suffix = "." + desiredURL.pathExtension } } else { suffix = "" } - let ownerDirectory = destURL.deletingLastPathComponent() - let fileNameWithoutExtension = destURL.lastPathComponent.dropping(suffix: suffix) + let ownerDirectory = desiredURL.deletingLastPathComponent() + let fileNameWithoutExtension = desiredURL.lastPathComponent.dropping(suffix: suffix) - for copy in 0... { - let destURL: URL = { + var index: UInt = 0 + repeat { + let desiredURL: URL = { // Zero means we haven't tried anything yet, so use the suggested name. // Otherwise, simply append the file name with the copy number. - guard copy > 0 else { return destURL } - return ownerDirectory.appendingPathComponent("\(fileNameWithoutExtension) \(copy)\(suffix)") + guard index > 0 else { return desiredURL } + return ownerDirectory.appendingPathComponent("\(fileNameWithoutExtension) \(index)\(suffix)") }() - do { - try operation(srcURL, destURL) - return destURL - - } catch CocoaError.fileWriteFileExists { - // This is expected, as moveItem throws an error if the file already exists - guard copy <= 1000 else { - // If it gets to 1000 of these then chances are something else is wrong - os_log("Failed to move file to Downloads folder, attempt %d", type: .error, copy) - throw CocoaError(.fileWriteFileExists) + if !self.fileExists(atPath: desiredURL.path) { + do { + return try operation(desiredURL) + } catch { + guard shouldContinue(error) else { throw error } + // This is expected, as moveItem throws an error if the file already exists + index += 1 } } - } - fatalError("Unexpected flow") + index += 1 + } while index <= limit + // If it gets beyond the limit then chances are something else is wrong + os_log("Failed to move file to %s, attempt: %d", type: .error, desiredURL.deletingLastPathComponent().path, index) + throw CocoaError(.fileWriteFileExists) + } + + func isInTrash(_ url: URL) -> Bool { + let resolvedUrl = url.resolvingSymlinksInPath() + guard let trashUrl = (try? self.url(for: .trashDirectory, in: .allDomainsMask, appropriateFor: resolvedUrl, create: false)) + ?? urls(for: .trashDirectory, in: .userDomainMask).first else { return false } + + return resolvedUrl.path.hasPrefix(trashUrl.path) } } diff --git a/DuckDuckGo/Common/Extensions/NSApplicationExtension.swift b/DuckDuckGo/Common/Extensions/NSApplicationExtension.swift index ec8c1cb750..c1c9e9b592 100644 --- a/DuckDuckGo/Common/Extensions/NSApplicationExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSApplicationExtension.swift @@ -70,7 +70,7 @@ extension NSApplication { }() var runType: RunType { Self.runType } -#if !NETWORK_EXTENSION +#if !NETWORK_EXTENSION && !SANDBOX_TEST_TOOL var mainMenuTyped: MainMenu { return mainMenu as! MainMenu // swiftlint:disable:this force_cast } diff --git a/DuckDuckGo/Common/Extensions/NSWorkspaceExtension.swift b/DuckDuckGo/Common/Extensions/NSWorkspaceExtension.swift index 8e7c81a0ac..ebfca54a36 100644 --- a/DuckDuckGo/Common/Extensions/NSWorkspaceExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSWorkspaceExtension.swift @@ -67,3 +67,15 @@ extension NSWorkspace { } } + +extension NSWorkspace.OpenConfiguration { + + convenience init(newInstance: Bool, environment: [String: String]? = nil) { + self.init() + self.createsNewApplicationInstance = newInstance + if let environment { + self.environment = environment + } + } + +} diff --git a/DuckDuckGo/Common/Extensions/StringExtension.swift b/DuckDuckGo/Common/Extensions/StringExtension.swift index b6d6affbb0..eeb8e5bedb 100644 --- a/DuckDuckGo/Common/Extensions/StringExtension.swift +++ b/DuckDuckGo/Common/Extensions/StringExtension.swift @@ -109,6 +109,15 @@ extension String { (self as NSString).pathExtension } + func appendingPathComponent(_ component: String) -> String { + (self as NSString).appendingPathComponent(component) + } + + func appendingPathExtension(_ pathExtension: String?) -> String { + guard let pathExtension, !pathExtension.isEmpty else { return self } + return self + "." + pathExtension + } + // MARK: - Mutating @inlinable mutating func prepend(_ string: String) { diff --git a/DuckDuckGo/Common/Extensions/URLExtension.swift b/DuckDuckGo/Common/Extensions/URLExtension.swift index 3f5b12e8f7..4c68d32b01 100644 --- a/DuckDuckGo/Common/Extensions/URLExtension.swift +++ b/DuckDuckGo/Common/Extensions/URLExtension.swift @@ -79,6 +79,7 @@ extension URL { // MARK: - Factory +#if !SANDBOX_TEST_TOOL static func makeSearchUrl(from searchQuery: String) -> URL? { let trimmedQuery = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) @@ -124,6 +125,7 @@ extension URL { return url } +#endif static let blankPage = URL(string: "about:blank")! @@ -136,9 +138,11 @@ extension URL { static let dataBrokerProtection = URL(string: "duck://dbp")! +#if !SANDBOX_TEST_TOOL static func settingsPane(_ pane: PreferencePaneIdentifier) -> URL { return settings.appendingPathComponent(pane.rawValue) } +#endif enum Invalid { static let aboutNewtab = URL(string: "about:newtab")! @@ -274,6 +278,7 @@ extension URL { return string } +#if !SANDBOX_TEST_TOOL func toString(forUserInput input: String, decodePunycode: Bool = true) -> String { let hasInputScheme = input.hasOrIsPrefix(of: self.separatedScheme ?? "") let hasInputWww = input.dropping(prefix: self.separatedScheme ?? "").hasOrIsPrefix(of: URL.HostPrefix.www.rawValue) @@ -284,6 +289,7 @@ extension URL { needsWWW: !input.dropping(prefix: self.separatedScheme ?? "").isEmpty && hasInputWww, dropTrailingSlash: !input.hasSuffix("/")) } +#endif /// Tries to use the file name part of the URL, if available, adjusting for content type, if available. var suggestedFilename: String? { diff --git a/DuckDuckGo/Common/Logging/Logging.swift b/DuckDuckGo/Common/Logging/Logging.swift index 70b37f76b7..de04f91cb7 100644 --- a/DuckDuckGo/Common/Logging/Logging.swift +++ b/DuckDuckGo/Common/Logging/Logging.swift @@ -25,6 +25,7 @@ extension OSLog { enum AppCategories: String, CaseIterable { case atb = "ATB" case config = "Configuration Downloading" + case downloads = "Downloads" case fire = "Fire" case dataImportExport = "Data Import/Export" case pixel = "Pixel" @@ -53,6 +54,7 @@ extension OSLog { @OSLogWrapper(.atb) static var atb @OSLogWrapper(.config) static var config + @OSLogWrapper(.downloads) static var downloads @OSLogWrapper(.fire) static var fire @OSLogWrapper(.dataImportExport) static var dataImportExport @OSLogWrapper(.pixel) static var pixel diff --git a/DuckDuckGo/FileDownload/Extensions/ProgressExtension.swift b/DuckDuckGo/FileDownload/Extensions/ProgressExtension.swift index 470bc966a7..f05db6cd48 100644 --- a/DuckDuckGo/FileDownload/Extensions/ProgressExtension.swift +++ b/DuckDuckGo/FileDownload/Extensions/ProgressExtension.swift @@ -21,18 +21,34 @@ import Foundation extension Progress { convenience init(totalUnitCount: Int64, - fileOperationKind: FileOperationKind, - kind: ProgressKind, - isPausable: Bool, - isCancellable: Bool, - fileURL: URL) { + completedUnitCount: Int64 = 0, + fileOperationKind: FileOperationKind? = nil, + kind: ProgressKind? = nil, + isPausable: Bool = false, + isCancellable: Bool = false, + fileURL: URL? = nil, + sourceURL: URL? = nil) { self.init(totalUnitCount: totalUnitCount) + self.completedUnitCount = completedUnitCount self.fileOperationKind = fileOperationKind self.kind = kind self.isPausable = isPausable self.isCancellable = isCancellable self.fileURL = fileURL + self.fileDownloadingSourceURL = sourceURL + } + + convenience init(copy progress: Progress) { + self.init(totalUnitCount: progress.totalUnitCount) + + self.completedUnitCount = progress.completedUnitCount + self.fileOperationKind = progress.fileOperationKind + self.kind = progress.kind + self.isPausable = progress.isPausable + self.isCancellable = progress.isCancellable + self.fileURL = progress.fileURL + self.fileDownloadingSourceURL = progress.fileDownloadingSourceURL } var fileDownloadingSourceURL: URL? { @@ -64,50 +80,33 @@ extension Progress { } } - var fileIconOriginalRect: NSRect? { + var fileIcon: NSImage? { get { - (self.userInfo[.fileIconOriginalRectKey] as? NSValue)?.rectValue + self.userInfo[.fileIconKey] as? NSImage } set { - self.setUserInfoObject(newValue.map(NSValue.init(rect:)), forKey: .fileIconOriginalRectKey) + self.setUserInfoObject(newValue, forKey: .fileIconKey) } } - var isPublished: Bool { + var fileIconOriginalRect: NSRect? { get { - self.userInfo[.isPublishedKey] as? Bool ?? false + (self.userInfo[.fileIconOriginalRectKey] as? NSValue)?.rectValue } set { - self.setUserInfoObject(newValue, forKey: .isPublishedKey) + self.setUserInfoObject(newValue.map(NSValue.init(rect:)), forKey: .fileIconOriginalRectKey) } } - var isUnpublished: Bool { + var startTime: Date? { get { - self.userInfo[.isUnpublishedKey] as? Bool ?? false + self.userInfo[.startTimeKey] as? Date } set { - self.setUserInfoObject(newValue, forKey: .isUnpublishedKey) + self.setUserInfoObject(newValue, forKey: .startTimeKey) } } - func publishIfNotPublished() { - dispatchPrecondition(condition: .onQueue(.main)) - guard !self.isPublished else { return } - self.isPublished = true - - self.publish() - } - - func unpublishIfNeeded() { - guard self.isPublished, - !self.isUnpublished - else { return } - self.isUnpublished = true - - self.unpublish() - } - /// Initialize a new Progress that publishes the progress of a file operation. /// /// Primarily this is used to show a bounce if the file is in a location on the user's dock (e.g. Downloads) @@ -141,8 +140,8 @@ extension ProgressUserInfoKey { static let fileDownloadingSourceURLKey = ProgressUserInfoKey(rawValue: "NSProgressFileDownloadingSourceURL") static let fileLocationCanChangeKey = ProgressUserInfoKey(rawValue: "NSProgressFileLocationCanChangeKey") static let flyToImageKey = ProgressUserInfoKey(rawValue: "NSProgressFlyToImageKey") + static let fileIconKey = ProgressUserInfoKey(rawValue: "NSProgressFileIconKey") static let fileIconOriginalRectKey = ProgressUserInfoKey(rawValue: "NSProgressFileAnimationImageOriginalRectKey") - fileprivate static let isPublishedKey = ProgressUserInfoKey(rawValue: "isPublishedKey") - fileprivate static let isUnpublishedKey = ProgressUserInfoKey(rawValue: "isUnpublishedKey") + fileprivate static let startTimeKey = ProgressUserInfoKey(rawValue: "startTimeKey") } diff --git a/DuckDuckGo/FileDownload/Model/DownloadListItem.swift b/DuckDuckGo/FileDownload/Model/DownloadListItem.swift index c28c98b009..1b6cdc515b 100644 --- a/DuckDuckGo/FileDownload/Model/DownloadListItem.swift +++ b/DuckDuckGo/FileDownload/Model/DownloadListItem.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Common import Foundation import UniformTypeIdentifiers @@ -25,27 +26,40 @@ struct DownloadListItem: Equatable { let added: Date var modified: Date - let url: URL + let downloadURL: URL let websiteURL: URL? + var fileName: String { + didSet { + guard fileName != oldValue else { return } + modified = Date() + } + } - var progress: Progress? + var progress: Progress? { + didSet { + guard progress !== oldValue else { return } + modified = Date() + } + } let isBurner: Bool - var fileType: UTType? { + /// final download destination url, will match actual file url when download is finished + var destinationURL: URL? { didSet { - guard fileType != oldValue else { return } + guard destinationURL != oldValue else { return } modified = Date() } } - var destinationURL: URL? { + var destinationFileBookmarkData: Data? { didSet { - guard destinationURL != oldValue else { return } + guard destinationFileBookmarkData != oldValue else { return } modified = Date() } } + /// temp download file URL (`.duckload`) var tempURL: URL? { didSet { guard tempURL != oldValue else { return } @@ -53,6 +67,13 @@ struct DownloadListItem: Equatable { } } + var tempFileBookmarkData: Data? { + didSet { + guard tempFileBookmarkData != oldValue else { return } + modified = Date() + } + } + var error: FileDownloadError? { didSet { guard error != oldValue else { return } diff --git a/DuckDuckGo/FileDownload/Model/DownloadListViewModel.swift b/DuckDuckGo/FileDownload/Model/DownloadListViewModel.swift index e89fc3f61a..8cf63751e3 100644 --- a/DuckDuckGo/FileDownload/Model/DownloadListViewModel.swift +++ b/DuckDuckGo/FileDownload/Model/DownloadListViewModel.swift @@ -16,8 +16,9 @@ // limitations under the License. // -import Foundation import Combine +import Common +import Foundation @MainActor final class DownloadListViewModel { @@ -40,6 +41,8 @@ final class DownloadListViewModel { } private func handleDownloadsUpdate(of kind: DownloadListCoordinator.UpdateKind, item: DownloadListItem) { + os_log(.debug, log: .downloads, "DownloadListViewModel: .\(kind) \(item.identifier)") + dispatchPrecondition(condition: .onQueue(.main)) switch kind { case .added: @@ -61,17 +64,6 @@ final class DownloadListViewModel { coordinator.cleanupInactiveDownloads() } - func filterRemovedDownloads() { - items = items.filter { - if let localUrl = $0.localURL { - let fileSize = try? localUrl.resourceValues(forKeys: [.fileSizeKey]).fileSize - return fileSize != nil || $0.isActive - } else { - return true - } - } - } - func cancelDownload(at index: Int) { guard let item = items[safe: index] else { assertionFailure("DownloadListViewModel: no item at \(index)") diff --git a/DuckDuckGo/FileDownload/Model/DownloadProgress.swift b/DuckDuckGo/FileDownload/Model/DownloadProgress.swift new file mode 100644 index 0000000000..97e4e25705 --- /dev/null +++ b/DuckDuckGo/FileDownload/Model/DownloadProgress.swift @@ -0,0 +1,91 @@ +// +// DownloadProgress.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. +// + +import Combine +import Foundation +import Navigation + +final class DownloadProgress: Progress { + + private enum Constants { + /// delay before we start calculating the estimated time - because initially it‘s not reliable + static let remainingDownloadTimeEstimationDelay: TimeInterval = 1 + /// this seems to be working… + static let downloadSpeedSmoothingFactor = 0.1 + } + + private var unitsCompletedCancellable: AnyCancellable? + + init(download: WebKitDownload) { + super.init(parent: nil, userInfo: nil) + + totalUnitCount = -1 + completedUnitCount = 0 + fileOperationKind = .downloading + kind = .file + fileDownloadingSourceURL = download.originalRequest?.url + isCancellable = true + + guard let downloadProgress = (download as? ProgressReporting)?.progress else { + assertionFailure("WKDownload expected to be ProgressReporting") + return + } + + // update the task progress, throughput and estimated time based on tatal&completed progress values of the download + unitsCompletedCancellable = Publishers.CombineLatest( + downloadProgress.publisher(for: \.totalUnitCount), + downloadProgress.publisher(for: \.completedUnitCount) + ) + .dropFirst() + .sink { [weak self] (total, completed) in + self?.updateProgress(withTotal: total, completed: completed) + } + } + + /// set totalUnitCount, completedUnitCount with updating startTime, throughput and estimated time remaining + private func updateProgress(withTotal total: Int64, completed: Int64) { + if totalUnitCount != total { + totalUnitCount = total + } + completedUnitCount = completed + guard completed > 0 else { return } + guard let startTime else { + // track start time from a first received byte (completed > 0) + startTime = Date() + return + } + + let elapsedTime = Date().timeIntervalSince(startTime) + // delay before we start calculating the estimated time - because initially it‘s not reliable + guard elapsedTime > Constants.remainingDownloadTimeEstimationDelay else { return } + + // calculate instantaneous download speed + var throughput = Double(completed) / elapsedTime + + // calculate the moving average of download speed + if let oldThroughput = self.throughput.map(Double.init) { + throughput = Constants.downloadSpeedSmoothingFactor * throughput + (1 - Constants.downloadSpeedSmoothingFactor) * oldThroughput + } + self.throughput = Int(throughput) + + if total > 0 { + self.estimatedTimeRemaining = Double(total - completed) / Double(throughput) + } + } + +} diff --git a/DuckDuckGo/FileDownload/Model/DownloadResumeData.swift b/DuckDuckGo/FileDownload/Model/DownloadResumeData.swift new file mode 100644 index 0000000000..a92280c559 --- /dev/null +++ b/DuckDuckGo/FileDownload/Model/DownloadResumeData.swift @@ -0,0 +1,71 @@ +// +// DownloadResumeData.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. +// + +import Foundation + +struct DownloadResumeData { + + private enum CodingKeys { + static let root = "NSKeyedArchiveRootObjectKey" + + static let localPath = "NSURLSessionResumeInfoLocalPath" + static let tempFileName = "NSURLSessionResumeInfoTempFileName" + } + + private var dict: [String: Any] + + var localPath: String? { + get { + dict[CodingKeys.localPath] as? String + } + set { + dict[CodingKeys.localPath] = newValue + } + } + + var tempFileName: String? { + get { + dict[CodingKeys.tempFileName] as? String + } + set { + dict[CodingKeys.tempFileName] = newValue + } + } + + init(resumeData: Data) throws { + // https://github.com/WebKit/WebKit/blob/b4ac73768e74d52bf877a1c466eeee4408f291c2/Source/WebKit/UIProcess/API/Cocoa/WKWebView.mm#L829 + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: resumeData) + let object = unarchiver.decodeObject(of: [NSDictionary.self, NSArray.self, NSString.self, NSNumber.self, NSData.self, NSURL.self, NSURLRequest.self], forKey: CodingKeys.root) + unarchiver.finishDecoding() + + dict = try object as? [String: Any] ?? { + throw unarchiver.error ?? CocoaError(.coderReadCorrupt) + }() + } + + func data() throws -> Data { + let archiver = NSKeyedArchiver(requiringSecureCoding: false) + archiver.encode(dict, forKey: CodingKeys.root) + archiver.finishEncoding() + if let error = archiver.error { + throw error + } + return archiver.encodedData + } + +} diff --git a/DuckDuckGo/FileDownload/Model/DownloadViewModel.swift b/DuckDuckGo/FileDownload/Model/DownloadViewModel.swift index e2b09bb09f..cdcdadf330 100644 --- a/DuckDuckGo/FileDownload/Model/DownloadViewModel.swift +++ b/DuckDuckGo/FileDownload/Model/DownloadViewModel.swift @@ -16,8 +16,9 @@ // limitations under the License. // -import Foundation import Combine +import Common +import Foundation import UniformTypeIdentifiers final class DownloadViewModel { @@ -26,15 +27,11 @@ final class DownloadViewModel { let url: URL let websiteURL: URL? - @Published private(set) var localURL: URL? { - didSet { - self.filename = localURL?.lastPathComponent ?? "" - } - } + @Published private(set) var localURL: URL? @Published private(set) var filename: String = "" - @Published private(set) var fileType: UTType? + private var cancellable: AnyCancellable? - enum State { + enum State: Equatable { case downloading(Progress, shouldAnimateOnAppear: Bool) case complete(URL?) case failed(FileDownloadError) @@ -57,7 +54,7 @@ final class DownloadViewModel { init(item: DownloadListItem, shouldAnimateOnAppear: Bool) { if let progress = item.progress { self = .downloading(progress, shouldAnimateOnAppear: shouldAnimateOnAppear) - } else if item.error == nil, let destinationURL = item.destinationURL { + } else if item.error == nil, let destinationURL = item.destinationURL, item.tempURL == nil { self = .complete(destinationURL) } else { self = .failed(item.error ?? .failedToCompleteDownloadTask(underlyingError: URLError(.cancelled), @@ -70,7 +67,7 @@ final class DownloadViewModel { init(item: DownloadListItem) { self.id = item.identifier - self.url = item.url + self.url = item.downloadURL self.websiteURL = item.websiteURL self.state = .init(item: item, shouldAnimateOnAppear: true) @@ -79,9 +76,13 @@ final class DownloadViewModel { func update(with item: DownloadListItem) { self.localURL = item.destinationURL - self.filename = item.destinationURL?.lastPathComponent ?? "" - self.fileType = item.fileType - self.state = .init(item: item, shouldAnimateOnAppear: state.shouldAnimateOnAppear ?? true) + self.filename = item.fileName + let oldState = self.state + let newState = State(item: item, shouldAnimateOnAppear: state.shouldAnimateOnAppear ?? true) + if oldState != newState { + os_log(.debug, log: .downloads, "DownloadViewModel: \(item.identifier): \(oldState) ➡️ \(newState)") + self.state = newState + } } /// resets shouldAnimateOnAppear flag @@ -106,3 +107,20 @@ extension DownloadViewModel { } } + +extension DownloadViewModel.State: CustomDebugStringConvertible { + + var debugDescription: String { + switch self { + case .downloading(let progress, shouldAnimateOnAppear: true): + ".downloading(\(progress.isIndeterminate ? -1 : progress.fractionCompleted), animateOnAppear: true)" + case .downloading(let progress, shouldAnimateOnAppear: false): + ".downloading(\(progress.isIndeterminate ? -1 : progress.fractionCompleted))" + case .complete: + ".complete" + case .failed: + ".failed" + } + } + +} diff --git a/DuckDuckGo/FileDownload/Model/FileDownloadError.swift b/DuckDuckGo/FileDownload/Model/FileDownloadError.swift index 26ba34f0e1..50146e7ef6 100644 --- a/DuckDuckGo/FileDownload/Model/FileDownloadError.swift +++ b/DuckDuckGo/FileDownload/Model/FileDownloadError.swift @@ -86,13 +86,15 @@ extension FileDownloadError: CustomNSError { var userInfo = [String: Any]() userInfo[UserInfoKeys.underlyingError.rawValue] = error userInfo[UserInfoKeys.resumeData.rawValue] = data - userInfo[UserInfoKeys.resumeData.rawValue] = data userInfo[UserInfoKeys.isRetryable.rawValue] = NSNumber(value: isRetryable) return userInfo } } - init(_ error: NSError, isRetryable: Bool) { + init(_ error: NSError) { + var isRetryable: Bool { + (error.userInfo[UserInfoKeys.isRetryable.rawValue] as? NSNumber)?.boolValue ?? false + } switch ErrorCode(rawValue: error.domain == Self.errorDomain ? error.code : -1) { case .failedToMoveFileToDownloads: self = .failedToMoveFileToDownloads diff --git a/DuckDuckGo/FileDownload/Model/FileDownloadManager.swift b/DuckDuckGo/FileDownload/Model/FileDownloadManager.swift index 6039e44f40..605e482423 100644 --- a/DuckDuckGo/FileDownload/Model/FileDownloadManager.swift +++ b/DuckDuckGo/FileDownload/Model/FileDownloadManager.swift @@ -27,10 +27,8 @@ protocol FileDownloadManagerProtocol: AnyObject { var downloadsPublisher: AnyPublisher { get } @discardableResult - func add(_ download: WebKitDownload, - fromBurnerWindow: Bool, - delegate: DownloadTaskDelegate?, - location: FileDownloadManager.DownloadLocationPreference) -> WebKitDownloadTask + @MainActor + func add(_ download: WebKitDownload, fromBurnerWindow: Bool, delegate: DownloadTaskDelegate?, destination: WebKitDownloadTask.DownloadDestination) -> WebKitDownloadTask func cancelAll(waitUntilDone: Bool) } @@ -38,26 +36,25 @@ protocol FileDownloadManagerProtocol: AnyObject { extension FileDownloadManagerProtocol { @discardableResult - func add(_ download: WebKitDownload, fromBurnerWindow: Bool, location: FileDownloadManager.DownloadLocationPreference) -> WebKitDownloadTask { - add(download, fromBurnerWindow: fromBurnerWindow, delegate: nil, location: location) + @MainActor + func add(_ download: WebKitDownload, fromBurnerWindow: Bool, destination: WebKitDownloadTask.DownloadDestination) -> WebKitDownloadTask { + add(download, fromBurnerWindow: fromBurnerWindow, delegate: nil, destination: destination) } } -@MainActor -protocol FileDownloadManagerDelegate: AnyObject { - func askUserToGrantAccessToDestination(_ folderUrl: URL) -} - final class FileDownloadManager: FileDownloadManagerProtocol { static let shared = FileDownloadManager() private let preferences: DownloadsPreferences + private let getLogger: (() -> OSLog) + private var log: OSLog { + getLogger() + } - weak var delegate: FileDownloadManagerDelegate? - - init(preferences: DownloadsPreferences = .shared) { + init(preferences: DownloadsPreferences = .shared, log: @autoclosure @escaping (() -> OSLog) = .downloads) { self.preferences = preferences + self.getLogger = log } private (set) var downloads = Set() @@ -68,47 +65,27 @@ final class FileDownloadManager: FileDownloadManagerProtocol { private var downloadTaskDelegates = [WebKitDownloadTask: () -> DownloadTaskDelegate?]() - enum DownloadLocationPreference: Equatable { - case auto - case prompt - case preset(destinationURL: URL, tempURL: URL?) - - var destinationURL: URL? { - guard case .preset(destinationURL: let url, tempURL: _) = self else { return nil } - return url - } - - var tempURL: URL? { - guard case .preset(destinationURL: _, tempURL: let url) = self else { return nil } - return url - } - - func shouldPromptForLocation(for url: URL?) -> Bool { - switch self { - case .prompt: return true - case .preset: return false - case .auto: return url?.isFileURL ?? true // always prompt when "downloading" a local file - } - } - } - @discardableResult - func add(_ download: WebKitDownload, fromBurnerWindow: Bool, delegate: DownloadTaskDelegate?, location: DownloadLocationPreference) -> WebKitDownloadTask { + @MainActor + func add(_ download: WebKitDownload, fromBurnerWindow: Bool, delegate: DownloadTaskDelegate?, destination: WebKitDownloadTask.DownloadDestination) -> WebKitDownloadTask { dispatchPrecondition(condition: .onQueue(.main)) - let task = WebKitDownloadTask(download: download, - promptForLocation: location.shouldPromptForLocation(for: download.originalRequest?.url), - destinationURL: location.destinationURL, - tempURL: location.tempURL, - isBurner: fromBurnerWindow) + var destination = destination + // always prompt when "downloading" a local file + if download.originalRequest?.url?.isFileURL ?? true, case .auto = destination { + destination = .prompt + } + let task = WebKitDownloadTask(download: download, destination: destination, isBurner: fromBurnerWindow) + os_log(log: log, "add \(download): \(download.originalRequest?.url?.absoluteString ?? "") -> \(destination): \(task)") let shouldCancelDownloadIfDelegateIsGone = delegate != nil - self.downloadTaskDelegates[task] = { [weak delegate] in + self.downloadTaskDelegates[task] = { [weak delegate, log] in if let delegate { return delegate } // if the delegate was originally provided but deallocated since then – the download task should be cancelled if shouldCancelDownloadIfDelegateIsGone { + os_log(log: log, "🦀 \(download) delegate is gone: cancelling") return CancelledDownloadTaskDelegate() } return nil @@ -124,21 +101,26 @@ final class FileDownloadManager: FileDownloadManagerProtocol { func cancelAll(waitUntilDone: Bool) { dispatchPrecondition(condition: .onQueue(.main)) + os_log(log: log, "FileDownloadManager: cancel all: [\(downloads.map(\.debugDescription).joined(separator: ", "))]") let dispatchGroup: DispatchGroup? = waitUntilDone ? DispatchGroup() : nil var cancellables = Set() for task in downloads { if waitUntilDone { dispatchGroup?.enter() - task.output.sink { _ in - dispatchGroup?.leave() - } receiveValue: { _ in } + task.$state.sink { state in + if state.isCompleted { + dispatchGroup?.leave() + } + } .store(in: &cancellables) } task.cancel() } - if let dispatchGroup = dispatchGroup { - RunLoop.main.run(until: RunLoop.ResumeCondition(dispatchGroup: dispatchGroup)) + if let dispatchGroup { + withExtendedLifetime(cancellables) { + RunLoop.main.run(until: RunLoop.ResumeCondition(dispatchGroup: dispatchGroup)) + } } } @@ -147,56 +129,50 @@ final class FileDownloadManager: FileDownloadManagerProtocol { extension FileDownloadManager: WebKitDownloadTaskDelegate { @MainActor - // swiftlint:disable:next function_body_length - func fileDownloadTaskNeedsDestinationURL(_ task: WebKitDownloadTask, - suggestedFilename: String, - completionHandler: @escaping (URL?, UTType?) -> Void) { - - let completion: (URL?, UTType?) -> Void = { url, fileType in - defer { - self.downloadTaskDelegates[task] = nil - } - - guard let url = url else { - completionHandler(nil, nil) - return - } - - if let originalRect = self.downloadTaskDelegates[task]?()?.fileIconFlyAnimationOriginalRect(for: task) { - let utType = UTType(filenameExtension: url.pathExtension) ?? fileType ?? .data - task.progress.flyToImage = NSWorkspace.shared.icon(for: utType) - task.progress.fileIconOriginalRect = originalRect - } + func fileDownloadTaskNeedsDestinationURL(_ task: WebKitDownloadTask, suggestedFilename: String, suggestedFileType fileType: UTType?) async -> (URL?, UTType?) { + guard case (.some(let url), let fileType) = await chooseDestination(for: task, suggestedFilename: suggestedFilename, suggestedFileType: fileType) else { + os_log(log: log, "choose destination cancelled: \(task)") + return (nil, nil) + } + os_log(log: log, "destination chosen: \(task): \"\(url.path)\" (\(fileType?.description ?? "nil"))") - completionHandler(url, fileType) + if let originalRect = self.downloadTaskDelegates[task]?()?.fileIconFlyAnimationOriginalRect(for: task) { + let utType = UTType(filenameExtension: url.pathExtension) ?? fileType ?? .data + task.progress.flyToImage = NSWorkspace.shared.icon(for: utType) + task.progress.fileIcon = task.progress.flyToImage + task.progress.fileIconOriginalRect = originalRect } - let downloadLocation = preferences.effectiveDownloadLocation - let fileType = task.suggestedFileType + self.downloadTaskDelegates[task] = nil + return (url, fileType) + } + @MainActor + private func chooseDestination(for task: WebKitDownloadTask, suggestedFilename: String, suggestedFileType fileType: UTType?) async -> (URL?, UTType?) { guard task.shouldPromptForLocation || preferences.alwaysRequestDownloadLocation, - let delegate = self.downloadTaskDelegates[task]?() - else { - // download to default Downloads destination - let fileName = suggestedFilename.isEmpty ? .uniqueFilename(for: fileType) : suggestedFilename - - guard let url = downloadLocation?.appendingPathComponent(fileName) else { - os_log("Failed to access Downloads folder") - Pixel.fire(.debug(event: .fileMoveToDownloadsFailed, error: CocoaError(.fileWriteUnknown))) - completion(nil, nil) - return - } + self.downloadTaskDelegates[task]?() != nil else { + return await defaultDownloadLocation(for: task, suggestedFilename: suggestedFilename, fileType: fileType) + } - // Make sure the app has an access to destination - let folderUrl = url.deletingLastPathComponent() - guard self.verifyAccessToDestinationFolder(folderUrl, - destinationRequested: preferences.alwaysRequestDownloadLocation, - isSandboxed: NSApp.isSandboxed) else { - completion(nil, nil) - return + return await requestDestinationFromUser(for: task, suggestedFilename: suggestedFilename, suggestedFileType: fileType) + } + + @MainActor + private func requestDestinationFromUser(for task: WebKitDownloadTask, suggestedFilename: String, suggestedFileType fileType: UTType?) async -> (URL?, UTType?) { + return await withCheckedContinuation { continuation in + requestDestinationFromUser(for: task, suggestedFilename: suggestedFilename, suggestedFileType: fileType) { (url, fileType) in + continuation.resume(returning: (url, fileType)) } + } + } - completion(url, fileType) + @MainActor + private func requestDestinationFromUser(for task: WebKitDownloadTask, suggestedFilename: String, suggestedFileType fileType: UTType?, completionHandler: @escaping (URL?, UTType?) -> Void) { + // !!! + // don‘t refactor this to `async` style as it will make the `delegate` retained for the scope of the async func + // leading to a retain cycle when a background Tab presenting Save Dialog is closed + guard let delegate = self.downloadTaskDelegates[task]?() else { + completionHandler(nil, nil) return } @@ -215,53 +191,62 @@ extension FileDownloadManager: WebKitDownloadTaskDelegate { fileTypes.append(fileType) } - delegate.chooseDestination(suggestedFilename: suggestedFilename, fileTypes: fileTypes) { [weak self] url, fileType in - guard let self, let url else { - completion(nil, nil) + os_log(log: log, "FileDownloadManager: requesting download location \"\(suggestedFilename)\"/\(fileTypes.map(\.description).joined(separator: ", "))") + delegate.chooseDestination(suggestedFilename: suggestedFilename, fileTypes: fileTypes) { url, fileType in + guard let url else { + completionHandler(nil, nil) return } let folderUrl = url.deletingLastPathComponent() self.preferences.lastUsedCustomDownloadLocation = folderUrl - // Make sure the app has an access to destination - guard self.verifyAccessToDestinationFolder(folderUrl, - destinationRequested: self.preferences.alwaysRequestDownloadLocation, - isSandboxed: NSApp.isSandboxed) else { - completion(nil, nil) - return - } - - if FileManager.default.fileExists(atPath: url.path) { - // if SavePanel points to an existing location that means overwrite was chosen - try? FileManager.default.removeItem(at: url) - } - - completion(url, fileType) + // we shouldn‘t validate directory access here as we won‘t have it in sandboxed builds - only to the destination URL + completionHandler(url, fileType) } } @MainActor - private func verifyAccessToDestinationFolder(_ folderUrl: URL, destinationRequested: Bool, isSandboxed: Bool) -> Bool { - if destinationRequested && isSandboxed { return true } + private func defaultDownloadLocation(for task: WebKitDownloadTask, suggestedFilename: String, fileType: UTType?) async -> (URL?, UTType?) { + // download to default Downloads destination + guard let downloadLocation = preferences.effectiveDownloadLocation ?? DownloadsPreferences.defaultDownloadLocation(validate: false /* verify later */) else { + pixelAssertionFailure("Failed to access Downloads folder") + return (nil, nil) + } + + let fileName = suggestedFilename.isEmpty ? "download".appendingPathExtension(fileType?.preferredFilenameExtension) : suggestedFilename + var url = downloadLocation.appendingPathComponent(fileName) + os_log(log: log, "FileDownloadManager: using default download location for \"\(suggestedFilename)\": \"\(url.path)\"") - let folderPath = folderUrl.relativePath - let c = open(folderPath, O_RDONLY) - let hasAccess = c != -1 - close(c) + // make sure the app has access to the destination + let folderUrl = url.deletingLastPathComponent() + let fm = FileManager.default + guard fm.isWritableFile(atPath: folderUrl.path) else { + os_log(log: log, "FileDownloadManager: no write permissions for \"\(folderUrl.path)\": fallback to user request") + return await requestDestinationFromUser(for: task, suggestedFilename: suggestedFilename, suggestedFileType: fileType) + } - if !hasAccess { - delegate?.askUserToGrantAccessToDestination(folderUrl) + // choose non-existent filename + do { + url = try fm.withNonExistentUrl(for: url, incrementingIndexIfExistsUpTo: 10000) { url in + // the file will be overwritten in the WebKitDownloadTask + try fm.createFile(atPath: url.path, contents: nil) ? url : { throw CocoaError(.fileWriteFileExists) }() + } + } catch { + pixelAssertionFailure("Failed to create file in the Downloads folder") + return (nil, nil) } - return hasAccess + return (url, fileType) } - func fileDownloadTask(_ task: WebKitDownloadTask, didFinishWith result: Result) { + func fileDownloadTask(_ task: WebKitDownloadTask, didFinishWith result: Result) { dispatchPrecondition(condition: .onQueue(.main)) self.downloads.remove(task) self.downloadTaskDelegates[task] = nil + + os_log(log: log, "❎ removed task \(task)") } } @@ -287,3 +272,18 @@ final class CancelledDownloadTaskDelegate: DownloadTaskDelegate { } } + +extension WebKitDownloadTask.DownloadDestination: CustomDebugStringConvertible { + var debugDescription: String { + switch self { + case .auto: + ".auto" + case .prompt: + ".prompt" + case .preset(let destinationURL): + ".preset(destinationURL: \"\(destinationURL.path)\")" + case .resume(destination: let destination, tempFile: let tempFile): + ".resume(destination: \"\(destination.url?.path ?? "")\", tempFile: \"\(tempFile.url?.path ?? "")\")" + } + } +} diff --git a/DuckDuckGo/FileDownload/Model/FilePresenter.swift b/DuckDuckGo/FileDownload/Model/FilePresenter.swift new file mode 100644 index 0000000000..be8d94e8a6 --- /dev/null +++ b/DuckDuckGo/FileDownload/Model/FilePresenter.swift @@ -0,0 +1,457 @@ +// +// FilePresenter.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. +// + +import Combine +import Common +import Foundation + +private protocol FilePresenterDelegate: AnyObject { + var logger: FilePresenterLogger { get } + var url: URL? { get } + var primaryPresentedItemURL: URL? { get } + func presentedItemDidMove(to newURL: URL) + func accommodatePresentedItemDeletion() throws + func accommodatePresentedItemEviction() throws +} + +protocol FilePresenterLogger { + func log(_ message: @autoclosure () -> String) +} + +extension OSLog: FilePresenterLogger { + func log(_ message: @autoclosure () -> String) { + os_log(.debug, log: self, message()) + } +} + +internal class FilePresenter { + + private static let dispatchSourceQueue = DispatchQueue(label: "CoordinatedFile.dispatchSourceQueue") + private static let presentedItemOperationQueue: OperationQueue = { + let queue = OperationQueue() + queue.underlyingQueue = dispatchSourceQueue + queue.name = "CoordinatedFile.presentedItemOperationQueue" + queue.maxConcurrentOperationCount = 1 + queue.isSuspended = false + return queue + }() + + /// NSFilePresenter needs to be removed from NSFileCoordinator before its deallocation, that‘s why we‘re using the wrapper + private class DelegatingFilePresenter: NSObject, NSFilePresenter { + + final let presentedItemOperationQueue: OperationQueue + fileprivate final weak var delegate: FilePresenterDelegate? + + init(presentedItemOperationQueue: OperationQueue) { + self.presentedItemOperationQueue = presentedItemOperationQueue + } + + final var presentedItemURL: URL? { + guard let delegate else { return nil } + FilePresenter.dispatchSourceQueue.async { + // prevent owning FilePresenter deallocation inside the presentedItemURL getter + withExtendedLifetime(delegate) {} + } + let url = delegate.url + return url + } + + final func presentedItemDidMove(to newURL: URL) { + assert(delegate != nil) + delegate?.presentedItemDidMove(to: newURL) + } + + func accommodatePresentedItemDeletion(completionHandler: @escaping @Sendable ((any Error)?) -> Void) { + assert(delegate != nil) + do { + try delegate?.accommodatePresentedItemDeletion() + completionHandler(nil) + } catch { + completionHandler(error) + } + } + + func accommodatePresentedItemEviction(completionHandler: @escaping @Sendable ((any Error)?) -> Void) { + assert(delegate != nil) + do { + try delegate?.accommodatePresentedItemEviction() + completionHandler(nil) + } catch { + completionHandler(error) + } + } + + } + + final private class DelegatingRelatedFilePresenter: DelegatingFilePresenter { + + var primaryPresentedItemURL: URL? { + let url = delegate?.primaryPresentedItemURL + return url + } + + } + + fileprivate let lock = NSLock() + private var innerPresenter: DelegatingFilePresenter? + private var dispatchSourceCancellable: AnyCancellable? + + fileprivate let logger: any FilePresenterLogger + + let primaryPresentedItemURL: URL? + + private var _url: URL? + final var url: URL? { + lock.withLock { + _url + } + } + private func setURL(_ newURL: URL?) { + guard let oldValue = lock.withLock({ () -> URL?? in + let oldValue = _url + guard oldValue != newURL else { return URL??.none } + _url = newURL + return oldValue + }) else { return } + + didSetURL(newURL, oldValue: oldValue) + } + + private var urlSubject = PassthroughSubject() + final var urlPublisher: AnyPublisher { + urlSubject.prepend(url).eraseToAnyPublisher() + } + + init(url: URL, primaryItemURL: URL? = nil, logger: FilePresenterLogger = OSLog.disabled, createIfNeededCallback: ((URL) throws -> URL)? = nil) throws { + self._url = url + self.primaryPresentedItemURL = primaryItemURL + self.logger = logger + + let innerPresenter: DelegatingFilePresenter + if primaryItemURL != nil { + innerPresenter = DelegatingRelatedFilePresenter(presentedItemOperationQueue: FilePresenter.presentedItemOperationQueue) + } else { + innerPresenter = DelegatingFilePresenter(presentedItemOperationQueue: FilePresenter.presentedItemOperationQueue) + } + self.innerPresenter = innerPresenter + innerPresenter.delegate = self + NSFileCoordinator.addFilePresenter(innerPresenter) + + if !FileManager.default.fileExists(atPath: url.path) { + if let createFile = createIfNeededCallback { + logger.log("🗄️💥 creating file for presenter at \"\(url.path)\"") + self._url = try coordinateWrite(at: url, using: createFile) + + // re-add File Presenter for the updated URL + NSFileCoordinator.removeFilePresenter(innerPresenter) + NSFileCoordinator.addFilePresenter(innerPresenter) + + } else { + throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: url.path]) + } + } + addFSODispatchSource(for: url) + + logger.log("🗄️ added file presenter for \"\(url.path)\"\(primaryPresentedItemURL != nil ? " primary item: \"\(primaryPresentedItemURL!.path)\"" : "")") + } + + private func addFSODispatchSource(for url: URL) { + let fileDescriptor = open(url.path, O_EVTONLY) + + guard fileDescriptor != -1 else { + let err = errno + logger.log("🗄️❌ error opening \(url.path): \(err) – \(String(cString: strerror(err)))") + return + } + + // FilePresenter doesn‘t observe `rm` calls + let dispatchSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, eventMask: .delete, queue: .main) + + dispatchSource.setEventHandler { [weak self] in + guard let self, let url = self.url else { return } + self.logger.log("🗄️⚠️ file delete event handler: \"\(url.path)\"") + var resolvedBookmarkData: URL? { + var isStale = false + guard let presenter = self as? SandboxFilePresenter, + let bookmarkData = presenter.fileBookmarkData, + let url = try? URL(resolvingBookmarkData: bookmarkData, bookmarkDataIsStale: &isStale) else { + if FileManager().fileExists(atPath: url.path) { return url } // file still exists but with different letter case ? + return nil + } + return url + } + if let existingUrl = resolvedBookmarkData { + self.logger.log("🗄️⚠️ ignoring file delete event handler as file exists: \"\(url.path)\"") + presentedItemDidMove(to: existingUrl) + return + } + + try? accommodatePresentedItemDeletion() + self.dispatchSourceCancellable = nil + } + + self.dispatchSourceCancellable = AnyCancellable { + dispatchSource.cancel() + close(fileDescriptor) + } + dispatchSource.resume() + } + + private func removeFilePresenter() { + if let innerPresenter { + logger.log("🗄️ removing file presenter for \"\(url?.path ?? "")\"") + NSFileCoordinator.removeFilePresenter(innerPresenter) + self.innerPresenter = nil + } + } + + fileprivate func didSetURL(_ newValue: URL?, oldValue: URL?) { + assert(newValue != oldValue) + logger.log("🗄️ did update url from \"\(oldValue?.path ?? "")\" to \"\(newValue?.path ?? "")\"") + urlSubject.send(newValue) + } + + deinit { + removeFilePresenter() + } + +} +extension FilePresenter: FilePresenterDelegate { + + func presentedItemDidMove(to newURL: URL) { + logger.log("🗄️ presented item did move to \"\(newURL.path)\"") + setURL(newURL) + } + + func accommodatePresentedItemDeletion() throws { + logger.log("🗄️ accommodatePresentedItemDeletion (\"\(url?.path ?? "")\")") + setURL(nil) + removeFilePresenter() + } + + func accommodatePresentedItemEviction() throws { + logger.log("🗄️ accommodatePresentedItemEviction (\"\(url?.path ?? "")\")") + try accommodatePresentedItemDeletion() + } + +} + +/// Maintains File Bookmark Data for presented resource URL +/// and manages its sandbox security scope access calling `stopAccessingSecurityScopedResource` on deinit +/// balanced with preceding `startAccessingSecurityScopedResource` +final class SandboxFilePresenter: FilePresenter { + + private let securityScopedURL: URL? + + private var _fileBookmarkData: Data? + final var fileBookmarkData: Data? { + lock.withLock { + _fileBookmarkData + } + } + + private var fileBookmarkDataSubject = PassthroughSubject() + final var fileBookmarkDataPublisher: AnyPublisher { + fileBookmarkDataSubject.prepend(fileBookmarkData).eraseToAnyPublisher() + } + + /// - Parameter url: represented file URL access to which is coordinated by the File Presenter. + /// - Parameter primaryItemURL: URL to a main file resource access to which has been granted. + /// Used to grant out-of-sandbox access to `url` representing a “secondary” resource like “download.duckload” where the `primaryItemURL` would point to “download.zip”. + /// - Note: the secondary (“duckload”) file extension should be registered in the Info.plist with `NSIsRelatedItemType` flag set to `true`. + /// - Parameter consumeUnbalancedStartAccessingResource: assume the `url` is already accessible (e.g. after choosing the file using Open Panel). + /// would cause an unbalanced `stopAccessingSecurityScopedResource` call on the File Presenter deallocation. + init(url: URL, primaryItemURL: URL? = nil, consumeUnbalancedStartAccessingResource: Bool = false, logger: FilePresenterLogger = OSLog.disabled, createIfNeededCallback: ((URL) throws -> URL)? = nil) throws { + + if consumeUnbalancedStartAccessingResource || url.startAccessingSecurityScopedResource() == true { + self.securityScopedURL = url + logger.log("🏝️ \(consumeUnbalancedStartAccessingResource ? "consuming unbalanced startAccessingResource for" : "started resource access for") \"\(url.path)\"") + } else { + self.securityScopedURL = nil + logger.log("🏖️ didn‘t start resource access for \"\(url.path)\"") + } + + try super.init(url: url, primaryItemURL: primaryItemURL, logger: logger, createIfNeededCallback: createIfNeededCallback) + + do { + try self.coordinateRead(at: url, with: .withoutChanges) { url in + logger.log("📒 updating bookmark data for \"\(url.path)\"") + self._fileBookmarkData = try url.bookmarkData(options: .withSecurityScope) + } + } catch { + logger.log("📕 bookmark data retreival failed for \"\(url.path)\": \(error)") + throw error + } + } + + init(fileBookmarkData: Data, logger: FilePresenterLogger = OSLog.disabled) throws { + self._fileBookmarkData = fileBookmarkData + + var isStale = false + logger.log("📒 resolving url from bookmark data") + let url = try URL(resolvingBookmarkData: fileBookmarkData, options: .withSecurityScope, bookmarkDataIsStale: &isStale) + if url.startAccessingSecurityScopedResource() == true { + self.securityScopedURL = url + logger.log("🏝️ started resource access for \"\(url.path)\"\(isStale ? " (stale)" : "")") + } else { + self.securityScopedURL = nil + logger.log("🏖️ didn‘t start resource access for \"\(url.path)\"\(isStale ? " (stale)" : "")") + } + + try super.init(url: url, logger: logger) + + if isStale { + DispatchQueue.global().async { [weak self] in + self?.updateFileBookmarkData(for: url) + } + } + } + + override func didSetURL(_ newValue: URL?, oldValue: URL?) { + super.didSetURL(newValue, oldValue: oldValue) + updateFileBookmarkData(for: newValue) + } + + fileprivate func updateFileBookmarkData(for url: URL?) { + logger.log("📒 updateFileBookmarkData for \"\(url?.path ?? "")\"") + + var fileBookmarkData: Data? + do { + fileBookmarkData = try url?.bookmarkData(options: .withSecurityScope) + } catch { + logger.log("📕 updateFileBookmarkData failed with \(error)") + } + + guard lock.withLock({ + guard _fileBookmarkData != fileBookmarkData else { return false } + _fileBookmarkData = fileBookmarkData + return true + }) else { return } + + fileBookmarkDataSubject.send(fileBookmarkData) + } + + deinit { + if let securityScopedURL { + logger.log("🗄️ stopAccessingSecurityScopedResource \"\(securityScopedURL.path)\"") + securityScopedURL.stopAccessingSecurityScopedResource() + } + } + +} + +extension FilePresenter { + + func coordinateRead(at url: URL? = nil, with options: NSFileCoordinator.ReadingOptions = [], using reader: (URL) throws -> T) throws -> T { + guard let innerPresenter, let url = url ?? self.url else { throw CocoaError(.fileNoSuchFile) } + + return try NSFileCoordinator(filePresenter: innerPresenter).coordinateRead(at: url, with: options, using: reader) + } + + func coordinateWrite(at url: URL? = nil, with options: NSFileCoordinator.WritingOptions = [], using writer: (URL) throws -> T) throws -> T { + guard let innerPresenter, let url = url ?? self.url else { throw CocoaError(.fileNoSuchFile) } + + // temporarily disable DispatchSource file removal events + dispatchSourceCancellable?.cancel() + defer { + if FileManager.default.fileExists(atPath: url.path) { + addFSODispatchSource(for: url) + } + } + return try NSFileCoordinator(filePresenter: innerPresenter).coordinateWrite(at: url, with: options, using: writer) + } + + public func coordinateMove(from url: URL? = nil, to: URL, with options2: NSFileCoordinator.WritingOptions = .forReplacing, using move: (URL, URL) throws -> T) throws -> T { + guard let innerPresenter, let url = url ?? self.url else { throw CocoaError(.fileNoSuchFile) } + + return try NSFileCoordinator(filePresenter: innerPresenter).coordinateMove(from: url, to: to, with: options2, using: move) + } + +} + +extension NSFileCoordinator { + + func coordinateRead(at url: URL, with options: NSFileCoordinator.ReadingOptions = [], using reader: (URL) throws -> T) throws -> T { + var result: Result! + var error: NSError? + coordinate(readingItemAt: url, options: options, error: &error) { url in + result = Result { + try reader(url) + } + } + + if let error { throw error } + return try result.get() + } + + func coordinateWrite(at url: URL, with options: NSFileCoordinator.WritingOptions = [], using writer: (URL) throws -> T) throws -> T { + var result: Result! + var error: NSError? + coordinate(writingItemAt: url, options: options, error: &error) { url in + result = Result { + try writer(url) + } + } + + if let error { throw error } + return try result.get() + } + + public func coordinateMove(from url: URL, to: URL, with options2: NSFileCoordinator.WritingOptions = .forReplacing, using move: (URL, URL) throws -> T) throws -> T { + var result: Result! + var error: NSError? + coordinate(writingItemAt: url, options: .forMoving, writingItemAt: to, options: options2, error: &error) { from, to in + result = Result { + try move(from, to) + } + } + if let error { throw error } + return try result.get() + } + +} + +#if DEBUG +extension NSURL { + + private static var stopAccessingSecurityScopedResourceCallback: ((URL) -> Void)? + + private static let originalStopAccessingSecurityScopedResource = { + class_getInstanceMethod(NSURL.self, #selector(NSURL.stopAccessingSecurityScopedResource))! + }() + private static let swizzledStopAccessingSecurityScopedResource = { + class_getInstanceMethod(NSURL.self, #selector(NSURL.swizzled_stopAccessingSecurityScopedResource))! + }() + private static let swizzleStopAccessingSecurityScopedResourceOnce: Void = { + method_exchangeImplementations(originalStopAccessingSecurityScopedResource, swizzledStopAccessingSecurityScopedResource) + }() + + static func swizzleStopAccessingSecurityScopedResource(with stopAccessingSecurityScopedResourceCallback: ((URL) -> Void)?) { + _=swizzleStopAccessingSecurityScopedResourceOnce + self.stopAccessingSecurityScopedResourceCallback = stopAccessingSecurityScopedResourceCallback + } + + @objc private dynamic func swizzled_stopAccessingSecurityScopedResource() { + if let stopAccessingSecurityScopedResourceCallback = Self.stopAccessingSecurityScopedResourceCallback { + stopAccessingSecurityScopedResourceCallback(self as URL) + } + self.swizzled_stopAccessingSecurityScopedResource() // call original + } + +} +#endif diff --git a/DuckDuckGo/FileDownload/Model/FileProgressPresenter.swift b/DuckDuckGo/FileDownload/Model/FileProgressPresenter.swift new file mode 100644 index 0000000000..2552eaffbf --- /dev/null +++ b/DuckDuckGo/FileDownload/Model/FileProgressPresenter.swift @@ -0,0 +1,70 @@ +// +// FileProgressPresenter.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. +// + +import Combine +import Common +import Foundation + +final class FileProgressPresenter { + + private var cancellables = Set() + private let progress: Progress + private(set) var fileProgress: Progress? { + willSet { + fileProgress?.unpublish() + } + } + + init(progress: Progress) { + self.progress = progress + } + + /// display file fly-to-dock animation and download progress in Finder and Dock + @MainActor func displayFileProgress(at url: URL?) { + self.cancellables.removeAll(keepingCapacity: true) + guard let url else { + self.fileProgress = nil + return + } + + let fileProgress = Progress(copy: progress) + fileProgress.fileURL = url + fileProgress.cancellationHandler = { [progress] in + progress.cancel() + } + // only display fly-to-dock animation only once - setting the original &progress.flyToImage to nil + swap(&fileProgress.fileIconOriginalRect, &progress.fileIconOriginalRect) + swap(&fileProgress.flyToImage, &progress.flyToImage) + fileProgress.fileIcon = progress.fileIcon + + progress.publisher(for: \.totalUnitCount) + .assign(to: \.totalUnitCount, onWeaklyHeld: fileProgress) + .store(in: &cancellables) + progress.publisher(for: \.completedUnitCount) + .assign(to: \.completedUnitCount, onWeaklyHeld: fileProgress) + .store(in: &cancellables) + + self.fileProgress = fileProgress + fileProgress.publish() + } + + deinit { + fileProgress?.unpublish() + } + +} diff --git a/DuckDuckGo/FileDownload/Model/WebKitDownloadTask.swift b/DuckDuckGo/FileDownload/Model/WebKitDownloadTask.swift index 49b170abc8..a39f173fa1 100644 --- a/DuckDuckGo/FileDownload/Model/WebKitDownloadTask.swift +++ b/DuckDuckGo/FileDownload/Model/WebKitDownloadTask.swift @@ -17,68 +17,105 @@ // import Combine +import Common import Foundation import Navigation import UniformTypeIdentifiers import WebKit protocol WebKitDownloadTaskDelegate: AnyObject { - func fileDownloadTaskNeedsDestinationURL(_ task: WebKitDownloadTask, - suggestedFilename: String, - completionHandler: @escaping (URL?, UTType?) -> Void) - func fileDownloadTask(_ task: WebKitDownloadTask, didFinishWith result: Result) + func fileDownloadTaskNeedsDestinationURL(_ task: WebKitDownloadTask, suggestedFilename: String, suggestedFileType: UTType?) async -> (URL?, UTType?) + func fileDownloadTask(_ task: WebKitDownloadTask, didFinishWith result: Result) } /// WKDownload wrapper managing Finder File Progress and coordinating file URLs final class WebKitDownloadTask: NSObject, ProgressReporting, @unchecked Sendable { static let downloadExtension = "duckload" - private enum Constants { - static let remainingDownloadTimeEstimationDelay: TimeInterval = 1 - static let downloadSpeedSmoothingFactor = 0.1 + + enum DownloadDestination { + /// download destination would be requested from user or selected automatically depending on the “always prompt where to save files” setting + case auto + /// override “always prompt where to save files” for this download and prompt user for location + case prompt + /// desired destination URL provided when adding the download (like a temporary URL for PDF printing) + case preset(URL) + /// download is resumed to existing destination placeholder and `.duckload` file + case resume(destination: FilePresenter, tempFile: FilePresenter) } + enum FileDownloadState { + case initial(DownloadDestination) + /// - Parameter destination: final destination file placeholder file presenter + /// - Parameter tempFile: Temporary (.duckload) file presenter + case downloading(destination: FilePresenter, tempFile: FilePresenter) + /// file presenter used to track the downloaded file across file system + case downloaded(FilePresenter) + /// `destination` and `tempFile` can be used along with `resumeData` to restart a failed download (if `error.isRetryable` is `true`) + case failed(destination: FilePresenter?, tempFile: FilePresenter?, resumeData: Data?, error: FileDownloadError /* error type is force-casted in the code below in mapError (twice)! */) + + var isInitial: Bool { + if case .initial = self { true } else { false } + } - let progress: Progress - let shouldPromptForLocation: Bool - let isBurner: Bool + var isDownloading: Bool { + if case .downloading = self { true } else { false } + } - private(set) var suggestedFilename: String? - private(set) var suggestedFileType: UTType? + var destinationFilePresenter: FilePresenter? { + switch self { + case .initial(.resume(destination: let destinationFile, _)): return destinationFile + case .initial: return nil + case .downloading(destination: let destinationFile, _): return destinationFile + case .downloaded(let destinationFile): return destinationFile + case .failed(destination: let destinationFile, _, resumeData: _, _): return destinationFile + } + } + + var tempFilePresenter: FilePresenter? { + switch self { + case .initial(.resume(_, tempFile: let tempFile)): return tempFile + case .initial: return nil + case .downloading(_, tempFile: let tempFile): return tempFile + case .downloaded: return nil + case .failed(_, tempFile: let tempFile, _, _): return tempFile + } + } - struct FileLocation: Equatable { - /// Desired local destination file URL used to display download location - var destinationURL: URL? - /// Temporary (.duckload) file URL; set to nil when download completes - var tempURL: URL? - /// Item-replacement directory for the item when .duckload file could not be created - var itemReplacementDirectory: URL? + var isCompleted: Bool { + switch self { + case .initial: false + case .downloading: false + case .downloaded: true + case .failed: true + } + } } - @Published private(set) var location: FileLocation { + @Published @MainActor private(set) var state: FileDownloadState { didSet { - guard let tempURL = location.tempURL else { return } - - self.progress.fileURL = tempURL - self.progress.publishIfNotPublished() + subscribeToTempFileURL(state.tempFilePresenter) } } - private lazy var future: Future = { - dispatchPrecondition(condition: .onQueue(.main)) - let future = Future { self.fulfill = $0 } - assert(self.fulfill != nil) - return future - }() - private var fulfill: Future.Promise? - /// Task completion Publisher outputting destination URL or failure Error with Resume Data if available - var output: AnyPublisher { future.eraseToAnyPublisher() } + /// downloads initiated from a Burner Window won‘t stay in the Downloads panel after completion + let isBurner: Bool + private let log: OSLog private weak var delegate: WebKitDownloadTaskDelegate? private let download: WebKitDownload - private var progressCancellable: AnyCancellable? + /// used to report the download progress, byte count and estimated time + let progress: Progress + /// used to report file progress in Finder and Dock + private var fileProgressPresenter: FileProgressPresenter? +#if DEBUG + var fileProgress: Progress? { fileProgressPresenter?.fileProgress } +#endif - private var decideDestinationCompletionHandler: ((URL?) -> Void)? + /// temp directory for the downloaded item (removed after completion) + @MainActor private var itemReplacementDirectory: URL? + @MainActor private var itemReplacementDirectoryFSOCancellable: AnyCancellable? + @MainActor private var tempFileUrlCancellable: AnyCancellable? var originalRequest: URLRequest? { download.originalRequest @@ -86,129 +123,283 @@ final class WebKitDownloadTask: NSObject, ProgressReporting, @unchecked Sendable var originalWebView: WKWebView? { download.webView } + @MainActor var shouldPromptForLocation: Bool { + return if case .initial(.prompt) = state { true } else { false } + } - init(download: WebKitDownload, promptForLocation: Bool, destinationURL: URL?, tempURL: URL?, isBurner: Bool) { - + @MainActor(unsafe) + init(download: WebKitDownload, destination: DownloadDestination, isBurner: Bool, log: OSLog = .downloads) { self.download = download - self.progress = Progress(totalUnitCount: -1) - self.shouldPromptForLocation = promptForLocation - self.location = .init(destinationURL: destinationURL, tempURL: tempURL) + self.progress = DownloadProgress(download: download) + self.fileProgressPresenter = FileProgressPresenter(progress: progress) + self.state = .initial(destination) self.isBurner = isBurner + self.log = log super.init() - download.delegate = self - - progress.fileOperationKind = .downloading - progress.kind = .file - progress.completedUnitCount = 0 - - progress.isPausable = false - progress.isCancellable = true - progress.cancellationHandler = { [weak self] in + progress.cancellationHandler = { [weak self, log, taskDescr=self.debugDescription] in + os_log(log: log, "❌ progress.cancellationHandler \(taskDescr)") self?.cancel() } } - func start(delegate: WebKitDownloadTaskDelegate) { - _=future + /// called by the FileDownloadManager after adding the Download Task + /// + /// 1. sets `WKDownload` delegate to approve&start the download + /// 2. `WKDownload` (a new one) calls `…decideDestinationUsingResponse:…` + /// - resumed downloads use pre-provided destination and temp file + /// 3. after destination is chosen we create a placeholder file at the final destination URL (and set a File Presenter to track its renaming/removal) + /// but start the download into a temporary directory (`itemReplacementDirectory`) observing its contents. + /// 4. when the download is started, we detect a file being created in the temporary directory and move it to the final destination folder + /// replacing its original file extension with `.duckload` – this file would be used to track user-facing progress in Finder/Dock + /// 5. after the download is finished we merge the two files by replacing the final destination file with the `.duckload` file + /// + /// - if the temporary file is renamed, we try to rename the destination file accordingly (this would fail in sandboxed builds for non-preset directories) + /// - if any of the two files is removed, the download is cancelled + @MainActor func start(delegate: WebKitDownloadTaskDelegate) { + os_log(.debug, log: log, "🟢 start \(self)") + self.delegate = delegate - start() + + // if resuming download – file presenters are provided as init parameter + // when the resumed download is started using `WKWebView.resumeDownload`, + // `decideDestination` callback wouldn‘t be called. + // if the download is “resumed” as a new download (replacing the destination file) - + // the presenters will be used in the `decideDestination` callback + if case .initial(.resume(destination: let destination, tempFile: let tempFile)) = state { + state = .downloading(destination: destination, tempFile: tempFile) + } + // otherwise, setting `download.delegate` initiates `decideDestination` callback + // that will call `localFileURLCompletionHandler` + download.delegate = self } - private func start() { - self.progress.fileDownloadingSourceURL = download.originalRequest?.url - if let progress = (self.download as? ProgressReporting)?.progress { - - var startTime: Date? - progressCancellable = progress.publisher(for: \.totalUnitCount) - .combineLatest(progress.publisher(for: \.completedUnitCount)) - .sink { [weak progress=self.progress] total, completed in - guard let progress else { return } - if progress.totalUnitCount != total { - progress.totalUnitCount = total - } - progress.completedUnitCount = completed - - if completed > 0 { - guard let startTime else { - startTime = Date() - return - } - let elapsedTime = Date().timeIntervalSince(startTime) - // delay before we start calculating the estimated time - because initially it‘s not reliable - guard elapsedTime > Constants.remainingDownloadTimeEstimationDelay else { return } - - // calculate instantaneous download speed - var throughput = Double(completed) / elapsedTime - - // calculate the moving average of download speed - if let oldThroughput = progress.throughput.map(Double.init) { - throughput = Constants.downloadSpeedSmoothingFactor * throughput + (1 - Constants.downloadSpeedSmoothingFactor) * oldThroughput - } - progress.throughput = Int(throughput) - } - if total > 0, completed > 0, let throughput = progress.throughput { - progress.estimatedTimeRemaining = Double(total - completed) / Double(throughput) - } + @MainActor func subscribeToTempFileURL(_ tempFilePresenter: FilePresenter?) { + tempFileUrlCancellable = (tempFilePresenter?.urlPublisher ?? Just(nil).eraseToAnyPublisher()) + .sink { [weak self] url in + Task { [weak self] in + await self?.tempFileUrlUpdated(to: url) } - } + } } - private func localFileURLCompletionHandler(localURL: URL?, fileType: UTType?) { - dispatchPrecondition(condition: .onQueue(.main)) + /// Observe `.duckload` file moving and renaming, update the file progress and rename destination file if needed + private nonisolated func tempFileUrlUpdated(to url: URL?) async { + let (state, itemReplacementDirectory) = await MainActor.run { + // display file progress and fly-to-dock animation + // don‘t display progress in itemReplacementDirectory + if let url, self.itemReplacementDirectory == nil || !url.path.hasPrefix(self.itemReplacementDirectory!.path) { + self.fileProgressPresenter?.displayFileProgress(at: url) + } else { + self.fileProgressPresenter?.displayFileProgress(at: nil) + } + + return (self.state, self.itemReplacementDirectory) + } + + /// if user has renamed the `.duckload` file - also rename the destination file + guard let destinationFilePresenter = state.destinationFilePresenter, + let destinationURL = destinationFilePresenter.url, + let newDestinationURL = state.tempFilePresenter?.url.flatMap({ tempFileURL -> URL? in + guard itemReplacementDirectory == nil || !tempFileURL.path.hasPrefix(itemReplacementDirectory!.path) else { return nil } + + // drop `duckload` file extension (if it‘s still there) and append the original one + let newFileName = tempFileURL.lastPathComponent.dropping(suffix: "." + Self.downloadExtension).appendingPathExtension(destinationURL.pathExtension) + return destinationURL.deletingLastPathComponent().appendingPathComponent(newFileName) + }), + destinationURL != newDestinationURL else { try? await Task.sleep(interval: 1); return } do { - guard let localURL = localURL, - let completionHandler = self.decideDestinationCompletionHandler - else { throw URLError(.cancelled) } + os_log(.debug, log: log, "renaming destination file \"\(destinationURL.path)\" ➡️ \"\(destinationURL.path)\"") + try destinationFilePresenter.coordinateMove(to: newDestinationURL, with: []) { from, to in + try FileManager.default.moveItem(at: from, to: to) + destinationFilePresenter.presentedItemDidMove(to: newDestinationURL) // coordinated File Presenter won‘t receive URL updates + } + } catch { + os_log(.debug, log: log, "renaming file failed: \(error)") + } + } - self.location = try self.downloadLocation(for: localURL) + /// called at `WKDownload`s `decideDestination` completion callback with selected URL (or `nil` if cancelled) + private enum DestinationCleanupStyle { case remove, clear } + private nonisolated func prepareChosenDestinationURL(_ destinationURL: URL?, fileType _: UTType?, cleanupStyle: DestinationCleanupStyle) async -> URL? { + do { + let fm = FileManager() + guard let destinationURL else { throw URLError(.cancelled) } + os_log(.debug, log: log, "download task callback: creating temp directory for \"\(destinationURL.path)\"") + + switch cleanupStyle { + case .remove: + // 1. remove the destination file if exists – that would clear existing downloads file presenters and stop downloads accordingly (if any) + try NSFileCoordinator().coordinateWrite(at: destinationURL, with: .forDeleting) { url in + if !fm.fileExists(atPath: url.path) { + // validate we can write to the directory even if there‘s no existing file + try Data().write(to: destinationURL) + } + os_log(.debug, log: log, "🧹 removing \"\(url.path)\"") + try fm.removeItem(at: url) + } + case .clear: + // 2. the download is “resumed” to existing destinationURL – clear it. + try Data().write(to: destinationURL) + } + + // 2. start downloading to a newly created same-volume temporary directory + let tempURL = try await setupTemporaryDownloadURL(for: destinationURL, fileAddedHandler: { [weak self] tempURL in + // keep the cancellable ref until File Presenters instantiation is finished + // - in case we receive an early `downloadDidFail:` + self?.itemReplacementDirectoryFSOCancellable?.cancel() + + // then move the file to the final destination + Task { [weak self] in + await self?.tempDownloadFileCreated(at: tempURL, destinationURL: destinationURL) + self?.itemReplacementDirectoryFSOCancellable = nil + } + }) - completionHandler(location.tempURL) + os_log(.debug, log: log, "download task callback: temp file: \(tempURL.path)") + return tempURL } catch { - self.download.cancel() - self.finish(with: .failure(.failedToCompleteDownloadTask(underlyingError: URLError(.cancelled), resumeData: nil, isRetryable: false))) - self.decideDestinationCompletionHandler?(nil) + await MainActor.run { + os_log(.error, log: log, "🛑 download task callback: \(self): \(error)") - Pixel.fire(.debug(event: .fileGetDownloadLocationFailed, error: error)) + self.download.cancel() + self.finish(with: .failure(.failedToCompleteDownloadTask(underlyingError: error, resumeData: nil, isRetryable: false))) + Pixel.fire(.debug(event: .fileGetDownloadLocationFailed, error: error)) + } + return nil } } - private func downloadLocation(for localURL: URL) throws -> FileLocation { - var downloadURL = self.location.tempURL ?? localURL.appendingPathExtension(Self.downloadExtension) - let downloadFilename = downloadURL.lastPathComponent - let ext = localURL.pathExtension + (localURL.pathExtension.isEmpty ? "" : ".") + Self.downloadExtension - var itemReplacementDirectory: URL? + /// create sandbox-accessible temporary directory on the same volume with the desired destination URL and notify on the download file creation + private nonisolated func setupTemporaryDownloadURL(for destinationURL: URL, fileAddedHandler: @escaping @MainActor (URL) -> Void) async throws -> URL { + let itemReplacementDirectory = try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: destinationURL, create: true) + let tempURL = itemReplacementDirectory.appendingPathComponent(destinationURL.lastPathComponent) + + // monitor our folder for download start + let fileDescriptor = open(itemReplacementDirectory.path, O_EVTONLY) + if fileDescriptor == -1 { + let err = errno + os_log(.error, log: log, "could not open \(itemReplacementDirectory.path): \(err) – \(String(cString: strerror(err)))") + throw NSError(domain: NSPOSIXErrorDomain, code: Int(err), userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(err))]) + } + let fileMonitor = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, eventMask: .write, queue: .main) + + // Set up a handler for file system events + fileMonitor.setEventHandler { + MainActor.assumeIsolated { // DispatchSource is set up with the main queue above + fileAddedHandler(tempURL) + } + } + await MainActor.run { + self.itemReplacementDirectory = itemReplacementDirectory + self.itemReplacementDirectoryFSOCancellable = AnyCancellable { + fileMonitor.cancel() + close(fileDescriptor) + } + } + + fileMonitor.resume() + + return tempURL + } + + /// when the download has started to a temporary directory, create a placeholder file at the destination URL and move the temp file to the destination directory as a `.duckload` + @MainActor + private func tempDownloadFileCreated(at tempURL: URL, destinationURL: URL) async { + os_log(.debug, log: log, "temp file created: \(self): \(tempURL.path)") - // create temp file and move to Downloads folder with .duckload extension increasing index if needed - let fm = FileManager.default - let tempURL = fm.temporaryDirectory.appendingPathComponent(.uniqueFilename()) do { - guard fm.createFile(atPath: tempURL.path, contents: nil, attributes: nil) else { - throw CocoaError(.fileWriteNoPermission) + let presenters = if case .downloading(destination: let destination, tempFile: let tempFile) = state { + // when “resuming” a non-resumable download - use the existing file presenters + try await self.reuseFilePresenters(tempFile: tempFile, destination: destination, tempURL: tempURL) + } else { + // instantiate File Presenters and move the temp file to the final destination directory + try await self.filePresenters(for: destinationURL, tempURL: tempURL) } + self.state = .downloading(destination: presenters.destinationFile, tempFile: presenters.tempFile) + + } catch { + os_log(.error, log: log, "🛑 file presenters failure: \(self): \(error)") + + self.download.cancel() + self.finish(with: .failure(.failedToCompleteDownloadTask(underlyingError: error, resumeData: nil, isRetryable: false))) + + Pixel.fire(.debug(event: .fileDownloadCreatePresentersFailed, error: error)) + } + } + + /// opens File Presenters for destination file and temp file + private nonisolated func filePresenters(for destinationURL: URL, tempURL: URL) async throws -> (tempFile: FilePresenter, destinationFile: FilePresenter) { + var destinationURL = destinationURL + let duckloadURL = destinationURL.deletingPathExtension().appendingPathExtension(Self.downloadExtension) + let fm = FileManager.default + + // 🧙‍♂️ now we‘re doing do some magique here 🧙‍♂️ + // -------------------------------------- + os_log(.debug, log: log, "🧙‍♂️ magique.start: \"\(destinationURL.path)\" (\"\(duckloadURL.path)\") directory writable: \(fm.isWritableFile(atPath: destinationURL.deletingLastPathComponent().path))") + // 1. create our final destination file (let‘s say myfile.zip) and setup a File Presenter for it + // doing this we preserve access to the file until it‘s actually downloaded + let destinationFilePresenter = try SandboxFilePresenter(url: destinationURL, consumeUnbalancedStartAccessingResource: true, logger: log) { url in + try fm.createFile(atPath: url.path, contents: nil) ? url : { + throw CocoaError(.fileWriteNoPermission, userInfo: [NSFilePathErrorKey: url.path]) + }() + } + if duckloadURL == destinationURL { + // corner-case when downloading a `.duckload` file - the source and destination files will be the same then + return try await reuseFilePresenters(tempFile: destinationFilePresenter, destination: destinationFilePresenter, tempURL: tempURL) + } + + // 2. mark the file as hidden until it‘s downloaded to not to confuse user + // and prevent from unintentional opening of the empty file + var resourceValues = URLResourceValues() + resourceValues.isHidden = true + try destinationURL.setResourceValues(resourceValues) + os_log(.debug, log: log, "🧙‍♂️ \"\(destinationURL.path)\" hidden, moving temp file from \"\(tempURL.path)\" to \"\(duckloadURL.path)\"") + + // 3. then we move the temporary download file to the destination directory (myfile.zip.duckload) + // this is doable in sandboxed builds by using “Related Items” i.e. using a file URL with an extra + // `.duckload` extension appended and “Primary Item” pointing to the sandbox-accessible destination URL + // the `.duckload` document type is registered in the Info.plist with `NSIsRelatedItemType` flag + // + // - after the file is downloaded we‘ll replace the destination file with the `.duckload` file + if fm.fileExists(atPath: duckloadURL.path) { + // remove the `.duckload` item if already exists do { - downloadURL = try fm.moveItem(at: tempURL, to: downloadURL, incrementingIndexIfExists: true, pathExtension: ext) - } catch CocoaError.fileWriteNoPermission { - // [Sandbox] we have no access to whole directory, only to the localURL - // ask system for a temp directory on destination volume so we can adjust file quarantine attributes inside of it - itemReplacementDirectory = try fm.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: localURL, create: true) - downloadURL = try fm.moveItem(at: tempURL, to: itemReplacementDirectory!.appendingPathComponent(downloadFilename), incrementingIndexIfExists: true) + try FilePresenter(url: duckloadURL, primaryItemURL: destinationURL).coordinateWrite(with: .forDeleting) { duckloadURL in + try fm.removeItem(at: duckloadURL) + } + } catch { + // that‘s ok, we‘ll keep using the original temp file + os_log(.error, log: log, "❗️ could not remove \"\(duckloadURL.path)\" \(error)") } - } catch CocoaError.fileWriteNoPermission { - try? fm.removeItem(at: tempURL) - downloadURL = localURL - // make sure we can write to the download location - guard fm.createFile(atPath: downloadURL.path, contents: nil, attributes: nil) else { - throw CocoaError(.fileWriteNoPermission) + } + // now move the temp file to `.duckload` instantiating a File Presenter with it + let tempFilePresenter = try SandboxFilePresenter(url: duckloadURL, primaryItemURL: destinationURL, logger: log) { [log] duckloadURL in + do { + try fm.moveItem(at: tempURL, to: duckloadURL) + } catch { + // fallback: move failed, keep the temp file in the original location + os_log(.error, log: log, "🙁 fallback with \(error), will use \(tempURL.path)") + Pixel.fire(.debug(event: .fileAccessRelatedItemFailed, error: error)) + return tempURL } + return duckloadURL } + os_log(.debug, log: log, "🧙‍♂️ \"\(duckloadURL.path)\" (\"\(tempFilePresenter.url?.path ?? "")\") ready") - // remove temp item and let WebKit download the file - try? fm.removeItem(at: downloadURL) + return (tempFile: tempFilePresenter, destinationFile: destinationFilePresenter) + } - return .init(destinationURL: localURL, tempURL: downloadURL, itemReplacementDirectory: itemReplacementDirectory) + private nonisolated func reuseFilePresenters(tempFile: FilePresenter, destination: FilePresenter, tempURL: URL) async throws -> (tempFile: FilePresenter, destinationFile: FilePresenter) { + // if the download is “resumed” as a new download (replacing the destination file) - + // use the existing `.duckload` file and move the temp file in its place + _=try tempFile.coordinateWrite(with: .forReplacing) { duckloadURL in + try FileManager.default.replaceItemAt(duckloadURL, withItemAt: tempURL) + } + + return (tempFile: tempFile, destinationFile: destination) } func cancel() { @@ -218,144 +409,263 @@ final class WebKitDownloadTask: NSObject, ProgressReporting, @unchecked Sendable } return } - download.cancel { [weak self] _ in - self?.downloadDidFail(with: URLError(.cancelled), resumeData: nil) + os_log(.debug, log: log, "cancel \(self)") + download.cancel { [weak self, log, taskDescr=self.debugDescription] resumeData in + os_log(.debug, log: log, "\(taskDescr): download.cancel callback") + DispatchQueue.main.asyncOrNow { + self?.downloadDidFail(with: URLError(.cancelled), resumeData: resumeData) + } } } - private func finish(with result: Result) { - dispatchPrecondition(condition: .onQueue(.main)) - guard let fulfill = self.fulfill else { - // already finished - return - } + @MainActor + private func finish(with result: Result) { + assert(state.isInitial || state.isDownloading) + fileProgressPresenter = nil + itemReplacementDirectoryFSOCancellable = nil // in case we‘ve failed without the temp file been created - if case .success(let url) = result { - let newLocation = FileLocation(destinationURL: url, tempURL: nil) - if self.location != newLocation { - self.location = newLocation + switch result { + case .success(let presenter): + let url = presenter.url + os_log(.debug, log: log, "finish \(self) with .success(\"\(url?.path ?? "")\")") + + if progress.totalUnitCount == -1 { + progress.totalUnitCount = max(1, self.progress.completedUnitCount) } - if progress.fileURL != url { - progress.fileURL = url + progress.completedUnitCount = progress.totalUnitCount + + self.state = .downloaded(presenter) + + case .failure(let error): + os_log(.debug, log: log, "finish \(self) with .failure(\(error))") + + self.state = .failed(destination: error.isRetryable ? self.state.destinationFilePresenter : nil, // stop tracking removed files for non-retryable downloads + tempFile: error.isRetryable ? self.state.tempFilePresenter : nil, + resumeData: error.resumeData, + error: error) + } + + self.delegate?.fileDownloadTask(self, didFinishWith: result.map { _ in }) + + // temp dir cleanup + if let itemReplacementDirectory, + // don‘t remove itemReplacementDirectory if we‘re keeping the temp file in it for a retryable error + self.state.tempFilePresenter?.url?.path.hasPrefix(itemReplacementDirectory.path) != true { + + DispatchQueue.global().async { [log, itemReplacementDirectory] in + os_log(.debug, log: log, "removing \"\(itemReplacementDirectory.path)\"") + try? FileManager.default.removeItem(at: itemReplacementDirectory) } - if self.progress.totalUnitCount == -1 { - self.progress.totalUnitCount = self.progress.completedUnitCount + self.itemReplacementDirectory = nil + } + } + + @MainActor + private func downloadDidFail(with error: Error, resumeData: Data?) { + guard case .downloading(destination: let destinationFile, tempFile: let tempFile) = self.state else { + // cancelled at early stage + if state.isInitial { + self.finish(with: .failure(.failedToCompleteDownloadTask(underlyingError: error, resumeData: nil, isRetryable: false))) + return } - self.progress.completedUnitCount = self.progress.totalUnitCount + os_log(.debug, log: log, "ignoring `cancel` for already completed task \(self)") + return } - self.progress.unpublishIfNeeded() + 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 + } else { + true + } + + os_log(.debug, log: log, "❗️ downloadDidFail \(self): \(error), retryable: \(isRetryable)") + self.finish(with: .failure(.failedToCompleteDownloadTask(underlyingError: error, resumeData: resumeData, isRetryable: isRetryable))) - self.delegate?.fileDownloadTask(self, didFinishWith: result) - self.fulfill = nil - fulfill(result) + if !isRetryable { + DispatchQueue.global().async { [log, itemReplacementDirectory] in + let fm = FileManager() + try? destinationFile.coordinateWrite(with: .forDeleting) { url in + os_log(.debug, log: log, "removing \"\(url.path)\"") + try fm.removeItem(at: url) + } + try? tempFile.coordinateWrite(with: .forDeleting) { url in + var url = url + // if temp file is still in the itemReplacementDirectory - remove the itemReplacementDirectory + if let itemReplacementDirectory, url.path.hasPrefix(itemReplacementDirectory.path) { + url = itemReplacementDirectory + } + os_log(.debug, log: log, "removing \"\(url.path)\"") + try fm.removeItem(at: url) + } + } + self.itemReplacementDirectory = nil + } } - private func downloadDidFail(with error: Error, resumeData: Data?) { - if resumeData == nil, - let tempURL = location.tempURL { - try? FileManager.default.removeItem(at: tempURL) - if let itemReplacementDirectory = location.itemReplacementDirectory { - try? FileManager.default.removeItem(at: itemReplacementDirectory) + /// when `downloadDidFinish` or `downloadDidFail` callback is received before File Presenters finish initialization - + /// we wait for the `state` to switch to `.downloading` and re-call the callback + private func waitForDownloadDidStart(completionHandler: @escaping @MainActor () -> Void) { + var cancellable: AnyCancellable? + cancellable = $state.receive(on: DispatchQueue.main).sink { state in + withExtendedLifetime(cancellable) { + switch state { + case .initial: return + case .downloading: + MainActor.assumeIsolated(completionHandler) + cancellable = nil + case .downloaded: + pixelAssertionFailure("unexpected state change to \(state)") + fallthrough + case .failed: + // something went wrong while initializing File Presenters, but we‘re already completed + cancellable = nil + } } } - self.finish(with: .failure(.failedToCompleteDownloadTask(underlyingError: error, - resumeData: resumeData, - isRetryable: location.destinationURL != nil))) } deinit { - dispatchPrecondition(condition: .onQueue(.main)) - self.progress.unpublishIfNeeded() - assert(fulfill == nil, "FileDownloadTask is deallocated without finish(with:) been called") + @MainActor(unsafe) + func performRegardlessOfMainThread() { + os_log(.debug, log: log, ".deinit") + assert(state.isCompleted, "FileDownloadTask is deallocated without finish(with:) been called") + } + performRegardlessOfMainThread() } } extension WebKitDownloadTask: WKDownloadDelegate { - func download(_: WKDownload, - decideDestinationUsing response: URLResponse, - suggestedFilename: String, - completionHandler: @escaping (URL?) -> Void) { + @MainActor + func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String) async -> URL? { + os_log(.debug, log: log, "decide destination \(self)") guard let delegate = delegate else { assertionFailure("WebKitDownloadTask: delegate is gone") - completionHandler(nil) - return + return nil } - if var mimeType = response.mimeType { + let suggestedFileType: UTType? = { + guard var mimeType = response.mimeType else { return nil } // drop ;charset=.. from "text/plain;charset=utf-8" if let charsetRange = mimeType.range(of: ";charset=") { mimeType = String(mimeType[.. Void) { + os_log(log: log, "will perform HTTP redirection \(self): \(response) to \(request)") decisionHandler(.allow) } + @MainActor func download(_ download: WKDownload, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + os_log(log: log, "did receive challenge \(self): \(challenge)") download.webView?.navigationDelegate?.webView?(download.webView!, didReceive: challenge, completionHandler: completionHandler) ?? { completionHandler(.performDefaultHandling, nil) }() } - func downloadDidFinish(_: WKDownload) { - guard var destinationURL = location.destinationURL else { - self.finish(with: .failure(.failedToMoveFileToDownloads)) + @MainActor + func downloadDidFinish(_ download: WKDownload) { + let fm = FileManager.default + + guard case .downloading(destination: let destinationFile, tempFile: let tempFile) = self.state else { + // if we receive `downloadDidFinish:` before the File Presenters are set up (async) + // - we‘ll be waiting for the `.downloading` state to come in with the presenters + os_log(log: log, "🏁 download did finish too early, we‘ll wait for the `.downloading` state: \(self)") + assert(itemReplacementDirectory != nil, "itemReplacementDirectory should be set") + waitForDownloadDidStart { [weak self] in + self?.downloadDidFinish(download) + } return } - // set quarantine attributes - try? (location.tempURL ?? destinationURL).setQuarantineAttributes(sourceURL: originalRequest?.url, referrerURL: originalRequest?.mainDocumentURL) + os_log(log: log, "🏁 download did finish: \(self)") - if let tempURL = location.tempURL, tempURL != destinationURL { - do { - destinationURL = try FileManager.default.moveItem(at: tempURL, to: destinationURL, incrementingIndexIfExists: true) - } catch { - Pixel.fire(.debug(event: .fileMoveToDownloadsFailed, error: error)) - destinationURL = tempURL + do { + try tempFile.coordinateWrite(with: .forMoving) { tempURL in + // replace destination file with temp file + try destinationFile.coordinateWrite(with: .forReplacing) { [log] destinationURL in + if destinationURL != tempURL { // could be a corner-case when downloading a `.duckload` file + os_log(.debug, log: log, "replacing \"\(destinationURL.path)\" with \"\(tempURL.path)\"") + _=try fm.replaceItemAt(destinationURL, withItemAt: tempURL) + } + // set quarantine attributes + try? destinationURL.setQuarantineAttributes(sourceURL: originalRequest?.url, referrerURL: originalRequest?.mainDocumentURL) + } } - } - if let itemReplacementDirectory = location.itemReplacementDirectory { - try? FileManager.default.removeItem(at: itemReplacementDirectory) - } + // remove temp file item replacement directory if present + if let itemReplacementDirectory { + os_log(.debug, log: log, "removing \(itemReplacementDirectory.path)") + try? fm.removeItem(at: itemReplacementDirectory) + self.itemReplacementDirectory = nil + } + self.finish(with: .success(destinationFile)) - self.finish(with: .success(destinationURL)) + } catch { + Pixel.fire(.debug(event: .fileMoveToDownloadsFailed, error: error)) + os_log(.error, log: log, "fileMoveToDownloadsFailed: \(error)") + self.finish(with: .failure(.failedToCompleteDownloadTask(underlyingError: error, resumeData: nil, isRetryable: false))) + } } + @MainActor func download(_: WKDownload, didFailWithError error: Error, resumeData: Data?) { + // if we receive `downloadDidFail:` before File Presenters are set up (async) + // - we‘ll be waiting for the `.downloading` state to come in with the presenters + guard state.isDownloading + // in case the File Presenters instantiation task is still running - we‘ll either receive the `state` change + // or the task will be finished with a file error + || itemReplacementDirectoryFSOCancellable == nil else { + os_log(log: log, "❌ download did fail too early, we‘ll wait for the `.downloading` state: \(self)") + waitForDownloadDidStart { [weak self] in + self?.downloadDidFail(with: error, resumeData: resumeData) + } + return + } downloadDidFail(with: error, resumeData: resumeData) } @@ -364,18 +674,66 @@ extension WebKitDownloadTask: WKDownloadDelegate { extension WebKitDownloadTask { var didChooseDownloadLocationPublisher: AnyPublisher { - Publishers.Merge( - $location - // waiting for the download location to be chosen - .compactMap { $0.destinationURL } - .mapError { (_: Never) -> FileDownloadError in } - .eraseToAnyPublisher(), - // downloadTask.output Publisher will complete with an error if cancelled - output.eraseToAnyPublisher() - ) - // complete the Publisher when the location is chosen + $state.tryCompactMap { state in + switch state { + case .initial: + return nil + case .downloading(destination: let destinationFile, _), + .downloaded(let destinationFile): + return destinationFile.url + case .failed(_, _, resumeData: _, error: let error): + throw error + } + } + .mapError { $0 as! FileDownloadError } // swiftlint:disable:this force_cast .first() .eraseToAnyPublisher() } } + +extension WebKitDownloadTask { + + override var description: String { + guard Thread.isMainThread else { +#if DEBUG + os_log(""" + + + ------------------------------------------------------------------------------------------------------ + BREAK: + ------------------------------------------------------------------------------------------------------ + + ❗️accessing WebKitDownloadTask.description from non-main thread + + Hit Continue (^⌘Y) to continue program execution + ------------------------------------------------------------------------------------------------------ + + """, type: .fault) + raise(SIGINT) +#endif + return "" + } + return MainActor.assumeIsolated { + "" + } + } + +} + +extension WebKitDownloadTask.FileDownloadState: CustomDebugStringConvertible { + + var debugDescription: String { + switch self { + case .initial(let destination): + ".initial(\(destination))" + case .downloading(destination: let destination, tempFile: let tempFile): + ".downloading(dest: \"\(destination.url?.path ?? "")\", temp: \"\(tempFile.url?.path ?? "")\")" + case .downloaded(let destination): + ".downloaded(dest: \"\(destination.url?.path ?? "")\")" + case .failed(destination: let destination, tempFile: let tempFile, resumeData: let resumeData, error: let error): + ".failed(dest: \"\(destination?.url?.path ?? "")\", temp: \"\(tempFile?.url?.path ?? "")\", resumeData: \(resumeData?.description ?? "") error: \(error))" + } + } + +} diff --git a/DuckDuckGo/FileDownload/Services/DownloadListCoordinator.swift b/DuckDuckGo/FileDownload/Services/DownloadListCoordinator.swift index 880ef07041..a7d0b3e1d3 100644 --- a/DuckDuckGo/FileDownload/Services/DownloadListCoordinator.swift +++ b/DuckDuckGo/FileDownload/Services/DownloadListCoordinator.swift @@ -45,9 +45,12 @@ final class DownloadListCoordinator { private var items = [UUID: DownloadListItem]() private var downloadsCancellable: AnyCancellable? - private var downloadTaskCancellables = [WebKitDownloadTask: Set]() + private var downloadTaskCancellables = [WebKitDownloadTask: AnyCancellable]() private var taskProgressCancellables = [WebKitDownloadTask: Set]() + private var filePresenters = [UUID: (destination: FilePresenter?, tempFile: FilePresenter?)]() + private var filePresenterCancellables = [UUID: Set]() + enum UpdateKind { case added case removed @@ -58,35 +61,51 @@ final class DownloadListCoordinator { let progress = Progress() + private let getLogger: (() -> OSLog) + private var log: OSLog { + getLogger() + } + init(store: DownloadListStoring = DownloadListStore(), downloadManager: FileDownloadManagerProtocol = FileDownloadManager.shared, clearItemsOlderThan clearDate: Date = .daysAgo(2), - webViewProvider: (() -> WKWebView?)? = nil) { + webViewProvider: (() -> WKWebView?)? = nil, + log: @autoclosure @escaping (() -> OSLog) = .downloads) { self.store = store self.downloadManager = downloadManager self.webViewProvider = webViewProvider + self.getLogger = log load(clearingItemsOlderThan: clearDate) subscribeToDownloadManager() } private func load(clearingItemsOlderThan clearDate: Date) { - store.fetch(clearingItemsOlderThan: clearDate) { [weak self] result in + store.fetch { [weak self] result in // WebKitDownloadTask should be used from the Main Thread (even in callbacks: see a notice below) dispatchPrecondition(condition: .onQueue(.main)) - guard let self = self else { return } switch result { case .success(let items): + os_log(.error, log: log, "coordinator: loaded \(items.count) items") for item in items { - self.items[item.identifier] = item - self.updatesSubject.send((.added, item)) + do { + try add(item, ifModifiedLaterThan: clearDate) + } catch { + os_log(.debug, log: self.log, "❗️ coordinator: drop item \(item.identifier): \(error)") + // remove item from db removing temp files if needed without sending a `.removed` update + cleanupTempFiles(for: item) + filePresenters[item.identifier] = nil + filePresenterCancellables[item.identifier] = nil + self.items[item.identifier] = nil + store.remove(item) + } } case .failure(let error): - os_log("Cleaning and loading of downloads failed: %s", log: .history, type: .error, error.localizedDescription) + os_log(.error, log: log, "coordinator: loading failed: \(error)") } } } @@ -101,6 +120,80 @@ final class DownloadListCoordinator { } } + private enum FileAddError: Error { + case urlIsNil + case fileInTrash + case noDestinationUrl + case itemOutdated + } + private func add(_ item: DownloadListItem, ifModifiedLaterThan minModificationDate: Date) throws { + var item = item + let modified = item.modified // setting error would reset `.modified` + if item.tempURL != nil, item.error == nil { + // initially loaded item with non-nil `tempURL` means the browser was terminated without writing a cancellation error + item.error = .failedToCompleteDownloadTask(underlyingError: URLError(.cancelled), resumeData: nil, isRetryable: false) + } + self.items[item.identifier] = item + + let presenters = try setupFilePresenters(for: item) + + // clear old downloads + guard modified > minModificationDate else { throw FileAddError.itemOutdated } + guard let destinationFilePresenter = try presenters.destination.get() else { throw FileAddError.noDestinationUrl } + + self.subscribeToPresenters((destination: destinationFilePresenter, tempFile: try? presenters.tempFile.get()), of: item) + self.updatesSubject.send((.added, item)) + } + + // swiftlint:disable:next cyclomatic_complexity + private func setupFilePresenters(for item: DownloadListItem) throws -> (destination: Result, tempFile: Result) { + let fm = FileManager.default + + // locate destination file + let destinationPresenterResult = Result { + if let destinationFileBookmarkData = item.destinationFileBookmarkData { + try SandboxFilePresenter(fileBookmarkData: destinationFileBookmarkData, logger: log) + } else if let destinationURL = item.destinationURL { + try SandboxFilePresenter(url: destinationURL, logger: log) + } else { + nil + } + } + + // locate temp download file + var tempFilePresenterResult = Result { + if let tempFileBookmarkData = item.tempFileBookmarkData { + try SandboxFilePresenter(fileBookmarkData: tempFileBookmarkData, logger: log) + } else if let tempURL = item.tempURL { + try SandboxFilePresenter(url: tempURL, logger: log) + } else { + nil + } + } + // corner-case when downloading a `.duckload` file - the source and destination files will be the same then + if (try? tempFilePresenterResult.get()?.url) == (try? destinationPresenterResult.get()?.url) { + tempFilePresenterResult = destinationPresenterResult + } + self.filePresenters[item.identifier] = (destination: try? destinationPresenterResult.get(), tempFile: try? tempFilePresenterResult.get()) + + // validate file exists and not in the Trash + for result in [destinationPresenterResult, tempFilePresenterResult] { + guard let presenter = try? result.get() else { continue } + + // presented file should have URL + guard let url = presenter.url else { throw FileAddError.urlIsNil } + // presented file should exist + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: url.path, isDirectory: &isDirectory) else { throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: url.path]) } + // the file should not be in the Trash + guard !fm.isInTrash(url) else { throw FileAddError.fileInTrash } + // it should be a file, not a directory + guard !isDirectory.boolValue else { throw CocoaError(.fileNoSuchFile, userInfo: [NSFilePathErrorKey: url.path]) } + } + + return (destinationPresenterResult, tempFilePresenterResult) + } + @MainActor(unsafe) private func subscribeToDownloadTask(_ task: WebKitDownloadTask, updating item: DownloadListItem? = nil) { dispatchPrecondition(condition: .onQueue(.main)) @@ -116,34 +209,107 @@ final class DownloadListCoordinator { guard downloadTaskCancellables[task] == nil else { return } let item = item ?? DownloadListItem(task: task) + os_log(.debug, log: log, "coordinator: subscribing to \(item.identifier)") - task.$location - // only add item to the dict when destination URL is set - .filter { $0.destinationURL != nil } - .sink { [weak self] location in + self.downloadTaskCancellables[task] = task.$state + .sink { [weak self] state in DispatchQueue.main.async { - self?.addItemOrUpdateLocation(for: item, destinationURL: location.destinationURL, tempURL: location.tempURL) + guard let self else { return } + os_log(.debug, log: self.log, "coordinator: state updated \(item.identifier) ➡️ \(state)") + switch state { + case .initial: + // only add item when download starts, destination URL is set + return + case .downloading(destination: let destination, tempFile: let tempFile): + self.addItemIfNeededAndSubscribe(to: (destination, tempFile), for: item) + case .downloaded(let destination): + let updatedItem = self.downloadTask(task, withId: item.identifier, completedWith: .finished) + self.subscribeToPresenters((destination: destination, tempFile: nil), of: updatedItem ?? item) + case .failed(destination: let destination, tempFile: let tempFile, resumeData: _, error: let error): + let updatedItem = self.downloadTask(task, withId: item.identifier, completedWith: .failure(error)) + self.subscribeToPresenters((destination: destination, tempFile: tempFile), of: updatedItem ?? item) + } } } - .store(in: &self.downloadTaskCancellables[task, default: []]) - - task.output - .sink { [weak self] completion in - DispatchQueue.main.async { - self?.downloadTask(task, withId: item.identifier, completedWith: completion) - } - } receiveValue: { _ in } - .store(in: &self.downloadTaskCancellables[task, default: []]) self.subscribeToProgress(of: task) } + @MainActor - private func addItemOrUpdateLocation(for initialItem: DownloadListItem, destinationURL: URL?, tempURL: URL?) { + private func addItemIfNeededAndSubscribe(to presenters: (destination: FilePresenter, tempFile: FilePresenter?), for initialItem: DownloadListItem) { + os_log(.debug, log: log, "coordinator: add/update \(initialItem.identifier)") updateItem(withId: initialItem.identifier) { item in if item == nil { item = initialItem } - item!.destinationURL = destinationURL - item!.tempURL = tempURL } + subscribeToPresenters(presenters, of: initialItem) + } + + private func subscribeToPresenters(_ presenters: (destination: FilePresenter?, tempFile: FilePresenter?), of item: DownloadListItem) { + var cancellables = Set() + filePresenters[item.identifier] = presenters + + Publishers.CombineLatest( + presenters.destination?.urlPublisher ?? Just(nil).eraseToAnyPublisher(), + (presenters.destination as? SandboxFilePresenter)?.fileBookmarkDataPublisher ?? Just(nil).eraseToAnyPublisher() + ) + .scan((oldURL: nil, newURL: nil, fileBookmarkData: nil)) { (oldURL: $0.newURL, newURL: $1.0, fileBookmarkData: $1.1) } + .sink { [weak self] oldURL, newURL, fileBookmarkData in + DispatchQueue.main.asyncOrNow { + self?.updateItem(withId: item.identifier) { [id=item.identifier, log=(self?.log ?? .disabled)] item in + guard !Self.checkIfFileWasRemoved(oldURL: oldURL, newURL: newURL) else { + os_log(.debug, log: log, "coordinator: destination file removed \(id)") + item = nil + return + } + + os_log(.debug, log: log, "⚠️coordinator: destination url updated \(id): \"\(newURL?.path ?? "")\"") + item?.destinationURL = newURL + item?.destinationFileBookmarkData = fileBookmarkData + // keep the filename even when the destinationURL is nullified + let fileName = if let lastPathComponent = newURL?.lastPathComponent, !lastPathComponent.isEmpty { + lastPathComponent + } else { + item?.fileName ?? "" + } + item?.fileName = fileName + } + } + } + .store(in: &cancellables) + + Publishers.CombineLatest( + presenters.tempFile?.urlPublisher ?? Just(nil).eraseToAnyPublisher(), + (presenters.tempFile as? SandboxFilePresenter)?.fileBookmarkDataPublisher ?? Just(nil).eraseToAnyPublisher() + ) + .scan((oldURL: nil, newURL: nil, fileBookmarkData: nil)) { (oldURL: $0.newURL, newURL: $1.0, fileBookmarkData: $1.1) } + .sink { [weak self] oldURL, newURL, fileBookmarkData in + DispatchQueue.main.asyncOrNow { + self?.updateItem(withId: item.identifier) { [id=item.identifier, log=(self?.log ?? .disabled)] item in + guard !Self.checkIfFileWasRemoved(oldURL: oldURL, newURL: newURL) else { + os_log(.debug, log: log, "coordinator: temp file removed \(id)") + item = nil + return + } + + os_log(.debug, log: log, "coordinator: temp url updated \(id): \"\(newURL?.path ?? "")\"") + item?.tempURL = newURL + item?.tempFileBookmarkData = fileBookmarkData + } + } + } + .store(in: &cancellables) + + filePresenterCancellables[item.identifier] = cancellables + } + + private static func checkIfFileWasRemoved(oldURL: URL?, newURL: URL?) -> Bool { + // if the file was removed by user after the download has failed or finished + let fm = FileManager.default + if oldURL != nil, // it should‘ve been there when we started observing but removed/trashed after + newURL == nil || newURL.map({ !fm.fileExists(atPath: $0.path) || fm.isInTrash($0) }) == true { + return true + } + return false } private func subscribeToProgress(of task: WebKitDownloadTask) { @@ -162,41 +328,48 @@ final class DownloadListCoordinator { } .store(in: &self.taskProgressCancellables[task, default: []]) - task.output.receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { return } + task.$state.receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self, state.isCompleted else { return } + os_log(.debug, log: log, "coordinator: unsubscribe from progress: \(task)") progress.completedUnitCount -= lastKnownProgress.completed progress.totalUnitCount -= lastKnownProgress.total taskProgressCancellables[task] = nil - - } receiveValue: { _ in } + } .store(in: &self.taskProgressCancellables[task, default: []]) } @MainActor - private func downloadTask(_ task: WebKitDownloadTask, withId identifier: UUID, completedWith result: Subscribers.Completion) { - updateItem(withId: identifier) { item in + private func downloadTask(_ task: WebKitDownloadTask, withId identifier: UUID, completedWith result: Subscribers.Completion) -> DownloadListItem? { + os_log(.debug, log: log, "coordinator: task did finish \(identifier) \(task) with .\(result)") + + self.downloadTaskCancellables[task] = nil + + // item will be really updated (completed) only if it was added before in `addItemOrUpdateFilePresenter` (when state switched to .downloading) + // if it has failed without starting - it won‘t be added or updated here + return updateItem(withId: identifier) { item in if item?.isBurner ?? false { item = nil return } + item?.progress = nil if case .failure(let error) = result { item?.error = error + } else { + item?.tempURL = nil } - item?.progress = nil } - - self.downloadTaskCancellables[task] = nil } @MainActor - private func updateItem(withId identifier: UUID, mutate: (inout DownloadListItem?) -> Void) { + @discardableResult + private func updateItem(withId identifier: UUID, mutate: (inout DownloadListItem?) -> Void) -> DownloadListItem? { let original = self.items[identifier] var modified = original mutate(&modified) - guard modified != original else { return } + guard modified?.modified != original?.modified else { return modified } self.items[identifier] = modified @@ -210,24 +383,56 @@ final class DownloadListCoordinator { self.updatesSubject.send((.updated, item)) store.save(item) case (.some(let item), .none): - self.updatesSubject.send((.removed, item)) + item.progress?.cancel() + if original != nil { + self.updatesSubject.send((.removed, item)) + } + cleanupTempFiles(for: item) + filePresenters[item.identifier] = nil + filePresenterCancellables[item.identifier] = nil store.remove(item) } + return modified + } + + private func cleanupTempFiles(for item: DownloadListItem) { + let fm = FileManager.default + do { + try filePresenters[item.identifier]?.tempFile?.coordinateWrite(with: .forDeleting, using: { url in + os_log(.debug, log: self.log, "🦀 coordinator: removing \"\(url.path)\" (\(item.identifier))") + try fm.removeItem(at: url) + }) + } catch { + os_log(.error, log: self.log, "🦀 coordinator: failed to remove temp file: \(error)") + } + + struct DestinationFileNotEmpty: Error {} + do { + guard let presenter = filePresenters[item.identifier]?.destination, + (try? presenter.url?.resourceValues(forKeys: [.fileSizeKey]).fileSize) == 0 else { throw DestinationFileNotEmpty() } + try presenter.coordinateWrite(with: .forDeleting, using: { url in + os_log(.debug, log: self.log, "🦀 coordinator: removing \"\(url.path)\" (\(item.identifier))") + try fm.removeItem(at: url) + }) + } catch is DestinationFileNotEmpty { + // don‘t delete non-empty destination file + } catch { + os_log(.error, log: self.log, "🦀 coordinator: failed to remove destination file: \(error)") + } } - private func downloadRestartedCallback(for item: DownloadListItem, webView: WKWebView) -> (WebKitDownload) -> Void { - return { download in + private func downloadRestartedCallback(for item: DownloadListItem, webView: WKWebView, presenters: (destination: FilePresenter, tempFile: FilePresenter)?) -> @MainActor (WebKitDownload) -> Void { + return { @MainActor download in // Important: WebKitDownloadTask (as well as WKWebView) should be deallocated on the Main Thread dispatchPrecondition(condition: .onQueue(.main)) withExtendedLifetime(webView) { - guard let destinationURL = item.destinationURL else { - assertionFailure("trying to restart download with destinationURL not set") - return + os_log(.debug, log: self.log, "coordinator: restarting \(item.identifier): \(download)") + let destination: WebKitDownloadTask.DownloadDestination = if let presenters { + .resume(destination: presenters.destination, tempFile: presenters.tempFile) + } else { + .auto } - - let task = self.downloadManager.add(download, - fromBurnerWindow: item.isBurner, - location: .preset(destinationURL: destinationURL, tempURL: item.tempURL)) + let task = self.downloadManager.add(download, fromBurnerWindow: item.isBurner, destination: destination) self.subscribeToDownloadTask(task, updating: item) } } @@ -239,12 +444,6 @@ final class DownloadListCoordinator { !downloadTaskCancellables.isEmpty } - var mostRecentModification: Date? { - return items.values.max { a, b in - a.modified < b.modified - }?.modified - } - @MainActor func downloads(sortedBy keyPath: KeyPath, ascending: Bool) -> [DownloadListItem] { let comparator: (T, T) -> Bool = ascending ? (<) : (>) @@ -259,57 +458,79 @@ final class DownloadListCoordinator { @MainActor func restart(downloadWithIdentifier identifier: UUID) { + os_log(.debug, log: self.log, "coordinator: restart \(identifier)") guard let item = items[identifier], let webView = (webViewProvider ?? getFirstAvailableWebView)() else { return } do { - guard let resumeData = item.error?.resumeData, - let tempURL = item.tempURL, - FileManager.default.fileExists(atPath: tempURL.path), - item.destinationURL != nil - else { - struct ThrowableError: Error {} - throw ThrowableError() + guard var resumeData = item.error?.resumeData, + case .some((destination: .some(let destination), tempFile: .some(let tempFile))) = filePresenters[item.identifier], + let tempURL = tempFile.url else { + struct NoResumeData: Error {} + throw NoResumeData() + } + + do { + var downloadResumeData = try DownloadResumeData(resumeData: resumeData) + if downloadResumeData.localPath != tempURL.path { + downloadResumeData.localPath = tempURL.path + downloadResumeData.tempFileName = tempURL.lastPathComponent + resumeData = try downloadResumeData.data() + } + } catch { + assertionFailure("Resume data coding failed: \(error)") + Pixel.fire(.debug(event: .downloadResumeDataCodingFailed, error: error)) } - webView.resumeDownload(fromResumeData: resumeData, completionHandler: self.downloadRestartedCallback(for: item, webView: webView)) + + webView.resumeDownload(fromResumeData: resumeData, + completionHandler: self.downloadRestartedCallback(for: item, + webView: webView, + presenters: (destination: destination, tempFile: tempFile))) } catch { + let presenters: (destination: FilePresenter, tempFile: FilePresenter)? = if case .some((destination: .some(let destination), tempFile: .some(let tempFile))) = filePresenters[item.identifier] { + (destination, tempFile) + } else { + nil + } let request = item.createRequest() - webView.startDownload(using: request, completionHandler: self.downloadRestartedCallback(for: item, webView: webView)) + webView.startDownload(using: request, completionHandler: self.downloadRestartedCallback(for: item, webView: webView, presenters: presenters)) } } @MainActor func cleanupInactiveDownloads() { + os_log(.debug, log: self.log, "coordinator: cleanupInactiveDownloads") + for (id, item) in self.items where item.progress == nil { - self.items[id] = nil - self.updatesSubject.send((.removed, item)) + remove(downloadWithIdentifier: id) } - - store.clear() } @MainActor func cleanupInactiveDownloads(for baseDomains: Set, tld: TLD) { + os_log(.debug, log: self.log, "coordinator: cleanupInactiveDownloads for \(baseDomains)") + for (id, item) in self.items where item.progress == nil { let websiteUrlBaseDomain = tld.eTLDplus1(item.websiteURL?.host) ?? "" - let itemUrlBaseDomain = tld.eTLDplus1(item.url.host) ?? "" + let itemUrlBaseDomain = tld.eTLDplus1(item.downloadURL.host) ?? "" if baseDomains.contains(websiteUrlBaseDomain) || baseDomains.contains(itemUrlBaseDomain) { - self.items[id] = nil - self.updatesSubject.send((.removed, item)) - store.remove(item) + remove(downloadWithIdentifier: id) } } } @MainActor func remove(downloadWithIdentifier identifier: UUID) { + os_log(.debug, log: self.log, "coordinator: remove \(identifier)") + updateItem(withId: identifier) { item in - item?.progress?.cancel() item = nil } } @MainActor func cancel(downloadWithIdentifier identifier: UUID) { + os_log(.debug, log: self.log, "coordinator: cancel \(identifier)") + guard let item = self.items[identifier] else { assertionFailure("Item with identifier \(identifier) not found") return @@ -330,17 +551,16 @@ private extension DownloadListItem { self.init(identifier: UUID(), added: now, modified: now, - url: task.originalRequest?.url ?? .blankPage, + downloadURL: task.originalRequest?.url ?? .blankPage, websiteURL: task.originalRequest?.mainDocumentURL, + fileName: "", progress: task.progress, isBurner: task.isBurner, - destinationURL: nil, - tempURL: nil, error: nil) } func createRequest() -> URLRequest { - var request = URLRequest(url: url) + var request = URLRequest(url: downloadURL) request.setValue(websiteURL?.absoluteString, forHTTPHeaderField: URLRequest.HeaderKey.referer.rawValue) return request } diff --git a/DuckDuckGo/FileDownload/Services/DownloadListStore.swift b/DuckDuckGo/FileDownload/Services/DownloadListStore.swift index 98d890e4a7..cec1d0e0da 100644 --- a/DuckDuckGo/FileDownload/Services/DownloadListStore.swift +++ b/DuckDuckGo/FileDownload/Services/DownloadListStore.swift @@ -16,27 +16,23 @@ // limitations under the License. // -import Foundation -import CoreData import Combine +import Common +import CoreData +import Foundation import UniformTypeIdentifiers protocol DownloadListStoring { - func fetch(clearingItemsOlderThan date: Date, completionHandler: @escaping (Result<[DownloadListItem], Error>) -> Void) + func fetch(completionHandler: @escaping @MainActor (Result<[DownloadListItem], Error>) -> Void) func save(_ item: DownloadListItem, completionHandler: ((Error?) -> Void)?) func remove(_ item: DownloadListItem, completionHandler: ((Error?) -> Void)?) - func clear(itemsOlderThan date: Date, completionHandler: ((Error?) -> Void)?) func sync() } extension DownloadListStoring { - func clear() { - clear(itemsOlderThan: .distantFuture, completionHandler: nil) - } - func remove(_ item: DownloadListItem) { remove(item, completionHandler: nil) } @@ -105,17 +101,12 @@ final class DownloadListStore: DownloadListStoring { } } - func clear(itemsOlderThan date: Date, completionHandler: ((Error?) -> Void)?) { - remove(itemsWithPredicate: NSPredicate(format: (\DownloadManagedObject.modified)._kvcKeyPathString! + " < %@", date as NSDate), - completionHandler: completionHandler) - } - func remove(_ item: DownloadListItem, completionHandler: ((Error?) -> Void)?) { remove(itemsWithPredicate: NSPredicate(format: (\DownloadManagedObject.identifier)._kvcKeyPathString! + " == %@", item.identifier as CVarArg), completionHandler: completionHandler) } - func fetch(completionHandler: @escaping (Result<[DownloadListItem], Error>) -> Void) { + func fetch(completionHandler: @escaping @MainActor (Result<[DownloadListItem], Error>) -> Void) { guard let context = self.context else { return } func mainQueueCompletion(_ result: Result<[DownloadListItem], Error>) { @@ -137,12 +128,6 @@ final class DownloadListStore: DownloadListStoring { } } - func fetch(clearingItemsOlderThan date: Date, completionHandler: @escaping (Result<[DownloadListItem], Error>) -> Void) { - clear(itemsOlderThan: date) { _ in - self.fetch(completionHandler: completionHandler) - } - } - func save(_ item: DownloadListItem, completionHandler: ((Error?) -> Void)?) { guard let context = self.context else { return } @@ -212,17 +197,20 @@ extension DownloadListItem { } let error = (managedObject.errorEncrypted as? NSError).map { nsError in - FileDownloadError(nsError, isRetryable: managedObject.destinationURLEncrypted as? URL != nil) + FileDownloadError(nsError) } + let destinationURL = managedObject.destinationURLEncrypted as? URL self.init(identifier: identifier, added: added, modified: modified, - url: url, + downloadURL: url, websiteURL: managedObject.websiteURLEncrypted as? URL, + fileName: managedObject.filenameEncrypted as? String ?? destinationURL?.lastPathComponent ?? "", isBurner: false, - fileType: managedObject.fileType.flatMap(UTType.init(_:)), - destinationURL: managedObject.destinationURLEncrypted as? URL, + destinationURL: destinationURL, + destinationFileBookmarkData: managedObject.destinationFileBookmarkDataEncrypted as? Data, tempURL: managedObject.tempURLEncrypted as? URL, + tempFileBookmarkData: managedObject.tempFileBookmarkDataEncrypted as? Data, error: error) } @@ -239,13 +227,15 @@ extension DownloadManagedObject { assert(identifier == item.identifier) assert(added == item.added) - urlEncrypted = item.url as NSURL + urlEncrypted = item.downloadURL as NSURL websiteURLEncrypted = item.websiteURL as NSURL? modified = item.modified - fileType = item.fileType?.identifier destinationURLEncrypted = item.destinationURL as NSURL? + destinationFileBookmarkDataEncrypted = item.destinationFileBookmarkData as NSData? tempURLEncrypted = item.tempURL as NSURL? + tempFileBookmarkDataEncrypted = item.tempFileBookmarkData as NSData? errorEncrypted = item.error as NSError? + filenameEncrypted = item.fileName as NSString } } diff --git a/DuckDuckGo/FileDownload/Services/Downloads.xcdatamodeld/.xccurrentversion b/DuckDuckGo/FileDownload/Services/Downloads.xcdatamodeld/.xccurrentversion new file mode 100644 index 0000000000..76ea78b17b --- /dev/null +++ b/DuckDuckGo/FileDownload/Services/Downloads.xcdatamodeld/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + Downloads 2.xcdatamodel + + diff --git a/DuckDuckGo/FileDownload/Services/Downloads.xcdatamodeld/Downloads 2.xcdatamodel/contents b/DuckDuckGo/FileDownload/Services/Downloads.xcdatamodeld/Downloads 2.xcdatamodel/contents new file mode 100644 index 0000000000..4f4faa6092 --- /dev/null +++ b/DuckDuckGo/FileDownload/Services/Downloads.xcdatamodeld/Downloads 2.xcdatamodel/contents @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DuckDuckGo/FileDownload/View/DownloadsViewController.swift b/DuckDuckGo/FileDownload/View/DownloadsViewController.swift index 985b8e28a0..fb33a0788d 100644 --- a/DuckDuckGo/FileDownload/View/DownloadsViewController.swift +++ b/DuckDuckGo/FileDownload/View/DownloadsViewController.swift @@ -75,7 +75,6 @@ final class DownloadsViewController: NSViewController { } override func viewWillAppear() { - viewModel.filterRemovedDownloads() downloadsCancellable = viewModel.$items .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) diff --git a/DuckDuckGo/FileDownload/View/NSAlert+ActiveDownloadsTermination.swift b/DuckDuckGo/FileDownload/View/NSAlert+ActiveDownloadsTermination.swift index 5843071f3d..876027b225 100644 --- a/DuckDuckGo/FileDownload/View/NSAlert+ActiveDownloadsTermination.swift +++ b/DuckDuckGo/FileDownload/View/NSAlert+ActiveDownloadsTermination.swift @@ -23,8 +23,8 @@ extension NSAlert { static func activeDownloadsTerminationAlert(for downloads: Set) -> NSAlert { assert(!downloads.isEmpty) - let activeDownload = downloads.first(where: { $0.location.destinationURL != nil }) - let firstFileName = activeDownload?.location.destinationURL?.lastPathComponent ?? activeDownload?.suggestedFilename ?? "" + let activeDownload = downloads.first(where: { $0.state.isDownloading }) + let firstFileName = activeDownload?.state.destinationFilePresenter?.url?.lastPathComponent ?? "" let andOthers = downloads.count > 1 ? UserText.downloadsActiveAlertMessageAndOthers : "" let alert = NSAlert() diff --git a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift index 6fb1869064..41c8bc3075 100644 --- a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift @@ -195,10 +195,12 @@ extension HomePage.Models { DataBrokerProtectionAppEvents().handleWaitlistInvitedNotification(source: .cardUI) #endif case .vpnThankYou: - guard let window = NSApp.keyWindow else { return } + guard let window = NSApp.keyWindow, + case .normal = NSApp.runType else { return } waitlistBetaThankYouPresenter.presentVPNThankYouPrompt(in: window) case .pirThankYou: - guard let window = NSApp.keyWindow else { return } + guard let window = NSApp.keyWindow, + case .normal = NSApp.runType else { return } waitlistBetaThankYouPresenter.presentPIRThankYouPrompt(in: window) } } diff --git a/DuckDuckGo/Info.plist b/DuckDuckGo/Info.plist index bdb809d70e..a521153891 100644 --- a/DuckDuckGo/Info.plist +++ b/DuckDuckGo/Info.plist @@ -32,6 +32,18 @@ LSHandlerRank Default + + CFBundleTypeExtensions + + duckload + + CFBundleTypeName + Incomplete download + CFBundleTypeRole + Editor + NSIsRelatedItemType + + CFBundleExecutable $(EXECUTABLE_NAME) diff --git a/DuckDuckGo/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index 15e6e5b723..2e6c0d2639 100644 --- a/DuckDuckGo/Menus/MainMenu.swift +++ b/DuckDuckGo/Menus/MainMenu.swift @@ -550,7 +550,7 @@ import SubscriptionUI toggleDownloadsShortcutMenuItem.title = LocalPinningManager.shared.shortcutTitle(for: .downloads) #if NETWORK_PROTECTION - if await DefaultNetworkProtectionVisibility().isVPNVisible() { + if DefaultNetworkProtectionVisibility().isVPNVisible() { toggleNetworkProtectionShortcutMenuItem.isHidden = false toggleNetworkProtectionShortcutMenuItem.title = LocalPinningManager.shared.shortcutTitle(for: .networkProtection) } else { diff --git a/DuckDuckGo/Preferences/Model/DownloadsPreferences.swift b/DuckDuckGo/Preferences/Model/DownloadsPreferences.swift index aae77c4a04..ce6962e4bf 100644 --- a/DuckDuckGo/Preferences/Model/DownloadsPreferences.swift +++ b/DuckDuckGo/Preferences/Model/DownloadsPreferences.swift @@ -165,13 +165,13 @@ final class DownloadsPreferences: ObservableObject { private var persistor: DownloadsPreferencesPersistor - static func defaultDownloadLocation() -> URL? { + static func defaultDownloadLocation(validate: Bool = true) -> URL? { let fileManager = FileManager.default let folders = fileManager.urls(for: .downloadsDirectory, in: .userDomainMask) guard let folderURL = folders.first, let resolvedURL = try? URL(resolvingAliasFileAt: folderURL), - fileManager.isWritableFile(atPath: resolvedURL.path) else { return nil } + fileManager.isWritableFile(atPath: resolvedURL.path) || !validate else { return nil } return resolvedURL } diff --git a/DuckDuckGo/Statistics/PixelEvent.swift b/DuckDuckGo/Statistics/PixelEvent.swift index 695cc12e88..96b9540699 100644 --- a/DuckDuckGo/Statistics/PixelEvent.swift +++ b/DuckDuckGo/Statistics/PixelEvent.swift @@ -303,7 +303,10 @@ extension Pixel { case fileStoreWriteFailed case fileMoveToDownloadsFailed + case fileAccessRelatedItemFailed case fileGetDownloadLocationFailed + case fileDownloadCreatePresentersFailed + case downloadResumeDataCodingFailed case suggestionsFetchFailed case appOpenURLFailed @@ -774,6 +777,12 @@ extension Pixel.Event.Debug { return "df" case .fileGetDownloadLocationFailed: return "dl" + case .fileAccessRelatedItemFailed: + return "dari" + case .fileDownloadCreatePresentersFailed: + return "dfpf" + case .downloadResumeDataCodingFailed: + return "drdc" case .suggestionsFetchFailed: return "sgf" diff --git a/DuckDuckGo/Statistics/PixelParameters.swift b/DuckDuckGo/Statistics/PixelParameters.swift index afbd32473f..d6f9496add 100644 --- a/DuckDuckGo/Statistics/PixelParameters.swift +++ b/DuckDuckGo/Statistics/PixelParameters.swift @@ -245,6 +245,9 @@ extension Pixel.Event.Debug { .fileStoreWriteFailed, .fileMoveToDownloadsFailed, .fileGetDownloadLocationFailed, + .fileAccessRelatedItemFailed, + .fileDownloadCreatePresentersFailed, + .downloadResumeDataCodingFailed, .suggestionsFetchFailed, .appOpenURLFailed, .appStateRestorationFailed, diff --git a/DuckDuckGo/Tab/Model/Tab+UIDelegate.swift b/DuckDuckGo/Tab/Model/Tab+UIDelegate.swift index 3de0df0c9b..26ae48bca4 100644 --- a/DuckDuckGo/Tab/Model/Tab+UIDelegate.swift +++ b/DuckDuckGo/Tab/Model/Tab+UIDelegate.swift @@ -48,7 +48,12 @@ extension Tab: WKUIDelegate, PrintingUserScriptDelegate { @objc(_webView:saveDataToFile:suggestedFilename:mimeType:originatingURL:) func webView(_ webView: WKWebView, saveDataToFile data: Data, suggestedFilename: String, mimeType: String, originatingURL: URL) { Task { - let result = try? await saveDownloadedData(data, suggestedFilename: suggestedFilename, mimeType: mimeType, originatingURL: originatingURL) + var result: URL? + do { + result = try await saveDownloadedData(data, suggestedFilename: suggestedFilename, mimeType: mimeType, originatingURL: originatingURL) + } catch { + assertionFailure("Save web content failed with \(error)") + } // when print function saves a PDF setting the callback, return the saved temporary file to it await Self.consumeExpectedSaveDataToFileCallback()?(result) } diff --git a/DuckDuckGo/Tab/TabExtensions/DownloadsTabExtension.swift b/DuckDuckGo/Tab/TabExtensions/DownloadsTabExtension.swift index c61045125c..65eb64e98a 100644 --- a/DuckDuckGo/Tab/TabExtensions/DownloadsTabExtension.swift +++ b/DuckDuckGo/Tab/TabExtensions/DownloadsTabExtension.swift @@ -95,22 +95,23 @@ final class DownloadsTabExtension: NSObject { } if url.isFileURL { self.nextSaveDataRequestDownloadLocation = location - _=try? await self.saveDownloadedData(nil, suggestedFilename: url.lastPathComponent, mimeType: mimeType ?? "text/html", originatingURL: url) + do { + _=try await self.saveDownloadedData(nil, suggestedFilename: url.lastPathComponent, mimeType: mimeType ?? "text/html", originatingURL: url) + } catch { + assertionFailure("Save web content failed with \(error)") + } return } + let destination = self.downloadDestination(for: location, suggestedFilename: webView.suggestedFilename ?? "") let download = await webView.startDownload(using: URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad)) - let location = self.downloadLocation(for: location, suggestedFilename: download.webView?.suggestedFilename ?? "") - self.downloadManager.add(download, - fromBurnerWindow: self.isBurner, - delegate: self, - location: location) + self.downloadManager.add(download, fromBurnerWindow: self.isBurner, delegate: self, destination: destination) } } - private func downloadLocation(for location: DownloadLocation, suggestedFilename: String) -> FileDownloadManager.DownloadLocationPreference { + private func downloadDestination(for location: DownloadLocation, suggestedFilename: String) -> WebKitDownloadTask.DownloadDestination { switch location { case .auto: return .auto @@ -121,7 +122,7 @@ final class DownloadsTabExtension: NSObject { let fm = FileManager.default let dirURL = fm.temporaryDirectory.appendingPathComponent(.uniqueFilename()) try? fm.createDirectory(at: dirURL, withIntermediateDirectories: true) - return .preset(destinationURL: dirURL.appendingPathComponent(suggestedFilename), tempURL: nil) + return .preset(dirURL.appendingPathComponent(suggestedFilename)) } } @@ -208,11 +209,9 @@ extension DownloadsTabExtension: NavigationResponder { enqueueDownload(download, withNavigationAction: navigationResponse.mainFrameNavigation?.navigationAction) } + @MainActor func enqueueDownload(_ download: WebKitDownload, withNavigationAction navigationAction: NavigationAction?) { - let task = downloadManager.add(download, - fromBurnerWindow: self.isBurner, - delegate: self, - location: .auto) + let task = downloadManager.add(download, fromBurnerWindow: self.isBurner, delegate: self, destination: .auto) // If the download has started from a popup Tab - close it after starting the download // e.g. download button on this page: @@ -245,13 +244,11 @@ extension DownloadsTabExtension: NavigationResponder { extension DownloadsTabExtension: WKNavigationDelegate { + @MainActor @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, - location: .prompt) + downloadManager.add(download, fromBurnerWindow: isBurner, delegate: self, destination: .prompt) } } @@ -298,7 +295,7 @@ extension DownloadsTabExtension: TabExtension, DownloadsTabExtensionProtocol { defer { self.nextSaveDataRequestDownloadLocation = .auto } - switch downloadLocation(for: nextSaveDataRequestDownloadLocation, suggestedFilename: suggestedFilename) { + switch downloadDestination(for: nextSaveDataRequestDownloadLocation, suggestedFilename: suggestedFilename) { case .auto: guard !downloadsPreferences.alwaysRequestDownloadLocation, let location = downloadsPreferences.effectiveDownloadLocation else { fallthrough /* prompt */ } @@ -320,9 +317,12 @@ extension DownloadsTabExtension: TabExtension, DownloadsTabExtensionProtocol { try saveDownloadedData(data, to: url, originatingURL: originatingURL) return url - case .preset(destinationURL: let destinationURL, tempURL: _): + case .preset(let destinationURL): try saveDownloadedData(data, to: destinationURL, originatingURL: originatingURL) return destinationURL + + case .resume: + fatalError("Unexpected resume download location") } } } diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index 456886e3e9..57268ce061 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -961,14 +961,14 @@ extension BrowserTabViewController: TabDelegate { let alert = AuthenticationAlert(host: request.parameters.host, isEncrypted: request.parameters.receivesCredentialSecurely) - alert.beginSheetModal(for: window) { [request] response in + alert.beginSheetModal(for: window) { [weak request] response in // don‘t submit the query when tab is switched if case .abort = response { return } guard case .OK = response else { - request.submit(nil) + request?.submit(nil) return } - request.submit(.credential(URLCredential(user: alert.usernameTextField.stringValue, + request?.submit(.credential(URLCredential(user: alert.usernameTextField.stringValue, password: alert.passwordTextField.stringValue, persistence: .forSession))) } @@ -987,14 +987,16 @@ extension BrowserTabViewController: TabDelegate { suggestedFilename: request.parameters.suggestedFilename, directoryURL: directoryURL) - savePanel.beginSheetModal(for: window) { [request] response in - if case .abort = response { + savePanel.beginSheetModal(for: window) { [weak request] response in + switch response { + case .abort: // panel not closed by user but by a tab switching return - } else if case .OK = response, let url = savePanel.url { - request.submit( (url, savePanel.selectedFileType) ) - } else { - request.submit(nil) + case .OK: + guard let url = savePanel.url else { fallthrough } + request?.submit( (url, savePanel.selectedFileType) ) + default: + request?.submit(nil) } } @@ -1008,14 +1010,16 @@ extension BrowserTabViewController: TabDelegate { let openPanel = NSOpenPanel() openPanel.allowsMultipleSelection = request.parameters.allowsMultipleSelection - openPanel.beginSheetModal(for: window) { [request] response in - // don‘t submit the query when tab is switched - if case .abort = response { return } - guard case .OK = response else { - request.submit(nil) + openPanel.beginSheetModal(for: window) { [weak request] response in + switch response { + case .abort: + // don‘t submit the query when tab is switched return + case .OK: + request?.submit(openPanel.urls) + default: + request?.submit(nil) } - request.submit(openPanel.urls) } // when subscribing to another Tab, the sheet will be temporarily closed with response == .abort on the cancellable deinit diff --git a/DuckDuckGo/Windows/View/WindowsManager.swift b/DuckDuckGo/Windows/View/WindowsManager.swift index cb622baed4..245b36ed0c 100644 --- a/DuckDuckGo/Windows/View/WindowsManager.swift +++ b/DuckDuckGo/Windows/View/WindowsManager.swift @@ -114,7 +114,8 @@ final class WindowsManager { popUp: popUp) } - class func openNewWindow(with initialUrl: URL, source: Tab.TabContent.URLSource, isBurner: Bool, parentTab: Tab? = nil) { + @discardableResult + class func openNewWindow(with initialUrl: URL, source: Tab.TabContent.URLSource, isBurner: Bool, parentTab: Tab? = nil) -> MainWindow? { openNewWindow(with: Tab(content: .contentFromURL(initialUrl, source: source), parentTab: parentTab, shouldLoadInBackground: true, burnerMode: BurnerMode(isBurner: isBurner))) } diff --git a/IntegrationTests/Downloads/DownloadsIntegrationTests.swift b/IntegrationTests/Downloads/DownloadsIntegrationTests.swift index b8d32914f4..35e2024c05 100644 --- a/IntegrationTests/Downloads/DownloadsIntegrationTests.swift +++ b/IntegrationTests/Downloads/DownloadsIntegrationTests.swift @@ -85,7 +85,7 @@ class DownloadsIntegrationTests: XCTestCase { _=await tab.setUrl(url, source: .link)?.result let fileUrl = try await downloadTaskFuture.get().output - .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError, isRetryable: false) }.first().promise().get() + .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError) }.first().promise().get() XCTAssertEqual(fileUrl, FileManager.default.temporaryDirectory.appendingPathComponent("fname_\(suffix).dat")) XCTAssertEqual(try? Data(contentsOf: fileUrl), data.html) @@ -135,7 +135,7 @@ class DownloadsIntegrationTests: XCTestCase { withExtendedLifetime(c) {} let downloadTaskOutputPromise = downloadTask.output - .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError, isRetryable: false) }.first().promise() + .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError) }.first().promise() // now close the background download tab XCTAssertEqual(tabCollectionViewModel.allTabsCount, 2) @@ -212,7 +212,7 @@ class DownloadsIntegrationTests: XCTestCase { withExtendedLifetime((c, c2)) {} let downloadTaskOutputPromise = downloadTask.output - .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError, isRetryable: false) }.first().promise() + .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError) }.first().promise() // now close the window tabCollectionViewModel = nil @@ -260,7 +260,7 @@ class DownloadsIntegrationTests: XCTestCase { try! await tab.webView.evaluateJavaScript(js) let fileUrl = try await downloadTaskFuture.get().output - .timeout(5, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError, isRetryable: false) }.first().promise().get() + .timeout(5, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError) }.first().promise().get() XCTAssertEqual(fileUrl, FileManager.default.temporaryDirectory.appendingPathComponent("helloWorld_\(suffix).txt")) XCTAssertEqual(try? Data(contentsOf: fileUrl), data.testData) @@ -294,7 +294,7 @@ class DownloadsIntegrationTests: XCTestCase { try! await tab.webView.evaluateJavaScript(js) let fileUrl = try await downloadTaskFuture.get().output - .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError, isRetryable: false) }.first().promise().get() + .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError) }.first().promise().get() XCTAssertEqual(fileUrl, FileManager.default.temporaryDirectory.appendingPathComponent("blobdload_\(suffix).json")) XCTAssertEqual(try? Data(contentsOf: fileUrl), data.testData) @@ -313,3 +313,23 @@ private extension DownloadsIntegrationTests { window.sendEvent(mouseUp) } } + +extension WebKitDownloadTask { + + var output: AnyPublisher { + $state.tryCompactMap { state in + switch state { + case .initial, .downloading: + return nil + case .downloaded(let destinationFile): + return destinationFile.url + case .failed(_, _, resumeData: _, error: let error): + throw error + } + } + .mapError { $0 as! FileDownloadError } // swiftlint:disable:this force_cast + .first() + .eraseToAnyPublisher() + } + +} diff --git a/IntegrationTests/HTTPSUpgrade/HTTPSUpgradeIntegrationTests.swift b/IntegrationTests/HTTPSUpgrade/HTTPSUpgradeIntegrationTests.swift index 9dbc287a0a..15683bfe2e 100644 --- a/IntegrationTests/HTTPSUpgrade/HTTPSUpgradeIntegrationTests.swift +++ b/IntegrationTests/HTTPSUpgrade/HTTPSUpgradeIntegrationTests.swift @@ -100,7 +100,7 @@ class HTTPSUpgradeIntegrationTests: XCTestCase { _=try await tab.webView.evaluateJavaScript("(function() { document.getElementById('download').click(); return true })()") let fileUrl = try await downloadTaskFuture.value.output - .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError, isRetryable: false) }.first().promise().get() + .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError) }.first().promise().get() struct Results: Decodable { struct Result: Decodable { @@ -178,7 +178,7 @@ class HTTPSUpgradeIntegrationTests: XCTestCase { _=try await tab.webView.evaluateJavaScript("(function() { document.getElementById('download').click(); return true })()") let fileUrl = try await downloadTaskFuture.value.output - .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError, isRetryable: false) }.first().promise().get() + .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError) }.first().promise().get() struct Results: Decodable { struct Result: Decodable { diff --git a/IntegrationTests/NavigationProtection/NavigationProtectionIntegrationTests.swift b/IntegrationTests/NavigationProtection/NavigationProtectionIntegrationTests.swift index f36748c64b..f06aa2ade3 100644 --- a/IntegrationTests/NavigationProtection/NavigationProtectionIntegrationTests.swift +++ b/IntegrationTests/NavigationProtection/NavigationProtectionIntegrationTests.swift @@ -183,7 +183,7 @@ class NavigationProtectionIntegrationTests: XCTestCase { // wait for the download to complete let fileUrl = try await downloadTaskPromise.value.output - .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError(description: "failed to download") as NSError, isRetryable: false) }.first().promise().get() + .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError(description: "failed to download") as NSError) }.first().promise().get() // print(try! String(contentsOf: fileUrl)) let results = try JSONDecoder().decode(Results.self, from: Data(contentsOf: fileUrl)).results @@ -259,7 +259,7 @@ class NavigationProtectionIntegrationTests: XCTestCase { _=try await tab.webView.evaluateJavaScript("(function() { document.getElementById('download').click(); return true })()") let fileUrl = try await downloadTaskFuture.value.output - .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError, isRetryable: false) }.first().promise().get() + .timeout(1, scheduler: DispatchQueue.main) { .init(TimeoutError() as NSError) }.first().promise().get() // print(try! String(contentsOf: fileUrl)) results = try JSONDecoder().decode(Results.self, from: Data(contentsOf: fileUrl)) diff --git a/IntegrationTests/Tab/AddressBarTests.swift b/IntegrationTests/Tab/AddressBarTests.swift index deace94f7f..22b6ce65aa 100644 --- a/IntegrationTests/Tab/AddressBarTests.swift +++ b/IntegrationTests/Tab/AddressBarTests.swift @@ -781,17 +781,3 @@ class AddressBarTests: XCTestCase { } } - -protocol MainActorPerformer { - func perform(_ closure: @MainActor () -> Void) -} -struct OnMainActor: MainActorPerformer { - private init() {} - - static func instance() -> MainActorPerformer { OnMainActor() } - - @MainActor(unsafe) - func perform(_ closure: @MainActor () -> Void) { - closure() - } -} diff --git a/IntegrationTests/Tab/TabContentTests.swift b/IntegrationTests/Tab/TabContentTests.swift index 13ba1fc393..f2abe4b312 100644 --- a/IntegrationTests/Tab/TabContentTests.swift +++ b/IntegrationTests/Tab/TabContentTests.swift @@ -235,6 +235,9 @@ class TabContentTests: XCTestCase { XCTAssertNotNil(saveAsMenuItem.action) XCTAssertNotNil(saveAsMenuItem.pdfHudRepresentedObject) + let persistor = DownloadsPreferencesUserDefaultsPersistor() + persistor.lastUsedCustomDownloadLocation = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0].path + // Click Save As… _=saveAsMenuItem.action.map { action in NSApp.sendAction(action, to: saveAsMenuItem.target, from: saveAsMenuItem) @@ -286,6 +289,9 @@ class TabContentTests: XCTestCase { } } + let persistor = DownloadsPreferencesUserDefaultsPersistor() + persistor.lastUsedCustomDownloadLocation = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0].path + // Hit Cmd+S let keyDown = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [.command], timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "s", charactersIgnoringModifiers: "s", isARepeat: false, keyCode: UInt16(kVK_ANSI_S))! let keyUp = NSEvent.keyEvent(with: .keyUp, location: .zero, modifierFlags: [.command], timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "s", charactersIgnoringModifiers: "s", isARepeat: false, keyCode: UInt16(kVK_ANSI_S))! diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionEventPixelsTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionEventPixelsTests.swift index 998450fe12..42c3bc94be 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionEventPixelsTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionEventPixelsTests.swift @@ -347,7 +347,7 @@ final class DataBrokerProtectionEventPixelsTests: XCTestCase { return } - let eventOne = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .scanStarted, date: eighDaysSinceToday) + /*let eventOne*/ _ = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .scanStarted, date: eighDaysSinceToday) let eventTwo = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .scanStarted, date: sixDaysSinceToday) let dataBrokerProfileQueries: [BrokerProfileQueryData] = [ .init(dataBroker: .mockWithURL("www.brokerone.com"), diff --git a/UnitTests/Common/PublishersExtensions.swift b/UnitTests/Common/PublishersExtensions.swift index 3d1fda80a8..dc1618c6c2 100644 --- a/UnitTests/Common/PublishersExtensions.swift +++ b/UnitTests/Common/PublishersExtensions.swift @@ -28,3 +28,12 @@ extension Publisher where Failure == Never { } } + +extension Publisher where Failure: Error { + + func timeout(_ interval: TimeInterval, _ description: String? = nil, file: StaticString = #file, line: UInt = #line) -> Publishers.Timeout, DispatchQueue> { + return self.mapError { $0 } + .timeout(.seconds(interval), scheduler: DispatchQueue.main, customError: { TimeoutError(interval: interval, description: description, file: file, line: line) }) + } + +} diff --git a/UnitTests/FileDownload/DownloadListCoordinatorTests.swift b/UnitTests/FileDownload/DownloadListCoordinatorTests.swift index d8dbed84c5..1092aac187 100644 --- a/UnitTests/FileDownload/DownloadListCoordinatorTests.swift +++ b/UnitTests/FileDownload/DownloadListCoordinatorTests.swift @@ -16,44 +16,34 @@ // limitations under the License. // +import Combine +import Common import Foundation import UniformTypeIdentifiers import XCTest @testable import DuckDuckGo_Privacy_Browser -@MainActor final class DownloadListCoordinatorTests: XCTestCase { - let store = DownloadListStoreMock() - let downloadManager = FileDownloadManagerMock() + var store: DownloadListStoreMock! + var downloadManager: FileDownloadManagerMock! var coordinator: DownloadListCoordinator! + var webView: DownloadsWebViewMock! let fm = FileManager.default - let testFile = "downloaded file.pdf" + var testFile: String! var destURL: URL! var tempURL: URL! var chooseDestinationBlock: ((String?, URL?, [UTType], @escaping (URL?, UTType?) -> Void) -> Void)? - lazy var webView = DownloadsWebViewMock() - - func clearTemp() { - let tempDir = fm.temporaryDirectory - for file in (try? fm.contentsOfDirectory(atPath: tempDir.path)) ?? [] where file.hasPrefix(testFile) { - try? fm.removeItem(at: tempDir.appendingPathComponent(file)) - } - } - override func setUp() { - clearTemp() - + self.store = DownloadListStoreMock() + self.downloadManager = FileDownloadManagerMock() + self.webView = DownloadsWebViewMock() + self.testFile = UUID().uuidString + ".pdf" self.destURL = fm.temporaryDirectory.appendingPathComponent(testFile) - self.tempURL = fm.temporaryDirectory.appendingPathComponent(testFile).appendingPathExtension("duckload") - fm.createFile(atPath: tempURL.path, contents: "test".data(using: .utf8)!, attributes: nil) - } - - override func tearDown() { - clearTemp() + self.tempURL = fm.temporaryDirectory.appendingPathComponent(testFile).deletingPathExtension().appendingPathExtension("duckload") } func setUpCoordinator() { @@ -62,19 +52,37 @@ final class DownloadListCoordinatorTests: XCTestCase { } } + @MainActor func setUpCoordinatorAndAddDownload(isBurner: Bool = false) -> (WKDownloadMock, WebKitDownloadTask, UUID) { setUpCoordinator() + return addDownload(isBurner: isBurner) + } + + @MainActor + func addDownload(tempURL: URL? = nil, destURL: URL? = nil, isBurner: Bool = false) -> (WKDownloadMock, WebKitDownloadTask, UUID) { let download = WKDownloadMock(url: .duckDuckGo) - let task = WebKitDownloadTask(download: download, promptForLocation: false, destinationURL: destURL, tempURL: tempURL, isBurner: isBurner) + + let fm = FileManager.default + let destURL = destURL ?? self.destURL! + XCTAssertTrue(fm.createFile(atPath: destURL.path, contents: nil)) + let destFile = try! FilePresenter(url: destURL) + let tempURL = tempURL ?? self.tempURL! + 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) let e = expectation(description: "download added") var id: UUID! - let c = coordinator.updates.sink { _, item in - e.fulfill() - id = item.identifier + let c = coordinator.updates.sink { kind, item in + if kind == .added { + e.fulfill() + id = item.identifier + } } downloadManager.downloadAddedSubject.send(task) - waitForExpectations(timeout: 1) + task.start(delegate: downloadManager) + waitForExpectations(timeout: 3) c.cancel() return (download, task, id) @@ -82,115 +90,156 @@ final class DownloadListCoordinatorTests: XCTestCase { // MARK: - Tests - func testWhenCoordinatorInitializedThenClearItemsBeforeIsCalled() { - let clearDate: Date = Date.daysAgo(2) - let e = expectation(description: "clear older than date called") - store.fetchBlock = { date, _ in - e.fulfill() - XCTAssertEqual(Int(clearDate.timeIntervalSinceReferenceDate), Int(date.timeIntervalSinceReferenceDate)) - } - setUpCoordinator() - - waitForExpectations(timeout: 0) - } - + @MainActor func testWhenCoordinatorInitializedThenDownloadItemsAreLoadedFromStore() { - store.fetchBlock = { _, completionHandler in - completionHandler(.success([.testItem, .olderItem])) + let items: [DownloadListItem] = [.testItem, .yesterdaysItem, .olderItem, .testRemovedItem, .testFailedItem] + let expectedItems: [DownloadListItem] = [.testItem, .yesterdaysItem, .testFailedItem] + + let e1 = expectation(description: "fetch called") + store.fetchBlock = { completionHandler in + e1.fulfill() + let fm = FileManager() + for item in items where item != .testRemovedItem { + XCTAssertTrue(fm.createFile(atPath: item.destinationURL!.path, contents: nil)) + if let tempURL = item.tempURL { + XCTAssertTrue(fm.createFile(atPath: tempURL.path, contents: "test".utf8data)) + } + } + completionHandler(.success(items)) } setUpCoordinator() + waitForExpectations(timeout: 1) - XCTAssertEqual(coordinator.downloads(sortedBy: \.modified, ascending: true), [.olderItem, .testItem]) + let resultItems = coordinator.downloads(sortedBy: \.modified, ascending: true) + XCTAssertEqual(resultItems.map(\.identifier), expectedItems.map(\.identifier)) + XCTAssertEqual(resultItems.compactMap { $0.destinationFileBookmarkData }.count, 3) } + @MainActor func testWhenInitialLoadingFailsThenDownloadItemsAreEmpty() { - store.fetchBlock = { _, completionHandler in + let e1 = expectation(description: "fetch called") + store.fetchBlock = { completionHandler in + e1.fulfill() completionHandler(.failure(TestError())) } setUpCoordinator() - XCTAssertEqual(coordinator.downloads(sortedBy: \.modified, ascending: true), []) + waitForExpectations(timeout: 0) + XCTAssertEqual(coordinator.downloads(sortedBy: \.modified, ascending: true), []) } + @MainActor func testWhenStoreItemsAreLoadedThenUpdatesArePublished() { var itemsLoaded: ((Result<[DownloadListItem], Error>) -> Void)! - store.fetchBlock = { _, completionHandler in + store.fetchBlock = { completionHandler in itemsLoaded = completionHandler } setUpCoordinator() XCTAssertEqual(coordinator.downloads(sortedBy: \.modified, ascending: true), []) - let e1 = expectation(description: "item 1 added") - let e2 = expectation(description: "item 2 added") + let items: [DownloadListItem] = [.testItem, .yesterdaysItem, .olderItem, .testRemovedItem, .testFailedItem] + var expectations = [UUID: XCTestExpectation]() + + for item in items where item != .testRemovedItem { + XCTAssertTrue(fm.createFile(atPath: item.destinationURL!.path, contents: nil)) + if let tempURL = item.tempURL { + XCTAssertTrue(fm.createFile(atPath: tempURL.path, contents: "test".utf8data)) + } + if item != .olderItem { + expectations[item.identifier] = expectation(description: "\(item.fileName) added") + } + } + let c = coordinator.updates.sink { (kind, item) in - XCTAssertEqual(kind, .added) - switch item { - case .testItem: - e1.fulfill() - case .olderItem: - e2.fulfill() - default: - XCTFail("unexpected item") + if kind == .added { + expectations[item.identifier]!.fulfill() + } else if kind != .updated { + XCTFail("unexpected \(kind) \(item.fileName)") } } - itemsLoaded(.success([.testItem, .olderItem])) + itemsLoaded(.success(items)) withExtendedLifetime(c) { - waitForExpectations(timeout: 0) + waitForExpectations(timeout: 1) } } - func testWhenDownloadAddedThenDownloadItemIsPublished() { + @MainActor + func testWhenFileIsRenamed_nameChangeUpdateIsPublished() { + + } + + @MainActor + func testWhenDownloadAddedThenDownloadItemIsPublished() async { setUpCoordinator() - let task = WebKitDownloadTask(download: WKDownloadMock(url: .duckDuckGo), promptForLocation: false, destinationURL: destURL, tempURL: tempURL, isBurner: false) + 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 e = expectation(description: "download added") + let e1 = expectation(description: "download added") + let e2 = expectation(description: "download updated") let c = coordinator.updates.sink { [coordinator] (kind, item) in - XCTAssertEqual(kind, .added) - XCTAssertEqual(item.destinationURL, self.destURL) - XCTAssertEqual(item.tempURL, self.tempURL) - XCTAssertEqual(item.progress, task.progress) - XCTAssertTrue(coordinator!.hasActiveDownloads) - e.fulfill() + switch kind { + case .added: + XCTAssertEqual(item.progress, task.progress) + XCTAssertTrue(coordinator!.hasActiveDownloads) + e1.fulfill() + case .updated: + guard let tempUrlValue = item.tempURL else { return } + XCTAssertEqual(item.destinationURL, destURL) + XCTAssertEqual(tempUrlValue, tempURL) + XCTAssertEqual(item.fileName, destURL.lastPathComponent) + e2.fulfill() + case .removed: + XCTFail("unexpected .removed") + } } downloadManager.downloadAddedSubject.send(task) + task.start(delegate: downloadManager) + let url = await task.download(download.asWKDownload(), decideDestinationUsing: URLResponse(url: download.originalRequest!.url!, mimeType: nil, expectedContentLength: 1, textEncodingName: nil), suggestedFilename: destURL.lastPathComponent) + XCTAssertNotNil(url) + XCTAssertTrue(FileManager().createFile(atPath: url?.path ?? "", contents: nil)) - withExtendedLifetime(c) { - waitForExpectations(timeout: 1) - } + await fulfillment(of: [e1, e2], timeout: 1) + withExtendedLifetime(c) {} XCTAssertTrue(coordinator.hasActiveDownloads) } + @MainActor func testWhenDownloadFinishesThenDownloadItemUpdated() { let (download, task, _) = setUpCoordinatorAndAddDownload() - let locationUpdated = expectation(description: "location updated") - let taskCompleted = expectation(description: "location updated") - let c = coordinator.updates.sink { (kind, item) in - XCTAssertEqual(kind, .updated) - if item.progress != nil { - locationUpdated.fulfill() - } else { - taskCompleted.fulfill() + let taskCompleted = expectation(description: "item updated") + var c: AnyCancellable! + c = coordinator.updates.sink { (kind, item) in + guard kind == .updated, item.progress == nil else { return } - XCTAssertEqual(item.destinationURL, self.destURL) - XCTAssertNil(item.tempURL) - XCTAssertNil(item.progress) - } + taskCompleted.fulfill() + + XCTAssertEqual(item.destinationURL, self.destURL) + XCTAssertNil(item.tempURL) + XCTAssertNil(item.progress) + c?.cancel() } task.downloadDidFinish(download.asWKDownload()) - withExtendedLifetime(c) { - waitForExpectations(timeout: 1) - } + waitForExpectations(timeout: 1) + c = nil + XCTAssertFalse(coordinator.hasActiveDownloads) } + @MainActor + func testWhenDownloadFinishesFasterThanTempFileIsCreatedThenDownloadItemUpdated() { + } + + @MainActor func testWhenDownloadFromBurnerWindowFinishesThenDownloadItemRemoved() { let (download, task, _) = setUpCoordinatorAndAddDownload(isBurner: true) @@ -211,41 +260,52 @@ final class DownloadListCoordinatorTests: XCTestCase { XCTAssertFalse(coordinator.hasActiveDownloads) } + @MainActor func testWhenDownloadFailsThenDownloadItemUpdatedWithError() { let (download, task, _) = setUpCoordinatorAndAddDownload() let taskCompleted = expectation(description: "location updated") - let c = coordinator.updates.sink { (kind, item) in + var c: AnyCancellable! + c = coordinator.updates.sink { (kind, item) in XCTAssertEqual(kind, .updated) - taskCompleted.fulfill() + guard item.destinationURL != nil, item.tempURL != nil else { return } XCTAssertEqual(item.destinationURL, self.destURL) XCTAssertEqual(item.tempURL, self.tempURL) XCTAssertNil(item.progress) XCTAssertEqual(item.error, FileDownloadError.failedToCompleteDownloadTask(underlyingError: TestError(), resumeData: .resumeData, isRetryable: true)) XCTAssertEqual(item.error?.resumeData, .resumeData) + taskCompleted.fulfill() + c.cancel() } task.download(download.asWKDownload(), didFailWithError: TestError(), resumeData: .resumeData) - withExtendedLifetime(c) { - waitForExpectations(timeout: 1) - } + waitForExpectations(timeout: 1) + c = nil + XCTAssertFalse(coordinator.hasActiveDownloads) } - func testWhenPreloadedDownloadRestartedWithResumeDataThenResumeIsCalled() { + @MainActor + func testWhenPreloadedDownloadRestartedWithResumeDataThenResumeIsCalled() throws { var item: DownloadListItem = .testFailedItem item.tempURL = self.tempURL - store.fetchBlock = { _, completionHandler in + item.destinationURL = self.destURL + XCTAssertTrue(fm.createFile(atPath: tempURL.path, contents: nil)) + XCTAssertTrue(fm.createFile(atPath: destURL.path, contents: nil)) + + store.fetchBlock = { completionHandler in completionHandler(.success([item])) } setUpCoordinator() let resumeCalled = expectation(description: "resume called") - webView.resumeDownloadBlock = { data in + webView.resumeDownloadBlock = { [testFile, tempURL] data in resumeCalled.fulfill() - XCTAssertEqual(data, .resumeData) + let resumeData = try? data.map(DownloadResumeData.init(resumeData:)) + XCTAssertEqual(resumeData?.localPath, tempURL!.path) + XCTAssertEqual(resumeData?.tempFileName, testFile!.dropping(suffix: "." + testFile!.pathExtension).appendingPathExtension("duckload")) return WKDownloadMock(url: .duckDuckGo) } webView.startDownloadBlock = { _ in @@ -254,15 +314,15 @@ final class DownloadListCoordinatorTests: XCTestCase { } let downloadAdded = expectation(description: "download addeed") - downloadManager.addDownloadBlock = { [unowned self] download, _, location in + downloadManager.addDownloadBlock = { [unowned self] download, _, destination in downloadAdded.fulfill() - let task = WebKitDownloadTask(download: download, - promptForLocation: location == .prompt ? true : false, - destinationURL: location.destinationURL, - tempURL: location.tempURL, - isBurner: false) - self.downloadManager.downloadAddedSubject.send(task) - XCTAssertEqual(location, .preset(destinationURL: item.destinationURL!, tempURL: item.tempURL)) + let task = WebKitDownloadTask(download: download, destination: destination, isBurner: false) + if case .resume(destination: let dest, tempFile: let temp) = destination { + XCTAssertEqual(dest.url, destURL) + XCTAssertEqual(temp.url, tempURL) + } else { + XCTFail("unexpected destination: \(destination)") + } return task } @@ -285,7 +345,8 @@ final class DownloadListCoordinatorTests: XCTestCase { XCTAssertEqual(coordinator.downloads(sortedBy: \.modified, ascending: true).count, 1) } - func testWhenAddedDownloadRestartedWithResumeDataThenResumeIsCalled() { + @MainActor + func testWhenAddedDownloadRestartedWithResumeDataThenResumeIsCalled() throws { let (download, task, id) = setUpCoordinatorAndAddDownload() let taskFailed = expectation(description: "task failed") let c1 = coordinator.updates.sink { _, _ in @@ -296,9 +357,11 @@ final class DownloadListCoordinatorTests: XCTestCase { c1.cancel() let resumeCalled = expectation(description: "resume called") - webView.resumeDownloadBlock = { data in + webView.resumeDownloadBlock = { [testFile, tempURL] data in resumeCalled.fulfill() - XCTAssertEqual(data, .resumeData) + let resumeData = try? data.map(DownloadResumeData.init(resumeData:)) + XCTAssertEqual(resumeData?.localPath, tempURL!.path) + XCTAssertEqual(resumeData?.tempFileName, testFile!.dropping(suffix: "." + testFile!.pathExtension).appendingPathExtension("duckload")) return WKDownloadMock(url: .duckDuckGo) } webView.startDownloadBlock = { _ in @@ -307,15 +370,17 @@ final class DownloadListCoordinatorTests: XCTestCase { } let downloadAdded = expectation(description: "download addeed") - downloadManager.addDownloadBlock = { [unowned self] download, _, location in + downloadManager.addDownloadBlock = { [unowned self] download, _, destination in downloadAdded.fulfill() - let task = WebKitDownloadTask(download: download, - promptForLocation: location == .prompt ? true : false, - destinationURL: location.destinationURL, - tempURL: location.tempURL, - isBurner: false) + let task = WebKitDownloadTask(download: download, destination: destination, isBurner: false) self.downloadManager.downloadAddedSubject.send(task) - XCTAssertEqual(location, .preset(destinationURL: self.destURL, tempURL: self.tempURL)) + task.start(delegate: self.downloadManager) + if case .resume(destination: let dest, tempFile: let temp) = destination { + XCTAssertEqual(dest.url, destURL) + XCTAssertEqual(temp.url, tempURL) + } else { + XCTFail("unexpected destination: \(destination)") + } return task } @@ -338,11 +403,15 @@ final class DownloadListCoordinatorTests: XCTestCase { XCTAssertEqual(coordinator.downloads(sortedBy: \.modified, ascending: true).count, 1) } - func testWhenDownloadRestartedWithoutResumeDataThenDownloadIsStarted() { + @MainActor + func testWhenDownloadRestartedWithoutResumeDataThenDownloadIsStarted() throws { var item: DownloadListItem = .testFailedItem item.tempURL = self.tempURL + item.destinationURL = self.destURL + XCTAssertTrue(fm.createFile(atPath: tempURL.path, contents: nil)) + XCTAssertTrue(fm.createFile(atPath: destURL.path, contents: nil)) item.error = .failedToCompleteDownloadTask(underlyingError: TestError(), resumeData: nil, isRetryable: false) - store.fetchBlock = { _, completionHandler in + store.fetchBlock = { completionHandler in completionHandler(.success([item])) } setUpCoordinator() @@ -354,20 +423,22 @@ final class DownloadListCoordinatorTests: XCTestCase { } webView.startDownloadBlock = { request in startCalled.fulfill() - XCTAssertEqual(request?.url, item.url) + XCTAssertEqual(request?.url, item.downloadURL) return WKDownloadMock(url: .duckDuckGo) } let downloadAdded = expectation(description: "download addeed") - downloadManager.addDownloadBlock = { [unowned self] download, _, location in + downloadManager.addDownloadBlock = { [unowned self] download, _, destination in downloadAdded.fulfill() - let task = WebKitDownloadTask(download: download, - promptForLocation: location == .prompt ? true : false, - destinationURL: location.destinationURL, - tempURL: location.tempURL, - isBurner: false) + let task = WebKitDownloadTask(download: download, destination: destination, isBurner: false) self.downloadManager.downloadAddedSubject.send(task) - XCTAssertEqual(location, .preset(destinationURL: item.destinationURL!, tempURL: item.tempURL)) + task.start(delegate: self.downloadManager) + if case .resume(destination: let dest, tempFile: let temp) = destination { + XCTAssertEqual(dest.url, destURL) + XCTAssertEqual(temp.url, tempURL) + } else { + XCTFail("unexpected destination: \(destination)") + } return task } @@ -390,6 +461,7 @@ final class DownloadListCoordinatorTests: XCTestCase { XCTAssertEqual(coordinator.downloads(sortedBy: \.modified, ascending: true).count, 1) } + @MainActor func testWhenDownloadRemovedThenUpdateIsPublished() { let (download, _, id) = setUpCoordinatorAndAddDownload() @@ -411,29 +483,50 @@ final class DownloadListCoordinatorTests: XCTestCase { } } + @MainActor func testWhenInactiveDownloadsClearedThenOnlyInactiveDownloadsRemoved() { - let (download1, task1, _) = setUpCoordinatorAndAddDownload() - let (download2, task2, _) = setUpCoordinatorAndAddDownload() - let (download3, task3, _) = setUpCoordinatorAndAddDownload() + let (download1, task1, keptId) = setUpCoordinatorAndAddDownload() + let destURL2 = fm.temporaryDirectory.appendingPathComponent("testfile2.pdf") + let tempURL2 = fm.temporaryDirectory.appendingPathComponent("testfile2.duckload") + let (download2, task2, _) = addDownload(tempURL: tempURL2, destURL: destURL2) + let destURL3 = fm.temporaryDirectory.appendingPathComponent("myfile3.pdf") + let tempURL3 = fm.temporaryDirectory.appendingPathComponent("myfile3.duckload") + let (download3, task3, _) = addDownload(tempURL: tempURL3, destURL: destURL3) task2.download(download2.asWKDownload(), didFailWithError: TestError(), resumeData: nil) task3.downloadDidFinish(download3.asWKDownload()) - let clearCalled = expectation(description: "clear called") - store.clearBlock = { date, _ in - clearCalled.fulfill() - XCTAssertEqual(date, .distantFuture) + let e1 = expectation(description: "download stopped") + e1.expectedFulfillmentCount = 2 + var c = coordinator.updates.sink { (kind, item) in + guard kind == .updated, item.progress == nil else { return } + e1.fulfill() + XCTAssertNotEqual(item.identifier, keptId) + } + + waitForExpectations(timeout: 1) + + let e2 = expectation(description: "item removed") + e2.expectedFulfillmentCount = 2 + c = coordinator.updates.sink { (kind, item) in + guard kind == .removed else { return } + e2.fulfill() + XCTAssertNotEqual(item.identifier, keptId) } coordinator.cleanupInactiveDownloads() - waitForExpectations(timeout: 1) + withExtendedLifetime(c) { + waitForExpectations(timeout: 1) + } + XCTAssertTrue(coordinator.hasActiveDownloads) XCTAssertEqual(coordinator.downloads(sortedBy: \.modified, ascending: true).count, 1) task1.download(download1.asWKDownload(), didFailWithError: TestError(), resumeData: nil) } + @MainActor func testWhenDownloadCancelledThenTaskIsCancelled() { let (download, _, id) = setUpCoordinatorAndAddDownload() let e = expectation(description: "cancelled") @@ -444,6 +537,46 @@ final class DownloadListCoordinatorTests: XCTestCase { waitForExpectations(timeout: 1) } + @MainActor + func testWhenDownloadTaskProgressCancelledThenTaskIsCancelled() { + let (download, task, _) = setUpCoordinatorAndAddDownload() + let e = expectation(description: "cancelled") + download.cancelBlock = { + e.fulfill() + } + task.progress.cancel() + waitForExpectations(timeout: 1) + } + + @MainActor + func testWhenDownloadFileProgressCancelledThenTaskIsCancelled() { + let (download, task, _) = setUpCoordinatorAndAddDownload() + let eCancelled = expectation(description: "cancelled") + download.cancelBlock = { + eCancelled.fulfill() + } + + let eProgressPopulated = expectation(description: "file progress populated") + var timer: Timer? + if task.fileProgress != nil { + eProgressPopulated.fulfill() + } else { + timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in + if task.fileProgress != nil { + eProgressPopulated.fulfill() + timer.invalidate() + } + } + } + wait(for: [eProgressPopulated], timeout: 2) + timer?.invalidate() + + XCTAssertNotNil(task.fileProgress) + task.fileProgress?.cancel() + wait(for: [eCancelled], timeout: 1) + } + + @MainActor func testSync() { let e = expectation(description: "sync called") store.syncBlock = { @@ -458,20 +591,87 @@ final class DownloadListCoordinatorTests: XCTestCase { private struct TestError: Error, Equatable {} private extension Data { - static let resumeData = "resumeData".data(using: .utf8)! + static let resumeData: Data = { + let dict = [ + "NSURLSessionResumeInfoLocalPath": FileManager.default.temporaryDirectory.appendingPathComponent("downloaded file.duckload"), + "NSURLSessionResumeInfoTempFileName": "downloaded file.pdf" + ] + let archiver = NSKeyedArchiver(requiringSecureCoding: false) + archiver.encode(dict, forKey: "NSKeyedArchiveRootObjectKey") + archiver.finishEncoding() + return archiver.encodedData + }() } private extension DownloadListItem { + static let testItem = DownloadListItem(identifier: UUID(), + added: Date(), + modified: Date(), + downloadURL: URL(string: "https://duckduckgo.com/testdload")!, + websiteURL: .duckDuckGo, + fileName: "testItem.pdf", + progress: nil, + isBurner: false, + destinationURL: FileManager.default.temporaryDirectory.appendingPathComponent("testItem.pdf"), + destinationFileBookmarkData: nil, + tempURL: nil, + tempFileBookmarkData: nil, + error: nil) + + static let yesterdaysItem = DownloadListItem(identifier: UUID(), + added: .daysAgo(30), + modified: .daysAgo(1), + downloadURL: .duckDuckGo, + websiteURL: .duckDuckGo, + fileName: "oldItem.pdf", + progress: nil, + isBurner: false, + destinationURL: FileManager.default.temporaryDirectory.appendingPathComponent("oldItem.pdf"), + destinationFileBookmarkData: nil, + tempURL: nil, + tempFileBookmarkData: nil, + error: nil) + + static let olderItem = DownloadListItem(identifier: UUID(), + added: .daysAgo(30), + modified: .daysAgo(3), + downloadURL: URL(string: "https://testdownload.com")!, + websiteURL: nil, + fileName: "outdated_fileName", + progress: nil, + isBurner: false, + destinationURL: FileManager.default.temporaryDirectory.appendingPathComponent("olderItem.pdf"), + destinationFileBookmarkData: nil, + tempURL: nil, + tempFileBookmarkData: nil, + error: nil) + + static let testRemovedItem = DownloadListItem(identifier: UUID(), + added: Date(), + modified: Date(), + downloadURL: URL(string: "https://duckduckgo.com/testdload")!, + websiteURL: .duckDuckGo, + fileName: "fileName", + progress: nil, + isBurner: false, + destinationURL: URL(fileURLWithPath: "/test/path"), + destinationFileBookmarkData: nil, + tempURL: URL(fileURLWithPath: "/temp/file/path"), + tempFileBookmarkData: nil, + error: nil) + static let testFailedItem = DownloadListItem(identifier: UUID(), added: Date(), modified: Date(), - url: URL(string: "https://duckduckgo.com/testdload")!, - websiteURL: URL(string: "https://duckduckgo.com")!, + downloadURL: URL(string: "https://duckduckgo.com/testdload")!, + websiteURL: .duckDuckGo, + fileName: "testFailedItem.pdf", progress: nil, isBurner: false, - fileType: .pdf, - destinationURL: URL(fileURLWithPath: "/test/file/path"), - tempURL: URL(fileURLWithPath: "/temp/file/path"), + destinationURL: FileManager.default.temporaryDirectory.appendingPathComponent("testFailedItem.pdf"), + destinationFileBookmarkData: nil, + tempURL: FileManager.default.temporaryDirectory.appendingPathComponent("testFailedItem.duckload"), + tempFileBookmarkData: nil, error: .failedToCompleteDownloadTask(underlyingError: TestError(), resumeData: .resumeData, isRetryable: false)) } diff --git a/UnitTests/FileDownload/DownloadListStoreTests.swift b/UnitTests/FileDownload/DownloadListStoreTests.swift index 2d3250602d..c87d347e07 100644 --- a/UnitTests/FileDownload/DownloadListStoreTests.swift +++ b/UnitTests/FileDownload/DownloadListStoreTests.swift @@ -65,38 +65,6 @@ final class DownloadListStoreTests: XCTestCase { XCTAssertEqual(items.count, 1) } - func testWhenFetchClearingItemsOlderThanIsCalled_ThenOlderItemsThanDateAreCleaned() { - let oldItem = DownloadListItem(identifier: UUID(), - added: Date.daysAgo(30), - modified: Date.daysAgo(3), - url: URL(string: "https://duckduckgo.com")!, - websiteURL: nil, - progress: nil, - isBurner: false, - fileType: .pdf, - destinationURL: URL(fileURLWithPath: "/test/path"), - tempURL: nil, - error: nil) - let notSoOldItem = DownloadListItem.olderItem - let newItem = DownloadListItem.testItem - - save(oldItem, expectation: self.expectation(description: "Saving 1")) - save(notSoOldItem, expectation: self.expectation(description: "Saving 2")) - save(newItem, expectation: self.expectation(description: "Saving 3")) - - let loadingExpectation = self.expectation(description: "Loading") - store.fetch(clearingItemsOlderThan: Date.daysAgo(2)) { result in - loadingExpectation.fulfill() - guard case .success(let items) = result else { XCTFail("unexpected failure \(result)"); return } - - XCTAssertEqual(items.count, 2) - XCTAssertTrue(items.contains(notSoOldItem)) - XCTAssertTrue(items.contains(newItem)) - } - - waitForExpectations(timeout: 1, handler: nil) - } - func testWhenDownloadIsRemoved_ThenItShouldntBeLoadedFromStore() throws { let item1 = DownloadListItem.testItem let item2 = DownloadListItem.olderItem @@ -116,46 +84,46 @@ final class DownloadListStoreTests: XCTestCase { waitForExpectations(timeout: 1) } - func testWhenDownloadsCleared_ThenNoItemsLoaded() throws { - save(.testItem, expectation: self.expectation(description: "Saving 1")) - save(.olderItem, expectation: self.expectation(description: "Saving 2")) - - store.clear() - - let loadingExpectation = self.expectation(description: "Loading") - store.fetch { result in - loadingExpectation.fulfill() - guard case .success(let items) = result else { XCTFail("unexpected failure \(result)"); return } - - XCTAssertEqual(items, []) - } - - waitForExpectations(timeout: 1) - } - } -extension DownloadListItem { +private extension DownloadListItem { static let testItem = DownloadListItem(identifier: UUID(), added: Date(), modified: Date(), - url: URL(string: "https://duckduckgo.com/testdload")!, - websiteURL: URL(string: "https://duckduckgo.com")!, + downloadURL: URL(string: "https://duckduckgo.com/testdload")!, + websiteURL: .duckDuckGo, + fileName: "fileName", progress: nil, isBurner: false, - fileType: .pdf, - destinationURL: URL(fileURLWithPath: "/test/file/path"), + destinationURL: URL(fileURLWithPath: "/test/path"), + destinationFileBookmarkData: nil, tempURL: URL(fileURLWithPath: "/temp/file/path"), + tempFileBookmarkData: nil, error: nil) + static let oldItem = DownloadListItem(identifier: UUID(), + added: .daysAgo(30), + modified: .daysAgo(3), + downloadURL: .duckDuckGo, + websiteURL: .duckDuckGo, + fileName: "fileName", + progress: nil, + isBurner: false, + destinationURL: URL(fileURLWithPath: "/test/path"), + destinationFileBookmarkData: nil, + tempURL: nil, + tempFileBookmarkData: nil, + error: nil) static let olderItem = DownloadListItem(identifier: UUID(), - added: Date.daysAgo(30), - modified: Date.daysAgo(1), - url: URL(string: "https://testdownload.com")!, + added: .daysAgo(30), + modified: .daysAgo(1), + downloadURL: URL(string: "https://testdownload.com")!, websiteURL: nil, + fileName: "fileName", progress: nil, isBurner: false, - fileType: .jpeg, destinationURL: URL(fileURLWithPath: "/test/path.jpeg"), + destinationFileBookmarkData: nil, tempURL: nil, + tempFileBookmarkData: nil, error: nil) } diff --git a/UnitTests/FileDownload/FileDownloadManagerTests.swift b/UnitTests/FileDownload/FileDownloadManagerTests.swift index fd3cfff755..0882a65371 100644 --- a/UnitTests/FileDownload/FileDownloadManagerTests.swift +++ b/UnitTests/FileDownload/FileDownloadManagerTests.swift @@ -61,6 +61,7 @@ final class FileDownloadManagerTests: XCTestCase { preferences.alwaysRequestDownloadLocation = false } + @MainActor func testWhenDownloadIsAddedThenItsPublished() { let e = expectation(description: "empty downloads populated") let cancellable = dm.downloadsPublisher.sink { _ in @@ -68,13 +69,14 @@ final class FileDownloadManagerTests: XCTestCase { } let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: nil, location: .auto) + dm.add(download, fromBurnerWindow: false, delegate: nil, destination: .auto) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 0.3) } } + @MainActor func testWhenDownloadFailsInstantlyThenItsRemoved() { let e = expectation(description: "FileDownloadTask populated") let cancellable = dm.downloadsPublisher.sink { _ in @@ -82,7 +84,7 @@ final class FileDownloadManagerTests: XCTestCase { } let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: nil, location: .auto) + dm.add(download, fromBurnerWindow: false, delegate: nil, destination: .auto) struct TestError: Error {} download.delegate?.download!(download.asWKDownload(), didFailWithError: TestError(), resumeData: nil) @@ -93,23 +95,35 @@ final class FileDownloadManagerTests: XCTestCase { XCTAssertTrue(dm.downloads.isEmpty) } - func testWhenDownloadFinishesInstantlyThenItsRemoved() { + @MainActor + func testWhenDownloadFinishesInstantlyThenItsRemoved() async { let e = expectation(description: "FileDownloadTask populated") let cancellable = dm.downloadsPublisher.sink { _ in e.fulfill() } let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: nil, location: .auto) + let tempURL = fm.temporaryDirectory.appendingPathComponent("download") + dm.add(download, fromBurnerWindow: false, 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)) download.delegate?.downloadDidFinish!(download.asWKDownload()) - - withExtendedLifetime(cancellable) { - waitForExpectations(timeout: 0.3) + let eDownloadRemoved = expectation(description: "FileDownloadTask removed") + let t = Task { + while !dm.downloads.isEmpty { + try await Task.sleep(interval: 0.01) + } + eDownloadRemoved.fulfill() } - XCTAssertTrue(dm.downloads.isEmpty) + + await fulfillment(of: [e, eDownloadRemoved], timeout: 1) + withExtendedLifetime(cancellable) {} + t.cancel() } + @MainActor func testWhenRequiredByDownloadRequestThenDownloadLocationChooserIsCalled() { let downloadsURL = fm.temporaryDirectory preferences.selectedDownloadLocation = downloadsURL @@ -126,7 +140,7 @@ final class FileDownloadManagerTests: XCTestCase { } let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: self, location: .prompt) + dm.add(download, fromBurnerWindow: false, 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") @@ -142,6 +156,7 @@ final class FileDownloadManagerTests: XCTestCase { XCTAssertEqual(preferences.lastUsedCustomDownloadLocation, lastUsedCustomDownloadLocation, "lastUsedCustomDownloadLocation shouldn‘t change") } + @MainActor func testWhenRequiredByPreferencesThenDownloadLocationChooserIsCalled() { preferences.alwaysRequestDownloadLocation = true let downloadsURL = fm.temporaryDirectory @@ -159,7 +174,7 @@ final class FileDownloadManagerTests: XCTestCase { } let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: self, location: .auto) + dm.add(download, fromBurnerWindow: false, 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") @@ -168,7 +183,6 @@ final class FileDownloadManagerTests: XCTestCase { decideDestinationUsing: response, suggestedFilename: "suggested.filename") { url in dispatchPrecondition(condition: .onQueue(.main)) - XCTAssertEqual(url, localURL.appendingPathExtension(WebKitDownloadTask.downloadExtension)) e2.fulfill() } @@ -176,20 +190,19 @@ final class FileDownloadManagerTests: XCTestCase { XCTAssertEqual(preferences.lastUsedCustomDownloadLocation, downloadsURL, "lastUsedCustomDownloadLocation should be saved") } + @MainActor func testWhenChosenDownloadLocationExistsThenItsOverwritten() { let localURL = fm.temporaryDirectory.appendingPathComponent(testFile + ".jpg") - fm.createFile(atPath: localURL.path, contents: nil, attributes: nil) - XCTAssertTrue(fm.fileExists(atPath: localURL.path)) + XCTAssertTrue(fm.createFile(atPath: localURL.path, contents: nil, attributes: nil)) let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: self, location: .prompt) + dm.add(download, fromBurnerWindow: false, delegate: self, destination: .prompt) self.chooseDestination = { _, _, callback in callback(localURL, nil) } let e = expectation(description: "WKDownload called") download.delegate?.download(download.asWKDownload(), decideDestinationUsing: response, suggestedFilename: "suggested.filename") { url in - XCTAssertEqual(url, localURL.appendingPathExtension(WebKitDownloadTask.downloadExtension)) e.fulfill() } @@ -199,6 +212,7 @@ final class FileDownloadManagerTests: XCTestCase { XCTAssertEqual(preferences.lastUsedCustomDownloadLocation, localURL.deletingLastPathComponent(), "lastUsedCustomDownloadLocation should be saved") } + @MainActor func testWhenDownloadingLocalFileThenLocationChooserIsCalled() { let downloadsURL = fm.temporaryDirectory preferences.selectedDownloadLocation = downloadsURL @@ -214,11 +228,10 @@ final class FileDownloadManagerTests: XCTestCase { callback(localURL, .html) } - dm.add(download, fromBurnerWindow: false, delegate: self, location: .auto) + dm.add(download, fromBurnerWindow: false, delegate: self, destination: .auto) let e2 = expectation(description: "WKDownload called") - download.delegate?.download(download.asWKDownload(), decideDestinationUsing: response, suggestedFilename: testFile) { [testFile] url in - XCTAssertEqual(url, downloadsURL.appendingPathComponent(testFile).appendingPathExtension(WebKitDownloadTask.downloadExtension)) + download.delegate?.download(download.asWKDownload(), decideDestinationUsing: response, suggestedFilename: testFile) { url in e2.fulfill() } @@ -226,59 +239,70 @@ final class FileDownloadManagerTests: XCTestCase { XCTAssertEqual(preferences.lastUsedCustomDownloadLocation, downloadsURL, "lastUsedCustomDownloadLocation should be saved") } + @MainActor func testWhenNotRequiredByPreferencesThenDefaultDownloadLocationIsChosen() { let downloadsURL = fm.temporaryDirectory preferences.selectedDownloadLocation = downloadsURL let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: self, location: .auto) + dm.add(download, fromBurnerWindow: false, delegate: self, destination: .auto) self.chooseDestination = { _, _, _ in XCTFail("Unpected chooseDestination call") } let e = expectation(description: "WKDownload called") - download.delegate?.download(download.asWKDownload(), decideDestinationUsing: response, suggestedFilename: testFile) { [testFile] url in - XCTAssertEqual(url, downloadsURL.appendingPathComponent(testFile).appendingPathExtension(WebKitDownloadTask.downloadExtension)) + download.delegate?.download(download.asWKDownload(), decideDestinationUsing: response, suggestedFilename: testFile) { url in + XCTAssertNotNil(url) e.fulfill() } waitForExpectations(timeout: 0.3) } + @MainActor func testWhenSuggestedFilenameIsEmptyThenItsUniquelyGenerated() { let downloadsURL = fm.temporaryDirectory preferences.selectedDownloadLocation = downloadsURL let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: self, location: .auto) + dm.add(download, fromBurnerWindow: false, delegate: self, destination: .auto) self.chooseDestination = { _, _, _ in XCTFail("Unpected chooseDestination call") } let e = expectation(description: "WKDownload called") download.delegate?.download(download.asWKDownload(), decideDestinationUsing: response, suggestedFilename: "") { url in - XCTAssertEqual(url?.pathExtension, WebKitDownloadTask.downloadExtension) + XCTAssertEqual(url?.lastPathComponent, "download.html") XCTAssertTrue(url?.lastPathComponent.count ?? 0 > WebKitDownloadTask.downloadExtension.count + 1) - XCTAssertEqual(url?.deletingLastPathComponent().path, downloadsURL.path) e.fulfill() } waitForExpectations(timeout: 0.3) } - func testWhenDefaultDownloadsLocationIsReadOnlyThenDownloadFails() { + @MainActor + func testWhenDefaultDownloadsLocationIsNotWritableThenDownloadLocationIsRequested() { preferences.selectedDownloadLocation = nil FileManager.swizzleUrlsForIn { _, _ in [URL(fileURLWithPath: "/")] } let download = WKDownloadMock(url: .duckDuckGo) - dm.add(download, fromBurnerWindow: false, delegate: self, location: .auto) + dm.add(download, fromBurnerWindow: false, delegate: self, destination: .auto) - let e = expectation(description: "WKDownload called") - download.delegate?.download(download.asWKDownload(), decideDestinationUsing: response, suggestedFilename: "") { url in + let e1 = expectation(description: "download location requested") + self.chooseDestination = { suggestedFilename, fileTypes, callback in + dispatchPrecondition(condition: .onQueue(.main)) + XCTAssertEqual(suggestedFilename, "suggested.filename") + e1.fulfill() + + callback(nil, nil) + } + + let e2 = expectation(description: "WKDownload called") + download.delegate?.download(download.asWKDownload(), decideDestinationUsing: response, suggestedFilename: "suggested.filename") { url in XCTAssertNil(url) - e.fulfill() + e2.fulfill() } waitForExpectations(timeout: 0.3) diff --git a/UnitTests/FileDownload/FilePresenterTests.swift b/UnitTests/FileDownload/FilePresenterTests.swift new file mode 100644 index 0000000000..fb0390c09d --- /dev/null +++ b/UnitTests/FileDownload/FilePresenterTests.swift @@ -0,0 +1,780 @@ +// +// FilePresenterTests.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. +// + +import Combine +import Foundation +import XCTest + +@testable import DuckDuckGo_Privacy_Browser + +@available(macOS 12.0, *) +final class FilePresenterTests: XCTestCase { + + let fm = FileManager() + let testData = "test data".utf8data + let helperApp = URL(fileURLWithPath: ProcessInfo().environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"]!).appendingPathComponent("sandbox-test-tool.app") + var runningApp: NSRunningApplication? + var cancellables = Set() + + var onFileRead: ((FileReadResult) -> Void)? + var onError: ((NSError) -> Void)? + + override func setUp() async throws { + DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.fileRead.name).sink { [unowned self] n in + guard let onFileRead, + let object = n.object as? String else { + XCTFail("❌ unexpected file read: \(n)") + return + } + do { + let result = try FileReadResult.decode(from: object) + onFileRead(result) + } catch { + XCTFail("❌ could not decode FileReadResult from \(object): \(error)") + } + }.store(in: &cancellables) + + DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.error.name).sink { [unowned self] n in + guard let onError, + let error = n.error(includingUserInfo: false) else { + XCTFail("❌ unexpected error: \(n)") + return + } + onError(error) + }.store(in: &cancellables) + } + + override func tearDown() async throws { + await terminateApp() + cancellables.removeAll() + onError = nil + onFileRead = nil + NSURL.swizzleStopAccessingSecurityScopedResource(with: nil) + } + + private func makeNonSandboxFile() throws -> URL { + let fileName = UUID().uuidString + ".txt" + let fileURL = fm.temporaryDirectory.appendingPathComponent(fileName) + try testData.write(to: fileURL) + + return fileURL + } + + private func runHelperApp(opening url: URL? = nil, newInstance: Bool = true, helloExpectation: XCTestExpectation? = XCTestExpectation(description: "hello received")) async throws -> NSRunningApplication { + var c: AnyCancellable? + if let helloExpectation { + c = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.hello.name).sink { n in + helloExpectation.fulfill() + } + } + + let app = if let url { + try await NSWorkspace.shared.open([url], withApplicationAt: helperApp, configuration: .init(newInstance: newInstance, environment: [:])) + } else { + try await NSWorkspace.shared.openApplication(at: helperApp, configuration: .init(newInstance: newInstance, environment: [:])) + } + + await fulfillment(of: helloExpectation.map { [$0] } ?? [], timeout: 5) + withExtendedLifetime(c) {} + + return app + } + + private func terminateApp(timeout: TimeInterval = 1) async { + let eTerminated = runningApp != nil ? expectation(description: "terminated") : nil + let c = runningApp?.publisher(for: \.isTerminated).filter { $0 }.sink { _ in + eTerminated?.fulfill() + } + post(.terminate) + runningApp?.forceTerminate() + + await fulfillment(of: eTerminated.map { [$0] } ?? [], timeout: timeout) + withExtendedLifetime(c) {} + } + + private func post(_ name: SandboxTestNotification, with object: String? = nil) { + DistributedNotificationCenter.default().post(name: .init(name.rawValue), object: object) + } + + private func fileReadPromise(timeout: TimeInterval = 5) -> Future { + Future { [unowned self] fulfill in + onFileRead = { result in + fulfill(.success(result)) + self.onFileRead = nil + self.onError = nil + } + onError = { error in + fulfill(.failure(error)) + self.onFileRead = nil + self.onError = nil + } + } + .timeout(timeout) + .first() + .promise() + } + + // MARK: - Test sandboxed file access +#if APPSTORE && !CI + func testTool_run() async throws { + // 1. make non-sandbox file + let nonSandboxUrl = try makeNonSandboxFile() + + // 2. run the helper app + runningApp = try await runHelperApp() + + // 3. send command to open the non-sandbox file + let fileReadPromise = fileReadPromise() + post(.openFileWithoutBookmark, with: nonSandboxUrl.path) + + // 4. Validate file opening failed + do { + let r = try await fileReadPromise.value + XCTFail("File should be inaccessible, got \(r)") + } catch let error as NSError { + XCTAssertEqual(error, CocoaError(.fileReadNoPermission) as NSError) + } + } + + func testTool_bookmarkCreation() async throws { + // 1. make non-sandbox file + let nonSandboxUrl = try makeNonSandboxFile() + + // 2. open the file with the helper app + let fileReadPromise = fileReadPromise() + runningApp = try await runHelperApp(opening: nonSandboxUrl) + + // 3. Validate file opened successfully + let result = try await fileReadPromise.value + XCTAssertEqual(result.path, nonSandboxUrl.path) + XCTAssertEqual(result.data, testData.utf8String()) + XCTAssertNotNil(result.bookmark) + } + + func testWhenSandboxFilePresenterIsOpen_itCanReadFile_accessStoppedWhenClosed() async throws { + // 1. make non-sandbox file; open the file and create bookmark with the helper app + let nonSandboxUrl = try makeNonSandboxFile() + var fileReadPromise = self.fileReadPromise() + runningApp = try await runHelperApp(opening: nonSandboxUrl) + guard let bookmark = try await fileReadPromise.value.bookmark else { XCTFail("No bookmark"); return } + + // 2. restart the app + await terminateApp() + runningApp = try await runHelperApp() + + // 3. open the bookmark with SandboxFilePresenter + post(.openBookmarkWithFilePresenter, with: bookmark.base64EncodedString()) + + // 4. read the file + fileReadPromise = self.fileReadPromise() + post(.openFile, with: nonSandboxUrl.path) + let result = try await fileReadPromise.value + XCTAssertEqual(result.path, nonSandboxUrl.path) + XCTAssertEqual(result.data, testData.utf8String()) + XCTAssertEqual(result.bookmark, bookmark) + + // 5. close SandboxFilePresenter + let e = expectation(description: "access stopped") + let c = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.stopAccessingSecurityScopedResourceCalled.name).sink { _ in + e.fulfill() + } + post(.closeFilePresenter, with: nonSandboxUrl.path) + await fulfillment(of: [e], timeout: 1) + + withExtendedLifetime(c) {} + + // 6. Validate file reading fails + fileReadPromise = self.fileReadPromise() + post(.openFile, with: nonSandboxUrl.path) + do { + let r = try await fileReadPromise.value + XCTFail("File should be inaccessible, got \(r)") + } catch let error as NSError { + XCTAssertEqual(error, CocoaError(.fileReadNoPermission) as NSError) + } + } + + func testWhenFileIsRenamed_accessIsPreserved() async throws { + // 1. make non-sandbox file; open the file and create bookmark with the helper app + let nonSandboxUrl = try makeNonSandboxFile() + var fileReadPromise = self.fileReadPromise() + runningApp = try await runHelperApp(opening: nonSandboxUrl) + guard let bookmark = try await fileReadPromise.value.bookmark else { XCTFail("No bookmark"); return } + + // 2. restart the app + await terminateApp() + runningApp = try await runHelperApp() + + // 3. open the bookmark with SandboxFilePresenter + post(.openBookmarkWithFilePresenter, with: bookmark.base64EncodedString()) + fileReadPromise = self.fileReadPromise() + post(.openFile, with: nonSandboxUrl.path) + _=try await fileReadPromise.value + + // 4.a rename the file + let newUrl = nonSandboxUrl.deletingPathExtension().appendingPathExtension("1.txt") + let e1 = expectation(description: "file presenter: file renamed") + var c1 = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.fileMoved.name).sink { n in + XCTAssertEqual(newUrl.path, n.object as? String) + e1.fulfill() + } + let e2 = expectation(description: "file presenter: bookmark updated") + var c2 = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.fileBookmarkDataUpdated.name).sink { n in + e2.fulfill() + } + + try NSFileCoordinator().coordinateMove(from: nonSandboxUrl, to: newUrl) { from, to in + try FileManager.default.moveItem(at: from, to: to) + } + await fulfillment(of: [e1, e2], timeout: 5) + + // 4.b rename the file twice + let newUrl2 = nonSandboxUrl.deletingPathExtension().appendingPathExtension("2.txt") + let e3 = expectation(description: "file presenter: file renamed 2") + c1 = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.fileMoved.name).sink { n in + XCTAssertEqual(newUrl2.path, n.object as? String) + e3.fulfill() + } + let e4 = expectation(description: "file presenter: bookmark updated 2") + var newFileBookmarkData: Data? + c2 = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.fileBookmarkDataUpdated.name).sink { n in + newFileBookmarkData = (n.object as? String).flatMap { Data(base64Encoded: $0) } + e4.fulfill() + } + + try NSFileCoordinator().coordinateMove(from: newUrl, to: newUrl2) { from, to in + try FileManager.default.moveItem(at: from, to: to) + } + await fulfillment(of: [e3, e4], timeout: 5) + withExtendedLifetime((c1, c2)) {} + + // 5. read the renamed file + fileReadPromise = self.fileReadPromise() + post(.openFile, with: newUrl2.path) + let result = try await fileReadPromise.value + XCTAssertEqual(result.path, newUrl2.path) + XCTAssertEqual(result.data, testData.utf8String()) + // bookmark should update + XCTAssertNotNil(result.bookmark) + XCTAssertNotEqual(result.bookmark, bookmark) + XCTAssertEqual(result.bookmark, newFileBookmarkData) + } + + func testWhenFileIsRenamed_renamedFileCanBeRead() async throws { + // 1. make non-sandbox file; open the file and create bookmark with the helper app + let nonSandboxUrl = try makeNonSandboxFile() + var fileReadPromise = self.fileReadPromise() + runningApp = try await runHelperApp(opening: nonSandboxUrl) + guard let bookmark = try await fileReadPromise.value.bookmark else { XCTFail("No bookmark"); return } + + // 2. rename the file + let newUrl = nonSandboxUrl.deletingPathExtension().appendingPathExtension("1.txt") + try FileManager.default.moveItem(at: nonSandboxUrl, to: newUrl) + + // 3. restart the app + await terminateApp() + runningApp = try await runHelperApp() + + // 3. open the bookmark with SandboxFilePresenter + post(.openBookmarkWithFilePresenter, with: bookmark.base64EncodedString()) + fileReadPromise = self.fileReadPromise() + + // 4. read the renamed file + fileReadPromise = self.fileReadPromise() + post(.openFile, with: newUrl.path) + let result = try await fileReadPromise.value + XCTAssertEqual(result.path, newUrl.path) + XCTAssertEqual(result.data, testData.utf8String()) + // bookmark should update + XCTAssertNotNil(result.bookmark) + XCTAssertNotEqual(result.bookmark, bookmark) + } + + func testWhenFileIsMovedToTrash_moveIsDetected() async throws { + // 1. make non-sandbox file; open the file and create bookmark with the helper app + let nonSandboxUrl = try makeNonSandboxFile() + var fileReadPromise = self.fileReadPromise() + runningApp = try await runHelperApp(opening: nonSandboxUrl) + guard let bookmark = try await fileReadPromise.value.bookmark else { XCTFail("No bookmark"); return } + + // 2. restart the app + await terminateApp() + runningApp = try await runHelperApp() + + // 3. open the bookmark with SandboxFilePresenter + post(.openBookmarkWithFilePresenter, with: bookmark.base64EncodedString()) + fileReadPromise = self.fileReadPromise() + post(.openFile, with: nonSandboxUrl.path) + _=try await fileReadPromise.value + + // 4. rename the file + var newUrl: NSURL? + let e1 = expectation(description: "file presenter: file renamed") + let c1 = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.fileMoved.name).sink { n in + XCTAssertNotNil(newUrl) + XCTAssertEqual(newUrl?.path, n.object as? String) + e1.fulfill() + } + let e2 = expectation(description: "file presenter: bookmark updated") + let c2 = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.fileBookmarkDataUpdated.name).sink { n in + XCTAssertNotNil((n.object as? String).flatMap { Data(base64Encoded: $0) }) + e2.fulfill() + } + + try NSFileCoordinator().coordinateWrite(at: nonSandboxUrl, with: .forMoving) { url in + try FileManager.default.trashItem(at: url, resultingItemURL: &newUrl) + } + await fulfillment(of: [e1, e2], timeout: 5) + + withExtendedLifetime((c1, c2)) {} + } + + func testWhenSandboxFilePresenterIsOpenAndFileIsRenamed_accessStoppedWhenClosed() async throws { + // 1. make non-sandbox file; open the file and create bookmark with the helper app + let nonSandboxUrl = try makeNonSandboxFile() + var fileReadPromise = self.fileReadPromise() + runningApp = try await runHelperApp(opening: nonSandboxUrl) + guard let bookmark = try await fileReadPromise.value.bookmark else { XCTFail("No bookmark"); return } + + // 2. restart the app + await terminateApp() + runningApp = try await runHelperApp() + + // 3. open the bookmark with SandboxFilePresenter + post(.openBookmarkWithFilePresenter, with: bookmark.base64EncodedString()) + fileReadPromise = self.fileReadPromise() + post(.openFile, with: nonSandboxUrl.path) + _=try await fileReadPromise.value + + // 4. rename the file + let newUrl = nonSandboxUrl.deletingPathExtension().appendingPathExtension("1.txt") + let e = expectation(description: "file presenter: file renamed") + let c = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.fileMoved.name).sink { n in + XCTAssertEqual(newUrl.path, n.object as? String) + e.fulfill() + } + + try NSFileCoordinator().coordinateMove(from: nonSandboxUrl, to: newUrl) { from, to in + try FileManager.default.moveItem(at: from, to: to) + } + await fulfillment(of: [e], timeout: 5) + + // 5. close SandboxFilePresenter + let eStopped = expectation(description: "access stopped") + let c2 = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.stopAccessingSecurityScopedResourceCalled.name).sink { _ in + eStopped.fulfill() + } + post(.closeFilePresenter, with: nonSandboxUrl.path) + await fulfillment(of: [eStopped], timeout: 1) + + withExtendedLifetime((c, c2)) {} + + // 6. Validate file reading fails + fileReadPromise = self.fileReadPromise() + post(.openFile, with: newUrl.path) + do { + let r = try await fileReadPromise.value + XCTFail("File should be inaccessible, got \(r)") + } catch let error as NSError { + XCTAssertEqual(error, CocoaError(.fileReadNoPermission) as NSError) + } + } + + func testWhenSandboxFilePresenterIsClosed_fileRenameIsNotDetected() async throws { + // 1. make non-sandbox file; open the file and create bookmark with the helper app + let nonSandboxUrl = try makeNonSandboxFile() + var fileReadPromise = self.fileReadPromise() + runningApp = try await runHelperApp(opening: nonSandboxUrl) + guard let bookmark = try await fileReadPromise.value.bookmark else { XCTFail("No bookmark"); return } + + // 2. restart the app + await terminateApp() + runningApp = try await runHelperApp() + + // 3. open the bookmark with SandboxFilePresenter + post(.openBookmarkWithFilePresenter, with: bookmark.base64EncodedString()) + fileReadPromise = self.fileReadPromise() + post(.openFile, with: nonSandboxUrl.path) + _=try await fileReadPromise.value + + // 4. close SandboxFilePresenter + let eStopped = expectation(description: "access stopped") + let c2 = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.stopAccessingSecurityScopedResourceCalled.name).sink { _ in + eStopped.fulfill() + } + post(.closeFilePresenter, with: nonSandboxUrl.path) + await fulfillment(of: [eStopped], timeout: 1) + + // 5. rename the file + let newUrl = nonSandboxUrl.deletingPathExtension().appendingPathExtension("1.txt") + let e = expectation(description: "file presenter: file renamed - should not be fulfilled") + e.isInverted = true + let c = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.fileMoved.name).sink { n in + e.fulfill() + } + try NSFileCoordinator().coordinateMove(from: nonSandboxUrl, to: newUrl) { from, to in + try FileManager.default.moveItem(at: from, to: to) + } + + // 6. Validate file reading fails + fileReadPromise = self.fileReadPromise() + post(.openFile, with: newUrl.path) + do { + let r = try await fileReadPromise.value + XCTFail("File should be inaccessible, got \(r)") + } catch let error as NSError { + XCTAssertEqual(error, CocoaError(.fileReadNoPermission) as NSError) + } + // file renamed callback shouldn‘t be called (e is inverted) + await fulfillment(of: [e], timeout: 0) + withExtendedLifetime((c, c2)) {} + } + + func testWhenFileIsRenamedAndRecreatedWithOriginalName_fileIsNotAccessible() async throws { + // 1. make non-sandbox file; open the file and create bookmark with the helper app + let nonSandboxUrl = try makeNonSandboxFile() + var fileReadPromise = self.fileReadPromise() + runningApp = try await runHelperApp(opening: nonSandboxUrl) + guard let bookmark = try await fileReadPromise.value.bookmark else { XCTFail("No bookmark"); return } + + // 2. restart the app + await terminateApp() + runningApp = try await runHelperApp() + + // 3. open the bookmark with SandboxFilePresenter + post(.openBookmarkWithFilePresenter, with: bookmark.base64EncodedString()) + fileReadPromise = self.fileReadPromise() + post(.openFile, with: nonSandboxUrl.path) + _=try await fileReadPromise.value + + // 4. rename the file + let newUrl = nonSandboxUrl.deletingPathExtension().appendingPathExtension("1.txt") + let e = expectation(description: "file presenter: file renamed") + let c = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.fileMoved.name).sink { n in + XCTAssertEqual(newUrl.path, n.object as? String) + e.fulfill() + } + + try NSFileCoordinator().coordinateMove(from: nonSandboxUrl, to: newUrl) { from, to in + try FileManager.default.moveItem(at: from, to: to) + } + await fulfillment(of: [e], timeout: 5) + + // 5. create a new file with original name + try testData.write(to: nonSandboxUrl) + + // 6. read the re-created file - should fail + fileReadPromise = self.fileReadPromise() + + post(.openFile, with: nonSandboxUrl.path) + do { + let r = try await fileReadPromise.value + XCTFail("File should be inaccessible, got \(r)") + } catch let error as NSError { + XCTAssertEqual(error, CocoaError(.fileReadNoPermission) as NSError) + } + + withExtendedLifetime(c) {} + } + + func testWhenFileIsRemoved_removalIsDetected() async throws { + // 1. make non-sandbox file; open the file and create bookmark with the helper app + let nonSandboxUrl = try makeNonSandboxFile() + var fileReadPromise = self.fileReadPromise() + runningApp = try await runHelperApp(opening: nonSandboxUrl) + guard let bookmark = try await fileReadPromise.value.bookmark else { XCTFail("No bookmark"); return } + + // 2. restart the app + await terminateApp() + runningApp = try await runHelperApp() + + // 3. open the bookmark with SandboxFilePresenter + post(.openBookmarkWithFilePresenter, with: bookmark.base64EncodedString()) + fileReadPromise = self.fileReadPromise() + post(.openFile, with: nonSandboxUrl.path) + _=try await fileReadPromise.value + + // 4. remove the file + let e1 = expectation(description: "file presenter: file removed") + let e2 = expectation(description: "file presenter: bookmark updated") + let c1 = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.fileMoved.name).sink { n in + XCTAssertNil(n.object) + e1.fulfill() + } + let c2 = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.fileBookmarkDataUpdated.name).sink { n in + XCTAssertNil(n.object) + e2.fulfill() + } + + try NSFileCoordinator().coordinateWrite(at: nonSandboxUrl, with: .forDeleting) { url in + try FileManager.default.removeItem(at: url) + } + await fulfillment(of: [e1, e2], timeout: 5) + withExtendedLifetime((c1, c2)) {} + } + + func testWhen2FilesAreCrossRenamedAnd1stFileClosed_accessTo2ndIsPreserved() async throws { + // 1. make 2 non-sandbox files; open the files and create bookmarks with the helper app + let nonSandboxUrl1 = try makeNonSandboxFile() + let nonSandboxUrl2 = try makeNonSandboxFile() + var fileReadPromise = self.fileReadPromise() + runningApp = try await runHelperApp(opening: nonSandboxUrl1) + guard let bookmark1 = try await fileReadPromise.value.bookmark else { XCTFail("No bookmark"); return } + fileReadPromise = self.fileReadPromise() + _=try await runHelperApp(opening: nonSandboxUrl2, newInstance: false, helloExpectation: nil) + guard let bookmark2 = try await fileReadPromise.value.bookmark else { XCTFail("No bookmark"); return } + + // 2. restart the app + await terminateApp() + runningApp = try await runHelperApp() + + // 3. open the bookmark with SandboxFilePresenter + post(.openBookmarkWithFilePresenter, with: bookmark1.base64EncodedString()) + post(.openBookmarkWithFilePresenter, with: bookmark2.base64EncodedString()) + fileReadPromise = self.fileReadPromise() + post(.openFile, with: nonSandboxUrl1.path) + _=try await fileReadPromise.value + fileReadPromise = self.fileReadPromise() + post(.openFile, with: nonSandboxUrl2.path) + _=try await fileReadPromise.value + + // 4. cross-rename the files + let tempUrl = nonSandboxUrl1.appendingPathExtension("tmp") + for (from, to) in [(nonSandboxUrl1, tempUrl), (nonSandboxUrl2, nonSandboxUrl1), (tempUrl, nonSandboxUrl2)] { + let e = expectation(description: "file presenter: file renamed") + let c = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.fileMoved.name).sink { n in + XCTAssertEqual(to.path, n.object as? String) + e.fulfill() + } + try NSFileCoordinator().coordinateMove(from: from, to: to) { from, to in + try FileManager.default.moveItem(at: from, to: to) + } + await fulfillment(of: [e], timeout: 5) + withExtendedLifetime(c) {} + } + + // 5. close FilePresenter 1 (at the original URL) + let e3 = expectation(description: "access stopped") + let c2 = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.stopAccessingSecurityScopedResourceCalled.name).sink { n in + XCTAssertEqual(n.object as? String, nonSandboxUrl1.path) + e3.fulfill() + } + post(.closeFilePresenter, with: nonSandboxUrl1.path) + await fulfillment(of: [e3], timeout: 1) + + withExtendedLifetime(c2) {} + + // 6.a validate 2nd file (renamed to nonSandboxUrl1) can still be accessed + fileReadPromise = self.fileReadPromise() + post(.openFile, with: nonSandboxUrl1.path) + let result = try await fileReadPromise.value + XCTAssertEqual(result.path, nonSandboxUrl1.path) + XCTAssertEqual(result.data, testData.utf8String()) + // bookmark should update + XCTAssertNotNil(result.bookmark) + + // 6.b 1st file read should fail + fileReadPromise = self.fileReadPromise() + + post(.openFile, with: nonSandboxUrl2.path) + do { + let r = try await fileReadPromise.value + XCTFail("File should be inaccessible, got \(r)") + } catch let error as NSError { + XCTAssertEqual(error, CocoaError(.fileReadNoPermission) as NSError) + } + } + + func testWhenFilePresenterClosesFileOpenedByOS_fileAccessIsPreserved() async throws { + // 1. make non-sandbox file and open the file with the helper app + let nonSandboxUrl = try makeNonSandboxFile() + var fileReadPromise = self.fileReadPromise() + runningApp = try await runHelperApp(opening: nonSandboxUrl) + guard let bookmark = try await fileReadPromise.value.bookmark else { XCTFail("No bookmark"); return } + + // 2. open file presenter + post(.openBookmarkWithFilePresenter, with: bookmark.base64EncodedString()) + + // 3. close the file presenter + let e = expectation(description: "access stopped") + let c = DistributedNotificationCenter.default().publisher(for: SandboxTestNotification.stopAccessingSecurityScopedResourceCalled.name).sink { n in + XCTAssertEqual(n.object as? String, nonSandboxUrl.path) + e.fulfill() + } + post(.closeFilePresenter, with: nonSandboxUrl.path) + await fulfillment(of: [e], timeout: 1) + withExtendedLifetime(c) {} + + // 4. validate file can still be read + fileReadPromise = self.fileReadPromise() + post(.openFile, with: nonSandboxUrl.path) + let result = try await fileReadPromise.value + XCTAssertEqual(result.path, nonSandboxUrl.path) + } +#endif + + // MARK: - Test non-sandboxed file access + + func testWhenSandboxFilePresenterIsOpen_itCanReadFile_accessIsNotStoppedWhenClosed_noSandbox() async throws { + // 1. make non-sandbox file; create bookmark + let nonSandboxUrl = try makeNonSandboxFile() + guard let bookmarkData = try SandboxFilePresenter(url: nonSandboxUrl).fileBookmarkData else { XCTFail("No bookmark"); return } + + // 2. open the bookmark with SandboxFilePresenter + var filePresenter: SandboxFilePresenter! = try SandboxFilePresenter(fileBookmarkData: bookmarkData) + + // 3. validate + var publishedUrl: URL? + _=filePresenter.urlPublisher.sink { publishedUrl = $0 } + var publishedBookmarkData: Data? + _=filePresenter.fileBookmarkDataPublisher.sink { publishedBookmarkData = $0 } + XCTAssertEqual(filePresenter.url?.resolvingSymlinksInPath(), nonSandboxUrl.resolvingSymlinksInPath()) + XCTAssertEqual(publishedUrl?.resolvingSymlinksInPath(), nonSandboxUrl.resolvingSymlinksInPath()) + XCTAssertEqual(filePresenter.fileBookmarkData, bookmarkData) + XCTAssertEqual(publishedBookmarkData, bookmarkData) + + // 4. close file presenter, access should not stop + filePresenter = nil + XCTAssertEqual(try Data(contentsOf: nonSandboxUrl), testData) + } + + func testWhenFileIsRenamed_urlIsUpdated_noSandbox() async throws { + // 1. make non-sandbox file + let nonSandboxUrl = try makeNonSandboxFile() + let filePresenter = try SandboxFilePresenter(url: nonSandboxUrl) + + // 4. rename the file + let newUrl = nonSandboxUrl.deletingPathExtension().appendingPathExtension("1.txt") + let e1 = expectation(description: "file presenter: file renamed") + let c1 = filePresenter.urlPublisher.dropFirst().sink { url in + XCTAssertEqual(newUrl, url) + e1.fulfill() + } + let e2 = expectation(description: "file presenter: bookmark updated") + var newFileBookmarkData: Data? + let c2 = filePresenter.fileBookmarkDataPublisher.dropFirst().sink { bookmark in + newFileBookmarkData = bookmark + e2.fulfill() + } + + try NSFileCoordinator().coordinateMove(from: nonSandboxUrl, to: newUrl) { from, to in + try FileManager.default.moveItem(at: from, to: to) + } + await fulfillment(of: [e1, e2], timeout: 5) + withExtendedLifetime((c1, c2)) {} + + let bookmarkData = try newUrl.bookmarkData(options: .withSecurityScope) + + // url&bookmark should update + XCTAssertEqual(filePresenter.url, newUrl) + XCTAssertEqual(filePresenter.fileBookmarkData, bookmarkData) + XCTAssertEqual(newFileBookmarkData, bookmarkData) + } + + func testWhenFileIsRemoved_removalIsDetected_noSandbox() async throws { + // 1. make non-sandbox file + let nonSandboxUrl = try makeNonSandboxFile() + let filePresenter = try SandboxFilePresenter(url: nonSandboxUrl) + + // 2. remove the file + let e1 = expectation(description: "file presenter: file removed") + let e2 = expectation(description: "file presenter: bookmark updated") + let c1 = filePresenter.urlPublisher.dropFirst().sink { url in + XCTAssertNil(url) + e1.fulfill() + } + let c2 = filePresenter.fileBookmarkDataPublisher.dropFirst().sink { bookmark in + XCTAssertNil(bookmark) + e2.fulfill() + } + + try NSFileCoordinator().coordinateWrite(at: nonSandboxUrl, with: .forDeleting) { url in + try FileManager.default.removeItem(at: url) + } + await fulfillment(of: [e1, e2], timeout: 5) + withExtendedLifetime((c1, c2)) {} + } + + func testWhen2FilesAreCrossRenamedAnd1stFileClosed_accessTo2ndIsPreserved_noSandbox() async throws { + // 1. make 2 non-sandbox files + let nonSandboxUrl1 = try makeNonSandboxFile() + let bookmarkData1 = try nonSandboxUrl1.bookmarkData(options: .withSecurityScope) + let nonSandboxUrl2 = try makeNonSandboxFile() + let bookmarkData2 = try nonSandboxUrl2.bookmarkData(options: .withSecurityScope) + let filePresenter1 = try SandboxFilePresenter(fileBookmarkData: bookmarkData1) + let filePresenter2 = try SandboxFilePresenter(fileBookmarkData: bookmarkData2) + + // 2. cross-rename the files + let tempUrl = nonSandboxUrl1.appendingPathExtension("tmp") + var newBookmarkData1: Data? + var newBookmarkData2: Data? + for (from, to, presenter) in [(nonSandboxUrl1, tempUrl, filePresenter1), (nonSandboxUrl2, nonSandboxUrl1, filePresenter2), (tempUrl, nonSandboxUrl2, filePresenter1)] { + let e1 = expectation(description: "file presenter: file renamed") + let c1 = presenter.urlPublisher.dropFirst().sink { [unowned presenter] url in + XCTAssertEqual(url, presenter.url) + XCTAssertEqual(to, url) + e1.fulfill() + } + let e2 = expectation(description: "file presenter: bookmark updated") + let c2 = presenter.fileBookmarkDataPublisher.dropFirst().sink { [unowned presenter] bookmarkData in + XCTAssertEqual(bookmarkData, presenter.fileBookmarkData) + if presenter === filePresenter1 { + newBookmarkData1 = bookmarkData + } else { + newBookmarkData2 = bookmarkData + } + e2.fulfill() + } + let c3 = ((presenter === filePresenter1) ? filePresenter2 : filePresenter1).urlPublisher.dropFirst().sink { _ in + XCTFail("Unexpected url published from another file presenter") + } + try NSFileCoordinator().coordinateMove(from: from, to: to) { from, to in + try FileManager.default.moveItem(at: from, to: to) + } + await fulfillment(of: [e1, e2], timeout: 5) + withExtendedLifetime((c1, c2, c3)) {} + } + + XCTAssertEqual(filePresenter1.url, nonSandboxUrl2) + XCTAssertEqual(filePresenter2.url, nonSandboxUrl1) + var isStale = false + XCTAssertEqual(newBookmarkData1, filePresenter1.fileBookmarkData) + XCTAssertEqual(try URL(resolvingBookmarkData: filePresenter1.fileBookmarkData ?? Data(), bookmarkDataIsStale: &isStale).resolvingSymlinksInPath(), + nonSandboxUrl2.resolvingSymlinksInPath()) + // XCTAssertFalse(isStale) - why it‘s false? + XCTAssertEqual(newBookmarkData2, filePresenter2.fileBookmarkData) + XCTAssertEqual(try URL(resolvingBookmarkData: filePresenter2.fileBookmarkData ?? Data(), bookmarkDataIsStale: &isStale).resolvingSymlinksInPath(), + nonSandboxUrl1.resolvingSymlinksInPath()) + // XCTAssertFalse(isStale) - why it‘s false? + } + +} + +private extension Notification { + func error(includingUserInfo: Bool = true) -> NSError? { + guard let object = object as? String, + let dict = try? JSONSerialization.jsonObject(with: object.utf8data) as? [String: Any], + let domain = dict[UserInfoKeys.errorDomain] as? String, + let code = dict[UserInfoKeys.errorCode] as? Int + else { return nil } + if includingUserInfo { + return NSError(domain: domain, code: code, userInfo: dict) + } + return NSError(domain: domain, code: code) + } +} diff --git a/UnitTests/FileDownload/Helpers/DownloadListStoreMock.swift b/UnitTests/FileDownload/Helpers/DownloadListStoreMock.swift index bf6fc31cb9..28001ba4e1 100644 --- a/UnitTests/FileDownload/Helpers/DownloadListStoreMock.swift +++ b/UnitTests/FileDownload/Helpers/DownloadListStoreMock.swift @@ -21,9 +21,9 @@ import Foundation final class DownloadListStoreMock: DownloadListStoring { - var fetchBlock: ((Date, @escaping (Result<[DownloadListItem], Error>) -> Void) -> Void)? - func fetch(clearingItemsOlderThan date: Date, completionHandler: @escaping (Result<[DownloadListItem], Error>) -> Void) { - fetchBlock?(date, completionHandler) + var fetchBlock: ((@escaping @MainActor (Result<[DownloadListItem], Error>) -> Void) -> Void)? + func fetch(completionHandler: @escaping @MainActor (Result<[DuckDuckGo_Privacy_Browser.DownloadListItem], any Error>) -> Void) { + fetchBlock?(completionHandler) } var saveBlock: ((DownloadListItem, ((Error?) -> Void)?) -> Void)? @@ -36,11 +36,6 @@ final class DownloadListStoreMock: DownloadListStoring { removeBlock?(item, completionHandler) } - var clearBlock: ((Date, ((Error?) -> Void)?) -> Void)? - func clear(itemsOlderThan date: Date, completionHandler: ((Error?) -> Void)?) { - clearBlock?(date, completionHandler) - } - var syncBlock: (() -> Void)? func sync() { syncBlock?() diff --git a/UnitTests/FileDownload/Helpers/FileDownloadManagerMock.swift b/UnitTests/FileDownload/Helpers/FileDownloadManagerMock.swift index 32ee06bf77..a8d1432629 100644 --- a/UnitTests/FileDownload/Helpers/FileDownloadManagerMock.swift +++ b/UnitTests/FileDownload/Helpers/FileDownloadManagerMock.swift @@ -16,12 +16,14 @@ // limitations under the License. // -import Navigation -import Foundation import Combine +import Foundation +import Navigation +import UniformTypeIdentifiers + @testable import DuckDuckGo_Privacy_Browser -final class FileDownloadManagerMock: FileDownloadManagerProtocol { +final class FileDownloadManagerMock: FileDownloadManagerProtocol, WebKitDownloadTaskDelegate { var downloads = Set() @@ -32,9 +34,9 @@ final class FileDownloadManagerMock: FileDownloadManagerProtocol { var addDownloadBlock: ((WebKitDownload, DownloadTaskDelegate?, - FileDownloadManager.DownloadLocationPreference) -> WebKitDownloadTask)? - func add(_ download: WebKitDownload, fromBurnerWindow: Bool, delegate: DownloadTaskDelegate?, location: FileDownloadManager.DownloadLocationPreference) -> WebKitDownloadTask { - addDownloadBlock!(download, delegate, location) + WebKitDownloadTask.DownloadDestination) -> WebKitDownloadTask)? + func add(_ download: WebKitDownload, fromBurnerWindow: Bool, delegate: DownloadTaskDelegate?, destination: WebKitDownloadTask.DownloadDestination) -> WebKitDownloadTask { + addDownloadBlock!(download, delegate, destination) } var cancelAllBlock: ((Bool) -> Void)? @@ -42,4 +44,13 @@ final class FileDownloadManagerMock: FileDownloadManagerProtocol { cancelAllBlock?(waitUntilDone) } + func fileDownloadTaskNeedsDestinationURL(_ task: DuckDuckGo_Privacy_Browser.WebKitDownloadTask, suggestedFilename: String, suggestedFileType: UTType?) async -> (URL?, UTType?) { + (nil, nil) + } + + var downloadTaskDidFinishSubject = PassthroughSubject<(WebKitDownloadTask, Result), Never>() + func fileDownloadTask(_ task: WebKitDownloadTask, didFinishWith result: Result) { + downloadTaskDidFinishSubject.send((task, result)) + } + } diff --git a/UnitTests/HomePage/ContinueSetUpModelTests.swift b/UnitTests/HomePage/ContinueSetUpModelTests.swift index c182554a38..08f034af48 100644 --- a/UnitTests/HomePage/ContinueSetUpModelTests.swift +++ b/UnitTests/HomePage/ContinueSetUpModelTests.swift @@ -200,7 +200,7 @@ final class ContinueSetUpModelTests: XCTestCase { @MainActor func testWhenInstallDateIsLessThanADayAgoButUserNotIn10PercentNoSurveyCardIsShown() { let aDayAgo = Calendar.current.date(byAdding: .day, value: -1, to: Date())! userDefaults.set(aDayAgo, forKey: UserDefaultsWrapper.Key.firstLaunchDate.rawValue) - var randomGenerator = MockRandomNumberGenerator() + let randomGenerator = MockRandomNumberGenerator() randomGenerator.numberToReturn = 10 vm = HomePage.Models.ContinueSetUpModel.fixture(appGroupUserDefaults: userDefaults, randomNumberGenerator: randomGenerator) vm.shouldShowAllFeatures = true diff --git a/sandbox-test-tool/FileReadResult.swift b/sandbox-test-tool/FileReadResult.swift new file mode 100644 index 0000000000..02f2019214 --- /dev/null +++ b/sandbox-test-tool/FileReadResult.swift @@ -0,0 +1,38 @@ +// +// FileReadResult.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. +// + +import Foundation + +struct FileReadResult: Codable { + + let path: String + let data: String + let bookmark: Data? + + static func decode(from string: String) throws -> FileReadResult { + try JSONDecoder().decode(Self.self, from: string.data(using: .utf8)!) + } + + func encoded() -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let json = try? encoder.encode(self) + return String(data: json!, encoding: .utf8)! + } + +} diff --git a/sandbox-test-tool/Info.plist b/sandbox-test-tool/Info.plist new file mode 100644 index 0000000000..dcdec858f4 --- /dev/null +++ b/sandbox-test-tool/Info.plist @@ -0,0 +1,57 @@ + + + + + CFBundleDocumentTypes + + + CFBundleTypeExtensions + + txt + + CFBundleTypeIconFile + document.icns + CFBundleTypeName + Text Document + CFBundleTypeOSTypes + + TXT + + CFBundleTypeRole + Viewer + LSHandlerRank + None + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + Copyright © 2024 DuckDuckGo. All rights reserved. + NSPrincipalClass + $(INFOPLIST_KEY_NSPrincipalClass) + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + ViewBridgeService + + + diff --git a/sandbox-test-tool/SandboxTestTool.swift b/sandbox-test-tool/SandboxTestTool.swift new file mode 100644 index 0000000000..2a800cfa18 --- /dev/null +++ b/sandbox-test-tool/SandboxTestTool.swift @@ -0,0 +1,232 @@ +// +// SandboxTestTool.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. +// + +import AppKit +import Combine +import Common +import Foundation + +@main +struct SandboxTestTool { + static func main() { + SandboxTestToolApp.shared.run() + } +} + +@objc(SandboxTestToolApp) +final class SandboxTestToolApp: NSApplication { + + private var _delegate: SandboxTestToolAppDelegate! + + override init() { + super.init() + + _delegate = SandboxTestToolAppDelegate() + self.delegate = _delegate + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +extension FileLogger: FilePresenterLogger { + func log(_ message: @autoclosure () -> String) { + log(message(), includeTimestamp: true) + } +} + +final class FileLogger { + static let shared = FileLogger() + + private init() {} + + private let pid = ProcessInfo().processIdentifier + + private let fileURL: URL = { + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documentsURL.appendingPathComponent("logfile.txt") + }() + + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter + }() + + private let queue = DispatchQueue(label: "log queue") + private lazy var fileHandle: FileHandle = { + let fileHandle = (try? FileHandle(forWritingTo: fileURL))! + fileHandle.seekToEndOfFile() + return fileHandle + }() + + func log(_ message: String, includeTimestamp: Bool) { + os_log("%{public}s", message) + queue.sync { + let logMessage = includeTimestamp ? "[\(pid)] \(dateFormatter.string(from: Date())): \(message)\n" : (message + "\n") + + fileHandle.write(logMessage.data(using: .utf8)!) + } + } + +} + +final class SandboxTestToolAppDelegate: NSObject, NSApplicationDelegate { + + // uncomment these for logging +// #if CI + let logger = OSLog.disabled +// #else +// let logger = FileLogger.shared +// #endif + + override init() { + logger.log("\n\n\n🚦 starting…\n") + + super.init() + + DistributedNotificationCenter.default().addObserver(forName: SandboxTestNotification.terminate.name, object: nil, queue: nil, using: terminate) + DistributedNotificationCenter.default().addObserver(forName: SandboxTestNotification.ping.name, object: nil, queue: nil, using: ping) + DistributedNotificationCenter.default().addObserver(forName: SandboxTestNotification.openFile.name, object: nil, queue: nil, using: openFile) + DistributedNotificationCenter.default().addObserver(forName: SandboxTestNotification.openFileWithoutBookmark.name, object: nil, queue: nil, using: openFile) + DistributedNotificationCenter.default().addObserver(forName: SandboxTestNotification.openBookmarkWithFilePresenter.name, object: nil, queue: nil, using: openBookmarkWithFilePresenter) + DistributedNotificationCenter.default().addObserver(forName: SandboxTestNotification.closeFilePresenter.name, object: nil, queue: nil, using: closeFilePresenter) + + NSURL.swizzleStopAccessingSecurityScopedResource { [unowned self] url in + post(.stopAccessingSecurityScopedResourceCalled, with: url.path) + } + } + + func applicationWillFinishLaunching(_ notification: Notification) { + post(.hello, with: nil) + } + + func applicationDidFinishLaunching(_ notification: Notification) { + logger.log("🚦 didFinishLaunching\n") + } + + private func ping(_ notification: Notification) { + logger.log("➡️ ping") + post(.pong, with: notification.object as? String) + } + + private func openFile(_ notification: Notification) { + logger.log("➡️ openFile \(notification.object as? String ?? "")") + guard let filePath = notification.object as? String else { + post(.error, with: "No file path provided") + return + } + openFile(filePath, creatingBookmark: notification.name == SandboxTestNotification.openFile) + } + + func application(_ sender: NSApplication, openFile filename: String) -> Bool { + logger.log("➡️ app.openFile(\"\(filename)\")") + openFile(filename, creatingBookmark: true) + return true + } + + private func openFile(_ filePath: String, creatingBookmark bookmarkNeeded: Bool) { + let url = URL(fileURLWithPath: filePath) + + let data: String + do { + data = try String(contentsOf: url) + } catch { + post(.error, with: error.encoded("Error opening file")) + return + } + + var bookmark: Data? + if bookmarkNeeded { + do { + bookmark = try url.bookmarkData(options: .withSecurityScope) + } catch let error as NSError { + post(.error, with: error.encoded("Error creating bookmark")) + return + } + } + + post(.fileRead, with: FileReadResult(path: filePath, data: data, bookmark: bookmark).encoded()) + } + + private var filePresenters = [URL: FilePresenter]() + private var filePresenterCancellables = [URL: Set]() + + private func openBookmarkWithFilePresenter(_ notification: Notification) { + logger.log("📕 openBookmarkWithFilePresenter") + guard let object = notification.object as? String, let bookmark = Data(base64Encoded: object) else { + post(.error, with: CocoaError(CocoaError.Code.coderReadCorrupt).encoded("Invalid bookmark data")) + return + } + do { + let filePresenter = try SandboxFilePresenter(fileBookmarkData: bookmark, logger: logger) + guard let url = filePresenter.url else { throw NSError(domain: "SandboxTestTool", code: -1, userInfo: [NSLocalizedDescriptionKey: "FilePresenter URL is nil"]) } + + filePresenter.urlPublisher.dropFirst().sink { [unowned self] url in + post(.fileMoved, with: url?.path) + }.store(in: &filePresenterCancellables[url, default: []]) + filePresenter.fileBookmarkDataPublisher.dropFirst().sink { [unowned self] fileBookmarkData in + post(.fileBookmarkDataUpdated, with: fileBookmarkData?.base64EncodedString()) + }.store(in: &filePresenterCancellables[url, default: []]) + self.filePresenters[url] = filePresenter + logger.log("📗 openBookmarkWithFilePresenter done: \"\(filePresenter.url?.path ?? "")\"") + } catch { + post(.error, with: error.encoded("could not open SandboxFilePresenter")) + } + } + + private func closeFilePresenter(_ notification: Notification) { + guard let path = notification.object as? String else { + post(.error, with: CocoaError(CocoaError.Code.coderReadCorrupt).encoded("Should provide file path to close Presenter")) + return + } + logger.log("🌂 closeFilePresenter for \(path)") + + let url = URL(fileURLWithPath: path) + filePresenterCancellables[url] = nil + filePresenters[url] = nil + } + + private func terminate(_ notification: Notification) { + logger.log("😵 terminate\n---------------") + NSApp.terminate(self) + } + + private func post(_ name: SandboxTestNotification, with object: String? = nil) { + logger.log("📮 \(name.rawValue)\(object != nil ? ": \(object!)" : "")") + DistributedNotificationCenter.default().post(name: .init(name.rawValue), object: object) + } + +} + +private extension Error { + func encoded(_ descr: String? = nil, file: StaticString = #file, line: UInt = #line) -> String { + let error = self as NSError + var dict: [String: Any] = [ + UserInfoKeys.errorDomain: error.domain, + UserInfoKeys.errorCode: error.code + ].merging(error.userInfo.filter { $0.value is String || $0.value is Int }, uniquingKeysWith: { $1 }) + if let descr { + dict[UserInfoKeys.errorDescription] = descr + } + let json = try? JSONSerialization.data(withJSONObject: dict, options: .prettyPrinted) + return String(data: json!, encoding: .utf8)! + } +} diff --git a/sandbox-test-tool/SandboxTestToolNotifications.swift b/sandbox-test-tool/SandboxTestToolNotifications.swift new file mode 100644 index 0000000000..9c52474242 --- /dev/null +++ b/sandbox-test-tool/SandboxTestToolNotifications.swift @@ -0,0 +1,58 @@ +// +// SandboxTestToolNotifications.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. +// + +import Foundation + +enum SandboxTestNotification: String { + case hello = "sandbox_test_tool_hello" + case ping = "sandbox_test_tool_ping" + case pong = "sandbox_test_tool_pong" + + case openFile = "sandbox_test_tool_open_file" + case openFileWithoutBookmark = "sandbox_test_tool_open_file_no_bookmark" + case fileRead = "sandbox_test_tool_open_file_read" + + case error = "sandbox_test_tool_error" + case terminate = "sandbox_test_tool_term" + + case openBookmarkWithFilePresenter = "sandbox_test_tool_open_bookmark_with_file_presenter" + case closeFilePresenter = "sandbox_test_tool_close_file_presenter" + + case fileMoved = "sandbox_test_tool_file_presenter_file_moved" + case fileBookmarkDataUpdated = "sandbox_test_tool_file_presenter_bookmark_data_updated" + + case stopAccessingSecurityScopedResourceCalled = "sandbox_test_tool_stop_accessing_security_scoped_resource" + + var name: Notification.Name { + .init(rawValue: rawValue) + } + + public static func == (a: SandboxTestNotification, b: Notification.Name) -> Bool { + a.rawValue == b.rawValue + } + + public static func == (a: Notification.Name, b: SandboxTestNotification) -> Bool { + a.rawValue == b.rawValue + } +} + +enum UserInfoKeys { + static let errorDescription = "descr" + static let errorDomain = "domain" + static let errorCode = "code" +} diff --git a/sandbox-test-tool/sandbox_test_tool.entitlements b/sandbox-test-tool/sandbox_test_tool.entitlements new file mode 100644 index 0000000000..269c73f96d --- /dev/null +++ b/sandbox-test-tool/sandbox_test_tool.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + $(DBP_APP_GROUP) + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + +