diff --git a/Configuration/App/DuckDuckGoPrivacyPro.xcconfig b/Configuration/App/DuckDuckGoPrivacyPro.xcconfig index 75c0f3a963..d7cbfb5168 100644 --- a/Configuration/App/DuckDuckGoPrivacyPro.xcconfig +++ b/Configuration/App/DuckDuckGoPrivacyPro.xcconfig @@ -21,5 +21,6 @@ #include "DuckDuckGo.xcconfig" -FEATURE_FLAGS = FEEDBACK NETWORK_PROTECTION SPARKLE SUBSCRIPTION DBP +FEATURE_FLAGS = FEEDBACK NETWORK_PROTECTION SPARKLE SUBSCRIPTION DBP STRIPE PRODUCT_NAME = $(PRODUCT_NAME_PREFIX) Privacy Pro +PRODUCT_MODULE_NAME = DuckDuckGo_Privacy_Browser diff --git a/Configuration/BuildNumber.xcconfig b/Configuration/BuildNumber.xcconfig index 4d07d781a0..37a49e7d76 100644 --- a/Configuration/BuildNumber.xcconfig +++ b/Configuration/BuildNumber.xcconfig @@ -1 +1 @@ -CURRENT_PROJECT_VERSION = 95 +CURRENT_PROJECT_VERSION = 96 diff --git a/Configuration/Global.xcconfig b/Configuration/Global.xcconfig index dba0d06237..39c875ee65 100644 --- a/Configuration/Global.xcconfig +++ b/Configuration/Global.xcconfig @@ -101,3 +101,10 @@ DDG_SLOW_COMPILE_CHECK_THRESHOLD[config=CI] = 250 DDG_SLOW_COMPILE_CHECK = -Xfrontend -warn-long-expression-type-checking=$(DDG_SLOW_COMPILE_CHECK_THRESHOLD) -Xfrontend -warn-long-function-bodies=$(DDG_SLOW_COMPILE_CHECK_THRESHOLD) OTHER_SWIFT_FLAGS[config=Debug][arch=*][sdk=*] = $(inherited) $(DDG_SLOW_COMPILE_CHECK) OTHER_SWIFT_FLAGS[config=CI][arch=*][sdk=*] = $(inherited) $(DDG_SLOW_COMPILE_CHECK) + +// Automatically generate Color and Image asset accessor extensions +ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES + +// Automatically generate localization string catalogs +LOCALIZATION_PREFERS_STRING_CATALOGS = YES +SWIFT_EMIT_LOC_STRINGS = YES diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 1415d4afb4..4b185eebdc 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -75,6 +75,7 @@ 1DFAB51E2A8982A600A0F7F6 /* SetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DFAB51C2A8982A600A0F7F6 /* SetExtension.swift */; }; 1DFAB5222A8983DE00A0F7F6 /* SetExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DFAB51F2A89830D00A0F7F6 /* SetExtensionTests.swift */; }; 1DFAB5232A8983E100A0F7F6 /* SetExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DFAB51F2A89830D00A0F7F6 /* SetExtensionTests.swift */; }; + 1E0068AD2B1673BB00BBF43B /* SubscriptionUI in Frameworks */ = {isa = PBXBuildFile; productRef = 1E0068AC2B1673BB00BBF43B /* SubscriptionUI */; }; 1E0C72062ABC63BD00802009 /* SubscriptionPagesUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0C72052ABC63BD00802009 /* SubscriptionPagesUserScript.swift */; }; 1E0C72072ABC63BD00802009 /* SubscriptionPagesUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0C72052ABC63BD00802009 /* SubscriptionPagesUserScript.swift */; }; 1E2AE4C72ACB215900684E0A /* NetworkProtectionRemoteMessaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15D62ABB8A110083F6DF /* NetworkProtectionRemoteMessaging.swift */; }; @@ -83,14 +84,13 @@ 1E2AE4CA2ACB21A000684E0A /* NetworkProtectionRemoteMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15D82ABB8A7F0083F6DF /* NetworkProtectionRemoteMessage.swift */; }; 1E2AE4CB2ACB21C800684E0A /* HardwareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9579202AC687170062CA31 /* HardwareModel.swift */; }; 1E2AE4CC2ACB224A00684E0A /* NetworkProtectionRemoteMessagingRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15DC2ABB8CFC0083F6DF /* NetworkProtectionRemoteMessagingRequest.swift */; }; - 1E3ED4FD2AC1E0290075F60F /* Purchase in Frameworks */ = {isa = PBXBuildFile; productRef = 1E3ED4FC2AC1E0290075F60F /* Purchase */; }; 1E7E2E9029029A2A00C01B54 /* ContentBlockingRulesUpdateObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7E2E8F29029A2A00C01B54 /* ContentBlockingRulesUpdateObserver.swift */; }; 1E7E2E9229029F9B00C01B54 /* WebsiteBreakageReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7E2E9129029F9B00C01B54 /* WebsiteBreakageReporter.swift */; }; 1E7E2E942902AC0E00C01B54 /* PrivacyDashboardPermissionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7E2E932902AC0E00C01B54 /* PrivacyDashboardPermissionHandler.swift */; }; + 1E934E2B2B167CA80084722B /* Subscription in Frameworks */ = {isa = PBXBuildFile; productRef = 1E934E2A2B167CA80084722B /* Subscription */; }; 1E950E3F2912A10D0051A99B /* ContentBlocking in Frameworks */ = {isa = PBXBuildFile; productRef = 1E950E3E2912A10D0051A99B /* ContentBlocking */; }; 1E950E412912A10D0051A99B /* PrivacyDashboard in Frameworks */ = {isa = PBXBuildFile; productRef = 1E950E402912A10D0051A99B /* PrivacyDashboard */; }; 1E950E432912A10D0051A99B /* UserScript in Frameworks */ = {isa = PBXBuildFile; productRef = 1E950E422912A10D0051A99B /* UserScript */; }; - 1EC88CA12AC1DD63003A4471 /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 1EC88CA02AC1DD63003A4471 /* Account */; }; 310E79BF294A19A8007C49E8 /* FireproofingReferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310E79BE294A19A8007C49E8 /* FireproofingReferenceTests.swift */; }; 311B262728E73E0A00FD181A /* TabShadowConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311B262628E73E0A00FD181A /* TabShadowConfig.swift */; }; 3143C8792B0D1F3D00382627 /* DataBrokerProtection in Frameworks */ = {isa = PBXBuildFile; productRef = 3143C8782B0D1F3D00382627 /* DataBrokerProtection */; }; @@ -190,7 +190,6 @@ 3706FAB0293F65D500E42796 /* BookmarkOutlineViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B92928726670D1600AD2C21 /* BookmarkOutlineViewCell.swift */; }; 3706FAB1293F65D500E42796 /* UnprotectedDomains.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = B604085A274B8CA300680351 /* UnprotectedDomains.xcdatamodeld */; }; 3706FAB2293F65D500E42796 /* TabInstrumentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB88B4F25B7BA2B006F6B06 /* TabInstrumentation.swift */; }; - 3706FAB3293F65D500E42796 /* BrowserImportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59024226B35F7C00489384 /* BrowserImportViewController.swift */; }; 3706FAB4293F65D500E42796 /* NSPopUpButtonExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292D62667124000AD2C21 /* NSPopUpButtonExtension.swift */; }; 3706FAB5293F65D500E42796 /* ConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85D33F1125C82EB3002B91A6 /* ConfigurationManager.swift */; }; 3706FAB6293F65D500E42796 /* YoutubePlayerUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31F28C4C28C8EEC500119F70 /* YoutubePlayerUserScript.swift */; }; @@ -211,7 +210,6 @@ 3706FAC8293F65D500E42796 /* AppTrackerDataSetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9833912E27AAA3CE00DAF119 /* AppTrackerDataSetProvider.swift */; }; 3706FAC9293F65D500E42796 /* EncryptionKeyGeneration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA1A6B2258B080A00F6F690 /* EncryptionKeyGeneration.swift */; }; 3706FACA293F65D500E42796 /* TabLazyLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B11B3828095E6600CBB621 /* TabLazyLoader.swift */; }; - 3706FACB293F65D500E42796 /* FileImportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DEF26B0002B00E14D75 /* FileImportViewController.swift */; }; 3706FACC293F65D500E42796 /* SaveCredentialsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8589063B267BCDC000D23B0D /* SaveCredentialsViewController.swift */; }; 3706FACD293F65D500E42796 /* PopUpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBE0AA627B9B027003B37A8 /* PopUpButton.swift */; }; 3706FACE293F65D500E42796 /* SuggestionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABEE6A424AA0A7F0043105B /* SuggestionViewController.swift */; }; @@ -221,7 +219,6 @@ 3706FAD4293F65D500E42796 /* DataExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AC3AF625D5DBFD00C7D2AA /* DataExtension.swift */; }; 3706FAD6293F65D500E42796 /* ConfigurationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85480FCE25D1AA22009424E3 /* ConfigurationStore.swift */; }; 3706FAD7293F65D500E42796 /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3D531A27A2F57E00074EC1 /* Feedback.swift */; }; - 3706FAD8293F65D500E42796 /* RequestFilePermissionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB99D0526FE1979001E4761 /* RequestFilePermissionViewController.swift */; }; 3706FAD9293F65D500E42796 /* FirefoxFaviconsReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0A63E7289DB58E00378EF7 /* FirefoxFaviconsReader.swift */; }; 3706FADA293F65D500E42796 /* CopyHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858A798226A8B75F00A75A42 /* CopyHandler.swift */; }; 3706FADB293F65D500E42796 /* ContentBlockingRulesUpdateObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7E2E8F29029A2A00C01B54 /* ContentBlockingRulesUpdateObserver.swift */; }; @@ -238,7 +235,6 @@ 3706FAE7293F65D500E42796 /* DebugUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB88B4425B7B55C006F6B06 /* DebugUserScript.swift */; }; 3706FAE8293F65D500E42796 /* RecentlyClosedTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC6881828626BF800D54247 /* RecentlyClosedTab.swift */; }; 3706FAE9293F65D500E42796 /* PDFSearchTextMenuItemHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B688B4DE27420D290087BEAF /* PDFSearchTextMenuItemHandler.swift */; }; - 3706FAEA293F65D500E42796 /* DataImportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DEE26B0002B00E14D75 /* DataImportViewController.swift */; }; 3706FAEB293F65D500E42796 /* HistoryMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7E919628746BCC00AB6B62 /* HistoryMenu.swift */; }; 3706FAEC293F65D500E42796 /* ContentScopeFeatureFlagging.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A6198B283CFFBB007F2080 /* ContentScopeFeatureFlagging.swift */; }; 3706FAED293F65D500E42796 /* OnboardingButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85707F23276A332A00DC0649 /* OnboardingButtonStyles.swift */; }; @@ -266,7 +262,6 @@ 3706FB09293F65D500E42796 /* CrashReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC30A29268E239100D2D9CD /* CrashReport.swift */; }; 3706FB0A293F65D500E42796 /* NSPathControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC53F327E8D4620028713D /* NSPathControlView.swift */; }; 3706FB0B293F65D500E42796 /* DefaultBrowserPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85589E9D27BFE4500038AD11 /* DefaultBrowserPromptView.swift */; }; - 3706FB0D293F65D500E42796 /* BrowserImportSummaryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B78A86A26BB3ADD0071BB16 /* BrowserImportSummaryViewController.swift */; }; 3706FB0E293F65D500E42796 /* FaviconManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA512D1324D99D9800230283 /* FaviconManager.swift */; }; 3706FB0F293F65D500E42796 /* ChromiumFaviconsReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0AACAB28BC63ED001038AC /* ChromiumFaviconsReader.swift */; }; 3706FB10293F65D500E42796 /* SuggestionTableRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABEE6AA24ACA0F90043105B /* SuggestionTableRowView.swift */; }; @@ -287,7 +282,6 @@ 3706FB21293F65D500E42796 /* NavigationBarBadgeAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CF3431288B0B1B0087244B /* NavigationBarBadgeAnimator.swift */; }; 3706FB22293F65D500E42796 /* NSTextViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858A798426A8BB5D00A75A42 /* NSTextViewExtension.swift */; }; 3706FB23293F65D500E42796 /* DownloadsCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B1E88326D5EB570062C350 /* DownloadsCellView.swift */; }; - 3706FB24293F65D500E42796 /* FileImportSummaryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DF026B0002B00E14D75 /* FileImportSummaryViewController.swift */; }; 3706FB25293F65D500E42796 /* PublishedAfter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AAAC2C260330580029438D /* PublishedAfter.swift */; }; 3706FB27293F65D500E42796 /* DeviceAuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBC169F27C4859400E00A38 /* DeviceAuthenticationService.swift */; }; 3706FB28293F65D500E42796 /* AutofillPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776582E27F82E62009A6B35 /* AutofillPreferences.swift */; }; @@ -412,7 +406,7 @@ 3706FBB3293F65D500E42796 /* ChromiumLoginReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59023926B35F3600489384 /* ChromiumLoginReader.swift */; }; 3706FBB4293F65D500E42796 /* NSAlert+PasswordManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85D885B226A5A9DE0077C374 /* NSAlert+PasswordManager.swift */; }; 3706FBB5293F65D500E42796 /* UserContentUpdating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983DFB2428B67036006B7E34 /* UserContentUpdating.swift */; }; - 3706FBB6293F65D500E42796 /* ChromePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7A57CE279A4EF300B1C70E /* ChromePreferences.swift */; }; + 3706FBB6293F65D500E42796 /* ChromiumPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7A57CE279A4EF300B1C70E /* ChromiumPreferences.swift */; }; 3706FBB7293F65D500E42796 /* FirePopoverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6AD95A2704B6DB00159F8A /* FirePopoverViewController.swift */; }; 3706FBB8293F65D500E42796 /* SavePaymentMethodPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE4005227CF3DC3007D3161 /* SavePaymentMethodPopover.swift */; }; 3706FBB9293F65D500E42796 /* FindInPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A0116825AF1D8900FA6A0C /* FindInPageViewController.swift */; }; @@ -424,7 +418,6 @@ 3706FBC0293F65D500E42796 /* CSVImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DF726B0002B00E14D75 /* CSVImporter.swift */; }; 3706FBC1293F65D500E42796 /* StartupPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A4CEB9282E992F00D75B89 /* StartupPreferences.swift */; }; 3706FBC2293F65D500E42796 /* MainMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4BBA3A25C58FA200C4FB0F /* MainMenu.swift */; }; - 3706FBC3293F65D500E42796 /* EdgeDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8AC93226B3B06300879451 /* EdgeDataImporter.swift */; }; 3706FBC5293F65D500E42796 /* CallToAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85707F21276A32B600DC0649 /* CallToAction.swift */; }; 3706FBC6293F65D500E42796 /* MouseOverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693953D26F04BE70015B914 /* MouseOverView.swift */; }; 3706FBC7293F65D500E42796 /* HistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE7527B263B056C00B973F8 /* HistoryStore.swift */; }; @@ -510,7 +503,7 @@ 3706FC1F293F65D500E42796 /* BookmarksBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD18EFF283F0BC500058124 /* BookmarksBarViewController.swift */; }; 3706FC20293F65D500E42796 /* PreferencesAutofillView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DE4BC27EA31AC002CC3DE /* PreferencesAutofillView.swift */; }; 3706FC21293F65D500E42796 /* UserText+PasswordManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858A797E26A79EAA00A75A42 /* UserText+PasswordManager.swift */; }; - 3706FC22293F65D500E42796 /* ProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693954026F04BE80015B914 /* ProgressView.swift */; }; + 3706FC22293F65D500E42796 /* LoadingProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693954026F04BE80015B914 /* LoadingProgressView.swift */; }; 3706FC23293F65D500E42796 /* StatisticsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69B50362726A12000758A2B /* StatisticsStore.swift */; }; 3706FC25293F65D500E42796 /* ColorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693954626F04BEA0015B914 /* ColorView.swift */; }; 3706FC26293F65D500E42796 /* RecentlyClosedCacheItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C1DD2285A217F0089850C /* RecentlyClosedCacheItem.swift */; }; @@ -547,7 +540,6 @@ 3706FC49293F65D500E42796 /* RoundedSelectionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0511B3262CAA5A00F6079C /* RoundedSelectionRowView.swift */; }; 3706FC4A293F65D500E42796 /* LocalStatisticsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69B50392726A12500758A2B /* LocalStatisticsStore.swift */; }; 3706FC4B293F65D500E42796 /* BackForwardListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B689ECD426C247DB006FB0C5 /* BackForwardListItem.swift */; }; - 3706FC4C293F65D500E42796 /* BrowserImportMoreInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C48CD027908C1000D3263E /* BrowserImportMoreInfoViewController.swift */; }; 3706FC4E293F65D500E42796 /* AtbAndVariantCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69B50562727D16900758A2B /* AtbAndVariantCleanup.swift */; }; 3706FC4F293F65D500E42796 /* NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693953C26F04BE70015B914 /* NibLoadable.swift */; }; 3706FC50293F65D500E42796 /* FeedbackWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3D531427A1ED9300074EC1 /* FeedbackWindow.swift */; }; @@ -572,7 +564,6 @@ 3706FC63293F65D500E42796 /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65E6B9D26D9EC0800095F96 /* CircularProgressView.swift */; }; 3706FC64293F65D500E42796 /* SuggestionContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABEE69B24A902BB0043105B /* SuggestionContainer.swift */; }; 3706FC65293F65D500E42796 /* HomePageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85589E7D27BBB8630038AD11 /* HomePageViewController.swift */; }; - 3706FC66293F65D500E42796 /* BraveDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59023C26B35F3600489384 /* BraveDataImporter.swift */; }; 3706FC67293F65D500E42796 /* OperatingSystemVersionExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A9E46A2614618A0067D1B9 /* OperatingSystemVersionExtension.swift */; }; 3706FC68293F65D500E42796 /* ToggleableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */; }; 3706FC69293F65D500E42796 /* UserScripts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AC3AEE25D5CE9800C7D2AA /* UserScripts.swift */; }; @@ -584,7 +575,6 @@ 3706FC71293F65D500E42796 /* NSColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41D174025CB131900472416 /* NSColorExtension.swift */; }; 3706FC72293F65D500E42796 /* Stored.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7E919B2875C65000AB6B62 /* Stored.swift */; }; 3706FC73293F65D500E42796 /* AddressBarButtonsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC5E4F525D6BF2C007F5990 /* AddressBarButtonsViewController.swift */; }; - 3706FC75293F65D500E42796 /* ChromeDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59023826B35F3600489384 /* ChromeDataImporter.swift */; }; 3706FC76293F65D500E42796 /* PixelDataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68C92C32750EF76002AC6B0 /* PixelDataRecord.swift */; }; 3706FC77293F65D500E42796 /* PageObserverUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 853014D525E671A000FB8205 /* PageObserverUserScript.swift */; }; 3706FC78293F65D500E42796 /* SecureVaultErrorReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B642738127B65BAC0005DFD1 /* SecureVaultErrorReporter.swift */; }; @@ -647,7 +637,6 @@ 3706FCBF293F65D500E42796 /* ContentOverlay.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7B1E819C27C8874900FF0E60 /* ContentOverlay.storyboard */; }; 3706FCC0293F65D500E42796 /* FindInPage.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85A0117325AF2EDF00FA6A0C /* FindInPage.storyboard */; }; 3706FCC1293F65D500E42796 /* AddEditFavoriteViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85589E7B27BBB8630038AD11 /* AddEditFavoriteViewController.storyboard */; }; - 3706FCC2293F65D500E42796 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC8B256C49B8007083E7 /* Localizable.strings */; }; 3706FCC3293F65D500E42796 /* userscript.js in Resources */ = {isa = PBXBuildFile; fileRef = B31055BE27A1BA1D001AC618 /* userscript.js */; }; 3706FCC4293F65D500E42796 /* fb-tds.json in Resources */ = {isa = PBXBuildFile; fileRef = EA4617EF273A28A700F110A2 /* fb-tds.json */; }; 3706FCC5293F65D500E42796 /* TabPreview.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AAE8B101258A41C000E81239 /* TabPreview.storyboard */; }; @@ -667,7 +656,6 @@ 3706FCD9293F65D500E42796 /* trackers-1.json in Resources */ = {isa = PBXBuildFile; fileRef = AA3439732754D55100B241FA /* trackers-1.json */; }; 3706FCDA293F65D500E42796 /* dark-trackers-1.json in Resources */ = {isa = PBXBuildFile; fileRef = AA3439762754D55100B241FA /* dark-trackers-1.json */; }; 3706FCDB293F65D500E42796 /* Feedback.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA3863C427A1E28F00749AB5 /* Feedback.storyboard */; }; - 3706FCDC293F65D500E42796 /* DataImport.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4B723DED26B0002B00E14D75 /* DataImport.storyboard */; }; 3706FCDD293F65D500E42796 /* BookmarkTableCellView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4B92928A26670D1700AD2C21 /* BookmarkTableCellView.xib */; }; 3706FCDE293F65D500E42796 /* HomePageAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 85AC7AD827BD625000FFB69B /* HomePageAssets.xcassets */; }; 3706FCDF293F65D500E42796 /* shield-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6E627E8809D00036718 /* shield-mouse-over.json */; }; @@ -678,7 +666,6 @@ 3706FCE4293F65D500E42796 /* Fire.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AAB7320626DD0C37002FACF9 /* Fire.storyboard */; }; 3706FCE6293F65D500E42796 /* social_images in Resources */ = {isa = PBXBuildFile; fileRef = EA18D1C9272F0DC8006DC101 /* social_images */; }; 3706FCE7293F65D500E42796 /* shield-dot-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6E827E880A600036718 /* shield-dot-mouse-over.json */; }; - 3706FCE8293F65D500E42796 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC91256C49BC007083E7 /* Localizable.stringsdict */; }; 3706FCE9293F65D500E42796 /* fb-sdk.js in Resources */ = {isa = PBXBuildFile; fileRef = EAC80DDF271F6C0100BBF02D /* fb-sdk.js */; }; 3706FCEA293F65D500E42796 /* PasswordManager.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85625993269C8F9600EE44BC /* PasswordManager.storyboard */; }; 3706FCEB293F65D500E42796 /* dark-flame-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6E127E7D05500036718 /* dark-flame-mouse-over.json */; }; @@ -985,8 +972,8 @@ 3783F92329432E1800BCA897 /* WebViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3783F92229432E1800BCA897 /* WebViewTests.swift */; }; 378F44E429B4BDE900899924 /* SwiftUIExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 378F44E329B4BDE900899924 /* SwiftUIExtensions */; }; 378F44E629B4BDEE00899924 /* SwiftUIExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 378F44E529B4BDEE00899924 /* SwiftUIExtensions */; }; - 378F44EB29B4C73E00899924 /* View+RoundedCorners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F44EA29B4C73E00899924 /* View+RoundedCorners.swift */; }; - 378F44EC29B4C73E00899924 /* View+RoundedCorners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F44EA29B4C73E00899924 /* View+RoundedCorners.swift */; }; + 378F44EB29B4C73E00899924 /* ViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F44EA29B4C73E00899924 /* ViewExtension.swift */; }; + 378F44EC29B4C73E00899924 /* ViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F44EA29B4C73E00899924 /* ViewExtension.swift */; }; 3793FDD829535EBA00A2E28F /* Assertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E319372953446000DD3BCF /* Assertions.swift */; }; 379DE4BD27EA31AC002CC3DE /* PreferencesAutofillView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DE4BC27EA31AC002CC3DE /* PreferencesAutofillView.swift */; }; 379E877629E98729001C8BB0 /* BookmarksCleanupErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379E877529E98729001C8BB0 /* BookmarksCleanupErrorHandling.swift */; }; @@ -1103,8 +1090,6 @@ 4B2D067C2A13340900DE1F49 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4BEC322A11B509001D9AC5 /* Logging.swift */; }; 4B2D067F2A1334D700DE1F49 /* NetworkProtectionUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4B2D067E2A1334D700DE1F49 /* NetworkProtectionUI */; }; 4B2E7D6326FF9D6500D2DB17 /* PrintingUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2E7D6226FF9D6500D2DB17 /* PrintingUserScript.swift */; }; - 4B36CD362B34E46900FBC707 /* NetworkProtectionSubscriptionEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B36CD352B34E46900FBC707 /* NetworkProtectionSubscriptionEventHandler.swift */; }; - 4B36CD372B34E46900FBC707 /* NetworkProtectionSubscriptionEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B36CD352B34E46900FBC707 /* NetworkProtectionSubscriptionEventHandler.swift */; }; 4B379C1527BD91E3008A968E /* QuartzIdleStateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B379C1427BD91E3008A968E /* QuartzIdleStateProvider.swift */; }; 4B379C1E27BDB7FF008A968E /* DeviceAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B379C1D27BDB7FF008A968E /* DeviceAuthenticator.swift */; }; 4B379C2227BDBA29008A968E /* LocalAuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B379C2127BDBA29008A968E /* LocalAuthenticationService.swift */; }; @@ -1177,11 +1162,8 @@ 4B4D60E22A0C883A00BCD287 /* AppMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4D60E12A0C883A00BCD287 /* AppMain.swift */; }; 4B4D60E32A0C883A00BCD287 /* AppMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4D60E12A0C883A00BCD287 /* AppMain.swift */; }; 4B4F72EC266B2ED300814C60 /* CollectionExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4F72EB266B2ED300814C60 /* CollectionExtension.swift */; }; - 4B59023D26B35F3600489384 /* ChromeDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59023826B35F3600489384 /* ChromeDataImporter.swift */; }; 4B59023E26B35F3600489384 /* ChromiumLoginReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59023926B35F3600489384 /* ChromiumLoginReader.swift */; }; 4B59024026B35F3600489384 /* ChromiumDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59023B26B35F3600489384 /* ChromiumDataImporter.swift */; }; - 4B59024126B35F3600489384 /* BraveDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59023C26B35F3600489384 /* BraveDataImporter.swift */; }; - 4B59024326B35F7C00489384 /* BrowserImportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59024226B35F7C00489384 /* BrowserImportViewController.swift */; }; 4B59024826B3673600489384 /* ThirdPartyBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59024726B3673600489384 /* ThirdPartyBrowser.swift */; }; 4B59024C26B38BB800489384 /* ChromiumLoginReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59024B26B38BB800489384 /* ChromiumLoginReaderTests.swift */; }; 4B59CC8C290083240058F2F6 /* ConnectBitwardenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59CC8B290083240058F2F6 /* ConnectBitwardenViewModelTests.swift */; }; @@ -1206,20 +1188,15 @@ 4B723E0726B0003E00E14D75 /* CSVImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723E0126B0003E00E14D75 /* CSVImporterTests.swift */; }; 4B723E0826B0003E00E14D75 /* MockSecureVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723E0326B0003E00E14D75 /* MockSecureVault.swift */; }; 4B723E0926B0003E00E14D75 /* CSVLoginExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723E0426B0003E00E14D75 /* CSVLoginExporterTests.swift */; }; - 4B723E0A26B0005900E14D75 /* DataImportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DEE26B0002B00E14D75 /* DataImportViewController.swift */; }; - 4B723E0B26B0005B00E14D75 /* FileImportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DEF26B0002B00E14D75 /* FileImportViewController.swift */; }; - 4B723E0C26B0005D00E14D75 /* FileImportSummaryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DF026B0002B00E14D75 /* FileImportSummaryViewController.swift */; }; 4B723E0D26B0006100E14D75 /* SecureVaultLoginImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DF326B0002B00E14D75 /* SecureVaultLoginImporter.swift */; }; 4B723E0E26B0006300E14D75 /* LoginImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DF426B0002B00E14D75 /* LoginImport.swift */; }; 4B723E0F26B0006500E14D75 /* CSVParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DF626B0002B00E14D75 /* CSVParser.swift */; }; 4B723E1026B0006700E14D75 /* CSVImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DF726B0002B00E14D75 /* CSVImporter.swift */; }; - 4B723E1126B0006C00E14D75 /* DataImport.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4B723DED26B0002B00E14D75 /* DataImport.storyboard */; }; 4B723E1226B0006E00E14D75 /* DataImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DEB26B0002B00E14D75 /* DataImport.swift */; }; 4B723E1326B0007A00E14D75 /* CSVLoginExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DFD26B0002B00E14D75 /* CSVLoginExporter.swift */; }; 4B723E1926B000DC00E14D75 /* TemporaryFileCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723E1726B000DC00E14D75 /* TemporaryFileCreator.swift */; }; 4B7534CC2A1FD7EA00158A99 /* NetworkProtectionInviteDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4D606C2A0B29FA00BCD287 /* NetworkProtectionInviteDialog.swift */; }; - 4B78A86B26BB3ADD0071BB16 /* BrowserImportSummaryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B78A86A26BB3ADD0071BB16 /* BrowserImportSummaryViewController.swift */; }; - 4B7A57CF279A4EF300B1C70E /* ChromePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7A57CE279A4EF300B1C70E /* ChromePreferences.swift */; }; + 4B7A57CF279A4EF300B1C70E /* ChromiumPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7A57CE279A4EF300B1C70E /* ChromiumPreferences.swift */; }; 4B7A60A1273E0BE400BBDFEB /* WKWebsiteDataStoreExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7A60A0273E0BE400BBDFEB /* WKWebsiteDataStoreExtension.swift */; }; 4B81AD322B29507300706C96 /* DataBrokerProtectionPixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B81AD312B29507300706C96 /* DataBrokerProtectionPixelTests.swift */; }; 4B81AD332B29507300706C96 /* DataBrokerProtectionPixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B81AD312B29507300706C96 /* DataBrokerProtectionPixelTests.swift */; }; @@ -1228,7 +1205,6 @@ 4B85A48028821CC500FC4C39 /* NSPasteboardItemExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B85A47F28821CC500FC4C39 /* NSPasteboardItemExtension.swift */; }; 4B8A4DFF27C83B29005F40E8 /* SaveIdentityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8A4DFE27C83B29005F40E8 /* SaveIdentityViewController.swift */; }; 4B8A4E0127C8447E005F40E8 /* SaveIdentityPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8A4E0027C8447E005F40E8 /* SaveIdentityPopover.swift */; }; - 4B8AC93326B3B06300879451 /* EdgeDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8AC93226B3B06300879451 /* EdgeDataImporter.swift */; }; 4B8AC93526B3B2FD00879451 /* NSAlert+DataImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8AC93426B3B2FD00879451 /* NSAlert+DataImport.swift */; }; 4B8AC93926B48A5100879451 /* FirefoxLoginReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8AC93826B48A5100879451 /* FirefoxLoginReader.swift */; }; 4B8AC93B26B48ADF00879451 /* ASN1Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8AC93A26B48ADF00879451 /* ASN1Parser.swift */; }; @@ -1337,7 +1313,6 @@ 4B9579822AC7AE700062CA31 /* BookmarkOutlineViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B92928726670D1600AD2C21 /* BookmarkOutlineViewCell.swift */; }; 4B9579832AC7AE700062CA31 /* UnprotectedDomains.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = B604085A274B8CA300680351 /* UnprotectedDomains.xcdatamodeld */; }; 4B9579842AC7AE700062CA31 /* TabInstrumentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB88B4F25B7BA2B006F6B06 /* TabInstrumentation.swift */; }; - 4B9579852AC7AE700062CA31 /* BrowserImportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59024226B35F7C00489384 /* BrowserImportViewController.swift */; }; 4B9579862AC7AE700062CA31 /* NSPopUpButtonExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292D62667124000AD2C21 /* NSPopUpButtonExtension.swift */; }; 4B9579872AC7AE700062CA31 /* ConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85D33F1125C82EB3002B91A6 /* ConfigurationManager.swift */; }; 4B9579882AC7AE700062CA31 /* YoutubePlayerUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31F28C4C28C8EEC500119F70 /* YoutubePlayerUserScript.swift */; }; @@ -1363,7 +1338,6 @@ 4B95799E2AC7AE700062CA31 /* EncryptionKeyGeneration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA1A6B2258B080A00F6F690 /* EncryptionKeyGeneration.swift */; }; 4B95799F2AC7AE700062CA31 /* TabLazyLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B11B3828095E6600CBB621 /* TabLazyLoader.swift */; }; 4B9579A02AC7AE700062CA31 /* InvitedToWaitlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9DB0162A983B24000927DB /* InvitedToWaitlistView.swift */; }; - 4B9579A12AC7AE700062CA31 /* FileImportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DEF26B0002B00E14D75 /* FileImportViewController.swift */; }; 4B9579A22AC7AE700062CA31 /* SaveCredentialsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8589063B267BCDC000D23B0D /* SaveCredentialsViewController.swift */; }; 4B9579A32AC7AE700062CA31 /* PopUpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBE0AA627B9B027003B37A8 /* PopUpButton.swift */; }; 4B9579A42AC7AE700062CA31 /* NetworkProtectionInviteDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4D606C2A0B29FA00BCD287 /* NetworkProtectionInviteDialog.swift */; }; @@ -1377,7 +1351,6 @@ 4B9579AE2AC7AE700062CA31 /* DataExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AC3AF625D5DBFD00C7D2AA /* DataExtension.swift */; }; 4B9579AF2AC7AE700062CA31 /* ConfigurationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85480FCE25D1AA22009424E3 /* ConfigurationStore.swift */; }; 4B9579B02AC7AE700062CA31 /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3D531A27A2F57E00074EC1 /* Feedback.swift */; }; - 4B9579B12AC7AE700062CA31 /* RequestFilePermissionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB99D0526FE1979001E4761 /* RequestFilePermissionViewController.swift */; }; 4B9579B22AC7AE700062CA31 /* FirefoxFaviconsReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0A63E7289DB58E00378EF7 /* FirefoxFaviconsReader.swift */; }; 4B9579B32AC7AE700062CA31 /* CopyHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858A798226A8B75F00A75A42 /* CopyHandler.swift */; }; 4B9579B42AC7AE700062CA31 /* ContentBlockingRulesUpdateObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7E2E8F29029A2A00C01B54 /* ContentBlockingRulesUpdateObserver.swift */; }; @@ -1395,7 +1368,6 @@ 4B9579C02AC7AE700062CA31 /* DebugUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB88B4425B7B55C006F6B06 /* DebugUserScript.swift */; }; 4B9579C12AC7AE700062CA31 /* RecentlyClosedTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC6881828626BF800D54247 /* RecentlyClosedTab.swift */; }; 4B9579C22AC7AE700062CA31 /* PDFSearchTextMenuItemHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B688B4DE27420D290087BEAF /* PDFSearchTextMenuItemHandler.swift */; }; - 4B9579C32AC7AE700062CA31 /* DataImportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DEE26B0002B00E14D75 /* DataImportViewController.swift */; }; 4B9579C42AC7AE700062CA31 /* HistoryMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7E919628746BCC00AB6B62 /* HistoryMenu.swift */; }; 4B9579C52AC7AE700062CA31 /* ContentScopeFeatureFlagging.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A6198B283CFFBB007F2080 /* ContentScopeFeatureFlagging.swift */; }; 4B9579C62AC7AE700062CA31 /* OnboardingButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85707F23276A332A00DC0649 /* OnboardingButtonStyles.swift */; }; @@ -1441,7 +1413,6 @@ 4B9579F02AC7AE700062CA31 /* Bookmark.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 987799FC29999B64005D8EB6 /* Bookmark.xcdatamodeld */; }; 4B9579F12AC7AE700062CA31 /* DefaultBrowserPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85589E9D27BFE4500038AD11 /* DefaultBrowserPromptView.swift */; }; 4B9579F22AC7AE700062CA31 /* WaitlistActivationDateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4032832AAAC24400CCA602 /* WaitlistActivationDateStore.swift */; }; - 4B9579F32AC7AE700062CA31 /* BrowserImportSummaryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B78A86A26BB3ADD0071BB16 /* BrowserImportSummaryViewController.swift */; }; 4B9579F42AC7AE700062CA31 /* FaviconManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA512D1324D99D9800230283 /* FaviconManager.swift */; }; 4B9579F52AC7AE700062CA31 /* PFMoveApplication.m in Sources */ = {isa = PBXBuildFile; fileRef = 7BB108582A43375D000AB95F /* PFMoveApplication.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; 4B9579F62AC7AE700062CA31 /* ChromiumFaviconsReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0AACAB28BC63ED001038AC /* ChromiumFaviconsReader.swift */; }; @@ -1470,7 +1441,6 @@ 4B957A0D2AC7AE700062CA31 /* FutureExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B634DBE6293C98C500C3C99E /* FutureExtension.swift */; }; 4B957A0E2AC7AE700062CA31 /* UserDialogRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B634DBE2293C8FFF00C3C99E /* UserDialogRequest.swift */; }; 4B957A0F2AC7AE700062CA31 /* DownloadsCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B1E88326D5EB570062C350 /* DownloadsCellView.swift */; }; - 4B957A102AC7AE700062CA31 /* FileImportSummaryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DF026B0002B00E14D75 /* FileImportSummaryViewController.swift */; }; 4B957A112AC7AE700062CA31 /* PublishedAfter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AAAC2C260330580029438D /* PublishedAfter.swift */; }; 4B957A122AC7AE700062CA31 /* FirefoxBerkeleyDatabaseReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3701C9CD29BD040900305B15 /* FirefoxBerkeleyDatabaseReader.swift */; }; 4B957A132AC7AE700062CA31 /* WebViewSnapshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37054FCD2876472D00033B6F /* WebViewSnapshotView.swift */; }; @@ -1535,7 +1505,7 @@ 4B957A4E2AC7AE700062CA31 /* URL+NetworkProtection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B602E8152A1E2570006D261F /* URL+NetworkProtection.swift */; }; 4B957A4F2AC7AE700062CA31 /* PrivacyFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB6BCDF827C6BEFF00CC76DC /* PrivacyFeatures.swift */; }; 4B957A502AC7AE700062CA31 /* (null) in Sources */ = {isa = PBXBuildFile; }; - 4B957A512AC7AE700062CA31 /* View+RoundedCorners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F44EA29B4C73E00899924 /* View+RoundedCorners.swift */; }; + 4B957A512AC7AE700062CA31 /* ViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F44EA29B4C73E00899924 /* ViewExtension.swift */; }; 4B957A522AC7AE700062CA31 /* AVCaptureDevice+SwizzledAuthState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DB3CF826A00E2D00D459B7 /* AVCaptureDevice+SwizzledAuthState.swift */; }; 4B957A532AC7AE700062CA31 /* SubscriptionPagesUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0C72052ABC63BD00802009 /* SubscriptionPagesUserScript.swift */; }; 4B957A542AC7AE700062CA31 /* VisitMenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAB9115288EB46B00A057A9 /* VisitMenuItem.swift */; }; @@ -1642,7 +1612,7 @@ 4B957ABC2AC7AE700062CA31 /* ChromiumLoginReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59023926B35F3600489384 /* ChromiumLoginReader.swift */; }; 4B957ABD2AC7AE700062CA31 /* NSAlert+PasswordManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85D885B226A5A9DE0077C374 /* NSAlert+PasswordManager.swift */; }; 4B957ABE2AC7AE700062CA31 /* UserContentUpdating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983DFB2428B67036006B7E34 /* UserContentUpdating.swift */; }; - 4B957ABF2AC7AE700062CA31 /* ChromePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7A57CE279A4EF300B1C70E /* ChromePreferences.swift */; }; + 4B957ABF2AC7AE700062CA31 /* ChromiumPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7A57CE279A4EF300B1C70E /* ChromiumPreferences.swift */; }; 4B957AC02AC7AE700062CA31 /* FirePopoverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6AD95A2704B6DB00159F8A /* FirePopoverViewController.swift */; }; 4B957AC12AC7AE700062CA31 /* SavePaymentMethodPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE4005227CF3DC3007D3161 /* SavePaymentMethodPopover.swift */; }; 4B957AC22AC7AE700062CA31 /* FindInPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A0116825AF1D8900FA6A0C /* FindInPageViewController.swift */; }; @@ -1656,7 +1626,6 @@ 4B957ACA2AC7AE700062CA31 /* StartupPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A4CEB9282E992F00D75B89 /* StartupPreferences.swift */; }; 4B957ACB2AC7AE700062CA31 /* UserDefaults+NetworkProtectionWaitlist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFE95532A9DF2930081ABE9 /* UserDefaults+NetworkProtectionWaitlist.swift */; }; 4B957ACC2AC7AE700062CA31 /* MainMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4BBA3A25C58FA200C4FB0F /* MainMenu.swift */; }; - 4B957ACD2AC7AE700062CA31 /* EdgeDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8AC93226B3B06300879451 /* EdgeDataImporter.swift */; }; 4B957ACE2AC7AE700062CA31 /* BrowserTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA585D83248FD31100E9A3E2 /* BrowserTabViewController.swift */; }; 4B957ACF2AC7AE700062CA31 /* CallToAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85707F21276A32B600DC0649 /* CallToAction.swift */; }; 4B957AD02AC7AE700062CA31 /* MouseOverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693953D26F04BE70015B914 /* MouseOverView.swift */; }; @@ -1762,7 +1731,7 @@ 4B957B352AC7AE700062CA31 /* PreferencesAutofillView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DE4BC27EA31AC002CC3DE /* PreferencesAutofillView.swift */; }; 4B957B362AC7AE700062CA31 /* BurnerHomePageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DCFBC8929ADF32B00313531 /* BurnerHomePageView.swift */; }; 4B957B372AC7AE700062CA31 /* UserText+PasswordManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858A797E26A79EAA00A75A42 /* UserText+PasswordManager.swift */; }; - 4B957B382AC7AE700062CA31 /* ProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693954026F04BE80015B914 /* ProgressView.swift */; }; + 4B957B382AC7AE700062CA31 /* LoadingProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693954026F04BE80015B914 /* LoadingProgressView.swift */; }; 4B957B392AC7AE700062CA31 /* StatisticsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69B50362726A12000758A2B /* StatisticsStore.swift */; }; 4B957B3A2AC7AE700062CA31 /* BWInstallationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBDEE8C28FC14760092FAA6 /* BWInstallationService.swift */; }; 4B957B3B2AC7AE700062CA31 /* BookmarksBarPromptPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F30632A72A7BB00C20372 /* BookmarksBarPromptPopover.swift */; }; @@ -1807,7 +1776,6 @@ 4B957B622AC7AE700062CA31 /* RoundedSelectionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0511B3262CAA5A00F6079C /* RoundedSelectionRowView.swift */; }; 4B957B632AC7AE700062CA31 /* LocalStatisticsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69B50392726A12500758A2B /* LocalStatisticsStore.swift */; }; 4B957B642AC7AE700062CA31 /* BackForwardListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B689ECD426C247DB006FB0C5 /* BackForwardListItem.swift */; }; - 4B957B652AC7AE700062CA31 /* BrowserImportMoreInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C48CD027908C1000D3263E /* BrowserImportMoreInfoViewController.swift */; }; 4B957B672AC7AE700062CA31 /* AtbAndVariantCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69B50562727D16900758A2B /* AtbAndVariantCleanup.swift */; }; 4B957B682AC7AE700062CA31 /* NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693953C26F04BE70015B914 /* NibLoadable.swift */; }; 4B957B692AC7AE700062CA31 /* FeedbackWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3D531427A1ED9300074EC1 /* FeedbackWindow.swift */; }; @@ -1840,7 +1808,6 @@ 4B957B842AC7AE700062CA31 /* SuggestionContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABEE69B24A902BB0043105B /* SuggestionContainer.swift */; }; 4B957B852AC7AE700062CA31 /* FindInPageTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C00ECC292F89D9009C73A6 /* FindInPageTabExtension.swift */; }; 4B957B862AC7AE700062CA31 /* HomePageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85589E7D27BBB8630038AD11 /* HomePageViewController.swift */; }; - 4B957B872AC7AE700062CA31 /* BraveDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59023C26B35F3600489384 /* BraveDataImporter.swift */; }; 4B957B882AC7AE700062CA31 /* OperatingSystemVersionExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A9E46A2614618A0067D1B9 /* OperatingSystemVersionExtension.swift */; }; 4B957B892AC7AE700062CA31 /* ToggleableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */; }; 4B957B8A2AC7AE700062CA31 /* TabCleanupPreparer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D36F4232A3B85C50052B527 /* TabCleanupPreparer.swift */; }; @@ -1858,7 +1825,6 @@ 4B957B962AC7AE700062CA31 /* Stored.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7E919B2875C65000AB6B62 /* Stored.swift */; }; 4B957B972AC7AE700062CA31 /* AddressBarButtonsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC5E4F525D6BF2C007F5990 /* AddressBarButtonsViewController.swift */; }; 4B957B982AC7AE700062CA31 /* BWError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DDF076028F815AD00EDFBE3 /* BWError.swift */; }; - 4B957B992AC7AE700062CA31 /* ChromeDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B59023826B35F3600489384 /* ChromeDataImporter.swift */; }; 4B957B9A2AC7AE700062CA31 /* PixelDataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68C92C32750EF76002AC6B0 /* PixelDataRecord.swift */; }; 4B957B9B2AC7AE700062CA31 /* PageObserverUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 853014D525E671A000FB8205 /* PageObserverUserScript.swift */; }; 4B957B9C2AC7AE700062CA31 /* SecureVaultErrorReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B642738127B65BAC0005DFD1 /* SecureVaultErrorReporter.swift */; }; @@ -1919,15 +1885,12 @@ 4B957BD62AC7AE700062CA31 /* LoginItems in Frameworks */ = {isa = PBXBuildFile; productRef = 4B95793F2AC7AE700062CA31 /* LoginItems */; }; 4B957BD72AC7AE700062CA31 /* NetworkProtection in Frameworks */ = {isa = PBXBuildFile; productRef = 4B95793E2AC7AE700062CA31 /* NetworkProtection */; }; 4B957BD82AC7AE700062CA31 /* BrowserServicesKit in Frameworks */ = {isa = PBXBuildFile; productRef = 4B95792B2AC7AE700062CA31 /* BrowserServicesKit */; }; - 4B957BD92AC7AE700062CA31 /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 4B9579412AC7AE700062CA31 /* Account */; }; 4B957BDA2AC7AE700062CA31 /* Bookmarks in Frameworks */ = {isa = PBXBuildFile; productRef = 4B9579342AC7AE700062CA31 /* Bookmarks */; }; 4B957BDB2AC7AE700062CA31 /* ContentBlocking in Frameworks */ = {isa = PBXBuildFile; productRef = 4B95792E2AC7AE700062CA31 /* ContentBlocking */; }; 4B957BDC2AC7AE700062CA31 /* SwiftUIExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 4B9579372AC7AE700062CA31 /* SwiftUIExtensions */; }; 4B957BDD2AC7AE700062CA31 /* UserScript in Frameworks */ = {isa = PBXBuildFile; productRef = 4B9579302AC7AE700062CA31 /* UserScript */; }; 4B957BDE2AC7AE700062CA31 /* Configuration in Frameworks */ = {isa = PBXBuildFile; productRef = 4B9579322AC7AE700062CA31 /* Configuration */; }; - 4B957BDF2AC7AE700062CA31 /* Purchase in Frameworks */ = {isa = PBXBuildFile; productRef = 4B9579422AC7AE700062CA31 /* Purchase */; }; 4B957BE02AC7AE700062CA31 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4B95793A2AC7AE700062CA31 /* Lottie */; }; - 4B957BE12AC7AE700062CA31 /* Subscription in Frameworks */ = {isa = PBXBuildFile; productRef = 4B9579402AC7AE700062CA31 /* Subscription */; }; 4B957BE22AC7AE700062CA31 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 4B9579292AC7AE700062CA31 /* Sparkle */; }; 4B957BE32AC7AE700062CA31 /* Navigation in Frameworks */ = {isa = PBXBuildFile; productRef = 4B9579332AC7AE700062CA31 /* Navigation */; }; 4B957BE42AC7AE700062CA31 /* DDGSync in Frameworks */ = {isa = PBXBuildFile; productRef = 4B9579352AC7AE700062CA31 /* DDGSync */; }; @@ -1955,7 +1918,6 @@ 4B957BFC2AC7AE700062CA31 /* FindInPage.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85A0117325AF2EDF00FA6A0C /* FindInPage.storyboard */; }; 4B957BFD2AC7AE700062CA31 /* JSAlert.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = EEC111E3294D06020086524F /* JSAlert.storyboard */; }; 4B957BFE2AC7AE700062CA31 /* AddEditFavoriteViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85589E7B27BBB8630038AD11 /* AddEditFavoriteViewController.storyboard */; }; - 4B957BFF2AC7AE700062CA31 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC8B256C49B8007083E7 /* Localizable.strings */; }; 4B957C002AC7AE700062CA31 /* userscript.js in Resources */ = {isa = PBXBuildFile; fileRef = B31055BE27A1BA1D001AC618 /* userscript.js */; }; 4B957C012AC7AE700062CA31 /* fb-tds.json in Resources */ = {isa = PBXBuildFile; fileRef = EA4617EF273A28A700F110A2 /* fb-tds.json */; }; 4B957C022AC7AE700062CA31 /* TabPreview.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AAE8B101258A41C000E81239 /* TabPreview.storyboard */; }; @@ -1975,7 +1937,6 @@ 4B957C122AC7AE700062CA31 /* trackers-1.json in Resources */ = {isa = PBXBuildFile; fileRef = AA3439732754D55100B241FA /* trackers-1.json */; }; 4B957C132AC7AE700062CA31 /* dark-trackers-1.json in Resources */ = {isa = PBXBuildFile; fileRef = AA3439762754D55100B241FA /* dark-trackers-1.json */; }; 4B957C142AC7AE700062CA31 /* Feedback.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA3863C427A1E28F00749AB5 /* Feedback.storyboard */; }; - 4B957C152AC7AE700062CA31 /* DataImport.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4B723DED26B0002B00E14D75 /* DataImport.storyboard */; }; 4B957C162AC7AE700062CA31 /* BookmarkTableCellView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4B92928A26670D1700AD2C21 /* BookmarkTableCellView.xib */; }; 4B957C172AC7AE700062CA31 /* HomePageAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 85AC7AD827BD625000FFB69B /* HomePageAssets.xcassets */; }; 4B957C182AC7AE700062CA31 /* shield-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6E627E8809D00036718 /* shield-mouse-over.json */; }; @@ -1986,7 +1947,6 @@ 4B957C1D2AC7AE700062CA31 /* Fire.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AAB7320626DD0C37002FACF9 /* Fire.storyboard */; }; 4B957C1F2AC7AE700062CA31 /* social_images in Resources */ = {isa = PBXBuildFile; fileRef = EA18D1C9272F0DC8006DC101 /* social_images */; }; 4B957C202AC7AE700062CA31 /* shield-dot-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6E827E880A600036718 /* shield-dot-mouse-over.json */; }; - 4B957C212AC7AE700062CA31 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC91256C49BC007083E7 /* Localizable.stringsdict */; }; 4B957C222AC7AE700062CA31 /* fb-sdk.js in Resources */ = {isa = PBXBuildFile; fileRef = EAC80DDF271F6C0100BBF02D /* fb-sdk.js */; }; 4B957C232AC7AE700062CA31 /* PasswordManager.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85625993269C8F9600EE44BC /* PasswordManager.storyboard */; }; 4B957C242AC7AE700062CA31 /* dark-flame-mouse-over.json in Resources */ = {isa = PBXBuildFile; fileRef = AA7EB6E127E7D05500036718 /* dark-flame-mouse-over.json */; }; @@ -2073,7 +2033,6 @@ 4BB99D0226FE191E001E4761 /* ImportedBookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB99CFA26FE191E001E4761 /* ImportedBookmarks.swift */; }; 4BB99D0326FE191E001E4761 /* SafariBookmarksReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB99CFC26FE191E001E4761 /* SafariBookmarksReader.swift */; }; 4BB99D0426FE191E001E4761 /* SafariDataImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB99CFD26FE191E001E4761 /* SafariDataImporter.swift */; }; - 4BB99D0626FE1979001E4761 /* RequestFilePermissionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB99D0526FE1979001E4761 /* RequestFilePermissionViewController.swift */; }; 4BB99D0F26FE1A84001E4761 /* ChromiumBookmarksReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB99D0C26FE1A83001E4761 /* ChromiumBookmarksReaderTests.swift */; }; 4BB99D1026FE1A84001E4761 /* FirefoxBookmarksReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB99D0D26FE1A83001E4761 /* FirefoxBookmarksReaderTests.swift */; }; 4BB99D1126FE1A84001E4761 /* SafariBookmarksReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB99D0E26FE1A84001E4761 /* SafariBookmarksReaderTests.swift */; }; @@ -2336,7 +2295,6 @@ 85B7184C27677C6500B4277F /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B7184B27677C6500B4277F /* OnboardingViewController.swift */; }; 85B7184E27677CBB00B4277F /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B7184D27677CBB00B4277F /* RootView.swift */; }; 85C48CCC278D808F00D3263E /* NSAttributedStringExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C48CCB278D808F00D3263E /* NSAttributedStringExtension.swift */; }; - 85C48CD127908C1000D3263E /* BrowserImportMoreInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C48CD027908C1000D3263E /* BrowserImportMoreInfoViewController.swift */; }; 85C5991B27D10CF000E605B2 /* FireAnimationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C5991A27D10CF000E605B2 /* FireAnimationView.swift */; }; 85C6A29625CC1FFD00EEB5F1 /* UserDefaultsWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C6A29525CC1FFD00EEB5F1 /* UserDefaultsWrapper.swift */; }; 85CC1D7B26A05ECF0062F04E /* PasswordManagementItemListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85CC1D7A26A05ECF0062F04E /* PasswordManagementItemListModel.swift */; }; @@ -2483,8 +2441,6 @@ AA80EC67256C4691007083E7 /* BrowserTab.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC69256C4691007083E7 /* BrowserTab.storyboard */; }; AA80EC73256C46A2007083E7 /* Suggestion.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC75256C46A2007083E7 /* Suggestion.storyboard */; }; AA80EC79256C46AA007083E7 /* TabBar.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC7B256C46AA007083E7 /* TabBar.storyboard */; }; - AA80EC89256C49B8007083E7 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC8B256C49B8007083E7 /* Localizable.strings */; }; - AA80EC8F256C49BC007083E7 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = AA80EC91256C49BC007083E7 /* Localizable.stringsdict */; }; AA840A9827319D1600E63CDD /* FirePopoverWrapperViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA840A9727319D1600E63CDD /* FirePopoverWrapperViewController.swift */; }; AA88D14B252A557100980B4E /* URLRequestExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA88D14A252A557100980B4E /* URLRequestExtension.swift */; }; AA8EDF2424923E980071C2E8 /* URLExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8EDF2324923E980071C2E8 /* URLExtension.swift */; }; @@ -2605,6 +2561,9 @@ B603FD9F2A02712E00F3FCA9 /* CIImageExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B603FD9D2A02712E00F3FCA9 /* CIImageExtension.swift */; }; B6040856274B830F00680351 /* DictionaryExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6040855274B830F00680351 /* DictionaryExtension.swift */; }; B604085C274B8FBA00680351 /* UnprotectedDomains.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = B604085A274B8CA300680351 /* UnprotectedDomains.xcdatamodeld */; }; + B6080BC52B21E78100B418EF /* DataImportErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6080BC42B21E78100B418EF /* DataImportErrorView.swift */; }; + B6080BC62B21E78100B418EF /* DataImportErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6080BC42B21E78100B418EF /* DataImportErrorView.swift */; }; + B6080BC82B21E78100B418EF /* DataImportErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6080BC42B21E78100B418EF /* DataImportErrorView.swift */; }; B6085D062743905F00A9C456 /* CoreDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6085D052743905F00A9C456 /* CoreDataStore.swift */; }; B6085D092743AAB600A9C456 /* FireproofDomains.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = B6085D072743993C00A9C456 /* FireproofDomains.xcdatamodeld */; }; B60C6F7729B0E286007BFAA8 /* SearchNonexistentDomainNavigationResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60C6F7629B0E286007BFAA8 /* SearchNonexistentDomainNavigationResponder.swift */; }; @@ -2657,6 +2616,9 @@ B62B483E2ADE48DE000DECE5 /* MenuBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62B483D2ADE48DE000DECE5 /* MenuBuilder.swift */; }; B62B483F2ADE48DE000DECE5 /* MenuBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62B483D2ADE48DE000DECE5 /* MenuBuilder.swift */; }; B62B48412ADE48DE000DECE5 /* MenuBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62B483D2ADE48DE000DECE5 /* MenuBuilder.swift */; }; + B62B48562ADE730D000DECE5 /* FileImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62B48552ADE730D000DECE5 /* FileImportView.swift */; }; + B62B48572ADE730D000DECE5 /* FileImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62B48552ADE730D000DECE5 /* FileImportView.swift */; }; + B62B48592ADE730D000DECE5 /* FileImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62B48552ADE730D000DECE5 /* FileImportView.swift */; }; B62EB47C25BAD3BB005745C6 /* WKWebViewPrivateMethodsAvailabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62EB47B25BAD3BB005745C6 /* WKWebViewPrivateMethodsAvailabilityTests.swift */; }; B630793526731BC400DCEE41 /* URLSuggestedFilenameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8553FF51257523760029327F /* URLSuggestedFilenameTests.swift */; }; B630793A26731F2600DCEE41 /* FileDownloadManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B630793926731F2600DCEE41 /* FileDownloadManagerTests.swift */; }; @@ -2717,6 +2679,9 @@ B65783E725F8AAFB00D8DB33 /* String+Punycode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65783E625F8AAFB00D8DB33 /* String+Punycode.swift */; }; B657841A25FA484B00D8DB33 /* NSException+Catch.m in Sources */ = {isa = PBXBuildFile; fileRef = B657841925FA484B00D8DB33 /* NSException+Catch.m */; }; B657841F25FA497600D8DB33 /* NSException+Catch.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657841E25FA497600D8DB33 /* NSException+Catch.swift */; }; + B658BAB62B0F845D00D1F2C7 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B658BAB52B0F845D00D1F2C7 /* Localizable.xcstrings */; }; + B658BAB72B0F848D00D1F2C7 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B658BAB52B0F845D00D1F2C7 /* Localizable.xcstrings */; }; + B658BAB92B0F849100D1F2C7 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B658BAB52B0F845D00D1F2C7 /* Localizable.xcstrings */; }; B65CD8CB2B316DF100A595BB /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = B65CD8CA2B316DF100A595BB /* SnapshotTesting */; }; B65CD8CD2B316DFC00A595BB /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = B65CD8CC2B316DFC00A595BB /* SnapshotTesting */; }; B65CD8CF2B316E0200A595BB /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = B65CD8CE2B316E0200A595BB /* SnapshotTesting */; }; @@ -2733,6 +2698,15 @@ B65DA5F52A77D3FA00CBEE8D /* BundleExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6106B9D26A565DA0013B453 /* BundleExtension.swift */; }; B65E6B9E26D9EC0800095F96 /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65E6B9D26D9EC0800095F96 /* CircularProgressView.swift */; }; B65E6BA026D9F10600095F96 /* NSBezierPathExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65E6B9F26D9F10600095F96 /* NSBezierPathExtension.swift */; }; + B6619EF62B10DFF700CD9186 /* InstructionsFormatParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */; }; + B6619EF72B10DFF700CD9186 /* InstructionsFormatParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */; }; + B6619EFB2B111CC500CD9186 /* InstructionsFormatParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */; }; + B6619EFC2B111CC600CD9186 /* InstructionsFormatParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */; }; + B6619EFE2B111CCC00CD9186 /* InstructionsFormatParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */; }; + B6619F032B17123200CD9186 /* DataImportViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619F022B17123200CD9186 /* DataImportViewModelTests.swift */; }; + B6619F042B17123200CD9186 /* DataImportViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619F022B17123200CD9186 /* DataImportViewModelTests.swift */; }; + B6619F062B17138D00CD9186 /* DataImportSourceViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619F052B17138D00CD9186 /* DataImportSourceViewModelTests.swift */; }; + B6619F072B17138D00CD9186 /* DataImportSourceViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6619F052B17138D00CD9186 /* DataImportSourceViewModelTests.swift */; }; B66260DD29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66260DC29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift */; }; B66260DE29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66260DC29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift */; }; B66260E029AC6EBD00E9E3EE /* HistoryTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66260DF29AC6EBD00E9E3EE /* HistoryTabExtension.swift */; }; @@ -2743,7 +2717,10 @@ B662D3D92755D7AD0035D4D6 /* PixelStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B662D3D82755D7AD0035D4D6 /* PixelStoreTests.swift */; }; B662D3DE275613BB0035D4D6 /* EncryptionKeyStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B662D3DD275613BB0035D4D6 /* EncryptionKeyStoreMock.swift */; }; B662D3DF275616FF0035D4D6 /* EncryptionKeyStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B662D3DD275613BB0035D4D6 /* EncryptionKeyStoreMock.swift */; }; + B6656E0D2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6656E0C2B29C733008798A1 /* FileImportViewLocalizationTests.swift */; }; + B6656E0E2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6656E0C2B29C733008798A1 /* FileImportViewLocalizationTests.swift */; }; B6656E122B29E3BE008798A1 /* DownloadListStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693956026F1C1BC0015B914 /* DownloadListStoreMock.swift */; }; + B6656E5B2B2ADB1C008798A1 /* RequestFilePermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */; }; B6676BE12AA986A700525A21 /* AddressBarTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */; }; B6676BE22AA986A700525A21 /* AddressBarTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */; }; B6685E3D29A602D90043D2EE /* ExternalAppSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B687B7CB2947A1E9001DEA6F /* ExternalAppSchemeHandler.swift */; }; @@ -2751,6 +2728,15 @@ B6685E4029A606190043D2EE /* WorkspaceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6685E3E29A606190043D2EE /* WorkspaceProtocol.swift */; }; B6685E4229A61C470043D2EE /* DownloadsTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6685E4129A61C460043D2EE /* DownloadsTabExtension.swift */; }; B6685E4329A61C470043D2EE /* DownloadsTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6685E4129A61C460043D2EE /* DownloadsTabExtension.swift */; }; + B66CA41E2AD910B300447CF0 /* DataImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66CA41D2AD910B300447CF0 /* DataImportView.swift */; }; + B66CA41F2AD910B300447CF0 /* DataImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66CA41D2AD910B300447CF0 /* DataImportView.swift */; }; + B66CA4212AD910B300447CF0 /* DataImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66CA41D2AD910B300447CF0 /* DataImportView.swift */; }; + B677FC4F2B06376B0099EB04 /* ReportFeedbackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B677FC4E2B06376B0099EB04 /* ReportFeedbackView.swift */; }; + B677FC502B06376B0099EB04 /* ReportFeedbackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B677FC4E2B06376B0099EB04 /* ReportFeedbackView.swift */; }; + B677FC522B06376B0099EB04 /* ReportFeedbackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B677FC4E2B06376B0099EB04 /* ReportFeedbackView.swift */; }; + B677FC542B064A9C0099EB04 /* DataImportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B677FC532B064A9C0099EB04 /* DataImportViewModel.swift */; }; + B677FC552B064A9C0099EB04 /* DataImportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B677FC532B064A9C0099EB04 /* DataImportViewModel.swift */; }; + B677FC572B064A9C0099EB04 /* DataImportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B677FC532B064A9C0099EB04 /* DataImportViewModel.swift */; }; B67C6C3D2654B897006C872E /* WebViewExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67C6C3C2654B897006C872E /* WebViewExtensionTests.swift */; }; B67C6C422654BF49006C872E /* DuckDuckGo-Symbol.jpg in Resources */ = {isa = PBXBuildFile; fileRef = B67C6C412654BF49006C872E /* DuckDuckGo-Symbol.jpg */; }; B67C6C472654C643006C872E /* FileManagerExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67C6C462654C643006C872E /* FileManagerExtensionTests.swift */; }; @@ -2790,7 +2776,7 @@ B693954A26F04BEB0015B914 /* NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693953C26F04BE70015B914 /* NibLoadable.swift */; }; B693954B26F04BEB0015B914 /* MouseOverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693953D26F04BE70015B914 /* MouseOverView.swift */; }; B693954C26F04BEB0015B914 /* FocusRingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693953E26F04BE70015B914 /* FocusRingView.swift */; }; - B693954E26F04BEB0015B914 /* ProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693954026F04BE80015B914 /* ProgressView.swift */; }; + B693954E26F04BEB0015B914 /* LoadingProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693954026F04BE80015B914 /* LoadingProgressView.swift */; }; B693954F26F04BEB0015B914 /* PaddedImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693954126F04BE80015B914 /* PaddedImageButton.swift */; }; B693955026F04BEB0015B914 /* ShadowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693954226F04BE90015B914 /* ShadowView.swift */; }; B693955126F04BEB0015B914 /* GradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693954326F04BE90015B914 /* GradientView.swift */; }; @@ -2823,6 +2809,9 @@ B69B50542726CD8100758A2B /* atb-with-update.json in Resources */ = {isa = PBXBuildFile; fileRef = B69B50502726CD7F00758A2B /* atb-with-update.json */; }; B69B50552726CD8100758A2B /* invalid.json in Resources */ = {isa = PBXBuildFile; fileRef = B69B50512726CD8000758A2B /* invalid.json */; }; B69B50572727D16900758A2B /* AtbAndVariantCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69B50562727D16900758A2B /* AtbAndVariantCleanup.swift */; }; + B6A22B622B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */; }; + B6A22B632B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */; }; + B6A22B652B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */; }; B6A5A27125B9377300AA7ADA /* StatePersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A5A27025B9377300AA7ADA /* StatePersistenceService.swift */; }; B6A5A27925B93FFF00AA7ADA /* StateRestorationManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A5A27825B93FFE00AA7ADA /* StateRestorationManagerTests.swift */; }; B6A5A27E25B9403E00AA7ADA /* FileStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A5A27D25B9403E00AA7ADA /* FileStoreMock.swift */; }; @@ -2856,6 +2845,23 @@ B6B1E88B26D774090062C350 /* LinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B1E88A26D774090062C350 /* LinkButton.swift */; }; B6B2400E28083B49001B8F3A /* WebViewContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B2400D28083B49001B8F3A /* WebViewContainerView.swift */; }; B6B3E0E12657EA7A0040E0A2 /* NSScreenExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B3E0DC2657E9CF0040E0A2 /* NSScreenExtension.swift */; }; + B6B4D1C52B0B3B5400C26286 /* DataImportReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */; }; + B6B4D1C62B0B3B5400C26286 /* DataImportReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */; }; + B6B4D1C82B0B3B5400C26286 /* DataImportReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */; }; + B6B4D1CA2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C92B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift */; }; + B6B4D1CB2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C92B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift */; }; + B6B4D1CD2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1C92B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift */; }; + B6B4D1CF2B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */; }; + B6B4D1D02B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */; }; + B6B4D1D22B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */; }; + B6B5F57F2B024105008DB58A /* DataImportSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */; }; + B6B5F5802B024105008DB58A /* DataImportSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */; }; + B6B5F5822B024105008DB58A /* DataImportSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */; }; + B6B5F5842B03580A008DB58A /* RequestFilePermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */; }; + B6B5F5852B03580A008DB58A /* RequestFilePermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */; }; + B6B5F5892B03673B008DB58A /* BrowserImportMoreInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F5882B03673B008DB58A /* BrowserImportMoreInfoView.swift */; }; + B6B5F58A2B03673B008DB58A /* BrowserImportMoreInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F5882B03673B008DB58A /* BrowserImportMoreInfoView.swift */; }; + B6B5F58C2B03673B008DB58A /* BrowserImportMoreInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B5F5882B03673B008DB58A /* BrowserImportMoreInfoView.swift */; }; B6B71C582B23379600487131 /* NSLayoutConstraintExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B71C572B23379600487131 /* NSLayoutConstraintExtension.swift */; }; B6B71C592B23379600487131 /* NSLayoutConstraintExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B71C572B23379600487131 /* NSLayoutConstraintExtension.swift */; }; B6B71C5A2B23379600487131 /* NSLayoutConstraintExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B71C572B23379600487131 /* NSLayoutConstraintExtension.swift */; }; @@ -2863,6 +2869,21 @@ B6BBF1702744CDE1004F850E /* CoreDataStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BBF16F2744CDE1004F850E /* CoreDataStoreTests.swift */; }; B6BBF1722744CE36004F850E /* FireproofDomainsStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BBF1712744CE36004F850E /* FireproofDomainsStoreMock.swift */; }; B6BBF17427475B15004F850E /* PopupBlockedPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BBF17327475B15004F850E /* PopupBlockedPopover.swift */; }; + B6BCC51E2AFCD9ED002C5499 /* DataImportSourcePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC51D2AFCD9ED002C5499 /* DataImportSourcePicker.swift */; }; + B6BCC51F2AFCD9ED002C5499 /* DataImportSourcePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC51D2AFCD9ED002C5499 /* DataImportSourcePicker.swift */; }; + B6BCC5212AFCD9ED002C5499 /* DataImportSourcePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC51D2AFCD9ED002C5499 /* DataImportSourcePicker.swift */; }; + B6BCC5232AFCDABB002C5499 /* DataImportSourceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC5222AFCDABB002C5499 /* DataImportSourceViewModel.swift */; }; + B6BCC5242AFCDABB002C5499 /* DataImportSourceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC5222AFCDABB002C5499 /* DataImportSourceViewModel.swift */; }; + B6BCC5262AFCDABB002C5499 /* DataImportSourceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC5222AFCDABB002C5499 /* DataImportSourceViewModel.swift */; }; + B6BCC53B2AFD15DF002C5499 /* DataImportProfilePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC53A2AFD15DF002C5499 /* DataImportProfilePicker.swift */; }; + B6BCC53C2AFD15DF002C5499 /* DataImportProfilePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC53A2AFD15DF002C5499 /* DataImportProfilePicker.swift */; }; + B6BCC53E2AFD15DF002C5499 /* DataImportProfilePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC53A2AFD15DF002C5499 /* DataImportProfilePicker.swift */; }; + B6BCC54A2AFDF24B002C5499 /* TaskWithProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC5492AFDF24B002C5499 /* TaskWithProgress.swift */; }; + B6BCC54B2AFDF24B002C5499 /* TaskWithProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC5492AFDF24B002C5499 /* TaskWithProgress.swift */; }; + B6BCC54D2AFDF24B002C5499 /* TaskWithProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC5492AFDF24B002C5499 /* TaskWithProgress.swift */; }; + B6BCC54F2AFE4F7D002C5499 /* DataImportTypePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC54E2AFE4F7D002C5499 /* DataImportTypePicker.swift */; }; + B6BCC5502AFE4F7D002C5499 /* DataImportTypePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC54E2AFE4F7D002C5499 /* DataImportTypePicker.swift */; }; + B6BCC5522AFE4F7D002C5499 /* DataImportTypePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BCC54E2AFE4F7D002C5499 /* DataImportTypePicker.swift */; }; B6BDDA012942389000F68088 /* TabExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BDDA002942389000F68088 /* TabExtensions.swift */; }; B6BE9FAA293F7955006363C6 /* ModalSheetCancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BE9FA9293F7955006363C6 /* ModalSheetCancellable.swift */; }; B6BF5D852946FFDA006742B1 /* PrivacyDashboardTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BF5D842946FFDA006742B1 /* PrivacyDashboardTabExtension.swift */; }; @@ -2887,6 +2908,9 @@ 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 */; }; + 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 */; }; B6D574B429472253008ED1B6 /* FBProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D574B329472253008ED1B6 /* FBProtectionTabExtension.swift */; }; @@ -2907,6 +2931,9 @@ B6DB3AF6278EA0130024C5C4 /* BundleExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6106B9D26A565DA0013B453 /* BundleExtension.swift */; }; B6DB3CF926A00E2D00D459B7 /* AVCaptureDevice+SwizzledAuthState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DB3CF826A00E2D00D459B7 /* AVCaptureDevice+SwizzledAuthState.swift */; }; B6DB3CFB26A17CB800D459B7 /* PermissionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DB3CFA26A17CB800D459B7 /* PermissionModel.swift */; }; + B6DE57F62B05EA9000CD54B9 /* SheetHostingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DE57F52B05EA9000CD54B9 /* SheetHostingWindow.swift */; }; + B6DE57F72B05EA9000CD54B9 /* SheetHostingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DE57F52B05EA9000CD54B9 /* SheetHostingWindow.swift */; }; + B6DE57F92B05EA9000CD54B9 /* SheetHostingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DE57F52B05EA9000CD54B9 /* SheetHostingWindow.swift */; }; B6E1491029A5C30500AAFBE8 /* ContentBlockingTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D574B12947224C008ED1B6 /* ContentBlockingTabExtension.swift */; }; B6E1491129A5C30A00AAFBE8 /* FBProtectionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D574B329472253008ED1B6 /* FBProtectionTabExtension.swift */; }; B6E319382953446000DD3BCF /* Assertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E319372953446000DD3BCF /* Assertions.swift */; }; @@ -3180,9 +3207,8 @@ 1E7E2E8F29029A2A00C01B54 /* ContentBlockingRulesUpdateObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentBlockingRulesUpdateObserver.swift; sourceTree = ""; }; 1E7E2E9129029F9B00C01B54 /* WebsiteBreakageReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteBreakageReporter.swift; sourceTree = ""; }; 1E7E2E932902AC0E00C01B54 /* PrivacyDashboardPermissionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardPermissionHandler.swift; sourceTree = ""; }; - 1E862A852A9FBD7000F84D4B /* Account */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Account; sourceTree = ""; }; - 1E862A882A9FC01200F84D4B /* Subscription */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Subscription; sourceTree = ""; }; - 1EC88CA22AC1DE82003A4471 /* Purchase */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Purchase; sourceTree = ""; }; + 1E862A882A9FC01200F84D4B /* SubscriptionUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SubscriptionUI; sourceTree = ""; }; + 1E8F997E2B221B3600AC5D34 /* Subscription */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Subscription; sourceTree = ""; }; 310E79BE294A19A8007C49E8 /* FireproofingReferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FireproofingReferenceTests.swift; sourceTree = ""; }; 311B262628E73E0A00FD181A /* TabShadowConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabShadowConfig.swift; sourceTree = ""; }; 3139A1512AA4B3C000969C7D /* DataBrokerProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionManager.swift; sourceTree = ""; }; @@ -3274,7 +3300,7 @@ 378E2799296F6FDE00FCADA2 /* ManualAppStoreRelease.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ManualAppStoreRelease.xcconfig; sourceTree = ""; }; 378E279D2970217400FCADA2 /* BuildToolPlugins */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = BuildToolPlugins; sourceTree = ""; }; 378F44E229B4B7B600899924 /* SwiftUIExtensions */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SwiftUIExtensions; sourceTree = ""; }; - 378F44EA29B4C73E00899924 /* View+RoundedCorners.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+RoundedCorners.swift"; sourceTree = ""; }; + 378F44EA29B4C73E00899924 /* ViewExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewExtension.swift; sourceTree = ""; }; 379DE4BC27EA31AC002CC3DE /* PreferencesAutofillView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesAutofillView.swift; sourceTree = ""; }; 379E877529E98729001C8BB0 /* BookmarksCleanupErrorHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksCleanupErrorHandling.swift; sourceTree = ""; }; 37A4CEB9282E992F00D75B89 /* StartupPreferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StartupPreferences.swift; sourceTree = ""; }; @@ -3367,7 +3393,6 @@ 4B2D06642A132F3A00DE1F49 /* NetworkProtectionAppExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NetworkProtectionAppExtension.entitlements; sourceTree = ""; }; 4B2D06692A13318400DE1F49 /* DuckDuckGo VPN App Store.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "DuckDuckGo VPN App Store.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 4B2E7D6226FF9D6500D2DB17 /* PrintingUserScript.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrintingUserScript.swift; sourceTree = ""; }; - 4B36CD352B34E46900FBC707 /* NetworkProtectionSubscriptionEventHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkProtectionSubscriptionEventHandler.swift; sourceTree = ""; }; 4B379C1427BD91E3008A968E /* QuartzIdleStateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuartzIdleStateProvider.swift; sourceTree = ""; }; 4B379C1D27BDB7FF008A968E /* DeviceAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAuthenticator.swift; sourceTree = ""; }; 4B379C2127BDBA29008A968E /* LocalAuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationService.swift; sourceTree = ""; }; @@ -3413,11 +3438,8 @@ 4B4D60D22A0C84F700BCD287 /* UserText+NetworkProtection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UserText+NetworkProtection.swift"; sourceTree = ""; }; 4B4D60E12A0C883A00BCD287 /* AppMain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMain.swift; sourceTree = ""; }; 4B4F72EB266B2ED300814C60 /* CollectionExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionExtension.swift; sourceTree = ""; }; - 4B59023826B35F3600489384 /* ChromeDataImporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChromeDataImporter.swift; sourceTree = ""; }; 4B59023926B35F3600489384 /* ChromiumLoginReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChromiumLoginReader.swift; sourceTree = ""; }; 4B59023B26B35F3600489384 /* ChromiumDataImporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChromiumDataImporter.swift; sourceTree = ""; }; - 4B59023C26B35F3600489384 /* BraveDataImporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BraveDataImporter.swift; sourceTree = ""; }; - 4B59024226B35F7C00489384 /* BrowserImportViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserImportViewController.swift; sourceTree = ""; }; 4B59024726B3673600489384 /* ThirdPartyBrowser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartyBrowser.swift; sourceTree = ""; }; 4B59024B26B38BB800489384 /* ChromiumLoginReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromiumLoginReaderTests.swift; sourceTree = ""; }; 4B59CC8B290083240058F2F6 /* ConnectBitwardenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectBitwardenViewModelTests.swift; sourceTree = ""; }; @@ -3437,10 +3459,6 @@ 4B70BFFF27B0793D000386ED /* DuckDuckGo-ExampleCrash.ips */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "DuckDuckGo-ExampleCrash.ips"; sourceTree = ""; }; 4B70C00027B0793D000386ED /* CrashReportTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CrashReportTests.swift; sourceTree = ""; }; 4B723DEB26B0002B00E14D75 /* DataImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImport.swift; sourceTree = ""; }; - 4B723DED26B0002B00E14D75 /* DataImport.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = DataImport.storyboard; sourceTree = ""; }; - 4B723DEE26B0002B00E14D75 /* DataImportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportViewController.swift; sourceTree = ""; }; - 4B723DEF26B0002B00E14D75 /* FileImportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileImportViewController.swift; sourceTree = ""; }; - 4B723DF026B0002B00E14D75 /* FileImportSummaryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileImportSummaryViewController.swift; sourceTree = ""; }; 4B723DF326B0002B00E14D75 /* SecureVaultLoginImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureVaultLoginImporter.swift; sourceTree = ""; }; 4B723DF426B0002B00E14D75 /* LoginImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginImport.swift; sourceTree = ""; }; 4B723DF626B0002B00E14D75 /* CSVParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVParser.swift; sourceTree = ""; }; @@ -3452,14 +3470,12 @@ 4B723E0326B0003E00E14D75 /* MockSecureVault.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockSecureVault.swift; sourceTree = ""; }; 4B723E0426B0003E00E14D75 /* CSVLoginExporterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CSVLoginExporterTests.swift; sourceTree = ""; }; 4B723E1726B000DC00E14D75 /* TemporaryFileCreator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TemporaryFileCreator.swift; sourceTree = ""; }; - 4B78A86A26BB3ADD0071BB16 /* BrowserImportSummaryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportSummaryViewController.swift; sourceTree = ""; }; - 4B7A57CE279A4EF300B1C70E /* ChromePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromePreferences.swift; sourceTree = ""; }; + 4B7A57CE279A4EF300B1C70E /* ChromiumPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromiumPreferences.swift; sourceTree = ""; }; 4B7A60A0273E0BE400BBDFEB /* WKWebsiteDataStoreExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKWebsiteDataStoreExtension.swift; sourceTree = ""; }; 4B81AD312B29507300706C96 /* DataBrokerProtectionPixelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DataBrokerProtectionPixelTests.swift; path = LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionPixelTests.swift; sourceTree = SOURCE_ROOT; }; 4B85A47F28821CC500FC4C39 /* NSPasteboardItemExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSPasteboardItemExtension.swift; sourceTree = ""; }; 4B8A4DFE27C83B29005F40E8 /* SaveIdentityViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveIdentityViewController.swift; sourceTree = ""; }; 4B8A4E0027C8447E005F40E8 /* SaveIdentityPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveIdentityPopover.swift; sourceTree = ""; }; - 4B8AC93226B3B06300879451 /* EdgeDataImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EdgeDataImporter.swift; sourceTree = ""; }; 4B8AC93426B3B2FD00879451 /* NSAlert+DataImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAlert+DataImport.swift"; sourceTree = ""; }; 4B8AC93826B48A5100879451 /* FirefoxLoginReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxLoginReader.swift; sourceTree = ""; }; 4B8AC93A26B48ADF00879451 /* ASN1Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASN1Parser.swift; sourceTree = ""; }; @@ -3556,7 +3572,6 @@ 4BB99CFA26FE191E001E4761 /* ImportedBookmarks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImportedBookmarks.swift; sourceTree = ""; }; 4BB99CFC26FE191E001E4761 /* SafariBookmarksReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SafariBookmarksReader.swift; sourceTree = ""; }; 4BB99CFD26FE191E001E4761 /* SafariDataImporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SafariDataImporter.swift; sourceTree = ""; }; - 4BB99D0526FE1979001E4761 /* RequestFilePermissionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestFilePermissionViewController.swift; sourceTree = ""; }; 4BB99D0C26FE1A83001E4761 /* ChromiumBookmarksReaderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChromiumBookmarksReaderTests.swift; sourceTree = ""; }; 4BB99D0D26FE1A83001E4761 /* FirefoxBookmarksReaderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirefoxBookmarksReaderTests.swift; sourceTree = ""; }; 4BB99D0E26FE1A84001E4761 /* SafariBookmarksReaderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SafariBookmarksReaderTests.swift; sourceTree = ""; }; @@ -3750,7 +3765,6 @@ 85B7184B27677C6500B4277F /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; }; 85B7184D27677CBB00B4277F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 85C48CCB278D808F00D3263E /* NSAttributedStringExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSAttributedStringExtension.swift; sourceTree = ""; }; - 85C48CD027908C1000D3263E /* BrowserImportMoreInfoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMoreInfoViewController.swift; sourceTree = ""; }; 85C5991A27D10CF000E605B2 /* FireAnimationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FireAnimationView.swift; sourceTree = ""; }; 85C6A29525CC1FFD00EEB5F1 /* UserDefaultsWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsWrapper.swift; sourceTree = ""; }; 85CC1D7A26A05ECF0062F04E /* PasswordManagementItemListModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordManagementItemListModel.swift; sourceTree = ""; }; @@ -3889,8 +3903,6 @@ AA80EC68256C4691007083E7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/BrowserTab.storyboard; sourceTree = ""; }; AA80EC74256C46A2007083E7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Suggestion.storyboard; sourceTree = ""; }; AA80EC7A256C46AA007083E7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/TabBar.storyboard; sourceTree = ""; }; - AA80EC8A256C49B8007083E7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - AA80EC90256C49BC007083E7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; AA840A9727319D1600E63CDD /* FirePopoverWrapperViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirePopoverWrapperViewController.swift; sourceTree = ""; }; AA88D14A252A557100980B4E /* URLRequestExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRequestExtension.swift; sourceTree = ""; }; AA8EDF2324923E980071C2E8 /* URLExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtension.swift; sourceTree = ""; }; @@ -3991,6 +4003,7 @@ B603FD9D2A02712E00F3FCA9 /* CIImageExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageExtension.swift; sourceTree = ""; }; B6040855274B830F00680351 /* DictionaryExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryExtension.swift; sourceTree = ""; }; B604085B274B8CA400680351 /* Permissions.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Permissions.xcdatamodel; sourceTree = ""; }; + B6080BC42B21E78100B418EF /* DataImportErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportErrorView.swift; sourceTree = ""; }; B6085D052743905F00A9C456 /* CoreDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStore.swift; sourceTree = ""; }; B6085D082743993D00A9C456 /* Permissions.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Permissions.xcdatamodel; sourceTree = ""; }; B60C6F7629B0E286007BFAA8 /* SearchNonexistentDomainNavigationResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchNonexistentDomainNavigationResponder.swift; sourceTree = ""; }; @@ -4023,6 +4036,7 @@ B62A233F29C41D4400D22475 /* HistoryIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryIntegrationTests.swift; sourceTree = ""; }; B62B48382ADE46FC000DECE5 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; B62B483D2ADE48DE000DECE5 /* MenuBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBuilder.swift; sourceTree = ""; }; + B62B48552ADE730D000DECE5 /* FileImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileImportView.swift; sourceTree = ""; }; B62EB47B25BAD3BB005745C6 /* WKWebViewPrivateMethodsAvailabilityTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WKWebViewPrivateMethodsAvailabilityTests.swift; sourceTree = ""; }; B630793926731F2600DCEE41 /* FileDownloadManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileDownloadManagerTests.swift; sourceTree = ""; }; B630794126731F5400DCEE41 /* WKDownloadMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKDownloadMock.swift; sourceTree = ""; }; @@ -4070,19 +4084,28 @@ B657841825FA484B00D8DB33 /* NSException+Catch.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSException+Catch.h"; sourceTree = ""; }; B657841925FA484B00D8DB33 /* NSException+Catch.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSException+Catch.m"; sourceTree = ""; }; B657841E25FA497600D8DB33 /* NSException+Catch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSException+Catch.swift"; sourceTree = ""; }; + B658BAB52B0F845D00D1F2C7 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; B65CD8D42B316FCA00A595BB /* __Snapshots__ */ = {isa = PBXFileReference; lastKnownFileType = folder; path = __Snapshots__; sourceTree = ""; }; B65CD8D72B341FD300A595BB /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; B65E6B9D26D9EC0800095F96 /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; B65E6B9F26D9F10600095F96 /* NSBezierPathExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSBezierPathExtension.swift; sourceTree = ""; }; + B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionsFormatParserTests.swift; sourceTree = ""; }; + B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionsFormatParser.swift; sourceTree = ""; }; + B6619F022B17123200CD9186 /* DataImportViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportViewModelTests.swift; sourceTree = ""; }; + B6619F052B17138D00CD9186 /* DataImportSourceViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportSourceViewModelTests.swift; sourceTree = ""; }; B66260DC29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationProtectionTabExtension.swift; sourceTree = ""; }; B66260DF29AC6EBD00E9E3EE /* HistoryTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryTabExtension.swift; sourceTree = ""; }; B66260E529ACAE4B00E9E3EE /* NavigationHotkeyHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationHotkeyHandler.swift; sourceTree = ""; }; B662D3D82755D7AD0035D4D6 /* PixelStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PixelStoreTests.swift; sourceTree = ""; }; B662D3DD275613BB0035D4D6 /* EncryptionKeyStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionKeyStoreMock.swift; sourceTree = ""; }; + B6656E0C2B29C733008798A1 /* FileImportViewLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileImportViewLocalizationTests.swift; sourceTree = ""; }; B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressBarTextEditor.swift; sourceTree = ""; }; B6685E3E29A606190043D2EE /* WorkspaceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceProtocol.swift; sourceTree = ""; }; B6685E4129A61C460043D2EE /* DownloadsTabExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadsTabExtension.swift; sourceTree = ""; }; B66B9C5B29A5EBAD0010E8F3 /* NavigationActionExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationActionExtension.swift; sourceTree = ""; }; + B66CA41D2AD910B300447CF0 /* DataImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportView.swift; sourceTree = ""; }; + B677FC4E2B06376B0099EB04 /* ReportFeedbackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportFeedbackView.swift; sourceTree = ""; }; + B677FC532B064A9C0099EB04 /* DataImportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportViewModel.swift; sourceTree = ""; }; B67C6C3C2654B897006C872E /* WebViewExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewExtensionTests.swift; sourceTree = ""; }; B67C6C412654BF49006C872E /* DuckDuckGo-Symbol.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "DuckDuckGo-Symbol.jpg"; sourceTree = ""; }; B67C6C462654C643006C872E /* FileManagerExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerExtensionTests.swift; sourceTree = ""; }; @@ -4110,7 +4133,7 @@ B693953C26F04BE70015B914 /* NibLoadable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NibLoadable.swift; sourceTree = ""; }; B693953D26F04BE70015B914 /* MouseOverView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MouseOverView.swift; sourceTree = ""; }; B693953E26F04BE70015B914 /* FocusRingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusRingView.swift; sourceTree = ""; }; - B693954026F04BE80015B914 /* ProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressView.swift; sourceTree = ""; }; + B693954026F04BE80015B914 /* LoadingProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadingProgressView.swift; sourceTree = ""; }; B693954126F04BE80015B914 /* PaddedImageButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaddedImageButton.swift; sourceTree = ""; }; B693954226F04BE90015B914 /* ShadowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowView.swift; sourceTree = ""; }; B693954326F04BE90015B914 /* GradientView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GradientView.swift; sourceTree = ""; }; @@ -4145,6 +4168,7 @@ B69B50502726CD7F00758A2B /* atb-with-update.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "atb-with-update.json"; sourceTree = ""; }; B69B50512726CD8000758A2B /* invalid.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = invalid.json; sourceTree = ""; }; B69B50562727D16900758A2B /* AtbAndVariantCleanup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AtbAndVariantCleanup.swift; sourceTree = ""; }; + B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportSummaryViewModel.swift; sourceTree = ""; }; B6A5A27025B9377300AA7ADA /* StatePersistenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatePersistenceService.swift; sourceTree = ""; }; B6A5A27825B93FFE00AA7ADA /* StateRestorationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRestorationManagerTests.swift; sourceTree = ""; }; B6A5A27D25B9403E00AA7ADA /* FileStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileStoreMock.swift; sourceTree = ""; }; @@ -4171,10 +4195,21 @@ B6B1E88A26D774090062C350 /* LinkButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkButton.swift; sourceTree = ""; }; B6B2400D28083B49001B8F3A /* WebViewContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewContainerView.swift; sourceTree = ""; }; B6B3E0DC2657E9CF0040E0A2 /* NSScreenExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSScreenExtension.swift; sourceTree = ""; }; + B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportReportModel.swift; sourceTree = ""; }; + B6B4D1C92B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxCompatibilityPreferences.swift; sourceTree = ""; }; + B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportNoDataView.swift; sourceTree = ""; }; + B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportSummaryView.swift; sourceTree = ""; }; + B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestFilePermissionView.swift; sourceTree = ""; }; + B6B5F5882B03673B008DB58A /* BrowserImportMoreInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMoreInfoView.swift; sourceTree = ""; }; B6B71C572B23379600487131 /* NSLayoutConstraintExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraintExtension.swift; sourceTree = ""; }; B6BBF16F2744CDE1004F850E /* CoreDataStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStoreTests.swift; sourceTree = ""; }; B6BBF1712744CE36004F850E /* FireproofDomainsStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FireproofDomainsStoreMock.swift; sourceTree = ""; }; B6BBF17327475B15004F850E /* PopupBlockedPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupBlockedPopover.swift; sourceTree = ""; }; + B6BCC51D2AFCD9ED002C5499 /* DataImportSourcePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportSourcePicker.swift; sourceTree = ""; }; + B6BCC5222AFCDABB002C5499 /* DataImportSourceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportSourceViewModel.swift; sourceTree = ""; }; + B6BCC53A2AFD15DF002C5499 /* DataImportProfilePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportProfilePicker.swift; sourceTree = ""; }; + B6BCC5492AFDF24B002C5499 /* TaskWithProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskWithProgress.swift; sourceTree = ""; }; + B6BCC54E2AFE4F7D002C5499 /* DataImportTypePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportTypePicker.swift; sourceTree = ""; }; B6BDD9F429409DDD00F68088 /* ContentBlockingMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentBlockingMock.swift; sourceTree = ""; }; B6BDDA002942389000F68088 /* TabExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabExtensions.swift; sourceTree = ""; }; B6BE9FA9293F7955006363C6 /* ModalSheetCancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalSheetCancellable.swift; sourceTree = ""; }; @@ -4198,6 +4233,7 @@ 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 = ""; }; + B6C8CAA62AD010DD0060E1CD /* YandexDataImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YandexDataImporter.swift; sourceTree = ""; }; B6CA4823298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdClickAttributionTabExtensionTests.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 = ""; }; @@ -4213,6 +4249,7 @@ B6DB3AEE278D5C370024C5C4 /* URLSessionExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionExtension.swift; sourceTree = ""; }; B6DB3CF826A00E2D00D459B7 /* AVCaptureDevice+SwizzledAuthState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVCaptureDevice+SwizzledAuthState.swift"; sourceTree = ""; }; B6DB3CFA26A17CB800D459B7 /* PermissionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionModel.swift; sourceTree = ""; }; + 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 = ""; }; B6EC37DD29B5D05A001ACE79 /* DownloadsIntegrationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadsIntegrationTests.swift; sourceTree = ""; }; @@ -4390,12 +4427,12 @@ buildActionMask = 2147483647; files = ( 4B957BD52AC7AE700062CA31 /* QuickLookUI.framework in Frameworks */, + 1E934E2B2B167CA80084722B /* Subscription in Frameworks */, 3143C8792B0D1F3D00382627 /* DataBrokerProtection in Frameworks */, 372217842B33380E00B8E9C2 /* TestUtils in Frameworks */, 4B957BD62AC7AE700062CA31 /* LoginItems in Frameworks */, 4B957BD72AC7AE700062CA31 /* NetworkProtection in Frameworks */, 4B957BD82AC7AE700062CA31 /* BrowserServicesKit in Frameworks */, - 4B957BD92AC7AE700062CA31 /* Account in Frameworks */, 4B957BDA2AC7AE700062CA31 /* Bookmarks in Frameworks */, 4B957BDB2AC7AE700062CA31 /* ContentBlocking in Frameworks */, 4B957BDC2AC7AE700062CA31 /* SwiftUIExtensions in Frameworks */, @@ -4403,9 +4440,7 @@ 7BBD44282AD730A400D0A064 /* PixelKit in Frameworks */, 7B31FD902AD1257B0086AA24 /* NetworkProtectionIPC in Frameworks */, 4B957BDE2AC7AE700062CA31 /* Configuration in Frameworks */, - 4B957BDF2AC7AE700062CA31 /* Purchase in Frameworks */, 4B957BE02AC7AE700062CA31 /* Lottie in Frameworks */, - 4B957BE12AC7AE700062CA31 /* Subscription in Frameworks */, 4B957BE22AC7AE700062CA31 /* Sparkle in Frameworks */, 4B957BE32AC7AE700062CA31 /* Navigation in Frameworks */, 4B957BE42AC7AE700062CA31 /* DDGSync in Frameworks */, @@ -4416,6 +4451,7 @@ 37269F032B332FD8005E8E46 /* Common in Frameworks */, 4B957BE82AC7AE700062CA31 /* SyncUI in Frameworks */, 4B957BE92AC7AE700062CA31 /* NetworkProtectionUI in Frameworks */, + 1E0068AD2B1673BB00BBF43B /* SubscriptionUI in Frameworks */, 4B957BEB2AC7AE700062CA31 /* Persistence in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4463,7 +4499,6 @@ 9DB6E7242AA0DC5800A17F3C /* LoginItems in Frameworks */, EE7295E32A545B9A008C0991 /* NetworkProtection in Frameworks */, 9807F645278CA16F00E1547B /* BrowserServicesKit in Frameworks */, - 1EC88CA12AC1DD63003A4471 /* Account in Frameworks */, 987799ED299998B1005D8EB6 /* Bookmarks in Frameworks */, 7B5DD69A2AE51FFA001DE99C /* PixelKit in Frameworks */, 1E950E3F2912A10D0051A99B /* ContentBlocking in Frameworks */, @@ -4473,7 +4508,6 @@ CBC83E3629B63D380008E19C /* Configuration in Frameworks */, 7B31FD8C2AD125620086AA24 /* NetworkProtectionIPC in Frameworks */, 37269EFB2B332F9E005E8E46 /* Common in Frameworks */, - 1E3ED4FD2AC1E0290075F60F /* Purchase in Frameworks */, 4B2AAAF529E70DEA0026AFC0 /* Lottie in Frameworks */, AA06B6B72672AF8100F541C5 /* Sparkle in Frameworks */, B6B77BE8297973D4001E68A1 /* Navigation in Frameworks */, @@ -4860,16 +4894,15 @@ 378E279C2970217400FCADA2 /* LocalPackages */ = { isa = PBXGroup; children = ( - 1E862A852A9FBD7000F84D4B /* Account */, 378E279D2970217400FCADA2 /* BuildToolPlugins */, 3192A2702A4C4E330084EA89 /* DataBrokerProtection */, 9DB6E7222AA0DA7A00A17F3C /* LoginItems */, 7B25FE322AD12C990012AFAB /* NetworkProtectionMac */, 4BE15DB12A0B0DD500898243 /* PixelKit */, - 1EC88CA22AC1DE82003A4471 /* Purchase */, 378F44E229B4B7B600899924 /* SwiftUIExtensions */, 37BA812B29B3CB8A0053F1A3 /* SyncUI */, - 1E862A882A9FC01200F84D4B /* Subscription */, + 1E8F997E2B221B3600AC5D34 /* Subscription */, + 1E862A882A9FC01200F84D4B /* SubscriptionUI */, 7BEC182D2AD5D89C00D30536 /* SystemExtensionManager */, 7B76E6852AD5D77600186A84 /* XPCHelper */, ); @@ -5180,7 +5213,6 @@ children = ( 4BCF15D52ABB83D70083F6DF /* NetworkProtectionRemoteMessaging */, 7BA7CC4D2AD11F6F0042E5CE /* NetworkProtectionIPCTunnelController.swift */, - 4B36CD352B34E46900FBC707 /* NetworkProtectionSubscriptionEventHandler.swift */, ); path = DeveloperIDTarget; sourceTree = ""; @@ -5268,12 +5300,10 @@ 4B59023726B35F3600489384 /* Chromium */ = { isa = PBXGroup; children = ( - 4B59023826B35F3600489384 /* ChromeDataImporter.swift */, 4B59023926B35F3600489384 /* ChromiumLoginReader.swift */, 4BE53373286E39F10019DBFD /* ChromiumKeychainPrompt.swift */, 4B59023B26B35F3600489384 /* ChromiumDataImporter.swift */, - 4B59023C26B35F3600489384 /* BraveDataImporter.swift */, - 4B8AC93226B3B06300879451 /* EdgeDataImporter.swift */, + B6C8CAA62AD010DD0060E1CD /* YandexDataImporter.swift */, ); path = Chromium; sourceTree = ""; @@ -5369,10 +5399,12 @@ 4B723DEB26B0002B00E14D75 /* DataImport.swift */, 4B5A4F4B27F3A5AA008FBD88 /* NSNotificationName+DataImport.swift */, 4B59024726B3673600489384 /* ThirdPartyBrowser.swift */, - 4B7A57CE279A4EF300B1C70E /* ChromePreferences.swift */, + 4B7A57CE279A4EF300B1C70E /* ChromiumPreferences.swift */, + B6B4D1C92B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift */, 4BB99CF326FE191E001E4761 /* Bookmarks */, 4B723DF126B0002B00E14D75 /* Logins */, 4B723DEC26B0002B00E14D75 /* View */, + B6BCC51C2AFCD9C9002C5499 /* Model */, ); path = DataImport; sourceTree = ""; @@ -5380,15 +5412,18 @@ 4B723DEC26B0002B00E14D75 /* View */ = { isa = PBXGroup; children = ( - 85C48CD027908C1000D3263E /* BrowserImportMoreInfoViewController.swift */, - 4B78A86A26BB3ADD0071BB16 /* BrowserImportSummaryViewController.swift */, - 4B59024226B35F7C00489384 /* BrowserImportViewController.swift */, - 4B723DF026B0002B00E14D75 /* FileImportSummaryViewController.swift */, - 4B723DEF26B0002B00E14D75 /* FileImportViewController.swift */, - 4B723DED26B0002B00E14D75 /* DataImport.storyboard */, - 4B723DEE26B0002B00E14D75 /* DataImportViewController.swift */, + B66CA41D2AD910B300447CF0 /* DataImportView.swift */, + B6BCC51D2AFCD9ED002C5499 /* DataImportSourcePicker.swift */, + B6BCC53A2AFD15DF002C5499 /* DataImportProfilePicker.swift */, + B6BCC54E2AFE4F7D002C5499 /* DataImportTypePicker.swift */, + B62B48552ADE730D000DECE5 /* FileImportView.swift */, + B6B5F5882B03673B008DB58A /* BrowserImportMoreInfoView.swift */, + B6B5F57E2B024105008DB58A /* DataImportSummaryView.swift */, + B6080BC42B21E78100B418EF /* DataImportErrorView.swift */, + B6B4D1CE2B0E0DD000C26286 /* DataImportNoDataView.swift */, 4B8AC93426B3B2FD00879451 /* NSAlert+DataImport.swift */, - 4BB99D0526FE1979001E4761 /* RequestFilePermissionViewController.swift */, + B6B5F5832B03580A008DB58A /* RequestFilePermissionView.swift */, + B677FC4E2B06376B0099EB04 /* ReportFeedbackView.swift */, ); path = View; sourceTree = ""; @@ -5445,7 +5480,11 @@ 4B723E0126B0003E00E14D75 /* CSVImporterTests.swift */, 4B723E0026B0003E00E14D75 /* CSVParserTests.swift */, 4B723DFF26B0003E00E14D75 /* DataImportMocks.swift */, + B6619F052B17138D00CD9186 /* DataImportSourceViewModelTests.swift */, + B6619F022B17123200CD9186 /* DataImportViewModelTests.swift */, + B6619EF52B10DFF700CD9186 /* InstructionsFormatParserTests.swift */, 4BB99D0D26FE1A83001E4761 /* FirefoxBookmarksReaderTests.swift */, + B6656E0C2B29C733008798A1 /* FileImportViewLocalizationTests.swift */, 4B98D27B28D960DD003C2B6F /* FirefoxFaviconsReaderTests.swift */, 4B43469428655D1400177407 /* FirefoxDataImporterTests.swift */, 4B2975982828285900187C4E /* FirefoxKeyReaderTests.swift */, @@ -5491,12 +5530,12 @@ 4B8AC93726B489C500879451 /* Firefox */ = { isa = PBXGroup; children = ( + 4B8AC93A26B48ADF00879451 /* ASN1Parser.swift */, + B696AFFA2AC5924800C93203 /* FileLineError.swift */, 4B8AC93826B48A5100879451 /* FirefoxLoginReader.swift */, 4B29759628281F0900187C4E /* FirefoxEncryptionKeyReader.swift */, 3701C9CD29BD040900305B15 /* FirefoxBerkeleyDatabaseReader.swift */, 4B5FF67726B602B100D42879 /* FirefoxDataImporter.swift */, - 4B8AC93A26B48ADF00879451 /* ASN1Parser.swift */, - B696AFFA2AC5924800C93203 /* FileLineError.swift */, ); path = Firefox; sourceTree = ""; @@ -6039,8 +6078,9 @@ 37CC53F327E8D4620028713D /* NSPathControlView.swift */, 4B1E6EEF27AB5E5D00F51793 /* NSPopUpButtonView.swift */, 31C3CE0128EDC1E70002C24A /* CustomRoundedCornersShape.swift */, - 378F44EA29B4C73E00899924 /* View+RoundedCorners.swift */, + 378F44EA29B4C73E00899924 /* ViewExtension.swift */, B690152B2ACBF4DA00AD0BAB /* MenuPreview.swift */, + B6DE57F52B05EA9000CD54B9 /* SheetHostingWindow.swift */, 4B44FEF22B1FEF5A000619D8 /* FocusableTextEditor.swift */, ); path = SwiftUI; @@ -6063,7 +6103,7 @@ B693953C26F04BE70015B914 /* NibLoadable.swift */, B693954726F04BEA0015B914 /* NSSavePanelExtension.swift */, B693954126F04BE80015B914 /* PaddedImageButton.swift */, - B693954026F04BE80015B914 /* ProgressView.swift */, + B693954026F04BE80015B914 /* LoadingProgressView.swift */, B60C6F8C29B200AB007BFAA8 /* SavePanelAccessoryView.swift */, B693954226F04BE90015B914 /* ShadowView.swift */, B693954526F04BEA0015B914 /* WindowDraggingView.swift */, @@ -6459,6 +6499,7 @@ AA585D80248FD31100E9A3E2 /* DuckDuckGo */ = { isa = PBXGroup; children = ( + B658BAB52B0F845D00D1F2C7 /* Localizable.xcstrings */, 3192EC862A4DCF0E001E97A5 /* DBP */, EEAEA3F4294D05CF00D04DF3 /* JSAlert */, B31055BB27A1BA0E001AC618 /* Autoconsent */, @@ -6788,8 +6829,6 @@ children = ( AA80EC53256BE3BC007083E7 /* UserText.swift */, 4B4D60D22A0C84F700BCD287 /* UserText+NetworkProtection.swift */, - AA80EC8B256C49B8007083E7 /* Localizable.strings */, - AA80EC91256C49BC007083E7 /* Localizable.stringsdict */, ); path = Localizables; sourceTree = ""; @@ -7336,6 +7375,7 @@ 1DFAB51C2A8982A600A0F7F6 /* SetExtension.swift */, B65783E625F8AAFB00D8DB33 /* String+Punycode.swift */, AA8EDF2624923EC70071C2E8 /* StringExtension.swift */, + B6BCC5492AFDF24B002C5499 /* TaskWithProgress.swift */, AAADFD05264AA282001555EA /* TimeIntervalExtension.swift */, AA8EDF2324923E980071C2E8 /* URLExtension.swift */, AA88D14A252A557100980B4E /* URLRequestExtension.swift */, @@ -7796,6 +7836,18 @@ path = View; sourceTree = ""; }; + B6BCC51C2AFCD9C9002C5499 /* Model */ = { + isa = PBXGroup; + children = ( + B6BCC5222AFCDABB002C5499 /* DataImportSourceViewModel.swift */, + B677FC532B064A9C0099EB04 /* DataImportViewModel.swift */, + B6B4D1C42B0B3B5400C26286 /* DataImportReportModel.swift */, + B6A22B612B1E29D000ECD2BA /* DataImportSummaryViewModel.swift */, + B6619EF82B111CBE00CD9186 /* InstructionsFormatParser.swift */, + ); + path = Model; + sourceTree = ""; + }; B6BDD9F12940764100F68088 /* UserScripts */ = { isa = PBXGroup; children = ( @@ -8276,12 +8328,10 @@ 4B95793D2AC7AE700062CA31 /* NetworkProtectionUI */, 4B95793E2AC7AE700062CA31 /* NetworkProtection */, 4B95793F2AC7AE700062CA31 /* LoginItems */, - 4B9579402AC7AE700062CA31 /* Subscription */, - 4B9579412AC7AE700062CA31 /* Account */, - 4B9579422AC7AE700062CA31 /* Purchase */, - 7B8C083B2AE1268E00F4C67F /* PixelKit */, 7B31FD8F2AD1257B0086AA24 /* NetworkProtectionIPC */, 3143C8782B0D1F3D00382627 /* DataBrokerProtection */, + 1E0068AC2B1673BB00BBF43B /* SubscriptionUI */, + 1E934E2A2B167CA80084722B /* Subscription */, 37269F022B332FD8005E8E46 /* Common */, 372217832B33380E00B8E9C2 /* TestUtils */, ); @@ -8414,8 +8464,6 @@ 4B4D60B02A0C83B900BCD287 /* NetworkProtectionUI */, EE7295E22A545B9A008C0991 /* NetworkProtection */, 9DB6E7232AA0DC5800A17F3C /* LoginItems */, - 1EC88CA02AC1DD63003A4471 /* Account */, - 1E3ED4FC2AC1E0290075F60F /* Purchase */, 7B31FD8B2AD125620086AA24 /* NetworkProtectionIPC */, 7BA59C9A2AE18B49009A97B1 /* SystemExtensionManager */, 7B5DD6992AE51FFA001DE99C /* PixelKit */, @@ -8599,7 +8647,6 @@ 3706FCC0293F65D500E42796 /* FindInPage.storyboard in Resources */, EEC8EB3F2982CA440065AA39 /* JSAlert.storyboard in Resources */, 3706FCC1293F65D500E42796 /* AddEditFavoriteViewController.storyboard in Resources */, - 3706FCC2293F65D500E42796 /* Localizable.strings in Resources */, 3706FCC3293F65D500E42796 /* userscript.js in Resources */, 3706FCC4293F65D500E42796 /* fb-tds.json in Resources */, 3706FCC5293F65D500E42796 /* TabPreview.storyboard in Resources */, @@ -8619,7 +8666,7 @@ 3706FCD9293F65D500E42796 /* trackers-1.json in Resources */, 3706FCDA293F65D500E42796 /* dark-trackers-1.json in Resources */, 3706FCDB293F65D500E42796 /* Feedback.storyboard in Resources */, - 3706FCDC293F65D500E42796 /* DataImport.storyboard in Resources */, + B658BAB72B0F848D00D1F2C7 /* Localizable.xcstrings in Resources */, 3706FCDD293F65D500E42796 /* BookmarkTableCellView.xib in Resources */, 3706FCDE293F65D500E42796 /* HomePageAssets.xcassets in Resources */, 3706FCDF293F65D500E42796 /* shield-mouse-over.json in Resources */, @@ -8630,7 +8677,6 @@ 3706FCE4293F65D500E42796 /* Fire.storyboard in Resources */, 3706FCE6293F65D500E42796 /* social_images in Resources */, 3706FCE7293F65D500E42796 /* shield-dot-mouse-over.json in Resources */, - 3706FCE8293F65D500E42796 /* Localizable.stringsdict in Resources */, 3706FCE9293F65D500E42796 /* fb-sdk.js in Resources */, 3706FCEA293F65D500E42796 /* PasswordManager.storyboard in Resources */, 3706FCEB293F65D500E42796 /* dark-flame-mouse-over.json in Resources */, @@ -8733,7 +8779,6 @@ 4B957BFC2AC7AE700062CA31 /* FindInPage.storyboard in Resources */, 4B957BFD2AC7AE700062CA31 /* JSAlert.storyboard in Resources */, 4B957BFE2AC7AE700062CA31 /* AddEditFavoriteViewController.storyboard in Resources */, - 4B957BFF2AC7AE700062CA31 /* Localizable.strings in Resources */, 4B957C002AC7AE700062CA31 /* userscript.js in Resources */, 4B957C012AC7AE700062CA31 /* fb-tds.json in Resources */, 4B957C022AC7AE700062CA31 /* TabPreview.storyboard in Resources */, @@ -8753,7 +8798,7 @@ 4B957C122AC7AE700062CA31 /* trackers-1.json in Resources */, 4B957C132AC7AE700062CA31 /* dark-trackers-1.json in Resources */, 4B957C142AC7AE700062CA31 /* Feedback.storyboard in Resources */, - 4B957C152AC7AE700062CA31 /* DataImport.storyboard in Resources */, + B658BAB92B0F849100D1F2C7 /* Localizable.xcstrings in Resources */, 4B957C162AC7AE700062CA31 /* BookmarkTableCellView.xib in Resources */, 4B957C172AC7AE700062CA31 /* HomePageAssets.xcassets in Resources */, 4B957C182AC7AE700062CA31 /* shield-mouse-over.json in Resources */, @@ -8764,7 +8809,6 @@ 4B957C1D2AC7AE700062CA31 /* Fire.storyboard in Resources */, 4B957C1F2AC7AE700062CA31 /* social_images in Resources */, 4B957C202AC7AE700062CA31 /* shield-dot-mouse-over.json in Resources */, - 4B957C212AC7AE700062CA31 /* Localizable.stringsdict in Resources */, 4B957C222AC7AE700062CA31 /* fb-sdk.js in Resources */, 4B957C232AC7AE700062CA31 /* PasswordManager.storyboard in Resources */, 4B957C242AC7AE700062CA31 /* dark-flame-mouse-over.json in Resources */, @@ -8836,7 +8880,6 @@ 85A0117425AF2EDF00FA6A0C /* FindInPage.storyboard in Resources */, EEC111E4294D06020086524F /* JSAlert.storyboard in Resources */, 85589E8127BBB8630038AD11 /* AddEditFavoriteViewController.storyboard in Resources */, - AA80EC89256C49B8007083E7 /* Localizable.strings in Resources */, B31055C627A1BA1D001AC618 /* userscript.js in Resources */, EA4617F0273A28A700F110A2 /* fb-tds.json in Resources */, AAE8B102258A41C000E81239 /* TabPreview.storyboard in Resources */, @@ -8856,7 +8899,7 @@ AA3439792754D55100B241FA /* trackers-1.json in Resources */, AA34397C2754D55100B241FA /* dark-trackers-1.json in Resources */, AA3863C527A1E28F00749AB5 /* Feedback.storyboard in Resources */, - 4B723E1126B0006C00E14D75 /* DataImport.storyboard in Resources */, + B658BAB62B0F845D00D1F2C7 /* Localizable.xcstrings in Resources */, 4B92929026670D1700AD2C21 /* BookmarkTableCellView.xib in Resources */, 85AC7AD927BD625000FFB69B /* HomePageAssets.xcassets in Resources */, AA7EB6E727E8809D00036718 /* shield-mouse-over.json in Resources */, @@ -8867,7 +8910,6 @@ AAB7320726DD0C37002FACF9 /* Fire.storyboard in Resources */, EA18D1CA272F0DC8006DC101 /* social_images in Resources */, AA7EB6E927E880A600036718 /* shield-dot-mouse-over.json in Resources */, - AA80EC8F256C49BC007083E7 /* Localizable.stringsdict in Resources */, EAC80DE0271F6C0100BBF02D /* fb-sdk.js in Resources */, 85625994269C8F9600EE44BC /* PasswordManager.storyboard in Resources */, AA7EB6E327E7D05500036718 /* dark-flame-mouse-over.json in Resources */, @@ -9253,7 +9295,6 @@ 3706FAB1293F65D500E42796 /* UnprotectedDomains.xcdatamodeld in Sources */, 85393C872A6FF1B600F11EB3 /* BookmarksBarAppearance.swift in Sources */, 3706FAB2293F65D500E42796 /* TabInstrumentation.swift in Sources */, - 3706FAB3293F65D500E42796 /* BrowserImportViewController.swift in Sources */, 3706FAB4293F65D500E42796 /* NSPopUpButtonExtension.swift in Sources */, 3706FAB5293F65D500E42796 /* ConfigurationManager.swift in Sources */, 3706FAB6293F65D500E42796 /* YoutubePlayerUserScript.swift in Sources */, @@ -9268,6 +9309,7 @@ 3706FABD293F65D500E42796 /* NSNotificationName+PasswordManager.swift in Sources */, 3706FABE293F65D500E42796 /* RulesCompilationMonitor.swift in Sources */, 3706FABF293F65D500E42796 /* CrashReportReader.swift in Sources */, + B6B4D1C62B0B3B5400C26286 /* DataImportReportModel.swift in Sources */, 3706FAC0293F65D500E42796 /* DataTaskProviding.swift in Sources */, 3706FAC1293F65D500E42796 /* FeedbackViewController.swift in Sources */, 3706FAC2293F65D500E42796 /* FaviconSelector.swift in Sources */, @@ -9283,9 +9325,9 @@ 3706FAC8293F65D500E42796 /* AppTrackerDataSetProvider.swift in Sources */, 3706FAC9293F65D500E42796 /* EncryptionKeyGeneration.swift in Sources */, 3706FACA293F65D500E42796 /* TabLazyLoader.swift in Sources */, - 3706FACB293F65D500E42796 /* FileImportViewController.swift in Sources */, 3706FACC293F65D500E42796 /* SaveCredentialsViewController.swift in Sources */, 3706FACD293F65D500E42796 /* PopUpButton.swift in Sources */, + B6BCC54B2AFDF24B002C5499 /* TaskWithProgress.swift in Sources */, 3706FACE293F65D500E42796 /* SuggestionViewController.swift in Sources */, 7BFE95552A9DF2990081ABE9 /* UserDefaults+NetworkProtectionWaitlist.swift in Sources */, 3706FAD1293F65D500E42796 /* VisitViewModel.swift in Sources */, @@ -9295,7 +9337,6 @@ 3706FAD6293F65D500E42796 /* ConfigurationStore.swift in Sources */, 3706FAD7293F65D500E42796 /* Feedback.swift in Sources */, 3707C722294B5D2900682A9F /* WKWebViewExtension.swift in Sources */, - 3706FAD8293F65D500E42796 /* RequestFilePermissionViewController.swift in Sources */, 3706FAD9293F65D500E42796 /* FirefoxFaviconsReader.swift in Sources */, 3706FADA293F65D500E42796 /* CopyHandler.swift in Sources */, 3706FADB293F65D500E42796 /* ContentBlockingRulesUpdateObserver.swift in Sources */, @@ -9315,7 +9356,6 @@ 3706FAE8293F65D500E42796 /* RecentlyClosedTab.swift in Sources */, 1D36E65C298ACD2900AA485D /* AppIconChanger.swift in Sources */, 3706FAE9293F65D500E42796 /* PDFSearchTextMenuItemHandler.swift in Sources */, - 3706FAEA293F65D500E42796 /* DataImportViewController.swift in Sources */, 3706FAEB293F65D500E42796 /* HistoryMenu.swift in Sources */, 3706FAEC293F65D500E42796 /* ContentScopeFeatureFlagging.swift in Sources */, 3706FAED293F65D500E42796 /* OnboardingButtonStyles.swift in Sources */, @@ -9327,6 +9367,7 @@ 31F2D2002AF026D800BF0144 /* WaitlistTermsAndConditionsActionHandler.swift in Sources */, 3706FAF3293F65D500E42796 /* LocalAuthenticationService.swift in Sources */, 1D36E659298AA3BA00AA485D /* InternalUserDeciderStore.swift in Sources */, + B6BCC5242AFCDABB002C5499 /* DataImportSourceViewModel.swift in Sources */, 3706FEBC293F6EFF00E42796 /* BWResponse.swift in Sources */, 3706FAF4293F65D500E42796 /* SafariBookmarksReader.swift in Sources */, 31C9ADE62AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift in Sources */, @@ -9362,7 +9403,6 @@ 1E0C72072ABC63BD00802009 /* SubscriptionPagesUserScript.swift in Sources */, 3706FB0A293F65D500E42796 /* NSPathControlView.swift in Sources */, 3706FB0B293F65D500E42796 /* DefaultBrowserPromptView.swift in Sources */, - 3706FB0D293F65D500E42796 /* BrowserImportSummaryViewController.swift in Sources */, 3706FB0E293F65D500E42796 /* FaviconManager.swift in Sources */, 3706FB0F293F65D500E42796 /* ChromiumFaviconsReader.swift in Sources */, 4B0BD7B82A9FE6E600EF609D /* NetworkProtectionOnboardingMenu.swift in Sources */, @@ -9383,6 +9423,7 @@ 3706FB1D293F65D500E42796 /* StatisticsLoader.swift in Sources */, 3793FDD829535EBA00A2E28F /* Assertions.swift in Sources */, 3706FB1E293F65D500E42796 /* WebsiteBreakageReporter.swift in Sources */, + B62B48572ADE730D000DECE5 /* FileImportView.swift in Sources */, B6676BE22AA986A700525A21 /* AddressBarTextEditor.swift in Sources */, 3706FB1F293F65D500E42796 /* PrivacyPreferencesModel.swift in Sources */, 3706FB20293F65D500E42796 /* LocalUnprotectedDomains.swift in Sources */, @@ -9390,7 +9431,6 @@ 3706FB21293F65D500E42796 /* NavigationBarBadgeAnimator.swift in Sources */, 3706FB22293F65D500E42796 /* NSTextViewExtension.swift in Sources */, 3706FB23293F65D500E42796 /* DownloadsCellView.swift in Sources */, - 3706FB24293F65D500E42796 /* FileImportSummaryViewController.swift in Sources */, 3706FB25293F65D500E42796 /* PublishedAfter.swift in Sources */, 3706FEC1293F6EFF00E42796 /* BWCredential.swift in Sources */, 3706FEC9293F6F7500E42796 /* BWManagement.swift in Sources */, @@ -9446,6 +9486,7 @@ 3706FB54293F65D500E42796 /* PermissionContextMenu.swift in Sources */, 3706FB55293F65D500E42796 /* ContextMenuUserScript.swift in Sources */, 3706FB56293F65D500E42796 /* NSSavePanelExtension.swift in Sources */, + B6B5F5802B024105008DB58A /* DataImportSummaryView.swift in Sources */, 4B9DB0422A983B24000927DB /* WaitlistDialogView.swift in Sources */, 3706FB57293F65D500E42796 /* AppPrivacyConfigurationDataProvider.swift in Sources */, 857E5AF62A790B7000FC0FB4 /* PixelExperiment.swift in Sources */, @@ -9498,9 +9539,11 @@ 3706FB7F293F65D500E42796 /* GeolocationProvider.swift in Sources */, B603FD9F2A02712E00F3FCA9 /* CIImageExtension.swift in Sources */, 3706FB80293F65D500E42796 /* NSAlert+ActiveDownloadsTermination.swift in Sources */, + B677FC552B064A9C0099EB04 /* DataImportViewModel.swift in Sources */, D64A5FF92AEA5C2B00B6D6E7 /* HomeButtonMenuFactory.swift in Sources */, 3707C717294B5D0F00682A9F /* FindInPageTabExtension.swift in Sources */, 3706FB81293F65D500E42796 /* IndexPathExtension.swift in Sources */, + B6BCC5502AFE4F7D002C5499 /* DataImportTypePicker.swift in Sources */, 7BAF9E4B2A8A3CC9002D3B6E /* UserDefaults+NetworkProtectionShared.swift in Sources */, B6685E3D29A602D90043D2EE /* ExternalAppSchemeHandler.swift in Sources */, B6E1491029A5C30500AAFBE8 /* ContentBlockingTabExtension.swift in Sources */, @@ -9544,7 +9587,7 @@ 3706FB9A293F65D500E42796 /* FireproofDomainsStore.swift in Sources */, 3706FB9B293F65D500E42796 /* PrivacyDashboardPermissionHandler.swift in Sources */, 3706FB9C293F65D500E42796 /* TabCollectionViewModel.swift in Sources */, - 378F44EC29B4C73E00899924 /* View+RoundedCorners.swift in Sources */, + 378F44EC29B4C73E00899924 /* ViewExtension.swift in Sources */, 3706FB9D293F65D500E42796 /* BookmarkManager.swift in Sources */, B626A76E29928B1600053070 /* TestsClosureNavigationResponder.swift in Sources */, 3707C71A294B5D0F00682A9F /* TabExtensions.swift in Sources */, @@ -9577,7 +9620,7 @@ 1D6A492129CF7A490011DF74 /* NSPopoverExtension.swift in Sources */, 3706FBB5293F65D500E42796 /* UserContentUpdating.swift in Sources */, 4B4D60B72A0C847D00BCD287 /* NetworkProtectionNavBarButtonModel.swift in Sources */, - 3706FBB6293F65D500E42796 /* ChromePreferences.swift in Sources */, + 3706FBB6293F65D500E42796 /* ChromiumPreferences.swift in Sources */, 3706FBB7293F65D500E42796 /* FirePopoverViewController.swift in Sources */, 3706FBB8293F65D500E42796 /* SavePaymentMethodPopover.swift in Sources */, 4BCF15EF2ABBDBFF0083F6DF /* NetworkProtectionRemoteMessaging.swift in Sources */, @@ -9587,10 +9630,10 @@ 3706FBBD293F65D500E42796 /* Preferences.swift in Sources */, 3706FBBE293F65D500E42796 /* DownloadListViewModel.swift in Sources */, 3706FBBF293F65D500E42796 /* BookmarkManagementDetailViewController.swift in Sources */, + B6B4D1CB2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */, 3706FBC0293F65D500E42796 /* CSVImporter.swift in Sources */, 3706FBC1293F65D500E42796 /* StartupPreferences.swift in Sources */, 3706FBC2293F65D500E42796 /* MainMenu.swift in Sources */, - 3706FBC3293F65D500E42796 /* EdgeDataImporter.swift in Sources */, 3706FBC5293F65D500E42796 /* CallToAction.swift in Sources */, 3706FBC6293F65D500E42796 /* MouseOverView.swift in Sources */, 4B9DB0212A983B24000927DB /* ProductWaitlistRequest.swift in Sources */, @@ -9631,6 +9674,7 @@ 4B4D60E32A0C883A00BCD287 /* AppMain.swift in Sources */, 37197EA12942441700394917 /* Tab+UIDelegate.swift in Sources */, 56D6A3D729DB2BAB0055215A /* ContinueSetUpView.swift in Sources */, + B6BCC51F2AFCD9ED002C5499 /* DataImportSourcePicker.swift in Sources */, 3706FBDF293F65D500E42796 /* String+Punycode.swift in Sources */, EEC4A6722B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift in Sources */, 3706FBE0293F65D500E42796 /* NSException+Catch.m in Sources */, @@ -9671,6 +9715,7 @@ 3706FBFE293F65D500E42796 /* History.xcdatamodeld in Sources */, B68D21C92ACBC96E002DA3C2 /* MockPrivacyConfiguration.swift in Sources */, 3706FBFF293F65D500E42796 /* PermissionStore.swift in Sources */, + B6080BC62B21E78100B418EF /* DataImportErrorView.swift in Sources */, 3706FC00293F65D500E42796 /* PrivacyIconViewModel.swift in Sources */, 3706FC01293F65D500E42796 /* ChromiumBookmarksReader.swift in Sources */, 3706FC02293F65D500E42796 /* Downloads.xcdatamodeld in Sources */, @@ -9684,12 +9729,14 @@ 3706FC06293F65D500E42796 /* OnboardingViewModel.swift in Sources */, 3706FC07293F65D500E42796 /* ScriptSourceProviding.swift in Sources */, 4B6785402AA7C726008A5004 /* DailyPixel.swift in Sources */, + B6619EFC2B111CC600CD9186 /* InstructionsFormatParser.swift in Sources */, 3706FC08293F65D500E42796 /* CoreDataBookmarkImporter.swift in Sources */, 3706FC09293F65D500E42796 /* SuggestionViewModel.swift in Sources */, 3706FC0A293F65D500E42796 /* BookmarkManagedObject.swift in Sources */, 3706FC0B293F65D500E42796 /* CSVLoginExporter.swift in Sources */, 37197EAC294244D600394917 /* FutureExtension.swift in Sources */, 4B9DB0452A983B24000927DB /* WaitlistModalViewController.swift in Sources */, + B66CA41F2AD910B300447CF0 /* DataImportView.swift in Sources */, 3706FC0C293F65D500E42796 /* NSAttributedStringExtension.swift in Sources */, 3706FC0D293F65D500E42796 /* AnimationView.swift in Sources */, 3706FC0E293F65D500E42796 /* NSRectExtension.swift in Sources */, @@ -9712,7 +9759,7 @@ 3706FC1F293F65D500E42796 /* BookmarksBarViewController.swift in Sources */, 3706FC20293F65D500E42796 /* PreferencesAutofillView.swift in Sources */, 3706FC21293F65D500E42796 /* UserText+PasswordManager.swift in Sources */, - 3706FC22293F65D500E42796 /* ProgressView.swift in Sources */, + 3706FC22293F65D500E42796 /* LoadingProgressView.swift in Sources */, 3706FC23293F65D500E42796 /* StatisticsStore.swift in Sources */, 3706FC25293F65D500E42796 /* ColorView.swift in Sources */, 3706FC26293F65D500E42796 /* RecentlyClosedCacheItem.swift in Sources */, @@ -9764,7 +9811,6 @@ 3706FC4A293F65D500E42796 /* LocalStatisticsStore.swift in Sources */, 3706FC4B293F65D500E42796 /* BackForwardListItem.swift in Sources */, 4B4D60DD2A0C875E00BCD287 /* NetworkProtectionOptionKeyExtension.swift in Sources */, - 3706FC4C293F65D500E42796 /* BrowserImportMoreInfoViewController.swift in Sources */, 3707C723294B5D2900682A9F /* URLSessionExtension.swift in Sources */, 3706FC4E293F65D500E42796 /* AtbAndVariantCleanup.swift in Sources */, 3706FC4F293F65D500E42796 /* NibLoadable.swift in Sources */, @@ -9776,6 +9822,7 @@ 3706FC54293F65D500E42796 /* BookmarkListTreeControllerDataSource.swift in Sources */, 3706FC55293F65D500E42796 /* AddressBarViewController.swift in Sources */, 3706FC56293F65D500E42796 /* Permissions.swift in Sources */, + B6B4D1D02B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */, 3706FC57293F65D500E42796 /* TabPreviewWindowController.swift in Sources */, 3706FC58293F65D500E42796 /* NSSizeExtension.swift in Sources */, 3706FC59293F65D500E42796 /* Fire.swift in Sources */, @@ -9787,15 +9834,17 @@ 3706FC5E293F65D500E42796 /* OnboardingViewController.swift in Sources */, 7BE146082A6A83C700C313B8 /* NetworkProtectionDebugMenu.swift in Sources */, 3706FC5F293F65D500E42796 /* DeviceAuthenticator.swift in Sources */, + B6DE57F72B05EA9000CD54B9 /* SheetHostingWindow.swift in Sources */, 3706FEB9293F6EFF00E42796 /* BWVault.swift in Sources */, + B6B5F5852B03580A008DB58A /* RequestFilePermissionView.swift in Sources */, 3706FC60293F65D500E42796 /* TabBarCollectionView.swift in Sources */, 3706FC61293F65D500E42796 /* NSAlertExtension.swift in Sources */, 3706FC62293F65D500E42796 /* ThirdPartyBrowser.swift in Sources */, 3706FC63293F65D500E42796 /* CircularProgressView.swift in Sources */, 3706FC64293F65D500E42796 /* SuggestionContainer.swift in Sources */, 3706FC65293F65D500E42796 /* HomePageViewController.swift in Sources */, - 3706FC66293F65D500E42796 /* BraveDataImporter.swift in Sources */, 3706FC67293F65D500E42796 /* OperatingSystemVersionExtension.swift in Sources */, + B677FC502B06376B0099EB04 /* ReportFeedbackView.swift in Sources */, 3706FC68293F65D500E42796 /* ToggleableScrollView.swift in Sources */, 3706FC69293F65D500E42796 /* UserScripts.swift in Sources */, 3706FC6A293F65D500E42796 /* NSWorkspaceExtension.swift in Sources */, @@ -9810,7 +9859,6 @@ 3706FC72293F65D500E42796 /* Stored.swift in Sources */, 1DB9618229F67F6100CF5568 /* FaviconNullStore.swift in Sources */, 3706FC73293F65D500E42796 /* AddressBarButtonsViewController.swift in Sources */, - 3706FC75293F65D500E42796 /* ChromeDataImporter.swift in Sources */, 3706FC76293F65D500E42796 /* PixelDataRecord.swift in Sources */, 7BFE955A2A9DF4550081ABE9 /* NetworkProtectionWaitlistFeatureFlagOverridesMenu.swift in Sources */, 3706FC77293F65D500E42796 /* PageObserverUserScript.swift in Sources */, @@ -9834,6 +9882,7 @@ 3706FC86293F65D500E42796 /* FeedbackSender.swift in Sources */, 3706FC88293F65D500E42796 /* TabBarViewItem.swift in Sources */, 3706FC89293F65D500E42796 /* NSWindow+Toast.swift in Sources */, + B6BCC53C2AFD15DF002C5499 /* DataImportProfilePicker.swift in Sources */, 3706FC8A293F65D500E42796 /* AutoconsentUserScript.swift in Sources */, 3706FC8B293F65D500E42796 /* BookmarksExporter.swift in Sources */, 4BCF15EE2ABBDBFD0083F6DF /* NetworkProtectionRemoteMessage.swift in Sources */, @@ -9843,6 +9892,7 @@ 3706FC8E293F65D500E42796 /* PinnedTabsView.swift in Sources */, 3706FC8F293F65D500E42796 /* FireproofInfoViewController.swift in Sources */, 3706FC92293F65D500E42796 /* NSStoryboardExtension.swift in Sources */, + B6B5F58A2B03673B008DB58A /* BrowserImportMoreInfoView.swift in Sources */, 3706FC93293F65D500E42796 /* PreferencesViewController.swift in Sources */, 3706FC94293F65D500E42796 /* FireproofDomains.swift in Sources */, 3706FC95293F65D500E42796 /* Database.swift in Sources */, @@ -9851,10 +9901,12 @@ 3706FC97293F65D500E42796 /* BookmarksOutlineView.swift in Sources */, 3706FC98293F65D500E42796 /* CountryList.swift in Sources */, 3706FC99293F65D500E42796 /* PreferencesSection.swift in Sources */, + B6C8CAA82AD010DD0060E1CD /* YandexDataImporter.swift in Sources */, B655124929A79465009BFE1C /* NavigationActionExtension.swift in Sources */, 3706FC9A293F65D500E42796 /* AutoconsentManagement.swift in Sources */, 3706FC9C293F65D500E42796 /* BookmarkStore.swift in Sources */, 3706FC9D293F65D500E42796 /* PrivacyDashboardViewController.swift in Sources */, + B6A22B632B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */, 3706FC9E293F65D500E42796 /* PreferencesAppearanceView.swift in Sources */, 3706FC9F293F65D500E42796 /* NSMenuItemExtension.swift in Sources */, 3706FCA0293F65D500E42796 /* ContiguousBytesExtension.swift in Sources */, @@ -9956,6 +10008,8 @@ 3706FE1E293F661700E42796 /* GeolocationProviderTests.swift in Sources */, 3706FE1F293F661700E42796 /* AppStateChangePublisherTests.swift in Sources */, 3706FE20293F661700E42796 /* CLLocationManagerMock.swift in Sources */, + B6656E0E2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */, + B6619EF72B10DFF700CD9186 /* InstructionsFormatParserTests.swift in Sources */, 3706FE21293F661700E42796 /* DownloadsPreferencesTests.swift in Sources */, 3706FE22293F661700E42796 /* FireproofDomainsTests.swift in Sources */, 3706FE23293F661700E42796 /* SuggestionLoadingMock.swift in Sources */, @@ -9994,6 +10048,7 @@ 3706FE3D293F661700E42796 /* DataEncryptionTests.swift in Sources */, 3706FE3E293F661700E42796 /* ClickToLoadModelTests.swift in Sources */, 3706FE3F293F661700E42796 /* FileStoreMock.swift in Sources */, + B6619F042B17123200CD9186 /* DataImportViewModelTests.swift in Sources */, 3706FE40293F661700E42796 /* BWResponseTests.swift in Sources */, 3706FE41293F661700E42796 /* DownloadListCoordinatorTests.swift in Sources */, 986189E72A7CFB3E001B4519 /* LocalBookmarkStoreSavingTests.swift in Sources */, @@ -10002,6 +10057,7 @@ 3706FE44293F661700E42796 /* GeolocationServiceTests.swift in Sources */, 1DA6D1032A1FFA3B00540406 /* HTTPCookieTests.swift in Sources */, 3706FE45293F661700E42796 /* ProgressEstimationTests.swift in Sources */, + B6619F072B17138D00CD9186 /* DataImportSourceViewModelTests.swift in Sources */, 3706FE46293F661700E42796 /* EncryptedValueTransformerTests.swift in Sources */, 3706FE47293F661700E42796 /* URLExtensionTests.swift in Sources */, 1DB9617B29F1D06D00CF5568 /* InternalUserDeciderMock.swift in Sources */, @@ -10311,6 +10367,7 @@ 4B9579542AC7AE700062CA31 /* DownloadListStore.swift in Sources */, 4B9579552AC7AE700062CA31 /* Logging.swift in Sources */, 4B9579562AC7AE700062CA31 /* CrashReportPromptPresenter.swift in Sources */, + B6B4D1CD2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */, 4B9579572AC7AE700062CA31 /* BWCredential.swift in Sources */, 4B9579582AC7AE700062CA31 /* PreferencesRootView.swift in Sources */, 4B9579592AC7AE700062CA31 /* AppStateChangedPublisher.swift in Sources */, @@ -10358,7 +10415,6 @@ 4B9579822AC7AE700062CA31 /* BookmarkOutlineViewCell.swift in Sources */, 4B9579832AC7AE700062CA31 /* UnprotectedDomains.xcdatamodeld in Sources */, 4B9579842AC7AE700062CA31 /* TabInstrumentation.swift in Sources */, - 4B9579852AC7AE700062CA31 /* BrowserImportViewController.swift in Sources */, 4B9579862AC7AE700062CA31 /* NSPopUpButtonExtension.swift in Sources */, 4B9579872AC7AE700062CA31 /* ConfigurationManager.swift in Sources */, 4B9579882AC7AE700062CA31 /* YoutubePlayerUserScript.swift in Sources */, @@ -10376,19 +10432,20 @@ 4B9579942AC7AE700062CA31 /* CrashReportReader.swift in Sources */, 4B9579952AC7AE700062CA31 /* DataTaskProviding.swift in Sources */, 4B9579962AC7AE700062CA31 /* FeatureFlag.swift in Sources */, + B6B4D1C82B0B3B5400C26286 /* DataImportReportModel.swift in Sources */, 4B9579972AC7AE700062CA31 /* FeedbackViewController.swift in Sources */, 4B9579982AC7AE700062CA31 /* FaviconSelector.swift in Sources */, 4B9579992AC7AE700062CA31 /* AddEditFavoriteViewController.swift in Sources */, 4B95799A2AC7AE700062CA31 /* PrintingUserScript.swift in Sources */, 4B95799B2AC7AE700062CA31 /* ConnectBitwardenViewController.swift in Sources */, 4B95799C2AC7AE700062CA31 /* BWManager.swift in Sources */, + B6BCC5262AFCDABB002C5499 /* DataImportSourceViewModel.swift in Sources */, 4B95799D2AC7AE700062CA31 /* AppTrackerDataSetProvider.swift in Sources */, D64A5FFB2AEA5C2B00B6D6E7 /* HomeButtonMenuFactory.swift in Sources */, 4B95799E2AC7AE700062CA31 /* EncryptionKeyGeneration.swift in Sources */, 4B95799F2AC7AE700062CA31 /* TabLazyLoader.swift in Sources */, B690152F2ACBF4DA00AD0BAB /* MenuPreview.swift in Sources */, 4B9579A02AC7AE700062CA31 /* InvitedToWaitlistView.swift in Sources */, - 4B9579A12AC7AE700062CA31 /* FileImportViewController.swift in Sources */, 4B9579A22AC7AE700062CA31 /* SaveCredentialsViewController.swift in Sources */, 4B9579A32AC7AE700062CA31 /* PopUpButton.swift in Sources */, 4B9579A42AC7AE700062CA31 /* NetworkProtectionInviteDialog.swift in Sources */, @@ -10403,7 +10460,6 @@ 4B9579AE2AC7AE700062CA31 /* DataExtension.swift in Sources */, 4B9579AF2AC7AE700062CA31 /* ConfigurationStore.swift in Sources */, 4B9579B02AC7AE700062CA31 /* Feedback.swift in Sources */, - 4B9579B12AC7AE700062CA31 /* RequestFilePermissionViewController.swift in Sources */, 4B9579B22AC7AE700062CA31 /* FirefoxFaviconsReader.swift in Sources */, 4B9579B32AC7AE700062CA31 /* CopyHandler.swift in Sources */, 4B9579B42AC7AE700062CA31 /* ContentBlockingRulesUpdateObserver.swift in Sources */, @@ -10422,7 +10478,6 @@ 4B9579C02AC7AE700062CA31 /* DebugUserScript.swift in Sources */, 4B9579C12AC7AE700062CA31 /* RecentlyClosedTab.swift in Sources */, 4B9579C22AC7AE700062CA31 /* PDFSearchTextMenuItemHandler.swift in Sources */, - 4B9579C32AC7AE700062CA31 /* DataImportViewController.swift in Sources */, 4B9579C42AC7AE700062CA31 /* HistoryMenu.swift in Sources */, 4B9579C52AC7AE700062CA31 /* ContentScopeFeatureFlagging.swift in Sources */, 4B9579C62AC7AE700062CA31 /* OnboardingButtonStyles.swift in Sources */, @@ -10455,6 +10510,7 @@ 4B05265F2B1AEFDB0054955A /* VPNMetadataCollector.swift in Sources */, 4B9579E02AC7AE700062CA31 /* BookmarkExtension.swift in Sources */, 4B9579E12AC7AE700062CA31 /* PasswordManagementCreditCardModel.swift in Sources */, + B677FC522B06376B0099EB04 /* ReportFeedbackView.swift in Sources */, 4B9579E22AC7AE700062CA31 /* NSEventExtension.swift in Sources */, 4B9579E32AC7AE700062CA31 /* Onboarding.swift in Sources */, 4B9579E42AC7AE700062CA31 /* PopUpWindow.swift in Sources */, @@ -10474,7 +10530,6 @@ 4B9579F02AC7AE700062CA31 /* Bookmark.xcdatamodeld in Sources */, 4B9579F12AC7AE700062CA31 /* DefaultBrowserPromptView.swift in Sources */, 4B9579F22AC7AE700062CA31 /* WaitlistActivationDateStore.swift in Sources */, - 4B9579F32AC7AE700062CA31 /* BrowserImportSummaryViewController.swift in Sources */, 4B9579F42AC7AE700062CA31 /* FaviconManager.swift in Sources */, 4B9579F52AC7AE700062CA31 /* PFMoveApplication.m in Sources */, B68D21D22ACBCA01002DA3C2 /* ContentBlockerRulesManagerMock.swift in Sources */, @@ -10505,8 +10560,8 @@ 4B957A0D2AC7AE700062CA31 /* FutureExtension.swift in Sources */, 4B957A0E2AC7AE700062CA31 /* UserDialogRequest.swift in Sources */, 4B957A0F2AC7AE700062CA31 /* DownloadsCellView.swift in Sources */, - 4B957A102AC7AE700062CA31 /* FileImportSummaryViewController.swift in Sources */, 4B957A112AC7AE700062CA31 /* PublishedAfter.swift in Sources */, + B6B5F58C2B03673B008DB58A /* BrowserImportMoreInfoView.swift in Sources */, 4B957A122AC7AE700062CA31 /* FirefoxBerkeleyDatabaseReader.swift in Sources */, 4B957A132AC7AE700062CA31 /* WebViewSnapshotView.swift in Sources */, 4B957A142AC7AE700062CA31 /* DeviceAuthenticationService.swift in Sources */, @@ -10514,6 +10569,7 @@ 4B957A162AC7AE700062CA31 /* SyncSettingsAdapter.swift in Sources */, 4B957A172AC7AE700062CA31 /* AutofillPreferences.swift in Sources */, 4B957A182AC7AE700062CA31 /* WebsiteBreakage.swift in Sources */, + B6DE57F92B05EA9000CD54B9 /* SheetHostingWindow.swift in Sources */, 4B957A192AC7AE700062CA31 /* PasswordManagerCoordinator.swift in Sources */, 4B957A1A2AC7AE700062CA31 /* PasswordManagementIdentityModel.swift in Sources */, 4B957A1B2AC7AE700062CA31 /* UserDefaultsWrapper.swift in Sources */, @@ -10556,6 +10612,7 @@ 4B957A3E2AC7AE700062CA31 /* EnableWaitlistFeatureView.swift in Sources */, 4B957A3F2AC7AE700062CA31 /* GrammarFeaturesManager.swift in Sources */, 4B957A402AC7AE700062CA31 /* WaitlistModalViewController.swift in Sources */, + B6BCC53E2AFD15DF002C5499 /* DataImportProfilePicker.swift in Sources */, 4B957A412AC7AE700062CA31 /* WKMenuItemIdentifier.swift in Sources */, 4B957A422AC7AE700062CA31 /* SafariFaviconsReader.swift in Sources */, 4B957A432AC7AE700062CA31 /* NSScreenExtension.swift in Sources */, @@ -10574,7 +10631,7 @@ 4B957A4E2AC7AE700062CA31 /* URL+NetworkProtection.swift in Sources */, 4B957A4F2AC7AE700062CA31 /* PrivacyFeatures.swift in Sources */, 4B957A502AC7AE700062CA31 /* (null) in Sources */, - 4B957A512AC7AE700062CA31 /* View+RoundedCorners.swift in Sources */, + 4B957A512AC7AE700062CA31 /* ViewExtension.swift in Sources */, 4B957A522AC7AE700062CA31 /* AVCaptureDevice+SwizzledAuthState.swift in Sources */, 4B957A532AC7AE700062CA31 /* SubscriptionPagesUserScript.swift in Sources */, 4B957A542AC7AE700062CA31 /* VisitMenuItem.swift in Sources */, @@ -10587,6 +10644,7 @@ 4B957A5A2AC7AE700062CA31 /* CSVParser.swift in Sources */, 4B957A5B2AC7AE700062CA31 /* PixelDataModel.xcdatamodeld in Sources */, 4B957A5C2AC7AE700062CA31 /* PrivacyDashboardWebView.swift in Sources */, + B6656E5B2B2ADB1C008798A1 /* RequestFilePermissionView.swift in Sources */, 4B957A5D2AC7AE700062CA31 /* AppearancePreferences.swift in Sources */, 4B957A5E2AC7AE700062CA31 /* DownloadListCoordinator.swift in Sources */, 4B957A5F2AC7AE700062CA31 /* AdClickAttributionTabExtension.swift in Sources */, @@ -10598,6 +10656,7 @@ 4B957A632AC7AE700062CA31 /* HistoryEntry.swift in Sources */, 4B957A642AC7AE700062CA31 /* FaviconStore.swift in Sources */, 4B957A652AC7AE700062CA31 /* WaitlistTermsAndConditionsView.swift in Sources */, + B62B48592ADE730D000DECE5 /* FileImportView.swift in Sources */, 4B957A662AC7AE700062CA31 /* SuggestionListCharacteristics.swift in Sources */, 4B957A672AC7AE700062CA31 /* TimeIntervalExtension.swift in Sources */, 4B957A682AC7AE700062CA31 /* NetworkProtectionFeatureDisabler.swift in Sources */, @@ -10610,6 +10669,7 @@ 4B957A6F2AC7AE700062CA31 /* DuckPlayerTabExtension.swift in Sources */, 4B957A702AC7AE700062CA31 /* RecentlyClosedCoordinator.swift in Sources */, 4B957A712AC7AE700062CA31 /* URLRequestExtension.swift in Sources */, + B6080BC82B21E78100B418EF /* DataImportErrorView.swift in Sources */, 4B957A722AC7AE700062CA31 /* FaviconHostReference.swift in Sources */, 4B957A732AC7AE700062CA31 /* DownloadsTabExtension.swift in Sources */, 4B957A742AC7AE700062CA31 /* WebsiteBreakageSender.swift in Sources */, @@ -10626,6 +10686,7 @@ B62B48412ADE48DE000DECE5 /* MenuBuilder.swift in Sources */, 4B957A7D2AC7AE700062CA31 /* IndexPathExtension.swift in Sources */, 4B957A7E2AC7AE700062CA31 /* PasswordManagementNoteItemView.swift in Sources */, + B6B4D1D22B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */, 4B957A7F2AC7AE700062CA31 /* NSApplicationExtension.swift in Sources */, 4B957A802AC7AE700062CA31 /* NSWindowExtension.swift in Sources */, 4B957A812AC7AE700062CA31 /* KeychainType+ClientDefault.swift in Sources */, @@ -10691,9 +10752,10 @@ 4B957ABA2AC7AE700062CA31 /* HomePageContinueSetUpModel.swift in Sources */, 4B957ABB2AC7AE700062CA31 /* WebKitDownloadTask.swift in Sources */, 4B957ABC2AC7AE700062CA31 /* ChromiumLoginReader.swift in Sources */, + B6BCC5522AFE4F7D002C5499 /* DataImportTypePicker.swift in Sources */, 4B957ABD2AC7AE700062CA31 /* NSAlert+PasswordManager.swift in Sources */, 4B957ABE2AC7AE700062CA31 /* UserContentUpdating.swift in Sources */, - 4B957ABF2AC7AE700062CA31 /* ChromePreferences.swift in Sources */, + 4B957ABF2AC7AE700062CA31 /* ChromiumPreferences.swift in Sources */, 4B957AC02AC7AE700062CA31 /* FirePopoverViewController.swift in Sources */, 4B957AC12AC7AE700062CA31 /* SavePaymentMethodPopover.swift in Sources */, 4B957AC22AC7AE700062CA31 /* FindInPageViewController.swift in Sources */, @@ -10708,7 +10770,6 @@ 4B957ACA2AC7AE700062CA31 /* StartupPreferences.swift in Sources */, 4B957ACB2AC7AE700062CA31 /* UserDefaults+NetworkProtectionWaitlist.swift in Sources */, 4B957ACC2AC7AE700062CA31 /* MainMenu.swift in Sources */, - 4B957ACD2AC7AE700062CA31 /* EdgeDataImporter.swift in Sources */, 4B957ACE2AC7AE700062CA31 /* BrowserTabViewController.swift in Sources */, 4B957ACF2AC7AE700062CA31 /* CallToAction.swift in Sources */, 4B957AD02AC7AE700062CA31 /* MouseOverView.swift in Sources */, @@ -10717,12 +10778,14 @@ 4B957AD32AC7AE700062CA31 /* ArrayExtension.swift in Sources */, 4B957AD42AC7AE700062CA31 /* NetworkProtectionInviteCodeViewModel.swift in Sources */, 4B957AD52AC7AE700062CA31 /* CrashReportSender.swift in Sources */, + B6BCC5212AFCD9ED002C5499 /* DataImportSourcePicker.swift in Sources */, 4B957AD62AC7AE700062CA31 /* BookmarkHTMLImporter.swift in Sources */, 4B957AD72AC7AE700062CA31 /* CustomRoundedCornersShape.swift in Sources */, 4B957AD82AC7AE700062CA31 /* LocaleExtension.swift in Sources */, 4B957AD92AC7AE700062CA31 /* SavePaymentMethodViewController.swift in Sources */, 4B957ADA2AC7AE700062CA31 /* BWStatus.swift in Sources */, 4B957ADB2AC7AE700062CA31 /* WebKitVersionProvider.swift in Sources */, + B6BCC54D2AFDF24B002C5499 /* TaskWithProgress.swift in Sources */, 4B957ADC2AC7AE700062CA31 /* NSCoderExtensions.swift in Sources */, 4B957ADD2AC7AE700062CA31 /* RunningApplicationCheck.swift in Sources */, 4B957ADE2AC7AE700062CA31 /* StatePersistenceService.swift in Sources */, @@ -10782,6 +10845,7 @@ EEC4A6612B277F1100F7C0AA /* NetworkProtectionVPNCountryLabelsModel.swift in Sources */, 4B957B142AC7AE700062CA31 /* PrivacyIconViewModel.swift in Sources */, 4B957B152AC7AE700062CA31 /* ChromiumBookmarksReader.swift in Sources */, + B66CA4212AD910B300447CF0 /* DataImportView.swift in Sources */, 4B957B162AC7AE700062CA31 /* Downloads.xcdatamodeld in Sources */, 4B957B172AC7AE700062CA31 /* TabPreviewViewController.swift in Sources */, 4B957B182AC7AE700062CA31 /* PreferencesPrivacyView.swift in Sources */, @@ -10805,6 +10869,7 @@ 4B957B2A2AC7AE700062CA31 /* PasswordManagementLoginModel.swift in Sources */, 4B957B2B2AC7AE700062CA31 /* TabViewModel.swift in Sources */, 4B957B2C2AC7AE700062CA31 /* TabDragAndDropManager.swift in Sources */, + B677FC572B064A9C0099EB04 /* DataImportViewModel.swift in Sources */, 4B957B2D2AC7AE700062CA31 /* NSNotificationName+Favicons.swift in Sources */, 4B957B2E2AC7AE700062CA31 /* PinningManager.swift in Sources */, 4B957B2F2AC7AE700062CA31 /* SyncMetadataDatabase.swift in Sources */, @@ -10816,7 +10881,7 @@ 4B957B352AC7AE700062CA31 /* PreferencesAutofillView.swift in Sources */, 4B957B362AC7AE700062CA31 /* BurnerHomePageView.swift in Sources */, 4B957B372AC7AE700062CA31 /* UserText+PasswordManager.swift in Sources */, - 4B957B382AC7AE700062CA31 /* ProgressView.swift in Sources */, + 4B957B382AC7AE700062CA31 /* LoadingProgressView.swift in Sources */, 7BEC20472B0F505F00243D3E /* BookmarkAddFolderPopoverViewController.swift in Sources */, 4B957B392AC7AE700062CA31 /* StatisticsStore.swift in Sources */, EEC4A66B2B2C87D300F7C0AA /* VPNLocationView.swift in Sources */, @@ -10834,9 +10899,11 @@ 4B957B452AC7AE700062CA31 /* BookmarkHTMLReader.swift in Sources */, 4B957B462AC7AE700062CA31 /* Tab+NSSecureCoding.swift in Sources */, 4B957B472AC7AE700062CA31 /* NSNotificationName+EmailManager.swift in Sources */, + B6619EFE2B111CCC00CD9186 /* InstructionsFormatParser.swift in Sources */, 4B957B482AC7AE700062CA31 /* MouseOverButton.swift in Sources */, 4B957B492AC7AE700062CA31 /* FireInfoViewController.swift in Sources */, 4B957B4A2AC7AE700062CA31 /* LoginItem+NetworkProtection.swift in Sources */, + B6C8CAAA2AD010DD0060E1CD /* YandexDataImporter.swift in Sources */, 4B957B4B2AC7AE700062CA31 /* PermissionButton.swift in Sources */, 4B957B4C2AC7AE700062CA31 /* MoreOptionsMenu.swift in Sources */, 4B957B4D2AC7AE700062CA31 /* PermissionAuthorizationViewController.swift in Sources */, @@ -10863,9 +10930,9 @@ 3158B1522B0BF75400AF130C /* DataBrokerProtectionLoginItemScheduler.swift in Sources */, 4B957B612AC7AE700062CA31 /* HomePage.swift in Sources */, 4B957B622AC7AE700062CA31 /* RoundedSelectionRowView.swift in Sources */, + B6A22B652B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */, 4B957B632AC7AE700062CA31 /* LocalStatisticsStore.swift in Sources */, 4B957B642AC7AE700062CA31 /* BackForwardListItem.swift in Sources */, - 4B957B652AC7AE700062CA31 /* BrowserImportMoreInfoViewController.swift in Sources */, 4B957B672AC7AE700062CA31 /* AtbAndVariantCleanup.swift in Sources */, 4B957B682AC7AE700062CA31 /* NibLoadable.swift in Sources */, 4B957B692AC7AE700062CA31 /* FeedbackWindow.swift in Sources */, @@ -10873,6 +10940,7 @@ 4B957B6B2AC7AE700062CA31 /* RecentlyVisitedView.swift in Sources */, 4B957B6C2AC7AE700062CA31 /* MouseOverAnimationButton.swift in Sources */, 4B957B6D2AC7AE700062CA31 /* TabBarScrollView.swift in Sources */, + B6B5F5822B024105008DB58A /* DataImportSummaryView.swift in Sources */, 4B957B6E2AC7AE700062CA31 /* BookmarkListTreeControllerDataSource.swift in Sources */, 4B957B6F2AC7AE700062CA31 /* AddressBarViewController.swift in Sources */, 4B957B702AC7AE700062CA31 /* Permissions.swift in Sources */, @@ -10880,7 +10948,6 @@ 4B957B722AC7AE700062CA31 /* NSSizeExtension.swift in Sources */, 4B957B732AC7AE700062CA31 /* Fire.swift in Sources */, 4B957B742AC7AE700062CA31 /* SyncBookmarksAdapter.swift in Sources */, - 4B36CD372B34E46900FBC707 /* NetworkProtectionSubscriptionEventHandler.swift in Sources */, 4B957B752AC7AE700062CA31 /* RandomAccessCollectionExtension.swift in Sources */, 4B957B762AC7AE700062CA31 /* NSOutlineViewExtensions.swift in Sources */, 4B957B772AC7AE700062CA31 /* AppDelegate.swift in Sources */, @@ -10902,7 +10969,6 @@ 4B957B842AC7AE700062CA31 /* SuggestionContainer.swift in Sources */, 4B957B852AC7AE700062CA31 /* FindInPageTabExtension.swift in Sources */, 4B957B862AC7AE700062CA31 /* HomePageViewController.swift in Sources */, - 4B957B872AC7AE700062CA31 /* BraveDataImporter.swift in Sources */, 4B957B882AC7AE700062CA31 /* OperatingSystemVersionExtension.swift in Sources */, 4B957B892AC7AE700062CA31 /* ToggleableScrollView.swift in Sources */, 4B957B8A2AC7AE700062CA31 /* TabCleanupPreparer.swift in Sources */, @@ -10924,7 +10990,6 @@ 4B957B962AC7AE700062CA31 /* Stored.swift in Sources */, 4B957B972AC7AE700062CA31 /* AddressBarButtonsViewController.swift in Sources */, 4B957B982AC7AE700062CA31 /* BWError.swift in Sources */, - 4B957B992AC7AE700062CA31 /* ChromeDataImporter.swift in Sources */, 4B957B9A2AC7AE700062CA31 /* PixelDataRecord.swift in Sources */, 4B957B9B2AC7AE700062CA31 /* PageObserverUserScript.swift in Sources */, 4B957B9C2AC7AE700062CA31 /* SecureVaultErrorReporter.swift in Sources */, @@ -11062,11 +11127,13 @@ 4B92928F26670D1700AD2C21 /* BookmarkTableCellView.swift in Sources */, 4B9292CF2667123700AD2C21 /* BookmarkManagementSidebarViewController.swift in Sources */, 4B39AAF627D9B2C700A73FD5 /* NSStackViewExtension.swift in Sources */, + B66CA41E2AD910B300447CF0 /* DataImportView.swift in Sources */, B637273D26CCF0C200C8CB02 /* OptionalExtension.swift in Sources */, 4BE65477271FCD41008D1D63 /* PasswordManagementLoginItemView.swift in Sources */, AA80EC54256BE3BC007083E7 /* UserText.swift in Sources */, B61EF3EC266F91E700B4D78F /* WKWebView+Download.swift in Sources */, 311B262728E73E0A00FD181A /* TabShadowConfig.swift in Sources */, + B6BCC54A2AFDF24B002C5499 /* TaskWithProgress.swift in Sources */, B6DB3AEF278D5C370024C5C4 /* URLSessionExtension.swift in Sources */, 4B7A60A1273E0BE400BBDFEB /* WKWebsiteDataStoreExtension.swift in Sources */, B693955326F04BEC0015B914 /* WindowDraggingView.swift in Sources */, @@ -11095,16 +11162,17 @@ 1D43EB3A292B63B00065E5D6 /* BWRequest.swift in Sources */, B68458CD25C7EB9000DC17B6 /* WKWebViewConfigurationExtensions.swift in Sources */, 85AC7ADD27BEB6EE00FFB69B /* HomePageDefaultBrowserModel.swift in Sources */, + B6619EFB2B111CC500CD9186 /* InstructionsFormatParser.swift in Sources */, AAC30A26268DFEE200D2D9CD /* CrashReporter.swift in Sources */, B60D64492AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift in Sources */, 3184AC6D288F29D800C35E4B /* BadgeNotificationAnimationModel.swift in Sources */, 857FFEC027D239DC00415E7A /* HyperLink.swift in Sources */, 37445F992A1566420029F789 /* SyncDataProviders.swift in Sources */, 4B9292A426670D2A00AD2C21 /* PasteboardWriting.swift in Sources */, + B6B4D1C52B0B3B5400C26286 /* DataImportReportModel.swift in Sources */, 4B92928D26670D1700AD2C21 /* BookmarkOutlineViewCell.swift in Sources */, B604085C274B8FBA00680351 /* UnprotectedDomains.xcdatamodeld in Sources */, 4BB88B5025B7BA2B006F6B06 /* TabInstrumentation.swift in Sources */, - 4B59024326B35F7C00489384 /* BrowserImportViewController.swift in Sources */, 4B9292D72667124000AD2C21 /* NSPopUpButtonExtension.swift in Sources */, 85D33F1225C82EB3002B91A6 /* ConfigurationManager.swift in Sources */, 31F28C4F28C8EEC500119F70 /* YoutubePlayerUserScript.swift in Sources */, @@ -11133,7 +11201,6 @@ 4BA1A6B3258B080A00F6F690 /* EncryptionKeyGeneration.swift in Sources */, 37B11B3928095E6600CBB621 /* TabLazyLoader.swift in Sources */, 4B9DB03B2A983B24000927DB /* InvitedToWaitlistView.swift in Sources */, - 4B723E0B26B0005B00E14D75 /* FileImportViewController.swift in Sources */, 8589063C267BCDC000D23B0D /* SaveCredentialsViewController.swift in Sources */, 4BBE0AA727B9B027003B37A8 /* PopUpButton.swift in Sources */, 4B4D60CF2A0C849600BCD287 /* NetworkProtectionInviteDialog.swift in Sources */, @@ -11149,7 +11216,6 @@ 85AC3AF725D5DBFD00C7D2AA /* DataExtension.swift in Sources */, 85480FCF25D1AA22009424E3 /* ConfigurationStore.swift in Sources */, AA3D531B27A2F57E00074EC1 /* Feedback.swift in Sources */, - 4BB99D0626FE1979001E4761 /* RequestFilePermissionViewController.swift in Sources */, 4B0A63E8289DB58E00378EF7 /* FirefoxFaviconsReader.swift in Sources */, 858A798326A8B75F00A75A42 /* CopyHandler.swift in Sources */, 1E7E2E9029029A2A00C01B54 /* ContentBlockingRulesUpdateObserver.swift in Sources */, @@ -11163,12 +11229,12 @@ 4BB88B4A25B7B690006F6B06 /* SequenceExtensions.swift in Sources */, B602E7CF2A93A5FF00F12201 /* WKBackForwardListExtension.swift in Sources */, 4B59024026B35F3600489384 /* ChromiumDataImporter.swift in Sources */, + B62B48562ADE730D000DECE5 /* FileImportView.swift in Sources */, AAA0CC3C25337FAB0079BC96 /* WKBackForwardListItemViewModel.swift in Sources */, 1D43EB3429297D760065E5D6 /* BWNotRespondingAlert.swift in Sources */, 4BB88B4525B7B55C006F6B06 /* DebugUserScript.swift in Sources */, AAC6881928626BF800D54247 /* RecentlyClosedTab.swift in Sources */, B688B4DF27420D290087BEAF /* PDFSearchTextMenuItemHandler.swift in Sources */, - 4B723E0A26B0005900E14D75 /* DataImportViewController.swift in Sources */, AA7E919728746BCC00AB6B62 /* HistoryMenu.swift in Sources */, F4A6198C283CFFBB007F2080 /* ContentScopeFeatureFlagging.swift in Sources */, 85707F24276A332A00DC0649 /* OnboardingButtonStyles.swift in Sources */, @@ -11217,7 +11283,6 @@ 98779A0029999B64005D8EB6 /* Bookmark.xcdatamodeld in Sources */, 85589E9E27BFE4500038AD11 /* DefaultBrowserPromptView.swift in Sources */, 4B4032842AAAC24400CCA602 /* WaitlistActivationDateStore.swift in Sources */, - 4B78A86B26BB3ADD0071BB16 /* BrowserImportSummaryViewController.swift in Sources */, AA512D1424D99D9800230283 /* FaviconManager.swift in Sources */, 7BB108592A43375D000AB95F /* PFMoveApplication.m in Sources */, 4B0AACAC28BC63ED001038AC /* ChromiumFaviconsReader.swift in Sources */, @@ -11235,6 +11300,7 @@ 4B9DB0262A983B24000927DB /* WaitlistViewModel.swift in Sources */, 987799F929999973005D8EB6 /* LocalBookmarkStore.swift in Sources */, 1D02633628D8A9A9005CBB41 /* BWEncryption.m in Sources */, + B6B5F5892B03673B008DB58A /* BrowserImportMoreInfoView.swift in Sources */, B69B503A2726A12500758A2B /* StatisticsLoader.swift in Sources */, 1E7E2E9229029F9B00C01B54 /* WebsiteBreakageReporter.swift in Sources */, 37CD54C927F2FDD100F1F7B9 /* PrivacyPreferencesModel.swift in Sources */, @@ -11246,7 +11312,6 @@ B634DBE7293C98C500C3C99E /* FutureExtension.swift in Sources */, B634DBE3293C900000C3C99E /* UserDialogRequest.swift in Sources */, B6B1E88426D5EB570062C350 /* DownloadsCellView.swift in Sources */, - 4B723E0C26B0005D00E14D75 /* FileImportSummaryViewController.swift in Sources */, B6AAAC2D260330580029438D /* PublishedAfter.swift in Sources */, 3701C9CE29BD040C00305B15 /* FirefoxBerkeleyDatabaseReader.swift in Sources */, 37054FCE2876472D00033B6F /* WebViewSnapshotView.swift in Sources */, @@ -11286,7 +11351,6 @@ 37F19A6A28E2F2D000740DC6 /* DuckPlayer.swift in Sources */, AA5FA69A275F91C700DCE9C9 /* Favicon.swift in Sources */, AABEE69A24A902A90043105B /* SuggestionContainerViewModel.swift in Sources */, - 4B36CD362B34E46900FBC707 /* NetworkProtectionSubscriptionEventHandler.swift in Sources */, AA840A9827319D1600E63CDD /* FirePopoverWrapperViewController.swift in Sources */, 4B85A48028821CC500FC4C39 /* NSPasteboardItemExtension.swift in Sources */, 37CD54CA27F2FDD100F1F7B9 /* AutofillPreferencesModel.swift in Sources */, @@ -11314,7 +11378,7 @@ 4BBF0915282DD40100EE1418 /* TemporaryFileHandler.swift in Sources */, B602E8162A1E2570006D261F /* URL+NetworkProtection.swift in Sources */, CB6BCDF927C6BEFF00CC76DC /* PrivacyFeatures.swift in Sources */, - 378F44EB29B4C73E00899924 /* View+RoundedCorners.swift in Sources */, + 378F44EB29B4C73E00899924 /* ViewExtension.swift in Sources */, B6DB3CF926A00E2D00D459B7 /* AVCaptureDevice+SwizzledAuthState.swift in Sources */, 1E0C72062ABC63BD00802009 /* SubscriptionPagesUserScript.swift in Sources */, AAAB9116288EB46B00A057A9 /* VisitMenuItem.swift in Sources */, @@ -11330,6 +11394,7 @@ B6B1E87B26D381710062C350 /* DownloadListCoordinator.swift in Sources */, B68D21C82ACBC96D002DA3C2 /* MockPrivacyConfiguration.swift in Sources */, B647EFBB2922584B00BA628D /* AdClickAttributionTabExtension.swift in Sources */, + B677FC542B064A9C0099EB04 /* DataImportViewModel.swift in Sources */, 4B980E212817604000282EE1 /* NSNotificationName+Debug.swift in Sources */, B690152C2ACBF4DA00AD0BAB /* MenuPreview.swift in Sources */, 31F7F2A6288AD2CA001C0D64 /* NavigationBarBadgeAnimationView.swift in Sources */, @@ -11358,11 +11423,13 @@ 4BB99CFF26FE191E001E4761 /* BookmarkImport.swift in Sources */, B68503A7279141CD00893A05 /* KeySetDictionary.swift in Sources */, B66260E029AC6EBD00E9E3EE /* HistoryTabExtension.swift in Sources */, + B6BCC54F2AFE4F7D002C5499 /* DataImportTypePicker.swift in Sources */, AAEEC6A927088ADB008445F7 /* FireCoordinator.swift in Sources */, B655369B268442EE00085A79 /* GeolocationProvider.swift in Sources */, B6C0B23C26E87D900031CB7F /* NSAlert+ActiveDownloadsTermination.swift in Sources */, AAECA42024EEA4AC00EFA63A /* IndexPathExtension.swift in Sources */, 4B9579212AC687170062CA31 /* HardwareModel.swift in Sources */, + B6080BC52B21E78100B418EF /* DataImportErrorView.swift in Sources */, 4BE65478271FCD41008D1D63 /* PasswordManagementNoteItemView.swift in Sources */, AA5C8F632591021700748EB7 /* NSApplicationExtension.swift in Sources */, AA9E9A5625A3AE8400D1959D /* NSWindowExtension.swift in Sources */, @@ -11375,8 +11442,10 @@ B6106BA726A7BECC0013B453 /* PermissionAuthorizationQuery.swift in Sources */, 3171D6BA288984D00068632A /* BadgeAnimationView.swift in Sources */, 4B9292CE2667123700AD2C21 /* BrowserTabSelectionDelegate.swift in Sources */, + B6BCC53B2AFD15DF002C5499 /* DataImportProfilePicker.swift in Sources */, 3158B1562B0BF75D00AF130C /* DataBrokerProtectionFeatureVisibility.swift in Sources */, 56D6A3D629DB2BAB0055215A /* ContinueSetUpView.swift in Sources */, + B6B5F5842B03580A008DB58A /* RequestFilePermissionView.swift in Sources */, 4B1E6EEE27AB5E5100F51793 /* PasswordManagementListSection.swift in Sources */, 31C9ADE52AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift in Sources */, AA222CB92760F74E00321475 /* FaviconReferenceCache.swift in Sources */, @@ -11421,6 +11490,7 @@ 4B92929F26670D2A00AD2C21 /* PasteboardBookmark.swift in Sources */, 37BF3F14286D8A6500BD9014 /* PinnedTabsManager.swift in Sources */, 856CADF0271710F400E79BB0 /* HoverUserScript.swift in Sources */, + B6DE57F62B05EA9000CD54B9 /* SheetHostingWindow.swift in Sources */, AA6EF9B525081B4C004754E6 /* MainMenuActions.swift in Sources */, B63D466925BEB6C200874977 /* WKWebView+SessionState.swift in Sources */, 4B4D60C02A0C848D00BCD287 /* NetworkProtectionControllerErrorStore.swift in Sources */, @@ -11436,7 +11506,7 @@ 4B59023E26B35F3600489384 /* ChromiumLoginReader.swift in Sources */, 85D885B326A5A9DE0077C374 /* NSAlert+PasswordManager.swift in Sources */, 983DFB2528B67036006B7E34 /* UserContentUpdating.swift in Sources */, - 4B7A57CF279A4EF300B1C70E /* ChromePreferences.swift in Sources */, + 4B7A57CF279A4EF300B1C70E /* ChromiumPreferences.swift in Sources */, AA6AD95B2704B6DB00159F8A /* FirePopoverViewController.swift in Sources */, 4BE4005327CF3DC3007D3161 /* SavePaymentMethodPopover.swift in Sources */, 85A0116925AF1D8900FA6A0C /* FindInPageViewController.swift in Sources */, @@ -11451,7 +11521,6 @@ 4B41EDB12B168B1E001EEDF4 /* VPNFeedbackFormView.swift in Sources */, 7BFE95542A9DF2930081ABE9 /* UserDefaults+NetworkProtectionWaitlist.swift in Sources */, AA4BBA3B25C58FA200C4FB0F /* MainMenu.swift in Sources */, - 4B8AC93326B3B06300879451 /* EdgeDataImporter.swift in Sources */, AA585D84248FD31100E9A3E2 /* BrowserTabViewController.swift in Sources */, 85707F22276A32B600DC0649 /* CallToAction.swift in Sources */, B693954B26F04BEB0015B914 /* MouseOverView.swift in Sources */, @@ -11462,6 +11531,7 @@ 4B4D60CC2A0C849600BCD287 /* NetworkProtectionInviteCodeViewModel.swift in Sources */, AAC30A2C268F1ECD00D2D9CD /* CrashReportSender.swift in Sources */, 373A1AB02842C4EA00586521 /* BookmarkHTMLImporter.swift in Sources */, + B6B5F57F2B024105008DB58A /* DataImportSummaryView.swift in Sources */, 31C3CE0228EDC1E70002C24A /* CustomRoundedCornersShape.swift in Sources */, 4B8D9062276D1D880078DB17 /* LocaleExtension.swift in Sources */, 4BE4005527CF3F19007D3161 /* SavePaymentMethodViewController.swift in Sources */, @@ -11495,6 +11565,7 @@ EA0BA3A9272217E6002A0B6C /* ClickToLoadUserScript.swift in Sources */, AAA892EA250A4CEF005B37B2 /* WindowControllersManager.swift in Sources */, 85C5991B27D10CF000E605B2 /* FireAnimationView.swift in Sources */, + B6B4D1CA2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */, AA6197C4276B314D008396F0 /* FaviconUrlReference.swift in Sources */, B696AFFB2AC5924800C93203 /* FileLineError.swift in Sources */, 85CC1D7B26A05ECF0062F04E /* PasswordManagementItemListModel.swift in Sources */, @@ -11504,6 +11575,7 @@ AAA0CC6A253CC43C0079BC96 /* WKUserContentControllerExtension.swift in Sources */, 4BE65479271FCD41008D1D63 /* EditableTextView.swift in Sources */, AA9FF95D24A1FA1C0039E328 /* TabCollection.swift in Sources */, + B6B4D1CF2B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */, B688B4DA273E6D3B0087BEAF /* MainView.swift in Sources */, B61E2CD5294346C000773D8A /* Tab+Navigation.swift in Sources */, 4B65143E263924B5005B46EB /* EmailUrlExtensions.swift in Sources */, @@ -11531,6 +11603,7 @@ 4BB99D0126FE191E001E4761 /* ChromiumBookmarksReader.swift in Sources */, B6C0B23426E71BCD0031CB7F /* Downloads.xcdatamodeld in Sources */, AAE8B110258A456C00E81239 /* TabPreviewViewController.swift in Sources */, + B6C8CAA72AD010DD0060E1CD /* YandexDataImporter.swift in Sources */, 37CC53EC27E8A4D10028713D /* PreferencesPrivacyView.swift in Sources */, 4B0135CE2729F1AA00D54834 /* NSPasteboardExtension.swift in Sources */, 85707F31276A7DCA00DC0649 /* OnboardingViewModel.swift in Sources */, @@ -11542,6 +11615,7 @@ AA3F895324C18AD500628DDE /* SuggestionViewModel.swift in Sources */, 4B9292A326670D2A00AD2C21 /* BookmarkManagedObject.swift in Sources */, 4B723E1326B0007A00E14D75 /* CSVLoginExporter.swift in Sources */, + B6BCC51E2AFCD9ED002C5499 /* DataImportSourcePicker.swift in Sources */, 85C48CCC278D808F00D3263E /* NSAttributedStringExtension.swift in Sources */, 4B41EDB42B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */, AA7EB6E527E7D6DC00036718 /* AnimationView.swift in Sources */, @@ -11567,7 +11641,7 @@ 379DE4BD27EA31AC002CC3DE /* PreferencesAutofillView.swift in Sources */, 1DCFBC8A29ADF32B00313531 /* BurnerHomePageView.swift in Sources */, 858A797F26A79EAA00A75A42 /* UserText+PasswordManager.swift in Sources */, - B693954E26F04BEB0015B914 /* ProgressView.swift in Sources */, + B693954E26F04BEB0015B914 /* LoadingProgressView.swift in Sources */, B69B503C2726A12500758A2B /* StatisticsStore.swift in Sources */, 3158B14A2B0BF74300AF130C /* DataBrokerProtectionDebugMenu.swift in Sources */, 4BBDEE9128FC14760092FAA6 /* BWInstallationService.swift in Sources */, @@ -11615,7 +11689,6 @@ 376705AF27EB488600DD8D76 /* RoundedSelectionRowView.swift in Sources */, B69B503F2726A12500758A2B /* LocalStatisticsStore.swift in Sources */, B689ECD526C247DB006FB0C5 /* BackForwardListItem.swift in Sources */, - 85C48CD127908C1000D3263E /* BrowserImportMoreInfoViewController.swift in Sources */, B69B50572727D16900758A2B /* AtbAndVariantCleanup.swift in Sources */, B693954A26F04BEB0015B914 /* NibLoadable.swift in Sources */, AA3D531527A1ED9300074EC1 /* FeedbackWindow.swift in Sources */, @@ -11650,7 +11723,6 @@ AABEE69C24A902BB0043105B /* SuggestionContainer.swift in Sources */, B6C00ECD292F89D9009C73A6 /* FindInPageTabExtension.swift in Sources */, 85589E8327BBB8630038AD11 /* HomePageViewController.swift in Sources */, - 4B59024126B35F3600489384 /* BraveDataImporter.swift in Sources */, B6A9E46B2614618A0067D1B9 /* OperatingSystemVersionExtension.swift in Sources */, 4BDFA4AE27BF19E500648192 /* ToggleableScrollView.swift in Sources */, 1D36F4242A3B85C50052B527 /* TabCleanupPreparer.swift in Sources */, @@ -11668,9 +11740,9 @@ AA7E919C2875C65000AB6B62 /* Stored.swift in Sources */, AAC5E4F625D6BF2C007F5990 /* AddressBarButtonsViewController.swift in Sources */, 1D2DC0072901679C008083A1 /* BWError.swift in Sources */, - 4B59023D26B35F3600489384 /* ChromeDataImporter.swift in Sources */, B68C92C42750EF76002AC6B0 /* PixelDataRecord.swift in Sources */, 853014D625E671A000FB8205 /* PageObserverUserScript.swift in Sources */, + B677FC4F2B06376B0099EB04 /* ReportFeedbackView.swift in Sources */, B642738227B65BAC0005DFD1 /* SecureVaultErrorReporter.swift in Sources */, 4B139AFD26B60BD800894F82 /* NSImageExtensions.swift in Sources */, B62B48392ADE46FC000DECE5 /* Application.swift in Sources */, @@ -11711,6 +11783,7 @@ 4B02198A25E05FAC00ED7DEA /* FireproofDomains.swift in Sources */, 4B677442255DBEEA00025BD8 /* Database.swift in Sources */, 4BE5336E286915A10019DBFD /* HorizontallyCenteredLayout.swift in Sources */, + B6BCC5232AFCDABB002C5499 /* DataImportSourceViewModel.swift in Sources */, 4B92928B26670D1700AD2C21 /* BookmarksOutlineView.swift in Sources */, 4BF01C00272AE74C00884A61 /* CountryList.swift in Sources */, 37CD54CC27F2FDD100F1F7B9 /* PreferencesSection.swift in Sources */, @@ -11723,6 +11796,7 @@ 37D2771527E870D4003365FD /* PreferencesAppearanceView.swift in Sources */, AA72D5FE25FFF94E00C77619 /* NSMenuItemExtension.swift in Sources */, 4BA1A6C2258B0A1300F6F690 /* ContiguousBytesExtension.swift in Sources */, + B6A22B622B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */, 37534CA8281198CD002621E7 /* AdjacentItemEnumerator.swift in Sources */, 4BCF15DB2ABB8CED0083F6DF /* NetworkProtectionRemoteMessagingStorage.swift in Sources */, 987799F62999996B005D8EB6 /* BookmarkDatabase.swift in Sources */, @@ -11752,10 +11826,12 @@ AAEC74BC2642F0F800C2EFBC /* History.xcdatamodeld in Sources */, 56B234BF2A84EFD200F2A1CC /* NavigationBarUrlExtensionsTests.swift in Sources */, 37534C9E28104D9B002621E7 /* TabLazyLoaderTests.swift in Sources */, + B6619EF62B10DFF700CD9186 /* InstructionsFormatParserTests.swift in Sources */, 569277C429DEE09D00B633EF /* ContinueSetUpModelTests.swift in Sources */, 85F1B0C925EF9759004792B6 /* URLEventHandlerTests.swift in Sources */, 4B9292BD2667103100AD2C21 /* BookmarkOutlineViewDataSourceTests.swift in Sources */, 4BF6961D28BE911100D402D4 /* RecentlyVisitedSiteModelTests.swift in Sources */, + B6619F062B17138D00CD9186 /* DataImportSourceViewModelTests.swift in Sources */, 4BBF0917282DD6EF00EE1418 /* TemporaryFileHandlerTests.swift in Sources */, B6A5A27925B93FFF00AA7ADA /* StateRestorationManagerTests.swift in Sources */, B630E7FE29C887ED00363609 /* NSErrorAdditionalInfo.swift in Sources */, @@ -11877,6 +11953,7 @@ 1DA6D1022A1FFA3700540406 /* HTTPCookieTests.swift in Sources */, B68172AE269EB43F006D1092 /* GeolocationServiceTests.swift in Sources */, B6AE74342609AFCE005B9B1A /* ProgressEstimationTests.swift in Sources */, + B6619F032B17123200CD9186 /* DataImportViewModelTests.swift in Sources */, 4BA1A6FE258C5C1300F6F690 /* EncryptedValueTransformerTests.swift in Sources */, 85F69B3C25EDE81F00978E59 /* URLExtensionTests.swift in Sources */, B6DA44112616C0FC00DD1EC2 /* PixelTests.swift in Sources */, @@ -11910,6 +11987,7 @@ 1D1C36E329FAE8DA001FA40C /* FaviconManagerTests.swift in Sources */, 4B723E0526B0003E00E14D75 /* DataImportMocks.swift in Sources */, 4B70C00227B0793D000386ED /* CrashReportTests.swift in Sources */, + B6656E0D2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */, 37D23787287F5C2900BCE03B /* PinnedTabsViewModelTests.swift in Sources */, 4B9DB0542A983B55000927DB /* MockWaitlistStorage.swift in Sources */, 4BF4EA5027C71F26004E57C4 /* PasswordManagementListSectionTests.swift in Sources */, @@ -12131,22 +12209,6 @@ name = TabBar.storyboard; sourceTree = ""; }; - AA80EC8B256C49B8007083E7 /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - AA80EC8A256C49B8007083E7 /* en */, - ); - name = Localizable.strings; - sourceTree = ""; - }; - AA80EC91256C49BC007083E7 /* Localizable.stringsdict */ = { - isa = PBXVariantGroup; - children = ( - AA80EC90256C49BC007083E7 /* en */, - ); - name = Localizable.stringsdict; - sourceTree = ""; - }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ @@ -12981,8 +13043,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit"; requirement = { - branch = "sam/add-netp-subscription-auth-support"; - kind = branch; + kind = exactVersion; + version = 99.0.2; }; }; AA06B6B52672AF8100F541C5 /* XCRemoteSwiftPackageReference "Sparkle" */ = { @@ -13020,9 +13082,13 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 1E3ED4FC2AC1E0290075F60F /* Purchase */ = { + 1E0068AC2B1673BB00BBF43B /* SubscriptionUI */ = { isa = XCSwiftPackageProductDependency; - productName = Purchase; + productName = SubscriptionUI; + }; + 1E934E2A2B167CA80084722B /* Subscription */ = { + isa = XCSwiftPackageProductDependency; + productName = Subscription; }; 1E950E3E2912A10D0051A99B /* ContentBlocking */ = { isa = XCSwiftPackageProductDependency; @@ -13039,10 +13105,6 @@ package = 9807F643278CA16F00E1547B /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; productName = UserScript; }; - 1EC88CA02AC1DD63003A4471 /* Account */ = { - isa = XCSwiftPackageProductDependency; - productName = Account; - }; 3143C8782B0D1F3D00382627 /* DataBrokerProtection */ = { isa = XCSwiftPackageProductDependency; productName = DataBrokerProtection; @@ -13332,18 +13394,6 @@ isa = XCSwiftPackageProductDependency; productName = LoginItems; }; - 4B9579402AC7AE700062CA31 /* Subscription */ = { - isa = XCSwiftPackageProductDependency; - productName = Subscription; - }; - 4B9579412AC7AE700062CA31 /* Account */ = { - isa = XCSwiftPackageProductDependency; - productName = Account; - }; - 4B9579422AC7AE700062CA31 /* Purchase */ = { - isa = XCSwiftPackageProductDependency; - productName = Purchase; - }; 7B31FD8B2AD125620086AA24 /* NetworkProtectionIPC */ = { isa = XCSwiftPackageProductDependency; productName = NetworkProtectionIPC; diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 394477550b..2a122ea08a 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/BrowserServicesKit", "state" : { - "branch" : "sam/add-netp-subscription-auth-support", - "revision" : "a2e41dbf95c49f66120497002c680b929e969f97" + "revision" : "18043cbc24e5bb79aa1f44e01a367e0bccd2fa6e", + "version" : "99.0.2" } }, { 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 70c4322cf6..a280d3c9f1 100644 --- a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme +++ b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme @@ -54,7 +54,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index 940dfc6439..e85d6aaf1d 100644 --- a/DuckDuckGo/Application/AppDelegate.swift +++ b/DuckDuckGo/Application/AppDelegate.swift @@ -34,6 +34,10 @@ import UserNotifications import NetworkProtection #endif +#if SUBSCRIPTION +import Subscription +#endif + @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, FileDownloadManagerDelegate { @@ -263,6 +267,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FileDownloadManagerDel #if DBP DataBrokerProtectionAppEvents().applicationDidFinishLaunching() #endif + +#if SUBSCRIPTION + Task { + #if STRIPE + SubscriptionPurchaseEnvironment.current = .stripe + #else + SubscriptionPurchaseEnvironment.current = .appStore + #endif + await AccountManager().checkSubscriptionState() + } +#endif } func applicationDidBecomeActive(_ notification: Notification) { diff --git a/DuckDuckGo/Assets.xcassets/Colors/ClearColor.colorset/Contents.json b/DuckDuckGo/Assets.xcassets/Colors/ClearColor.colorset/Contents.json deleted file mode 100644 index 677d17f304..0000000000 --- a/DuckDuckGo/Assets.xcassets/Colors/ClearColor.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "0.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "contrast", - "value" : "high" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "0.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/DuckDuckGo/Assets.xcassets/Images/Import-Export-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Import-Export-16.imageset/Contents.json new file mode 100644 index 0000000000..ac6954bab7 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Import-Export-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Import-Export-16D.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Import-Export-16.imageset/Import-Export-16D.pdf b/DuckDuckGo/Assets.xcassets/Images/Import-Export-16.imageset/Import-Export-16D.pdf new file mode 100644 index 0000000000..c0a31332a6 Binary files /dev/null and b/DuckDuckGo/Assets.xcassets/Images/Import-Export-16.imageset/Import-Export-16D.pdf differ diff --git a/DuckDuckGo/Assets.xcassets/Images/Menu-Hamburger-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Menu-Hamburger-16.imageset/Contents.json new file mode 100644 index 0000000000..0ccadd1570 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Menu-Hamburger-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Menu-Hamburger-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Menu-Hamburger-16.imageset/Menu-Hamburger-16.pdf b/DuckDuckGo/Assets.xcassets/Images/Menu-Hamburger-16.imageset/Menu-Hamburger-16.pdf new file mode 100644 index 0000000000..0a58a17857 Binary files /dev/null and b/DuckDuckGo/Assets.xcassets/Images/Menu-Hamburger-16.imageset/Menu-Hamburger-16.pdf differ diff --git a/DuckDuckGo/Assets.xcassets/Images/Menu-Horizontal-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Menu-Horizontal-16.imageset/Contents.json new file mode 100644 index 0000000000..05c1cf85e9 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Menu-Horizontal-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Menu-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Menu-Horizontal-16.imageset/Menu-16.pdf b/DuckDuckGo/Assets.xcassets/Images/Menu-Horizontal-16.imageset/Menu-16.pdf new file mode 100644 index 0000000000..7fdb921d88 Binary files /dev/null and b/DuckDuckGo/Assets.xcassets/Images/Menu-Horizontal-16.imageset/Menu-16.pdf differ diff --git a/DuckDuckGo/Assets.xcassets/Images/Menu-Vertical-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Menu-Vertical-16.imageset/Contents.json new file mode 100644 index 0000000000..8a37726a3c --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Menu-Vertical-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Menu-Vertical-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Menu-Vertical-16.imageset/Menu-Vertical-16.pdf b/DuckDuckGo/Assets.xcassets/Images/Menu-Vertical-16.imageset/Menu-Vertical-16.pdf new file mode 100644 index 0000000000..5026ec4d01 Binary files /dev/null and b/DuckDuckGo/Assets.xcassets/Images/Menu-Vertical-16.imageset/Menu-Vertical-16.pdf differ diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift b/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift index a432fcb295..54fe8dd783 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift @@ -43,7 +43,7 @@ protocol BookmarkManager: AnyObject { func canMoveObjectWithUUID(objectUUID uuid: String, to parent: BookmarkFolder) -> Bool func move(objectUUIDs: [String], toIndex: Int?, withinParentFolder: ParentFolderType, completion: @escaping (Error?) -> Void) func moveFavorites(with objectUUIDs: [String], toIndex: Int?, completion: @escaping (Error?) -> Void) - func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarkImportResult + func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarksImportSummary func handleFavoritesAfterDisablingSync() @@ -313,7 +313,7 @@ final class LocalBookmarkManager: BookmarkManager { // MARK: - Import - func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarkImportResult { + func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarksImportSummary { let results = bookmarkStore.importBookmarks(bookmarks, source: source) loadBookmarks() requestSync() diff --git a/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift b/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift index 9f1ae6c010..6b7981c96a 100644 --- a/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift +++ b/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift @@ -30,12 +30,12 @@ enum ParentFolderType { case parent(uuid: String) } -struct BookmarkImportResult: Equatable { +struct BookmarksImportSummary: Equatable { var successful: Int var duplicates: Int var failed: Int - static func += (left: inout BookmarkImportResult, right: BookmarkImportResult) { + static func += (left: inout BookmarksImportSummary, right: BookmarksImportSummary) { left.successful += right.successful left.duplicates += right.duplicates left.failed += right.failed @@ -57,7 +57,7 @@ protocol BookmarkStore { func canMoveObjectWithUUID(objectUUID uuid: String, to parent: BookmarkFolder) -> Bool func move(objectUUIDs: [String], toIndex: Int?, withinParentFolder: ParentFolderType, completion: @escaping (Error?) -> Void) func moveFavorites(with objectUUIDs: [String], toIndex: Int?, completion: @escaping (Error?) -> Void) - func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarkImportResult + func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarksImportSummary func handleFavoritesAfterDisablingSync() } diff --git a/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift b/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift index 5a85c39762..f505891891 100644 --- a/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift +++ b/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift @@ -110,9 +110,9 @@ public final class BookmarkStoreMock: BookmarkStore { } var importBookmarksCalled = false - func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarkImportResult { + func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarksImportSummary { importBookmarksCalled = true - return BookmarkImportResult(successful: 0, duplicates: 0, failed: 0) + return BookmarksImportSummary(successful: 0, duplicates: 0, failed: 0) } var canMoveObjectWithUUIDCalled = false diff --git a/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift b/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift index b955524cac..6ee67e4d27 100644 --- a/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift +++ b/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift @@ -705,8 +705,8 @@ final class LocalBookmarkStore: BookmarkStore { /// 2. **Safari:** Create a root level "Imported Favorites" folder to store bookmarks from the bookmarks bar, and all other bookmarks go at the root level. /// 3. **Chrome:** Put all bookmarks at the root level, except for Other Bookmarks which go in a root level "Other Bookmarks" folder. /// 4. **Firefox:** Put all bookmarks at the root level, except for Other Bookmarks which go in a root level "Other Bookmarks" folder. - func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarkImportResult { - var total = BookmarkImportResult(successful: 0, duplicates: 0, failed: 0) + func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarksImportSummary { + var total = BookmarksImportSummary(successful: 0, duplicates: 0, failed: 0) do { let context = makeContext() @@ -747,7 +747,7 @@ final class LocalBookmarkStore: BookmarkStore { private func createEntitiesFromBookmarks(allFolders: [BookmarkEntity], bookmarks: ImportedBookmarks, importSourceName: String, - in context: NSManagedObjectContext) -> BookmarkImportResult { + in context: NSManagedObjectContext) -> BookmarksImportSummary { guard let root = bookmarksRoot(in: context) else { return .init(successful: 0, @@ -755,7 +755,7 @@ final class LocalBookmarkStore: BookmarkStore { failed: bookmarks.numberOfBookmarks) } - var total = BookmarkImportResult(successful: 0, duplicates: 0, failed: 0) + var total = BookmarksImportSummary(successful: 0, duplicates: 0, failed: 0) var parent = root var makeFavorties = true @@ -791,8 +791,8 @@ final class LocalBookmarkStore: BookmarkStore { private func recursivelyCreateEntities(from bookmarks: [ImportedBookmarks.BookmarkOrFolder], parent: BookmarkEntity, markBookmarksAsFavorite: Bool? = false, - in context: NSManagedObjectContext) -> BookmarkImportResult { - var total = BookmarkImportResult(successful: 0, duplicates: 0, failed: 0) + in context: NSManagedObjectContext) -> BookmarksImportSummary { + var total = BookmarksImportSummary(successful: 0, duplicates: 0, failed: 0) let favoritesFolders = BookmarkUtils.fetchFavoritesFolders(for: favoritesDisplayMode, in: context) diff --git a/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift index 391917da3f..ee3fe4e639 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift @@ -173,7 +173,7 @@ final class BookmarkListViewController: NSViewController { } @IBAction func onImportClicked(_ sender: NSButton) { - DataImportViewController.show() + DataImportView.show() } // MARK: NSOutlineView Configuration diff --git a/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift index 4cb959f7eb..68d8993c8f 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift @@ -125,7 +125,7 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem } @IBAction func onImportClicked(_ sender: NSButton) { - DataImportViewController.show() + DataImportView.show() } @IBAction func handleDoubleClick(_ sender: NSTableView) { diff --git a/DuckDuckGo/Common/Extensions/NSColorExtension.swift b/DuckDuckGo/Common/Extensions/NSColorExtension.swift index 156d7a76a4..7330963968 100644 --- a/DuckDuckGo/Common/Extensions/NSColorExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSColorExtension.swift @@ -74,10 +74,6 @@ extension NSColor { NSColor(named: "FindInPageFocusedBackgroundColor")! } - static var inactiveSearchBarBackground: NSColor { - NSColor(named: "InactiveSearchBarBackground")! - } - static var suggestionTextColor: NSColor { NSColor(named: "SuggestionTextColor")! } @@ -138,14 +134,6 @@ extension NSColor { NSColor(named: "DialogPanelBackground")! } - static let bookmarkFilledTint = NSColor(named: "BookmarkFilledTint")! - - static let bookmarkRepresentingColor1 = NSColor(named: "BookmarkRepresentingColor1")! - static let bookmarkRepresentingColor2 = NSColor(named: "BookmarkRepresentingColor2")! - static let bookmarkRepresentingColor3 = NSColor(named: "BookmarkRepresentingColor3")! - static let bookmarkRepresentingColor4 = NSColor(named: "BookmarkRepresentingColor4")! - static let bookmarkRepresentingColor5 = NSColor(named: "BookmarkRepresentingColor5")! - static var buttonMouseDownColor: NSColor { NSColor(named: "ButtonMouseDownColor")! } diff --git a/DuckDuckGo/Common/Extensions/NSOpenPanelExtensions.swift b/DuckDuckGo/Common/Extensions/NSOpenPanelExtensions.swift index 1e48157aaa..d2817a5057 100644 --- a/DuckDuckGo/Common/Extensions/NSOpenPanelExtensions.swift +++ b/DuckDuckGo/Common/Extensions/NSOpenPanelExtensions.swift @@ -33,14 +33,13 @@ extension NSOpenPanel { return panel } - static func filePanel(allowedExtension: String) -> NSOpenPanel { - let panel = NSOpenPanel() - - panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Desktop") - panel.canChooseFiles = true - panel.allowedContentTypes = UTType(filenameExtension: allowedExtension).map { [$0] } ?? [] - panel.canChooseDirectories = false + convenience init(allowedFileTypes: [UTType], directoryURL: URL? = nil) { + self.init() - return panel + self.directoryURL = directoryURL + canChooseFiles = true + allowedContentTypes = allowedFileTypes + canChooseDirectories = false } + } diff --git a/DuckDuckGo/Common/Extensions/NSWorkspaceExtension.swift b/DuckDuckGo/Common/Extensions/NSWorkspaceExtension.swift index 294c9d7401..8e7c81a0ac 100644 --- a/DuckDuckGo/Common/Extensions/NSWorkspaceExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSWorkspaceExtension.swift @@ -58,4 +58,12 @@ extension NSWorkspace { return missionControlWindows.count > allScreenSizes.count } + @available(macOS, obsoleted: 14.0, message: "This needs to be removed as it‘s no longer necessary.") + @nonobjc func urls(forApplicationsWithBundleId bundleId: String) -> [URL] { + if #available(macOS 12.0, *) { + return self.urlsForApplications(withBundleIdentifier: bundleId) + } + return LSCopyApplicationURLsForBundleIdentifier(bundleId as CFString, nil)?.takeRetainedValue() as? [URL] ?? [] + } + } diff --git a/DuckDuckGo/Common/Extensions/SetExtension.swift b/DuckDuckGo/Common/Extensions/SetExtension.swift index a355443656..bdd5701a11 100644 --- a/DuckDuckGo/Common/Extensions/SetExtension.swift +++ b/DuckDuckGo/Common/Extensions/SetExtension.swift @@ -32,3 +32,11 @@ extension Set where Element == String { } } + +extension Set { + + @inlinable func intersects(_ other: S) -> Bool where Element == S.Element { + !isDisjoint(with: other) + } + +} diff --git a/DuckDuckGo/Common/Extensions/TaskWithProgress.swift b/DuckDuckGo/Common/Extensions/TaskWithProgress.swift new file mode 100644 index 0000000000..145a1d5a33 --- /dev/null +++ b/DuckDuckGo/Common/Extensions/TaskWithProgress.swift @@ -0,0 +1,169 @@ +// +// TaskWithProgress.swift +// +// Copyright © 2023 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 + +typealias TaskProgressProgressEvent = TaskWithProgress.ProgressEvent +typealias TaskProgress = AsyncStream> +typealias TaskProgressUpdateCallback = TaskWithProgress.ProgressUpdateCallback + +/// Used to run an async Task while tracking its completion progress +/// Usage: +/// ``` +/// func doSomeOperation() -> TaskWithProgress { +/// .withProgress((total: 100, completed: 0)) { updateProgress in +/// try await part1() +/// updateProgress(50) +/// +/// try await part2() +/// updateProgress(100) +/// } +/// } +/// ``` +struct TaskWithProgress: Sendable where Success: Sendable, Failure: Error { + + enum ProgressEvent { + case progress(ProgressUpdate) + case completed(Result) + } + + let task: Task + + typealias Progress = AsyncStream + typealias ProgressUpdateCallback = (ProgressUpdate) throws -> Void + + let progress: Progress + + fileprivate init(task: Task, progress: Progress) { + self.task = task + self.progress = progress + } + + /// The task's result + var value: Success { + get async throws { + try await task.value + } + } + + /// The task's result + var result: Result { + get async { + await task.result + } + } + + /// Cancel the Task + func cancel() { + task.cancel() + } + + var isCancelled: Bool { + task.isCancelled + } + +} + +extension TaskWithProgress: Hashable { + + func hash(into hasher: inout Hasher) { + task.hash(into: &hasher) + } + + static func == (lhs: TaskWithProgress, rhs: TaskWithProgress) -> Bool { + lhs.task == rhs.task + } + +} + +extension TaskWithProgress where Failure == Never { + + /// The result from a nonthrowing task, after it completes. + var value: Success { + get async { + await task.value + } + } + +} + +protocol AnyTask { + associatedtype Success + associatedtype Failure +} +extension Task: AnyTask {} +extension TaskWithProgress: AnyTask {} + +extension AnyTask where Failure == Error { + + static func detachedWithProgress(_ progress: ProgressUpdate? = nil, priority: TaskPriority? = nil, do operation: @escaping @Sendable (@escaping TaskProgressUpdateCallback) async throws -> Success) -> TaskWithProgress { + let (progressStream, progressContinuation) = TaskProgress.makeStream() + if let progress { + progressContinuation.yield(.progress(progress)) + } + + let task = Task.detached { + let updateProgressCallback: TaskProgressUpdateCallback = { update in + try Task.checkCancellation() + progressContinuation.yield(.progress(update)) + } + + defer { + progressContinuation.finish() + } + do { + let result = try await operation(updateProgressCallback) + progressContinuation.yield(.completed(.success(result))) + + return result + } catch { + progressContinuation.yield(.completed(.failure(error))) + throw error + } + } + + return TaskWithProgress(task: task, progress: progressStream) + } + +} + +extension AnyTask where Failure == Never { + + static func detachedWithProgress(_ progress: ProgressUpdate? = nil, completed: UInt? = nil, priority: TaskPriority? = nil, do operation: @escaping @Sendable (@escaping TaskProgressUpdateCallback) async -> Success) -> TaskWithProgress { + let (progressStream, progressContinuation) = TaskProgress.makeStream() + if let progress { + progressContinuation.yield(.progress(progress)) + } + + let task = Task.detached { + let updateProgressCallback: TaskProgressUpdateCallback = { update in + try Task.checkCancellation() + progressContinuation.yield(.progress(update)) + } + + let result = await operation(updateProgressCallback) + progressContinuation.yield(.completed(.success(result))) + progressContinuation.finish() + + return result + } + + return TaskWithProgress(task: task, progress: progressStream) + } + +} diff --git a/DuckDuckGo/Common/Extensions/URLExtension.swift b/DuckDuckGo/Common/Extensions/URLExtension.swift index 827d9cb330..7d3c638120 100644 --- a/DuckDuckGo/Common/Extensions/URLExtension.swift +++ b/DuckDuckGo/Common/Extensions/URLExtension.swift @@ -36,10 +36,18 @@ extension URL { * Returns a URL pointing to `${HOME}/Library`, regardless of whether the app is sandboxed or not. */ static var nonSandboxLibraryDirectoryURL: URL { - guard NSApp.isSandboxed else { - return FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! + if NSApp.isSandboxed { + return FileManager.default.homeDirectoryForCurrentUser.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + } + return FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! + } + + static var nonSandboxHomeDirectory: URL { + if NSApp.isSandboxed { + return FileManager.default.homeDirectoryForCurrentUser + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() } - return FileManager.default.homeDirectoryForCurrentUser.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + return FileManager.default.homeDirectoryForCurrentUser } /** diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 0eb91fe989..91926817f7 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -31,6 +31,7 @@ struct UserText { static let save = NSLocalizedString("save", value: "Save", comment: "Save button") static let copy = NSLocalizedString("copy", value: "Copy", comment: "Copy button") static let submit = NSLocalizedString("submit", value: "Submit", comment: "Submit button") + static let submitReport = NSLocalizedString("submit.report", value: "Submit Report", comment: "Submit Report button") static let pasteFromClipboard = NSLocalizedString("paste-from-clipboard", value: "Paste from Clipboard", comment: "Paste button") static let edit = NSLocalizedString("edit", value: "Edit", comment: "Edit button") static let copySelection = NSLocalizedString("copy-selection", value: "Copy", comment: "Copy selection menu item") @@ -415,8 +416,11 @@ struct UserText { static let passwordManagementLock = NSLocalizedString("passsword.management.lock", value: "Lock", comment: "Lock Logins Vault menu") static let passwordManagementUnlock = NSLocalizedString("passsword.management.unlock", value: "Unlock", comment: "Unlock Logins Vault menu") - static let importBookmarks = NSLocalizedString("import.browser.data", value: "Import Bookmarks…", comment: "Opens Import Browser Data dialog") - static let importPasswords = NSLocalizedString("import.browser.data", value: "Import Passwords…", comment: "Opens Import Browser Data dialog") + static let importBookmarks = NSLocalizedString("import.browser.data.bookmarks", value: "Import Bookmarks…", comment: "Opens Import Browser Data dialog") + static let importPasswords = NSLocalizedString("import.browser.data.passwords", value: "Import Passwords…", comment: "Opens Import Browser Data dialog") + + static let importDataTitle = NSLocalizedString("import.browser.data", value: "Import Browser Data", comment: "Import Browser Data dialog title") + static let exportLogins = NSLocalizedString("export.logins.data", value: "Export Passwords…", comment: "Opens Export Logins Data dialog") static let exportBookmarks = NSLocalizedString("export.bookmarks.menu.item", value: "Export Bookmarks…", comment: "Export bookmarks menu item") static let bookmarks = NSLocalizedString("bookmarks", value: "Bookmarks", comment: "Button for bookmarks") @@ -576,50 +580,24 @@ struct UserText { // MARK: - Login Import & Export - static let safariPreferences = NSLocalizedString("import.logins.safari.preferences", value: "Preferences", comment: "Title of the Safari Preferences menu (up to and including macOS 12)") - static let safariSettings = NSLocalizedString("import.logins.safari.settings", value: "Settings", comment: "Title of the Safari Settings menu (macOS 13 and above)") - - static let importLoginsCSV = NSLocalizedString("import.logins.csv.title", value: "CSV Password File", comment: "Title text for the CSV importer") - static let importBookmarksHTML = NSLocalizedString("import.bookmarks.html.title", value: "HTML Bookmarks File", comment: "Title text for the HTML Bookmarks importer") - static let importBookmarksSelectHTMLFile = NSLocalizedString("import.bookmarks.select-html-file", value: "Select HTML Bookmarks File…", comment: "Button text for selecting HTML Bookmarks file") - static let importBookmarksSelectAnotherFile = NSLocalizedString("import.bookmarks.select-another-file", value: "Select Another HTML File…", comment: "Button text for selecting another file") - static let importBookmarksFailedToReadHTMLFile = NSLocalizedString("import.bookmarks.failed-to-read-file", value: "Failed to read HTML file", comment: "Error text when importing a HTML file") - - static func importingFile(validBookmarks: Int) -> String { - let localized = NSLocalizedString("import.bookmarks.html.valid-bookmarks", - value: "Contains %@ bookmarks", - comment: "Displays the number of the bookmarks being imported") - return String(format: localized, String(validBookmarks)) + static let importLoginsCSV = NSLocalizedString("import.logins.csv.title", value: "CSV Passwords File (for other browsers)", comment: "Title text for the CSV importer") + static let importBookmarksHTML = NSLocalizedString("import.bookmarks.html.title", value: "HTML Bookmarks File (for other browsers)", comment: "Title text for the HTML Bookmarks importer") + static let importBookmarksSelectHTMLFile = NSLocalizedString("import.bookmarks.select-html-file", value: "Select Bookmarks HTML File…", comment: "Button text for selecting HTML Bookmarks file") + static let importLoginsSelectCSVFile = NSLocalizedString("import.logins.select-csv-file", value: "Select Passwords CSV File…", comment: "Button text for selecting a CSV file") + static func importLoginsSelectCSVFile(from source: DataImport.Source) -> String { + String(format: NSLocalizedString("import.logins.select-csv-file.source", value: "Select %@ CSV File…", comment: "Button text for selecting a CSV file exported from (LastPass or Bitwarden or 1Password - %@)"), source.importSourceName) } - static let csvImportDescription = NSLocalizedString("import.logins.csv.description", value: "The CSV importer will try to match column headers to their position.\nIf there is no header, it supports two formats:\n\n1. URL, Username, Password\n2. Title, URL, Username, Password", comment: "Description text for the CSV importer") - static let importLoginsSelectCSVFile = NSLocalizedString("import.logins.select-csv-file", value: "Select CSV File…", comment: "Button text for selecting a CSV file") - static let importLoginsSelectSafariCSVFile = NSLocalizedString("import.logins.select-safari-csv-file", value: "Select Passwords CSV File…", comment: "Button text for selecting a Safari CSV file") - static let importLoginsSelect1PasswordCSVFile = NSLocalizedString("import.logins.select-1password-csv-file", value: "Select 1Password CSV File…", comment: "Button text for selecting a 1Password CSV file") - static let importLoginsSelectLastPassCSVFile = NSLocalizedString("import.logins.select-lastpass-csv-file", value: "Select LastPass CSV File…", comment: "Button text for selecting a LastPass CSV file") - - static let importLoginsSelectAnotherFile = NSLocalizedString("import.logins.select-another-file", value: "Select Another CSV File…", comment: "Button text for selecting another file") - static let importLoginsFailedToReadCSVFile = NSLocalizedString("import.logins.failed-to-read-file", value: "Failed to get CSV file URL", comment: "Error text when importing a CSV file") - - static func importingFile(validLogins: Int) -> String { - let localized = NSLocalizedString("import.logins.csv.valid-logins", - value: "Contains %@ valid passwords", - comment: "Displays the number of the logins being imported") - return String(format: localized, String(validLogins)) - } + static let importLoginsPasswords = NSLocalizedString("import.logins.passwords", value: "Passwords", comment: "Title text for the Passwords import option") static let initiateImport = NSLocalizedString("import.data.initiate", value: "Import", comment: "Button text for importing data") - static let doneImporting = NSLocalizedString("import.data.done", value: "Done", comment: "Button text for finishing the data import") - - static let dataImportFailedTitle = NSLocalizedString("import.data.import-failed.title", value: "Sorry, we weren't able to import your data.", comment: "Alert title when the data import fails") - - static let dataImportSubmitFeedback = NSLocalizedString("import.data.submit-feedback", value: "submit feedback", comment: "Link text used in the data import failure alert") - static let dataImportFailedBody = NSLocalizedString("import.data.import-failed.body", - value: "Please submit feedback so we can address this issue.", - comment: "Alert body text used in the data import failure alert") + static let skipBookmarksImport = NSLocalizedString("import.data.skip.bookmarks", value: "Skip bookmarks", comment: "Button text to skip bookmarks manual import") + static let skipPasswordsImport = NSLocalizedString("import.data.skip.passwords", value: "Skip passwords", comment: "Button text to skip bookmarks manual import") + static let skip = NSLocalizedString("import.data.skip", value: "Skip", comment: "Button text to skip an import step") + static let done = NSLocalizedString("import.data.done", value: "Done", comment: "Button text for finishing the data import") + static let manualImport = NSLocalizedString("import.data.manual", value: "Manual import…", comment: "Button text for initiating manual data import using a HTML or CSV file when automatic import has failed") static let dataImportAlertImport = NSLocalizedString("import.data.alert.import", value: "Import", comment: "Import button for data import alerts") - static let dataImportAlertAccept = NSLocalizedString("import.data.alert.accept", value: "Okay", comment: "Accept button for data import alerts") static let dataImportAlertCancel = NSLocalizedString("import.data.alert.cancel", value: "Cancel", comment: "Cancel button for data import alerts") static func dataImportRequiresPasswordTitle(_ source: DataImport.Source) -> String { @@ -636,77 +614,9 @@ struct UserText { return String(format: localized, source.importSourceName) } - static func dataImportBrowserMustBeClosed(_ source: DataImport.Source) -> String { - let localized = NSLocalizedString("import.data.close-browser", - value: "Please ensure that %@ is not running before importing data", - comment: "Alert body text when the data import fails due to the browser being open") - return String(format: localized, source.importSourceName) - } - - static func dataImportQuitBrowserTitle(_ source: DataImport.Source) -> String { - let localized = NSLocalizedString("import.data.quit-browser.title", - value: "Would you like to quit %@ now?", - comment: "Alert title text when prompting to close the browser") - return String(format: localized, source.importSourceName) - } - - static func dataImportQuitBrowserBody(_ source: DataImport.Source) -> String { - let localized = NSLocalizedString("import.data.quit-browser.body", - value: "You must quit %@ before importing data.", - comment: "Alert body text when prompting to close the browser") - return String(format: localized, source.importSourceName) - } - - static func dataImportQuitBrowserButton(_ source: DataImport.Source) -> String { - let localized = NSLocalizedString("import.data.quit-browser.accept-button", - value: "Quit %@", - comment: "Accept button text when prompting to close the browser") - return String(format: localized, source.importSourceName) - } - - static func loginImportSuccessfulCSVImports(totalSuccessfulImports: Int) -> String { - let localized = NSLocalizedString("import.logins.csv.successful-imports", - value: "New passwords: %@", - comment: "Status text indicating the number of successful CSV login imports") - return String(format: localized, String(totalSuccessfulImports)) - } - - static func loginImportSuccessfulBrowserImports(totalSuccessfulImports: Int) -> String { - let localized = NSLocalizedString("import.logins.browser.successful-imports", - value: "Passwords: %@", - comment: "Status text indicating the number of successful browser login imports") - return String(format: localized, String(totalSuccessfulImports)) - } - - static func successfulBookmarkImports(_ totalSuccessfulImports: Int) -> String { - let localized = NSLocalizedString("import.bookmarks.browser.successful-imports", - value: "Bookmarks: %@", - comment: "Status text indicating the number of successful browser bookmark imports") - return String(format: localized, String(totalSuccessfulImports)) - } - - static func duplicateBookmarkImports(_ totalFailedImports: Int) -> String { - let localized = NSLocalizedString("import.bookmarks.browser.duplicate-imports", - value: "Duplicate Bookmarks Skipped: %@", - comment: "Status text indicating the number of duplicate browser bookmark imports") - return String(format: localized, String(totalFailedImports)) - } - - static func failedBookmarkImports(_ totalFailedImports: Int) -> String { - let localized = NSLocalizedString("import.bookmarks.browser.failed-imports", - value: "Failed Imports: %@", - comment: "Status text indicating the number of failed browser bookmark imports") - return String(format: localized, String(totalFailedImports)) - } - - static let bookmarkImportSafariPermissionDescription = NSLocalizedString("import.bookmarks.safari.permission-description", value: "DuckDuckGo needs your permission to read the Safari bookmarks file. Select the Safari folder to import bookmarks.", comment: "Description text for the Safari bookmark import permission screen") static let bookmarkImportSafariRequestPermissionButtonTitle = NSLocalizedString("import.bookmarks.safari.permission-button.title", value: "Select Safari Folder…", comment: "Text for the Safari data import permission button") - static let bookmarkImportBookmarksBar = NSLocalizedString("import.bookmarks.folder.bookmarks-bar", value: "Bookmarks Bar", comment: "Title text for Bookmarks Bar import folder") - static let bookmarkImportOtherBookmarks = NSLocalizedString("import.bookmarks.folder.other-bookmarks", value: "Other Bookmarks", comment: "Title text for Other Bookmarks import folder") - static let bookmarkImportBookmarks = NSLocalizedString("import.bookmarks.bookmarks", value: "Bookmarks", comment: "Title text for the Bookmarks import option") - static let bookmarkImportBookmarksAndFavorites = NSLocalizedString("import.bookmarks.bookmarks-and-favorites", value: "Bookmarks & Favorites", comment: "Title text for the Bookmarks & Favorites import option") static let openDeveloperTools = NSLocalizedString("main.menu.show.inspector", value: "Open Developer Tools", comment: "Show Web Inspector/Open Developer Tools") static let closeDeveloperTools = NSLocalizedString("main.menu.close.inspector", value: "Close Developer Tools", comment: "Hide Web Inspector/Close Developer Tools") @@ -757,13 +667,23 @@ struct UserText { static let onboardingSetDefaultButton = NSLocalizedString("onboarding.setdefault.button", value: "Let's Do It!", comment: "Launch the set default UI") static let onboardingNotNowButton = NSLocalizedString("onboarding.notnow.button", value: "Maybe Later", comment: "Skip a step of the onboarding flow") - static let importFromChromiumMoreInfo = NSLocalizedString("import.from.chromium.info", value: """ - If your computer prompts you to enter a password prior to import, DuckDuckGo will not see that password. - - Imported passwords are stored securely using encryption. - """, comment: "More info when importing from Chromium") + static func importingBookmarks(_ numberOfBookmarks: Int?) -> String { + if let numberOfBookmarks, numberOfBookmarks > 0 { + let localized = NSLocalizedString("import.bookmarks.number.progress.text", value: "Importing %d bookmarks…", comment: "Operation progress info message about %d number of bookmarks being imported") + return String(format: localized, numberOfBookmarks) + } else { + return NSLocalizedString("import.bookmarks.indefinite.progress.text", value: "Importing bookmarks…", comment: "Operation progress info message about indefinite number of bookmarks being imported") + } + } - static let importFromFirefoxMoreInfo = NSLocalizedString("import.from.firefox.info", value: "You'll be asked to enter your Primary Password for Firefox.\n\nImported passwords are encrypted and only stored on this computer.", comment: "More info when importing from Firefox") + static func importingPasswords(_ numberOfPasswords: Int?) -> String { + if let numberOfPasswords, numberOfPasswords > 0 { + let localized = NSLocalizedString("import.passwords.number.progress.text", value: "Importing %d passwords…", comment: "Operation progress info message about %d number of passwords being imported") + return String(format: localized, numberOfPasswords) + } else { + return NSLocalizedString("import.passwords.indefinite.progress.text", value: "Importing passwords…", comment: "Operation progress info message about indefinite number of passwords being imported") + } + } static let moreOrLessCollapse = NSLocalizedString("more.or.less.collapse", value: "Show Less", comment: "For collapsing views to show less.") static let moreOrLessExpand = NSLocalizedString("more.or.less.expand", value: "Show More", comment: "For expanding views to show more.") @@ -1062,5 +982,9 @@ struct UserText { #if SUBSCRIPTION static let subscriptionOptionsMenuItem = NSLocalizedString("subscription.menu.item", value: "Privacy Pro", comment: "Title for Subscription item in the options menu") static let subscription = NSLocalizedString("preferences.subscription", value: "Privacy Pro", comment: "Show subscription preferences") + + static let purchasingSubscriptionTitle = NSLocalizedString("subscription.progress.view.purchasing.subscription", value: "Purchase in progress...", comment: "Progress view title when starting the purchase") + static let restoringSubscriptionTitle = NSLocalizedString("subscription.progress.view.restoring.subscription", value: "Restoring subscription...", comment: "Progress view title when restoring past subscription purchase") + static let completingPurchaseTitle = NSLocalizedString("subscription.progress.view.completing.purchase", value: "Completing purchase...", comment: "Progress view title when completing the purchase") #endif } diff --git a/DuckDuckGo/Common/Localizables/en.lproj/Localizable.strings b/DuckDuckGo/Common/Localizables/en.lproj/Localizable.strings deleted file mode 100644 index 8b13789179..0000000000 --- a/DuckDuckGo/Common/Localizables/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/DuckDuckGo/Common/Localizables/en.lproj/Localizable.stringsdict b/DuckDuckGo/Common/Localizables/en.lproj/Localizable.stringsdict deleted file mode 100644 index 006d3e9b5a..0000000000 --- a/DuckDuckGo/Common/Localizables/en.lproj/Localizable.stringsdict +++ /dev/null @@ -1,30 +0,0 @@ - - - - - StringKey - - NSStringLocalizedFormatKey - %#@VARIABLE@ - VARIABLE - - NSStringFormatSpecTypeKey - NSStringPluralRuleType - NSStringFormatValueTypeKey - - zero - - one - - two - - few - - many - - other - - - - - diff --git a/DuckDuckGo/Common/View/AppKit/ProgressView.swift b/DuckDuckGo/Common/View/AppKit/LoadingProgressView.swift similarity index 99% rename from DuckDuckGo/Common/View/AppKit/ProgressView.swift rename to DuckDuckGo/Common/View/AppKit/LoadingProgressView.swift index 996e920e18..0c16098da7 100644 --- a/DuckDuckGo/Common/View/AppKit/ProgressView.swift +++ b/DuckDuckGo/Common/View/AppKit/LoadingProgressView.swift @@ -1,5 +1,5 @@ // -// ProgressView.swift +// LoadingProgressView.swift // // Copyright © 2021 DuckDuckGo. All rights reserved. // @@ -19,7 +19,7 @@ import AppKit @IBDesignable -final class ProgressView: NSView, CAAnimationDelegate { +final class LoadingProgressView: NSView, CAAnimationDelegate { private var progressLayer = CAGradientLayer() private var progressMask = CALayer() @@ -261,7 +261,7 @@ final class ProgressView: NSView, CAAnimationDelegate { } -extension ProgressView { +extension LoadingProgressView { struct Constants { static let gradientAnimationKey = "animateGradient" diff --git a/DuckDuckGo/Common/View/SwiftUI/SheetHostingWindow.swift b/DuckDuckGo/Common/View/SwiftUI/SheetHostingWindow.swift new file mode 100644 index 0000000000..5290733c81 --- /dev/null +++ b/DuckDuckGo/Common/View/SwiftUI/SheetHostingWindow.swift @@ -0,0 +1,40 @@ +// +// SheetHostingWindow.swift +// +// Copyright © 2023 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 Foundation +import SwiftUI + +internal class SheetHostingWindow: NSWindow { + + init(rootView: Content) { + super.init(contentRect: .zero, styleMask: [.titled, .closable, .docModalWindow], backing: .buffered, defer: false) + self.contentView = NSHostingView(rootView: rootView.legacyOnDismiss { [weak self] in + self?.performClose(nil) + }) + } + + override func performClose(_ sender: Any?) { + guard let sheetParent else { + super.performClose(sender) + return + } + sheetParent.endSheet(self, returnCode: .alertFirstButtonReturn) + } + +} diff --git a/DuckDuckGo/Common/View/SwiftUI/View+RoundedCorners.swift b/DuckDuckGo/Common/View/SwiftUI/View+RoundedCorners.swift deleted file mode 100644 index a094c37c83..0000000000 --- a/DuckDuckGo/Common/View/SwiftUI/View+RoundedCorners.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// View+RoundedCorners.swift -// -// Copyright © 2022 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import SwiftUI - -extension View { - - /** - * Rounds corners specified by `corners` using given `radius`. - */ - func cornerRadius(_ radius: CGFloat, corners: [NSBezierPath.Corners]) -> some View { - clipShape(RoundedCorner(radius: radius, corners: corners)) - } -} - -private struct RoundedCorner: Shape { - - var radius: CGFloat = 0 - var corners: [NSBezierPath.Corners] = NSBezierPath.Corners.allCases - - func path(in rect: CGRect) -> Path { - let path = NSBezierPath(roundedRect: rect, forCorners: corners, cornerRadius: radius) - return Path(path.cgPath) - } -} diff --git a/DuckDuckGo/Common/View/SwiftUI/ViewExtension.swift b/DuckDuckGo/Common/View/SwiftUI/ViewExtension.swift new file mode 100644 index 0000000000..8f5cd6f2dd --- /dev/null +++ b/DuckDuckGo/Common/View/SwiftUI/ViewExtension.swift @@ -0,0 +1,171 @@ +// +// ViewExtension.swift +// +// Copyright © 2022 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +extension View { + + /** + * Rounds corners specified by `corners` using given `radius`. + */ + func cornerRadius(_ radius: CGFloat, corners: [NSBezierPath.Corners]) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } + + @available(macOS, obsoleted: 12.3, message: "This needs to be removed as it‘s no longer necessary.") + @ViewBuilder + func keyboardShortcut(_ shortcut: KeyboardShortcut?) -> some View { + if let shortcut { + self.keyboardShortcut(shortcut) + } else { + self + } + } + +} + +extension View { + + @available(macOS, obsoleted: 14.0, message: "This needs to be removed as it‘s no longer necessary.") + @ViewBuilder + func legacyOnDismiss(_ onDismiss: @escaping () -> Void) -> some View { + if #available(macOS 14.0, *) { + self + + } else if let presentationModeKey = \EnvironmentValues.presentationMode as? WritableKeyPath { + // hacky way to set the @Environment.presentationMode. + // here we downcast a (non-writable) \.presentationMode KeyPath to a WritableKeyPath + self.environment(presentationModeKey, Binding(onDismiss: onDismiss)) + } else { +#if !APPSTORE + // macOS 11 compatibility: + self.environment(\.legacyDismiss, onDismiss) +#endif + } + } +} + +extension Binding where Value == PresentationMode { + + init(isPresented: Bool = true, onDismiss: @escaping () -> Void) { + // PresentationMode is a struct with a single isPresented property and a (statically dispatched) mutating function + // This technically makes it equal to a Bool variable (MemoryLayout.size == MemoryLayout.size == 1) + var isPresented = isPresented + self.init { + // just return the Bool as a PresentationMode + unsafeBitCast(isPresented, to: PresentationMode.self) + } set: { newValue in + // set it back + isPresented = newValue.isPresented + // and call the dismiss callback + if !isPresented { + onDismiss() + } + } + } + +} + +#if !APPSTORE +@available(macOS, obsoleted: 12.0, message: "This needs to be removed as it‘s no longer necessary.") +struct DismissAction { + let dismiss: () -> Void + public func callAsFunction() { + dismiss() + } +} + +@available(macOS, obsoleted: 12.0, message: "This needs to be removed as it‘s no longer necessary.") +struct LegacyDismissAction: EnvironmentKey { + static var defaultValue: () -> Void { { } } +} + +extension EnvironmentValues { + @available(macOS, obsoleted: 12.0, message: "This extension needs to be removed as it‘s no longer necessary.") + var dismiss: DismissAction { + DismissAction { + if \EnvironmentValues.presentationMode as? WritableKeyPath != nil { + presentationMode.wrappedValue.dismiss() + } else { + self[LegacyDismissAction.self]() + } + } + } + @available(macOS, obsoleted: 12.0, message: "This extension needs to be removed as it‘s no longer necessary.") + fileprivate var legacyDismiss: () -> Void { + get { + self[LegacyDismissAction.self] + } + set { + self[LegacyDismissAction.self] = newValue + } + } +} +#endif + +private struct RoundedCorner: Shape { + + var radius: CGFloat = 0 + var corners: [NSBezierPath.Corners] = NSBezierPath.Corners.allCases + + func path(in rect: CGRect) -> Path { + let path = NSBezierPath(roundedRect: rect, forCorners: corners, cornerRadius: radius) + return Path(path.cgPath) + } +} + +extension View { + + @available(macOS, obsoleted: 12.0, message: "This extension needs to be removed as it‘s no longer necessary.") + @_disfavoredOverload + @inlinable func task(priority: TaskPriority = .userInitiated, @_inheritActorContext _ action: @escaping @Sendable () async -> Void) -> some View { + modifier(ViewAsyncTaskModifier(priority: priority, action: action)) + } + +} + +public struct ViewAsyncTaskModifier: ViewModifier { + + private let priority: TaskPriority + private let action: @Sendable () async -> Void + + public init(priority: TaskPriority, action: @escaping @Sendable () async -> Void) { + self.priority = priority + self.action = action + self.task = nil + } + + @State private var task: Task? + + public func body(content: Content) -> some View { + if #available(macOS 12.0, *) { + content.task(priority: priority, action) + } else { + content + .onAppear { + self.task = Task { + await action() + } + } + .onDisappear { + self.task?.cancel() + } + } + } + +} diff --git a/DuckDuckGo/ContentBlocker/AppPrivacyConfigurationDataProvider.swift b/DuckDuckGo/ContentBlocker/AppPrivacyConfigurationDataProvider.swift index f0e8b44787..60c7b2e15e 100644 --- a/DuckDuckGo/ContentBlocker/AppPrivacyConfigurationDataProvider.swift +++ b/DuckDuckGo/ContentBlocker/AppPrivacyConfigurationDataProvider.swift @@ -22,8 +22,8 @@ import BrowserServicesKit final class AppPrivacyConfigurationDataProvider: EmbeddedDataProvider { public struct Constants { - public static let embeddedDataETag = "\"8e38db3f042f4a2a5c9f7ca8d33f17c9\"" - public static let embeddedDataSHA = "a9fc8ee927a37d1b5545377ff29de798f3ec3b8a757430c3af72d13a3844c258" + public static let embeddedDataETag = "\"754731c1057e982a1f8af4b2923fb28b\"" + public static let embeddedDataSHA = "9d16dce193df62e6f0742733ddd071747065834942dee0461dd3c450cfb9b56a" } var embeddedDataEtag: String { diff --git a/DuckDuckGo/ContentBlocker/AppTrackerDataSetProvider.swift b/DuckDuckGo/ContentBlocker/AppTrackerDataSetProvider.swift index 94443f40cd..313c96ccaa 100644 --- a/DuckDuckGo/ContentBlocker/AppTrackerDataSetProvider.swift +++ b/DuckDuckGo/ContentBlocker/AppTrackerDataSetProvider.swift @@ -22,8 +22,8 @@ import BrowserServicesKit final class AppTrackerDataSetProvider: EmbeddedDataProvider { public struct Constants { - public static let embeddedDataETag = "\"b879198dd7dcd9e5f1e5ce0a5d4ba782\"" - public static let embeddedDataSHA = "0006b1b27b94f5006ba2576f96dd8c7ab0e66943b5556458ea99002aeb80ac1a" + public static let embeddedDataETag = "\"c9e76dac56186b93f609cc9f1e8c2c0a\"" + public static let embeddedDataSHA = "5735d4459ecdec68b70d75a3effb5c1f8f8bc18af2d05c7fd2ecae4e1d530627" } var embeddedDataEtag: String { diff --git a/DuckDuckGo/ContentBlocker/macos-config.json b/DuckDuckGo/ContentBlocker/macos-config.json index 6ec6c5ecf4..bb6185d1b2 100644 --- a/DuckDuckGo/ContentBlocker/macos-config.json +++ b/DuckDuckGo/ContentBlocker/macos-config.json @@ -1,6 +1,6 @@ { "readme": "https://github.com/duckduckgo/privacy-configuration", - "version": 1703003631137, + "version": 1703251114570, "features": { "adClickAttribution": { "readme": "https://help.duckduckgo.com/duckduckgo-help-pages/privacy/web-tracking-protections/#3rd-party-tracker-loading-protection", @@ -62,6 +62,9 @@ }, "ampLinks": { "exceptions": [ + { + "domain": "asana.com" + }, { "domain": "freecodecamp.org" }, @@ -110,7 +113,7 @@ ] }, "state": "enabled", - "hash": "5bfae22c5e0cb13c0a25ce80531827ff" + "hash": "369b8aac2cb358e0df3a828c1e3b9a29" }, "androidBrowserConfig": { "exceptions": [], @@ -125,6 +128,9 @@ { "domain": "bild.de" }, + { + "domain": "breitbart.com" + }, { "domain": "derstandard.de" }, @@ -267,7 +273,7 @@ ] }, "state": "enabled", - "hash": "a1060783f1bf56f5cc661b994c9c9e56" + "hash": "2c63be0009057e4511dc6f6726251a47" }, "autofill": { "exceptions": [ @@ -955,6 +961,9 @@ { "domain": "ads.google.com" }, + { + "domain": "soranews24.com" + }, { "domain": "earth.google.com" }, @@ -968,7 +977,7 @@ "domain": "sundancecatalog.com" } ], - "hash": "71ad0f38f9ce2bbacae5ff7db05511ec" + "hash": "e37447d42ee8194f185e35e40f577f41" }, "cookie": { "settings": { @@ -1055,7 +1064,7 @@ "rollout": { "steps": [ { - "percent": 3 + "percent": 10 } ] } @@ -1063,7 +1072,7 @@ }, "exceptions": [], "minSupportedVersion": "1.68.0", - "hash": "784ab72b62adf8b5c07f656f167a28d2" + "hash": "04f130fe1e3e088c7859ef6aa4a64e5c" }, "duckPlayer": { "exceptions": [], @@ -1800,6 +1809,18 @@ } ] }, + { + "domain": "beverfood.com", + "rules": [ + { + "type": "disable-default" + }, + { + "selector": ".adsbygoogle", + "type": "override" + } + ] + }, { "domain": "bild.de", "rules": [ @@ -2502,6 +2523,32 @@ { "selector": ".connatix-player", "type": "closest-empty" + }, + { + "selector": ".ad-container", + "type": "hide" + }, + { + "selector": ".bottom-right-sticky-container", + "type": "hide" + }, + { + "selector": ".video-container", + "type": "hide" + }, + { + "selector": ".top-ad-recirc", + "type": "modify-style", + "values": [ + { + "property": "height", + "value": "auto" + }, + { + "property": "min-height", + "value": "auto" + } + ] } ] }, @@ -2870,11 +2917,14 @@ ] }, { - "domain": "orange.fr", + "domain": "oceanofcompressed.xyz", "rules": [ { - "selector": ".tag-rm", - "type": "hide-empty" + "type": "disable-default" + }, + { + "selector": "#sticky-ads", + "type": "hide" } ] }, @@ -2891,6 +2941,15 @@ } ] }, + { + "domain": "orange.fr", + "rules": [ + { + "selector": ".tag-rm", + "type": "hide-empty" + } + ] + }, { "domain": "ouest-france.fr", "rules": [ @@ -3201,6 +3260,14 @@ } ] }, + { + "domain": "soranews24.com", + "rules": [ + { + "type": "disable-default" + } + ] + }, { "domain": "spanishdict.com", "rules": [ @@ -3319,6 +3386,15 @@ } ] }, + { + "domain": "thewindowsclub.com", + "rules": [ + { + "selector": ".adtester-container", + "type": "hide" + } + ] + }, { "domain": "thewordfinder.com", "rules": [ @@ -3702,7 +3778,7 @@ ] }, "state": "enabled", - "hash": "8551081ffe228a4bef49deba3fe37b19" + "hash": "815cda75f82d989dd56864bc43f76574" }, "exceptionHandler": { "exceptions": [ @@ -4393,6 +4469,11 @@ "state": "disabled", "hash": "dede7e70939822f5ecb9eb5fae577fa3" }, + "privacyProtectionsPopup": { + "state": "disabled", + "exceptions": [], + "hash": "c292bb627849854515cebbded288ef5a" + }, "referrer": { "exceptions": [ { @@ -4488,9 +4569,23 @@ "hash": "5e792dd491428702bc0104240fbce0ce" }, "sync": { + "state": "internal", + "features": { + "level0ShowSync": { + "state": "enabled" + }, + "level1AllowDataSyncing": { + "state": "enabled" + }, + "level2AllowSetupFlows": { + "state": "enabled" + }, + "level3AllowCreateAccount": { + "state": "enabled" + } + }, "exceptions": [], - "state": "disabled", - "hash": "728493ef7a1488e4781656d3f9db84aa" + "hash": "4e4382e6a69f7cc99222fd924168e80f" }, "trackerAllowlist": { "state": "enabled", @@ -4754,6 +4849,12 @@ "wxii12.com", "wyff4.com" ] + }, + { + "rule": "z-na.amazon-adsystem.com/widgets/onejs", + "domains": [ + "oceanofcompressed.xyz" + ] } ] }, @@ -5008,6 +5109,12 @@ "domains": [ "" ] + }, + { + "rule": "cdnjs.cloudflare.com/ajax/libs/fingerprintjs2/", + "domains": [ + "" + ] } ] }, @@ -6098,7 +6205,13 @@ { "rule": "api.hubspot.com/livechat-public/v1/message/public", "domains": [ - "pippintitle.com" + "" + ] + }, + { + "rule": "js.hubspot.com/web-interactives-embed.js", + "domains": [ + "" ] }, { @@ -6145,7 +6258,7 @@ { "rule": "cmp.inmobi.com", "domains": [ - "express.co.uk" + "" ] } ] @@ -6407,6 +6520,16 @@ } ] }, + "media.net": { + "rules": [ + { + "rule": "contextual.media.net/dmedianet.js", + "domains": [ + "oceanofcompressed.xyz" + ] + } + ] + }, "mediavine.com": { "rules": [ { @@ -6865,7 +6988,8 @@ { "rule": "secure.quantserve.com/quant.js", "domains": [ - "aternos.org" + "aternos.org", + "oceanofcompressed.xyz" ] } ] @@ -7550,7 +7674,7 @@ "domain": "sundancecatalog.com" } ], - "hash": "b6996e16fe1785f6d75cd6302da1f48e" + "hash": "aef10e6a36c19d0d87312466ee852936" }, "trackingCookies1p": { "settings": { diff --git a/DuckDuckGo/ContentBlocker/trackerData.json b/DuckDuckGo/ContentBlocker/trackerData.json index fb357bcf7f..c45d8ec8f0 100644 --- a/DuckDuckGo/ContentBlocker/trackerData.json +++ b/DuckDuckGo/ContentBlocker/trackerData.json @@ -1,6 +1,6 @@ { "_builtWith": { - "tracker-radar": "0928b2f5aba106ff5ff034b6901b3a3a06fdd6a6b14ef08ef3054c10a79a03e3-4013b4e91930c643394cb31c6c745356f133b04f", + "tracker-radar": "8265857a1597afd1f526c5bcf722a92da57631bfeb37b2dc236d86f7e24046ba-4013b4e91930c643394cb31c6c745356f133b04f", "tracker-surrogates": "326805e7ed18b0bd2e176fc3d1095dfab3569995" }, "readme": "https://github.com/duckduckgo/tracker-blocklists", @@ -451,7 +451,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -462,7 +462,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -508,7 +508,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -2396,7 +2396,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -2407,7 +2407,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -2451,7 +2451,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -2462,7 +2462,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -2596,7 +2596,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -2735,7 +2735,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -2746,7 +2746,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -3065,7 +3065,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -3192,7 +3192,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -3316,7 +3316,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -3700,7 +3700,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -3711,7 +3711,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -3722,7 +3722,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -3733,7 +3733,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -3799,7 +3799,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -3851,7 +3851,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -3862,7 +3862,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -3999,7 +3999,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -4350,7 +4350,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -4385,7 +4385,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -4829,7 +4829,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -4840,7 +4840,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -5042,7 +5042,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -5084,7 +5084,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -5107,7 +5107,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -5142,7 +5142,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -5171,7 +5171,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -5459,7 +5459,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -5576,7 +5576,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -5587,7 +5587,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -5598,7 +5598,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -5609,7 +5609,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -5646,7 +5646,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -5701,7 +5701,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -6237,7 +6237,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -6396,7 +6396,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -6712,7 +6712,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -6723,7 +6723,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -6847,7 +6847,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -7043,7 +7043,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -7079,7 +7079,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -7090,7 +7090,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -7101,7 +7101,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -7684,7 +7684,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -7695,7 +7695,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -7706,7 +7706,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -7787,7 +7787,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -7910,7 +7910,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -7921,7 +7921,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -7999,7 +7999,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -8010,7 +8010,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -8125,7 +8125,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -8276,7 +8276,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -8287,7 +8287,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -8813,7 +8813,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -8824,7 +8824,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -8865,7 +8865,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -9281,7 +9281,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -9886,7 +9886,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -9897,7 +9897,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -9908,7 +9908,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -9948,7 +9948,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -9990,7 +9990,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -10064,7 +10064,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -10132,7 +10132,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -10143,7 +10143,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -10191,7 +10191,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -10317,7 +10317,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -10477,7 +10477,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -10488,7 +10488,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -10499,7 +10499,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -10526,7 +10526,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -10576,7 +10576,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -10623,7 +10623,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -11095,7 +11095,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -11135,7 +11135,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -11867,7 +11867,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -11954,7 +11954,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -11965,7 +11965,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -12192,7 +12192,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -12232,7 +12232,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -12243,7 +12243,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -12254,7 +12254,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -12265,7 +12265,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -12591,7 +12591,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -12602,7 +12602,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -12613,7 +12613,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -14522,7 +14522,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -15207,7 +15207,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -15282,7 +15282,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -15333,7 +15333,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -16190,7 +16190,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -16201,7 +16201,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -16229,7 +16229,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -16448,7 +16448,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -16757,7 +16757,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -16768,7 +16768,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -16953,7 +16953,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -17002,7 +17002,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -17276,7 +17276,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -17287,7 +17287,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -17850,7 +17850,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -18198,7 +18198,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -18337,7 +18337,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -18696,7 +18696,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -18932,7 +18932,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -20121,7 +20121,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -20342,7 +20342,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -20353,7 +20353,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -20364,7 +20364,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -20504,7 +20504,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -20991,7 +20991,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -21020,7 +21020,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -21031,7 +21031,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -21042,7 +21042,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -21106,7 +21106,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -21186,7 +21186,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -21234,7 +21234,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -21245,7 +21245,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -21285,7 +21285,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -21296,7 +21296,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -21331,7 +21331,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -21342,7 +21342,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -21475,7 +21475,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -21559,7 +21559,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22030,7 +22030,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22041,7 +22041,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22052,7 +22052,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22122,7 +22122,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22133,7 +22133,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22286,7 +22286,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22297,7 +22297,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22339,7 +22339,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22350,7 +22350,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22361,7 +22361,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22631,7 +22631,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22642,7 +22642,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22703,7 +22703,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22761,7 +22761,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22772,7 +22772,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22872,7 +22872,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22895,7 +22895,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -22906,7 +22906,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -23263,7 +23263,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -23367,7 +23367,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -23764,7 +23764,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -23839,7 +23839,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -23850,7 +23850,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -23894,7 +23894,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -23905,7 +23905,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -23916,7 +23916,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -23927,7 +23927,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -24007,7 +24007,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -24040,7 +24040,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -24091,7 +24091,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -24227,7 +24227,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -24356,7 +24356,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -24367,7 +24367,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -24614,7 +24614,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -24625,7 +24625,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -24636,7 +24636,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -24917,7 +24917,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -24952,7 +24952,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -24993,7 +24993,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25109,7 +25109,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25120,7 +25120,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25149,7 +25149,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25245,7 +25245,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25302,7 +25302,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25313,7 +25313,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25434,7 +25434,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25591,7 +25591,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25617,7 +25617,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25628,7 +25628,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25691,7 +25691,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25702,7 +25702,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25713,7 +25713,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25944,7 +25944,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25971,7 +25971,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25982,7 +25982,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -25993,7 +25993,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26023,7 +26023,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26034,7 +26034,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26062,7 +26062,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26073,7 +26073,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26161,7 +26161,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26209,7 +26209,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26220,7 +26220,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26231,7 +26231,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26242,7 +26242,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26253,7 +26253,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26264,7 +26264,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26336,7 +26336,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26347,7 +26347,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26401,7 +26401,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26603,7 +26603,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26911,7 +26911,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26922,7 +26922,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -26933,7 +26933,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -27184,7 +27184,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -27195,7 +27195,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -27613,7 +27613,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -28470,7 +28470,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -28600,7 +28600,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -28611,7 +28611,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -28744,7 +28744,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -28827,7 +28827,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -28838,7 +28838,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -29198,7 +29198,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -29505,7 +29505,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -29516,7 +29516,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -29642,7 +29642,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -29653,7 +29653,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -31395,7 +31395,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -31684,7 +31684,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32844,7 +32844,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32855,7 +32855,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32866,7 +32866,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32877,7 +32877,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32888,7 +32888,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32899,7 +32899,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32910,7 +32910,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32921,7 +32921,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32932,7 +32932,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32943,7 +32943,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "adorableattention.com": { + "domain": "adorableattention.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32954,7 +32965,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32965,7 +32976,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32976,7 +32987,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32987,7 +32998,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -32998,7 +33009,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33009,7 +33020,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33020,7 +33031,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33031,7 +33042,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33042,7 +33053,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33053,7 +33064,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33064,7 +33075,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33075,7 +33086,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33086,7 +33097,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33097,7 +33108,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33108,7 +33119,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33119,7 +33130,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33130,7 +33141,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "axiomaticanger.com": { + "domain": "axiomaticanger.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33141,7 +33163,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33152,7 +33174,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33163,7 +33185,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33174,7 +33196,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33185,7 +33207,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33196,7 +33218,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33207,7 +33229,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33218,7 +33240,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33229,7 +33251,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33240,7 +33262,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33251,7 +33273,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33262,7 +33284,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33273,7 +33295,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33284,7 +33306,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33295,7 +33317,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "brainybasin.com": { + "domain": "brainybasin.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33306,7 +33339,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "branchborder.com": { + "domain": "branchborder.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33317,7 +33361,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33328,7 +33372,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33339,7 +33383,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33350,7 +33394,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33361,7 +33405,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33372,7 +33416,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33383,7 +33427,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33394,7 +33438,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33405,7 +33449,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33416,7 +33460,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33427,7 +33471,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33438,7 +33482,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33449,7 +33493,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33460,7 +33504,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33471,7 +33515,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33482,7 +33526,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33493,7 +33537,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33504,7 +33548,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33515,7 +33559,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33526,7 +33570,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33537,7 +33581,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33548,7 +33592,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33559,7 +33603,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33570,7 +33614,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33581,7 +33625,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33592,7 +33636,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33603,7 +33647,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33614,7 +33658,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33625,7 +33669,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33636,7 +33680,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33647,7 +33691,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33658,7 +33702,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33669,7 +33713,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33680,7 +33724,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33691,7 +33735,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33702,7 +33746,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33713,7 +33757,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33724,7 +33768,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33735,7 +33779,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33746,7 +33790,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "concernedchange.com": { + "domain": "concernedchange.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33757,7 +33812,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33768,7 +33823,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33779,7 +33834,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33790,7 +33845,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33801,7 +33856,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33812,7 +33867,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33823,7 +33878,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33834,7 +33889,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33845,7 +33900,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33856,7 +33911,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33867,7 +33922,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33878,7 +33933,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33889,7 +33944,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33900,7 +33955,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33911,7 +33966,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33922,7 +33977,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33933,7 +33988,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33944,7 +33999,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33955,7 +34010,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33966,7 +34021,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33977,7 +34032,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33988,7 +34043,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -33999,7 +34054,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34010,7 +34065,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34021,7 +34076,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34032,7 +34087,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34043,7 +34098,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34054,7 +34109,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34065,7 +34120,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34076,7 +34131,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34087,7 +34142,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "dewdroplagoon.com": { + "domain": "dewdroplagoon.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34098,7 +34164,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34109,7 +34175,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34120,7 +34186,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34131,7 +34197,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34142,7 +34208,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34153,7 +34219,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34164,7 +34230,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34175,7 +34241,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34186,7 +34252,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34197,7 +34263,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34208,7 +34274,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34219,7 +34285,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34230,7 +34296,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34241,7 +34307,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34252,7 +34318,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34263,7 +34329,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34274,7 +34340,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34285,7 +34351,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34296,7 +34362,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34307,7 +34373,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34318,7 +34384,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34329,7 +34395,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34340,7 +34406,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "fairiesbranch.com": { + "domain": "fairiesbranch.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34351,7 +34428,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34362,7 +34439,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34373,7 +34450,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34384,7 +34461,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34395,7 +34472,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34406,7 +34483,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34417,7 +34494,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34428,7 +34505,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34439,7 +34516,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34450,7 +34527,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34461,7 +34538,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34472,7 +34549,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34483,7 +34560,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34494,7 +34571,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34505,7 +34582,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34516,7 +34593,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34527,7 +34604,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34538,7 +34615,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34549,7 +34626,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34560,7 +34637,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34571,7 +34648,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34582,7 +34659,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34593,7 +34670,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34604,7 +34681,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34615,7 +34692,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34626,7 +34703,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34637,7 +34714,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34648,7 +34725,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34659,7 +34736,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34670,7 +34747,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34681,7 +34758,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34692,7 +34769,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34703,7 +34780,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34714,7 +34791,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34725,7 +34802,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34736,7 +34813,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34747,7 +34824,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34758,7 +34835,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34769,7 +34846,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34780,7 +34857,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34791,7 +34868,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34802,7 +34879,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34813,7 +34890,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34824,7 +34901,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34835,7 +34912,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "handyincrease.com": { + "domain": "handyincrease.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34846,7 +34934,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34857,7 +34945,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34868,7 +34956,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34879,7 +34967,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34890,7 +34978,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34901,7 +34989,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34912,7 +35000,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34923,7 +35011,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34934,7 +35022,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34945,7 +35033,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34956,7 +35044,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34967,7 +35055,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34978,7 +35066,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -34989,7 +35077,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35000,7 +35088,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35011,7 +35099,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35022,7 +35110,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35033,7 +35121,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35044,7 +35132,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35055,7 +35143,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35066,7 +35154,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35077,7 +35165,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35088,7 +35176,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35099,7 +35187,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35110,7 +35198,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35121,7 +35209,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35132,7 +35220,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35143,7 +35231,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35154,7 +35242,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35165,7 +35253,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35176,7 +35264,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35187,7 +35275,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35198,7 +35286,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35209,7 +35297,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35220,7 +35308,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35231,7 +35319,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35242,7 +35330,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35253,7 +35341,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35264,7 +35352,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35275,7 +35363,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35286,7 +35374,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35297,7 +35385,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35308,7 +35396,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35319,7 +35407,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "memopilot.com": { + "domain": "memopilot.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35330,7 +35429,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35341,7 +35440,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35352,7 +35451,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35363,7 +35462,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35374,7 +35473,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35385,7 +35484,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35396,7 +35495,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35407,7 +35506,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35418,7 +35517,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35429,7 +35528,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35440,7 +35539,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "naivestatement.com": { + "domain": "naivestatement.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35451,7 +35561,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "nebulousamusement.com": { + "domain": "nebulousamusement.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35462,7 +35583,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35473,7 +35594,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35484,7 +35605,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35495,7 +35616,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35506,7 +35627,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35517,7 +35638,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35528,7 +35649,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35539,7 +35660,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35550,7 +35671,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35561,7 +35682,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35572,7 +35693,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35583,7 +35704,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35594,7 +35715,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35605,7 +35726,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35616,7 +35737,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35627,7 +35748,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35638,7 +35759,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35649,7 +35770,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35660,7 +35781,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35671,7 +35792,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35682,7 +35803,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "pointdigestion.com": { + "domain": "pointdigestion.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35693,7 +35825,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35704,7 +35836,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35715,7 +35847,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35726,7 +35858,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "pricklypollution.com": { + "domain": "pricklypollution.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35737,7 +35880,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35748,7 +35891,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35759,7 +35902,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35770,7 +35913,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35781,7 +35924,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35792,7 +35935,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35803,7 +35946,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35814,7 +35957,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35825,7 +35968,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35836,7 +35979,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35847,7 +35990,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35858,7 +36001,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35869,7 +36012,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35880,7 +36023,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35891,7 +36034,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35902,7 +36045,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35913,7 +36056,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35924,7 +36067,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35935,7 +36078,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35946,7 +36089,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35957,7 +36100,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35968,7 +36111,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35979,7 +36122,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -35990,7 +36133,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36001,7 +36144,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36012,7 +36155,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36023,7 +36166,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36034,7 +36177,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36045,7 +36188,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36056,7 +36199,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36067,7 +36210,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36078,7 +36221,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36089,7 +36232,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36100,7 +36243,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36111,7 +36254,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "scarceshock.com": { + "domain": "scarceshock.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36122,7 +36276,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36133,7 +36287,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36144,7 +36298,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36155,7 +36309,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36166,7 +36320,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36177,7 +36331,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36188,7 +36342,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36199,7 +36353,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36210,7 +36364,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36221,7 +36375,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36232,7 +36386,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36243,7 +36397,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36254,7 +36408,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36265,7 +36419,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36276,7 +36430,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36287,7 +36441,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "shadeship.com": { + "domain": "shadeship.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36298,7 +36463,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36309,7 +36474,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36320,7 +36485,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36331,7 +36496,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36342,7 +36507,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36353,7 +36518,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36364,7 +36529,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36375,7 +36540,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36386,7 +36551,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36397,7 +36562,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36408,7 +36573,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36419,7 +36584,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36430,7 +36595,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36441,7 +36606,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36452,7 +36617,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36463,7 +36628,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36474,7 +36639,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36485,7 +36650,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36496,7 +36661,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36507,7 +36672,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36518,7 +36683,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36529,7 +36694,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36540,7 +36705,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36551,7 +36716,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36562,7 +36727,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36573,7 +36738,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36584,7 +36749,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36595,7 +36760,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36606,7 +36771,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36617,7 +36782,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36628,7 +36793,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36639,7 +36804,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36650,7 +36815,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36661,7 +36826,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36672,7 +36837,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36683,7 +36848,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36694,7 +36859,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36705,7 +36870,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36716,7 +36881,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36727,7 +36892,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36738,7 +36903,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36749,7 +36914,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "stopstomach.com": { + "domain": "stopstomach.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36760,7 +36936,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36771,7 +36947,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36782,7 +36958,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36793,7 +36969,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36804,7 +36980,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36815,7 +36991,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36826,7 +37002,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36837,7 +37013,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36848,7 +37024,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36859,7 +37035,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36870,7 +37046,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36881,7 +37057,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36892,7 +37068,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36903,7 +37079,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36914,7 +37090,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36925,7 +37101,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36936,7 +37112,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36947,7 +37123,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36958,7 +37134,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36969,7 +37145,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36980,7 +37156,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -36991,7 +37167,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37002,7 +37178,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37013,7 +37189,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37024,7 +37200,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37035,7 +37211,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37046,7 +37222,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37057,7 +37233,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37068,7 +37244,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37079,7 +37255,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37090,7 +37266,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37101,7 +37277,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37112,7 +37288,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37123,7 +37299,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37134,7 +37310,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37145,7 +37321,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37156,7 +37332,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37167,7 +37343,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "tritethunder.com": { + "domain": "tritethunder.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37178,7 +37365,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37189,7 +37376,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37200,7 +37387,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37211,7 +37398,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37222,7 +37409,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37233,7 +37420,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37244,7 +37431,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37255,7 +37442,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37266,7 +37453,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37277,7 +37464,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37288,7 +37475,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37299,7 +37486,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37310,7 +37497,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37321,7 +37508,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37332,7 +37519,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37343,7 +37530,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37354,7 +37541,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37365,7 +37552,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37376,7 +37563,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37387,7 +37574,18 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, + "fingerprinting": 1, + "cookies": 0.01, + "default": "block" + }, + "volatileprofit.com": { + "domain": "volatileprofit.com", + "owner": { + "name": "Leven Labs, Inc. DBA Admiral", + "displayName": "Admiral" + }, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37398,7 +37596,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37409,7 +37607,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37420,7 +37618,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37431,7 +37629,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37442,7 +37640,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37453,7 +37651,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37464,7 +37662,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37475,7 +37673,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37486,7 +37684,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37497,7 +37695,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -37508,7 +37706,7 @@ "name": "Leven Labs, Inc. DBA Admiral", "displayName": "Admiral" }, - "prevalence": 0.0145, + "prevalence": 0.0142, "fingerprinting": 1, "cookies": 0.01, "default": "block" @@ -47542,6 +47740,7 @@ "actuallything.com", "adamantsnail.com", "adorableanger.com", + "adorableattention.com", "adventurousamount.com", "agreeablearch.com", "agreeabletouch.com", @@ -47569,6 +47768,7 @@ "awarealley.com", "awesomeagreement.com", "awzbijw.com", + "axiomaticanger.com", "badgeboat.com", "badgevolcano.com", "bagbeam.com", @@ -47594,7 +47794,9 @@ "boredcrown.com", "boundarybusiness.com", "boundlessveil.com", + "brainybasin.com", "brainynut.com", + "branchborder.com", "bravecalculator.com", "breadbalance.com", "breakfastboat.com", @@ -47650,6 +47852,7 @@ "combcattle.com", "combcompetition.com", "comfortablecheese.com", + "concernedchange.com", "concernedchickens.com", "condemnedcomb.com", "conditioncrush.com", @@ -47695,6 +47898,7 @@ "detailedkitten.com", "detectdiscovery.com", "devilishdinner.com", + "dewdroplagoon.com", "digestiondrawer.com", "diplomahawaii.com", "disagreeabledrop.com", @@ -47728,6 +47932,7 @@ "exuberantedge.com", "fadedsnow.com", "fadewaves.com", + "fairiesbranch.com", "fallaciousfifth.com", "fancyactivity.com", "farmergoldfish.com", @@ -47797,6 +48002,7 @@ "handsomelyhealth.com", "handyfield.com", "handyfireman.com", + "handyincrease.com", "haplessland.com", "harborcaption.com", "hatefulrequest.com", @@ -47857,6 +48063,7 @@ "meatydime.com", "meddleplant.com", "meltmilk.com", + "memopilot.com", "memorizematch.com", "memorizeneck.com", "mentorsticks.com", @@ -47871,8 +48078,10 @@ "movemeal.com", "mundanenail.com", "muteknife.com", + "naivestatement.com", "nappyattack.com", "neatshade.com", + "nebulousamusement.com", "needlessnorth.com", "nervoussummer.com", "nightwound.com", @@ -47908,6 +48117,7 @@ "pluckypocket.com", "pocketfaucet.com", "poeticpackage.com", + "pointdigestion.com", "pointlesspocket.com", "politeplanes.com", "politicalporter.com", @@ -47919,6 +48129,7 @@ "prepareplanes.com", "priceypies.com", "pricklydebt.com", + "pricklypollution.com", "processplantation.com", "profusesupport.com", "protestcopy.com", @@ -47979,6 +48190,7 @@ "samplesamba.com", "satisfycork.com", "savoryorange.com", + "scarceshock.com", "scaredcomfort.com", "scaredsnake.com", "scaredsnakes.com", @@ -48005,6 +48217,7 @@ "serioussuit.com", "serpentshampoo.com", "settleshoes.com", + "shadeship.com", "shakegoldfish.com", "shakyseat.com", "shakysurprise.com", @@ -48074,6 +48287,7 @@ "stingyspoon.com", "stockingsleet.com", "stomachscience.com", + "stopstomach.com", "straightnest.com", "strangeclocks.com", "strangersponge.com", @@ -48129,6 +48343,7 @@ "tremendousearthquake.com", "tremendousplastic.com", "tritebadge.com", + "tritethunder.com", "troubledtail.com", "truculentrate.com", "tumbleicicle.com", @@ -48156,6 +48371,7 @@ "verseballs.com", "vibranthaven.com", "virtualvincent.com", + "volatileprofit.com", "volatilevessel.com", "voraciousgrip.com", "wantingwindow.com", @@ -48173,7 +48389,7 @@ "zipperxray.com", "zlp6s.pw" ], - "prevalence": 0.0145, + "prevalence": 0.0142, "displayName": "Admiral" } }, @@ -48905,6 +49121,7 @@ "actuallything.com": "Leven Labs, Inc. DBA Admiral", "adamantsnail.com": "Leven Labs, Inc. DBA Admiral", "adorableanger.com": "Leven Labs, Inc. DBA Admiral", + "adorableattention.com": "Leven Labs, Inc. DBA Admiral", "adventurousamount.com": "Leven Labs, Inc. DBA Admiral", "agreeablearch.com": "Leven Labs, Inc. DBA Admiral", "agreeabletouch.com": "Leven Labs, Inc. DBA Admiral", @@ -48932,6 +49149,7 @@ "awarealley.com": "Leven Labs, Inc. DBA Admiral", "awesomeagreement.com": "Leven Labs, Inc. DBA Admiral", "awzbijw.com": "Leven Labs, Inc. DBA Admiral", + "axiomaticanger.com": "Leven Labs, Inc. DBA Admiral", "badgeboat.com": "Leven Labs, Inc. DBA Admiral", "badgevolcano.com": "Leven Labs, Inc. DBA Admiral", "bagbeam.com": "Leven Labs, Inc. DBA Admiral", @@ -48957,7 +49175,9 @@ "boredcrown.com": "Leven Labs, Inc. DBA Admiral", "boundarybusiness.com": "Leven Labs, Inc. DBA Admiral", "boundlessveil.com": "Leven Labs, Inc. DBA Admiral", + "brainybasin.com": "Leven Labs, Inc. DBA Admiral", "brainynut.com": "Leven Labs, Inc. DBA Admiral", + "branchborder.com": "Leven Labs, Inc. DBA Admiral", "bravecalculator.com": "Leven Labs, Inc. DBA Admiral", "breadbalance.com": "Leven Labs, Inc. DBA Admiral", "breakfastboat.com": "Leven Labs, Inc. DBA Admiral", @@ -49013,6 +49233,7 @@ "combcattle.com": "Leven Labs, Inc. DBA Admiral", "combcompetition.com": "Leven Labs, Inc. DBA Admiral", "comfortablecheese.com": "Leven Labs, Inc. DBA Admiral", + "concernedchange.com": "Leven Labs, Inc. DBA Admiral", "concernedchickens.com": "Leven Labs, Inc. DBA Admiral", "condemnedcomb.com": "Leven Labs, Inc. DBA Admiral", "conditioncrush.com": "Leven Labs, Inc. DBA Admiral", @@ -49058,6 +49279,7 @@ "detailedkitten.com": "Leven Labs, Inc. DBA Admiral", "detectdiscovery.com": "Leven Labs, Inc. DBA Admiral", "devilishdinner.com": "Leven Labs, Inc. DBA Admiral", + "dewdroplagoon.com": "Leven Labs, Inc. DBA Admiral", "digestiondrawer.com": "Leven Labs, Inc. DBA Admiral", "diplomahawaii.com": "Leven Labs, Inc. DBA Admiral", "disagreeabledrop.com": "Leven Labs, Inc. DBA Admiral", @@ -49091,6 +49313,7 @@ "exuberantedge.com": "Leven Labs, Inc. DBA Admiral", "fadedsnow.com": "Leven Labs, Inc. DBA Admiral", "fadewaves.com": "Leven Labs, Inc. DBA Admiral", + "fairiesbranch.com": "Leven Labs, Inc. DBA Admiral", "fallaciousfifth.com": "Leven Labs, Inc. DBA Admiral", "fancyactivity.com": "Leven Labs, Inc. DBA Admiral", "farmergoldfish.com": "Leven Labs, Inc. DBA Admiral", @@ -49160,6 +49383,7 @@ "handsomelyhealth.com": "Leven Labs, Inc. DBA Admiral", "handyfield.com": "Leven Labs, Inc. DBA Admiral", "handyfireman.com": "Leven Labs, Inc. DBA Admiral", + "handyincrease.com": "Leven Labs, Inc. DBA Admiral", "haplessland.com": "Leven Labs, Inc. DBA Admiral", "harborcaption.com": "Leven Labs, Inc. DBA Admiral", "hatefulrequest.com": "Leven Labs, Inc. DBA Admiral", @@ -49220,6 +49444,7 @@ "meatydime.com": "Leven Labs, Inc. DBA Admiral", "meddleplant.com": "Leven Labs, Inc. DBA Admiral", "meltmilk.com": "Leven Labs, Inc. DBA Admiral", + "memopilot.com": "Leven Labs, Inc. DBA Admiral", "memorizematch.com": "Leven Labs, Inc. DBA Admiral", "memorizeneck.com": "Leven Labs, Inc. DBA Admiral", "mentorsticks.com": "Leven Labs, Inc. DBA Admiral", @@ -49234,8 +49459,10 @@ "movemeal.com": "Leven Labs, Inc. DBA Admiral", "mundanenail.com": "Leven Labs, Inc. DBA Admiral", "muteknife.com": "Leven Labs, Inc. DBA Admiral", + "naivestatement.com": "Leven Labs, Inc. DBA Admiral", "nappyattack.com": "Leven Labs, Inc. DBA Admiral", "neatshade.com": "Leven Labs, Inc. DBA Admiral", + "nebulousamusement.com": "Leven Labs, Inc. DBA Admiral", "needlessnorth.com": "Leven Labs, Inc. DBA Admiral", "nervoussummer.com": "Leven Labs, Inc. DBA Admiral", "nightwound.com": "Leven Labs, Inc. DBA Admiral", @@ -49271,6 +49498,7 @@ "pluckypocket.com": "Leven Labs, Inc. DBA Admiral", "pocketfaucet.com": "Leven Labs, Inc. DBA Admiral", "poeticpackage.com": "Leven Labs, Inc. DBA Admiral", + "pointdigestion.com": "Leven Labs, Inc. DBA Admiral", "pointlesspocket.com": "Leven Labs, Inc. DBA Admiral", "politeplanes.com": "Leven Labs, Inc. DBA Admiral", "politicalporter.com": "Leven Labs, Inc. DBA Admiral", @@ -49282,6 +49510,7 @@ "prepareplanes.com": "Leven Labs, Inc. DBA Admiral", "priceypies.com": "Leven Labs, Inc. DBA Admiral", "pricklydebt.com": "Leven Labs, Inc. DBA Admiral", + "pricklypollution.com": "Leven Labs, Inc. DBA Admiral", "processplantation.com": "Leven Labs, Inc. DBA Admiral", "profusesupport.com": "Leven Labs, Inc. DBA Admiral", "protestcopy.com": "Leven Labs, Inc. DBA Admiral", @@ -49342,6 +49571,7 @@ "samplesamba.com": "Leven Labs, Inc. DBA Admiral", "satisfycork.com": "Leven Labs, Inc. DBA Admiral", "savoryorange.com": "Leven Labs, Inc. DBA Admiral", + "scarceshock.com": "Leven Labs, Inc. DBA Admiral", "scaredcomfort.com": "Leven Labs, Inc. DBA Admiral", "scaredsnake.com": "Leven Labs, Inc. DBA Admiral", "scaredsnakes.com": "Leven Labs, Inc. DBA Admiral", @@ -49368,6 +49598,7 @@ "serioussuit.com": "Leven Labs, Inc. DBA Admiral", "serpentshampoo.com": "Leven Labs, Inc. DBA Admiral", "settleshoes.com": "Leven Labs, Inc. DBA Admiral", + "shadeship.com": "Leven Labs, Inc. DBA Admiral", "shakegoldfish.com": "Leven Labs, Inc. DBA Admiral", "shakyseat.com": "Leven Labs, Inc. DBA Admiral", "shakysurprise.com": "Leven Labs, Inc. DBA Admiral", @@ -49437,6 +49668,7 @@ "stingyspoon.com": "Leven Labs, Inc. DBA Admiral", "stockingsleet.com": "Leven Labs, Inc. DBA Admiral", "stomachscience.com": "Leven Labs, Inc. DBA Admiral", + "stopstomach.com": "Leven Labs, Inc. DBA Admiral", "straightnest.com": "Leven Labs, Inc. DBA Admiral", "strangeclocks.com": "Leven Labs, Inc. DBA Admiral", "strangersponge.com": "Leven Labs, Inc. DBA Admiral", @@ -49492,6 +49724,7 @@ "tremendousearthquake.com": "Leven Labs, Inc. DBA Admiral", "tremendousplastic.com": "Leven Labs, Inc. DBA Admiral", "tritebadge.com": "Leven Labs, Inc. DBA Admiral", + "tritethunder.com": "Leven Labs, Inc. DBA Admiral", "troubledtail.com": "Leven Labs, Inc. DBA Admiral", "truculentrate.com": "Leven Labs, Inc. DBA Admiral", "tumbleicicle.com": "Leven Labs, Inc. DBA Admiral", @@ -49519,6 +49752,7 @@ "verseballs.com": "Leven Labs, Inc. DBA Admiral", "vibranthaven.com": "Leven Labs, Inc. DBA Admiral", "virtualvincent.com": "Leven Labs, Inc. DBA Admiral", + "volatileprofit.com": "Leven Labs, Inc. DBA Admiral", "volatilevessel.com": "Leven Labs, Inc. DBA Admiral", "voraciousgrip.com": "Leven Labs, Inc. DBA Admiral", "wantingwindow.com": "Leven Labs, Inc. DBA Admiral", diff --git a/DuckDuckGo/DBP/DataBrokerProtectionSubscriptionEventHandler.swift b/DuckDuckGo/DBP/DataBrokerProtectionSubscriptionEventHandler.swift index 783c900d12..d95e292334 100644 --- a/DuckDuckGo/DBP/DataBrokerProtectionSubscriptionEventHandler.swift +++ b/DuckDuckGo/DBP/DataBrokerProtectionSubscriptionEventHandler.swift @@ -18,15 +18,15 @@ #if DBP && SUBSCRIPTION import Foundation -import Account +import Subscription import DataBrokerProtection final class DataBrokerProtectionSubscriptionEventHandler { - private let accountManager: Account.AccountManaging + private let accountManager: Subscription.AccountManaging private let authRepository: AuthenticationRepository - init(accountManager: Account.AccountManaging = Account.AccountManager(), + init(accountManager: Subscription.AccountManaging = Subscription.AccountManager(), authRepository: AuthenticationRepository = KeychainAuthenticationData()) { self.accountManager = accountManager self.authRepository = authRepository @@ -38,7 +38,7 @@ final class DataBrokerProtectionSubscriptionEventHandler { } @objc private func handleAccountDidSignIn() { - guard let token = accountManager.token else { + guard let token = accountManager.accessToken else { Pixel.fire(.dataBrokerProtectionErrorWhenFetchingSubscriptionAuthTokenAfterSignIn) assertionFailure("[DBP Subscription] AccountManager signed in but token could not be retrieved") return diff --git a/DuckDuckGo/DataImport/Bookmarks/BookmarkImport.swift b/DuckDuckGo/DataImport/Bookmarks/BookmarkImport.swift index 3a9f22d656..94f942dfd3 100644 --- a/DuckDuckGo/DataImport/Bookmarks/BookmarkImport.swift +++ b/DuckDuckGo/DataImport/Bookmarks/BookmarkImport.swift @@ -32,6 +32,6 @@ enum BookmarkImportSource: Equatable { protocol BookmarkImporter { - func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarkImportResult + func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarksImportSummary } diff --git a/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumBookmarksReader.swift b/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumBookmarksReader.swift index 3c1179f1a6..712be38829 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumBookmarksReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumBookmarksReader.swift @@ -31,20 +31,21 @@ final class ChromiumBookmarksReader { } var action: DataImportAction { .bookmarks } - let source: DataImport.Source let type: OperationType let underlyingError: Error? - } - func importError(type: ImportError.OperationType, underlyingError: Error) -> ImportError { - ImportError(source: source, type: type, underlyingError: underlyingError) + + var errorType: DataImport.ErrorType { + switch type { + case .fileRead: .noData + case .decodeJson: .dataCorrupted + } + } } private let chromiumBookmarksFileURL: URL - private let source: DataImport.Source - init(chromiumDataDirectoryURL: URL, source: DataImport.Source, bookmarksFileName: String = Constants.defaultBookmarksFileName) { + init(chromiumDataDirectoryURL: URL, bookmarksFileName: String = Constants.defaultBookmarksFileName) { self.chromiumBookmarksFileURL = chromiumDataDirectoryURL.appendingPathComponent(bookmarksFileName) - self.source = source } func readBookmarks() -> DataImportResult { @@ -55,7 +56,7 @@ final class ChromiumBookmarksReader { let decodedBookmarks = try JSONDecoder().decode(ImportedBookmarks.self, from: bookmarksFileData) return .success(decodedBookmarks) } catch { - return .failure(importError(type: currentOperationType, underlyingError: error)) + return .failure(ImportError(type: currentOperationType, underlyingError: error)) } } diff --git a/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumFaviconsReader.swift b/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumFaviconsReader.swift index c1d485bcdf..2922936cae 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumFaviconsReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Chromium/ChromiumFaviconsReader.swift @@ -33,12 +33,13 @@ final class ChromiumFaviconsReader { } var action: DataImportAction { .favicons } - let source: DataImport.Source let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { .other } } func importError(type: ImportError.OperationType, underlyingError: Error) -> ImportError { - ImportError(source: source, type: type, underlyingError: underlyingError) + ImportError(type: type, underlyingError: underlyingError) } final class ChromiumFavicon: FetchableRecord { @@ -61,11 +62,9 @@ final class ChromiumFaviconsReader { private let chromiumFaviconsDatabaseURL: URL private var currentOperationType: ImportError.OperationType = .copyTemporaryFile - private let source: DataImport.Source - init(chromiumDataDirectoryURL: URL, source: DataImport.Source) { + init(chromiumDataDirectoryURL: URL) { self.chromiumFaviconsDatabaseURL = chromiumDataDirectoryURL.appendingPathComponent(Constants.faviconsDatabaseName) - self.source = source } func readFavicons() -> DataImportResult<[String: [ChromiumFavicon]]> { diff --git a/DuckDuckGo/DataImport/Bookmarks/CoreDataBookmarkImporter.swift b/DuckDuckGo/DataImport/Bookmarks/CoreDataBookmarkImporter.swift index 618d7a66e0..abe2f82a42 100644 --- a/DuckDuckGo/DataImport/Bookmarks/CoreDataBookmarkImporter.swift +++ b/DuckDuckGo/DataImport/Bookmarks/CoreDataBookmarkImporter.swift @@ -26,7 +26,7 @@ final class CoreDataBookmarkImporter: BookmarkImporter { self.bookmarkManager = bookmarkManager } - func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarkImportResult { + func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarksImportSummary { return bookmarkManager.importBookmarks(bookmarks, source: source) } diff --git a/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxBookmarksReader.swift b/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxBookmarksReader.swift index 7b5bdbda68..3cb4c8e2a1 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxBookmarksReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxBookmarksReader.swift @@ -47,9 +47,16 @@ final class FirefoxBookmarksReader { } var action: DataImportAction { .bookmarks } - var source: DataImport.Source { .firefox } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { + switch type { + case .dbOpen: .noData + case .fetchRootEntries, .noRootEntries, .fetchTopLevelFolders, .fetchAllBookmarks, .fetchAllFolders: .dataCorrupted + case .copyTemporaryFile: .other + } + } } private let firefoxPlacesDatabaseURL: URL diff --git a/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxFaviconsReader.swift b/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxFaviconsReader.swift index c0d6cbc032..442d4ea913 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxFaviconsReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Firefox/FirefoxFaviconsReader.swift @@ -33,9 +33,10 @@ final class FirefoxFaviconsReader { } var action: DataImportAction { .favicons } - var source: DataImport.Source { .firefox } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { .other } } final class FirefoxFavicon: FetchableRecord { diff --git a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLImporter.swift b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLImporter.swift index 857569f497..3f95352d4e 100644 --- a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLImporter.swift +++ b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLImporter.swift @@ -32,29 +32,25 @@ final class BookmarkHTMLImporter: DataImporter { (try? bookmarkReaderResult.get().bookmarks.numberOfBookmarks) ?? 0 } - func importableTypes() -> [DataImport.DataType] { - [.bookmarks] + var importableTypes: [DataImport.DataType] { + return [.bookmarks] } - func importData( - types: [DataImport.DataType], - from profile: DataImport.BrowserProfile?, - modalWindow: NSWindow?, - completion: @escaping (DataImportResult) -> Void - ) { - DispatchQueue.global(qos: .userInitiated).async { - switch self.bookmarkReaderResult { - case let .success(importedData): - let source: BookmarkImportSource = importedData.source ?? .thirdPartyBrowser(.bookmarksHTML) - let bookmarksResult = self.bookmarkImporter.importBookmarks(importedData.bookmarks, source: source) - DispatchQueue.main.async { - completion(.success(DataImport.Summary(bookmarksResult: bookmarksResult))) - } - case let .failure(error): - DispatchQueue.main.async { - completion(.failure(error)) - } - } + func importData(types: Set) -> DataImportTask { + assert(types.contains(.bookmarks)) + return .detachedWithProgress { updateProgress in + [.bookmarks: self.importDataSync(types: types, updateProgress: updateProgress)] + } + } + private func importDataSync(types: Set, updateProgress: DataImportProgressCallback) -> DataImportResult { + switch self.bookmarkReaderResult { + case let .success(importedData): + let source: BookmarkImportSource = importedData.source ?? .thirdPartyBrowser(.bookmarksHTML) + let bookmarksResult = self.bookmarkImporter.importBookmarks(importedData.bookmarks, source: source) + return .success(.init(bookmarksResult)) + + case let .failure(error): + return .failure(error) } } diff --git a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift index 31dbf918f7..a764187011 100644 --- a/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/HTML/BookmarkHTMLReader.swift @@ -37,9 +37,10 @@ final class BookmarkHTMLReader { } var action: DataImportAction { .bookmarks } - var source: DataImport.Source { .bookmarksHTML } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { .dataCorrupted } } private var currentOperationType: ImportError.OperationType = .parseXml @@ -275,8 +276,16 @@ private extension BookmarkImportSource { case .thirdPartyBrowser(.brave), .thirdPartyBrowser(.chrome), + .thirdPartyBrowser(.chromium), + .thirdPartyBrowser(.coccoc), .thirdPartyBrowser(.edge), .thirdPartyBrowser(.firefox), + .thirdPartyBrowser(.opera), + .thirdPartyBrowser(.operaGX), + .thirdPartyBrowser(.tor), + .thirdPartyBrowser(.vivaldi), + .thirdPartyBrowser(.yandex), + .thirdPartyBrowser(.bitwarden), .thirdPartyBrowser(.onePassword8), .thirdPartyBrowser(.onePassword7), .thirdPartyBrowser(.lastPass), diff --git a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift index c1a0e7e9ac..747fdffa37 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariBookmarksReader.swift @@ -41,12 +41,15 @@ final class SafariBookmarksReader { case readPlist case getTopLevelEntries + case getChildren + case entryNotDict } var action: DataImportAction { .bookmarks } - var source: DataImport.Source { .safari } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { .dataCorrupted } } private let safariBookmarksFileURL: URL @@ -71,17 +74,30 @@ final class SafariBookmarksReader { } } + func validateFileReadAccess() -> DataImportResult { + if !FileManager.default.isReadableFile(atPath: safariBookmarksFileURL.path) { + do { + try _=Data(contentsOf: safariBookmarksFileURL) + } catch { + return .failure(ImportError(type: .readPlist, underlyingError: error)) + } + } + return .success( () ) + } + private func reallyReadBookmarks() throws -> ImportedBookmarks { currentOperationType = .readPlist let plistData = try readPropertyList() - guard let topLevelEntries = plistData[Constants.bookmarkChildrenKey] as? [[String: AnyObject]] else { throw ImportError(type: .getTopLevelEntries, underlyingError: nil) } + guard let children = plistData[Constants.bookmarkChildrenKey] else { throw ImportError(type: .getChildren, underlyingError: nil) } + guard let topLevelEntries = children as? [Any] else { throw ImportError(type: .getTopLevelEntries, underlyingError: nil) } var bookmarksBar: ImportedBookmarks.BookmarkOrFolder? var otherBookmarks: [ImportedBookmarks.BookmarkOrFolder] = [] - for entry in topLevelEntries - where ((entry[Constants.typeKey] as? String) == Constants.listType) || ((entry[Constants.typeKey] as? String) == Constants.leafType) { + for entry in topLevelEntries { + guard let entry = entry as? [String: AnyObject] else { throw ImportError(type: .entryNotDict, underlyingError: nil) } + guard (entry[Constants.typeKey] as? String) == Constants.listType || (entry[Constants.typeKey] as? String) == Constants.leafType else { continue } if let title = entry[Constants.titleKey] as? String, title == Constants.readingListKey { continue diff --git a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift index 2f2c60eb75..5da0fbb2d0 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariDataImporter.swift @@ -16,22 +16,17 @@ // limitations under the License. // +import AppKit import Foundation -protocol DataDirectoryPermissionAuthorization { - func canReadBookmarksFile() -> Bool - func requestDataDirectoryPermission() -> URL? -} - -final class SafariDataImporter: DataImporter, DataDirectoryPermissionAuthorization { - - func canReadBookmarksFile() -> Bool { - return FileManager.default.isReadableFile(atPath: safariDataDirectoryUrl.path) - } +final class SafariDataImporter: DataImporter { - func requestDataDirectoryPermission() -> URL? { + @MainActor + static func requestDataDirectoryPermission(for fileUrl: URL) -> URL? { let openPanel = NSOpenPanel() - openPanel.directoryURL = safariDataDirectoryUrl + // if file does not exist, grant permission to its parent folder + openPanel.directoryURL = fileUrl.deletingLastPathComponent() + openPanel.directoryURL = fileUrl openPanel.message = UserText.bookmarkImportSafariRequestPermissionButtonTitle openPanel.allowsOtherFileTypes = false openPanel.canChooseFiles = false @@ -41,82 +36,87 @@ final class SafariDataImporter: DataImporter, DataDirectoryPermissionAuthorizati return openPanel.urls.first } - private let safariDataDirectoryUrl: URL private let bookmarkImporter: BookmarkImporter private let faviconManager: FaviconManagement + private let profile: DataImport.BrowserProfile + private var source: DataImport.Source { + profile.browser.importSource + } - init(safariDataDirectoryUrl: URL, bookmarkImporter: BookmarkImporter, faviconManager: FaviconManagement) { - self.safariDataDirectoryUrl = safariDataDirectoryUrl + init(profile: DataImport.BrowserProfile, bookmarkImporter: BookmarkImporter, faviconManager: FaviconManagement = FaviconManager.shared) { + self.profile = profile self.bookmarkImporter = bookmarkImporter self.faviconManager = faviconManager } - convenience init?(importSource: DataImport.Source, bookmarkImporter: BookmarkImporter, faviconManager: FaviconManagement) { - guard let profile = ThirdPartyBrowser.browser(for: importSource)?.browserProfiles()?.defaultProfile else { return nil } - - self.init(safariDataDirectoryUrl: profile.profileURL, bookmarkImporter: bookmarkImporter, faviconManager: faviconManager) - } - - func importableTypes() -> [DataImport.DataType] { + var importableTypes: [DataImport.DataType] { return [.bookmarks] } - @MainActor(unsafe) - func importData(types: [DataImport.DataType], - from profile: DataImport.BrowserProfile?, - modalWindow: NSWindow?, - completion: @escaping (DataImportResult) -> Void) { - let result = importData(types: types, from: profile) - completion(result) + func importData(types: Set) -> DataImportTask { + .detachedWithProgress { updateProgress in + let result = await self.importDataSync(types: types, updateProgress: updateProgress) + return result + } } static private let bookmarksFileName = "Bookmarks.plist" + private var fileUrl: URL { + profile.profileURL.appendingPathComponent(Self.bookmarksFileName) + } + + func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? { + guard types.contains(.bookmarks) else { return nil } + + if case .failure(let error) = SafariBookmarksReader(safariBookmarksFileURL: fileUrl).validateFileReadAccess() { + return [.bookmarks: error] + } + return nil + } + @MainActor - private func importData(types: [DataImport.DataType], from profile: DataImport.BrowserProfile?) -> DataImportResult { - var summary = DataImport.Summary() - - if types.contains(.bookmarks) { - let fileUrl = safariDataDirectoryUrl.appendingPathComponent(Self.bookmarksFileName) - let bookmarkReader = SafariBookmarksReader(safariBookmarksFileURL: fileUrl) - let bookmarkResult = bookmarkReader.readBookmarks() - - let faviconsReader = SafariFaviconsReader(safariDataDirectoryURL: safariDataDirectoryUrl) - let faviconsResult = faviconsReader.readFavicons() - - switch faviconsResult { - case .success(let faviconsByURL): - let faviconsByDocument = faviconsByURL.reduce(into: [URL: [Favicon]]()) { result, pair in - guard let pageURL = URL(string: pair.key) else { return } - let favicons = pair.value.map { - Favicon(identifier: UUID(), - url: pageURL, - image: $0.image, - relation: .icon, - documentUrl: pageURL, - dateCreated: Date()) - } - result[pageURL] = favicons - } - faviconManager.handleFaviconsByDocumentUrl(faviconsByDocument) + private func importDataSync(types: Set, updateProgress: DataImportProgressCallback) async -> DataImportSummary { + // logins will be imported from CSV + guard types.contains(.bookmarks) else { return [:] } - case .failure(let error): - Pixel.fire(.dataImportFailed(error)) - } + let bookmarkReader = SafariBookmarksReader(safariBookmarksFileURL: fileUrl) + let bookmarkResult = bookmarkReader.readBookmarks() - switch bookmarkResult { - case .success(let bookmarks): - summary.bookmarksResult = bookmarkImporter.importBookmarks(bookmarks, source: .thirdPartyBrowser(.safari)) - case .failure(let error): - return .failure(error) - } + let summary = bookmarkResult.map { bookmarks in + bookmarkImporter.importBookmarks(bookmarks, source: .thirdPartyBrowser(source)) } - if types.contains(.logins) { - summary.loginsResult = .awaited + if case .success = summary { + await importFavicons(from: profile.profileURL) } - return .success(summary) + return [.bookmarks: summary.map { .init($0) }] + } + + private func importFavicons(from dataDirectoryURL: URL) async { + let faviconsReader = SafariFaviconsReader(safariDataDirectoryURL: dataDirectoryURL) + let faviconsResult = faviconsReader.readFavicons() + + switch faviconsResult { + case .success(let faviconsByURL): + let faviconsByDocument = faviconsByURL.reduce(into: [URL: [Favicon]]()) { result, pair in + guard let pageURL = URL(string: pair.key) else { return } + let favicons = pair.value.map { + Favicon(identifier: UUID(), + url: pageURL, + image: $0.image, + relation: .icon, + documentUrl: pageURL, + dateCreated: Date()) + } + result[pageURL] = favicons + } + await faviconManager.handleFaviconsByDocumentUrl(faviconsByDocument) + + case .failure(let error): + Pixel.fire(.dataImportFailed(source: source, sourceVersion: profile.installedAppsMajorVersionDescription(), error: error)) + } } } diff --git a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariFaviconsReader.swift b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariFaviconsReader.swift index f1d39d117d..0e35e730aa 100644 --- a/DuckDuckGo/DataImport/Bookmarks/Safari/SafariFaviconsReader.swift +++ b/DuckDuckGo/DataImport/Bookmarks/Safari/SafariFaviconsReader.swift @@ -35,9 +35,10 @@ final class SafariFaviconsReader { } var action: DataImportAction { .favicons } - var source: DataImport.Source { .safari } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { .other } } fileprivate final class SafariFaviconRecord: FetchableRecord { diff --git a/DuckDuckGo/DataImport/ChromePreferences.swift b/DuckDuckGo/DataImport/ChromiumPreferences.swift similarity index 59% rename from DuckDuckGo/DataImport/ChromePreferences.swift rename to DuckDuckGo/DataImport/ChromiumPreferences.swift index e24be4699a..b0318927ee 100644 --- a/DuckDuckGo/DataImport/ChromePreferences.swift +++ b/DuckDuckGo/DataImport/ChromiumPreferences.swift @@ -1,5 +1,5 @@ // -// ChromePreferences.swift +// ChromiumPreferences.swift // // Copyright © 2022 DuckDuckGo. All rights reserved. // @@ -18,28 +18,45 @@ import AppKit -struct ChromePreferences: Decodable { +struct ChromiumPreferences: Decodable { struct AccountInfo: Decodable { let email: String? let fullName: String? } struct Profile: Decodable { - let name: String + let name: String? let createdByVersion: String? } + struct Extensions: Decodable { + let lastChromeVersion: String? + let lastOperaVersion: String? + } + + enum Constants { + static let chromiumPreferencesFileName = "Preferences" + } let accountInfo: [AccountInfo]? let profile: Profile + let extensions: Extensions? + init(from data: Data) throws { - var decoder = JSONDecoder() + let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase self = try decoder.decode(Self.self, from: data) } - var profileName: String { + init(profileURL: URL, fileStore: FileStore = FileManager.default) throws { + guard let preferencesData = fileStore.loadData(at: profileURL.appendingPathComponent(Constants.chromiumPreferencesFileName)) else { + throw CocoaError(.fileReadUnknown) + } + try self.init(from: preferencesData) + } + + var profileName: String? { for account in accountInfo ?? [] { switch (account.fullName, account.email) { case (.some(let fullName), .some(let email)): @@ -54,4 +71,10 @@ struct ChromePreferences: Decodable { return profile.name } + var appVersion: String? { + // profile.createdByVersion updated on Chrome launch; + // if it‘s missing - check extensions.last_chrome_version or last_opera_version - for Opera[GX] + profile.createdByVersion ?? extensions?.lastChromeVersion ?? extensions?.lastOperaVersion + } + } diff --git a/DuckDuckGo/DataImport/DataImport.swift b/DuckDuckGo/DataImport/DataImport.swift index 53f024d052..fc8e6094d9 100644 --- a/DuckDuckGo/DataImport/DataImport.swift +++ b/DuckDuckGo/DataImport/DataImport.swift @@ -22,16 +22,23 @@ import PixelKit enum DataImport { - enum Source: CaseIterable, Equatable { - + enum Source: String, RawRepresentable, CaseIterable, Equatable { case brave case chrome + case chromium + case coccoc case edge case firefox + case opera + case operaGX case safari case safariTechnologyPreview + case tor + case vivaldi + case yandex case onePassword8 case onePassword7 + case bitwarden case lastPass case csv case bookmarksHTML @@ -44,14 +51,30 @@ enum DataImport { return "Brave" case .chrome: return "Chrome" + case .chromium: + return "Chromium" + case .coccoc: + return "Cốc Cốc" case .edge: return "Edge" case .firefox: return "Firefox" + case .opera: + return "Opera" + case .operaGX: + return "OperaGX" case .safari: return "Safari" case .safariTechnologyPreview: return "Safari Technology Preview" + case .tor: + return "Tor Browser" + case .vivaldi: + return "Vivaldi" + case .yandex: + return "Yandex" + case .bitwarden: + return "Bitwarden" case .lastPass: return "LastPass" case .onePassword7: @@ -75,117 +98,193 @@ enum DataImport { } switch self { - case .csv, .onePassword8, .onePassword7, .lastPass, .bookmarksHTML: + case .csv, .bitwarden, .onePassword8, .onePassword7, .lastPass, .bookmarksHTML: // Users can always import from exported files return true - case .brave, .chrome, .edge, .firefox, .safari, .safariTechnologyPreview: + case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera, .operaGX, .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex: // Users can't import from browsers unless they're installed return false } } + var isBrowser: Bool { + switch self { + case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera, .operaGX, .safari, .safariTechnologyPreview, .vivaldi, .yandex, .tor: + return true + case .onePassword8, .onePassword7, .bitwarden, .lastPass, .csv, .bookmarksHTML: + return false + } + } + + var supportedDataTypes: Set { + switch self { + case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera, .operaGX, .safari, .safariTechnologyPreview, .vivaldi, .yandex: + return [.bookmarks, .passwords] + case .tor: + return [.bookmarks] + case .onePassword8, .onePassword7, .bitwarden, .lastPass, .csv: + return [.passwords] + case .bookmarksHTML: + return [.bookmarks] + } + } + + func installedAppsMajorVersionDescription(selectedProfile: BrowserProfile?) -> String? { + let installedVersions: Set + if let appVersion = selectedProfile?.appVersion, !appVersion.isEmpty { + installedVersions = [appVersion] + } else if let versions = ThirdPartyBrowser.browser(for: self)?.installedAppsVersions { + installedVersions = versions + } else { + return nil + } + return Set(installedVersions.map { + // get major version + $0.components(separatedBy: ".")[0] // [0] component is always there even if no "." + }.sorted()) + // list installed browsers major versions separated + .joined(separator: "; ") + } + } - enum DataType { + enum DataType: String, Hashable, CaseIterable, CustomStringConvertible { + case bookmarks - case logins - } + case passwords - struct CompletedLoginsResult: Equatable { - let successfulImports: [String] - let duplicateImports: [String] - let failedImports: [String] - } + var displayName: String { + switch self { + case .bookmarks: UserText.bookmarkImportBookmarks + case .passwords: UserText.importLoginsPasswords + } + } + + var description: String { rawValue } + + var importAction: DataImportAction { + switch self { + case .bookmarks: .bookmarks + case .passwords: .passwords + } + } - enum LoginsResult: Equatable { - case awaited - case completed(CompletedLoginsResult) } - struct Summary: Equatable { - var bookmarksResult: BookmarkImportResult? - var loginsResult: LoginsResult? + struct DataTypeSummary: Equatable { + let successful: Int + let duplicate: Int + let failed: Int var isEmpty: Bool { - bookmarksResult == nil && loginsResult == nil + self == .empty + } + + static var empty: Self { + DataTypeSummary(successful: 0, duplicate: 0, failed: 0) + } + + init(successful: Int, duplicate: Int, failed: Int) { + self.successful = successful + self.duplicate = duplicate + self.failed = failed + } + init(_ bookmarksImportSummary: BookmarksImportSummary) { + self.init(successful: bookmarksImportSummary.successful, duplicate: bookmarksImportSummary.duplicates, failed: bookmarksImportSummary.failed) } } struct BrowserProfileList { - let browser: ThirdPartyBrowser - let profiles: [BrowserProfile] - var validImportableProfiles: [BrowserProfile] { - return profiles.filter(\.hasBrowserData) + enum Constants { + static let chromiumDefaultProfileName = "Default" + static let chromiumProfilePrefix = "Profile " + static let firefoxDefaultProfileName = "default-release" } - init(browser: ThirdPartyBrowser, profileURLs: [URL]) { - self.browser = browser - - switch browser { - case .brave, .chrome, .edge: - // Chromium profiles are either named "Default", or a series of incrementing profile names, i.e. "Profile 1", "Profile 2", etc. - let potentialProfiles = profileURLs.map({ - BrowserProfile.for(browser: browser, profileURL: $0) - }) - - let filteredProfiles = potentialProfiles.filter { - $0.hasNonDefaultProfileName || - $0.profileName == "Default" || - $0.profileName.hasPrefix("Profile ") - } + let browser: ThirdPartyBrowser + let profiles: [BrowserProfile] - let sortedProfiles = filteredProfiles.sorted() + typealias ProfileDataValidator = (BrowserProfile) -> () -> BrowserProfile.ProfileDataValidationResult? + private let validateProfileData: ProfileDataValidator - self.profiles = sortedProfiles - case .firefox, .safari, .safariTechnologyPreview: - self.profiles = profileURLs.map { - BrowserProfile.for(browser: browser, profileURL: $0) - }.sorted() - case .lastPass, .onePassword7, .onePassword8: - self.profiles = [] - } + var validImportableProfiles: [BrowserProfile] { + return profiles.filter { validateProfileData($0)()?.containsValidData == true } } - var showProfilePicker: Bool { - return validImportableProfiles.count > 1 + init(browser: ThirdPartyBrowser, profiles: [BrowserProfile], validateProfileData: @escaping ProfileDataValidator = BrowserProfile.validateProfileData) { + self.browser = browser + self.profiles = profiles + self.validateProfileData = validateProfileData } var defaultProfile: BrowserProfile? { + let preferredProfileName: String? switch browser { - case .brave, .chrome, .edge: - return profiles.first { $0.profileName == "Default" } ?? profiles.first - case .firefox: - return profiles.first { $0.profileName == "default-release" } ?? profiles.first - case .safari, .safariTechnologyPreview, .lastPass, .onePassword7, .onePassword8: - return profiles.first + case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi, .yandex: + preferredProfileName = Constants.chromiumDefaultProfileName + return validImportableProfiles.first { $0.profileName == Constants.chromiumDefaultProfileName } ?? validImportableProfiles.first ?? profiles.first + case .firefox, .tor: + preferredProfileName = Constants.firefoxDefaultProfileName + case .safari, .safariTechnologyPreview, .bitwarden, .lastPass, .onePassword7, .onePassword8: + preferredProfileName = nil + } + lazy var validImportableProfiles = self.validImportableProfiles + if let preferredProfileName, + let preferredProfile = validImportableProfiles.first(where: { $0.profileName == preferredProfileName }) { + + return preferredProfile } + return validImportableProfiles.first ?? profiles.first } + } struct BrowserProfile: Comparable { enum Constants { - static let chromiumPreferencesFileName = "Preferences" static let chromiumSystemProfileName = "System Profile" } let profileURL: URL var profileName: String { - return detectedChromePreferencesProfileName ?? fallbackProfileName - } + if profileURL.lastPathComponent == Constants.chromiumSystemProfileName { + return Constants.chromiumSystemProfileName + } - var hasNonDefaultProfileName: Bool { - return detectedChromePreferencesProfileName != nil + return profilePreferences?.profileName ?? fallbackProfileName } - private let browser: ThirdPartyBrowser + let browser: ThirdPartyBrowser private let fileStore: FileStore private let fallbackProfileName: String - private let detectedChromePreferencesProfileName: String? - static func `for`(browser: ThirdPartyBrowser, profileURL: URL) -> BrowserProfile { - return BrowserProfile(browser: browser, profileURL: profileURL) + enum ProfilePreferences { + case chromium(ChromiumPreferences) + case firefox(FirefoxCompatibilityPreferences) + + var appVersion: String? { + switch self { + case .chromium(let preferences): preferences.appVersion + case .firefox(let preferences): preferences.lastVersion + } + } + + var profileName: String? { + switch self { + case .chromium(let preferences): preferences.profileName + case .firefox: nil + } + } + + var isChromium: Bool { + if case .chromium = self { true } else { false } + } + } + let profilePreferences: ProfilePreferences? + + var appVersion: String? { + profilePreferences?.appVersion } init(browser: ThirdPartyBrowser, profileURL: URL, fileStore: FileStore = FileManager.default) { @@ -194,88 +293,154 @@ enum DataImport { self.profileURL = profileURL self.fallbackProfileName = Self.getDefaultProfileName(at: profileURL) - self.detectedChromePreferencesProfileName = Self.getChromeProfileName(at: profileURL, fileStore: fileStore) + + switch browser { + case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi, .yandex: + self.profilePreferences = (try? ChromiumPreferences(profileURL: profileURL, fileStore: fileStore)) + .map(ProfilePreferences.chromium) + case .firefox, .tor: + self.profilePreferences = (try? FirefoxCompatibilityPreferences(profileURL: profileURL, fileStore: fileStore)) + .map(ProfilePreferences.firefox) + case .bitwarden, .safari, .safariTechnologyPreview, .lastPass, .onePassword7, .onePassword8: + self.profilePreferences = nil + } } - var hasBrowserData: Bool { - guard let profileDirectoryContents = try? fileStore.directoryContents(at: profileURL.path) else { + enum ProfileDataItemValidationResult { + case available + case unavailable(path: String) + case unsupported + } + struct ProfileDataValidationResult { + let logins: ProfileDataItemValidationResult + let bookmarks: ProfileDataItemValidationResult + + var containsValidData: Bool { + if case .available = logins { return true } + if case .available = bookmarks { return true } return false } + } + + func validateProfileData() -> ProfileDataValidationResult? { + guard let profileDirectoryContents = try? fileStore.directoryContents(at: profileURL.path) else { return nil } let profileDirectoryContentsSet = Set(profileDirectoryContents) + return .init(logins: validateLoginsData(profileDirectoryContents: profileDirectoryContentsSet), + bookmarks: validateBookmarksData(profileDirectoryContents: profileDirectoryContentsSet)) + } + + private func validateLoginsData(profileDirectoryContents: Set) -> ProfileDataItemValidationResult { switch browser { - case .brave, .chrome, .edge: + case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi: let hasChromiumLogins = ChromiumLoginReader.LoginDataFileName.allCases.contains { loginFileName in - return profileDirectoryContentsSet.contains(loginFileName.rawValue) + return profileDirectoryContents.contains(loginFileName.rawValue) } - let hasChromiumBookmarks = profileDirectoryContentsSet.contains(ChromiumBookmarksReader.Constants.defaultBookmarksFileName) + return hasChromiumLogins ? .available + : .unavailable(path: profileURL.appendingPathComponent(ChromiumLoginReader.LoginDataFileName.allCases.first!.rawValue).path) - return hasChromiumLogins || hasChromiumBookmarks case .firefox: - let hasFirefoxLogins = FirefoxLoginReader.DataFormat.allCases.contains { dataFormat in - let (databaseName, loginFileName) = dataFormat.formatFileNames - - return profileDirectoryContentsSet.contains(databaseName) && profileDirectoryContentsSet.contains(loginFileName) + guard let firefoxLoginsFormat = FirefoxLoginReader.DataFormat.allCases.first(where: { dataFormat in + profileDirectoryContents.contains(dataFormat.formatFileNames.databaseName) + }) else { + return .unavailable(path: profileURL .appendingPathComponent(FirefoxLoginReader.DataFormat.allCases.last!.formatFileNames.databaseName).path) } + let hasFirefoxLogins = profileDirectoryContents.contains(firefoxLoginsFormat.formatFileNames.loginsFileName) - let hasFirefoxBookmarks = profileDirectoryContentsSet.contains(FirefoxBookmarksReader.Constants.placesDatabaseName) + return hasFirefoxLogins ? .available + : .unavailable(path: profileURL.appendingPathComponent(firefoxLoginsFormat.formatFileNames.loginsFileName).path) - return hasFirefoxLogins || hasFirefoxBookmarks - default: - return false + case .tor: + return .unsupported + + case .safari, .safariTechnologyPreview, .yandex, .bitwarden, .lastPass, .onePassword7, .onePassword8: + return .available } } - private static func getDefaultProfileName(at profileURL: URL) -> String { - return profileURL.lastPathComponent.components(separatedBy: ".").last ?? profileURL.lastPathComponent - } + private func validateBookmarksData(profileDirectoryContents: Set) -> ProfileDataItemValidationResult { + switch browser { + case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi, .yandex: + let hasChromiumBookmarks = profileDirectoryContents.contains(ChromiumBookmarksReader.Constants.defaultBookmarksFileName) - private static func getChromeProfileName(at profileURL: URL, fileStore: FileStore) -> String? { - guard let profileDirectoryContents = try? fileStore.directoryContents(at: profileURL.path) else { - return nil - } + return hasChromiumBookmarks ? .available + : .unavailable(path: profileURL.appendingPathComponent(ChromiumBookmarksReader.Constants.defaultBookmarksFileName).path) - guard profileURL.lastPathComponent != Constants.chromiumSystemProfileName else { - return nil - } + case .firefox, .tor: + let hasFirefoxBookmarks = profileDirectoryContents.contains(FirefoxBookmarksReader.Constants.placesDatabaseName) + + return hasFirefoxBookmarks ? .available + : .unavailable(path: profileURL.appendingPathComponent(FirefoxBookmarksReader.Constants.placesDatabaseName).path) - if profileDirectoryContents.contains(Constants.chromiumPreferencesFileName), - let chromePreferenceData = fileStore.loadData(at: profileURL.appendingPathComponent(Constants.chromiumPreferencesFileName)), - let chromePreferences = try? ChromePreferences(from: chromePreferenceData) { - return chromePreferences.profileName + case .safari, .safariTechnologyPreview: + return .available + + case .bitwarden, .lastPass, .onePassword7, .onePassword8: + return .unsupported } + } - return nil + private static func getDefaultProfileName(at profileURL: URL) -> String { + return profileURL.lastPathComponent.components(separatedBy: ".").last ?? profileURL.lastPathComponent } static func < (lhs: DataImport.BrowserProfile, rhs: DataImport.BrowserProfile) -> Bool { - return lhs.profileName.localizedCompare(rhs.profileName) == .orderedAscending + // first sort by profiles folder name if multiple profiles folders are present (Chrome, Chrome Canary…) + let profilesDirName1 = lhs.profileURL.deletingLastPathComponent().lastPathComponent + let profilesDirName2 = rhs.profileURL.deletingLastPathComponent().lastPathComponent + if profilesDirName1 == profilesDirName2 { + return lhs.profileName.localizedCompare(rhs.profileName) == .orderedAscending + } else { + return profilesDirName1.localizedCompare(profilesDirName2) == .orderedAscending + } } static func == (lhs: DataImport.BrowserProfile, rhs: DataImport.BrowserProfile) -> Bool { return lhs.profileURL == rhs.profileURL } + + func installedAppsMajorVersionDescription() -> String? { + self.browser.importSource.installedAppsMajorVersionDescription(selectedProfile: self) + } + } + + enum ErrorType: String, CustomStringConvertible, CaseIterable { + case noData + case decryptionError + case dataCorrupted + case keychainError + case other + + var description: String { rawValue } } } -enum DataImportAction { +enum DataImportAction: String, RawRepresentable { case bookmarks - case logins + case passwords case favicons case generic + + init(_ type: DataImport.DataType) { + switch type { + case .bookmarks: self = .bookmarks + case .passwords: self = .passwords + } + } } -protocol DataImportError: Error, CustomNSError, ErrorWithPixelParameters { +protocol DataImportError: Error, CustomNSError, ErrorWithPixelParameters, LocalizedError { associatedtype OperationType: RawRepresentable where OperationType.RawValue == Int - var source: DataImport.Source { get } var action: DataImportAction { get } var type: OperationType { get } var underlyingError: Error? { get } + var errorType: DataImport.ErrorType { get } + } extension DataImportError /* : CustomNSError */ { var errorCode: Int { @@ -294,28 +459,63 @@ extension DataImportError /* : ErrorWithParameters */ { underlyingError?.pixelParameters ?? [:] } } +extension DataImportError /* : LocalizedError */ { + + var errorDescription: String? { + let error = (self as NSError) + return "\(error.domain) \(error.code)" + { + guard let underlyingError = underlyingError as NSError? else { return "" } + return " (\(underlyingError.domain) \(underlyingError.code))" + }() + } + +} + +enum DataImportProgressEvent { + case initial + case importingPasswords(numberOfPasswords: Int?, fraction: Double) + case importingBookmarks(numberOfBookmarks: Int?, fraction: Double) + case done +} + +typealias DataImportSummary = [DataImport.DataType: DataImportResult] +typealias DataImportTask = TaskWithProgress +typealias DataImportProgressCallback = DataImportTask.ProgressUpdateCallback /// Represents an object able to import data from an outside source. The outside source may be capable of importing multiple types of data. -/// For instance, a browser data importer may be able to import logins and bookmarks. +/// For instance, a browser data importer may be able to import passwords and bookmarks. protocol DataImporter { /// Performs a quick check to determine if the data is able to be imported. It does not guarantee that the import will succeed. /// For example, a CSV importer will return true if the URL it has been created with is a CSV file, but does not check whether the CSV data matches the expected format. - func importableTypes() -> [DataImport.DataType] + var importableTypes: [DataImport.DataType] { get } - func importData(types: [DataImport.DataType], - from profile: DataImport.BrowserProfile?, - modalWindow: NSWindow?, - completion: @escaping (DataImportResult) -> Void) + /// validate file access/encryption password requirement before starting import. Returns non-empty dictionary with failures if access validation fails. + func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? + /// Start import process. Returns cancellable TaskWithProgress + func importData(types: Set) -> DataImportTask + + func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool } + extension DataImporter { - func importData(types: [DataImport.DataType], from profile: DataImport.BrowserProfile?, completion: @escaping (DataImportResult) -> Void) { - self.importData(types: types, from: profile, modalWindow: nil, completion: completion) + + var importableTypes: [DataImport.DataType] { + [.bookmarks, .passwords] + } + + func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? { + nil + } + + func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool { + false } + } -enum DataImportResult { +enum DataImportResult: CustomStringConvertible { case success(T) case failure(any DataImportError) @@ -327,6 +527,84 @@ enum DataImportResult { throw error } } + + var isSuccess: Bool { + if case .success = self { + true + } else { + false + } + } + + var error: (any DataImportError)? { + if case .failure(let error) = self { + error + } else { + nil + } + } + + /// Returns a new result, mapping any success value using the given transformation. + /// - Parameter transform: A closure that takes the success value of this instance. + /// - Returns: A `Result` instance with the result of evaluating `transform` + /// as the new success value if this instance represents a success. + @inlinable public func map(_ transform: (T) -> NewT) -> DataImportResult { + switch self { + case .success(let value): + return .success(transform(value)) + case .failure(let error): + return .failure(error) + } + } + + /// Returns a new result, mapping any success value using the given transformation and unwrapping the produced result. + /// + /// - Parameter transform: A closure that takes the success value of the instance. + /// - Returns: A `Result` instance, either from the closure or the previous + /// `.failure`. + @inlinable public func flatMap(_ transform: (T) throws -> DataImportResult) rethrows -> DataImportResult { + switch self { + case .success(let value): + switch try transform(value) { + case .success(let transformedValue): + return .success(transformedValue) + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } + } + + var description: String { + switch self { + case .success(let value): + ".success(\(value))" + case .failure(let error): + ".failure(\(error))" + } + } + +} + +extension DataImportResult: Equatable where T: Equatable { + static func == (lhs: DataImportResult, rhs: DataImportResult) -> Bool { + switch lhs { + case .success(let value): + if case .success(value) = rhs { + true + } else { + false + } + case .failure(let error1): + if case .failure(let error2) = rhs { + error1.errorParameters == error2.errorParameters + } else { + false + } + } + } + } struct LoginImporterError: DataImportError { @@ -334,19 +612,17 @@ struct LoginImporterError: DataImportError { private let error: Error? private let _type: OperationType? - var action: DataImportAction { .logins } - let source: DataImport.Source + var action: DataImportAction { .passwords } - init(source: DataImport.Source, error: Error?, type: OperationType? = nil) { - self.source = source + init(error: Error?, type: OperationType? = nil) { self.error = error self._type = type } - struct OperationType: RawRepresentable { + struct OperationType: RawRepresentable, Equatable { let rawValue: Int - static let defaultFirefoxProfilePathNotFound = OperationType(rawValue: -1) + static let malformedCSV = OperationType(rawValue: -2) } var type: OperationType { @@ -383,4 +659,32 @@ struct LoginImporterError: DataImportError { } } + var errorType: DataImport.ErrorType { + if case .malformedCSV = type { + return .dataCorrupted + } + if let secureStorageError = error as? SecureStorageError { + switch secureStorageError { + case .initFailed, + .authError, + .failedToOpenDatabase, + .databaseError: + return .keychainError + + case .keystoreError, .secError: + return .keychainError + + case .authRequired, + .invalidPassword, + .noL1Key, + .noL2Key, + .duplicateRecord, + .generalCryptoError, + .encodingFailed: + return .decryptionError + } + } + return .other + } + } diff --git a/DuckDuckGo/DataImport/FirefoxCompatibilityPreferences.swift b/DuckDuckGo/DataImport/FirefoxCompatibilityPreferences.swift new file mode 100644 index 0000000000..993e2eb25b --- /dev/null +++ b/DuckDuckGo/DataImport/FirefoxCompatibilityPreferences.swift @@ -0,0 +1,51 @@ +// +// FirefoxCompatibilityPreferences.swift +// +// Copyright © 2023 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 Common +import Foundation + +struct FirefoxCompatibilityPreferences { + + enum Constants { + static let firefoxCompatibilityPreferencesFileName = "compatibility.ini" + } + + let lastVersion: String? + + private static let lastVersionRegex = regex("^\\s*LastVersion\\s*=\\s*(\\S+)\\s*$") + + init(from data: Data) { + var lastVersion: String? + data.utf8String()?.enumerateLines(invoking: { line, stop in + guard let match = Self.lastVersionRegex.firstMatch(in: line, options: [], range: line.fullRange), + let range = Range(match.range(at: 1), in: line) else { return } + lastVersion = String(line[range]) + stop = true + }) + + self.lastVersion = lastVersion + } + + init(profileURL: URL, fileStore: FileStore = FileManager.default) throws { + guard let preferencesData = fileStore.loadData(at: profileURL.appendingPathComponent(Constants.firefoxCompatibilityPreferencesFileName)) else { + throw CocoaError(.fileReadUnknown) + } + self.init(from: preferencesData) + } + +} diff --git a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift index 9966fb88ec..b99d50b193 100644 --- a/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift +++ b/DuckDuckGo/DataImport/Logins/CSV/CSVImporter.swift @@ -16,61 +16,122 @@ // limitations under the License. // +import Common import Foundation final class CSVImporter: DataImporter { struct ColumnPositions { + private enum Regex { + // should end with "login" or "username" + static let username = regex("(?:^|\\b|\\s|_)(?:login|username)$", .caseInsensitive) + // should end with "password" or "pwd" + static let password = regex("(?:^|\\b|\\s|_)(?:password|pwd)$", .caseInsensitive) + // should end with "name" or "title" + static let title = regex("(?:^|\\b|\\s|_)(?:name|title)$", .caseInsensitive) + // should end with "url", "uri" + static let url = regex("(?:^|\\b|\\s|_)(?:url|uri)$", .caseInsensitive) + // should end with "notes" or "note" + static let notes = regex("(?:^|\\b|\\s|_)(?:notes|note)$", .caseInsensitive) + } + + static let rowFormatWithTitle = ColumnPositions(titleIndex: 0, urlIndex: 1, usernameIndex: 2, passwordIndex: 3) + static let rowFormatWithoutTitle = ColumnPositions(titleIndex: nil, urlIndex: 0, usernameIndex: 1, passwordIndex: 2) + let maximumIndex: Int let titleIndex: Int? - let urlIndex: Int + let urlIndex: Int? + let usernameIndex: Int let passwordIndex: Int - init(titleIndex: Int?, urlIndex: Int, usernameIndex: Int, passwordIndex: Int, maximumIndex: Int) { + let notesIndex: Int? + + let isZohoVault: Bool + + init(titleIndex: Int?, urlIndex: Int?, usernameIndex: Int, passwordIndex: Int, notesIndex: Int? = nil, isZohoVault: Bool = false) { self.titleIndex = titleIndex self.urlIndex = urlIndex self.usernameIndex = usernameIndex self.passwordIndex = passwordIndex - self.maximumIndex = maximumIndex + self.notesIndex = notesIndex + self.maximumIndex = max(titleIndex ?? -1, urlIndex ?? -1, usernameIndex, passwordIndex, notesIndex ?? -1) + self.isZohoVault = isZohoVault } - init?(csvValues: [String]) { - guard csvValues.count >= 3 else { return nil } - - var titlePosition: Int? - var urlPosition: Int? - var usernamePosition: Int? - var passwordPosition: Int? - - for (index, value) in csvValues.enumerated() { - switch value.lowercased() { - case "url", "login_uri": urlPosition = index - case "username", "login_username": usernamePosition = index - case "password", "login_password": passwordPosition = index - case "title", "name": titlePosition = index - default: break - } - } + private enum Format { + case general + case zohoGeneral + case zohoVault + } - if let url = urlPosition, let username = usernamePosition, let password = passwordPosition { - self.init(titleIndex: titlePosition, - urlIndex: url, - usernameIndex: username, - passwordIndex: password, - maximumIndex: csvValues.count - 1) + init?(csv: [[String]]) { + guard csv.count > 1, + csv[1].count >= 3 else { return nil } + var headerRow = csv[0] + + var format = Format.general + + let usernameIndex: Int + if let idx = headerRow.firstIndex(where: { value in + Regex.username.matches(in: value, range: value.fullRange).isEmpty == false + }) { + usernameIndex = idx + headerRow[usernameIndex] = "" + + // Zoho + } else if headerRow.first == "Password Name" { + if let idx = csv[1].firstIndex(of: "SecretData") { + format = .zohoVault + usernameIndex = idx + } else if csv[1].count == 7 { + format = .zohoGeneral + usernameIndex = 5 + } else { + return nil + } } else { return nil } + + let passwordIndex: Int + switch format { + case .general: + guard let idx = headerRow + .firstIndex(where: { !Regex.password.matches(in: $0, range: $0.fullRange).isEmpty }) else { return nil } + passwordIndex = idx + headerRow[passwordIndex] = "" + + case .zohoGeneral: + passwordIndex = usernameIndex + 1 + case .zohoVault: + passwordIndex = usernameIndex + } + + let titleIndex = headerRow.firstIndex(where: { !Regex.title.matches(in: $0, range: $0.fullRange).isEmpty }) + titleIndex.map { headerRow[$0] = "" } + + let urlIndex = headerRow.firstIndex(where: { !Regex.url.matches(in: $0, range: $0.fullRange).isEmpty }) + urlIndex.map { headerRow[$0] = "" } + + let notesIndex = headerRow.firstIndex(where: { !Regex.notes.matches(in: $0, range: $0.fullRange).isEmpty }) + + self.init(titleIndex: titleIndex, + urlIndex: urlIndex, + usernameIndex: usernameIndex, + passwordIndex: passwordIndex, + notesIndex: notesIndex, + isZohoVault: format == .zohoVault) } init?(source: DataImport.Source) { switch source { case .onePassword7, .onePassword8: - self.init(titleIndex: 3, urlIndex: 5, usernameIndex: 6, passwordIndex: 2, maximumIndex: 7) - case .lastPass, .firefox, .edge, .chrome, .brave, .safari, .safariTechnologyPreview, .csv, .bookmarksHTML: + self.init(titleIndex: 3, urlIndex: 5, usernameIndex: 6, passwordIndex: 2) + case .lastPass, .firefox, .edge, .chrome, .chromium, .coccoc, .brave, .opera, .operaGX, + .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex, .csv, .bookmarksHTML, .bitwarden: return nil } } @@ -80,86 +141,114 @@ final class CSVImporter: DataImporter { struct ImportError: DataImportError { enum OperationType: Int { case cannotReadFile - case cannotAccessSecureVault } - var action: DataImportAction { .logins } - var source: DataImport.Source { .csv } + var action: DataImportAction { .passwords } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { + .dataCorrupted + } } private let fileURL: URL - private let loginImporter: LoginImporter? + private let loginImporter: LoginImporter private let defaultColumnPositions: ColumnPositions? - init(fileURL: URL, loginImporter: LoginImporter?, defaultColumnPositions: ColumnPositions? = nil) { + init(fileURL: URL, loginImporter: LoginImporter, defaultColumnPositions: ColumnPositions?) { self.fileURL = fileURL self.loginImporter = loginImporter self.defaultColumnPositions = defaultColumnPositions } - func totalValidLogins() -> Int { + static func totalValidLogins(in fileURL: URL, defaultColumnPositions: ColumnPositions?) -> Int { guard let fileContents = try? String(contentsOf: fileURL, encoding: .utf8) else { return 0 } - let logins = Self.extractLogins(from: fileContents, defaultColumnPositions: self.defaultColumnPositions) + let logins = extractLogins(from: fileContents, defaultColumnPositions: defaultColumnPositions) ?? [] return logins.count } static func extractLogins(from fileContents: String, - defaultColumnPositions: ColumnPositions? = nil) -> [ImportedLoginCredential] { - let parsed = CSVParser.parse(string: fileContents) - - if let possibleHeaderRow = parsed.first, let inferredColumnPositions = ColumnPositions(csvValues: possibleHeaderRow) { - return parsed.dropFirst().compactMap { - ImportedLoginCredential(row: $0, inferredColumnPositions: inferredColumnPositions) - } + defaultColumnPositions: ColumnPositions? = nil) -> [ImportedLoginCredential]? { + guard let parsed = try? CSVParser().parse(string: fileContents) else { return nil } + + let columnPositions: ColumnPositions? + var startRow = 0 + if let autodetected = ColumnPositions(csv: parsed) { + columnPositions = autodetected + startRow = 1 } else { - return parsed.compactMap { - ImportedLoginCredential(row: $0, inferredColumnPositions: defaultColumnPositions) + columnPositions = defaultColumnPositions + } + + guard parsed.indices.contains(startRow) else { return [] } // no data + + let result = parsed[startRow...].compactMap(columnPositions.read) + + guard !result.isEmpty else { + if parsed.filter({ !$0.isEmpty }).isEmpty { + return [] // no data + } else { + return nil // error: could not parse data } } + + return result } - func importableTypes() -> [DataImport.DataType] { - if fileURL.pathExtension == "csv" { - return [.logins] - } else { - return [] + var importableTypes: [DataImport.DataType] { + return [.passwords] + } + + func importData(types: Set) -> DataImportTask { + .detachedWithProgress { updateProgress in + do { + let result = try await self.importLoginsSync(updateProgress: updateProgress) + return [.passwords: result] + } catch is CancellationError { + } catch { + assertionFailure("Only CancellationError should be thrown here") + } + return [:] } } - // This will change to return an array of DataImport.Summary objects, indicating the status of each import type that was requested. - func importData(types: [DataImport.DataType], - from profile: DataImport.BrowserProfile?, - modalWindow: NSWindow?, - completion: @escaping (DataImportResult) -> Void) { + private func importLoginsSync(updateProgress: @escaping DataImportProgressCallback) async throws -> DataImportResult { + + try updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.0)) + let fileContents: String do { fileContents = try String(contentsOf: fileURL, encoding: .utf8) } catch { - completion(.failure(ImportError(type: .cannotReadFile, underlyingError: error))) - return - } - guard let loginImporter = self.loginImporter else { - completion(.failure(ImportError(type: .cannotAccessSecureVault, underlyingError: nil))) - return + return .failure(ImportError(type: .cannotReadFile, underlyingError: error)) } - DispatchQueue.global(qos: .userInitiated).async { - let loginCredentials = Self.extractLogins(from: fileContents, defaultColumnPositions: self.defaultColumnPositions) + do { + try updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.2)) - do { - let result = try loginImporter.importLogins(loginCredentials) - DispatchQueue.main.async { - completion(.success(DataImport.Summary(bookmarksResult: nil, loginsResult: .completed(result)))) - } - } catch { - DispatchQueue.main.async { - completion(.failure(LoginImporterError(source: .csv, error: error))) - } + let loginCredentials = try Self.extractLogins(from: fileContents, defaultColumnPositions: defaultColumnPositions) ?? { + try Task.checkCancellation() + throw LoginImporterError(error: nil, type: .malformedCSV) + }() + + try updateProgress(.importingPasswords(numberOfPasswords: loginCredentials.count, fraction: 0.5)) + + let summary = try loginImporter.importLogins(loginCredentials) { count in + try updateProgress(.importingPasswords(numberOfPasswords: count, fraction: 0.5 + 0.5 * (Double(count) / Double(loginCredentials.count)))) } + + try updateProgress(.importingPasswords(numberOfPasswords: loginCredentials.count, fraction: 1.0)) + + return .success(summary) + } catch is CancellationError { + throw CancellationError() + } catch let error as DataImportError { + return .failure(error) + } catch { + return .failure(LoginImporterError(error: error)) } } @@ -172,11 +261,10 @@ extension ImportedLoginCredential { fileprivate var isHeaderRow: Bool { let types: NSTextCheckingResult.CheckingType = [.link] - guard let detector = try? NSDataDetector(types: types.rawValue), self.url.count > 0 else { - return false - } + guard let detector = try? NSDataDetector(types: types.rawValue), + let url, !url.isEmpty else { return false } - if detector.numberOfMatches(in: self.url, options: [], range: self.url.fullRange) > 0 { + if detector.numberOfMatches(in: url, options: [], range: url.fullRange) > 0 { return false } @@ -184,3 +272,54 @@ extension ImportedLoginCredential { } } + +extension CSVImporter.ColumnPositions { + + func read(_ row: [String]) -> ImportedLoginCredential? { + let username: String + let password: String + + if isZohoVault { + // cell contents: + // SecretType:Web Account + // User Name:username + // Password:password + guard let lines = row[safe: usernameIndex]?.components(separatedBy: "\n"), + let usernameLine = lines.first(where: { $0.hasPrefix("User Name:") }), + let passwordLine = lines.first(where: { $0.hasPrefix("Password:") }) else { return nil } + + username = usernameLine.dropping(prefix: "User Name:") + password = passwordLine.dropping(prefix: "Password:") + + } else if let user = row[safe: usernameIndex], + let pass = row[safe: passwordIndex] { + + username = user + password = pass + } else { + return nil + } + + return ImportedLoginCredential(title: row[safe: titleIndex ?? -1], + url: row[safe: urlIndex ?? -1], + username: username, + password: password, + notes: row[safe: notesIndex ?? -1]) + } + +} + +extension CSVImporter.ColumnPositions? { + + func read(_ row: [String]) -> ImportedLoginCredential? { + let columnPositions = self ?? [ + .rowFormatWithTitle, + .rowFormatWithoutTitle + ].first(where: { + row.count > $0.maximumIndex + }) + + return columnPositions?.read(row) + } + +} diff --git a/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift b/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift index 7e1eaebcb9..5f2785a2d2 100644 --- a/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift +++ b/DuckDuckGo/DataImport/Logins/CSV/CSVParser.swift @@ -18,53 +18,143 @@ import Foundation -final class CSVParser { +struct CSVParser { - static func parse(string: String) -> [[String]] { - return string.parseCSV() + enum ParserError: Error { + case unexpectedCharacterAfterQuote(Character) } -} + func parse(string: String) throws -> [[String]] { + var parser = Parser() + + for character in string { + try Task.checkCancellation() + try parser.accept(character) + } + + parser.flushField() + + return parser.result + } + + private enum State { + case start + case field + case enquotedField + } -private extension String { + private struct Parser { + var delimiter: Character? - func parseCSV() -> [[String]] { var result: [[String]] = [[]] - var currentField = "".unicodeScalars - var inQuotes = false - var hasPrecedingBackslash = false - @inline(__always) func flush() { - result[result.endIndex - 1].append(String(currentField)) - currentField.removeAll() + var state = State.start + var hasPrecedingQuote = false + + var currentField = "" + + @inline(__always) mutating func flushField() { + result[result.endIndex - 1].append(currentField) + currentField = "" + state = .start + hasPrecedingQuote = false + } + + @inline(__always) mutating func nextLine() { + flushField() + result.append([]) + state = .start + hasPrecedingQuote = false } - for character in self.unicodeScalars { - switch (character, inQuotes, hasPrecedingBackslash) { - case (",", false, _): - hasPrecedingBackslash = false - flush() - case ("\n", false, _): - hasPrecedingBackslash = false - flush() - result.append([]) - case ("\\", true, _): - hasPrecedingBackslash = true - case ("\"", _, false): - inQuotes = !inQuotes - case ("\"", _, true): - // The preceding characters was a backslash, so append the quote to the string instead of treating it as a delimiter - hasPrecedingBackslash = false + // swiftlint:disable:next cyclomatic_complexity + mutating func accept(_ character: Character) throws { + switch (state, character.kind(delimiter: delimiter), precedingQuote: hasPrecedingQuote) { + case (_, .unsupported, _): + return // skip control characters + + // expecting field start + case (.start, .quote, _): + state = .enquotedField + case (.start, .delimiter, _): + flushField() + delimiter = character + case (.start, .whitespace, _): + return // trim leading whitespaces + case (.start, .newline, _): + nextLine() + case (.start, .payload, _): + state = .field + currentField.append(character) + + // quote in field body is escaped with 2 quotes + case (_, .quote, precedingQuote: false): + hasPrecedingQuote = true + case (_, .quote, precedingQuote: true): currentField.append(character) + hasPrecedingQuote = false + + // enquoted field end + case (.enquotedField, .delimiter, precedingQuote: true): + flushField() + delimiter = character + case (.enquotedField, .newline, precedingQuote: true): + nextLine() + case (.enquotedField, .whitespace, precedingQuote: true): + return // trim whitespaces between fields + + // unbalanced quote + case (_, _, precedingQuote: true): + // only expecting a second quote after a quote in field body + throw ParserError.unexpectedCharacterAfterQuote(character) + + // non-enquoted field end + case (.field, .delimiter, _): + flushField() + delimiter = character + case (.field, .newline, _): + nextLine() + default: - hasPrecedingBackslash = false currentField.append(character) } } + } + +} - flush() +private extension Character { - return result + enum Kind { + case quote + case delimiter + case newline + case whitespace + case unsupported + case payload } + func kind(delimiter: Character?) -> Kind { + if self == "\"" { + .quote + } else if self.unicodeScalars.contains(where: { CharacterSet.unsupportedCharacters.contains($0) }) { + .unsupported + } else if CharacterSet.newlines.contains(unicodeScalars.first!) { + .newline + } else if CharacterSet.whitespaces.contains(unicodeScalars.first!) { + .whitespace + } else if self == delimiter || (delimiter == nil && CharacterSet.delimiters.contains(unicodeScalars.first!)) { + .delimiter + } else { + .payload + } + } + +} + +private extension CharacterSet { + + static let unsupportedCharacters = CharacterSet.controlCharacters.union(.illegalCharacters).subtracting(.newlines) + static let delimiters = CharacterSet(charactersIn: ",;") + } diff --git a/DuckDuckGo/DataImport/Logins/Chromium/BraveDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/BraveDataImporter.swift deleted file mode 100644 index e431aca3af..0000000000 --- a/DuckDuckGo/DataImport/Logins/Chromium/BraveDataImporter.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// BraveDataImporter.swift -// -// Copyright © 2021 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation - -final class BraveDataImporter: ChromiumDataImporter { - - override var processName: String { - return "Brave" - } - - override var source: DataImport.Source { - return .brave - } - - init(loginImporter: LoginImporter, bookmarkImporter: BookmarkImporter) { - let applicationSupport = URL.nonSandboxApplicationSupportDirectoryURL - let defaultDataURL = applicationSupport.appendingPathComponent("BraveSoftware/Brave-Browser/Default/") - - super.init(applicationDataDirectoryURL: defaultDataURL, - loginImporter: loginImporter, - bookmarkImporter: bookmarkImporter, - faviconManager: FaviconManager.shared) - } - -} diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromeDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromeDataImporter.swift deleted file mode 100644 index 7465dd8497..0000000000 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromeDataImporter.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// ChromeDataImporter.swift -// -// Copyright © 2021 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation - -final class ChromeDataImporter: ChromiumDataImporter { - - override var processName: String { - return "Chrome" - } - - override var source: DataImport.Source { - return .chrome - } - - init(loginImporter: LoginImporter, bookmarkImporter: BookmarkImporter) { - let applicationSupport = URL.nonSandboxApplicationSupportDirectoryURL - let defaultDataURL = applicationSupport.appendingPathComponent("Google/Chrome/Default/") - - super.init(applicationDataDirectoryURL: defaultDataURL, - loginImporter: loginImporter, - bookmarkImporter: bookmarkImporter, - faviconManager: FaviconManager.shared) - } - -} diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift index 49388313ef..a387ac7d34 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumDataImporter.swift @@ -20,80 +20,106 @@ import Foundation internal class ChromiumDataImporter: DataImporter { - var processName: String { - fatalError("Subclasses must provide their own process name") - } - - var source: DataImport.Source { - fatalError("Subclasses must return a source") - } - - private let applicationDataDirectoryURL: URL private let bookmarkImporter: BookmarkImporter - private let loginImporter: LoginImporter + private let loginImporter: LoginImporter? private let faviconManager: FaviconManagement + private let profile: DataImport.BrowserProfile + private var source: DataImport.Source { + profile.browser.importSource + } - init(applicationDataDirectoryURL: URL, loginImporter: LoginImporter, bookmarkImporter: BookmarkImporter, faviconManager: FaviconManagement) { - self.applicationDataDirectoryURL = applicationDataDirectoryURL + init(profile: DataImport.BrowserProfile, loginImporter: LoginImporter?, bookmarkImporter: BookmarkImporter, faviconManager: FaviconManagement) { + self.profile = profile self.loginImporter = loginImporter self.bookmarkImporter = bookmarkImporter self.faviconManager = faviconManager } - func importableTypes() -> [DataImport.DataType] { - return [.logins, .bookmarks] + convenience init(profile: DataImport.BrowserProfile, loginImporter: LoginImporter?, bookmarkImporter: BookmarkImporter) { + self.init(profile: profile, + loginImporter: loginImporter, + bookmarkImporter: bookmarkImporter, + faviconManager: FaviconManager.shared) } - func importData(types: [DataImport.DataType], - from profile: DataImport.BrowserProfile?, - modalWindow: NSWindow? = nil, - completion: @escaping (DataImportResult) -> Void) { - let result = importData(types: types, from: profile, modalWindow: modalWindow) - completion(result) + var importableTypes: [DataImport.DataType] { + return [.passwords, .bookmarks] } - func importData(types: [DataImport.DataType], from profile: DataImport.BrowserProfile?, modalWindow: NSWindow?) -> DataImportResult { - var summary = DataImport.Summary() - let dataDirectoryURL = profile?.profileURL ?? applicationDataDirectoryURL + func importData(types: Set) -> DataImportTask { + .detachedWithProgress { updateProgress in + do { + let result = try await self.importDataSync(types: types, updateProgress: updateProgress) + return result + } catch is CancellationError { + } catch { + assertionFailure("Only CancellationError should be thrown here") + } + return [:] + } + } + + private func importDataSync(types: Set, updateProgress: @escaping DataImportProgressCallback) async throws -> DataImportSummary { + var summary = DataImportSummary() + + let dataTypeFraction = 1.0 / Double(types.count) - if types.contains(.logins) { - let loginReader = ChromiumLoginReader(chromiumDataDirectoryURL: dataDirectoryURL, source: source, processName: processName) - let loginResult = loginReader.readLogins(modalWindow: modalWindow) + if types.contains(.passwords), let loginImporter { + try updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.0)) - switch loginResult { - case .success(let logins): + let loginReader = ChromiumLoginReader(chromiumDataDirectoryURL: profile.profileURL, source: source) + let loginResult = loginReader.readLogins(modalWindow: nil) + + let loginsSummary = try loginResult.flatMap { logins in do { - let result = try loginImporter.importLogins(logins) - summary.loginsResult = .completed(result) + return try .success(loginImporter.importLogins(logins) { count in + try updateProgress(.importingPasswords(numberOfPasswords: count, + fraction: dataTypeFraction * (0.5 + Double(count) / Double(logins.count)))) + }) + } catch is CancellationError { + throw CancellationError() } catch { - return .failure(LoginImporterError(source: source, error: error)) + return .failure(LoginImporterError(error: error)) } - case .failure(let error): - return .failure(error) } + + summary[.passwords] = loginsSummary + + try updateProgress(.importingPasswords(numberOfPasswords: try? loginResult.get().count, fraction: dataTypeFraction * 1.0)) } - if types.contains(.bookmarks) { - let bookmarkReader = ChromiumBookmarksReader(chromiumDataDirectoryURL: dataDirectoryURL, source: source) + let passwordsFraction: Double = types.contains(.passwords) ? 0.5 : 0.0 + if types.contains(.bookmarks) + // don‘t proceed with bookmarks import on Keychain prompt denial + && (summary[.passwords]?.error as? ChromiumLoginReader.ImportError)?.type != .userDeniedKeychainPrompt { + + try updateProgress(.importingBookmarks(numberOfBookmarks: nil, fraction: passwordsFraction + 0.0)) + + let bookmarkReader = ChromiumBookmarksReader(chromiumDataDirectoryURL: profile.profileURL) let bookmarkResult = bookmarkReader.readBookmarks() - importFavicons(from: dataDirectoryURL) + try updateProgress(.importingBookmarks(numberOfBookmarks: try? bookmarkResult.get().numberOfBookmarks, + fraction: passwordsFraction + dataTypeFraction * 0.5)) - switch bookmarkResult { - case .success(let bookmarks): - summary.bookmarksResult = bookmarkImporter.importBookmarks(bookmarks, source: .thirdPartyBrowser(source)) + let bookmarksSummary = bookmarkResult.map { bookmarks in + bookmarkImporter.importBookmarks(bookmarks, source: .thirdPartyBrowser(source)) + } - case .failure(let error): - return .failure(error) + if case .success = bookmarksSummary { + await importFavicons() } + + summary[.bookmarks] = bookmarksSummary.map { .init($0) } + + try updateProgress(.importingBookmarks(numberOfBookmarks: try? bookmarkResult.get().numberOfBookmarks, + fraction: passwordsFraction + dataTypeFraction * 1.0)) } - return .success(summary) + return summary } - @MainActor(unsafe) - func importFavicons(from dataDirectoryURL: URL) { - let faviconsReader = ChromiumFaviconsReader(chromiumDataDirectoryURL: dataDirectoryURL, source: source) + private func importFavicons() async { + let faviconsReader = ChromiumFaviconsReader(chromiumDataDirectoryURL: profile.profileURL) let faviconsResult = faviconsReader.readFavicons() switch faviconsResult { @@ -110,11 +136,15 @@ internal class ChromiumDataImporter: DataImporter { } result[pageURL] = favicons } - faviconManager.handleFaviconsByDocumentUrl(faviconsByDocument) + await faviconManager.handleFaviconsByDocumentUrl(faviconsByDocument) case .failure(let error): - Pixel.fire(.dataImportFailed(error)) + Pixel.fire(.dataImportFailed(source: source, sourceVersion: profile.installedAppsMajorVersionDescription(), error: error)) } } + func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool { + return selectedDataTypes.contains(.passwords) + } + } diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift index db1fdd32e4..4094df74ab 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import AppKit import Foundation import CommonCrypto import GRDB @@ -36,17 +37,22 @@ final class ChromiumLoginReader { case createImportedLoginCredentialsFailure } - var action: DataImportAction { .logins } - let source: DataImport.Source + var action: DataImportAction { .passwords } let type: OperationType - let underlyingError: Error? - - } - private func importError(type: ImportError.OperationType, underlyingError: Error? = nil) -> ImportError { - ImportError(source: source, type: type, underlyingError: underlyingError) + var underlyingError: Error? + + var errorType: DataImport.ErrorType { + switch type { + case .couldNotFindLoginData, .failedToTemporarilyCopyDatabase: .noData + case .databaseAccessFailed: .dataCorrupted + case .decryptionFailed, .failedToDecodePasswordData, .decryptionKeyAccessFailed, .passwordDataTooShort, .dataToStringConversionError: .decryptionError + case .userDeniedKeychainPrompt, .createImportedLoginCredentialsFailure: .other + } + } } + private func decryptionKeyAccessFailed(_ status: OSStatus) -> ImportError { - importError(type: .decryptionKeyAccessFailed, underlyingError: NSError(domain: "KeychainError", code: Int(status))) + ImportError(type: .decryptionKeyAccessFailed, underlyingError: NSError(domain: "KeychainError", code: Int(status))) } enum LoginDataFileName: String, CaseIterable { @@ -56,7 +62,6 @@ final class ChromiumLoginReader { private let chromiumLocalLoginDirectoryURL: URL private let chromiumGoogleAccountLoginDirectoryURL: URL - private let processName: String private let decryptionKey: String? private let decryptionKeyPrompt: ChromiumKeychainPrompting @@ -68,12 +73,10 @@ final class ChromiumLoginReader { init(chromiumDataDirectoryURL: URL, source: DataImport.Source, - processName: String, decryptionKey: String? = nil, decryptionKeyPrompt: ChromiumKeychainPrompting = ChromiumKeychainPrompt()) { self.chromiumLocalLoginDirectoryURL = chromiumDataDirectoryURL.appendingPathComponent(LoginDataFileName.loginData.rawValue) self.chromiumGoogleAccountLoginDirectoryURL = chromiumDataDirectoryURL.appendingPathComponent(LoginDataFileName.loginDataForAccount.rawValue) - self.processName = processName self.decryptionKey = decryptionKey self.decryptionKeyPrompt = decryptionKeyPrompt self.source = source @@ -89,7 +92,8 @@ final class ChromiumLoginReader { var keyPromptResult: ChromiumKeychainPromptResult? DispatchQueue.global().async { - keyPromptResult = self.decryptionKeyPrompt.promptForChromiumPasswordKeychainAccess(processName: self.processName) + let processName = ThirdPartyBrowser.browser(for: self.source)!.keychainProcessName + keyPromptResult = self.decryptionKeyPrompt.promptForChromiumPasswordKeychainAccess(processName: processName) DispatchQueue.main.async { modalSession.map(NSApp.endModalSession) } @@ -105,8 +109,8 @@ final class ChromiumLoginReader { switch keyPromptResult { case .password(let passwordString): key = passwordString - case .failedToDecodePasswordData: return .failure(importError(type: .failedToDecodePasswordData)) - case .none, .userDeniedKeychainPrompt: return .failure(importError(type: .userDeniedKeychainPrompt)) + case .failedToDecodePasswordData: return .failure(ImportError(type: .failedToDecodePasswordData)) + case .none, .userDeniedKeychainPrompt: return .failure(ImportError(type: .userDeniedKeychainPrompt)) case .keychainError(let status): return .failure(decryptionKeyAccessFailed(status)) } } @@ -115,7 +119,7 @@ final class ChromiumLoginReader { do { derivedKey = try deriveKey(from: key) } catch { - return .failure(importError(type: .decryptionFailed, underlyingError: error)) + return .failure(ImportError(type: .decryptionFailed, underlyingError: error)) } return readLogins(using: derivedKey) @@ -126,7 +130,7 @@ final class ChromiumLoginReader { .filter { FileManager.default.fileExists(atPath: $0.path) } guard !loginFileURLs.isEmpty else { - return .failure(importError(type: .couldNotFindLoginData)) + return .failure(ImportError(type: .couldNotFindLoginData)) } var loginRows = [ChromiumCredential.ID: ChromiumCredential]() @@ -155,7 +159,7 @@ final class ChromiumLoginReader { } catch let error as ImportError { return .failure(error) } catch { - return .failure(importError(type: .createImportedLoginCredentialsFailure, underlyingError: error)) + return .failure(ImportError(type: .createImportedLoginCredentialsFailure, underlyingError: error)) } } @@ -167,7 +171,7 @@ final class ChromiumLoginReader { do { temporaryDatabaseURL = try temporaryFileHandler.copyFileToTemporaryDirectory() } catch { - return .failure(importError(type: .failedToTemporarilyCopyDatabase, underlyingError: error)) + return .failure(ImportError(type: .failedToTemporarilyCopyDatabase, underlyingError: error)) } var loginRows = [ChromiumCredential.ID: ChromiumCredential]() @@ -195,7 +199,7 @@ final class ChromiumLoginReader { } } catch { - return .failure(importError(type: .databaseAccessFailed, underlyingError: error)) + return .failure(ImportError(type: .databaseAccessFailed, underlyingError: error)) } return .success(loginRows) @@ -213,11 +217,7 @@ final class ChromiumLoginReader { return nil } - return ImportedLoginCredential( - url: row.url, - username: row.username, - password: decryptedPassword - ) + return ImportedLoginCredential(url: row.url, username: row.username, password: decryptedPassword) } if result.isEmpty, let lastError { throw lastError @@ -254,13 +254,13 @@ final class ChromiumLoginReader { } private func decrypt(passwordData: Data, with key: Data) throws -> String { - guard passwordData.count >= 4 else { throw importError(type: .passwordDataTooShort, underlyingError: nil) } + guard passwordData.count >= 4 else { throw ImportError(type: .passwordDataTooShort, underlyingError: nil) } let trimmedPasswordData = passwordData[3...] let iv = String(repeating: " ", count: 16).utf8data let decrypted = try Cryptography.decryptAESCBC(data: trimmedPasswordData, key: key, iv: iv) - return try String(data: decrypted, encoding: .utf8) ?? { throw importError(type: .dataToStringConversionError, underlyingError: nil) }() + return try String(data: decrypted, encoding: .utf8) ?? { throw ImportError(type: .dataToStringConversionError, underlyingError: nil) }() } } diff --git a/DuckDuckGo/DataImport/Logins/Chromium/EdgeDataImporter.swift b/DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift similarity index 50% rename from DuckDuckGo/DataImport/Logins/Chromium/EdgeDataImporter.swift rename to DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift index f8e8ce1df0..c19ac930ac 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/EdgeDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/YandexDataImporter.swift @@ -1,7 +1,7 @@ // -// EdgeDataImporter.swift +// YandexDataImporter.swift // -// Copyright © 2021 DuckDuckGo. All rights reserved. +// Copyright © 2023 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. @@ -18,24 +18,26 @@ import Foundation -final class EdgeDataImporter: ChromiumDataImporter { +final class YandexDataImporter: ChromiumDataImporter { - override var processName: String { - return "Microsoft Edge" + init(profile: DataImport.BrowserProfile, bookmarkImporter: BookmarkImporter) { + super.init(profile: profile, + loginImporter: nil, + bookmarkImporter: bookmarkImporter, + faviconManager: FaviconManager.shared) } - override var source: DataImport.Source { - return .edge + override var importableTypes: [DataImport.DataType] { + return [.bookmarks] } - init(loginImporter: LoginImporter, bookmarkImporter: BookmarkImporter) { - let applicationSupport = URL.nonSandboxApplicationSupportDirectoryURL - let defaultDataURL = applicationSupport.appendingPathComponent("Microsoft Edge/Default/") + override func importData(types: Set) -> DataImportTask { + // logins will be imported from CSV + return super.importData(types: types.filter { $0 != .passwords }) + } - super.init(applicationDataDirectoryURL: defaultDataURL, - loginImporter: loginImporter, - bookmarkImporter: bookmarkImporter, - faviconManager: FaviconManager.shared) + override func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool { + false } } diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift index 1b218834b8..a6b87ee62b 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxDataImporter.swift @@ -19,83 +19,107 @@ import Foundation import SecureStorage -final class FirefoxDataImporter: DataImporter { - - var primaryPassword: String? +internal class FirefoxDataImporter: DataImporter { private let loginImporter: LoginImporter private let bookmarkImporter: BookmarkImporter private let faviconManager: FaviconManagement + private let profile: DataImport.BrowserProfile + private var source: DataImport.Source { + profile.browser.importSource + } - init(loginImporter: LoginImporter, bookmarkImporter: BookmarkImporter, faviconManager: FaviconManagement) { + private let primaryPassword: String? + + init(profile: DataImport.BrowserProfile, primaryPassword: String?, loginImporter: LoginImporter, bookmarkImporter: BookmarkImporter, faviconManager: FaviconManagement) { + self.profile = profile + self.primaryPassword = primaryPassword self.loginImporter = loginImporter self.bookmarkImporter = bookmarkImporter self.faviconManager = faviconManager } - func importableTypes() -> [DataImport.DataType] { - return [.logins, .bookmarks] + var importableTypes: [DataImport.DataType] { + return [.passwords, .bookmarks] } - func importData(types: [DataImport.DataType], - from profile: DataImport.BrowserProfile?, - modalWindow: NSWindow?, - completion: @escaping (DataImportResult) -> Void) { - let result = importData(types: types, from: profile) - completion(result) + func importData(types: Set) -> DataImportTask { + .detachedWithProgress { updateProgress in + do { + let result = try await self.importDataSync(types: types, updateProgress: updateProgress) + return result + } catch is CancellationError { + } catch { + assertionFailure("Only CancellationError should be thrown here") + } + return [:] + } } - private func importData(types: [DataImport.DataType], from profile: DataImport.BrowserProfile?) -> DataImportResult { - let firefoxProfileURL: URL - do { - firefoxProfileURL = try profile?.profileURL ?? Self.defaultFirefoxProfilePath() ?? { - throw LoginImporterError(source: .firefox, error: nil, type: .defaultFirefoxProfilePathNotFound) - }() - } catch let error as LoginImporterError { - return .failure(error) - } catch { - return .failure(LoginImporterError(source: .firefox, error: error, type: .defaultFirefoxProfilePathNotFound)) - } + private func importDataSync(types: Set, updateProgress: @escaping DataImportProgressCallback) async throws -> DataImportSummary { + var summary = DataImportSummary() + + let dataTypeFraction = 1.0 / Double(types.count) - var summary = DataImport.Summary() + if types.contains(.passwords) { + try updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.0)) - if types.contains(.logins) { - let loginReader = FirefoxLoginReader(firefoxProfileURL: firefoxProfileURL, primaryPassword: self.primaryPassword) + let loginReader = FirefoxLoginReader(firefoxProfileURL: profile.profileURL, primaryPassword: self.primaryPassword) let loginResult = loginReader.readLogins(dataFormat: nil) - switch loginResult { - case .success(let logins): + try updateProgress(.importingPasswords(numberOfPasswords: try? loginResult.get().count, fraction: dataTypeFraction * 0.5)) + + let loginsSummary = try loginResult.flatMap { logins in do { - let result = try loginImporter.importLogins(logins) - summary.loginsResult = .completed(result) + return try .success(loginImporter.importLogins(logins) { count in + try updateProgress(.importingPasswords(numberOfPasswords: count, + fraction: dataTypeFraction * (0.5 + 0.5 * Double(count) / Double(logins.count)))) + }) + } catch is CancellationError { + throw CancellationError() } catch { - return .failure(LoginImporterError(source: .firefox, error: error)) + return .failure(LoginImporterError(error: error)) } - case .failure(let error): - return .failure(error) } + + summary[.passwords] = loginsSummary + + try updateProgress(.importingPasswords(numberOfPasswords: try? loginResult.get().count, fraction: dataTypeFraction * 1.0)) } - if types.contains(.bookmarks) { - let bookmarkReader = FirefoxBookmarksReader(firefoxDataDirectoryURL: firefoxProfileURL) + let passwordsFraction: Double = types.contains(.passwords) ? 0.5 : 0.0 + if types.contains(.bookmarks) + // don‘t proceed with bookmarks import on invalid Primary Password + && (summary[.passwords]?.error as? FirefoxLoginReader.ImportError)?.type != .requiresPrimaryPassword { + + try updateProgress(.importingBookmarks(numberOfBookmarks: nil, fraction: passwordsFraction + 0.0)) + + let bookmarkReader = FirefoxBookmarksReader(firefoxDataDirectoryURL: profile.profileURL) let bookmarkResult = bookmarkReader.readBookmarks() - importFavicons(from: firefoxProfileURL) + try updateProgress(.importingBookmarks(numberOfBookmarks: try? bookmarkResult.get().numberOfBookmarks, + fraction: passwordsFraction + dataTypeFraction * 0.5)) - switch bookmarkResult { - case .success(let bookmarks): - summary.bookmarksResult = bookmarkImporter.importBookmarks(bookmarks, source: .thirdPartyBrowser(.firefox)) - case .failure(let error): - return .failure(error) + let bookmarksSummary = bookmarkResult.map { bookmarks in + bookmarkImporter.importBookmarks(bookmarks, source: .thirdPartyBrowser(source)) } + + if case .success = bookmarksSummary { + await importFavicons() + } + + summary[.bookmarks] = bookmarksSummary.map { .init($0) } + + try updateProgress(.importingBookmarks(numberOfBookmarks: try? bookmarkResult.get().numberOfBookmarks, + fraction: passwordsFraction + dataTypeFraction * 1.0)) } + try updateProgress(.done) - return .success(summary) + return summary } - @MainActor(unsafe) - private func importFavicons(from firefoxProfileURL: URL) { - let faviconsReader = FirefoxFaviconsReader(firefoxDataDirectoryURL: firefoxProfileURL) + private func importFavicons() async { + let faviconsReader = FirefoxFaviconsReader(firefoxDataDirectoryURL: profile.profileURL) let faviconsResult = faviconsReader.readFavicons() switch faviconsResult { @@ -112,41 +136,27 @@ final class FirefoxDataImporter: DataImporter { } result[pageURL] = favicons } - faviconManager.handleFaviconsByDocumentUrl(faviconsByDocument) + await faviconManager.handleFaviconsByDocumentUrl(faviconsByDocument) case .failure(let error): - Pixel.fire(.dataImportFailed(error)) - } - } - - static func loginDatabaseRequiresPrimaryPassword(profileURL: URL?) -> Bool { - guard let firefoxProfileURL = try? profileURL ?? defaultFirefoxProfilePath() else { - return false - } - - let loginReader = FirefoxLoginReader(firefoxProfileURL: firefoxProfileURL, primaryPassword: nil) - let loginResult = loginReader.readLogins(dataFormat: nil) - - switch loginResult { - case .failure(let failure as FirefoxLoginReader.ImportError): - return failure.type == .requiresPrimaryPassword - default: - return false + Pixel.fire(.dataImportFailed(source: source, sourceVersion: profile.installedAppsMajorVersionDescription(), error: error)) } } - private static func defaultFirefoxProfilePath() throws -> URL? { - let profilesDirectory = URL.nonSandboxApplicationSupportDirectoryURL.appendingPathComponent("Firefox/Profiles") - let potentialProfiles = try FileManager.default.contentsOfDirectory(atPath: profilesDirectory.path) + /// requires primary password? + func validateAccess(for selectedDataTypes: Set) -> [DataImport.DataType: any DataImportError]? { + guard selectedDataTypes.contains(.passwords) else { return nil } - // This is the value used by Firefox in production releases. Use it by default, if no profile is selected. - let profiles = potentialProfiles.filter { $0.hasSuffix(".default-release") } + let loginReader = FirefoxLoginReader(firefoxProfileURL: profile.profileURL, primaryPassword: primaryPassword) + do { + _=try loginReader.getEncryptionKey() + return nil - guard let selectedProfile = profiles.first else { + } catch let error as FirefoxLoginReader.ImportError where error.type == .requiresPrimaryPassword { + return [.passwords: error] + } catch { return nil } - - return profilesDirectory.appendingPathComponent(selectedProfile) } } diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxEncryptionKeyReader.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxEncryptionKeyReader.swift index 47bd91a4af..4a7cc35d3c 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxEncryptionKeyReader.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxEncryptionKeyReader.swift @@ -32,11 +32,16 @@ final class FirefoxEncryptionKeyReader: FirefoxEncryptionKeyReading { typealias KeyReaderFileLineError = FileLineError + init() { + } + func getEncryptionKey(key3DatabaseURL: URL, primaryPassword: String) -> DataImportResult { var operationType: FirefoxLoginReader.ImportError.OperationType = .key3readerStage1 do { let data = try getEncryptionKey(key3DatabaseURL: key3DatabaseURL, primaryPassword: primaryPassword, operationType: &operationType) return .success(data) + } catch let error as FirefoxLoginReader.ImportError { + return .failure(error) } catch { return .failure(FirefoxLoginReader.ImportError(type: operationType, underlyingError: error)) } @@ -84,7 +89,7 @@ final class FirefoxEncryptionKeyReader: FirefoxEncryptionKeyReading { } catch let error as FirefoxLoginReader.ImportError { return .failure(error) } catch { - return .failure(FirefoxLoginReader.ImportError(type: operationType, underlyingError: KeyReaderFileLineError())) + return .failure(FirefoxLoginReader.ImportError(type: operationType, underlyingError: error)) } } diff --git a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift index 31f9971995..3c354886cc 100644 --- a/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift +++ b/DuckDuckGo/DataImport/Logins/Firefox/FirefoxLoginReader.swift @@ -26,7 +26,9 @@ final class FirefoxLoginReader { enum OperationType: Int { case requiresPrimaryPassword = -1 - case couldNotFindLoginsFile + case couldNotDetermineFormat = -2 + + case couldNotFindLoginsFile = 0 case couldNotReadLoginsFile case key3readerStage1 @@ -41,10 +43,18 @@ final class FirefoxLoginReader { case decryptPassword } - var action: DataImportAction { .logins } - var source: DataImport.Source { .firefox } + var action: DataImportAction { .passwords } let type: OperationType let underlyingError: Error? + + var errorType: DataImport.ErrorType { + switch type { + case .couldNotFindLoginsFile, .couldNotReadLoginsFile: .noData + case .key3readerStage1, .key3readerStage2, .key3readerStage3, .key4readerStage1, .key4readerStage2, .key4readerStage3, .decryptUsername, .decryptPassword: .decryptionError + case .couldNotDetermineFormat: .dataCorrupted + case .requiresPrimaryPassword: .other + } + } } typealias LoginReaderFileLineError = FileLineError @@ -56,10 +66,10 @@ final class FirefoxLoginReader { case version3 case version2 - var formatFileNames: (databaseName: String, loginFileName: String) { + var formatFileNames: (databaseName: String, loginsFileName: String) { switch self { - case .version3: return (databaseName: "key4.db", loginFileName: "logins.json") - case .version2: return (databaseName: "key3.db", loginFileName: "logins.json") + case .version3: return (databaseName: "key4.db", loginsFileName: "logins.json") + case .version2: return (databaseName: "key3.db", loginsFileName: "logins.json") } } } @@ -67,24 +77,26 @@ final class FirefoxLoginReader { private let keyReader: FirefoxEncryptionKeyReading private let primaryPassword: String? private let firefoxProfileURL: URL - private var currentOperationType: ImportError.OperationType = .requiresPrimaryPassword /// Initialize a FirefoxLoginReader with a profile path and optional primary password. /// /// - Parameter firefoxProfileURL: The path to the profile being imported from. This should be the base path of the profile, containing the database and JSON files. /// - Parameter primaryPassword: The password used to decrypt the login data. This is optional, as Firefox's primary password feature is optional. init(firefoxProfileURL: URL, - keyReader: FirefoxEncryptionKeyReading = FirefoxEncryptionKeyReader(), + keyReader: FirefoxEncryptionKeyReading? = nil, primaryPassword: String? = nil) { - self.keyReader = keyReader + self.keyReader = keyReader ?? FirefoxEncryptionKeyReader() self.primaryPassword = primaryPassword self.firefoxProfileURL = firefoxProfileURL } func readLogins(dataFormat: DataFormat?) -> DataImportResult<[ImportedLoginCredential]> { + var currentOperationType: ImportError.OperationType = .couldNotFindLoginsFile do { - let result = try reallyReadLogins(dataFormat: dataFormat) + let dataFormat = try dataFormat ?? detectLoginFormat() ?? { throw ImportError(type: .couldNotDetermineFormat, underlyingError: nil) }() + let keyData = try getEncryptionKey(dataFormat: dataFormat) + let result = try reallyReadLogins(dataFormat: dataFormat, keyData: keyData, currentOperationType: ¤tOperationType) return .success(result) } catch let error as ImportError { return .failure(error) @@ -93,67 +105,46 @@ final class FirefoxLoginReader { } } - private func reallyReadLogins(dataFormat: DataFormat?) throws -> [ImportedLoginCredential] { - var detectedFormat: DataFormat? - var foundKeyDatabaseWithoutLoginFile = false + func getEncryptionKey() throws -> Data { + let dataFormat = try detectLoginFormat() ?? { throw ImportError(type: .couldNotDetermineFormat, underlyingError: nil) }() + return try getEncryptionKey(dataFormat: dataFormat) + } - if let dataFormat = dataFormat { - detectedFormat = dataFormat - } else { - let detectionResult = detectLoginFormat(withProfileURL: firefoxProfileURL) - detectedFormat = detectionResult.dataFormat - foundKeyDatabaseWithoutLoginFile = detectionResult.foundKeyDatabaseButNoLoginsFile - } + private func getEncryptionKey(dataFormat: DataFormat) throws -> Data { + let databaseURL = firefoxProfileURL.appendingPathComponent(dataFormat.formatFileNames.databaseName) - // There's a legitimate case where the key database exists, but there's no logins file. This happens when the user has never - // saved a login. To avoid showing them an error, we check for the existence of the SQLite database but no logins file. - if foundKeyDatabaseWithoutLoginFile { - return [] + switch dataFormat { + case .version2: + return try keyReader.getEncryptionKey(key3DatabaseURL: databaseURL, primaryPassword: primaryPassword ?? "").get() + case .version3: + return try keyReader.getEncryptionKey(key4DatabaseURL: databaseURL, primaryPassword: primaryPassword ?? "").get() } + } - guard let detectedFormat else { throw ImportError(type: .couldNotFindLoginsFile, underlyingError: nil) } - - let databaseURL = firefoxProfileURL.appendingPathComponent(detectedFormat.formatFileNames.databaseName) - let loginsFileURL = firefoxProfileURL.appendingPathComponent(detectedFormat.formatFileNames.loginFileName) - - // If there isn't a file where logins are expected, consider it a successful import of 0 logins - // to avoid showing an error state. - guard FileManager.default.fileExists(atPath: loginsFileURL.path) else { - return [] - } + private func reallyReadLogins(dataFormat: DataFormat, keyData: Data, currentOperationType: inout ImportError.OperationType) throws -> [ImportedLoginCredential] { + let loginsFileURL = firefoxProfileURL.appendingPathComponent(dataFormat.formatFileNames.loginsFileName) currentOperationType = .couldNotReadLoginsFile let logins = try readLoginsFile(from: loginsFileURL.path) - let encryptionKeyResult: DataImportResult - - switch detectedFormat { - case .version2: encryptionKeyResult = keyReader.getEncryptionKey(key3DatabaseURL: databaseURL, primaryPassword: primaryPassword ?? "") - case .version3: encryptionKeyResult = keyReader.getEncryptionKey(key4DatabaseURL: databaseURL, primaryPassword: primaryPassword ?? "") - } - - let keyData = try encryptionKeyResult.get() - let decryptedLogins = try decrypt(logins: logins, with: keyData) + let decryptedLogins = try decrypt(logins: logins, with: keyData, currentOperationType: ¤tOperationType) return decryptedLogins } - private func detectLoginFormat(withProfileURL firefoxProfileURL: URL) -> (dataFormat: DataFormat?, foundKeyDatabaseButNoLoginsFile: Bool) { - var foundKeyDatabaseWithoutLoginFile = false - + private func detectLoginFormat() throws -> DataFormat? { for potentialFormat in DataFormat.allCases { - let potentialDatabaseURL = firefoxProfileURL.appendingPathComponent(potentialFormat.formatFileNames.databaseName) - let potentialLoginsFileURL = firefoxProfileURL.appendingPathComponent(potentialFormat.formatFileNames.loginFileName) - - if FileManager.default.fileExists(atPath: potentialDatabaseURL.path) { - if FileManager.default.fileExists(atPath: potentialLoginsFileURL.path) { - return (potentialFormat, false) - } else { - foundKeyDatabaseWithoutLoginFile = true + let databaseURL = firefoxProfileURL.appendingPathComponent(potentialFormat.formatFileNames.databaseName) + let loginsURL = firefoxProfileURL.appendingPathComponent(potentialFormat.formatFileNames.loginsFileName) + + if FileManager.default.fileExists(atPath: databaseURL.path) { + guard FileManager.default.fileExists(atPath: loginsURL.path) else { + throw ImportError(type: .couldNotFindLoginsFile, underlyingError: nil) } + return potentialFormat } } - return (nil, foundKeyDatabaseWithoutLoginFile) + return nil } private func readLoginsFile(from loginsFilePath: String) throws -> EncryptedFirefoxLogins { @@ -162,7 +153,7 @@ final class FirefoxLoginReader { return try JSONDecoder().decode(EncryptedFirefoxLogins.self, from: loginsFileData) } - private func decrypt(logins: EncryptedFirefoxLogins, with key: Data) throws -> [ImportedLoginCredential] { + private func decrypt(logins: EncryptedFirefoxLogins, with key: Data, currentOperationType: inout ImportError.OperationType) throws -> [ImportedLoginCredential] { var credentials = [ImportedLoginCredential]() // Filter out rows that are used by the Firefox sync service. @@ -176,7 +167,7 @@ final class FirefoxLoginReader { currentOperationType = .decryptPassword let decryptedPassword = try decrypt(credential: login.encryptedPassword, key: key) - credentials.append(ImportedLoginCredential(url: login.hostname, username: decryptedUsername, password: decryptedPassword)) + credentials.append(ImportedLoginCredential(url: login.hostname, username: decryptedUsername, password: decryptedPassword, notes: nil)) } catch { lastError = error } diff --git a/DuckDuckGo/DataImport/Logins/LoginImport.swift b/DuckDuckGo/DataImport/Logins/LoginImport.swift index 9c4b569268..ecd8a095f9 100644 --- a/DuckDuckGo/DataImport/Logins/LoginImport.swift +++ b/DuckDuckGo/DataImport/Logins/LoginImport.swift @@ -21,63 +21,24 @@ import BrowserServicesKit struct ImportedLoginCredential: Equatable { - private enum RowFormatWithTitle: Int { - case title = 0 - case url - case username - case password - } - - private enum RowFormatWithoutTitle: Int { - case url = 0 - case username - case password - } - let title: String? - let url: String + let url: String? let username: String let password: String + let notes: String? - init(title: String? = nil, url: String, username: String, password: String) { + init(title: String? = nil, url: String?, username: String, password: String, notes: String? = nil) { self.title = title - self.url = URL(string: url)?.host ?? url // Try to use the host if possible, as the Secure Vault saves credentials using the host. + self.url = url.flatMap(URL.init(string:))?.host ?? url // Try to use the host if possible, as the Secure Vault saves credentials using the host. self.username = username self.password = password - } - - init?(row: [String], inferredColumnPositions: CSVImporter.ColumnPositions? = nil) { - if let inferredPositions = inferredColumnPositions { - guard row.count > inferredPositions.maximumIndex else { return nil } - - var title: String? - - if let titleIndex = inferredPositions.titleIndex { - title = row[titleIndex] - } - - self.init(title: title, - url: row[inferredPositions.urlIndex], - username: row[inferredPositions.usernameIndex], - password: row[inferredPositions.passwordIndex]) - } else if row.count >= 4 { - self.init(title: row[RowFormatWithTitle.title.rawValue], - url: row[RowFormatWithTitle.url.rawValue], - username: row[RowFormatWithTitle.username.rawValue], - password: row[RowFormatWithTitle.password.rawValue]) - } else if row.count >= 3 { - self.init(url: row[RowFormatWithoutTitle.url.rawValue], - username: row[RowFormatWithoutTitle.username.rawValue], - password: row[RowFormatWithoutTitle.password.rawValue]) - } else { - return nil - } + self.notes = notes } } protocol LoginImporter { - func importLogins(_ logins: [ImportedLoginCredential]) throws -> DataImport.CompletedLoginsResult + func importLogins(_ logins: [ImportedLoginCredential], progressCallback: @escaping (Int) throws -> Void) throws -> DataImport.DataTypeSummary } diff --git a/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift b/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift index aedf2bfb03..17acb874e0 100644 --- a/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift +++ b/DuckDuckGo/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift @@ -22,13 +22,7 @@ import SecureStorage final class SecureVaultLoginImporter: LoginImporter { - private let secureVault: any AutofillSecureVault - - init(secureVault: any AutofillSecureVault) { - self.secureVault = secureVault - } - - func importLogins(_ logins: [ImportedLoginCredential]) throws -> DataImport.CompletedLoginsResult { + func importLogins(_ logins: [ImportedLoginCredential], progressCallback: @escaping (Int) throws -> Void) throws -> DataImport.DataTypeSummary { let vault = try AutofillSecureVaultFactory.makeVault(errorReporter: SecureVaultErrorReporter.shared) var successful: [String] = [] @@ -39,9 +33,9 @@ final class SecureVaultLoginImporter: LoginImporter { let hashingSalt = try vault.getHashingSalt() try vault.inDatabaseTransaction { database in - for login in logins { + for (idx, login) in logins.enumerated() { let title = login.title - let account = SecureVaultModels.WebsiteAccount(title: title, username: login.username, domain: login.url) + let account = SecureVaultModels.WebsiteAccount(title: title, username: login.username, domain: login.url, notes: login.notes) let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: login.password.data(using: .utf8)!) let importSummaryValue: String @@ -61,10 +55,12 @@ final class SecureVaultLoginImporter: LoginImporter { failed.append(importSummaryValue) } } + + try progressCallback(idx + 1) } } - return .init(successfulImports: successful, duplicateImports: duplicates, failedImports: failed) + return .init(successful: successful.count, duplicate: duplicates.count, failed: failed.count) } } diff --git a/LocalPackages/Purchase/Tests/PurchaseTests/PurchaseTests.swift b/DuckDuckGo/DataImport/Model/DataImportReportModel.swift similarity index 53% rename from LocalPackages/Purchase/Tests/PurchaseTests/PurchaseTests.swift rename to DuckDuckGo/DataImport/Model/DataImportReportModel.swift index 9c6b73d0a1..41c8e03e81 100644 --- a/LocalPackages/Purchase/Tests/PurchaseTests/PurchaseTests.swift +++ b/DuckDuckGo/DataImport/Model/DataImportReportModel.swift @@ -1,5 +1,5 @@ // -// PurchaseTests.swift +// DataImportReportModel.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -16,14 +16,25 @@ // limitations under the License. // -import XCTest -@testable import Purchase +import Common +import Foundation -final class PurchaseTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(Purchase().text, "Hello, World!") +struct DataImportReportModel { + + var osVersion: String = "\(ProcessInfo.processInfo.operatingSystemVersion)" + var appVersion: String = "\(AppVersion.shared.versionNumber)" + + var importSource: DataImport.Source + var importSourceVersion: String? + + var importSourceDescription: String { + [importSource.importSourceName, importSourceVersion].compactMap { $0 }.joined(separator: " ") } + + var error: LocalizedError + + var text: String = "" + + var retryNumber: Int + } diff --git a/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift new file mode 100644 index 0000000000..ddd53cb310 --- /dev/null +++ b/DuckDuckGo/DataImport/Model/DataImportSourceViewModel.swift @@ -0,0 +1,45 @@ +// +// DataImportSourceViewModel.swift +// +// Copyright © 2023 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 DataImportSourceViewModel { + + let importSources: [DataImport.Source?] + var selectedSourceIndex: Int + + init(importSources: [DataImport.Source]? = nil, selectedSource: DataImport.Source) { + var importSources: [DataImport.Source?] = (importSources ?? DataImport.Source.allCases.filter(\.canImportData)) + + // The CSV row is at the bottom of the picker, and requires a separator above it, but only if the item array isn't + // empty (which would happen if there are no valid sources). + for source in [DataImport.Source.onePassword8, .csv] { + if let idx = importSources.lastIndex(of: source), idx > 0 { + // separator + importSources.insert(nil, at: idx) + } + } + self.importSources = importSources + + assert(!self.importSources.isEmpty) + + self.selectedSourceIndex = self.importSources.firstIndex(of: selectedSource) ?? 0 + assert(self.importSources.indices.contains(selectedSourceIndex)) + } + +} diff --git a/DuckDuckGo/DataImport/Model/DataImportSummaryViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportSummaryViewModel.swift new file mode 100644 index 0000000000..66527cfe26 --- /dev/null +++ b/DuckDuckGo/DataImport/Model/DataImportSummaryViewModel.swift @@ -0,0 +1,60 @@ +// +// DataImportSummaryViewModel.swift +// +// Copyright © 2023 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 DataImportSummaryViewModel { + typealias Source = DataImport.Source + typealias DataType = DataImport.DataType + typealias DataTypeImportResult = DataImportViewModel.DataTypeImportResult + typealias DataTypeSummary = DataImport.DataTypeSummary + + enum SummaryKind { + // total + case results + // one data type only + case importComplete(DataType) + // file import per data type + case fileImportComplete(DataType) + } + let summaryKind: SummaryKind + let results: [DataTypeImportResult] + + init(source: Source, results: [DataTypeImportResult], dataTypes: Set? = nil) { + let dataTypes = dataTypes ?? Set(results.map(\.dataType)) + assert(!dataTypes.isEmpty) + + if dataTypes.count > 1 || dataTypes.contains(where: { dataType in + // always “results” if there‘s a failure + results.last(where: { $0.dataType == dataType })?.result.isSuccess == false + }) { + self.summaryKind = .results + + } else { + let dataType = dataTypes.first ?? .bookmarks + + let isFileImport = source.initialScreen.isFileImport || results.filter({ $0.dataType == dataType }).count > 1 /* is file import retry */ + self.summaryKind = isFileImport ? .fileImportComplete(dataType) : .importComplete(dataType) + } + + self.results = DataType.allCases.compactMap { dataType in + dataTypes.contains(dataType) ? results.last(where: { $0.dataType == dataType }) : nil + } + } + +} diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift new file mode 100644 index 0000000000..7d982588ad --- /dev/null +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -0,0 +1,724 @@ +// +// DataImportViewModel.swift +// +// Copyright © 2023 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 Common +import Foundation +import UniformTypeIdentifiers + +struct DataImportViewModel { + + typealias Source = DataImport.Source + typealias BrowserProfileList = DataImport.BrowserProfileList + typealias BrowserProfile = DataImport.BrowserProfile + typealias DataType = DataImport.DataType + typealias DataTypeSummary = DataImport.DataTypeSummary + + @UserDefaultsWrapper(key: .homePageContinueSetUpImport, defaultValue: nil) + var successfulImportHappened: Bool? + + let availableImportSources: [DataImport.Source] + /// Browser to import data from + let importSource: Source + /// BrowserProfileList loader (factory method) - used + private let loadProfiles: (ThirdPartyBrowser) -> BrowserProfileList + /// Loaded BrowserProfileList + let browserProfiles: BrowserProfileList? + + typealias DataImporterFactory = @MainActor (Source, DataType?, URL, /* primaryPassword: */ String?) -> DataImporter + /// Factory for a DataImporter for importSource + private let dataImporterFactory: DataImporterFactory + + /// Show a master password input dialog callback + private let requestPrimaryPasswordCallback: @MainActor (Source) -> String? + + /// Show Open Panel to choose CSV/HTML file + private let openPanelCallback: @MainActor (DataType) -> URL? + + typealias ReportSenderFactory = () -> (DataImportReportModel) -> Void + /// Factory for a DataImporter for importSource + private let reportSenderFactory: ReportSenderFactory + + private func log(_ message: @autoclosure () -> String) { + if NSApp.runType == .unitTests { + } else if OSLog.dataImportExport != .disabled { + os_log(log: .dataImportExport, message()) + } else if NSApp.runType == .xcPreviews { + print(message()) + } + } + + enum Screen: Hashable { + case profileAndDataTypesPicker + case moreInfo + case getReadPermission(URL) + case fileImport(dataType: DataType, summary: Set = []) + case summary(Set) + case feedback + + var isFileImport: Bool { + if case .fileImport = self { true } else { false } + } + + var isGetReadPermission: Bool { + if case .getReadPermission = self { true } else { false } + } + + var fileImportDataType: DataType? { + switch self { + case .fileImport(dataType: let dataType, summary: _): + return dataType + default: + return nil + } + } + } + /// Currently displayed screen + private(set) var screen: Screen + + /// selected Browser Profile (if any) + var selectedProfile: BrowserProfile? + /// selected Data Types to import (bookmarks/passwords) + var selectedDataTypes: Set = [] + + /// data import concurrency Task launched in `initiateImport` + /// used to cancel import and in `importProgress` to trace import progress and import completion + private var importTask: DataImportTask? + + struct DataTypeImportResult: Equatable { + let dataType: DataImport.DataType + let result: DataImportResult + init(_ dataType: DataImport.DataType, _ result: DataImportResult) { + self.dataType = dataType + self.result = result + } + } + + /// collected import summary for current import operation per selected import source + private(set) var summary: [DataTypeImportResult] + + private var userReportText: String = "" + +#if DEBUG || REVIEW + + // simulated test import failure + struct TestImportError: DataImportError { + enum OperationType: Int { + case imp + } + var type: OperationType { .imp } + var action: DataImportAction + var underlyingError: Error? { CocoaError(.fileReadUnknown) } + var errorType: DataImport.ErrorType + } + + var testImportResults = [DataType: DataImportResult]() + +#endif + + init(importSource: Source? = nil, + screen: Screen? = nil, + availableImportSources: [DataImport.Source] = Source.allCases.filter { $0.canImportData }, + preferredImportSources: [Source] = [.chrome, .firefox, .safari], + summary: [DataTypeImportResult] = [], + loadProfiles: @escaping (ThirdPartyBrowser) -> BrowserProfileList = { $0.browserProfiles() }, + dataImporterFactory: @escaping DataImporterFactory = dataImporter, + requestPrimaryPasswordCallback: @escaping @MainActor (Source) -> String? = Self.requestPrimaryPasswordCallback, + openPanelCallback: @escaping @MainActor (DataType) -> URL? = Self.openPanelCallback, + reportSenderFactory: @escaping ReportSenderFactory = { FeedbackSender().sendDataImportReport }) { + + self.availableImportSources = availableImportSources + let importSource = importSource ?? preferredImportSources.first(where: { availableImportSources.contains($0) }) ?? .csv + + self.importSource = importSource + self.loadProfiles = loadProfiles + self.dataImporterFactory = dataImporterFactory + + self.screen = screen ?? importSource.initialScreen + + self.browserProfiles = ThirdPartyBrowser.browser(for: importSource).map(loadProfiles) + self.selectedProfile = browserProfiles?.defaultProfile + + self.selectedDataTypes = importSource.supportedDataTypes + + self.summary = summary + + self.requestPrimaryPasswordCallback = requestPrimaryPasswordCallback + self.openPanelCallback = openPanelCallback + self.reportSenderFactory = reportSenderFactory + } + + /// Import button press (starts browser data import) + @MainActor + mutating func initiateImport(primaryPassword: String? = nil, fileURL: URL? = nil) { + guard let url = fileURL ?? selectedProfile?.profileURL else { + assertionFailure("URL not provided") + return + } + assert(actionButton == .initiateImport(disabled: false) || screen.fileImportDataType != nil || screen.isGetReadPermission) + + // are we handling file import or browser selected data types import? + let dataType: DataType? = self.screen.fileImportDataType + // either import only data type for file import + let dataTypes = dataType.map { [$0] } + // or all the selected data types subtracting the ones that are already imported + ?? selectedDataTypes.subtracting(self.summary.filter { $0.result.isSuccess }.map(\.dataType)) + let importer = dataImporterFactory(importSource, dataType, url, primaryPassword) + + log("import \(dataTypes) at \"\(url.path)\" using \(type(of: importer))") + + // validate file access/encryption password requirement before starting import + if let errors = importer.validateAccess(for: dataTypes), + handleErrors(errors) == true { + return + } + +#if DEBUG || REVIEW + // simulated test import failures + guard dataTypes.compactMap({ testImportResults[$0] }).isEmpty else { + importTask = .detachedWithProgress { [testImportResults] _ in + var result = DataImportSummary() + let selectedDataTypesWithoutFailureReasons = dataTypes.intersection(importer.importableTypes).subtracting(testImportResults.keys) + var realSummary = DataImportSummary() + if !selectedDataTypesWithoutFailureReasons.isEmpty { + realSummary = await importer.importData(types: selectedDataTypesWithoutFailureReasons).task.value + } + for dataType in dataTypes { + if let importResult = testImportResults[dataType] { + result[dataType] = importResult + } else { + result[dataType] = realSummary[dataType] + } + } + return result + } + return + } +#endif + importTask = importer.importData(types: dataTypes) + } + + /// Called with data import task result to update the state by merging the summary with an existing summary + @MainActor + private mutating func mergeImportSummary(with summary: DataImportSummary) { + self.importTask = nil + + log("merging summary \(summary)") + + // append successful import results first keeping the original DataType sorting order + self.summary.append(contentsOf: DataType.allCases.compactMap { dataType in + (try? summary[dataType]?.get()).map { + .init(dataType, .success($0)) + } + }) + + // if there‘s read permission/primary password requested - request it and reinitiate import + if handleErrors(summary.compactMapValues { $0.error }) { return } + + var nextScreen: Screen? + // merge new import results into the model import summary keeping the original DataType sorting order + for (dataType, result) in DataType.allCases.compactMap({ dataType in summary[dataType].map { (dataType, $0) } }) { + switch result { + case .success(let dataTypeSummary): + // if a data type can‘t be imported (Yandex/Passwords) - switch to its file import displaying successful import results + if dataTypeSummary.isEmpty, !(screen.isFileImport && screen.fileImportDataType == dataType), nextScreen == nil { + nextScreen = .fileImport(dataType: dataType, summary: Set(summary.filter({ $0.value.isSuccess }).keys)) + } + case .failure(let error): + // successful imports are appended above + self.summary.append( .init(dataType, result) ) + + // show file import screen when import fails or no bookmarks|passwords found + if !(screen.isFileImport && screen.fileImportDataType == dataType), nextScreen == nil { + // switch to file import of the failed data type displaying successful import results + nextScreen = .fileImport(dataType: dataType, summary: Set(summary.filter({ $0.value.isSuccess }).keys)) + } + Pixel.fire(.dataImportFailed(source: importSource, sourceVersion: importSource.installedAppsMajorVersionDescription(selectedProfile: selectedProfile), error: error)) + } + } + + if let nextScreen { + log("mergeImportSummary: next screen: \(nextScreen)") + self.screen = nextScreen + } else if screenForNextDataTypeRemainingToImport(after: DataType.allCases.last(where: summary.keys.contains)) == nil, // no next data type manual import screen + // and there should be failed data types (and non-recovered) + selectedDataTypes.contains(where: { dataType in self.summary.last(where: { $0.dataType == dataType })?.result.error != nil }) { + log("mergeImportSummary: feedback") + // after last failed datatype show feedback + self.screen = .feedback + } else if screenForNextDataTypeRemainingToImport(after: DataType.allCases.last(where: summary.keys.contains)) == nil { // no next data type manual import screen + let allKeys = self.summary.reduce(into: Set()) { $0.insert($1.dataType) } + log("mergeImportSummary: final summary(\(Set(allKeys)))") + self.screen = .summary(allKeys) + } else { + log("mergeImportSummary: final summary(\(Set(summary.keys)))") + self.screen = .summary(Set(summary.keys)) + } + + if self.areAllSelectedDataTypesSuccessfullyImported { + successfulImportHappened = true + NotificationCenter.default.post(name: .dataImportComplete, object: nil) + } + } + + /// handle recoverable errors (request primary password or file permission) + @MainActor + private mutating func handleErrors(_ summary: [DataType: any DataImportError]) -> Bool { + for error in summary.values { + switch error { + // chromium user denied keychain prompt error + case let error as ChromiumLoginReader.ImportError where error.type == .userDeniedKeychainPrompt: + Pixel.fire(.dataImportFailed(source: importSource, sourceVersion: importSource.installedAppsMajorVersionDescription(selectedProfile: selectedProfile), error: error)) + // stay on the same screen + return true + + // firefox passwords db is master-password protected: request password + case let error as FirefoxLoginReader.ImportError where error.type == .requiresPrimaryPassword: + + log("primary password required") + // stay on the same screen but request password synchronously + if let password = self.requestPrimaryPasswordCallback(importSource) { + self.initiateImport(primaryPassword: password) + } + return true + + // no file read permission error: user must grant permission + case let importError where (importError.underlyingError as? CocoaError)?.code == .fileReadNoPermission: + guard let error = importError.underlyingError as? CocoaError, + let url = error.filePath.map(URL.init(fileURLWithPath:)) ?? error.url else { + assertionFailure("No url") + break + } + log("file read no permission for \(url.path)") + Pixel.fire(.dataImportFailed(source: importSource, sourceVersion: importSource.installedAppsMajorVersionDescription(selectedProfile: selectedProfile), error: importError)) + screen = .getReadPermission(url) + return true + + default: continue + } + } + return false + } + + /// Skip button press + @MainActor mutating func skipImport() { + if let screen = screenForNextDataTypeRemainingToImport(after: screen.fileImportDataType) { + // skip to next non-imported data type + self.screen = screen + } else if selectedDataTypes.first(where: { error(for: $0) != nil }) != nil { + // errors occurred during import: show feedback screen + self.screen = .feedback + } else { + // display total summary + self.screen = .summary(selectedDataTypes) + } + } + + /// Select CSV/HTML file for import button press + @MainActor mutating func selectFile() { + guard let dataType = screen.fileImportDataType else { + assertionFailure("Expected File Import") + return + } + guard let url = openPanelCallback(dataType) else { return } + + self.initiateImport(fileURL: url) + } + + mutating func goBack() { + // reset to initial screen + screen = importSource.initialScreen + summary.removeAll() + } + + func submitReport() { + let sendReport = reportSenderFactory() + sendReport(reportModel) + } + +} + +@MainActor +private func dataImporter(for source: DataImport.Source, fileDataType: DataImport.DataType?, url: URL, primaryPassword: String?) -> DataImporter { + + var profile: DataImport.BrowserProfile { + let browser = ThirdPartyBrowser.browser(for: source) ?? { + assertionFailure("Trying to get browser name for file import source \(source)") + return .chrome + }() + return DataImport.BrowserProfile(browser: browser, profileURL: url) + } + return switch source { + case .bookmarksHTML, + /* any */_ where fileDataType == .bookmarks: + + BookmarkHTMLImporter(fileURL: url, bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared)) + + case .onePassword8, .onePassword7, .bitwarden, .lastPass, .csv, + /* any */_ where fileDataType == .passwords: + CSVImporter(fileURL: url, loginImporter: SecureVaultLoginImporter(), defaultColumnPositions: .init(source: source)) + + case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi: + ChromiumDataImporter(profile: profile, + loginImporter: SecureVaultLoginImporter(), + bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared)) + case .yandex: + YandexDataImporter(profile: profile, + bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared)) + case .firefox, .tor: + FirefoxDataImporter(profile: profile, + primaryPassword: primaryPassword, + loginImporter: SecureVaultLoginImporter(), + bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared), + faviconManager: FaviconManager.shared) + case .safari, .safariTechnologyPreview: + SafariDataImporter(profile: profile, + bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared)) + } +} + +private var isOpenPanelShownFirstTime = true +private var openPanelDirectoryURL: URL? { + // only show Desktop once per launch, then open the last user-selected dir + if isOpenPanelShownFirstTime { + isOpenPanelShownFirstTime = false + return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Desktop") + } else { + return nil + } +} + +extension DataImport.Source { + + var initialScreen: DataImportViewModel.Screen { + switch self { + case .brave, .chrome, .chromium, .coccoc, .edge, .firefox, .opera, + .operaGX, .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex: + return .profileAndDataTypesPicker + case .onePassword8, .onePassword7, .bitwarden, .lastPass, .csv: + return .fileImport(dataType: .passwords) + case .bookmarksHTML: + return .fileImport(dataType: .bookmarks) + } + } + +} + +extension DataImport.DataType { + + static func dataTypes(before dataType: DataImport.DataType, inclusive: Bool) -> [Self].SubSequence { + let index = Self.allCases.firstIndex(of: dataType)! + if inclusive { + return Self.allCases[...index] + } else { + return Self.allCases[.. [Self].SubSequence { + let nextIndex = Self.allCases.firstIndex(of: dataType)! + 1 + return Self.allCases[nextIndex...] + } + + var allowedFileTypes: [UTType] { + switch self { + case .bookmarks: [.html] + case .passwords: [.commaSeparatedText] + } + } + +} + +extension DataImportViewModel { + + private var areAllSelectedDataTypesSuccessfullyImported: Bool { + selectedDataTypes.allSatisfy(isDataTypeSuccessfullyImported) + } + + func summary(for dataType: DataType) -> DataTypeSummary? { + if case .success(let summary) = self.summary.last(where: { $0.dataType == dataType })?.result { + return summary + } + return nil + } + + func isDataTypeSuccessfullyImported(_ dataType: DataType) -> Bool { + summary(for: dataType) != nil + } + + private func screenForNextDataTypeRemainingToImport(after currentDataType: DataType? = nil) -> Screen? { + // keep the original sort order among all data types or only after current data type + for dataType in (currentDataType.map { DataType.dataTypes(after: $0) } ?? DataType.allCases[0...]) where selectedDataTypes.contains(dataType) { + // if some of selected data types failed to import or not imported yet + switch summary.last(where: { $0.dataType == dataType })?.result { + case .success(let summary) where summary.isEmpty: + return .fileImport(dataType: dataType) + case .failure(let error) where error.errorType == .noData: + return .fileImport(dataType: dataType) + case .failure, .none: + return .fileImport(dataType: dataType) + case .success: + continue + } + } + return nil + } + + func error(for dataType: DataType) -> (any DataImportError)? { + if case .failure(let error) = summary.last(where: { $0.dataType == dataType })?.result { + return error + } + return nil + } + + private struct DataImportViewSummarizedError: LocalizedError { + let errors: [any DataImportError] + + var errorDescription: String? { + errors.enumerated().map { + "\($0.offset + 1): \($0.element.localizedDescription)" + }.joined(separator: "\n") + } + } + + var summarizedError: LocalizedError { + let errors = summary.compactMap { $0.result.error } + if errors.count == 1 { + return errors[0] + } + return DataImportViewSummarizedError(errors: errors) + } + + private static func requestPrimaryPasswordCallback(_ source: DataImport.Source) -> String? { + let alert = NSAlert.passwordRequiredAlert(source: source) + let response = alert.runModal() + + guard case .alertFirstButtonReturn = response, + let password = (alert.accessoryView as? NSSecureTextField)?.stringValue else { return nil } + + return password + } + + private static func openPanelCallback(for dataType: DataImport.DataType) -> URL? { + let panel = NSOpenPanel(allowedFileTypes: dataType.allowedFileTypes, + directoryURL: openPanelDirectoryURL) + guard case .OK = panel.runModal(), + let url = panel.url else { return nil } + + return url + } + + var isImportSourcePickerDisabled: Bool { + importSource.initialScreen != screen || importTask != nil + } + + // AsyncStream of Data Import task progress events + var importProgress: TaskProgress? { + guard let importTask else { return nil } + return AsyncStream { + for await event in importTask.progress { + switch event { + case .progress(let update): + log("progress: \(update)") + return .progress(update) + // on completion returns new DataImportViewModel with merged import summary + case .completed(.success(let summary)): + return await .completed(.success(self.mergingImportSummary(summary))) + } + } + return nil + } + } + + enum ButtonType: Hashable { + case next(Screen) + case initiateImport(disabled: Bool) + case skip + case cancel + case back + case done + case submit + + var isDisabled: Bool { + switch self { + case .initiateImport(disabled: let disabled): + return disabled + case .next, .skip, .done, .cancel, .back, .submit: + return false + } + } + } + + @MainActor var actionButton: ButtonType? { + func initiateImport() -> ButtonType { + .initiateImport(disabled: selectedDataTypes.isEmpty || importTask != nil) + } + + switch screen { + case .profileAndDataTypesPicker: + guard let importer = selectedProfile.map({ + dataImporterFactory(/* importSource: */ importSource, + /* dataType: */ nil, + /* profileURL: */ $0.profileURL, + /* primaryPassword: */ nil) + }), + selectedDataTypes.intersects(importer.importableTypes) else { + // no profiles found + // or selected data type not supported by selected browser data importer + guard let type = DataType.allCases.filter(selectedDataTypes.contains).first else { + // disabled Import button + return initiateImport() + } + // use CSV/HTML file import + return .next(.fileImport(dataType: type)) + } + + if importer.requiresKeychainPassword(for: selectedDataTypes) { + return .next(.moreInfo) + } + return initiateImport() + + case .moreInfo: + return initiateImport() + + case .getReadPermission: + return .initiateImport(disabled: true) + + case .fileImport where screen == importSource.initialScreen: + // no default action for File Import sources + return nil + case .fileImport(dataType: let dataType, summary: _) + // exlude all skipped datatypes that are ordered before + where selectedDataTypes.subtracting(DataType.dataTypes(before: dataType, inclusive: true)).isEmpty + // and no failures recorded - otherwise will skip to Feedback + && !summary.contains(where: { !$0.result.isSuccess }): + // no other data types to skip: + return .cancel + case .fileImport: + return .skip + + case .summary(let dataTypes): + if let screen = screenForNextDataTypeRemainingToImport(after: DataType.allCases.last(where: dataTypes.contains)) { + return .next(screen) + } else { + return .done + } + + case .feedback: + return .submit + } + } + + var secondaryButton: ButtonType? { + if importTask == nil { + switch screen { + case importSource.initialScreen, .feedback: + return .cancel + case .moreInfo, .getReadPermission: + return .back + default: + return nil + } + } else { + return .cancel + } + } + + var isSelectFileButtonDisabled: Bool { + importTask != nil + } + + @MainActor var buttons: [ButtonType] { + [secondaryButton, actionButton].compactMap { $0 } + } + + mutating func update(with importSource: Source) { + self = .init(importSource: importSource, loadProfiles: loadProfiles, dataImporterFactory: dataImporterFactory, requestPrimaryPasswordCallback: requestPrimaryPasswordCallback, reportSenderFactory: reportSenderFactory) + } + + @MainActor + mutating func performAction(for buttonType: ButtonType, dismiss: @escaping () -> Void) { + assert(buttons.contains(buttonType)) + + switch buttonType { + case .next(let screen): + self.screen = screen + case .back: + goBack() + + case .initiateImport: + initiateImport() + + case .skip: + skipImport() + + case .cancel: + importTask?.cancel() + self.dismiss(using: dismiss) + + case .submit: + submitReport() + self.dismiss(using: dismiss) + case .done: + self.dismiss(using: dismiss) + } + } + + private mutating func dismiss(using dismiss: @escaping () -> Void) { + // send `bookmarkPromptShouldShow` notification after dismiss if at least one bookmark was imported + if summary.reduce(into: 0, { $0 += $1.dataType == .bookmarks ? (try? $1.result.get().successful) ?? 0 : 0 }) > 0 { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .bookmarkPromptShouldShow, object: nil) + } + } + + log("dismiss") + dismiss() + if case .xcPreviews = NSApp.runType { + self.update(with: importSource) // reset + } + } + + @MainActor + private func mergingImportSummary(_ summary: DataImportSummary) -> Self { + var newState = self + newState.mergeImportSummary(with: summary) + return newState + } + + private var retryNumber: Int { + summary.reduce(into: [:]) { + // get maximum number of failures per data type + $0[$1.dataType, default: 0] += $1.result.isSuccess ? 0 : 1 + }.values.max() ?? 0 + } + + var reportModel: DataImportReportModel { + get { + DataImportReportModel(importSource: importSource, + importSourceVersion: importSource.installedAppsMajorVersionDescription(selectedProfile: selectedProfile), + error: summarizedError, + text: userReportText, + retryNumber: retryNumber) + } + set { + userReportText = newValue.text + } + } + +} diff --git a/DuckDuckGo/DataImport/Model/InstructionsFormatParser.swift b/DuckDuckGo/DataImport/Model/InstructionsFormatParser.swift new file mode 100644 index 0000000000..0f6fdbd576 --- /dev/null +++ b/DuckDuckGo/DataImport/Model/InstructionsFormatParser.swift @@ -0,0 +1,290 @@ +// +// InstructionsFormatParser.swift +// +// Copyright © 2023 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 Common +import Foundation + +/// NSLocalizedString format parser for CSV/HTML data import instructions screen +struct InstructionsFormatParser { + + // Localized String("Formatted text %s %d %@") + enum FormatComponent: Equatable { + // String literals: "Formatted text ", " ", " " + case text(String, bold: Bool = false, italic: Bool = false) + // %d + case number(argIndex: Int) + // %s + case string(argIndex: Int, bold: Bool = false, italic: Bool = false) + // %@: represents inline image or button + case object(argIndex: Int) + + var isNumber: Bool { + if case .number = self { true } else { false } + } + } + + struct ParseError: Error, LocalizedError { + enum ErrorType: Error { + case unexpectedEndOfText, unexpectedEndOfLine, unexpectedEscapedCharacter, escapedDigitExpected + } + + let type: ErrorType + + let format: String + let position: Int + let afterText: String + + var errorDescription: String? { + switch type { + case .unexpectedEndOfText: + "Unexpected end of text: expected escaped character after “\(afterText)” in pattern “\(format)”" + case .unexpectedEndOfLine: + "Unexpected end of line: expected escaped character after “\(afterText)” in pattern “\(format)”" + case .unexpectedEscapedCharacter: + "Unexpected escaped character “\(afterText.last ?? "?")” after “\(afterText)”. Supported escape sequences are: %d - line number, %s - variable string expression, %@ - image or buttom. In pattern “\(format)”" + case .escapedDigitExpected: + "Expected %ld or %lld, got “\(afterText)” in pattern “\(format)”" + } + } + + } + + func parse(format: String) throws -> [[FormatComponent]] { + var parser = Parser() + + var idx: Int! + do { + for (index, character) in format.enumerated() { + idx = index + try parser.accept(character) + } + // eof + try parser.accept(nil) + } catch let errorType as ParseError.ErrorType { + throw ParseError(type: errorType, format: format, position: idx + 1, + afterText: String(format[format.index(format.startIndex, offsetBy: idx - min(10, idx))...format.index(format.startIndex, offsetBy: idx)])) + } + + return parser.result + } + + private struct Parser { + var result: [[FormatComponent]] = [[]] + + // currently collected .text literal + var currentLiteral = "" + // currently collected escape syntax: %1$, **, __ + var currentEscapeSequence = "" { + didSet { + currentArgIndex = 0 + } + } + // when a %-escaped component contains argument number: %12$s + var currentArgIndex: Int = 0 + func countCurrentArgIndex() -> Int { + var count: Int = 0 + for line in result { + for component in line { + switch component { + case .text: continue + case .number, .string, .object: + count += 1 + } + } + } + return count + 1 + } + + // __italic__ = 2, _italic_ = 1, non-italic = 0 + var isItalic: Int = 0 + // **bold** + var isBold = false + + @inline(__always) mutating func append(_ character: Character) { + currentLiteral.append(character) + currentEscapeSequence = "" + } + + @inline(__always) mutating func append(_ component: FormatComponent) { + if case .text = component {} else { + flushField() + } + result[result.endIndex - 1].append(component) + currentEscapeSequence = "" + } + + @inline(__always) mutating func flushField() { + if !currentLiteral.isEmpty { + append(.text(currentLiteral, bold: isBold, italic: isItalic > 0)) + currentLiteral = "" + } + currentEscapeSequence = "" + } + + @inline(__always) mutating func nextLine() { + flushField() + result.append([]) + currentEscapeSequence = "" + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + mutating func accept(_ character: Character?) throws { + switch (currentEscapeSequence, character) { + // beggining of %-escaped seq + case ("", "%"): + currentEscapeSequence.append("%") + + // argument index in %-escaped seq: %12$s + case ("%", .some(let character)) where character.isNumber: + // append digit to arg index + currentArgIndex = currentArgIndex * 10 + (Int(String(character)) ?? 0) + + // arg index ended with $ + case ("%", "$"): + break // ignore + + // %s arg + case ("%", "s"): + append(.string(argIndex: currentArgIndex > 0 ? currentArgIndex : countCurrentArgIndex(), bold: isBold, italic: isItalic > 0)) + + // %@ arg + case ("%", "@"): + append(.object(argIndex: currentArgIndex > 0 ? currentArgIndex : countCurrentArgIndex())) + + // %d %ld %lld args + case ("%", "l"), ("%l", "l"): + currentEscapeSequence.append("l") + case ("%", "d"), ("%l", "d"), ("%ll", "d"): + append(.number(argIndex: currentArgIndex > 0 ? currentArgIndex : countCurrentArgIndex())) + + // %% escaped % char + case ("%", "%"): + currentEscapeSequence = "" + append("%") + + case ("%", _): + throw ParseError.ErrorType.unexpectedEscapedCharacter + case ("%l", _), ("%ll", _): + throw ParseError.ErrorType.escapedDigitExpected + + case ("", "*"), + ("*", "*"): + currentEscapeSequence.append("*") + + case ("*", _): // only one `*` – reset and recurse + append("*") + try accept(character) + + // " " follows ** - reset and recurse + case ("**", .some(let character)) where !character.isWordChar && !isBold: + append("*") + append("*") + try accept(character) + + case ("**", _): + flushField() + isBold.toggle() + try accept(character) + + case ("", "_"), + ("_", "_"): + currentEscapeSequence.append("_") + + // one "_" followed by non-alphanumeric – reset and recurse + case ("_", .some(let character)) where !character.isWordChar && isItalic == 0: + append("_") + try accept(character) + + // one "_" followed by non-alphanumeric when italic == 1: toggle italic + case ("_", .some(let character)) where !character.isWordChar && isItalic == 1: + flushField() + isItalic = 0 + try accept(character) + + case ("_", _) where isItalic == 0 && currentLiteral.last?.isWordChar != true: + // " _word" - italic start + flushField() + isItalic = 1 + try accept(character) + + // word continues after dash + case ("_", _): + append("_") + try accept(character) + + // " " follows __ - reset and recurse + case ("__", .some(let character)) where !character.isWordChar && isItalic == 0: + append("_") + append("_") + try accept(character) + + case ("__", _) where isItalic == 0: + flushField() + isItalic = 2 + try accept(character) + + case ("__", _) where isItalic == 2: + flushField() + isItalic = 0 + try accept(character) + + case (_, "\n"): + if currentEscapeSequence.hasPrefix("%") { + throw ParseError.ErrorType.unexpectedEndOfLine + } + for character in currentEscapeSequence { + append(character) + } + + nextLine() + + case (_, " ") where currentLiteral.isEmpty && (result[result.endIndex - 1].last?.isNumber ?? true): + // trim whitespace after number or for a new line + break + + case (_, .some(let character)): + currentLiteral.append(character) + + case (_, .none): + if currentEscapeSequence.hasPrefix("%") { + throw ParseError.ErrorType.unexpectedEndOfText + } + for character in currentEscapeSequence { + append(character) + } + + flushField() + } + } + } + +} + +private extension Character { + + var isWordChar: Bool { + CharacterSet.wordCharacters.contains(unicodeScalars.first!) + } + +} + +private extension CharacterSet { + + static let wordCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "%.-")) + +} diff --git a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift index 6428ed4b75..8692ac74e3 100644 --- a/DuckDuckGo/DataImport/ThirdPartyBrowser.swift +++ b/DuckDuckGo/DataImport/ThirdPartyBrowser.swift @@ -32,27 +32,40 @@ enum ThirdPartyBrowser: CaseIterable { case brave case chrome + case chromium + case coccoc case edge case firefox + case opera + case operaGX case safari case safariTechnologyPreview + case tor + case vivaldi + case yandex + + case bitwarden case lastPass case onePassword7 case onePassword8 - static var installedBrowsers: [ThirdPartyBrowser] { - return allCases.filter(\.isInstalled) - } - // swiftlint:disable:next cyclomatic_complexity static func browser(for source: DataImport.Source) -> ThirdPartyBrowser? { switch source { case .brave: return .brave case .chrome: return .chrome + case .chromium: return .chromium + case .coccoc: return .coccoc case .edge: return .edge case .firefox: return .firefox + case .opera: return .opera + case .operaGX: return .operaGX case .safari: return .safari case .safariTechnologyPreview: return .safariTechnologyPreview + case .tor: return .tor + case .vivaldi: return .vivaldi + case .yandex: return .yandex + case .bitwarden: return .bitwarden case .lastPass: return .lastPass case .onePassword7: return .onePassword7 case .onePassword8: return .onePassword8 @@ -62,10 +75,7 @@ enum ThirdPartyBrowser: CaseIterable { } var isInstalled: Bool { - let detectedApplicationPath = applicationPath != nil - let detectedBrowserProfiles = !(browserProfiles()?.profiles.isEmpty ?? false) - - return detectedApplicationPath && detectedBrowserProfiles + return applicationPath != nil } var isRunning: Bool { @@ -76,10 +86,18 @@ enum ThirdPartyBrowser: CaseIterable { switch self { case .brave: return .brave case .chrome: return .chrome + case .chromium: return .chromium + case .coccoc: return .coccoc case .edge: return .edge case .firefox: return .firefox + case .opera: return .opera + case .operaGX: return .operaGX case .safari: return .safari case .safariTechnologyPreview: return .safariTechnologyPreview + case .tor: return .tor + case .vivaldi: return .vivaldi + case .yandex: return .yandex + case .bitwarden: return .bitwarden case .onePassword7: return .onePassword7 case .onePassword8: return .onePassword8 case .lastPass: return .lastPass @@ -117,17 +135,42 @@ enum ThirdPartyBrowser: CaseIterable { return nil } + var installedAppsVersions: Set? { + let versions = bundleIdentifiers.all + .reduce(into: Set()) { result, bundleId in + for url in NSWorkspace.shared.urls(forApplicationsWithBundleId: bundleId) { + guard let version = ApplicationVersionReader.getVersion(of: url.path), + !version.isEmpty else { continue } + result.insert(version) + } + } + guard !versions.isEmpty else { return nil } + return versions + } + private var bundleIdentifiers: BundleIdentifiers { switch self { case .brave: return BundleIdentifiers(productionBundleID: "com.brave.Browser", relatedBundleIDs: ["com.brave.Browser.nightly"]) - case .chrome: return BundleIdentifiers(productionBundleID: "com.google.Chrome", relatedBundleIDs: ["com.google.Chrome.canary"]) + case .chrome: return BundleIdentifiers(productionBundleID: "com.google.Chrome", relatedBundleIDs: [ + "com.google.Chrome.canary", + "com.google.Chrome.beta", + "com.google.Chrome.dev" + ]) + case .chromium: return BundleIdentifiers(productionBundleID: "org.chromium.Chromium", relatedBundleIDs: []) + case .coccoc: return BundleIdentifiers(productionBundleID: "com.coccoc.Coccoc", relatedBundleIDs: []) case .edge: return BundleIdentifiers(productionBundleID: "com.microsoft.edgemac", relatedBundleIDs: []) case .firefox: return BundleIdentifiers(productionBundleID: "org.mozilla.firefox", relatedBundleIDs: [ "org.mozilla.nightly", "org.mozilla.firefoxdeveloperedition" ]) + case .opera: return BundleIdentifiers(productionBundleID: "com.operasoftware.Opera", relatedBundleIDs: []) + case .operaGX: return BundleIdentifiers(productionBundleID: "com.operasoftware.OperaGX", relatedBundleIDs: []) case .safari: return BundleIdentifiers(productionBundleID: "com.apple.safari", relatedBundleIDs: []) case .safariTechnologyPreview: return BundleIdentifiers(productionBundleID: "com.apple.SafariTechnologyPreview", relatedBundleIDs: []) + case .tor: return BundleIdentifiers(productionBundleID: "org.torproject.torbrowser", relatedBundleIDs: []) + case .vivaldi: return BundleIdentifiers(productionBundleID: "com.vivaldi.Vivaldi", relatedBundleIDs: []) + case .yandex: return BundleIdentifiers(productionBundleID: "ru.yandex.desktop.yandex-browser", relatedBundleIDs: []) + case .bitwarden: return BundleIdentifiers(productionBundleID: "com.bitwarden.desktop", relatedBundleIDs: []) case .onePassword7: return BundleIdentifiers(productionBundleID: "com.agilebits.onepassword7", relatedBundleIDs: [ "com.agilebits.onepassword", "com.agilebits.onepassword4" @@ -147,24 +190,56 @@ enum ThirdPartyBrowser: CaseIterable { } } - func browserProfiles(supportDirectoryURL: URL? = nil) -> DataImport.BrowserProfileList? { - let applicationSupportURL = supportDirectoryURL ?? URL.nonSandboxApplicationSupportDirectoryURL - - // Safari is an exception, as it may need permissions granted before being able to read the contents of the profile path. To be safe, - // return the profile anyway and check the file system permissions when preparing to import. - if [.safari, .safariTechnologyPreview].contains(self), - let profilePath = profilesDirectory(applicationSupportURL: applicationSupportURL) { - return DataImport.BrowserProfileList(browser: self, profileURLs: [profilePath]) + func browserProfiles(applicationSupportURL: URL? = nil) -> DataImport.BrowserProfileList { + var potentialProfileURLs: [URL] { + let fm = FileManager() + let profilesDirectories = self.profilesDirectories(applicationSupportURL: applicationSupportURL) + return profilesDirectories.reduce(into: []) { result, profilesDir in + result.append(contentsOf: (try? fm.contentsOfDirectory(at: profilesDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + .filter(\.hasDirectoryPath)) ?? []) + } + profilesDirectories } - guard let profilePath = profilesDirectory(applicationSupportURL: applicationSupportURL), - let potentialProfileURLs = try? FileManager.default.contentsOfDirectory(at: profilePath, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles]).filter(\.hasDirectoryPath) else { - return nil + let profiles: [DataImport.BrowserProfile] + switch self { + case .safari, .safariTechnologyPreview: + // Safari is an exception, as it may need permissions granted before being able to read the contents of the profile path. To be safe, + // return the profile anyway and check the file system permissions when preparing to import. + guard let profileURL = profilesDirectories(applicationSupportURL: applicationSupportURL).first else { + assertionFailure("Unexpected nil profileURL for Safari") + profiles = [] + break + } + profiles = [DataImport.BrowserProfile(browser: self, profileURL: profileURL)] + + case .brave, .chrome, .chromium, .coccoc, .edge, .opera, .operaGX, .vivaldi, .yandex: + // Chromium profiles are either named "Default", or a series of incrementing profile names, i.e. "Profile 1", "Profile 2", etc. + let potentialProfiles = potentialProfileURLs.map { + DataImport.BrowserProfile(browser: self, profileURL: $0) + } + + let filteredProfiles = potentialProfiles.filter { + $0.profilePreferences?.isChromium == true + || $0.profileName == DataImport.BrowserProfileList.Constants.chromiumDefaultProfileName + || $0.profileName.hasPrefix(DataImport.BrowserProfileList.Constants.chromiumProfilePrefix) + } + + let sortedProfiles = filteredProfiles.sorted() + + profiles = sortedProfiles + + case .firefox, .tor: + profiles = potentialProfileURLs.map { + DataImport.BrowserProfile(browser: self, profileURL: $0) + }.sorted() + + case .bitwarden, .lastPass, .onePassword7, .onePassword8: + profiles = [] } - return DataImport.BrowserProfileList(browser: self, profileURLs: potentialProfileURLs) + return DataImport.BrowserProfileList(browser: self, profiles: profiles) } private func findRunningApplications() -> [NSRunningApplication] { @@ -179,15 +254,45 @@ enum ThirdPartyBrowser: CaseIterable { } // Returns the URL to the profiles for a given browser. This directory will contain a list of directories, each representing a profile. - private func profilesDirectory(applicationSupportURL: URL) -> URL? { + // swiftlint:disable:next cyclomatic_complexity + func profilesDirectories(applicationSupportURL: URL? = nil) -> [URL] { + let applicationSupportURL = applicationSupportURL ?? URL.nonSandboxApplicationSupportDirectoryURL + return switch self { + case .brave: [applicationSupportURL.appendingPathComponent("BraveSoftware/Brave-Browser/")] + case .chrome: [ + applicationSupportURL.appendingPathComponent("Google/Chrome/"), + applicationSupportURL.appendingPathComponent("Google/Chrome Beta/"), + applicationSupportURL.appendingPathComponent("Google/Chrome Dev/"), + applicationSupportURL.appendingPathComponent("Google/Chrome Canary/"), + ] + case .chromium: [applicationSupportURL.appendingPathComponent("Chromium/")] + case .coccoc: [applicationSupportURL.appendingPathComponent("Coccoc/")] + case .edge: [applicationSupportURL.appendingPathComponent("Microsoft Edge/")] + case .firefox: [applicationSupportURL.appendingPathComponent("Firefox/Profiles/")] + case .opera: [applicationSupportURL.appendingPathComponent("com.operasoftware.Opera/")] + case .operaGX: [applicationSupportURL.appendingPathComponent("com.operasoftware.OperaGX/")] + case .safari: [URL.nonSandboxLibraryDirectoryURL.appendingPathComponent("Safari/")] + case .safariTechnologyPreview: [URL.nonSandboxLibraryDirectoryURL.appendingPathComponent("SafariTechnologyPreview/")] + case .tor: [applicationSupportURL.appendingPathComponent("TorBrowser-Data/Browser/")] + case .vivaldi: [applicationSupportURL.appendingPathComponent("Vivaldi/")] + case .yandex: [applicationSupportURL.appendingPathComponent("Yandex/YandexBrowser/")] + case .bitwarden, .lastPass, .onePassword7, .onePassword8: [] + } + } + + var keychainProcessName: String { switch self { - case .brave: return applicationSupportURL.appendingPathComponent("BraveSoftware/Brave-Browser/") - case .chrome: return applicationSupportURL.appendingPathComponent("Google/Chrome/") - case .edge: return applicationSupportURL.appendingPathComponent("Microsoft Edge/") - case .firefox: return applicationSupportURL.appendingPathComponent("Firefox/Profiles/") - case .safari: return URL.nonSandboxLibraryDirectoryURL.appendingPathComponent("Safari/") - case .safariTechnologyPreview: return URL.nonSandboxLibraryDirectoryURL.appendingPathComponent("SafariTechnologyPreview/") - case .lastPass, .onePassword7, .onePassword8: return nil + case .brave: "Brave" + case .chrome: "Chrome" + case .chromium: "Chromium" + case .coccoc: "CocCoc" + case .edge: "Microsoft Edge" + case .opera, .operaGX: "Opera" + case .vivaldi: "Vivaldi" + case .yandex: "Yandex" + // do not require Keychain access + case .firefox, .safari, .safariTechnologyPreview, .tor, + .bitwarden, .lastPass, .onePassword7, .onePassword8: "" } } diff --git a/DuckDuckGo/DataImport/View/BrowserImportMoreInfoView.swift b/DuckDuckGo/DataImport/View/BrowserImportMoreInfoView.swift new file mode 100644 index 0000000000..24806677bb --- /dev/null +++ b/DuckDuckGo/DataImport/View/BrowserImportMoreInfoView.swift @@ -0,0 +1,62 @@ +// +// BrowserImportMoreInfoView.swift +// +// Copyright © 2023 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 SwiftUI + +struct BrowserImportMoreInfoView: View { + + private let source: DataImport.Source + + init(source: DataImport.Source) { + self.source = source + } + + var body: some View { + switch source { + case .chrome, .chromium, .coccoc, .edge, .brave, .opera, .operaGX, .vivaldi: + Text(""" + If your computer prompts you to enter a password prior to import, DuckDuckGo will not see that password. + + Imported passwords are stored securely using encryption. + """, comment: "Warning that Chromium data import would require entering system passwords.") + + case .firefox: + Text(""" + You'll be asked to enter your Primary Password for \(source.importSourceName). + + Imported passwords are encrypted and only stored on this computer. + """, comment: "Warning that Firefox-based browser name (%@) data import would require entering a Primary Password for the browser.") + + case .safari, .safariTechnologyPreview, .yandex, .csv, .bitwarden, .lastPass, .onePassword7, .onePassword8, .bookmarksHTML, .tor: + fatalError("Unsupported source for more info") + } + } + +} + +#Preview { + VStack(alignment: .leading, spacing: 20) { + BrowserImportMoreInfoView(source: .chrome) + + Divider() + + BrowserImportMoreInfoView(source: .firefox) + } + .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) + .frame(width: 512) +} diff --git a/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift b/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift deleted file mode 100644 index 18263ab43c..0000000000 --- a/DuckDuckGo/DataImport/View/BrowserImportMoreInfoViewController.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// BrowserImportMoreInfoViewController.swift -// -// Copyright © 2022 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import AppKit - -final class BrowserImportMoreInfoViewController: NSViewController { - - enum Constants { - static let storyboardName = "DataImport" - static let identifier = "BrowserImportMoreInfoViewController" - } - - static func create(source: DataImport.Source) -> Self { - let storyboard = NSStoryboard(name: Constants.storyboardName, bundle: nil) - - return storyboard.instantiateController(identifier: Constants.identifier) { (coder) -> Self? in - return Self(coder: coder, source: source) - } - } - - let source: DataImport.Source - - init?(coder: NSCoder, source: DataImport.Source) { - self.source = source - super.init(coder: coder) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - @IBOutlet weak var label: NSTextField! - - override func viewDidLoad() { - super.viewDidLoad() - switch source { - case .chrome, .edge, .brave: - label.stringValue = UserText.importFromChromiumMoreInfo - - case .firefox: - label.stringValue = UserText.importFromFirefoxMoreInfo - - case .safari, .safariTechnologyPreview, .csv, .lastPass, .onePassword7, .onePassword8, .bookmarksHTML: - fatalError("Unsupported source for more info") - } - } - -} diff --git a/DuckDuckGo/DataImport/View/BrowserImportSummaryViewController.swift b/DuckDuckGo/DataImport/View/BrowserImportSummaryViewController.swift deleted file mode 100644 index ded5d828f9..0000000000 --- a/DuckDuckGo/DataImport/View/BrowserImportSummaryViewController.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// BrowserImportSummaryViewController.swift -// -// Copyright © 2021 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 - -final class BrowserImportSummaryViewController: NSViewController { - - enum Constants { - static let storyboardName = "DataImport" - static let identifier = "BrowserImportSummaryViewController" - } - - static func create(importSummary: DataImport.Summary) -> BrowserImportSummaryViewController { - let storyboard = NSStoryboard(name: Constants.storyboardName, bundle: nil) - - return storyboard.instantiateController(identifier: Constants.identifier) { (coder) -> BrowserImportSummaryViewController? in - return BrowserImportSummaryViewController(coder: coder, summary: importSummary) - } - } - - @IBOutlet var summaryRowsStackView: NSStackView! - - @IBOutlet var bookmarkSummaryRow: NSView! - @IBOutlet var bookmarkSummaryLabel: NSTextField! - - @IBOutlet var bookmarkDuplicatesRow: NSView! - @IBOutlet var bookmarkDuplicatesLabel: NSTextField! - - @IBOutlet var bookmarkFailureRow: NSView! - @IBOutlet var bookmarkFailureLabel: NSTextField! - - @IBOutlet var passwordSummaryRow: NSView! - @IBOutlet var passwordSummaryLabel: NSTextField! - - private let summary: DataImport.Summary - - init?(coder: NSCoder, summary: DataImport.Summary) { - self.summary = summary - super.init(coder: coder) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - configureUserInterface() - } - - private func configureUserInterface() { - summaryRowsStackView.arrangedSubviews.forEach { arrangedSubview in - arrangedSubview.isHidden = true - } - - if let result = summary.bookmarksResult { - bookmarkSummaryRow.isHidden = false - bookmarkSummaryLabel.stringValue = UserText.successfulBookmarkImports(result.successful) - - if result.duplicates > 0 { - bookmarkDuplicatesRow.isHidden = false - bookmarkDuplicatesLabel.stringValue = UserText.duplicateBookmarkImports(result.duplicates) - } else { - bookmarkDuplicatesRow.isHidden = true - } - - if result.failed > 0 { - bookmarkFailureRow.isHidden = false - bookmarkFailureLabel.stringValue = UserText.failedBookmarkImports(result.failed) - } else { - bookmarkFailureRow.isHidden = true - } - } - if case .completed(let result) = summary.loginsResult { - passwordSummaryRow.isHidden = false - passwordSummaryLabel.stringValue = UserText - .loginImportSuccessfulBrowserImports(totalSuccessfulImports: result.successfulImports.count) - } - } - -} diff --git a/DuckDuckGo/DataImport/View/BrowserImportViewController.swift b/DuckDuckGo/DataImport/View/BrowserImportViewController.swift deleted file mode 100644 index d61c77f52f..0000000000 --- a/DuckDuckGo/DataImport/View/BrowserImportViewController.swift +++ /dev/null @@ -1,163 +0,0 @@ -// -// BrowserImportViewController.swift -// -// Copyright © 2021 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 - -protocol BrowserImportViewControllerDelegate: AnyObject { - - func browserImportViewController(_ viewController: BrowserImportViewController, didChangeSelectedImportOptions options: [DataImport.DataType]) - func browserImportViewControllerRequestedParentViewRefresh(_ viewController: BrowserImportViewController) - -} - -final class BrowserImportViewController: NSViewController { - - enum Constants { - static let storyboardName = "DataImport" - static let identifier = "BrowserImportViewController" - static let browserWarningBarHeight: CGFloat = 32.0 - } - - static func create(with browser: DataImport.Source, profileList: DataImport.BrowserProfileList) -> BrowserImportViewController { - let storyboard = NSStoryboard(name: Constants.storyboardName, bundle: nil) - - return storyboard.instantiateController(identifier: Constants.identifier) { (coder) -> BrowserImportViewController? in - return BrowserImportViewController(coder: coder, browser: browser, profileList: profileList) - } - } - - @IBOutlet var importOptionsStackView: NSStackView! - - @IBOutlet var profileSelectionLabel: NSTextField! - @IBOutlet var profileSelectionPopUpButton: NSPopUpButton! - - @IBOutlet var bookmarksCheckbox: NSButton! - @IBOutlet var passwordsCheckbox: NSButton! - @IBOutlet var passwordsWarningLabel: NSTextField! - - weak var delegate: BrowserImportViewControllerDelegate? - - var selectedImportOptions: [DataImport.DataType] { - var options = [DataImport.DataType]() - - if bookmarksCheckbox.state == .on && !bookmarksCheckbox.isHidden { - options.append(.bookmarks) - } - - if passwordsCheckbox.state == .on && !passwordsCheckbox.isHidden { - options.append(.logins) - } - - return options - } - - var selectedProfile: DataImport.BrowserProfile? { - guard let selectedProfile = profileSelectionPopUpButton.selectedItem else { - // If there is no selected item, there should only be one item in the list. - return profileList.validImportableProfiles.first - } - - return profileList.validImportableProfiles.first { $0.profileURL == selectedProfile.representedObject as? URL } - } - - let browser: DataImport.Source - let profileList: DataImport.BrowserProfileList - - init?(coder: NSCoder, browser: DataImport.Source, profileList: DataImport.BrowserProfileList) { - self.browser = browser - self.profileList = profileList - - super.init(coder: coder) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - // Update the profile picker: - - importOptionsStackView.setCustomSpacing(18, after: profileSelectionPopUpButton) - - if profileList.showProfilePicker { - profileSelectionPopUpButton.displayBrowserProfiles(profiles: profileList.validImportableProfiles, - defaultProfile: profileList.defaultProfile) - } else { - profileSelectionLabel.isHidden = true - profileSelectionPopUpButton.isHidden = true - profileSelectionPopUpButton.removeAllItems() - } - - switch browser { - case .safari, .safariTechnologyPreview: - bookmarksCheckbox.title = UserText.bookmarkImportBookmarksAndFavorites - guard let safariMajorVersion = SafariVersionReader.getMajorVersion() else { - assertionFailure("Failed to get version of Safari") - passwordsWarningLabel.isHidden = false - return - } - - passwordsWarningLabel.isHidden = safariMajorVersion >= 15 - default: - bookmarksCheckbox.title = UserText.bookmarkImportBookmarks - passwordsWarningLabel.isHidden = true - } - } - - override func viewWillAppear() { - super.viewWillAppear() - delegate?.browserImportViewControllerRequestedParentViewRefresh(self) - } - - @IBAction func selectedImportOptionsChanged(_ sender: NSButton) { - delegate?.browserImportViewController(self, didChangeSelectedImportOptions: selectedImportOptions) - } - -} - -extension NSPopUpButton { - - fileprivate func displayBrowserProfiles(profiles: [DataImport.BrowserProfile], defaultProfile: DataImport.BrowserProfile?) { - removeAllItems() - - let validProfiles = profiles.filter { $0.hasBrowserData } - - var selectedSourceIndex: Int? - - for (index, profile) in validProfiles.enumerated() { - // Duplicate profile names won‘t be added to the Popup: need to deduplicate - var profileName: String - var i = 0 - repeat { - profileName = profile.profileName + (i > 0 ? " - \(i)" : "") - i += 1 - } while itemTitles.contains(profileName) - - addItem(withTitle: profileName, representedObject: profile.profileURL) - - if profile.profileURL == defaultProfile?.profileURL { - selectedSourceIndex = index - } - } - - selectItem(at: selectedSourceIndex ?? 0) - } - -} diff --git a/DuckDuckGo/DataImport/View/DataImport.storyboard b/DuckDuckGo/DataImport/View/DataImport.storyboard deleted file mode 100644 index 2c75c784c1..0000000000 --- a/DuckDuckGo/DataImport/View/DataImport.storyboard +++ /dev/null @@ -1,2142 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - First line - -Second line - -Third line - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DuckDuckGo/DataImport/View/DataImportErrorView.swift b/DuckDuckGo/DataImport/View/DataImportErrorView.swift new file mode 100644 index 0000000000..96740b2cc9 --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportErrorView.swift @@ -0,0 +1,51 @@ +// +// DataImportErrorView.swift +// +// Copyright © 2023 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 +import SwiftUI + +struct DataImportErrorView: View { + + let source: DataImport.Source + let dataType: DataImport.DataType + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + switch dataType { + case .bookmarks: + Text("We were unable to import bookmarks directly from \(source.importSourceName).", + comment: "Message when data import fails from a browser. %@ - a browser name") + .bold() + case .passwords: + Text("We were unable to import passwords directly from \(source.importSourceName).", + comment: "Message when data import fails from a browser. %@ - a browser name") + .bold() + } + + Text("Let’s try doing it manually. It won’t take long.", + comment: "Suggestion to switch to a Manual File Data Import when data import fails.") + } + } + +} + +#Preview { + DataImportNoDataView(source: .chrome, dataType: .bookmarks) + .frame(width: 512 - 20) + .padding() +} diff --git a/DuckDuckGo/DataImport/View/DataImportNoDataView.swift b/DuckDuckGo/DataImport/View/DataImportNoDataView.swift new file mode 100644 index 0000000000..84fb50a583 --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportNoDataView.swift @@ -0,0 +1,53 @@ +// +// DataImportNoDataView.swift +// +// Copyright © 2023 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 +import SwiftUI + +struct DataImportNoDataView: View { + + let source: DataImport.Source + let dataType: DataImport.DataType + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + switch dataType { + case .bookmarks: + Text("We couldn‘t find any bookmarks.", comment: "Data import error message: Bookmarks weren‘t found.") + .bold() + + Text("Try importing bookmarks manually instead.", + comment: "Data import error subtitle: suggestion to import Bookmarks manually by selecting a CSV or HTML file.") + + case .passwords: + Text("We couldn‘t find any passwords.", comment: "Data import error message: Passwords weren‘t found.") + .bold() + + Text("Try importing passwords manually instead.", + comment: "Data import error subtitle: suggestion to import Passwords manually by selecting a CSV or HTML file.") + } + } + } + +} + +#Preview { + DataImportNoDataView(source: .chrome, dataType: .bookmarks) + .frame(width: 512 - 20) + .padding() +} diff --git a/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift new file mode 100644 index 0000000000..5f3ac8e1dc --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportProfilePicker.swift @@ -0,0 +1,82 @@ +// +// DataImportProfilePicker.swift +// +// Copyright © 2023 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 SwiftUI + +struct DataImportProfilePicker: View { + + private let profiles: [DataImport.BrowserProfile] + @Binding private var selectedProfile: DataImport.BrowserProfile? + private let shouldDisplayFolderName: Bool + + init(profileList: DataImport.BrowserProfileList?, selectedProfile: Binding) { + self.profiles = profileList?.validImportableProfiles ?? [] + self._selectedProfile = selectedProfile + shouldDisplayFolderName = Set(self.profiles.map { + $0.profileURL.deletingLastPathComponent() + }).count > 1 + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Select Profile:", comment: "Browser Profile picker title for Data Import") + .bold() + + Picker(selection: Binding { + selectedProfile.flatMap(profiles.firstIndex(of:)) ?? 0 + } set: { + selectedProfile = profiles[safe: $0] + }) { + ForEach(profiles.indices, id: \.self) { idx in + // display profiles folder name if multiple profiles folders are present (Chrome, Chrome Canary…) + if shouldDisplayFolderName { + Text(profiles[idx].profileName + " ") + + Text(profiles[idx].profileURL + .deletingLastPathComponent().lastPathComponent) + .font(.system(size: 10)) + .fontWeight(.light) + } else { + Text(profiles[idx].profileName) + } + } + } label: {} + .pickerStyle(.menu) + .controlSize(.large) + } + } + +} + +#Preview { + DataImportProfilePicker(profileList: .init(browser: .chrome, profiles: [ + .init(browser: .chrome, + profileURL: URL(fileURLWithPath: "/Chrome/Default Profile")), + .init(browser: .chrome, + profileURL: URL(fileURLWithPath: "/Chrome Dev/Profile 1")), + .init(browser: .chrome, + profileURL: URL(fileURLWithPath: "/Chrome Canary/Profile 2")), + ], validateProfileData: { _ in { .init(logins: .available, bookmarks: .available) } }), selectedProfile: Binding { + .init(browser: .chrome, + profileURL: URL(fileURLWithPath: "/test/Profile 1")) + } set: { + print("Profile selected:", $0?.profileURL.lastPathComponent ?? "") + }) + .padding() + .frame(width: 512) + .font(.system(size: 13)) +} diff --git a/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift b/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift new file mode 100644 index 0000000000..3f94ffe2a0 --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportSourcePicker.swift @@ -0,0 +1,70 @@ +// +// DataImportSourcePicker.swift +// +// Copyright © 2023 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 SwiftUI + +@MainActor +struct DataImportSourcePicker: View { + + @State private var viewModel: DataImportSourceViewModel + + private let onSelectedSourceChanged: (DataImport.Source) -> Void + + private var importSources: [DataImport.Source?] { + viewModel.importSources + } + + init(importSources: [DataImport.Source], + selectedSource: DataImport.Source, + onSelectedSourceChanged: @escaping (DataImport.Source) -> Void) { + self.viewModel = DataImportSourceViewModel(importSources: importSources, selectedSource: selectedSource) + self.onSelectedSourceChanged = onSelectedSourceChanged + } + + var body: some View { + Picker(selection: $viewModel.selectedSourceIndex) { + ForEach(importSources.indices, id: \.self) { idx in + if let source = importSources[idx] { + HStack { + if let icon = source.importSourceImage?.resized(to: NSSize(width: 16, height: 16)) { + Image(nsImage: icon) + } + Text(source.importSourceName) + } + } else { + Divider() + } + } + } label: {} + .pickerStyle(.menu) + .controlSize(.large) + .onChange(of: viewModel.selectedSourceIndex) { idx in + guard let importSource = importSources[idx] else { return } + onSelectedSourceChanged(importSource) + } + } + +} + +#Preview { + DataImportSourcePicker(importSources: DataImport.Source.allCases, selectedSource: .csv) { + print("selection:", $0) + } + .padding() + .frame(width: 500) +} diff --git a/DuckDuckGo/DataImport/View/DataImportSummaryView.swift b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift new file mode 100644 index 0000000000..6ed0efab7c --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportSummaryView.swift @@ -0,0 +1,147 @@ +// +// DataImportSummaryView.swift +// +// Copyright © 2023 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 SwiftUI + +struct DataImportSummaryView: View { + + typealias DataType = DataImport.DataType + typealias Summary = DataImport.DataTypeSummary + + let model: DataImportSummaryViewModel + + init(_ importViewModel: DataImportViewModel, dataTypes: Set? = nil) { + self.init(model: .init(source: importViewModel.importSource, results: importViewModel.summary, dataTypes: dataTypes)) + } + + init(model: DataImportSummaryViewModel) { + self.model = model + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + { + switch model.summaryKind { + case .results, .importComplete(.passwords): + Text("Import Results:", comment: "Data Import result summary headline") + + case .importComplete(.bookmarks), + .fileImportComplete(.bookmarks): + Text("Bookmarks Import Complete:", comment: "Bookmarks Data Import result summary headline") + + case .fileImportComplete(.passwords): + Text("Password import complete. You can now delete the saved passwords file.", comment: "message about Passwords Data Import completion") + } + }().padding(.bottom, 4) + + ForEach(model.results, id: \.dataType) { item in + switch (item.dataType, item.result) { + case (.bookmarks, .success(let summary)): + HStack { + successImage() + Text("Bookmarks:", + comment: "Data import summary format of how many bookmarks (%lld) were successfully imported.") + + Text(" " as String) + + Text(String(summary.successful)).bold() + } + if summary.duplicate > 0 { + HStack { + failureImage() + Text("Duplicate Bookmarks Skipped:", + comment: "Data import summary format of how many duplicate bookmarks (%lld) were skipped during import.") + + Text(" " as String) + + Text(String(summary.duplicate)).bold() + } + } + if summary.failed > 0 { + HStack { + failureImage() + Text("Bookmark import failed:", + comment: "Data import summary format of how many bookmarks (%lld) failed to import.") + + Text(" " as String) + + Text(String(summary.failed)).bold() + } + } + + case (.bookmarks, .failure): + HStack { + failureImage() + Text("Bookmark import failed.", + comment: "Data import summary message of failed bookmarks import.") + } + + case (.passwords, .failure): + HStack { + failureImage() + Text("Password import failed.", + comment: "Data import summary message of failed passwords import.") + } + + case (.passwords, .success(let summary)): + HStack { + successImage() + Text("Passwords:", + comment: "Data import summary format of how many passwords (%lld) were successfully imported.") + + Text(" " as String) + + Text(String(summary.successful)).bold() + } + if summary.failed > 0 { + HStack { + failureImage() + Text("Password import failed: ", + comment: "Data import summary format of how many passwords (%lld) failed to import.") + + Text(" " as String) + + Text(String(summary.failed)).bold() + } + } + } + } + } + } + +} + +private func successImage() -> some View { + Image(.successCheckmark) + .frame(width: 16, height: 16) +} + +private func failureImage() -> some View { + Image(.error) + .frame(width: 16, height: 16) +} + +#if DEBUG +#Preview { + VStack { + HStack { + DataImportSummaryView(model: .init(source: .chrome, results: [ +// .init(.bookmarks, .success(.init(successful: 123, duplicate: 456, failed: 7890))), +// .init(.passwords, .success(.init(successful: 123, duplicate: 456, failed: 7890))), +// .init(.bookmarks, .failure(DataImportViewModel.TestImportError(action: .bookmarks, errorType: .dataCorrupted))), +// .init(.bookmarks, .failure(DataImportViewModel.TestImportError(action: .passwords, errorType: .keychainError))), + .init(.passwords, .failure(DataImportViewModel.TestImportError(action: .passwords, errorType: .keychainError))), + .init(.passwords, .failure(DataImportViewModel.TestImportError(action: .passwords, errorType: .keychainError))), + ])) + .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) + Spacer() + } + } + .frame(width: 512) +} +#endif diff --git a/DuckDuckGo/DataImport/View/DataImportTypePicker.swift b/DuckDuckGo/DataImport/View/DataImportTypePicker.swift new file mode 100644 index 0000000000..db6c2c9dd9 --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportTypePicker.swift @@ -0,0 +1,74 @@ +// +// DataImportTypePicker.swift +// +// Copyright © 2023 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 SwiftUI + +struct DataImportTypePicker: View { + + @Binding var viewModel: DataImportViewModel + + init(viewModel: Binding) { + _viewModel = viewModel + } + + var body: some View { + VStack(alignment: .leading) { + Text("Select data to import:", + comment: "Data Import section title for checkboxes of data type to import: Passwords or Bookmarks.") + .bold() + + ForEach(DataImport.DataType.allCases, id: \.self) { dataType in + // display all types for a browser disabling unavailable options + if viewModel.importSource.isBrowser + // display only supported types for a non-browser + || viewModel.importSource.supportedDataTypes.contains(dataType) { + + Toggle(isOn: Binding { + viewModel.selectedDataTypes.contains(dataType) + } set: { isOn in + viewModel.setDataType(dataType, selected: isOn) + }) { + Text(dataType.displayName) + } + .disabled(!viewModel.importSource.supportedDataTypes.contains(dataType)) + + // subtitle + if case .passwords = dataType, + !viewModel.importSource.supportedDataTypes.contains(.passwords) { + Text("\(viewModel.importSource.importSourceName) does not support storing passwords", + comment: "Data Import disabled checkbox message about a browser (%@) not supporting storing passwords") + .foregroundColor(Color(.disabledControlTextColor)) + } + } + } + } + } + +} + +extension DataImportViewModel { + + mutating func setDataType(_ dataType: DataType, selected: Bool) { + if selected { + selectedDataTypes.insert(dataType) + } else { + selectedDataTypes.remove(dataType) + } + } + +} diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift new file mode 100644 index 0000000000..4cb60196b9 --- /dev/null +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -0,0 +1,561 @@ +// +// DataImportView.swift +// +// Copyright © 2023 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 SwiftUI + +extension DataImportView { + + @MainActor + static func show(completion: (() -> Void)? = nil) { + guard let window = WindowControllersManager.shared.lastKeyMainWindowController?.window else { return } + + if !window.isKeyWindow { + window.makeKeyAndOrderFront(nil) + } + + let sheetWindow = SheetHostingWindow(rootView: DataImportView()) + + window.beginSheet(sheetWindow, completionHandler: completion.map { completion in { _ in + completion() + } + }) + } + +} + +@MainActor +struct DataImportView: View { + + @Environment(\.dismiss) private var dismiss + + @State var model = DataImportViewModel() + + struct ProgressState { + let text: String? + let fraction: Double? + let updated: CFTimeInterval + } + @State private var progress: ProgressState? + +#if DEBUG || REVIEW + @State private var debugViewDisabled: Bool = false +#endif + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + viewHeader() + .padding(.top, 20) + .padding(.leading, 20) + .padding(.trailing, 20) + + viewBody() + .padding(.leading, 20) + .padding(.trailing, 20) + .padding(.bottom, 32) + + // if import in progress… + if let importProgress = model.importProgress { + progressView(importProgress) + .padding(.leading, 20) + .padding(.trailing, 20) + .padding(.bottom, 8) + } + + Divider() + + viewFooter() + .padding(.top, 16) + .padding(.bottom, 16) + .padding(.trailing, 20) + +#if DEBUG || REVIEW + if !debugViewDisabled { + debugView() + } +#endif + } + .font(.system(size: 13)) + .frame(width: 512) + .fixedSize() + } + + private func viewHeader() -> some View { + VStack(alignment: .leading, spacing: 0) { + Text(UserText.importDataTitle) + .bold() + .padding(.bottom, 16) + + // browser to import data from picker popup + if case .feedback = model.screen {} else { + DataImportSourcePicker(importSources: model.availableImportSources, selectedSource: model.importSource) { importSource in + model.update(with: importSource) + } + .disabled(model.isImportSourcePickerDisabled) + .padding(.bottom, 24) + } + } + } + + private func viewBody() -> some View { + VStack(alignment: .leading, spacing: 0) { + // body + switch model.screen { + case .profileAndDataTypesPicker: + // Browser Profile picker + if model.browserProfiles?.validImportableProfiles.count ?? 0 > 1 { + DataImportProfilePicker(profileList: model.browserProfiles, + selectedProfile: $model.selectedProfile) + .disabled(model.isImportSourcePickerDisabled) + .padding(.bottom, 24) + } + + // Bookmarks/Passwords checkboxes + DataImportTypePicker(viewModel: $model) + .disabled(model.isImportSourcePickerDisabled) + + case .moreInfo: + // you will be asked for your keychain password blah blah... + BrowserImportMoreInfoView(source: model.importSource) + + case .getReadPermission(let url): + // give request to Safari folder, select Bookmarks.plist using open panel + RequestFilePermissionView(source: model.importSource, url: url, requestDataDirectoryPermission: SafariDataImporter.requestDataDirectoryPermission) { _ in + + model.initiateImport() + } + + case .fileImport(let dataType, summary: let summaryTypes): + if !summaryTypes.isEmpty { + DataImportSummaryView(model, dataTypes: summaryTypes) + .padding(.bottom, 24) + } + + // if no data to import + if model.summary(for: dataType)?.isEmpty == true + || model.error(for: dataType)?.errorType == .noData { + + DataImportNoDataView(source: model.importSource, dataType: dataType) + .padding(.bottom, 24) + + // if browser importer failed - display error message + } else if model.error(for: dataType) != nil { + DataImportErrorView(source: model.importSource, dataType: dataType) + .padding(.bottom, 24) + } + + // manual file import instructions for CSV/HTML + FileImportView(source: model.importSource, dataType: dataType, isButtonDisabled: model.isSelectFileButtonDisabled) { + model.selectFile() + } onFileDrop: { url in + model.initiateImport(fileURL: url) + } + + case .summary(let dataTypes): + DataImportSummaryView(model, dataTypes: dataTypes) + + case .feedback: + DataImportSummaryView(model) + .padding(.bottom, 20) + + ReportFeedbackView(model: $model.reportModel) + } + } + } + + private func progressView(_ progress: TaskProgress) -> some View { + // Progress bar with label: Importing [bookmarks|passwords]… + ProgressView(value: self.progress?.fraction) { + Text(self.progress?.text ?? "") + } + .task { + // when model.importProgress async sequence not nil + // receive progress updates events and update model on completion + await handleImportProgress(progress) + } + } + + // under line buttons + private func viewFooter() -> some View { + HStack(spacing: 8) { + Spacer() + + ForEach(model.buttons.indices, id: \.self) { idx in + Button { + model.performAction(for: model.buttons[idx], + dismiss: dismiss.callAsFunction) + } label: { + Text(model.buttons[idx].title(dataType: model.screen.fileImportDataType)) + .frame(minWidth: 80 - 16 - 1) + } + .keyboardShortcut(model.buttons[idx].shortcut) + .disabled(model.buttons[idx].isDisabled) + } + } + } + + private func handleImportProgress(_ progress: TaskProgress) async { + // receive import progress update events + // the loop is completed on the import task + // cancellation/completion or on did disappear + for await event in progress { + switch event { + case .progress(let progress): + let currentTime = CACurrentMediaTime() + // throttle progress updates + if (self.progress?.updated ?? 0) < currentTime - 0.2 { + self.progress = .init(text: progress.description, + fraction: progress.fraction, + updated: currentTime) + } + + // update view model on completion + case .completed(.success(let newModel)): + self.model = newModel + } + } + } + +#if DEBUG || REVIEW + private func debugView() -> some View { + + VStack(alignment: .leading, spacing: 10) { + Divider() + + HStack { + Text("REVIEW:" as String).bold() + .padding(.top, 10) + .padding(.leading, 20) + Spacer() + if case .normal = NSApp.runType { + Button { + debugViewDisabled.toggle() + } label: { + Image(.closeLarge) + } + .buttonStyle(.borderless) + .padding(.trailing, 20) + } + } + + ForEach(DataImport.DataType.allCases.filter(model.selectedDataTypes.contains), id: \.self) { selectedDataType in + failureReasonPicker(for: selectedDataType) + .padding(.leading, 20) + .padding(.trailing, 20) + } + } + .padding(.bottom, 10) + .background(Color(NSColor(red: 1, green: 0, blue: 0, alpha: 0.2))) + } + + private var noFailure: String { "No failure" } + private var zeroSuccess: String { "Success (0 imported)" } + private var allFailureReasons: [String?] { + [noFailure, zeroSuccess, nil] + DataImport.ErrorType.allCases.map { $0.rawValue } + } + + private func failureReasonPicker(for dataType: DataImport.DataType) -> some View { + Picker(selection: Binding { + allFailureReasons.firstIndex(where: { failureReason in + model.testImportResults[dataType]?.error?.errorType.rawValue == failureReason + || (failureReason == zeroSuccess && model.testImportResults[dataType] == .success(.empty)) + || (failureReason == noFailure && model.testImportResults[dataType] == nil) + })! + } set: { newValue in + let reason = allFailureReasons[newValue]! + switch reason { + case noFailure: model.testImportResults[dataType] = nil + case zeroSuccess: model.testImportResults[dataType] = .success(.empty) + default: + let errorType = DataImport.ErrorType(rawValue: reason)! + let error = DataImportViewModel.TestImportError(action: dataType.importAction, errorType: errorType) + model.testImportResults[dataType] = .failure(error) + } + }) { + ForEach(allFailureReasons.indices, id: \.self) { idx in + if let failureReason = allFailureReasons[idx] { + Text(failureReason) + } else { + Divider() + } + } + } label: { + Text("\(dataType.displayName) import error:" as String) + .frame(width: 150, alignment: .leading) + } + } +#endif + +} + +extension DataImportProgressEvent { + + var fraction: Double? { + switch self { + case .initial: + nil + case .importingBookmarks(numberOfBookmarks: _, fraction: let fraction): + fraction + case .importingPasswords(numberOfPasswords: _, fraction: let fraction): + fraction + case .done: + nil + } + } + + var description: String? { + switch self { + case .initial: + nil + case .importingBookmarks(numberOfBookmarks: let num, fraction: _): + UserText.importingBookmarks(num) + case .importingPasswords(numberOfPasswords: let num, fraction: _): + UserText.importingPasswords(num) + case .done: + nil + } + } + +} + +extension DataImportViewModel.ButtonType { + + var shortcut: KeyboardShortcut? { + switch self { + case .next: .defaultAction + case .initiateImport: .defaultAction + case .skip: .cancelAction + case .cancel: .cancelAction + case .back: nil + case .done: .defaultAction + case .submit: .defaultAction + } + } + +} + +extension DataImportViewModel.ButtonType { + + func title(dataType: DataImport.DataType?) -> String { + switch self { + case .next: + UserText.next + case .initiateImport: + UserText.initiateImport + case .skip: + switch dataType { + case .bookmarks: + UserText.skipBookmarksImport + case .passwords: + UserText.skipPasswordsImport + case nil: + UserText.skip + } + case .cancel: + UserText.cancel + case .back: + UserText.navigateBack + case .done: + UserText.done + case .submit: + UserText.submitReport + } + } + +} + +#Preview { { + + final class PreviewPreferences: ObservableObject { + @Published var shouldDisplayProgress = false + static let shared = PreviewPreferences() + } + + final class MockDataImporter: DataImporter { + + struct MockError: Error { } + + enum ImportError: DataImportError { + enum OperationType: Int { + case imp + } + + var type: OperationType { .imp } + var action: DataImportAction { .generic } + var underlyingError: Error? { + if case .err(let err) = self { + return err + } + return nil + } + var errorType: DataImport.ErrorType { .noData } + + case err(Error) + } + let source: DataImport.Source + var dataType: DataImport.DataType? + var importableTypes: [DataImport.DataType] { + [.safari, .yandex].contains(source) && dataType == nil ? [.bookmarks] : [.bookmarks, .passwords] + } + + func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? { + source == .firefox && types.contains(.passwords) ? [.passwords: FirefoxLoginReader.ImportError(type: .requiresPrimaryPassword, underlyingError: nil)] : nil + } + + func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool { + source == .chrome && selectedDataTypes.contains(.passwords) ? true : false + } + + init(source: DataImport.Source, dataType: DataImport.DataType? = nil) { + self.source = source + self.dataType = dataType + } + + // swiftlint:disable:next function_body_length + func importData(types: Set) -> DataImportTask { + .detachedWithProgress(.initial) { progressUpdate in + func makeProgress(_ op: (Double) throws -> Void) async throws { + guard PreviewPreferences.shared.shouldDisplayProgress else { return } + let n = 20 + for i in 0.. { + print("DISMISS!") + }) + + PreviewPreferencesView() + Spacer() + } + .frame(minHeight: 666) + +}() } diff --git a/DuckDuckGo/DataImport/View/DataImportViewController.swift b/DuckDuckGo/DataImport/View/DataImportViewController.swift deleted file mode 100644 index ff7e1fbb29..0000000000 --- a/DuckDuckGo/DataImport/View/DataImportViewController.swift +++ /dev/null @@ -1,631 +0,0 @@ -// -// DataImportViewController.swift -// -// Copyright © 2021 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 BrowserServicesKit -import Combine -import Common - -final class DataImportViewController: NSViewController { - - @UserDefaultsWrapper(key: .homePageContinueSetUpImport, defaultValue: nil) - var successfulImportHappened: Bool? - - private enum Constants { - static let storyboardName = "DataImport" - static let identifier = "DataImportViewController" - } - - private enum InteractionState: Equatable { - case unableToImport - case permissionsRequired([DataImport.DataType]) - case ableToImport - case moreInfoAvailable - case completedImport(DataImport.Summary) - } - - private struct ImportError: DataImportError { - enum OperationType: Int { - case secureVaultError - case selectProfile - } - - let source: DataImport.Source - let action: DataImportAction - let type: OperationType - let underlyingError: Error? - } - - private struct ViewState: Equatable { - var selectedImportSource: DataImport.Source - var interactionState: InteractionState - - static func defaultState() -> ViewState { - if let firstInstalledBrowser = ThirdPartyBrowser.installedBrowsers.first { - return ViewState(selectedImportSource: firstInstalledBrowser.importSource, - interactionState: [.safari, .safariTechnologyPreview].contains(firstInstalledBrowser.importSource) ? .ableToImport : .moreInfoAvailable) - } else { - return ViewState(selectedImportSource: .csv, interactionState: .ableToImport) - } - } - } - - static func show(completion: (() -> Void)? = nil) { - guard let windowController = WindowControllersManager.shared.lastKeyMainWindowController else { return } - if windowController.window?.isKeyWindow != true { - windowController.window?.makeKeyAndOrderFront(nil) - } - - let viewController = DataImportViewController.create() - windowController.mainViewController.beginSheet(viewController) { _ in - completion?() - } - } - - private static func create() -> DataImportViewController { - let storyboard = NSStoryboard(name: Constants.storyboardName, bundle: nil) - return storyboard.instantiateController(identifier: Constants.identifier) - } - - private func secureVaultImporter() throws -> SecureVaultLoginImporter { - let secureVault = try AutofillSecureVaultFactory.makeVault(errorReporter: SecureVaultErrorReporter.shared) - return SecureVaultLoginImporter(secureVault: secureVault) - } - - private var viewState: ViewState = .defaultState() { - didSet { - renderCurrentViewState() - refreshDataImporter() - } - } - - private weak var currentChildViewController: NSViewController? - private var browserImportViewController: BrowserImportViewController? - - private var bookmarkCount = 0 - - private var dataImporter: DataImporter? - private var selectedImportSourceCancellable: AnyCancellable? - - @IBOutlet var containerView: NSView! - @IBOutlet var importSourcePopUpButton: NSPopUpButton! - @IBOutlet var importButton: NSButton! - @IBOutlet var cancelButton: NSButton! - - @IBAction func cancelButtonClicked(_ sender: Any) { - if currentChildViewController is BrowserImportMoreInfoViewController { - viewState = .init(selectedImportSource: viewState.selectedImportSource, interactionState: .moreInfoAvailable) - importSourcePopUpButton.isEnabled = true - cancelButton.title = UserText.cancel - } else { - dismiss() - } - } - - @IBAction func actionButtonClicked(_ sender: Any) { - switch viewState.interactionState { - // Import click on first screen with Bookmarks checkmark: Can't read bookmarks: request permission - case .ableToImport where [.safari, .safariTechnologyPreview].contains(viewState.selectedImportSource) - && selectedImportOptions.contains(.bookmarks) - && (dataImporter as? DataDirectoryPermissionAuthorization)?.canReadBookmarksFile() == false: - - self.viewState = ViewState(selectedImportSource: viewState.selectedImportSource, interactionState: .permissionsRequired([.bookmarks])) - - // Import click on first screen with Passwords bookmark or Next click on Bookmarks Import Done screen: show CSV Import - case .ableToImport where [.safari, .safariTechnologyPreview].contains(viewState.selectedImportSource) - && selectedImportOptions.contains(.logins) - && (dataImporter is CSVImporter || selectedImportOptions == [.logins]) - && !(currentChildViewController is FileImportViewController): - // Only Safari Passwords selected, switch to CSV select - self.viewState = .init(selectedImportSource: viewState.selectedImportSource, interactionState: .permissionsRequired([.logins])) - - case .ableToImport: - completeImport() - case .completedImport(let summary) where summary.loginsResult == .awaited: - // Safari bookmarks import finished, switch to CSV select - self.viewState = .init(selectedImportSource: viewState.selectedImportSource, interactionState: .permissionsRequired([.logins])) - case .completedImport: - dismiss() - case .moreInfoAvailable: - showMoreInfo() - default: - assertionFailure("\(#file): Import button should be disabled when unable to import") - } - } - - // MARK: - View Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - importSourcePopUpButton.displayImportSources() - renderCurrentViewState() - refreshDataImporter() - - selectedImportSourceCancellable = importSourcePopUpButton.selectionPublisher.sink { [weak self] _ in - self?.refreshViewState() - } - } - - override func viewDidDisappear() { - super.viewDidDisappear() - if bookmarkCount > 0 { - NotificationCenter.default.post(name: .bookmarkPromptShouldShow, object: nil) - } - } - - private func refreshViewState() { - guard let item = self.importSourcePopUpButton.selectedImportSourceItem(withPreferredIndex: importSourcePopUpButton.indexOfSelectedItem) else { - pixelAssertionFailure("Failed to get valid import source item") - return - } - - let validSources = DataImport.Source.allCases.filter(\.canImportData) - let source = validSources.first(where: { $0.importSourceName == item.title })! - - switch source { - case .csv, .lastPass, .onePassword7, .onePassword8, .bookmarksHTML: - self.viewState = ViewState(selectedImportSource: source, interactionState: .unableToImport) - - case .chrome, .firefox, .brave, .edge, .safari, .safariTechnologyPreview: - let interactionState: InteractionState - switch (source, loginsSelected) { - case (.safari, _), - (.safariTechnologyPreview, _), - (_, false): - interactionState = .ableToImport - case (.firefox, _): - if FirefoxDataImporter.loginDatabaseRequiresPrimaryPassword(profileURL: selectedProfile?.profileURL) { - interactionState = .moreInfoAvailable - } else { - interactionState = .ableToImport - } - case (_, true): - interactionState = .moreInfoAvailable - } - - let newState = ViewState(selectedImportSource: source, interactionState: interactionState) - - if newState != self.viewState { - self.viewState = newState - } - } - } - - private func refreshDataImporter() { - do { - try throwingRefreshDataImporter() - } catch { - os_log("dataImporter initialization failed: %{public}s", type: .error, error.localizedDescription) - self.presentAlert(for: ImportError(source: viewState.selectedImportSource, action: .generic, type: .secureVaultError, underlyingError: error)) - } - } - - private func throwingRefreshDataImporter() throws { - let bookmarkImporter = CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared) - - switch viewState.selectedImportSource { - case .brave: - self.dataImporter = try BraveDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) - case .chrome: - self.dataImporter = try ChromeDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) - case .edge: - self.dataImporter = try EdgeDataImporter(loginImporter: secureVaultImporter(), bookmarkImporter: bookmarkImporter) - case .firefox: - self.dataImporter = try FirefoxDataImporter(loginImporter: secureVaultImporter(), - bookmarkImporter: bookmarkImporter, - faviconManager: FaviconManager.shared) - case .safari where !(currentChildViewController is FileImportViewController), - .safariTechnologyPreview where !(currentChildViewController is FileImportViewController): - - self.dataImporter = try SafariDataImporter(importSource: viewState.selectedImportSource, - bookmarkImporter: bookmarkImporter, - faviconManager: FaviconManager.shared) ?? { - throw ImportError(source: viewState.selectedImportSource, action: .generic, type: .selectProfile, underlyingError: nil) - }() - - case .bookmarksHTML: - if !(self.dataImporter is BookmarkHTMLImporter) { - self.dataImporter = nil - } - case .csv, .onePassword7, .onePassword8, .lastPass, - .safari, .safariTechnologyPreview /* csv only */: - if !(self.dataImporter is CSVImporter) { - self.dataImporter = nil - } - } - } - - private var loginsSelected: Bool { - if let browserViewController = self.currentChildViewController as? BrowserImportViewController { - return browserViewController.selectedImportOptions.contains(.logins) - } - - // Assume true as a default in order to show next button when new child view controller is set - return true - } - - private func renderCurrentViewState() { - updateActionButton(with: viewState.interactionState) - - if let viewController = newChildViewController(for: viewState.selectedImportSource, interactionState: viewState.interactionState) { - embed(viewController: viewController) - } - } - - private func updateActionButton(with interactionState: InteractionState) { - switch interactionState { - case .unableToImport: - importSourcePopUpButton.isHidden = false - importButton.title = UserText.initiateImport - importButton.isEnabled = false - cancelButton.isHidden = false - case .ableToImport: - importSourcePopUpButton.isHidden = false - importButton.title = UserText.initiateImport - importButton.isEnabled = true - cancelButton.isHidden = false - case .moreInfoAvailable: - importSourcePopUpButton.isHidden = false - importButton.title = UserText.next - importButton.isEnabled = true - cancelButton.isHidden = false - case .permissionsRequired: - importSourcePopUpButton.isHidden = false - importButton.title = UserText.initiateImport - importButton.isEnabled = false - cancelButton.isHidden = false - case .completedImport(let summary) where summary.loginsResult == .awaited: - // Continue to Logins import from Safari - importSourcePopUpButton.isHidden = false - importButton.title = UserText.next - importButton.isEnabled = true - cancelButton.isHidden = false - case .completedImport: - importSourcePopUpButton.isHidden = true - importButton.title = UserText.doneImporting - importButton.isEnabled = true - cancelButton.isHidden = true - } - } - - private func newChildViewController(for importSource: DataImport.Source, interactionState: InteractionState) -> NSViewController? { - switch importSource { - case .safari, .safariTechnologyPreview: - if case .permissionsRequired([.logins]) = interactionState { - let viewController = FileImportViewController.create(importSource: .safari) - viewController.delegate = self - - return viewController - } else if case .ableToImport = interactionState, - let fileImportViewController = currentChildViewController as? FileImportViewController, - [.safari, .safariTechnologyPreview].contains(fileImportViewController.importSource) { - fileImportViewController.importSource = importSource - - return nil - } - - fallthrough - case .brave, .chrome, .edge, .firefox: - if case let .completedImport(summary) = interactionState { - return BrowserImportSummaryViewController.create(importSummary: summary) - } else if case let .permissionsRequired(types) = interactionState { - guard let safariDataImporter = dataImporter as? DataDirectoryPermissionAuthorization else { - assertionFailure("Unexpected data importer kind: \(dataImporter.map(String.init(describing:)) ?? "")") - return nil - } - - let filePermissionViewController = RequestFilePermissionViewController.create(importSource: importSource, permissionsRequired: types, permissionAuthorization: safariDataImporter) - filePermissionViewController.delegate = self - return filePermissionViewController - } else if browserImportViewController?.browser == importSource { - return browserImportViewController - } else { - browserImportViewController = createBrowserImportViewController(for: importSource) - return browserImportViewController - } - - case .csv, .onePassword7, .onePassword8, .lastPass, .bookmarksHTML: - if case let .completedImport(summary) = interactionState { - return BrowserImportSummaryViewController.create(importSummary: summary) - } else { - if let fileImportViewController = currentChildViewController as? FileImportViewController { - fileImportViewController.importSource = importSource - return nil - } - let viewController = FileImportViewController.create(importSource: importSource) - viewController.delegate = self - return viewController - } - } - } - - private func embed(viewController newChildViewController: NSViewController) { - if newChildViewController === currentChildViewController { - return - } - - if let currentChildViewController = currentChildViewController { - addChild(newChildViewController) - transition(from: currentChildViewController, to: newChildViewController, options: []) - } else { - addChild(newChildViewController) - } - - currentChildViewController = newChildViewController - containerView.addSubview(newChildViewController.view) - - newChildViewController.view.translatesAutoresizingMaskIntoConstraints = false - newChildViewController.view.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true - newChildViewController.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor).isActive = true - newChildViewController.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor).isActive = true - newChildViewController.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true - } - - private func createBrowserImportViewController(for source: DataImport.Source) -> BrowserImportViewController? { - // Prevent transitioning to the same view controller. - if let viewController = currentChildViewController as? BrowserImportViewController, viewController.browser == source { return nil } - - guard let browser = ThirdPartyBrowser.browser(for: viewState.selectedImportSource), let profileList = browser.browserProfiles() else { - assertionFailure("Attempted to create BrowserImportViewController without a valid browser selected") - return nil - } - - let browserImportViewController = BrowserImportViewController.create(with: source, profileList: profileList) - browserImportViewController.delegate = self - - return browserImportViewController - } - - // MARK: - Actions - - private func showMoreInfo() { - viewState = .init(selectedImportSource: viewState.selectedImportSource, interactionState: .ableToImport) - importSourcePopUpButton.isEnabled = false - embed(viewController: BrowserImportMoreInfoViewController.create(source: viewState.selectedImportSource)) - cancelButton.title = UserText.navigateBack - } - - private var selectedProfile: DataImport.BrowserProfile? { - return browserImportViewController?.selectedProfile - } - - private var selectedImportOptions: [DataImport.DataType] { - guard let importer = self.dataImporter else { - assertionFailure("\(#file): No data importer or profile found") - return [] - } - let selectedOptions = browserImportViewController?.selectedImportOptions ?? [] - return selectedOptions.isEmpty ? importer.importableTypes() : selectedOptions - } - - private func completeImport() { - guard let importer = self.dataImporter else { - assertionFailure("\(#file): No data importer or profile found") - return - } - - let profile = selectedProfile - let importTypes = selectedImportOptions - - self.importButton.isEnabled = false - self.cancelButton.isEnabled = false - - importer.importData(types: importTypes, from: profile, modalWindow: view.window) { result in - self.importButton.isEnabled = true - self.cancelButton.isEnabled = true - - switch result { - case .success(let summary): - self.successfulImportHappened = true - if summary.isEmpty { - self.dismiss() - } else { - self.bookmarkCount += summary.bookmarksResult?.successful ?? 0 - self.viewState.interactionState = .completedImport(summary) - self.requestSync() - } - - NotificationCenter.default.post(name: .dataImportComplete, object: nil) - - case .failure(let error as ChromiumLoginReader.ImportError) where error.type == .userDeniedKeychainPrompt: - // user denied Keychain prompt: keep current screen - Pixel.fire(.dataImportFailed(error)) - - case .failure(let error as FirefoxLoginReader.ImportError) where error.type == .requiresPrimaryPassword: - self.requestPrimaryPassword() - - case .failure(let error): - if (error as? FirefoxLoginReader.ImportError)?.type != .requiresPrimaryPassword { - os_log("import failed: %{public}s", type: .error, error.localizedDescription) - self.viewState.interactionState = .ableToImport - } - self.presentAlert(for: error) - } - } - } - - private func requestPrimaryPassword() { - let alert = NSAlert.passwordRequiredAlert(source: viewState.selectedImportSource) - let response = alert.runModal() - - if response == .alertFirstButtonReturn { - // Assume Firefox, as it's the only supported option that uses a password - let password = (alert.accessoryView as? NSSecureTextField)?.stringValue - (dataImporter as? FirefoxDataImporter)?.primaryPassword = password - - completeImport() - } - } - - private func presentAlert(for error: any DataImportError) { - guard let window = view.window else { return } - - Pixel.fire(.dataImportFailed(error)) - - let alert = NSAlert.importFailedAlert(linkDelegate: self) - alert.beginSheetModal(for: window, completionHandler: nil) - } - - private func requestSync() { - guard let syncService = NSApp.delegateTyped.syncService else { - return - } - os_log(.debug, log: OSLog.sync, "Requesting sync if enabled") - syncService.scheduler.requestSyncImmediately() - } - -} - -extension DataImportViewController: FileImportViewControllerDelegate { - - func fileImportViewController(_ viewController: FileImportViewController, didSelectBookmarksFileWithURL url: URL?) { - guard let url else { - self.viewState.interactionState = .unableToImport - return - } - - let bookmarkImporter = CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared) - - self.dataImporter = BookmarkHTMLImporter(fileURL: url, bookmarkImporter: bookmarkImporter) - self.viewState.interactionState = .ableToImport - } - - func fileImportViewController(_ viewController: FileImportViewController, didSelectCSVFileWithURL url: URL?) { - guard let url else { - self.viewState.interactionState = .unableToImport - return - } - - do { - let secureVault = try AutofillSecureVaultFactory.makeVault(errorReporter: SecureVaultErrorReporter.shared) - let secureVaultImporter = SecureVaultLoginImporter(secureVault: secureVault) - self.dataImporter = CSVImporter(fileURL: url, - loginImporter: secureVaultImporter, - defaultColumnPositions: .init(source: self.viewState.selectedImportSource)) - self.viewState.interactionState = .ableToImport - } catch { - os_log("file import failed: %{public}s", type: .error, error.localizedDescription) - self.viewState.interactionState = .unableToImport - self.presentAlert(for: ImportError(source: self.viewState.selectedImportSource, action: .logins, type: .secureVaultError, underlyingError: error)) - } - } - - func totalValidLogins(in url: URL) -> Int? { - - let importer = CSVImporter(fileURL: url, - loginImporter: nil, - defaultColumnPositions: .init(source: self.viewState.selectedImportSource)) - return importer.totalValidLogins() - } - - func totalValidBookmarks(in fileURL: URL) -> Int? { - let importer = BookmarkHTMLImporter( - fileURL: fileURL, - bookmarkImporter: CoreDataBookmarkImporter(bookmarkManager: LocalBookmarkManager.shared) - ) - return importer.totalBookmarks - } - -} - -extension DataImportViewController: BrowserImportViewControllerDelegate { - - func browserImportViewController(_ viewController: BrowserImportViewController, didChangeSelectedImportOptions options: [DataImport.DataType]) { - if options.isEmpty { - viewState.interactionState = .unableToImport - } else { - refreshViewState() - } - } - - func browserImportViewControllerRequestedParentViewRefresh(_ viewController: BrowserImportViewController) { - refreshViewState() - } - -} - -extension DataImportViewController: RequestFilePermissionViewControllerDelegate { - - func requestFilePermissionViewControllerDidReceivePermission(_ viewController: RequestFilePermissionViewController) { - if [.safari, .safariTechnologyPreview].contains(viewState.selectedImportSource) - && selectedImportOptions.contains(.bookmarks) { - - self.completeImport() - return - } - - self.viewState.interactionState = .ableToImport - } - -} - -extension DataImportViewController: NSTextViewDelegate { - - func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { - guard let sheet = view.window?.attachedSheet else { - return false - } - - view.window?.endSheet(sheet) - dismiss() - - FeedbackPresenter.presentFeedbackForm() - - return true - } - -} - -extension NSPopUpButton { - - fileprivate func displayImportSources() { - removeAllItems() - - let validSources = DataImport.Source.allCases.filter(\.canImportData) - for source in validSources { - // The CSV row is at the bottom of the picker, and requires a separator above it, but only if the item array isn't - // empty (which would happen if there are no valid sources). - if (source == .onePassword8 || source == .csv) && !itemArray.isEmpty { - - let separator = NSMenuItem.separator() - menu?.addItem(separator) - } - - addItem(withTitle: source.importSourceName) - lastItem?.image = source.importSourceImage?.resized(to: NSSize(width: 16, height: 16)) - } - - if let preferredSource = DataImport.Source.preferredSources.first(where: { validSources.contains($0) }) { - selectItem(withTitle: preferredSource.importSourceName) - } - } - - /// Provides a safe way to extract the selected import source item from an `NSPopUpButton`. A pop up button can include a separator at the top, so the fallback logic of treating the first item as - /// selected means it's possible to get a separator as the selected import source. This function will check that the title is not empty and that the preferred index exists when checking for - /// the selected item, and will check subsequent items if the non-empty title condition is not met. - fileprivate func selectedImportSourceItem(withPreferredIndex index: Int) -> NSMenuItem? { - guard !itemArray.isEmpty, index != NSNotFound else { - assertionFailure("Failed to select an import source item") - return nil - } - - return itemArray[index...].first { !$0.title.isEmpty } - } - -} diff --git a/DuckDuckGo/DataImport/View/FileImportSummaryViewController.swift b/DuckDuckGo/DataImport/View/FileImportSummaryViewController.swift deleted file mode 100644 index 1399c13afc..0000000000 --- a/DuckDuckGo/DataImport/View/FileImportSummaryViewController.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// FileImportSummaryViewController.swift -// -// Copyright © 2021 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 - -final class FileImportSummaryViewController: NSViewController { - - enum Constants { - static let storyboardName = "DataImport" - static let identifier = "FileImportSummaryViewController" - } - - static func create(summary: DataImport.Summary?) -> FileImportSummaryViewController { - let storyboard = NSStoryboard(name: Constants.storyboardName, bundle: nil) - - return storyboard.instantiateController(identifier: Constants.identifier) { (coder) -> FileImportSummaryViewController? in - return FileImportSummaryViewController(coder: coder, summary: summary) - } - } - - @IBOutlet var importCompleteLabel: NSTextField! - - @IBOutlet var successfulImportsLabel: NSTextField! - - private let summary: DataImport.Summary? - - init?(coder: NSCoder, summary: DataImport.Summary?) { - self.summary = summary - super.init(coder: coder) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - if case .completed(let result) = summary?.loginsResult { - successfulImportsLabel.stringValue = UserText.loginImportSuccessfulCSVImports(totalSuccessfulImports: result.successfulImports.count) - } else { - successfulImportsLabel.isHidden = true - } - } - -} diff --git a/DuckDuckGo/DataImport/View/FileImportView.swift b/DuckDuckGo/DataImport/View/FileImportView.swift new file mode 100644 index 0000000000..faab1cf62b --- /dev/null +++ b/DuckDuckGo/DataImport/View/FileImportView.swift @@ -0,0 +1,837 @@ +// +// FileImportView.swift +// +// Copyright © 2023 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 Common +import SwiftUI +import UniformTypeIdentifiers + +// swiftlint:disable function_body_length cyclomatic_complexity +@InstructionsView.InstructionsBuilder +func fileImportInstructionsBuilder(source: DataImport.Source, dataType: DataImport.DataType, button: @escaping (String) -> AnyView) -> [InstructionsView.InstructionsItem] { + + switch (source, dataType) { + case (.chrome, .passwords): + NSLocalizedString("import.csv.instructions.chrome", value: """ + %d Open **%s** + %d In a fresh tab, click %@ then **Google Password Manager → Settings** + %d Find “Export Passwords” and click **Download File** + %d Save the passwords file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Google Chrome browser. + %N$d - step number + %2$s - browser name (Chrome) + %4$@ - hamburger menu icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importLoginsSelectCSVFile) + + case (.brave, .passwords): + NSLocalizedString("import.csv.instructions.brave", value: """ + %d Open **%s** + %d Click %@ to open the application menu then click **Password Manager** + %d Click %@ **at the top left** of the Password Manager and select **Settings** + %d Find “Export Passwords” and click **Download File** + %d Save the passwords file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Brave browser. + %N$d - step number + %2$s - browser name (Brave) + %4$@, %6$@ - hamburger menu icon + %10$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuHamburger16 + NSImage.menuHamburger16 + button(UserText.importLoginsSelectCSVFile) + + case (.chromium, .passwords), + (.edge, .passwords): + NSLocalizedString("import.csv.instructions.chromium", value: """ + %d Open **%s** + %d In a fresh tab, click %@ then **Password Manager → Settings** + %d Find “Export Passwords” and click **Download File** + %d Save the passwords file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Chromium-based browsers. + %N$d - step number + %2$s - browser name + %4$@ - hamburger menu icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importLoginsSelectCSVFile) + + case (.coccoc, .passwords): + NSLocalizedString("import.csv.instructions.coccoc", value: """ + %d Open **%s** + %d Type “_coccoc://settings/passwords_” into the Address bar + %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** + %d Save the passwords file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Cốc Cốc browser. + %N$d - step number + %2$s - browser name (Cốc Cốc) + %5$@ - hamburger menu icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importLoginsSelectCSVFile) + + case (.opera, .passwords): + NSLocalizedString("import.csv.instructions.opera", value: """ + %d Open **%s** + %d Use the Menu Bar to select **View → Show Password Manager** + %d Select **Settings** + %d Find “Export Passwords” and click **Download File** + %d Save the passwords file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Opera browser. + %N$d - step number + %2$s - browser name (Opera) + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importLoginsSelectCSVFile) + + case (.vivaldi, .passwords): + NSLocalizedString("import.csv.instructions.vivaldi", value: """ + %d Open **%s** + %d Type “_chrome://settings/passwords_” into the Address bar + %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords exported as CSV from Vivaldi browser. + %N$d - step number + %2$s - browser name (Vivaldi) + %5$@ - menu button icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importLoginsSelectCSVFile) + + case (.operaGX, .passwords): + NSLocalizedString("import.csv.instructions.operagx", value: """ + %d Open **%s** + %d Use the Menu Bar to select **View → Show Password Manager** + %d Click %@ (on the right from _Saved Passwords_) and select **Export passwords** + %d Save the passwords file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Opera GX browsers. + %N$d - step number + %2$s - browser name (Opera GX) + %5$@ - menu button icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importLoginsSelectCSVFile) + + case (.yandex, .passwords): + NSLocalizedString("import.csv.instructions.yandex", value: """ + %d Open **%s** + %d Click %@ to open the application menu then click **Passwords and cards** + %d Click %@ then **Export passwords** + %d Choose **To a text file (not secure)** and click **Export** + %d Save the passwords file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Yandex Browser. + %N$d - step number + %2$s - browser name (Yandex) + %4$@ - hamburger menu icon + %8$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuHamburger16 + NSImage.menuVertical16 + button(UserText.importLoginsSelectCSVFile) + + case (.brave, .bookmarks), + (.chrome, .bookmarks), + (.chromium, .bookmarks), + (.coccoc, .bookmarks), + (.edge, .bookmarks): + NSLocalizedString("import.html.instructions.chromium", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Bookmarks → Bookmark Manager** + %d Click %@ then **Export Bookmarks** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Chromium-based browsers. + %N$d - step number + %2$s - browser name + %5$@ - hamburger menu icon + %8$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importBookmarksSelectHTMLFile) + + case (.vivaldi, .bookmarks): + NSLocalizedString("import.html.instructions.vivaldi", value: """ + %d Open **%s** + %d Use the Menu Bar to select **File → Export Bookmarks…** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Vivaldi browser. + %N$d - step number + %2$s - browser name (Vivaldi) + %6$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importBookmarksSelectHTMLFile) + + case (.opera, .bookmarks): + NSLocalizedString("import.html.instructions.opera", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Bookmarks → Bookmarks** + %d Click **Open full Bookmarks view…** in the bottom left + %d Click **Import/Export…** in the bottom left and select **Export Bookmarks** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Opera browser. + %N$d - step number + %2$s - browser name (Opera) + %8$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importBookmarksSelectHTMLFile) + + case (.operaGX, .bookmarks): + NSLocalizedString("import.html.instructions.operagx", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Bookmarks → Bookmarks** + %d Click **Import/Export…** in the bottom left and select **Export Bookmarks** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Opera GX browser. + %N$d - step number + %2$s - browser name (Opera GX) + %7$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importBookmarksSelectHTMLFile) + + case (.yandex, .bookmarks): + NSLocalizedString("import.html.instructions.yandex", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Favorites → Bookmark Manager** + %d Click %@ then **Export bookmarks to HTML file** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Yandex Browser. + %N$d - step number + %2$s - browser name (Yandex) + %5$@ - hamburger menu icon + %8$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuVertical16 + button(UserText.importBookmarksSelectHTMLFile) + + case (.safari, .passwords), (.safariTechnologyPreview, .passwords): + NSLocalizedString("import.csv.instructions.safari", value: """ + %d Open **Safari** + %d Select **File → Export → Passwords** + %d Save the passwords file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Safari. + %N$d - step number + %5$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + button(UserText.importLoginsSelectCSVFile) + + case (.safari, .bookmarks), (.safariTechnologyPreview, .bookmarks): + NSLocalizedString("import.html.instructions.safari", value: """ + %d Open **Safari** + %d Select **File → Export → Bookmarks** + %d Save the passwords file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Safari. + %N$d - step number + %5$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + button(UserText.importBookmarksSelectHTMLFile) + + case (.firefox, .passwords): + NSLocalizedString("import.csv.instructions.firefox", value: """ + %d Open **%s** + %d Click %@ to open the application menu then click **Passwords** + %d Click %@ then **Export Logins…** + %d Save the passwords file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Firefox. + %N$d - step number + %2$s - browser name (Firefox) + %4$@, %6$@ - hamburger menu icon + %9$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.menuHamburger16 + NSImage.menuHorizontal16 + button(UserText.importLoginsSelectCSVFile) + + case (.firefox, .bookmarks), (.tor, .bookmarks): + NSLocalizedString("import.html.instructions.firefox", value: """ + %d Open **%s** + %d Use the Menu Bar to select **Bookmarks → Manage Bookmarks** + %d Click %@ then **Export bookmarks to HTML…** + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Bookmarks exported as HTML from Firefox based browsers. + %N$d - step number + %2$s - browser name (Firefox) + %5$@ - hamburger menu icon + %8$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage.importExport16 + button(UserText.importBookmarksSelectHTMLFile) + + case (.onePassword8, .passwords): + NSLocalizedString("import.csv.instructions.onePassword8", value: """ + %d Open and unlock **%s** + %d Select **File → Export** from the Menu Bar and choose the account you want to export + %d Enter your 1Password account password + %d Select the File Format: **CSV (Logins and Passwords only)** + %d Click Export Data and save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from 1Password 8. + %2$s - app name (1Password) + %8$@ - “Select 1Password CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importLoginsSelectCSVFile(from: source)) + + case (.onePassword7, .passwords): + NSLocalizedString("import.csv.instructions.onePassword7", value: """ + %d Open and unlock **%s** + %d Select the vault you want to export (you can only export one vault at a time) + %d Select **File → Export → All Items** from the Menu Bar + %d Enter your 1Password master or account password + %d Select the File Format: **iCloud Keychain (.csv)** + %d Save the passwords file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from 1Password 7. + %2$s - app name (1Password) + %9$@ - “Select 1Password CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importLoginsSelectCSVFile(from: source)) + + case (.bitwarden, .passwords): + NSLocalizedString("import.csv.instructions.bitwarden", value: """ + %d Open and unlock **%s** + %d Select **File → Export vault** from the Menu Bar + %d Select the File Format: **.csv** + %d Enter your Bitwarden master password + %d Click %@ and save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from Bitwarden. + %2$s - app name (Bitwarden) + %7$@ - hamburger menu icon + %9$@ - “Select Bitwarden CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + NSImage(systemSymbolName: "square.and.arrow.down", accessibilityDescription: nil) ?? .downloads + button(UserText.importLoginsSelectCSVFile(from: source)) + + case (.lastPass, .passwords): + NSLocalizedString("import.csv.instructions.lastpass", value: """ + %d Click on the **%s** icon in your browser and enter your master password + %d Select **Open My Vault** + %d From the sidebar select **Advanced Options → Export** + %d Enter your LastPass master password + %d Select the File Format: **Comma Delimited Text (.csv)** + %d %@ + """, comment: """ + Instructions to import Passwords as CSV from LastPass. + %2$s - app name (LastPass) + %8$@ - “Select LastPass CSV File” button + **bold text**; _italic text_ + """) + source.importSourceName + button(UserText.importLoginsSelectCSVFile(from: source)) + + case (.csv, .passwords): + NSLocalizedString("import.csv.instructions.generic", value: """ + The CSV importer will try to match column headers to their position. + If there is no header, it supports two formats: + %d URL, Username, Password + %d Title, URL, Username, Password + %@ + """, comment: """ + Instructions to import a generic CSV passwords file. + %N$d - step number + %3$@ - “Select Passwords CSV File” button + **bold text**; _italic text_ + """) + button(UserText.importLoginsSelectCSVFile) + + case (.bookmarksHTML, .bookmarks): + NSLocalizedString("import.html.instructions.generic", value: """ + %d Open your old browser + %d Open **Bookmark Manager** + %d Export bookmarks to HTML… + %d Save the file someplace you can find it (e.g., Desktop) + %d %@ + """, comment: """ + Instructions to import a generic HTML Bookmarks file. + %N$d - step number + %6$@ - “Select Bookmarks HTML File” button + **bold text**; _italic text_ + """) + button(UserText.importBookmarksSelectHTMLFile) + + case (.bookmarksHTML, .passwords), + (.tor, .passwords), + (.onePassword7, .bookmarks), + (.onePassword8, .bookmarks), + (.bitwarden, .bookmarks), + (.lastPass, .bookmarks), + (.csv, .bookmarks): + assertionFailure("Invalid source/dataType") + } +} +// swiftlint:enable function_body_length cyclomatic_complexity + +struct FileImportView: View { + + let source: DataImport.Source + let dataType: DataImport.DataType + let action: () -> Void + let onFileDrop: (URL) -> Void + + private var isButtonDisabled: Bool + + init(source: DataImport.Source, dataType: DataImport.DataType, isButtonDisabled: Bool, action: (() -> Void)? = nil, onFileDrop: ((URL) -> Void)? = nil) { + self.source = source + self.dataType = dataType + self.action = action ?? {} + self.onFileDrop = onFileDrop ?? { _ in } + self.isButtonDisabled = isButtonDisabled + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + { + switch dataType { + case .bookmarks: + Text("Import Bookmarks") + case .passwords: + Text("Import Passwords") + } + }().bold() + + if [.onePassword7, .onePassword8].contains(source) { + HStack { + Image(.info) + // markdown not supported on macOS 11 + InstructionsView { + NSLocalizedString("import.onePassword.app.version.info", value: """ + You can find your version by selecting **%s → About %s** from the Menu Bar. + """, comment: """ + Instructions how to find an installed 1Password password manager app version. + %1$s, %2$s - app name (1Password) + """) + source.importSourceName + source.importSourceName + } + + Spacer() + } + .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) + .background(Color("BlackWhite5")) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color("SeparatorColor"), + style: StrokeStyle(lineWidth: 1)) + ) + .padding(.top, 8) + .padding(.bottom, 8) + } + + InstructionsView { + fileImportInstructionsBuilder(source: source, dataType: dataType, button: self.button) + } + } + } + + private func button(_ title: String) -> AnyView { + AnyView( + Button(title, action: action) + .onDrop(of: dataType.allowedFileTypes, isTargeted: nil, perform: onDrop) + .disabled(isButtonDisabled) + ) + } + + private func onDrop(_ providers: [NSItemProvider], _ location: CGPoint) -> Bool { + let allowedTypeIdentifiers = providers.reduce(into: Set()) { + $0.formUnion($1.registeredTypeIdentifiers) + }.intersection(dataType.allowedFileTypes.map(\.identifier)) + + guard let typeIdentifier = allowedTypeIdentifiers.first, + let provider = providers.first(where: { + $0.hasItemConformingToTypeIdentifier(typeIdentifier) + }) else { + os_log(.error, log: .dataImportExport, "invalid type identifiers: \(allowedTypeIdentifiers)") + return false + } + + provider.loadItem(forTypeIdentifier: typeIdentifier) { data, error in + guard let data else { + os_log(.error, log: .dataImportExport, "error loading \(typeIdentifier): \(error?.localizedDescription ?? "?")") + return + } + let url: URL + switch data { + case let value as URL: + url = value + case let data as Data: + guard let value = URL(dataRepresentation: data, relativeTo: nil) else { + os_log(.error, log: .dataImportExport, "could not decode data: \(data.debugDescription)") + return + } + url = value + default: + os_log(.error, log: .dataImportExport, "unsupported data: \(data)") + return + } + + onFileDrop(url) + } + + return true + } + +} + +struct InstructionsView: View { + + // item used in InstructionBuilder: string literal, NSImage or Choose File Button (AnyView) + enum InstructionsItem { + case string(String) + case image(NSImage) + case view(AnyView) + } + // Text item view ViewModel - joined in a line using Text(string).bold().italic() + Text(image).. seq + enum TextItem { + case image(NSImage) + case text(text: String, isBold: Bool, isItalic: Bool) + } + // Possible InstructionsView line components: + // - lineNumber (number in a circle) + // - textItems: Text(string).bold().italic() + Text(image).. seq + // - view: Choose File Button + enum InstructionsViewItem { + case lineNumber(Int) + case textItems([TextItem]) + case view(AnyView) + } + + // View Model + private let instructions: [[InstructionsViewItem]] + + // swiftlint:disable:next function_body_length cyclomatic_complexity + init(@InstructionsBuilder builder: () -> [InstructionsItem]) { + var args = builder() + + guard case .string(let format) = args.first else { + assertionFailure("First item should provide instructions format using NSLocalizedString") + self.instructions = [] + return + } + + do { + // parse %12$d, %23$s, %34$@ out of the localized format into component sequence + let formatLines = try InstructionsFormatParser().parse(format: format) + + // assertion helper + func fline(_ lineIdx: Int) -> String { + format.components(separatedBy: "\n")[safe: lineIdx] ?? "?" + } + + // arguments are positioned (%42$s %23$@) but lines numbers are auto-incremented + // but the line arguments (%12$d) are still indexed. + // insert fake components at .line components positions to keep order + let lineNumberArgumentIndices = formatLines.reduce(into: IndexSet()) { + $0.formUnion($1.reduce(into: IndexSet()) { + if case .number(argIndex: let argIndex) = $1 { + $0.insert(argIndex) + } + }) + } + for idx in lineNumberArgumentIndices { + args.insert(.string(""), at: idx) + } + + // generate instructions view model from localized format + var result = [[InstructionsViewItem]]() + var lineNumber = 1 + var usedArgs = IndexSet() + for (lineIdx, line) in formatLines.enumerated() { + // collect view items placed in line + var resultLine = [InstructionsViewItem]() + func appendTextItem(_ textItem: TextItem) { + // text item should be appended to an ongoing textItem sequence if present + if case .textItems(var items) = resultLine.last { + items.append(textItem) + resultLine[resultLine.endIndex - 1] = .textItems(items) + } else { + // previous item is not .textItems - initiate a new textItem sequence + resultLine.append(.textItems([textItem])) + } + } + + for component in line { + switch component { + // %d line number argument + case .number(let argIndex): + resultLine.append(.lineNumber(lineNumber)) + usedArgs.insert(argIndex) + lineNumber += 1 // line number is auto-incremented + + // text literal [optionally with markdown attributes] + case .text(let text, bold: let bold, italic: let italic): + appendTextItem(.text(text: text, isBold: bold, isItalic: italic)) + + // %s string argument + case .string(let argIndex, bold: let bold, italic: let italic): + switch args[safe: argIndex] { + case .string(let str): + appendTextItem(.text(text: str, isBold: bold, isItalic: italic)) + case .none: + assertionFailure("String argument missing at index \(argIndex) in line \(lineIdx + 1):\n“\(fline(lineIdx))”.\nArgs:\n\(args)") + case .image(let obj as Any), .view(let obj as Any): + assertionFailure("Unexpected object argument at index \(argIndex):\n\(obj)\nExpected object in line \(lineIdx + 1):\n“\(fline(lineIdx))”.\nArgs:\n\(args)") + } + usedArgs.insert(argIndex) + + // %@ object argument - inline image or button (view) + case .object(let argIndex): + switch args[safe: argIndex] { + case .image(let image): + appendTextItem(.image(image)) + case .view(let view): + resultLine.append(.view(view)) + case .none: + assertionFailure("Object argument missing at index \(argIndex) in line \(lineIdx + 1):\n“\(fline(lineIdx))”.\nArgs:\n\(args)") + case .string(let string): + assertionFailure("Unexpected string argument at index \(argIndex):\n“\(string)”.\nExpected object in line \(lineIdx + 1):\n“\(fline(lineIdx))”.\nArgs:\n\(args)") + } + + usedArgs.insert(argIndex) + } + } + result.append(resultLine) + } + assert(usedArgs.subtracting(IndexSet(args.indices)).isEmpty, + "Unused arguments at indices \(usedArgs.subtracting(IndexSet(args.indices)))") + self.instructions = result + + } catch { + assertionFailure("Could not build instructions view: \(error)") + self.instructions = [] + } + } + + @resultBuilder + struct InstructionsBuilder { + static func buildBlock(_ components: [InstructionsItem]...) -> [InstructionsItem] { + return components.flatMap { $0 } + } + + static func buildOptional(_ components: [InstructionsItem]?) -> [InstructionsItem] { + return components ?? [] + } + + static func buildEither(first component: [InstructionsItem]) -> [InstructionsItem] { + component + } + + static func buildEither(second component: [InstructionsItem]) -> [InstructionsItem] { + component + } + + static func buildLimitedAvailability(_ component: [InstructionsItem]) -> [InstructionsItem] { + component + } + + static func buildArray(_ components: [[InstructionsItem]]) -> [InstructionsItem] { + components.flatMap { $0 } + } + + static func buildExpression(_ expression: [InstructionsItem]) -> [InstructionsItem] { + return expression + } + + static func buildExpression(_ value: String) -> [InstructionsItem] { + return [.string(value)] + } + + static func buildExpression(_ value: NSImage) -> [InstructionsItem] { + return [.image(value)] + } + + static func buildExpression(_ value: some View) -> [InstructionsItem] { + return [.view(AnyView(value))] + } + + static func buildExpression(_ expression: Void) -> [InstructionsItem] { + return [] + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(instructions.indices, id: \.self) { i in + HStack(alignment: .top, spacing: 8) { + ForEach(instructions[i].indices, id: \.self) { j in + switch instructions[i][j] { + case .lineNumber(let number): + CircleNumberView(number: number) + case .textItems(let textParts): + Text(textParts) + .makeSelectable() + .frame(minHeight: CircleNumberView.Constants.diameter) + case .view(let view): + view + } + } + } + } + } + } + +} + +private extension Text { + + init(_ textPart: InstructionsView.TextItem) { + switch textPart { + case .image(let image): + self.init(Image(nsImage: image)) + self = self + .baselineOffset(-3) + + case .text(let text, let isBold, let isItalic): + self.init(text) + if isBold { + self = self.bold() + } + if isItalic { + self = self.italic() + } + } + } + + init(_ textParts: [InstructionsView.TextItem]) { + guard !textParts.isEmpty else { + assertionFailure("Empty TextParts") + self.init("") + return + } + self.init(textParts[0]) + + guard textParts.count > 1 else { return } + for textPart in textParts[1...] { + // swiftlint:disable:next shorthand_operator + self = self + Text(textPart) + } + } + +} + +struct CircleNumberView: View { + + enum Constants { + static let diameter: CGFloat = 20 + } + + let number: Int + + var body: some View { + Circle() + .fill(.globalBackground) + .frame(width: Constants.diameter, height: Constants.diameter) + .overlay( + Text("\(number)") + .foregroundColor(.onboardingActionButton) + .bold() + + ) + } + +} + +// MARK: - Preview + +#Preview { + HStack { + FileImportView(source: .onePassword8, dataType: .passwords, isButtonDisabled: false) + .padding() + .frame(width: 512 - 20) + } + .font(.system(size: 13)) + .background(Color.white) +} diff --git a/DuckDuckGo/DataImport/View/FileImportViewController.swift b/DuckDuckGo/DataImport/View/FileImportViewController.swift deleted file mode 100644 index 505b8ea2cd..0000000000 --- a/DuckDuckGo/DataImport/View/FileImportViewController.swift +++ /dev/null @@ -1,225 +0,0 @@ -// -// FileImportViewController.swift -// -// Copyright © 2021 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 - -protocol FileImportViewControllerDelegate: AnyObject { - - func fileImportViewController(_ viewController: FileImportViewController, didSelectCSVFileWithURL: URL?) - func totalValidLogins(in fileURL: URL) -> Int? - - func fileImportViewController(_ viewController: FileImportViewController, didSelectBookmarksFileWithURL: URL?) - func totalValidBookmarks(in fileURL: URL) -> Int? -} - -final class FileImportViewController: NSViewController { - - enum Constants { - static let storyboardName = "DataImport" - static let identifier = "FileImportViewController" - static let wideStackViewSpacing: CGFloat = 20 - static let narrowStackViewSpacing: CGFloat = 12 - } - - static func create(importSource: DataImport.Source) -> FileImportViewController { - let storyboard = NSStoryboard(name: Constants.storyboardName, bundle: nil) - let controller: FileImportViewController = storyboard.instantiateController(identifier: Constants.identifier) - controller.importSource = importSource - return controller - } - - @IBOutlet var stackView: NSStackView! - - @IBOutlet var descriptionLabel: NSTextField! - @IBOutlet var selectFileButton: NSButton! - - @IBOutlet var selectedFileContainer: NSView! - @IBOutlet var selectedFileLabel: NSTextField! - @IBOutlet var totalValidLoginsLabel: NSTextField! - - @IBOutlet var safariInfoView: NSView! - @IBOutlet var lastPassInfoView: NSView! - @IBOutlet var onePassword7InfoView: NSView! - @IBOutlet var onePassword8InfoView: NSView! - - @IBOutlet var safariSettingsTextField: NSTextField! - - var importSource: DataImport.Source = .csv { - didSet { - if oldValue != importSource { - currentImportState = .awaitingFileSelection - } - - renderCurrentState() - } - } - weak var delegate: FileImportViewControllerDelegate? - - // MARK: - View State - - private enum ImportState { - case awaitingFileSelection - case selectedValidFile(fileURL: URL) - case selectedInvalidFile - } - - private var currentImportState: ImportState = .awaitingFileSelection - - // MARK: - View Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - setUpSafariImportInstructions() - renderCurrentState() - } - - private func setUpSafariImportInstructions() { - let safariSettingsTitle: String = { - if #available(macOS 13.0, *) { - return UserText.safariSettings - } else { - return UserText.safariPreferences - } - }() - - safariSettingsTextField.stringValue = "Safari → \(safariSettingsTitle)" - } - - private func renderCurrentState() { - guard isViewLoaded else { return } - render(state: currentImportState) - } - - private func renderAwaitingFileSelectionState() { - switch importSource { - case .safari, .safariTechnologyPreview: - descriptionLabel.isHidden = true - safariInfoView.isHidden = false - lastPassInfoView.isHidden = true - onePassword7InfoView.isHidden = true - onePassword8InfoView.isHidden = true - selectFileButton.title = UserText.importLoginsSelectSafariCSVFile - case .onePassword7: - descriptionLabel.isHidden = true - safariInfoView.isHidden = true - lastPassInfoView.isHidden = true - onePassword7InfoView.isHidden = false - onePassword8InfoView.isHidden = true - selectFileButton.title = UserText.importLoginsSelect1PasswordCSVFile - case .onePassword8: - descriptionLabel.isHidden = true - safariInfoView.isHidden = true - lastPassInfoView.isHidden = true - onePassword7InfoView.isHidden = true - onePassword8InfoView.isHidden = false - selectFileButton.title = UserText.importLoginsSelect1PasswordCSVFile - case .lastPass: - descriptionLabel.isHidden = true - safariInfoView.isHidden = true - lastPassInfoView.isHidden = false - onePassword7InfoView.isHidden = true - onePassword8InfoView.isHidden = true - selectFileButton.title = UserText.importLoginsSelectLastPassCSVFile - - case .brave, .chrome, .edge, .firefox: - assertionFailure("CSV Import not supported for \(importSource)") - fallthrough - case .csv: - descriptionLabel.isHidden = false - safariInfoView.isHidden = true - lastPassInfoView.isHidden = true - onePassword7InfoView.isHidden = true - onePassword8InfoView.isHidden = true - selectFileButton.title = UserText.importLoginsSelectCSVFile - case .bookmarksHTML: - descriptionLabel.isHidden = true - safariInfoView.isHidden = true - lastPassInfoView.isHidden = true - onePassword7InfoView.isHidden = true - onePassword8InfoView.isHidden = true - selectFileButton.title = UserText.importBookmarksSelectHTMLFile - } - } - - private func render(state: ImportState) { - descriptionLabel.stringValue = UserText.csvImportDescription - - switch state { - case .awaitingFileSelection: - selectedFileContainer.isHidden = true - renderAwaitingFileSelectionState() - case .selectedValidFile(let fileURL): - // In case the import source has changed, the file selection state's info view needs to be refreshed. - renderAwaitingFileSelectionState() - - selectedFileContainer.isHidden = false - selectedFileLabel.stringValue = fileURL.path - if importSource == .bookmarksHTML { - let totalBookmarksToImport = self.delegate?.totalValidBookmarks(in: fileURL) ?? 0 - selectFileButton.title = UserText.importBookmarksSelectAnotherFile - totalValidLoginsLabel.stringValue = UserText.importingFile(validBookmarks: totalBookmarksToImport) - } else { - let totalLoginsToImport = self.delegate?.totalValidLogins(in: fileURL) ?? 0 - selectFileButton.title = UserText.importLoginsSelectAnotherFile - totalValidLoginsLabel.stringValue = UserText.importingFile(validLogins: totalLoginsToImport) - } - case .selectedInvalidFile: - selectedFileLabel.isHidden = false - if importSource == .bookmarksHTML { - selectedFileLabel.stringValue = UserText.importBookmarksFailedToReadHTMLFile - selectFileButton.title = UserText.importBookmarksSelectHTMLFile - } else { - selectedFileLabel.stringValue = UserText.importLoginsFailedToReadCSVFile - selectFileButton.title = UserText.importLoginsSelectCSVFile - } - } - } - - @IBAction func selectFileButtonClicked(_ sender: Any) { - let fileExtension: String = { - switch importSource { - case .bookmarksHTML: - return "html" - default: - return "csv" - } - }() - let panel = NSOpenPanel.filePanel(allowedExtension: fileExtension) - let result = panel.runModal() - - if result == .OK { - if let selectedURL = panel.url { - currentImportState = .selectedValidFile(fileURL: selectedURL) - switch importSource { - case .bookmarksHTML: - delegate?.fileImportViewController(self, didSelectBookmarksFileWithURL: selectedURL) - case .csv, .onePassword8, .onePassword7, .lastPass, .safari, .safariTechnologyPreview: - delegate?.fileImportViewController(self, didSelectCSVFileWithURL: selectedURL) - case .brave, .chrome, .edge, .firefox: - break - } - } else { - currentImportState = .selectedInvalidFile - delegate?.fileImportViewController(self, didSelectCSVFileWithURL: nil) - } - } - - renderCurrentState() - } - -} diff --git a/DuckDuckGo/DataImport/View/NSAlert+DataImport.swift b/DuckDuckGo/DataImport/View/NSAlert+DataImport.swift index 1444e7e8c9..6019cd455e 100644 --- a/DuckDuckGo/DataImport/View/NSAlert+DataImport.swift +++ b/DuckDuckGo/DataImport/View/NSAlert+DataImport.swift @@ -24,35 +24,6 @@ extension NSAlert { return (accessoryView as? NSTextField)?.stringValue } - static func importFailedAlert(linkDelegate: NSTextViewDelegate) -> NSAlert { - let alert = NSAlert() - - let linkText = UserText.dataImportSubmitFeedback - let informativeText = UserText.dataImportFailedBody - - let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 250, height: 0)) - textView.applyLabelStyle() - - let attributedString = NSMutableAttributedString(string: informativeText) - attributedString.addLink("", toText: linkText) // The actual value of the link isn't important, we're reacting to the click via the delegate - attributedString.addAttributes([ - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - .foregroundColor: NSColor(named: "BlackWhite60")! - ], range: NSRange(location: 0, length: attributedString.length)) - - textView.textStorage?.setAttributedString(attributedString) - - textView.sizeToFit() - textView.delegate = linkDelegate - - alert.messageText = UserText.dataImportFailedTitle - alert.accessoryView = textView - alert.alertStyle = .warning - alert.addButton(withTitle: UserText.dataImportAlertAccept) - - return alert - } - static func passwordRequiredAlert(source: DataImport.Source) -> NSAlert { let textField = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) let alert = NSAlert() @@ -68,15 +39,4 @@ extension NSAlert { return alert } - static func failureAlert(message: String) -> NSAlert { - let alert = NSAlert() - - alert.messageText = UserText.dataImportFailedTitle - alert.informativeText = message - alert.alertStyle = .warning - alert.addButton(withTitle: UserText.dataImportAlertAccept) - - return alert - } - } diff --git a/DuckDuckGo/DataImport/View/ReportFeedbackView.swift b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift new file mode 100644 index 0000000000..577b779cc6 --- /dev/null +++ b/DuckDuckGo/DataImport/View/ReportFeedbackView.swift @@ -0,0 +1,153 @@ +// +// ReportFeedbackView.swift +// +// Copyright © 2023 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 SwiftUI + +struct ReportFeedbackView: View { + + @Binding var model: DataImportReportModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + { + if model.retryNumber <= 1 { + Text("Please submit a report to help us fix the issue.", + comment: "Data import failure Report dialog title.") + } else { + Text("That didn’t work either. Please submit a report to help us fix the issue.", + comment: "Data import failure Report dialog title containing a message that not only automatic data import has failed failed but manual browser data import didn‘t work either.") + } + }() + .bold() + .padding(.bottom, 8) + + VStack(alignment: .leading, spacing: 12) { + Text(""" + The following information will be sent to DuckDuckGo. No personally identifiable information will be sent. + """, comment: "Data import failure Report dialog subtitle about the data being collected with the report.") + + InfoItemView(model.osVersion) { + Text("macOS version", comment: "Data import failure Report dialog description of a report field providing user‘s macOS version") + } + InfoItemView(model.appVersion) { + Text("DuckDuckGo browser version", comment: "Data import failure Report dialog description of a report field providing current DuckDuckGo Browser version") + } + InfoItemView(model.importSourceDescription) { + Text("The version of the browser you are trying to import from", comment: "Data import failure Report dialog description of a report field providing version of a browser user is trying to import data from") + } + InfoItemView(model.error.localizedDescription) { + Text("Error message & code", comment: "") + } + } + .padding(.bottom, 24) + + EditableTextView(text: $model.text, + font: .systemFont(ofSize: 13), + insets: NSSize(width: 7, height: 12), + cornerRadius: 6, + backgroundColor: .textBackgroundColor, + textColor: .textColor, + focusRingType: .exterior, + isFocusedOnAppear: true) + .frame(height: 114) + .shadow(color: Color.addressBarShadow, radius: 1, x: 0, y: 1) + .overlay( + VStack(alignment: .leading) { + HStack(alignment: .top) { + Text("Add any details that you think may help us fix the problem", + comment: "Data import failure Report dialog suggestion to provide a comments with extra details helping to identify the data import problem.") + .foregroundColor(Color(.placeholderTextColor)) + .padding(.leading, 11) + Spacer() + } + .padding(.top, 11) + Spacer() + } + .visibility(model.text.isEmpty ? .visible : .gone) + .allowsHitTesting(false) + ) + } + } + +} + +private struct InfoItemView: View { + + let text: () -> Text + let data: String + @State private var isPopoverVisible = false + + init(_ data: String, text: @escaping () -> Text) { + self.text = text + self.data = data + } + + var body: some View { + HStack(spacing: 6) { + Button { + isPopoverVisible.toggle() + } label: { + Image(.infoLight) + } + .buttonStyle(.borderless) + .popover(isPresented: $isPopoverVisible, arrowEdge: .bottom) { + Text(data).padding() + } + + text() + } + } + +} + +#Preview { { + + struct PreviewView: View { + @State var model = DataImportReportModel(importSource: .safari, importSourceVersion: UserAgent.safariVersion, error: { + enum ImportError: DataImportError { + enum OperationType: Int { + case imp + } + + var type: OperationType { .imp } + var action: DataImportAction { .generic } + var underlyingError: Error? { + if case .err(let err) = self { + return err + } + return nil + } + + static var errorDomain: String { "ReportFeedbackPreviewError" } + var errorType: DataImport.ErrorType { .noData } + + case err(Error) + } + return ImportError.err(CocoaError(.fileReadUnknown)) + }(), retryNumber: 1) + + var body: some View { + ReportFeedbackView(model: $model) + .frame(width: 512 - 20) + .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) + } + } + return PreviewView() + +} ()} diff --git a/DuckDuckGo/DataImport/View/RequestFilePermissionView.swift b/DuckDuckGo/DataImport/View/RequestFilePermissionView.swift new file mode 100644 index 0000000000..17549183d4 --- /dev/null +++ b/DuckDuckGo/DataImport/View/RequestFilePermissionView.swift @@ -0,0 +1,55 @@ +// +// RequestFilePermissionView.swift +// +// Copyright © 2023 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 SwiftUI + +struct RequestFilePermissionView: View { + + private let source: DataImport.Source + private let url: URL + private let requestDataDirectoryPermission: @MainActor (URL) -> URL? + private let callback: @MainActor (URL) -> Void + + init(source: DataImport.Source, url: URL, requestDataDirectoryPermission: @escaping @MainActor (URL) -> URL?, callback: @escaping @MainActor (URL) -> Void) { + self.source = source + self.url = url + self.requestDataDirectoryPermission = requestDataDirectoryPermission + self.callback = callback + } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text("DuckDuckGo needs your permission to read the \(source.importSourceName) bookmarks file. Select the \(source.importSourceName) folder to import bookmarks.", + comment: "Data import warning that DuckDuckGo browser requires file reading permissions for another browser name (%1$@), and instruction to select its (same browser name - %2$@) bookmarks folder.") + Button("Select \(source.importSourceName) Folder…") { + if let url = requestDataDirectoryPermission(url) { + callback(url) + } + } + } + } + +} + +#Preview { + RequestFilePermissionView(source: .safari, url: URL(fileURLWithPath: "/file/path")) { + $0 + } callback: { _ in } + .padding(EdgeInsets(top: 20, leading: 20, bottom: 16, trailing: 20)) + .frame(width: 512) +} diff --git a/DuckDuckGo/DataImport/View/RequestFilePermissionViewController.swift b/DuckDuckGo/DataImport/View/RequestFilePermissionViewController.swift deleted file mode 100644 index 87ace08729..0000000000 --- a/DuckDuckGo/DataImport/View/RequestFilePermissionViewController.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// RequestFilePermissionViewController.swift -// -// Copyright © 2021 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 - -protocol RequestFilePermissionViewControllerDelegate: AnyObject { - - func requestFilePermissionViewControllerDidReceivePermission(_ viewController: RequestFilePermissionViewController) - -} - -final class RequestFilePermissionViewController: NSViewController { - - enum Constants { - static let storyboardName = "DataImport" - static let identifier = "RequestFilePermissionViewController" - } - - static func create(importSource: DataImport.Source, permissionsRequired: [DataImport.DataType], permissionAuthorization: DataDirectoryPermissionAuthorization) -> RequestFilePermissionViewController { - let storyboard = NSStoryboard(name: Constants.storyboardName, bundle: nil) - - return storyboard.instantiateController(identifier: Constants.identifier) { coder in - RequestFilePermissionViewController(coder: coder, - importSource: importSource, - permissionsRequired: permissionsRequired, - permissionAuthorization: permissionAuthorization) - } - } - - @IBOutlet var descriptionLabel: NSTextField! - @IBOutlet var requestPermissionButton: NSButton! - - weak var delegate: RequestFilePermissionViewControllerDelegate? - - private let importSource: DataImport.Source - private let permissionsRequired: [DataImport.DataType] - private let permissionAuthorization: DataDirectoryPermissionAuthorization - - init?(coder: NSCoder, importSource: DataImport.Source, permissionsRequired: [DataImport.DataType], permissionAuthorization: DataDirectoryPermissionAuthorization) { - self.importSource = importSource - self.permissionsRequired = permissionsRequired - self.permissionAuthorization = permissionAuthorization - - super.init(coder: coder) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - descriptionLabel.stringValue = UserText.bookmarkImportSafariPermissionDescription - requestPermissionButton.title = UserText.bookmarkImportSafariRequestPermissionButtonTitle - } - - @IBAction private func presentBookmarksOpenPanel(_ sender: AnyObject) { - if permissionAuthorization.requestDataDirectoryPermission() != nil, - permissionAuthorization.canReadBookmarksFile() { - - delegate?.requestFilePermissionViewControllerDidReceivePermission(self) - } - } - -} diff --git a/DuckDuckGo/DuckDuckGoAppStore.entitlements b/DuckDuckGo/DuckDuckGoAppStore.entitlements index 340f405420..cfabed1af6 100644 --- a/DuckDuckGo/DuckDuckGoAppStore.entitlements +++ b/DuckDuckGo/DuckDuckGoAppStore.entitlements @@ -26,6 +26,14 @@ /Library/Application Support/BraveSoftware/Brave-Browser/ /Library/Application Support/Firefox/ /Library/Application Support/Microsoft Edge/ + /Library/Application Support/Vivaldi/ + /Library/Application Support/com.operasoftware.Opera/ + /Library/Application Support/com.operasoftware.OperaGX/ + /Library/Application Support/Yandex/YandexBrowser/ + /Library/Application Support/Chromium/ + /Library/Application Support/Coccoc/ + /Library/Application Support/TorBrowser-Data/Browser/ + /Library/Application Support/Arc/User Data/ keychain-access-groups diff --git a/DuckDuckGo/Feedback/Model/Feedback.swift b/DuckDuckGo/Feedback/Model/Feedback.swift index bc44cefab4..a4998aa897 100644 --- a/DuckDuckGo/Feedback/Model/Feedback.swift +++ b/DuckDuckGo/Feedback/Model/Feedback.swift @@ -22,9 +22,13 @@ import QuartzCore struct Feedback { enum Category { + case generalFeedback + case designFeedback case bug case featureRequest case other + case usability + case dataImport } let category: Category diff --git a/DuckDuckGo/Feedback/Model/FeedbackSender.swift b/DuckDuckGo/Feedback/Model/FeedbackSender.swift index 1aa70afd15..3e032d36aa 100644 --- a/DuckDuckGo/Feedback/Model/FeedbackSender.swift +++ b/DuckDuckGo/Feedback/Model/FeedbackSender.swift @@ -48,15 +48,33 @@ final class FeedbackSender { } } + func sendDataImportReport(_ report: DataImportReportModel) { + sendFeedback(Feedback(category: .dataImport, + comment: """ + \(report.text.trimmingWhitespace()) + + --- + + Import source: \(report.importSourceDescription) + Error: \(report.error.localizedDescription) + """, + appVersion: report.appVersion, + osVersion: report.osVersion)) + } + } fileprivate extension Feedback.Category { var asanaId: String { switch self { - case .bug: return "1199184518165816" - case .featureRequest: return "1199184518165815" - case .other: return "1200574389728916" + case .generalFeedback: "1199184518165814" + case .designFeedback: "1199214127353569" + case .bug: "1199184518165816" + case .featureRequest: "1199184518165815" + case .other: "1200574389728916" + case .usability: "1204135764912065" + case .dataImport: "1205975547451886" } } diff --git a/DuckDuckGo/Feedback/View/FeedbackViewController.swift b/DuckDuckGo/Feedback/View/FeedbackViewController.swift index 4043a88c7b..5b9a1abf36 100644 --- a/DuckDuckGo/Feedback/View/FeedbackViewController.swift +++ b/DuckDuckGo/Feedback/View/FeedbackViewController.swift @@ -260,6 +260,9 @@ final class FeedbackViewController: NSViewController { browserFeedbackDescriptionLabel.stringValue = UserText.feedbackFeatureRequestDescription case .other: browserFeedbackDescriptionLabel.stringValue = UserText.feedbackOtherDescription + case .generalFeedback, .designFeedback, .usability, .dataImport: + assertionFailure("unexpected flow") + browserFeedbackDescriptionLabel.stringValue = "\(category)" } } diff --git a/DuckDuckGo/HomePage/Model/DataImportStatusProviding.swift b/DuckDuckGo/HomePage/Model/DataImportStatusProviding.swift index 3ce04a4a41..2bbf0dca02 100644 --- a/DuckDuckGo/HomePage/Model/DataImportStatusProviding.swift +++ b/DuckDuckGo/HomePage/Model/DataImportStatusProviding.swift @@ -47,8 +47,10 @@ final class BookmarksAndPasswordsImportStatusProvider: DataImportStatusProviding } return successfulImportHappened! } + + @MainActor func showImportWindow(completion: (() -> Void)?) { - DataImportViewController.show(completion: completion) + DataImportView.show(completion: completion) } // It only cover the case in which the user has imported bookmar AFTER already having some bookmarks diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings new file mode 100644 index 0000000000..96ee5fdd1b --- /dev/null +++ b/DuckDuckGo/Localizable.xcstrings @@ -0,0 +1,9267 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "" : { + + }, + "\nTerms of Service" : { + + }, + "\nThe service cannot remove all of your information from the Internet." : { + + }, + "\nThe service is for limited and personal use only." : { + + }, + "\nWe don’t save your personal information for this service to function." : { + + }, + "\nWe may terminate access at any time." : { + + }, + "\nWe provide this beta service as-is, and without warranty." : { + + }, + "\nWe submit removal requests to data broker sites on your behalf." : { + + }, + "\nYou give DuckDuckGo authority to act on your Here's an updated version with the remaining content:" : { + + }, + "\nYour personal information is stored locally on your device." : { + + }, + "**%lld** tracking attempts blocked" : { + + }, + "%@ does not support storing passwords" : { + "comment" : "Data Import disabled checkbox message about a browser (%@) not supporting storing passwords" + }, + "%lld" : { + + }, + "%lld tracking attempts blocked" : { + + }, + "• Because data broker sites often have multi-step processes required to have information removed, and because they regularly update their databases with new personal information, this authorization includes ongoing action on your behalf solely to perform the service." : { + + }, + "• Our main " : { + + }, + "• The information you provide when you sign-up to use this service, for example your name, age, address, and phone number is stored on your device." : { + + }, + "• The only personal information we may receive is a confirmation email from data broker sites which is deleted within 72 hours." : { + + }, + "• The service is available for your personal use only. You represent and warrant that you will only initiate removal of your own personal information." : { + + }, + "• This beta product may collect more diagnostic data than our typical products. Examples of such data include: alerts of low memory, application restarts, and user engagement with product features." : { + + }, + "• This Privacy Policy is for our waitlist beta service." : { + + }, + "• This service is available on one device only." : { + + }, + "• This service is in beta, and your access to it is temporary." : { + + }, + "• This service is provided as-is and without warranties or guarantees of any kind." : { + + }, + "• This service requests removal from a limited number of data broker sites only. You understand that we cannot guarantee that the third-party sites will honor the requests, or that your personal information will not reappear in the future." : { + + }, + "• To the extent possible under applicable law, DuckDuckGo will not be liable for any damage or loss arising from your use of the service. In any event, the total aggregate liability of DuckDuckGo shall not exceed $25 or the equivalent in your local currency." : { + + }, + "• To use this service, you must be 18 or older." : { + + }, + "• We may find additional information on data broker sites through this scanning process, like alternative names or phone numbers, or the names of your relatives. This information is also stored locally on your device." : { + + }, + "• We may in the future transfer responsibility for the service to a subsidiary of DuckDuckGo. If that happens, you agree that references to “DuckDuckGo” will refer to our subsidiary, which will then become responsible for providing the service and for any liabilities relating to it." : { + + }, + "• We regularly re-scan data broker sites to check on the removal status of your information. If it has reappeared, we resubmit the removal request." : { + + }, + "• We reserve the right to terminate access at any time in our sole discretion, including for violation of these terms or our DuckDuckGo Terms of Service, which are incorporated by reference." : { + + }, + "• We submit removal requests to the data broker sites directly from your device, unlike other services where the removal process is initiated on remote servers." : { + + }, + "• We then scan data brokers from your device to check if any sites contain your personal information." : { + + }, + "• You hereby authorize DuckDuckGo to act on your behalf to request removal of your personal information from data broker sites." : { + + }, + "• You understand that we will only be able to request the removal of information based upon the information you provide to us." : { + + }, + "••••••••••••" : { + + }, + "`continue`" : { + "comment" : "Continue button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Continue" + } + } + } + }, + "⚠️ Notes are deprecated." : { + + }, + "about.app_name" : { + "comment" : "Application name to be displayed in the About dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo" + } + } + } + }, + "about.app_name_app_store" : { + "comment" : "Application name to be displayed in the About dialog in App Store app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo for Mac App Store" + } + } + } + }, + "Actual Size" : { + "comment" : "Main Menu View item" + }, + "Add any details that you think may help us fix the problem" : { + "comment" : "Data import failure Report dialog suggestion to provide a comments with extra details helping to identify the data import problem." + }, + "add.favorite" : { + "comment" : "Button for adding a favorite bookmark", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add Favorite" + } + } + } + }, + "add.link.to.bookmarks" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add Link to Bookmarks" + } + } + } + }, + "add.to.favorites" : { + "comment" : "Button for adding bookmarks to favorites", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add to Favorites" + } + } + } + }, + "Address" : { + + }, + "address.bar.search.suffix" : { + "comment" : "Suffix of searched terms in address bar. Example: best watching machine . Search DuckDuckGo", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search DuckDuckGo" + } + } + } + }, + "address.bar.visit.suffix" : { + "comment" : "Address bar suffix of possibly visited website. Example: spreadprivacy.com . Visit spreadprivacy.com", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Visit" + } + } + } + }, + "after.bitwarden.installation.info" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "After installing, return to DuckDuckGo to complete the setup." + } + } + } + }, + "alert.sync-bookmarks-paused-description" : { + "comment" : "Description for alert shown when sync bookmarks paused for too many items", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You have exceeded the bookmarks sync limit. Try deleting some bookmarks. Until this is resolved your bookmarks will not be backed up." + } + } + } + }, + "alert.sync-bookmarks-paused-title" : { + "comment" : "Title for alert shown when sync bookmarks paused for too many items", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks Sync is Paused" + } + } + } + }, + "alert.sync-credentials-paused-description" : { + "comment" : "Description for alert shown when sync credentials paused for too many items", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You have exceeded the passwords sync limit. Try deleting some passwords. Until this is resolved your passwords will not be backed up." + } + } + } + }, + "alert.sync-credentials-paused-title" : { + "comment" : "Title for alert shown when sync credentials paused for too many items", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords Sync is Paused" + } + } + } + }, + "alert.sync.warning.data-syncing-disabled-upgrade-required" : { + "comment" : "Data syncing unavailable warning message", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sorry, but Sync & Backup is no longer available in this app version. Please update DuckDuckGo to the latest version to continue." + } + } + } + }, + "alert.sync.warning.sync-paused" : { + "comment" : "Title of the warning message", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sync & Backup is Paused" + } + } + } + }, + "alert.sync.warning.sync-unavailable-message" : { + "comment" : "Data syncing unavailable warning message", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sorry, but Sync & Backup is currently unavailable. Please try again later." + } + } + } + }, + "alert.title" : { + "comment" : "Title formatted with presenting domain", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "A message from %@" + } + } + } + }, + "allow.integration" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allow Integration with DuckDuckGo" + } + } + } + }, + "also applies here." : { + + }, + "auth.alert.login.button" : { + "comment" : "Authentication Alert Sign In Button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign In" + } + } + } + }, + "auth.alert.message.encrypted" : { + "comment" : "Authentication Alert - populated with a domain name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign in to %@. Your login information will be sent securely." + } + } + } + }, + "auth.alert.message.plain" : { + "comment" : "Authentication Alert - populated with a domain name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log in to %@. Your password will be sent insecurely because the connection is unencrypted." + } + } + } + }, + "auth.alert.password.placeholder" : { + "comment" : "Authentication Password field placeholder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Password" + } + } + } + }, + "auth.alert.title" : { + "comment" : "Authentication Alert Title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Authentication Required" + } + } + } + }, + "auth.alert.username.placeholder" : { + "comment" : "Authentication User name field placeholder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Username" + } + } + } + }, + "autoconsent.checkbox.title" : { + "comment" : "Autoconsent settings checkbox title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Automatically handle cookie pop-ups" + } + } + } + }, + "autoconsent.explanation" : { + "comment" : "Autoconsent feature explanation in settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo will try to select the most private settings available and hide these pop-ups for you." + } + } + } + }, + "autoconsent.from.setup.modal.body" : { + "comment" : "Body for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "When we detect cookie pop-ups on sites you visit, we can try to select the most private settings available and hide pop-ups like this." + } + } + } + }, + "autoconsent.from.setup.modal.cta.confirm" : { + "comment" : "Confirm button for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Handle Pop-ups For Me" + } + } + } + }, + "autoconsent.from.setup.modal.title" : { + "comment" : "Title for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Want DuckDuckGo to handle cookie pop-ups?" + } + } + } + }, + "autoconsent.modal.body" : { + "comment" : "Body for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Want me to handle these for you? I can try to minimize cookies, maximize privacy, and hide pop-ups like these." + } + } + } + }, + "autoconsent.modal.cta.confirm" : { + "comment" : "Confirm button for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manage Cookie Pop-ups" + } + } + } + }, + "autoconsent.modal.cta.deny" : { + "comment" : "Deny button for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Thanks" + } + } + } + }, + "autoconsent.modal.title" : { + "comment" : "Title for modal asking the user to auto manage cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Looks like this site has a cookie pop-up 👇" + } + } + } + }, + "autoconsent.title" : { + "comment" : "Autoconsent settings section title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cookie Pop-ups" + } + } + } + }, + "autofill.addresses" : { + "comment" : "Autofill autosaved data type", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Addresses" + } + } + } + }, + "autofill.ask-to-save" : { + "comment" : "Autofill settings section title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save and Autofill" + } + } + } + }, + "autofill.ask-to-save.explanation" : { + "comment" : "Description of Autofill autosaving feature - used in settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Receive prompts to save new information and autofill online forms." + } + } + } + }, + "autofill.auto-lock" : { + "comment" : "Autofill settings section title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Auto-lock" + } + } + } + }, + "autofill.autolock-locks-form-filling" : { + "comment" : "Lock form filling when auto-lock is active text", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Also lock password form fill" + } + } + } + }, + "autofill.copy-password" : { + "comment" : "Tooltip for the Autofill panel's Copy Password button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy password" + } + } + } + }, + "autofill.copy-username" : { + "comment" : "Tooltip for the Autofill panel's Copy Username button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy username" + } + } + } + }, + "autofill.hide-password" : { + "comment" : "Tooltip for the Autofill panel's Hide Password button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide password" + } + } + } + }, + "autofill.lock-when-idle" : { + "comment" : "Autofill auto-lock setting", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Lock autofill after computer is idle for" + } + } + } + }, + "autofill.manager.status.locked" : { + "comment" : "Locked status for password manager", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Locked" + } + } + } + }, + "autofill.manager.status.unlocked" : { + "comment" : "Unlocked status for password manager", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unlocked" + } + } + } + }, + "autofill.never-lock" : { + "comment" : "Autofill auto-lock setting", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Never lock autofill" + } + } + } + }, + "autofill.never-lock-warning" : { + "comment" : "Autofill disabled auto-lock warning", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "If not locked, anyone with access to your device will be able to use and modify your autofill data. For security purposes, credit card form fill always requires authentication." + } + } + } + }, + "autofill.password-manager" : { + "comment" : "Autofill settings section title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Password Manager" + } + } + } + }, + "autofill.password-manager.bitwarden" : { + "comment" : "Autofill password manager row title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden" + } + } + } + }, + "autofill.password-manager.bitwarden.disclaimer" : { + "comment" : "Autofill password manager Bitwarden disclaimer", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Setup requires installing the Bitwarden app." + } + } + } + }, + "autofill.password-manager.duckduckgo" : { + "comment" : "Autofill password manager row title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo built-in password manager" + } + } + } + }, + "autofill.payment-methods" : { + "comment" : "Autofill autosaved data type", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Payment methods" + } + } + } + }, + "autofill.popover.autosave.button.text" : { + "comment" : "Button to view the recently autosaved password", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "View" + } + } + } + }, + "autofill.popover.autosave.text" : { + "comment" : "Text confirming a password has been saved for the %@ domain", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Password saved for %@" + } + } + } + }, + "autofill.popover.change-in" : { + "comment" : "Suffix of the label - change in settings - ", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change in" + } + } + } + }, + "autofill.popover.open-password-manager" : { + "comment" : "Open password manager button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open %@" + } + } + } + }, + "autofill.popover.password-manager-connected-to-user" : { + "comment" : "Label describing what user is connected to the password manager", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Connected to user %@" + } + } + } + }, + "autofill.popover.password-manager-title" : { + "comment" : "Explanation of what password manager is being used", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You're using %@ to manage passwords" + } + } + } + }, + "autofill.popover.settings-button" : { + "comment" : "Open Settings Button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Settings" + } + } + } + }, + "autofill.show-password" : { + "comment" : "Tooltip for the Autofill panel's Show Password button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show password" + } + } + } + }, + "autofill.usernames-and-passwords" : { + "comment" : "Autofill autosaved data type", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Usernames and passwords" + } + } + } + }, + "autofill.view-autofill-content" : { + "comment" : "View Autofill Content Button name in the autofill settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "View Autofill Content…" + } + } + } + }, + "Birthday" : { + + }, + "bitwarden.app.found" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden app found!" + } + } + } + }, + "bitwarden.cant.access.container" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo needs permission to access Bitwarden. You can grant DuckDuckGo Full Disk Access in System Settings, or switch back to the built-in password manager." + } + } + } + }, + "bitwarden.connect.communication-info" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All communication between Bitwarden and DuckDuckGo is encrypted and the data never leaves your device." + } + } + } + }, + "bitwarden.connect.description" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We’ll walk you through connecting to Bitwarden, so you can use it in DuckDuckGo." + } + } + } + }, + "bitwarden.connect.history-info" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden will have access to your browsing history." + } + } + } + }, + "bitwarden.connect.privacy" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy" + } + } + } + }, + "bitwarden.connect.title" : { + "comment" : "Title for the Bitwarden onboarding flow", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Connect to Bitwarden" + } + } + } + }, + "bitwarden.connecting" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Connecting to Bitwarden" + } + } + } + }, + "bitwarden.error" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unable to find or connect to Bitwarden" + } + } + } + }, + "bitwarden.handshake.not.approved" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Handshake not approved in Bitwarden app" + } + } + } + }, + "bitwarden.install" : { + "comment" : "Button to install Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Install Bitwarden" + } + } + } + }, + "bitwarden.install.info" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "To begin setup, first install Bitwarden from the App Store." + } + } + } + }, + "bitwarden.integration.complete" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden integration complete!" + } + } + } + }, + "bitwarden.integration.complete.info" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You are now using Bitwarden as your password manager." + } + } + } + }, + "bitwarden.integration.not.approved" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Integration with DuckDuckGo is not approved in Bitwarden app" + } + } + } + }, + "bitwarden.is.ready.to.connect" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden is ready to connect to DuckDuckGo!" + } + } + } + }, + "bitwarden.missing.handshake" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Missing handshake" + } + } + } + }, + "bitwarden.not.installed" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden app is not installed" + } + } + } + }, + "bitwarden.old.version" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please update Bitwarden to the latest version" + } + } + } + }, + "bitwarden.preferences.complete-setup" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Complete Setup…" + } + } + } + }, + "bitwarden.preferences.open-bitwarden" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Bitwarden" + } + } + } + }, + "bitwarden.preferences.run" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden app not running" + } + } + } + }, + "bitwarden.preferences.unable-to-connect" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unable to find or connect to Bitwarden" + } + } + } + }, + "bitwarden.preferences.unlock" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unlock Bitwarden" + } + } + } + }, + "bitwarden.waiting.for.handshake" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Waiting for the handshake approval in Bitwarden app" + } + } + } + }, + "bitwarden.waiting.for.permissions" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Waiting for permission to use Bitwarden in DuckDuckGo…" + } + } + } + }, + "bitwarden.waiting.for.status.response" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Waiting for the status response from Bitwarden" + } + } + } + }, + "Bookmark import failed:" : { + "comment" : "Data import summary format of how many bookmarks (%lld) failed to import." + }, + "Bookmark import failed." : { + "comment" : "Data import summary message of failed bookmarks import." + }, + "bookmark.page" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmark Page" + } + } + } + }, + "bookmark.this.page" : { + "comment" : "Menu item for bookmarking current page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmark This Page" + } + } + } + }, + "bookmark.update" : { + "comment" : "Option for updating a bookmark", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Update Bookmark" + } + } + } + }, + "bookmarks" : { + "comment" : "Button for bookmarks", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks" + } + } + } + }, + "Bookmarks Import Complete:" : { + "comment" : "Bookmarks Data Import result summary headline" + }, + "Bookmarks:" : { + "comment" : "Data import summary format of how many bookmarks (%lld) were successfully imported." + }, + "Bookmarks…" : { + "comment" : "Main Menu File-Export item" + }, + "bookmarks.bar.context-menu.copy" : { + "comment" : "Copy menu item for the bookmarks bar context menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy" + } + } + } + }, + "bookmarks.bar.context-menu.delete" : { + "comment" : "Delete menu item for the bookmarks bar context menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete" + } + } + } + }, + "bookmarks.bar.context-menu.move-to-end" : { + "comment" : "Move to End menu item for the bookmarks bar context menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Move to End" + } + } + } + }, + "bookmarks.bar.folder.empty" : { + "comment" : "Empty state for a bookmarks bar folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Empty" + } + } + } + }, + "bookmarks.bar.prompt.accept" : { + "comment" : "Accept button label on bookmarks bar prompt", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show" + } + } + } + }, + "bookmarks.bar.prompt.dismiss" : { + "comment" : "Dismiss button label on bookmarks bar prompt", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide" + } + } + } + }, + "bookmarks.bar.prompt.message" : { + "comment" : "Message show for bookmarks bar prompt", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show the Bookmarks Bar for quick access to your new bookmarks." + } + } + } + }, + "bookmarks.bar.prompt.title" : { + "comment" : "Title for bookmarks bar prompt", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Bookmarks Bar?" + } + } + } + }, + "bookmarks.bar.show" : { + "comment" : "Menu item for showing the bookmarks bar\n Preference item for showing the bookmarks bar", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks Bar" + } + } + } + }, + "bookmarks.bar.show.always" : { + "comment" : "Preference for always showing the bookmarks bar", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always Show" + } + } + } + }, + "bookmarks.bar.show.never" : { + "comment" : "Preference for never showing the bookmarks bar on new tab", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Never Show" + } + } + } + }, + "bookmarks.bar.show.new-tab-only" : { + "comment" : "Preference for only showing the bookmarks bar on new tab", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Only Show on New Tab" + } + } + } + }, + "bookmarks.imported.from.folder" : { + "comment" : "Name of the folder the imported bookmarks are saved into", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Imported from" + } + } + } + }, + "bookmarks.manage" : { + "comment" : "Button for opening the bookmarks management interface", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manage" + } + } + } + }, + "bookmarks.manage-bookmarks" : { + "comment" : "Menu item for opening the bookmarks management interface", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manage Bookmarks" + } + } + } + }, + "bookmarks.open.in.new.tabs" : { + "comment" : "Open all bookmarks in folder in new tabs", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open in New Tabs" + } + } + } + }, + "bookmarks.show-toolbar-panel" : { + "comment" : "Menu item for opening the bookmarks panel", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Bookmarks Panel" + } + } + } + }, + "Bring All to Front" : { + "comment" : "Main Menu Window item" + }, + "burner.homepage.description.1" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Browse without saving local history" + } + } + } + }, + "burner.homepage.description.2" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign in to a site with a different account" + } + } + } + }, + "burner.homepage.description.3" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Troubleshoot websites" + } + } + } + }, + "burner.homepage.description.4" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fire windows are isolated from other browser data, and their data is burned when you close them. They have the same tracking protection as other windows." + } + } + } + }, + "burner.tab.home.title" : { + "comment" : "Tab title for Fire Tab", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Fire Tab" + } + } + } + }, + "burner.window.header" : { + "comment" : "Header shown on the hompage of the Fire Window", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fire Window" + } + } + } + }, + "cancel" : { + "comment" : "Cancel button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, + "cannot.open.file.alert.header" : { + "comment" : "Header of the alert dialog informing user it is not possible to open the file", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cannot Open File" + } + } + } + }, + "cannot.open.file.alert.informative" : { + "comment" : "Informative of the alert dialog informing user it is not possible to open the file", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The App Store version of DuckDuckGo can only access local files if you drag-and-drop them into a browser window.\n\n To navigate local files using the address bar, please download DuckDuckGo directly from https://duckduckgo.com/mac." + } + } + } + }, + "Capitalize" : { + "comment" : "Main Menu Edit-Transformations item" + }, + "Check Document Now" : { + "comment" : "Main Menu Edit-Spellingand item" + }, + "Check for Updates…" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "Check Grammar With Spelling" : { + "comment" : "Main Menu Edit-Spellingand item" + }, + "Check Spelling While Typing" : { + "comment" : "Main Menu Edit-Spellingand item" + }, + "check.allow.integration" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Check Allow integration with DuckDuckGo." + } + } + } + }, + "check.for.update" : { + "comment" : "Button users can use to check for a new update", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Check for Update" + } + } + } + }, + "clear" : { + "comment" : "Clear button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear" + } + } + } + }, + "Clear All History…" : { + "comment" : "Main Menu History item" + }, + "Close All Windows" : { + "comment" : "Main Menu File item" + }, + "Close Window" : { + "comment" : "Main Menu File item" + }, + "close.other.tabs" : { + "comment" : "Menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close Other Tabs" + } + } + } + }, + "close.tab" : { + "comment" : "Menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close Tab" + } + } + } + }, + "close.tab.on.back" : { + "comment" : "Close Child Tab on Back Button press and return Back to the Parent Tab without title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close and Return to Previous Tab" + } + } + } + }, + "close.tab.on.back.format" : { + "comment" : "Close Child Tab on Back Button press and return Back to the Parent Tab titled “%@”", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close and Return to “%@”" + } + } + } + }, + "close.tabs.to.the.right" : { + "comment" : "Menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close Tabs to the Right" + } + } + } + }, + "Contact Info" : { + + }, + "copy" : { + "comment" : "Copy button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy" + } + } + } + }, + "Copy" : { + "comment" : "Command\nMain Menu Edit item" + }, + "copy-selection" : { + "comment" : "Copy selection menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy" + } + } + } + }, + "copy.image.address" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy Image Address" + } + } + } + }, + "Correct Spelling Automatically" : { + "comment" : "Main Menu Edit-Spellingand item" + }, + "Country" : { + + }, + "Cut" : { + "comment" : "Main Menu Edit item" + }, + "dashboard.permission.allow" : { + "comment" : "Privacy Dashboard: Website can always access input media device", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always allow" + } + } + } + }, + "dashboard.permission.allow.on" : { + "comment" : "Permission Popover 'Always allow on' (for domainName) checkbox", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always allow on" + } + } + } + }, + "dashboard.permission.ask" : { + "comment" : "Privacy Dashboard: Website should always Ask for permission for input media device access", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Ask every time" + } + } + } + }, + "dashboard.permission.deny" : { + "comment" : "Privacy Dashboard: Website can never access input media device", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always deny" + } + } + } + }, + "dashboard.popups.ask" : { + "comment" : "Make PopUp Windows always asked from user for current domain", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Notify" + } + } + } + }, + "Data Detectors" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "data-broker-protection.optionsMenu" : { + "comment" : "Menu item data broker protection feature", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Personal Information Removal" + } + } + } + }, + "data-broker-protection.privacy-policy.title" : { + "comment" : "Privacy Policy title for Personal Information Removal", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy Policy" + } + } + } + }, + "data-broker-protection.waitlist.availability-disclaimer" : { + "comment" : "Availability disclaimer for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Personal Information Removal is free during the beta.\nJoin the waitlist and we'll notify you when ready." + } + } + } + }, + "data-broker-protection.waitlist.button.agree-and-continue" : { + "comment" : "Agree and Continue button for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Agree and Continue" + } + } + } + }, + "data-broker-protection.waitlist.button.cancel" : { + "comment" : "Cancel button for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, + "data-broker-protection.waitlist.button.close" : { + "comment" : "Close button for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close" + } + } + } + }, + "data-broker-protection.waitlist.button.dismiss" : { + "comment" : "Dismiss button for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Dismiss" + } + } + } + }, + "data-broker-protection.waitlist.button.done" : { + "comment" : "Close button for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Done" + } + } + } + }, + "data-broker-protection.waitlist.button.enable-notifications" : { + "comment" : "Enable Notifications button for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Notifications" + } + } + } + }, + "data-broker-protection.waitlist.button.get-started" : { + "comment" : "Get Started button for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get Started" + } + } + } + }, + "data-broker-protection.waitlist.button.got-it" : { + "comment" : "Get started button for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get started" + } + } + } + }, + "data-broker-protection.waitlist.button.join-waitlist" : { + "comment" : "Join Waitlist button for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Join the Waitlist" + } + } + } + }, + "data-broker-protection.waitlist.button.no-thanks" : { + "comment" : "No Thanks button for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Thanks" + } + } + } + }, + "data-broker-protection.waitlist.enable-notifications" : { + "comment" : "Enable notifications prompt for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Want to get a notification when your Personal Information Removal invite is ready?" + } + } + } + }, + "data-broker-protection.waitlist.enable.subtitle" : { + "comment" : "Subtitle for Personal Information Removal enable screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We’ll need your name, address and the year you were born in order to find your personal information on data broker sites\n\nThis info is stored securely on your device, and is never sent to DuckDuckGo." + } + } + } + }, + "data-broker-protection.waitlist.enable.title" : { + "comment" : "Title for Personal Information Removal enable screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Let’s get started" + } + } + } + }, + "data-broker-protection.waitlist.invited.subtitle" : { + "comment" : "Subtitle for Personal Information Removal invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Automatically find and remove your personal information – such as your name and address – from 17+ sites that store and sell it, reducing the risk of identity theft and spam." + } + } + } + }, + "data-broker-protection.waitlist.invited.title" : { + "comment" : "Title for Personal Information Removal invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You’re invited to try\nPersonal Information Removal beta!" + } + } + } + }, + "data-broker-protection.waitlist.join.subtitle.1" : { + "comment" : "First subtitle for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Automatically scan and remove your data from 17+ sites that sell personal information with DuckDuckGo’s Personal Information Removal." + } + } + } + }, + "data-broker-protection.waitlist.join.title" : { + "comment" : "Title for Personal Information Removal join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Personal Information Removal Beta" + } + } + } + }, + "data-broker-protection.waitlist.joined.title" : { + "comment" : "Title for Personal Information Removal joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You’re on the list!" + } + } + } + }, + "data-broker-protection.waitlist.joined.with-notifications.subtitle.1" : { + "comment" : "Subtitle 1 for Personal Information Removal joined waitlist screen when notifications are enabled", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New invites are sent every few days, on a first come, first served basis." + } + } + } + }, + "data-broker-protection.waitlist.joined.with-notifications.subtitle.2" : { + "comment" : "Subtitle 2 for Personal Information Removal joined waitlist screen when notifications are enabled", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We’ll notify you when your invite is ready." + } + } + } + }, + "data-broker-protection.waitlist.notification.text" : { + "comment" : "Title for Personal Information Removal waitlist notification", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open your invite" + } + } + } + }, + "data-broker-protection.waitlist.notification.title" : { + "comment" : "Title for Personal Information Removal waitlist notification", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Personal Information Removal beta is ready!" + } + } + } + }, + "database.factory.failed.information" : { + "comment" : "Info to restart macOS after database init failure", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restart your Mac and try again" + } + } + } + }, + "database.factory.failed.message" : { + "comment" : "Alert title when we fail to init database", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "There was an error initializing the database" + } + } + } + }, + "default.browser.prompt.button" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set Default…" + } + } + } + }, + "default.browser.prompt.message" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Make DuckDuckGo your default browser" + } + } + } + }, + "Delete" : { + "comment" : "Command\nMain Menu Edit item" + }, + "delete-bookmark" : { + "comment" : "Delete Bookmark button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete Bookmark" + } + } + } + }, + "Developer" : { + "comment" : "Main Menu " + }, + "disable" : { + "comment" : "Email protection Disable button text", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Disable" + } + } + } + }, + "disable.email.protection.mesage" : { + "comment" : "Message for alert shown when user disables email protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This will only disable Autofill for Duck Addresses in this browser. \n\n You can still manually enter Duck Addresses and continue to receive forwarded email." + } + } + } + }, + "disable.email.protection.title" : { + "comment" : "Title for alert shown when user disables email protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Disable Email Protection Autofill?" + } + } + } + }, + "Display progress" : { + + }, + "dont.quit" : { + "comment" : "Don’t Quit button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Don’t Quit" + } + } + } + }, + "download.finishing" : { + "comment" : "Download being finished information text", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Finishing download…" + } + } + } + }, + "download.starting" : { + "comment" : "Download being initiated information text", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Starting download…" + } + } + } + }, + "downloads.active.alert.message.and.others" : { + "comment" : "Alert text format element for “, and other files”", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : ", and other files" + } + } + } + }, + "downloads.active.alert.message.format" : { + "comment" : "Alert text format when trying to quit application while file “filename”[, and others] are being downloaded", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to quit? DuckDuckGo Privacy Browser is currently downloading “%1$@”%2$@. If you quit now DuckDuckGo Privacy Browser won’t finish downloading this file." + } + } + } + }, + "downloads.active.alert.title" : { + "comment" : "Alert title when trying to quit application while files are being downloaded", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "A download is in progress." + } + } + } + }, + "downloads.always-ask" : { + "comment" : "Downloads preferences checkbox", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always ask where to save files" + } + } + } + }, + "downloads.change" : { + "comment" : "Change downloads directory button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change…" + } + } + } + }, + "downloads.error.canceled" : { + "comment" : "Short error description when downloaded file download was canceled", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Canceled" + } + } + } + }, + "downloads.error.move.failed" : { + "comment" : "Short error description when could not move downloaded file to the Downloads folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Could not move file to Downloads" + } + } + } + }, + "downloads.error.other" : { + "comment" : "Short error description when Download failed", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Error" + } + } + } + }, + "downloads.error.removed" : { + "comment" : "Short error description when downloaded file removed from Downloads folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Removed" + } + } + } + }, + "downloads.location" : { + "comment" : "Downloads directory location", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Location" + } + } + } + }, + "downloads.tooltip.cancel" : { + "comment" : "Mouse-over tooltip for Cancel Download button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel Download" + } + } + } + }, + "downloads.tooltip.redownload" : { + "comment" : "Mouse-over tooltip for Download [deleted file] Again button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Download Again" + } + } + } + }, + "downloads.tooltip.restart" : { + "comment" : "Mouse-over tooltip for Restart Download button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restart Download" + } + } + } + }, + "downloads.tooltip.reveal" : { + "comment" : "Mouse-over tooltip for Show in Finder button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show in Finder" + } + } + } + }, + "duck-player.always-open-in-player" : { + "comment" : "Private YouTube Player option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always open YouTube videos in Duck Player" + } + } + } + }, + "duck-player.explanation" : { + "comment" : "Private YouTube Player explanation in settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Duck Player provides a clean viewing experience without personalized ads and prevents viewing activity from influencing your YouTube recommendations." + } + } + } + }, + "duck-player.off" : { + "comment" : "Private YouTube Player option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Never use Duck Player" + } + } + } + }, + "duck-player.show-buttons" : { + "comment" : "Private YouTube Player option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show option to use Duck Player over YouTube previews on hover" + } + } + } + }, + "duck-player.title" : { + "comment" : "Private YouTube Player settings title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Duck Player" + } + } + } + }, + "DuckDuckGo browser version" : { + "comment" : "Data import failure Report dialog description of a report field providing current DuckDuckGo Browser version" + }, + "DuckDuckGo Help" : { + "comment" : "Main Menu Help item" + }, + "DuckDuckGo needs your permission to read the %@ bookmarks file. Select the %@ folder to import bookmarks." : { + "comment" : "Data import warning that DuckDuckGo browser requires file reading permissions for another browser name (%1$@), and instruction to select its (same browser name - %2$@) bookmarks folder.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo needs your permission to read the %1$@ bookmarks file. Select the %2$@ folder to import bookmarks." + } + } + } + }, + "Duplicate Bookmarks Skipped:" : { + "comment" : "Data import summary format of how many duplicate bookmarks (%lld) were skipped during import." + }, + "duplicate.tab" : { + "comment" : "Menu item. Duplicate as a verb", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Duplicate Tab" + } + } + } + }, + "edit" : { + "comment" : "Edit button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit" + } + } + } + }, + "Edit" : { + "comment" : "Main Menu Edit" + }, + "Edit…" : { + "comment" : "Command" + }, + "edit.favorite" : { + "comment" : "Header of the view that edits a favorite bookmark", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit Favorite" + } + } + } + }, + "edit.folder" : { + "comment" : "Header of the view that edits a bookmark folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit Folder" + } + } + } + }, + "email.copied" : { + "comment" : "Private email address was copied to clipboard message", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New address copied to your clipboard" + } + } + } + }, + "email.optionsMenu" : { + "comment" : "Menu item email feature", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Email Protection" + } + } + } + }, + "email.optionsMenu.createAddress" : { + "comment" : "Create an email alias sub menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Generate Private Duck Address" + } + } + } + }, + "email.optionsMenu.manageAccount" : { + "comment" : "Manage private email account sub menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manage Account" + } + } + } + }, + "email.optionsMenu.turnOff" : { + "comment" : "Disable email sub menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Disable Email Protection Autofill" + } + } + } + }, + "email.optionsMenu.turnOn" : { + "comment" : "Enable email sub menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Email Protection" + } + } + } + }, + "Enter Full Screen" : { + "comment" : "Main Menu View item" + }, + "Error message & code" : { + + }, + "error.unknown" : { + "comment" : "Error page subtitle", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "An unknown error has occurred" + } + } + } + }, + "error.unknown.try.again" : { + "comment" : "Generic error message on a dialog for when the cause is not known.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "An unknown error has occurred" + } + } + } + }, + "Export" : { + "comment" : "Main Menu File item" + }, + "export.bookmarks.failed.informative" : { + "comment" : "Alert message when exporting bookmarks fails", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please check that no file exists at the location you selected." + } + } + } + }, + "export.bookmarks.failed.message" : { + "comment" : "Alert title when exporting login data fails", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed to Export Bookmarks…" + } + } + } + }, + "export.bookmarks.file.name.suffix" : { + "comment" : "The last part of the suggested file for exporting bookmarks", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks" + } + } + } + }, + "export.bookmarks.menu.item" : { + "comment" : "Export bookmarks menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Export Bookmarks…" + } + } + } + }, + "export.logins.data" : { + "comment" : "Opens Export Logins Data dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Export Passwords…" + } + } + } + }, + "export.logins.failed.informative" : { + "comment" : "Alert message when exporting login data fails", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please check that no file exists at the location you selected." + } + } + } + }, + "export.logins.failed.message" : { + "comment" : "Alert title when exporting login data fails", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed to Export Passwords" + } + } + } + }, + "export.logins.file.name.suffix" : { + "comment" : "The last part of the suggested file name for exporting logins", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords" + } + } + } + }, + "export.logins.warning" : { + "comment" : "Warning text presented when exporting logins.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This file contains your passwords in plain text and should be saved in a secure location and deleted when you are done.\nAnyone with access to this file will be able to read your passwords." + } + } + } + }, + "Favorite This Page…" : { + "comment" : "Main Menu History item" + }, + "favorites" : { + "comment" : "Title text for the Favorites menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Favorites" + } + } + } + }, + "feedback.breakage.disclaimer" : { + "comment" : "Disclaimer in breakage form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reports sent to DuckDuckGo are 100% anonymous and only include your selection above, your optional message, the URL, a list of trackers we found on the site, the DuckDuckGo app version, and your macOS version." + } + } + } + }, + "feedback.bug.description" : { + "comment" : "Label in the feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please describe the problem in as much detail as possible:" + } + } + } + }, + "feedback.disclaimer" : { + "comment" : "Disclaimer in breakage form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reports sent to DuckDuckGo are 100% anonymous and only include your message, the DuckDuckGo app version, and your macOS version." + } + } + } + }, + "feedback.feature.request.description" : { + "comment" : "Label in the feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "What feature would you like to see?" + } + } + } + }, + "feedback.other.description" : { + "comment" : "Label in the feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please give us your feedback:" + } + } + } + }, + "File" : { + "comment" : "Main Menu File" + }, + "Find" : { + "comment" : "Main Menu Edit item" + }, + "Find Next" : { + "comment" : "Main Menu Edit-Find item" + }, + "Find Previous" : { + "comment" : "Main Menu Edit-Find item" + }, + "find.in.page" : { + "comment" : "Find in page status (e.g. 1 of 99)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d of %2$d" + } + } + } + }, + "find.in.page.menu.item" : { + "comment" : "Menu item title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Find in Page…" + } + } + } + }, + "fire.active-tabs-info" : { + "comment" : "Info in the Fire Button popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close %1$d active %2$@ and clear all browsing history and cookies (%3$d %4$@)." + } + } + } + }, + "fire.all-data.description" : { + "comment" : "Description of the 'All Data' configuration option for the fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear all tabs and related site data" + } + } + } + }, + "fire.all-sites" : { + "comment" : "Configuration option for fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All sites" + } + } + } + }, + "fire.current-window.description" : { + "comment" : "Description of the 'Current Window' configuration option for the fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear current window and related site data" + } + } + } + }, + "fire.currentTab" : { + "comment" : "Configuration option for fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All sites visited in current tab" + } + } + } + }, + "fire.currentWindow" : { + "comment" : "Configuration option for fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All sites visited in current window" + } + } + } + }, + "fire.dialog.all-windows-will-close" : { + "comment" : "Warning label shown in an expanded view of the fire popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All windows will close" + } + } + } + }, + "fire.dialog.clear.sites" : { + "comment" : "Category of domains in fire button dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Selected sites will be cleared" + } + } + } + }, + "fire.dialog.details" : { + "comment" : "Button to show more details", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Details" + } + } + } + }, + "fire.dialog.fireproof.sites" : { + "comment" : "Category of domains in fire button dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fireproof sites won't be cleared" + } + } + } + }, + "fire.dialog.tab-will-close" : { + "comment" : "Warning label shown in an expanded view of the fire popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Current tab will close" + } + } + } + }, + "fire.dialog.tab-will-reload" : { + "comment" : "Warning label shown in an expanded view of the fire popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pinned tab will reload" + } + } + } + }, + "fire.dialog.window-will-close" : { + "comment" : "Warning label shown in an expanded view of the fire popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Current window will close" + } + } + } + }, + "fire.one-tab-info" : { + "comment" : "Info in the Fire Button popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close this tab and clear its browsing history and cookies (%1$d %2$@)." + } + } + } + }, + "fire.select-site-to-clear" : { + "comment" : "Info label in the fire button popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select a site to clear its data." + } + } + } + }, + "fire.selected-domains.description" : { + "comment" : "Description of the 'Current Window' configuration option for the fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear data only for selected domains" + } + } + } + }, + "fireproof" : { + "comment" : "Domain fireproof status\n Fireproof button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fireproof" + } + } + } + }, + "fireproof.checkbox.title" : { + "comment" : "Fireproof settings checkbox title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Ask to Fireproof websites when signing in" + } + } + } + }, + "fireproof.confirmation.message" : { + "comment" : "Fireproof confirmation message", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fireproofing this site will keep you signed in after using the Fire Button." + } + } + } + }, + "fireproof.confirmation.title" : { + "comment" : "Fireproof confirmation title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Would you like to Fireproof %@?" + } + } + } + }, + "fireproof.explanation" : { + "comment" : "Fireproofing mechanism explanation", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "When you Fireproof a site, cookies won't be erased and you'll stay signed in, even after using the Fire Button." + } + } + } + }, + "fireproof.manage-sites" : { + "comment" : "Fireproof settings button caption", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manage Fireproof Sites…" + } + } + } + }, + "fireproof.sites" : { + "comment" : "Fireproof sites list title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fireproof Sites" + } + } + } + }, + "folder.optionsMenu.deleteFolder" : { + "comment" : "Option for deleting a folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete Folder" + } + } + } + }, + "folder.optionsMenu.newFolder" : { + "comment" : "Option for creating a new folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Folder" + } + } + } + }, + "folder.optionsMenu.renameFolder" : { + "comment" : "Option for renaming a folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Rename Folder" + } + } + } + }, + "gpc.checkbox.title" : { + "comment" : "GPC settings checkbox title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Global Privacy Control" + } + } + } + }, + "gpc.explanation" : { + "comment" : "GPC explanation in settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tells participating websites not to sell or share your data." + } + } + } + }, + "gpc.title" : { + "comment" : "GPC settings title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Global Privacy Control (GPC)" + } + } + } + }, + "Help" : { + "comment" : "Main Menu Help" + }, + "Hide" : { + "comment" : "Main Menu > View > Home Button > None item" + }, + "Hide DuckDuckGo" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "Hide Find" : { + "comment" : "Main Menu Edit-Find item" + }, + "Hide Others" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "History" : { + "comment" : "Main Menu " + }, + "history.menu.clear.all.history.description" : { + "comment" : "Description in the alert with the confirmation to clear all data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cookies and site data for all sites will also be cleared, unless the site is Fireproof." + } + } + } + }, + "history.menu.clear.all.history.question" : { + "comment" : "Alert with the confirmation to clear all history and data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear all history and \nclose all tabs?" + } + } + } + }, + "history.menu.clear.data.description" : { + "comment" : "Description in the alert with the confirmation to clear browsing history", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cookies and other data for sites visited on this day will also be cleared unless the site is Fireproof. History from other days will not be cleared." + } + } + } + }, + "history.menu.clear.data.question" : { + "comment" : "Alert with the confirmation to clear all data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear History for %@?" + } + } + } + }, + "history.menu.clear.data.today.description" : { + "comment" : "Description in the alert with the confirmation to clear browsing history", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cookies and other data for sites visited today will also be cleared unless the site is Fireproof. History from other days will not be cleared." + } + } + } + }, + "history.menu.clear.data.today.question" : { + "comment" : "Alert with the confirmation to clear all data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear history for today \nand close all tabs?" + } + } + } + }, + "history.menu.clear.this.history" : { + "comment" : "Menu item to clear parts of history and data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear This History…" + } + } + } + }, + "history.menu.older" : { + "comment" : "Menu item representing older history", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Older…" + } + } + } + }, + "history.menu.recently.visited" : { + "comment" : "Section header of the history menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recently Visited" + } + } + } + }, + "Home" : { + "comment" : "Main Menu View item" + }, + "Home Button" : { + "comment" : "Main Menu > View > Home Button item" + }, + "home.page.burn.fireproof.site.alert" : { + "comment" : "Message for an alert displayed when trying to burn a fireproof website", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "History will be cleared for this site, but related data will remain, because this site is Fireproof" + } + } + } + }, + "home.page.clear.history" : { + "comment" : "Button caption for the burn fireproof website alert", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear History" + } + } + } + }, + "home.page.empty.state.item.message" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Keep browsing to see how many trackers were blocked" + } + } + } + }, + "home.page.empty.state.item.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recently visited sites appear here" + } + } + } + }, + "home.page.no.trackers.blocked" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No trackers blocked" + } + } + } + }, + "home.page.no.trackers.found" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No trackers found" + } + } + } + }, + "home.page.protection.duration" : { + "comment" : "Past 7 days in uppercase.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "PAST 7 DAYS" + } + } + } + }, + "home.page.protection.summary.info" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No recent activity" + } + } + } + }, + "If your computer prompts you to enter a password prior to import, DuckDuckGo will not see that password.\n\nImported passwords are stored securely using encryption." : { + "comment" : "Warning that Chromium data import would require entering system passwords." + }, + "Import Bookmarks" : { + + }, + "Import Bookmarks and Passwords…" : { + "comment" : "Main Menu File item" + }, + "Import Browser Data" : { + + }, + "Import Passwords" : { + "comment" : "my comment" + }, + "Import Results:" : { + "comment" : "Data Import result summary headline" + }, + "import.bookmarks.bookmarks" : { + "comment" : "Title text for the Bookmarks import option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks" + } + } + } + }, + "import.bookmarks.html.title" : { + "comment" : "Title text for the HTML Bookmarks importer", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "HTML Bookmarks File (for other browsers)" + } + } + } + }, + "import.bookmarks.indefinite.progress.text" : { + "comment" : "Operation progress info message about indefinite number of bookmarks being imported", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Importing bookmarks…" + } + } + } + }, + "import.bookmarks.number.progress.text" : { + "comment" : "Operation progress info message about %d number of bookmarks being imported", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Importing %d bookmarks…" + } + } + } + }, + "import.bookmarks.safari.permission-button.title" : { + "comment" : "Text for the Safari data import permission button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select Safari Folder…" + } + } + } + }, + "import.bookmarks.select-html-file" : { + "comment" : "Button text for selecting HTML Bookmarks file", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select Bookmarks HTML File…" + } + } + } + }, + "import.browser.data" : { + "comment" : "Import Browser Data dialog title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import Browser Data" + } + } + } + }, + "import.browser.data.bookmarks" : { + "comment" : "Opens Import Browser Data dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import Bookmarks…" + } + } + } + }, + "import.browser.data.passwords" : { + "comment" : "Opens Import Browser Data dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import Passwords…" + } + } + } + }, + "import.csv.instructions.bitwarden" : { + "comment" : "Instructions to import Passwords as CSV from Bitwarden.\n%2$s - app name (Bitwarden)\n%7$@ - hamburger menu icon\n%9$@ - “Select Bitwarden CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open and unlock **%2$s**\n%3$d Select **File → Export vault** from the Menu Bar\n%4$d Select the File Format: **.csv**\n%5$d Enter your Bitwarden master password\n%6$d Click %7$@ and save the file someplace you can find it (e.g., Desktop)\n%8$d %9$@" + } + } + } + }, + "import.csv.instructions.brave" : { + "comment" : "Instructions to import Passwords as CSV from Brave browser.\n%N$d - step number\n%2$s - browser name (Brave)\n%4$@, %6$@ - hamburger menu icon\n%10$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Click %4$@ to open the application menu then click **Password Manager**\n%5$d Click %6$@ **at the top left** of the Password Manager and select **Settings**\n%7$d Find “Export Passwords” and click **Download File**\n%8$d Save the passwords file someplace you can find it (e.g., Desktop)\n%9$d %10$@" + } + } + } + }, + "import.csv.instructions.chrome" : { + "comment" : "Instructions to import Passwords as CSV from Google Chrome browser.\n%N$d - step number\n%2$s - browser name (Chrome)\n%4$@ - hamburger menu icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d In a fresh tab, click %4$@ then **Google Password Manager → Settings**\n%5$d Find “Export Passwords” and click **Download File**\n%6$d Save the passwords file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.csv.instructions.chromium" : { + "comment" : "Instructions to import Passwords as CSV from Chromium-based browsers.\n%N$d - step number\n%2$s - browser name\n%4$@ - hamburger menu icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d In a fresh tab, click %4$@ then **Password Manager → Settings**\n%5$d Find “Export Passwords” and click **Download File**\n%6$d Save the passwords file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.csv.instructions.coccoc" : { + "comment" : "Instructions to import Passwords as CSV from Cốc Cốc browser.\n%N$d - step number\n%2$s - browser name (Cốc Cốc)\n%5$@ - hamburger menu icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Type “_coccoc://settings/passwords_” into the Address bar\n%4$d Click %5$@ (on the right from _Saved Passwords_) and select **Export passwords**\n%6$d Save the passwords file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.csv.instructions.firefox" : { + "comment" : "Instructions to import Passwords as CSV from Firefox.\n%N$d - step number\n%2$s - browser name (Firefox)\n%4$@, %6$@ - hamburger menu icon\n%9$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Click %4$@ to open the application menu then click **Passwords**\n%5$d Click %6$@ then **Export Logins…**\n%7$d Save the passwords file someplace you can find it (e.g., Desktop)\n%8$d %9$@" + } + } + } + }, + "import.csv.instructions.generic" : { + "comment" : "Instructions to import a generic CSV passwords file.\n%N$d - step number\n%3$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The CSV importer will try to match column headers to their position.\nIf there is no header, it supports two formats:\n%1$d URL, Username, Password\n%2$d Title, URL, Username, Password\n%3$@" + } + } + } + }, + "import.csv.instructions.lastpass" : { + "comment" : "Instructions to import Passwords as CSV from LastPass.\n%2$s - app name (LastPass)\n%8$@ - “Select LastPass CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Click on the **%2$s** icon in your browser and enter your master password\n%3$d Select **Open My Vault**\n%4$d From the sidebar select **Advanced Options → Export**\n%5$d Enter your LastPass master password\n%6$d Select the File Format: **Comma Delimited Text (.csv)**\n%7$d %8$@" + } + } + } + }, + "import.csv.instructions.onePassword7" : { + "comment" : "Instructions to import Passwords as CSV from 1Password 7.\n%2$s - app name (1Password)\n%9$@ - “Select 1Password CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open and unlock **%2$s**\n%3$d Select the vault you want to export (you can only export one vault at a time)\n%4$d Select **File → Export → All Items** from the Menu Bar\n%5$d Enter your 1Password master or account password\n%6$d Select the File Format: **iCloud Keychain (.csv)**\n%7$d Save the passwords file someplace you can find it (e.g., Desktop)\n%8$d %9$@" + } + } + } + }, + "import.csv.instructions.onePassword8" : { + "comment" : "Instructions to import Passwords as CSV from 1Password 8.\n%2$s - app name (1Password)\n%8$@ - “Select 1Password CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open and unlock **%2$s**\n%3$d Select **File → Export** from the Menu Bar and choose the account you want to export\n%4$d Enter your 1Password account password\n%5$d Select the File Format: **CSV (Logins and Passwords only)**\n%6$d Click Export Data and save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.csv.instructions.opera" : { + "comment" : "Instructions to import Passwords as CSV from Opera browser.\n%N$d - step number\n%2$s - browser name (Opera)\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **View → Show Password Manager**\n%4$d Select **Settings**\n%5$d Find “Export Passwords” and click **Download File**\n%6$d Save the passwords file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.csv.instructions.operagx" : { + "comment" : "Instructions to import Passwords as CSV from Opera GX browsers.\n%N$d - step number\n%2$s - browser name (Opera GX)\n%5$@ - menu button icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **View → Show Password Manager**\n%4$d Click %5$@ (on the right from _Saved Passwords_) and select **Export passwords**\n%6$d Save the passwords file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.csv.instructions.safari" : { + "comment" : "Instructions to import Passwords as CSV from Safari.\n%N$d - step number\n%5$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **Safari**\n%2$d Select **File → Export → Passwords**\n%3$d Save the passwords file someplace you can find it (e.g., Desktop)\n%4$d %5$@" + } + } + } + }, + "import.csv.instructions.vivaldi" : { + "comment" : "Instructions to import Passwords exported as CSV from Vivaldi browser.\n%N$d - step number\n%2$s - browser name (Vivaldi)\n%5$@ - menu button icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Type “_chrome://settings/passwords_” into the Address bar\n%4$d Click %5$@ (on the right from _Saved Passwords_) and select **Export passwords**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.csv.instructions.yandex" : { + "comment" : "Instructions to import Passwords as CSV from Yandex Browser.\n%N$d - step number\n%2$s - browser name (Yandex)\n%4$@ - hamburger menu icon\n%8$@ - “Select Passwords CSV File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Click %4$@ to open the application menu then click **Passwords and cards**\n%5$d Click %6$@ then **Export passwords**\n%7$d Choose **To a text file (not secure)** and click **Export**\n%8$d Save the passwords file someplace you can find it (e.g., Desktop)\n%9$d %10$@" + } + } + } + }, + "import.data.alert.cancel" : { + "comment" : "Cancel button for data import alerts", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, + "import.data.alert.import" : { + "comment" : "Import button for data import alerts", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import" + } + } + } + }, + "import.data.done" : { + "comment" : "Button text for finishing the data import", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Done" + } + } + } + }, + "import.data.initiate" : { + "comment" : "Button text for importing data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import" + } + } + } + }, + "import.data.manual" : { + "comment" : "Button text for initiating manual data import using a HTML or CSV file when automatic import has failed", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manual import…" + } + } + } + }, + "import.data.requires-password.body" : { + "comment" : "Alert body text when the data import needs a password", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo won't save or share your %1$@ Primary Password, but DuckDuckGo needs it to access and import passwords from %1$@." + } + } + } + }, + "import.data.requires-password.title" : { + "comment" : "Alert title text when the data import needs a password", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enter Primary Password for %@" + } + } + } + }, + "import.data.skip" : { + "comment" : "Button text to skip an import step", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Skip" + } + } + } + }, + "import.data.skip.bookmarks" : { + "comment" : "Button text to skip bookmarks manual import", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Skip bookmarks" + } + } + } + }, + "import.data.skip.passwords" : { + "comment" : "Button text to skip bookmarks manual import", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Skip passwords" + } + } + } + }, + "import.html.instructions.chromium" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Chromium-based browsers.\n%N$d - step number\n%2$s - browser name\n%5$@ - hamburger menu icon\n%8$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **Bookmarks → Bookmark Manager**\n%4$d Click %5$@ then **Export Bookmarks**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.html.instructions.firefox" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Firefox based browsers.\n%N$d - step number\n%2$s - browser name (Firefox)\n%5$@ - hamburger menu icon\n%8$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **Bookmarks → Manage Bookmarks**\n%4$d Click %5$@ then **Export bookmarks to HTML…**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.html.instructions.generic" : { + "comment" : "Instructions to import a generic HTML Bookmarks file.\n%N$d - step number\n%6$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open your old browser\n%2$d Open **Bookmark Manager**\n%3$d Export bookmarks to HTML…\n%4$d Save the file someplace you can find it (e.g., Desktop)\n%5$d %6$@" + } + } + } + }, + "import.html.instructions.opera" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Opera browser.\n%N$d - step number\n%2$s - browser name (Opera)\n%8$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **Bookmarks → Bookmarks**\n%4$d Click **Open full Bookmarks view…** in the bottom left\n%5$d Click **Import/Export…** in the bottom left and select **Export Bookmarks**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.html.instructions.operagx" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Opera GX browser.\n%N$d - step number\n%2$s - browser name (Opera GX)\n%7$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **Bookmarks → Bookmarks**\n%4$d Click **Import/Export…** in the bottom left and select **Export Bookmarks**\n%5$d Save the file someplace you can find it (e.g., Desktop)\n%6$d %7$@" + } + } + } + }, + "import.html.instructions.safari" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Safari.\n%N$d - step number\n%5$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **Safari**\n%2$d Select **File → Export → Bookmarks**\n%3$d Save the passwords file someplace you can find it (e.g., Desktop)\n%4$d %5$@" + } + } + } + }, + "import.html.instructions.vivaldi" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Vivaldi browser.\n%N$d - step number\n%2$s - browser name (Vivaldi)\n%6$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **File → Export Bookmarks…**\n%4$d Save the file someplace you can find it (e.g., Desktop)\n%5$d %6$@" + } + } + } + }, + "import.html.instructions.yandex" : { + "comment" : "Instructions to import Bookmarks exported as HTML from Yandex Browser.\n%N$d - step number\n%2$s - browser name (Yandex)\n%5$@ - hamburger menu icon\n%8$@ - “Select Bookmarks HTML File” button\n**bold text**; _italic text_", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d Open **%2$s**\n%3$d Use the Menu Bar to select **Favorites → Bookmark Manager**\n%4$d Click %5$@ then **Export bookmarks to HTML file**\n%6$d Save the file someplace you can find it (e.g., Desktop)\n%7$d %8$@" + } + } + } + }, + "import.logins.csv.title" : { + "comment" : "Title text for the CSV importer", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "CSV Passwords File (for other browsers)" + } + } + } + }, + "import.logins.passwords" : { + "comment" : "Title text for the Passwords import option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords" + } + } + } + }, + "import.logins.select-csv-file" : { + "comment" : "Button text for selecting a CSV file", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select Passwords CSV File…" + } + } + } + }, + "import.logins.select-csv-file.source" : { + "comment" : "Button text for selecting a CSV file exported from (LastPass or Bitwarden or 1Password - %@)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select %@ CSV File…" + } + } + } + }, + "import.onePassword.app.version.info" : { + "comment" : "Instructions how to find an installed 1Password password manager app version.\n%1$s, %2$s - app name (1Password)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You can find your version by selecting **%1$s → About %2$s** from the Menu Bar." + } + } + } + }, + "import.passwords.indefinite.progress.text" : { + "comment" : "Operation progress info message about indefinite number of passwords being imported", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Importing passwords…" + } + } + } + }, + "import.passwords.number.progress.text" : { + "comment" : "Operation progress info message about %d number of passwords being imported", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Importing %d passwords…" + } + } + } + }, + "invite.dialog.get.started.button" : { + "comment" : "Get Started button on an invite dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get Started" + } + } + } + }, + "invite.dialog.unrecognized.code.message" : { + "comment" : "Message to show after user enters an unrecognized invite code", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We didn’t recognize this Invite Code." + } + } + } + }, + "JavaScript Console" : { + "comment" : "Main Menu View-Developer item" + }, + "learnmore.link" : { + "comment" : "Learn More link", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Learn More" + } + } + } + }, + "Let’s try doing it manually. It won’t take long." : { + "comment" : "Suggestion to switch to a Manual File Data Import when data import fails." + }, + "looking.for.bitwarden" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden not installed…" + } + } + } + }, + "macOS version" : { + "comment" : "Data import failure Report dialog description of a report field providing user‘s macOS version" + }, + "main.menu.close.downloads" : { + "comment" : "Hide Downloads Popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Downloads" + } + } + } + }, + "main.menu.close.inspector" : { + "comment" : "Hide Web Inspector/Close Developer Tools", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close Developer Tools" + } + } + } + }, + "main.menu.show.downloads" : { + "comment" : "Show Downloads Popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Downloads" + } + } + } + }, + "main.menu.show.inspector" : { + "comment" : "Show Web Inspector/Open Developer Tools", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Developer Tools" + } + } + } + }, + "Make Lower Case" : { + "comment" : "Main Menu Edit-Transformations item" + }, + "Make Upper Case" : { + "comment" : "Main Menu Edit-Transformations item" + }, + "Manage Bookmarks" : { + "comment" : "Main Menu History item" + }, + "menu.item.new.tab" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Tab" + } + } + } + }, + "Merge All Windows" : { + "comment" : "Main Menu Window item" + }, + "Minimize" : { + "comment" : "Main Menu Window item" + }, + "more-options.zoom.default-zoom-page" : { + "comment" : "Default page zoom picker title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change Default Page Zoom…" + } + } + } + }, + "more.or.less.collapse" : { + "comment" : "For collapsing views to show less.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Less" + } + } + } + }, + "more.or.less.expand" : { + "comment" : "For expanding views to show more.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show More" + } + } + } + }, + "n.more.tabs" : { + "comment" : "suffix of string in Recently Closed menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : " (and %d more tabs)" + } + } + } + }, + "navigate.back" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Back" + } + } + } + }, + "navigate.forward" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Forward" + } + } + } + }, + "network-protection.privacy-policy.section.1.list" : { + "comment" : "Privacy Policy list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This Privacy Policy is for our limited waitlist beta VPN product.\n\nOur main [Privacy Policy](https://duckduckgo.com/privacy) also applies here." + } + } + } + }, + "network-protection.privacy-policy.section.1.title" : { + "comment" : "Privacy Policy title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We don’t ask for any personal information from you in order to use this beta service." + } + } + } + }, + "network-protection.privacy-policy.section.2.list" : { + "comment" : "Privacy Policy list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "That means we have no way to tie what you do online to you as an individual and we don’t have any record of things like:\n • Website visits\n • DNS requests\n • Connections made\n • IP addresses used\n • Session lengths" + } + } + } + }, + "network-protection.privacy-policy.section.2.title" : { + "comment" : "Privacy Policy title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We don’t keep any logs of your online activity." + } + } + } + }, + "network-protection.privacy-policy.section.3.list" : { + "comment" : "Privacy Policy list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Our servers store generic usage (for example, CPU load) and diagnostic data (for example, errors), but none of that data is connected to any individual’s activity.\n\nWe use this non-identifying information to monitor and ensure the performance and quality of the service, for example to make sure servers aren’t overloaded." + } + } + } + }, + "network-protection.privacy-policy.section.3.title" : { + "comment" : "Privacy Policy title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We only keep anonymous performance metrics that we cannot connect to your online activity." + } + } + } + }, + "network-protection.privacy-policy.section.4.list" : { + "comment" : "Privacy Policy list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Dedicated servers means they are not shared with anyone else.\n\nWe rent our servers from providers we carefully selected because they meet our privacy requirements.\n\nWe have strict access controls in place so that only limited DuckDuckGo team members have access to our servers." + } + } + } + }, + "network-protection.privacy-policy.section.4.title" : { + "comment" : "Privacy Policy title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We use dedicated servers for all VPN traffic." + } + } + } + }, + "network-protection.privacy-policy.section.5.list" : { + "comment" : "Privacy Policy list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "If you reach out to us for support by submitting a bug report or through email and agree to be contacted to troubleshoot the issue, we’ll contact you using the information you provide.\n\nIf you participate in a voluntary product survey or questionnaire and agree to provide further feedback, we may contact you using the information you provide.\n\nWe will permanently delete all personal information you provided to us (email, contact information), within 30 days after closing a support case or, in the case of follow up feedback, within 60 days after ending this beta service." + } + } + } + }, + "network-protection.privacy-policy.section.5.title" : { + "comment" : "Privacy Policy title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We protect and limit use of your data when you communicate directly with DuckDuckGo." + } + } + } + }, + "network-protection.privacy-policy.title" : { + "comment" : "Privacy Policy title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy Policy" + } + } + } + }, + "network-protection.terms-of-service.section.1.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This service is provided for your personal use only.\n\nYou are responsible for all activity in the service that occurs on or through your device.\n\nThis service may only be used through the DuckDuckGo app on the device on which you are given access. If you delete the DuckDuckGo app, you will lose access to the service.\n\nYou may not use this service through a third-party client." + } + } + } + }, + "network-protection.terms-of-service.section.1.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The service is for limited and personal use only." + } + } + } + }, + "network-protection.terms-of-service.section.2.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You agree that you will not use the service for any unlawful, illicit, criminal, or fraudulent purpose, or in any manner that could give rise to civil or criminal liability under applicable law.\n\nYou agree to comply with our [DuckDuckGo Terms of Service](https://duckduckgo.com/terms), which are incorporated by reference." + } + } + } + }, + "network-protection.terms-of-service.section.2.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You agree to comply with all applicable laws, rules, and regulations." + } + } + } + }, + "network-protection.terms-of-service.section.3.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Access to this beta is randomly awarded. You are responsible for ensuring eligibility.\n\nYou must be at least 18 years old and live in a location where use of a VPN is legal in order to be eligible to use this service." + } + } + } + }, + "network-protection.terms-of-service.section.3.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You must be eligible to use this service." + } + } + } + }, + "network-protection.terms-of-service.section.4.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This service is provided as-is and without warranties or guarantees of any kind.\n\nTo the extent possible under applicable law, DuckDuckGo will not be liable for any damage or loss arising from your use of the service. In any event, the total aggregate liability of DuckDuckGo shall not exceed $25 or the equivalent in your local currency.\n\nWe may in the future transfer responsibility for the service to a subsidiary of DuckDuckGo. If that happens, you agree that references to “DuckDuckGo” will refer to our subsidiary, which will then become responsible for providing the service and for any liabilities relating to it." + } + } + } + }, + "network-protection.terms-of-service.section.4.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We provide this beta service as-is and without warranty." + } + } + } + }, + "network-protection.terms-of-service.section.5.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We reserve the right to revoke access to the service at any time in our sole discretion.\n\nWe may also terminate access for violation of these terms, including for repeated infringement of the intellectual property rights of others." + } + } + } + }, + "network-protection.terms-of-service.section.5.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We may terminate access at any time." + } + } + } + }, + "network-protection.terms-of-service.section.6.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Access to this service is currently free of charge, but that is limited to this beta period.\n\nYou understand and agree that this service is provided on a temporary, testing basis only." + } + } + } + }, + "network-protection.terms-of-service.section.6.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The service is free during the beta period." + } + } + } + }, + "network-protection.terms-of-service.section.7.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The service is in beta, and we are regularly changing it.\n\nService coverage, speed, server locations, and quality may vary without warning." + } + } + } + }, + "network-protection.terms-of-service.section.7.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We are continually updating the service." + } + } + } + }, + "network-protection.terms-of-service.section.8.list" : { + "comment" : "Terms of Service list for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You may be asked during the beta period to provide feedback about your experience. Doing so is optional and your feedback may be used to improve the service.\n\nIf you have enabled notifications for the DuckDuckGo app, we may use notifications to ask about your experience. You can disable notifications if you do not want to receive them." + } + } + } + }, + "network-protection.terms-of-service.section.8.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We need your feedback." + } + } + } + }, + "network-protection.terms-of-service.title" : { + "comment" : "Terms of Service title for Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Terms of Service" + } + } + } + }, + "network-protection.waitlist.availability-disclaimer" : { + "comment" : "Availability disclaimer for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Network Protection is free to use during the beta." + } + } + } + }, + "network-protection.waitlist.button.agree-and-continue" : { + "comment" : "Agree and Continue button for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Agree and Continue" + } + } + } + }, + "network-protection.waitlist.button.cancel" : { + "comment" : "Cancel button for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, + "network-protection.waitlist.button.close" : { + "comment" : "Close button for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close" + } + } + } + }, + "network-protection.waitlist.button.dismiss" : { + "comment" : "Dismiss button for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Dismiss" + } + } + } + }, + "network-protection.waitlist.button.done" : { + "comment" : "Close button for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Done" + } + } + } + }, + "network-protection.waitlist.button.enable-notifications" : { + "comment" : "Enable Notifications button for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Notifications" + } + } + } + }, + "network-protection.waitlist.button.get-started" : { + "comment" : "Get Started button for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get Started" + } + } + } + }, + "network-protection.waitlist.button.got-it" : { + "comment" : "Got It button for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Got It" + } + } + } + }, + "network-protection.waitlist.button.join-waitlist" : { + "comment" : "Join Waitlist button for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Join the Waitlist" + } + } + } + }, + "network-protection.waitlist.button.no-thanks" : { + "comment" : "No Thanks button for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Thanks" + } + } + } + }, + "network-protection.waitlist.enable-notifications" : { + "comment" : "Enable notifications prompt for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Want to get a notification when your Network Protection invite is ready?" + } + } + } + }, + "network-protection.waitlist.enable.subtitle" : { + "comment" : "Subtitle for Network Protection enable screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Look for the globe icon in the browser toolbar or in the Mac menu bar.\n\nYou'll be asked to Allow a VPN connection once when setting up Network Protection the first time." + } + } + } + }, + "network-protection.waitlist.enable.title" : { + "comment" : "Title for Network Protection enable screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Ready to enable Network Protection?" + } + } + } + }, + "network-protection.waitlist.invited.section-1.subtitle" : { + "comment" : "Subtitle for section 1 of the Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Encrypt online traffic across your browsers and apps." + } + } + } + }, + "network-protection.waitlist.invited.section-1.title" : { + "comment" : "Title for section 1 of the Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Full-device coverage" + } + } + } + }, + "network-protection.waitlist.invited.section-2.subtitle" : { + "comment" : "Subtitle for section 2 of the Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No need for a separate app. Connect in one click and see your connection status at a glance." + } + } + } + }, + "network-protection.waitlist.invited.section-2.title" : { + "comment" : "Title for section 2 of the Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fast, reliable, and easy to use" + } + } + } + }, + "network-protection.waitlist.invited.section-3.subtitle" : { + "comment" : "Subtitle for section 3 of the Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We do not log or save any data that can connect you to your online activity." + } + } + } + }, + "network-protection.waitlist.invited.section-3.title" : { + "comment" : "Title for section 3 of the Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Strict no-logging policy" + } + } + } + }, + "network-protection.waitlist.invited.subtitle" : { + "comment" : "Subtitle for Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get an extra layer of protection online with the VPN built for speed and simplicity. Encrypt your internet connection across your entire device and hide your location and IP address from sites you visit." + } + } + } + }, + "network-protection.waitlist.invited.title" : { + "comment" : "Title for Network Protection invited screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You’re invited to try\nNetwork Protection beta!" + } + } + } + }, + "network-protection.waitlist.join.subtitle.1" : { + "comment" : "First subtitle for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Secure your connection anytime, anywhere with Network Protection, the VPN from DuckDuckGo." + } + } + } + }, + "network-protection.waitlist.join.subtitle.2" : { + "comment" : "Second subtitle for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Join the waitlist, and we’ll notify you when it’s your turn." + } + } + } + }, + "network-protection.waitlist.join.title" : { + "comment" : "Title for Network Protection join waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Network Protection Beta" + } + } + } + }, + "network-protection.waitlist.joined.title" : { + "comment" : "Title for Network Protection joined waitlist screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You’re on the list!" + } + } + } + }, + "network-protection.waitlist.joined.with-notifications.subtitle.1" : { + "comment" : "Subtitle 1 for Network Protection joined waitlist screen when notifications are enabled", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New invites are sent every few days, on a first come, first served basis." + } + } + } + }, + "network-protection.waitlist.joined.with-notifications.subtitle.2" : { + "comment" : "Subtitle 2 for Network Protection joined waitlist screen when notifications are enabled", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We’ll notify you when your invite is ready." + } + } + } + }, + "network-protection.waitlist.notification.text" : { + "comment" : "Title for Network Protection waitlist notification", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open your invite" + } + } + } + }, + "network-protection.waitlist.notification.title" : { + "comment" : "Title for Network Protection waitlist notification", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Network Protection beta is ready!" + } + } + } + }, + "network.protection" : { + "comment" : "Menu item for opening Network Protection", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Network Protection" + } + } + } + }, + "network.protection.configuration.system-settings.legacy" : { + "comment" : "Text for a label in the Network Protection popover, displayed after attempting to enable Network Protection for the first time while using macOS 12 and below", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Go to Security & Privacy in System Preferences to allow Network Protection to activate" + } + } + } + }, + "network.protection.configuration.system-settings.modern" : { + "comment" : "Text for a label in the Network Protection popover, displayed after attempting to enable Network Protection for the first time while using macOS 13 and above", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Go to Privacy & Security in System Settings to allow Network Protection to activate" + } + } + } + }, + "network.protection.invite.dialog.message" : { + "comment" : "Message for the network protection invite dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enter your invite code to get started." + } + } + } + }, + "network.protection.invite.dialog.title" : { + "comment" : "Title for the network protection invite dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You've unlocked a beta feature!" + } + } + } + }, + "network.protection.invite.field.prompt" : { + "comment" : "Prompt for the network protection invite code text field", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Code" + } + } + } + }, + "network.protection.invite.success.title" : { + "comment" : "Message for the network protection invite success view\n Title for the network protection invite success view", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Success! You’re in." + } + } + } + }, + "network.protection.navbar.status.view.share.feedback" : { + "comment" : "Menu item for 'Send Feedback' in the Network Protection status view that's shown in the navigation bar", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Send Feedback…" + } + } + } + }, + "network.protection.status.button.tooltip" : { + "comment" : "The tooltip for NetP's nav bar button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Network Protection" + } + } + } + }, + "network.protection.status.menu.vpn.settings" : { + "comment" : "The status menu 'VPN Settings' menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN Settings…" + } + } + } + }, + "network.protection.system.extension.please.reboot" : { + "comment" : "Message shown to users when they try to enable NetP and they need to reboot the computer to complete the installation", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please reboot to activate Network Protection" + } + } + } + }, + "network.protection.system.extension.unknown.activation.error" : { + "comment" : "Message shown to users when they try to enable NetP and there is an unexpected activation error.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "There as an unexpected error. Please try again." + } + } + } + }, + "network.protection.tunnel.name" : { + "comment" : "The name of the NetP VPN that will be visible in the system to the user", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo Network Protection" + } + } + } + }, + "network.protection.vpn.location.country.item.formatted.cities.count" : { + "comment" : "Subtitle of countries item when there are multiple cities, example : ", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%d cities" + } + } + } + }, + "New Tab" : { + "comment" : "Main Menu File item" + }, + "new.burner.window.menu.item" : { + "comment" : "Menu item title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Fire Window" + } + } + } + }, + "new.window.menu.item" : { + "comment" : "Menu item title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Window" + } + } + } + }, + "newTab.bottom.popover.title" : { + "comment" : "Title of the popover that appears when pressing the bottom right button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Tab Page" + } + } + } + }, + "newTab.favorites.section.title" : { + "comment" : "Title of the Favorites section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Favorites" + } + } + } + }, + "newTab.menu.item.show.continue.setup" : { + "comment" : "Title of the menu item in the home page to show/hide continue setup section", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Next Steps" + } + } + } + }, + "newTab.menu.item.show.favorite" : { + "comment" : "Title of the menu item in the home page to show/hide favorite section", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Favorites" + } + } + } + }, + "newTab.menu.item.show.recent.activity" : { + "comment" : "Title of the menu item in the home page to show/hide recent activity section", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Recent Activity" + } + } + } + }, + "newTab.recent.activity.section.title" : { + "comment" : "Title of the RecentActivity section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recent Activity" + } + } + } + }, + "newTab.setup.default.browser.action" : { + "comment" : "Action title on the action menu of the Default Browser card", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Make Default Browser" + } + } + } + }, + "newTab.setup.default.browser.summary" : { + "comment" : "Summary of the Default Browser card", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We automatically block trackers as you browse. It's privacy, simplified." + } + } + } + }, + "newTab.setup.default.browser.title" : { + "comment" : "Title of the Default Browser card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Default to Privacy" + } + } + } + }, + "newTab.setup.duck.player.action" : { + "comment" : "Action title on the action menu of the Duck Player card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Try Duck Player" + } + } + } + }, + "newTab.setup.duck.player.summary" : { + "comment" : "Summary of the Duck Player card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enjoy a clean viewing experience without personalized ads." + } + } + } + }, + "newTab.setup.duck.player.title" : { + "comment" : "Title of the Duck Player card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clean Up YouTube" + } + } + } + }, + "newTab.setup.email.protection.action" : { + "comment" : "Action title on the action menu of the Email Protection card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get a Duck Address" + } + } + } + }, + "newTab.setup.email.protection.summary" : { + "comment" : "Summary of the Email Protection card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Generate custom @duck.com addresses that clean trackers from incoming email." + } + } + } + }, + "newTab.setup.email.protection.title" : { + "comment" : "Title of the Email Protection card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Protect Your Inbox" + } + } + } + }, + "newTab.setup.Import.action" : { + "comment" : "Action title on the action menu of the Import card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import Now" + } + } + } + }, + "newTab.setup.import.summary" : { + "comment" : "Summary of the Import card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import bookmarks, favorites, and passwords from your old browser." + } + } + } + }, + "newTab.setup.import.title" : { + "comment" : "Title of the Import card of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bring Your Stuff" + } + } + } + }, + "newTab.setup.remove.item" : { + "comment" : "Action title on the action menu of the set up cards card of the SetUp section in the home page to remove the item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Dismiss" + } + } + } + }, + "newTab.setup.section.title" : { + "comment" : "Title of the setup section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Next Steps" + } + } + } + }, + "newTab.setup.survey.day.0.action" : { + "comment" : "Action title of the Day 0 durvey of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Share Your Thoughts" + } + } + } + }, + "newTab.setup.survey.day.0.summary" : { + "comment" : "Summary of the Day 0 durvey of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Take our short survey and help us build the best browser." + } + } + } + }, + "newTab.setup.survey.day.0.title" : { + "comment" : "Title of the Day 0 durvey of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tell Us What Brought You Here" + } + } + } + }, + "newTab.setup.survey.day.7.action" : { + "comment" : "Action title of the Day 7 durvey of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Share Your Thoughts" + } + } + } + }, + "newTab.setup.survey.day.7.summary" : { + "comment" : "Summary of the Day 7 durvey of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Take our short survey and help us build the best browser." + } + } + } + }, + "newTab.setup.survey.day.7.title" : { + "comment" : "Title of the Day 7 durvey of the Set Up section in the home page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Help Us Improve" + } + } + } + }, + "next" : { + "comment" : "Next button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Next" + } + } + } + }, + "no.access.to.downloads.folder.header" : { + "comment" : "Header of the alert dialog informing user about failed download", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo needs permission to access your Downloads folder" + } + } + } + }, + "no.access.to.downloads.folder.legacy" : { + "comment" : "Alert presented to user if the app doesn't have rights to access Downloads folder. This is used for macOS version 12 and below", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Grant access in Security & Privacy preferences in System Settings." + } + } + } + }, + "no.access.to.downloads.folder.modern" : { + "comment" : "Alert presented to user if the app doesn't have rights to access Downloads folder. This is used for macOS version 13 and above", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Grant access in Privacy & Security preferences in System Settings." + } + } + } + }, + "no.access.to.selected.folder" : { + "comment" : "Alert presented to user if the app doesn't have rights to access selected folder", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Grant access to the location of download." + } + } + } + }, + "no.access.to.selected.folder.header" : { + "comment" : "Header of the alert dialog informing user about failed download", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo needs permission to access selected folder" + } + } + } + }, + "notification.badge.cookiesmanaged" : { + "comment" : "Notification that appears when browser automatically handle cookies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cookies Managed" + } + } + } + }, + "notification.badge.popuphidden" : { + "comment" : "Notification that appears when browser cosmetically hides a cookie popup", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pop-up Hidden" + } + } + } + }, + "notnow" : { + "comment" : "Not Now button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Not Now" + } + } + } + }, + "ok" : { + "comment" : "OK button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "OK" + } + } + } + }, + "onboarding.importdata.button" : { + "comment" : "Launch the import data UI", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import" + } + } + } + }, + "onboarding.importdata.text" : { + "comment" : "Call to action to import data from other browsers", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "First, let me help you import your bookmarks 📖 and passwords 🔑 from those less private browsers." + } + } + } + }, + "onboarding.notnow.button" : { + "comment" : "Skip a step of the onboarding flow", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Maybe Later" + } + } + } + }, + "onboarding.setdefault.button" : { + "comment" : "Launch the set default UI", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Let's Do It!" + } + } + } + }, + "onboarding.setdefault.text" : { + "comment" : "Call to action to set the browser as default", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Next, try setting DuckDuckGo as your default️ browser, so you can open links with peace of mind, every time." + } + } + } + }, + "onboarding.startbrowsing.text" : { + "comment" : "Call to action to start using the app as a browser", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You’re all set!\n\nWant to see how I protect you? Try visiting one of your favorite sites 👆\n\nKeep watching the address bar as you go. I’ll be blocking trackers and upgrading the security of your connection when possibleu{00A0}🔒" + } + } + } + }, + "onboarding.welcome.button" : { + "comment" : "Start the onboarding flow", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Get Started" + } + } + } + }, + "onboarding.welcome.text" : { + "comment" : "Detailed welcome to the app text", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tired of being tracked online? You've come to the right place 👍\n\nI'll help you stay private️ as you search and browse the web. Trackers be gone!" + } + } + } + }, + "onboarding.welcome.title" : { + "comment" : "General welcome to the app title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Welcome to DuckDuckGo!" + } + } + } + }, + "one.more.tab" : { + "comment" : "suffix of string in Recently Closed menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : " (and 1 more tab)" + } + } + } + }, + "open" : { + "comment" : "Open button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open" + } + } + } + }, + "Open Location…" : { + "comment" : "Main Menu File item" + }, + "open.bitwarden" : { + "comment" : "Button to open Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Bitwarden" + } + } + } + }, + "open.bitwarden.and.log.in.or.unlock" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Bitwarden and Log in or Unlock your vault." + } + } + } + }, + "open.externally.failed" : { + "comment" : "’Link’ is link on a website", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The app required to open that link can’t be found" + } + } + } + }, + "open.image.in.new.burner.tab" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Image in New Fire Tab" + } + } + } + }, + "open.image.in.new.tab" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Image in New Tab" + } + } + } + }, + "open.in" : { + "comment" : "Opening an entity in other application", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open in %@" + } + } + } + }, + "open.in.new.tab" : { + "comment" : "Menu item that opens the link in a new tab", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open in New Tab" + } + } + } + }, + "open.in.new.window" : { + "comment" : "Menu item that opens the link in a new window", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open in New Window" + } + } + } + }, + "open.link.in.new.burner.tab" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Link in New Fire Tab" + } + } + } + }, + "open.link.in.new.tab" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Link in New Tab" + } + } + } + }, + "open.preferences" : { + "comment" : "Open System Preferences (to re-enable permission for the App) (up to and including macOS 12", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open System Preferences" + } + } + } + }, + "open.settings" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open System Settings…" + } + } + } + }, + "options.menu.fireproof-site" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fireproof This Site" + } + } + } + }, + "options.menu.move.tab.to.new.window" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Move Tab to New Window" + } + } + } + }, + "options.menu.remove-fireproofing" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove Fireproofing" + } + } + } + }, + "passsword.management" : { + "comment" : "Used as title for password management user interface", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Autofill" + } + } + } + }, + "passsword.management.all-items" : { + "comment" : "Used as title for the Autofill All Items option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All Items" + } + } + } + }, + "passsword.management.credit-cards" : { + "comment" : "Used as title for the Autofill Credit Cards option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Credit Cards" + } + } + } + }, + "passsword.management.identities" : { + "comment" : "Used as title for the Autofill Identities option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Identities" + } + } + } + }, + "passsword.management.lock" : { + "comment" : "Lock Logins Vault menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Lock" + } + } + } + }, + "passsword.management.logins" : { + "comment" : "Used as title for the Autofill Logins option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords" + } + } + } + }, + "passsword.management.notes" : { + "comment" : "Used as title for the Autofill Notes option", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Notes" + } + } + } + }, + "passsword.management.unlock" : { + "comment" : "Unlock Logins Vault menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unlock" + } + } + } + }, + "Password import complete. You can now delete the saved passwords file." : { + "comment" : "message about Passwords Data Import completion" + }, + "Password import failed: " : { + "comment" : "Data import summary format of how many passwords (%lld) failed to import." + }, + "Password import failed." : { + "comment" : "Data import summary message of failed passwords import." + }, + "password.manager" : { + "comment" : "Section header", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Password Manager" + } + } + } + }, + "Passwords:" : { + "comment" : "Data import summary format of how many passwords (%lld) were successfully imported." + }, + "Passwords…" : { + "comment" : "Main Menu File-Export item" + }, + "Paste" : { + "comment" : "Main Menu Edit item" + }, + "Paste and Match Style" : { + "comment" : "Main Menu Edit item" + }, + "paste-from-clipboard" : { + "comment" : "Paste button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Paste from Clipboard" + } + } + } + }, + "paste.and.go" : { + "comment" : "Paste & Go button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Paste & Go" + } + } + } + }, + "paste.and.search" : { + "comment" : "Paste & Search button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Paste & Search" + } + } + } + }, + "permission.allow.externalScheme" : { + "comment" : "Allow the App Name(%@ 1) to open “URL Scheme”(%@ 2) links\n Allow to open External Link (%@ 2) to open on current domain (%@ 1)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allow “%1$@“ to open %2$@" + } + } + } + }, + "permission.authorization.externalScheme.empty.format" : { + "comment" : "Popover asking to open link in External App (%@)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open this link in %@?" + } + } + } + }, + "permission.authorization.externalScheme.format" : { + "comment" : "Popover asking for domain %@ to open link in External App (%@)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "“%1$@” would like to open this link in %2$@" + } + } + } + }, + "permission.authorization.format" : { + "comment" : "Popover asking for domain %@ to use camera/mic/location (%@)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allow “%1$@“ to use your %2$@?" + } + } + } + }, + "permission.authorization.popups" : { + "comment" : "Popover asking for domain %@ to open Popup Window", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allow “%@“ to open PopUp Window?" + } + } + } + }, + "permission.camera" : { + "comment" : "Camera input media device name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Camera" + } + } + } + }, + "permission.cameraAndmicrophone" : { + "comment" : "camera and microphone input media devices name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Camera and Microphone" + } + } + } + }, + "permission.disabled.app" : { + "comment" : "The app (DuckDuckGo: %@ 2) has no access permission to (%@ 1) media device", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ access is disabled for %2$@" + } + } + } + }, + "permission.disabled.system" : { + "comment" : "Geolocation Services are disabled in System Preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "System location services are disabled" + } + } + } + }, + "permission.externalScheme.open.format" : { + "comment" : "Open %@ App Name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open %@" + } + } + } + }, + "permission.geolocation" : { + "comment" : "User's Geolocation permission access name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Location" + } + } + } + }, + "permission.microphone" : { + "comment" : "Microphone input media device name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Microphone" + } + } + } + }, + "permission.mute" : { + "comment" : "Temporarily pause input media device %@ access for %@2 website", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pause %1$@ use on “%2$@”" + } + } + } + }, + "permission.open.settings" : { + "comment" : "Open System Settings (to re-enable permission for the App) (macOS 13 and above)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open System Settings" + } + } + } + }, + "permission.popover.deny" : { + "comment" : "Permission Popover: Deny Website input media device access", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Deny" + } + } + } + }, + "permission.popup.open.format" : { + "comment" : "Open %@ URL Pop-up", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@" + } + } + } + }, + "permission.popup.title" : { + "comment" : "List of blocked popups Title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Blocked Pop-ups" + } + } + } + }, + "permission.popups" : { + "comment" : "Open Pop Up Windows permission access name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pop-ups" + } + } + } + }, + "permission.reloadPage" : { + "comment" : "Reload webpage to ask for input media device access permission again", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reload to ask permission again" + } + } + } + }, + "permission.unmute" : { + "comment" : "Resume input media device %@ access for %@ website", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Resume %1$@ use on “%2$@”" + } + } + } + }, + "pin.tab" : { + "comment" : "Menu item. Pin as a verb", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pin Tab" + } + } + } + }, + "pinning.hide-autofill-shortcut" : { + "comment" : "Menu item for hiding the autofill shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Autofill Shortcut" + } + } + } + }, + "pinning.hide-bookmarks-shortcut" : { + "comment" : "Menu item for hiding the bookmarks shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Bookmarks Shortcut" + } + } + } + }, + "pinning.hide-downloads-shortcut" : { + "comment" : "Menu item for hiding the downloads shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Downloads Shortcut" + } + } + } + }, + "pinning.hide-home-shortcut" : { + "comment" : "Menu item for hiding the Home shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Home Button" + } + } + } + }, + "pinning.hide-netp-shortcut" : { + "comment" : "Menu item for hiding the NetP shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Network Protection" + } + } + } + }, + "pinning.show-autofill-shortcut" : { + "comment" : "Menu item for showing the autofill shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Autofill Shortcut" + } + } + } + }, + "pinning.show-bookmarks-shortcut" : { + "comment" : "Menu item for showing the bookmarks shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Bookmarks Shortcut" + } + } + } + }, + "pinning.show-downloads-shortcut" : { + "comment" : "Menu item for showing the downloads shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Downloads Shortcut" + } + } + } + }, + "pinning.show-home-shortcut" : { + "comment" : "Menu item for showing the Home shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Home Button" + } + } + } + }, + "pinning.show-netp-shortcut" : { + "comment" : "Menu item for showing the NetP shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Network Protection" + } + } + } + }, + "Please submit a report to help us fix the issue." : { + "comment" : "Data import failure Report dialog title." + }, + "pm.activate" : { + "comment" : "Activate button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reactivate" + } + } + } + }, + "pm.activate.private.email" : { + "comment" : "Activate private email address button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reactivate Duck Address" + } + } + } + }, + "pm.added" : { + "comment" : "Label for login added data", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Added" + } + } + } + }, + "pm.address.address1" : { + "comment" : "Label for address 1 title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Address 1" + } + } + } + }, + "pm.address.address2" : { + "comment" : "Label for address 2 title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Address 2" + } + } + } + }, + "pm.address.city" : { + "comment" : "Label for city title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "City" + } + } + } + }, + "pm.address.postal-code" : { + "comment" : "Label for postal code title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Postal Code" + } + } + } + }, + "pm.address.state-province" : { + "comment" : "Label for state/province title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "State/Province" + } + } + } + }, + "pm.cancel" : { + "comment" : "Cancel button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, + "pm.card.cardholder-name" : { + "comment" : "Label for cardholder name title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cardholder Name" + } + } + } + }, + "pm.card.cvv" : { + "comment" : "Label for CVV title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "CVV" + } + } + } + }, + "pm.card.expiration-date" : { + "comment" : "Label for expiration date title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Expiration Date" + } + } + } + }, + "pm.card.number" : { + "comment" : "Label for card number title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Card Number" + } + } + } + }, + "pm.day" : { + "comment" : "Label for Day title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Day" + } + } + } + }, + "pm.deactivate" : { + "comment" : "Deactivate button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Deactivate" + } + } + } + }, + "pm.deactivate.private.email" : { + "comment" : "Deactivate private email address button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Deactivate Duck Address" + } + } + } + }, + "pm.delete" : { + "comment" : "Delete button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete" + } + } + } + }, + "pm.edit" : { + "comment" : "Edit button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit" + } + } + } + }, + "pm.email-address" : { + "comment" : "Label for email address title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Email Address" + } + } + } + }, + "pm.empty.cards.title" : { + "comment" : "Label for cards empty state title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Cards" + } + } + } + }, + "pm.empty.default.description" : { + "comment" : "Label for default empty state description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "If your passwords are saved in another browser, you can import them into DuckDuckGo." + } + } + } + }, + "pm.empty.default.title" : { + "comment" : "Label for default empty state title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No passwords or credit cards saved yet" + } + } + } + }, + "pm.empty.identities.title" : { + "comment" : "Label for identities empty state title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Identities" + } + } + } + }, + "pm.empty.logins.title" : { + "comment" : "Label for logins empty state title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No passwords" + } + } + } + }, + "pm.empty.notes.title" : { + "comment" : "Label for notes empty state title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Notes" + } + } + } + }, + "pm.enable.email.protection" : { + "comment" : "Text link to email protection website", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Email Protection" + } + } + } + }, + "pm.identification" : { + "comment" : "Label for identification title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Identification" + } + } + } + }, + "pm.identity.autofill.title.default" : { + "comment" : "Default title for Addresses/Identities", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Address" + } + } + } + }, + "pm.last.updated" : { + "comment" : "Label for last updated edit field", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last Updated" + } + } + } + }, + "pm.lock-screen.duration" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your autofill info will remain unlocked until your computer is idle for %@." + } + } + } + }, + "pm.lock-screen.preferences.label" : { + "comment" : "Label used for a button that opens preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change in" + } + } + } + }, + "pm.lock-screen.preferences.link" : { + "comment" : "Label used for a button that opens preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Settings" + } + } + } + }, + "pm.lock-screen.prompt.autofill" : { + "comment" : "Label presented when autofilling credit card information", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "unlock access to your autofill info" + } + } + } + }, + "pm.lock-screen.prompt.change-settings" : { + "comment" : "Label presented when changing Auto-Lock settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "change your autofill info access settings" + } + } + } + }, + "pm.lock-screen.prompt.export-logins" : { + "comment" : "Label presented when exporting logins", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "export your usernames and passwords" + } + } + } + }, + "pm.lock-screen.prompt.unlock-logins" : { + "comment" : "Label presented when unlocking Autofill", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "unlock access to your autofill info" + } + } + } + }, + "pm.lock-screen.threshold.1-hour" : { + "comment" : "Label used when selecting the Auto-Lock threshold", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "1 hour" + } + } + } + }, + "pm.lock-screen.threshold.1-minute" : { + "comment" : "Label used when selecting the Auto-Lock threshold", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "1 minute" + } + } + } + }, + "pm.lock-screen.threshold.5-minutes" : { + "comment" : "Label used when selecting the Auto-Lock threshold", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "5 minutes" + } + } + } + }, + "pm.lock-screen.threshold.12-hours" : { + "comment" : "Label used when selecting the Auto-Lock threshold", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "12 hours" + } + } + } + }, + "pm.lock-screen.threshold.15-minutes" : { + "comment" : "Label used when selecting the Auto-Lock threshold", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "15 minutes" + } + } + } + }, + "pm.lock-screen.threshold.30-minutes" : { + "comment" : "Label used when selecting the Auto-Lock threshold", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "30 minutes" + } + } + } + }, + "pm.month" : { + "comment" : "Label for Month title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Month" + } + } + } + }, + "pm.name.first" : { + "comment" : "Label for first name title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "First Name" + } + } + } + }, + "pm.name.last" : { + "comment" : "Label for last name title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last Name" + } + } + } + }, + "pm.name.middle" : { + "comment" : "Label for middle name title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Middle Name" + } + } + } + }, + "pm.new.card" : { + "comment" : "Label for new card title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Credit Card" + } + } + } + }, + "pm.new.identity" : { + "comment" : "Label for new identity title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Identity" + } + } + } + }, + "pm.new.login" : { + "comment" : "Label for new login title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Password" + } + } + } + }, + "pm.new.note" : { + "comment" : "Label for new note title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Note" + } + } + } + }, + "pm.note" : { + "comment" : "Label for note title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Note" + } + } + } + }, + "pm.note.empty" : { + "comment" : "Label for empty note title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Empty note" + } + } + } + }, + "pm.notes" : { + "comment" : "Label for notes edit field", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Notes" + } + } + } + }, + "pm.password" : { + "comment" : "Label for password edit field", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Password" + } + } + } + }, + "pm.phone-number" : { + "comment" : "Label for phone number title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Phone Number" + } + } + } + }, + "pm.private.email.mesage.activate.confirm.content" : { + "comment" : "Text for the confirmation message displayed when a user tries activate a Private Email Address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Emails sent to %@ will again be forwarded to your inbox." + } + } + } + }, + "pm.private.email.mesage.activate.confirm.title" : { + "comment" : "Title for the confirmation message displayed when a user tries activate a Private Email Address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reactivate Private Duck Address?" + } + } + } + }, + "pm.private.email.mesage.active" : { + "comment" : "Mesasage displayed when a private email address is active", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Duck Address Active" + } + } + } + }, + "pm.private.email.mesage.deactivate.confirm.content" : { + "comment" : "Text for the confirmation message displayed when a user tries deactivate a Private Email Address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Emails sent to %@ will no longer be forwarded to your inbox." + } + } + } + }, + "pm.private.email.mesage.deactivate.confirm.title" : { + "comment" : "Title for the confirmation message displayed when a user tries deactivate a Private Email Address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Deactivate Private Duck Address?" + } + } + } + }, + "pm.private.email.mesage.error" : { + "comment" : "Mesasage displayed when a user tries to manage a private email address but the service is not available, returns an error or network is down", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Management of this address is temporarily unavailable." + } + } + } + }, + "pm.private.email.mesage.inactive" : { + "comment" : "Mesasage displayed when a private email address is inactive", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Duck Address Deactivated" + } + } + } + }, + "pm.removed.duck.address.button" : { + "comment" : "Button text for the alert dialog telling the user an updated username is no longer a private email address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Got it" + } + } + } + }, + "pm.removed.duck.address.content" : { + "comment" : "Content for the alert dialog telling the user an updated username is no longer a private email address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You can still manage this Duck Address from emails received from it in your personal inbox." + } + } + } + }, + "pm.removed.duck.address.title" : { + "comment" : "Title for the alert dialog telling the user an updated username is no longer a private email address", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Private Duck Address username was removed" + } + } + } + }, + "pm.save" : { + "comment" : "Save button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save" + } + } + } + }, + "pm.save-credentials.editable.title" : { + "comment" : "Title for the editable Save Credentials popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save password?" + } + } + } + }, + "pm.save-credentials.non-editable.title" : { + "comment" : "Title for the non-editable Save Credentials popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Password Saved" + } + } + } + }, + "pm.signin.to.manage" : { + "comment" : "Message displayed to the user when they are logged out of Email protection.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@ to manage your Duck Addresses on this device." + } + } + } + }, + "pm.sort.date.ascending" : { + "comment" : "Label for Ascending date sort order", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Newest First" + } + } + } + }, + "pm.sort.date.descending" : { + "comment" : "Label for Descending date sort order", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Oldest First" + } + } + } + }, + "pm.sort.parameter.date-created" : { + "comment" : "Label for Date Created sort parameter", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Date Created" + } + } + } + }, + "pm.sort.parameter.date-modified" : { + "comment" : "Label for Date Modified sort parameter", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Date Modified" + } + } + } + }, + "pm.sort.parameter.title" : { + "comment" : "Label for Title sort parameter", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Title" + } + } + } + }, + "pm.sort.string.ascending" : { + "comment" : "Label for Ascending string sort order", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Alphabetically" + } + } + } + }, + "pm.sort.string.descending" : { + "comment" : "Label for Descending string sort order", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reverse Alphabetically" + } + } + } + }, + "pm.username" : { + "comment" : "Label for username edit field", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Username" + } + } + } + }, + "pm.website" : { + "comment" : "Label for website edit field", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Website URL" + } + } + } + }, + "pm.year" : { + "comment" : "Label for Year title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Year" + } + } + } + }, + "preferences-homepage" : { + "comment" : "Homepage behavior description\n Title for Homepage section in settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Homepage" + } + } + } + }, + "preferences-homepage-address" : { + "comment" : "Homepage address field label", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Address:" + } + } + } + }, + "preferences-homepage-customPage" : { + "comment" : "Option to control Specific Home Page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Specific page" + } + } + } + }, + "preferences-homepage-newTab" : { + "comment" : "Option to open a new tab", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Tab page" + } + } + } + }, + "preferences-homepage-set-homePage" : { + "comment" : "Set Homepage dialog title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set Homepage" + } + } + } + }, + "preferences-homepage-set-page" : { + "comment" : "Option to control the Specific Page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set Page…" + } + } + } + }, + "Preferences…" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "preferences.about" : { + "comment" : "Show about screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "About" + } + } + } + }, + "preferences.about.about-duckduckgo" : { + "comment" : "About screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "About DuckDuckGo" + } + } + } + }, + "preferences.about.more-at" : { + "comment" : "Link to the about page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "More at %@" + } + } + } + }, + "preferences.about.privacy-policy" : { + "comment" : "Link to privacy policy page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy Policy" + } + } + } + }, + "preferences.about.privacy-simplified" : { + "comment" : "About screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy, simplified." + } + } + } + }, + "preferences.about.send-feedback" : { + "comment" : "Feedback button in the about preferences page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Send Feedback" + } + } + } + }, + "preferences.about.unsupported-device-info1" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo is no longer providing browser updates for your version of macOS." + } + } + } + }, + "preferences.about.unsupported-device-info2-part1" : { + "comment" : "Second paragraph of unsupported device info - sentence part 1", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please" + } + } + } + }, + "preferences.about.unsupported-device-info2-part2" : { + "comment" : "Second paragraph of unsupported device info - sentence part 2 (underlined)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "update to macOS %@" + } + } + } + }, + "preferences.about.unsupported-device-info2-part3" : { + "comment" : "Second paragraph of unsupported device info - sentence part 3\n Second paragraph of unsupported device info - sentence part 4", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "or later to use the most recent version" + } + } + } + }, + "preferences.appearance" : { + "comment" : "Show appearance preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Appearance" + } + } + } + }, + "preferences.appearance.address-bar" : { + "comment" : "Theme preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Address Bar" + } + } + } + }, + "preferences.appearance.show-autocomplete-suggestions" : { + "comment" : "Option to show autocomplete suggestions in the address bar", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Autocomplete suggestions" + } + } + } + }, + "preferences.appearance.show-full-url" : { + "comment" : "Option to show full URL in the address bar", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Full website address" + } + } + } + }, + "preferences.appearance.theme" : { + "comment" : "Theme preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Theme" + } + } + } + }, + "preferences.appearance.zoom" : { + "comment" : "Zoom settings section title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Zoom" + } + } + } + }, + "preferences.appearance.zoom-picker" : { + "comment" : "Default page zoom picker title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Default page zoom" + } + } + } + }, + "preferences.autofill" : { + "comment" : "Show Autofill preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Autofill" + } + } + } + }, + "preferences.default-browser" : { + "comment" : "Show default browser preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Default Browser" + } + } + } + }, + "preferences.default-browser.active" : { + "comment" : "Indicate that the browser is the default", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo is your default browser" + } + } + } + }, + "preferences.default-browser.button.make-default" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Make DuckDuckGo Default…" + } + } + } + }, + "preferences.default-browser.inactive" : { + "comment" : "Indicate that the browser is not the default", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo is not your default browser." + } + } + } + }, + "preferences.downloads" : { + "comment" : "Show downloads browser preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads" + } + } + } + }, + "preferences.duck-player" : { + "comment" : "Show Duck Player browser preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Duck Player" + } + } + } + }, + "preferences.general" : { + "comment" : "Show general preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "General" + } + } + } + }, + "preferences.on-startup" : { + "comment" : "Name of the preferences section related to app startup", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "On Startup" + } + } + } + }, + "preferences.privacy" : { + "comment" : "Show privacy browser preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy" + } + } + } + }, + "preferences.reopen-windows" : { + "comment" : "Option to control session restoration", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reopen All Windows from Last Session" + } + } + } + }, + "preferences.show-home" : { + "comment" : "Option to control session startup", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open a new window" + } + } + } + }, + "preferences.subscription" : { + "comment" : "Show subscription preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy Pro" + } + } + } + }, + "preferences.sync" : { + "comment" : "Show sync preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sync & Backup" + } + } + } + }, + "preferences.vpn" : { + "comment" : "Show VPN preferences", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN" + } + } + } + }, + "print.menu.item" : { + "comment" : "Menu item title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Print…" + } + } + } + }, + "Privacy Policy " : { + + }, + "quit" : { + "comment" : "Quit button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Quit" + } + } + } + }, + "Quit DuckDuckGo" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "Recently Closed" : { + "comment" : "Main Menu History item" + }, + "Redo" : { + "comment" : "Main Menu Edit item" + }, + "Reload Page" : { + "comment" : "Main Menu View item" + }, + "reload.page" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reload Page" + } + } + } + }, + "remove-favorite" : { + "comment" : "Remove Favorite button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove Favorite" + } + } + } + }, + "remove.from.favorites" : { + "comment" : "Button for removing bookmarks from favorites", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove from Favorites" + } + } + } + }, + "reopen.last.closed.tab" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reopen Last Closed Tab" + } + } + } + }, + "reopen.last.closed.window" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reopen Last Closed Window" + } + } + } + }, + "restart.bitwarden" : { + "comment" : "Button to restart Bitwarden application", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restart Bitwarden" + } + } + } + }, + "restart.bitwarden.info" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bitwarden is not responding. Please restart it to initiate the communication again" + } + } + } + }, + "save" : { + "comment" : "Save button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save" + } + } + } + }, + "Save As…" : { + "comment" : "Main Menu File item" + }, + "save.image.as" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save Image As…" + } + } + } + }, + "scroll.to.find.app.settings" : { + "comment" : "Setup of the integration with Bitwarden app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Scroll to find the App Settings (All Accounts) section." + } + } + } + }, + "search.with.DuckDuckGo" : { + "comment" : "Context menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search with DuckDuckGo" + } + } + } + }, + "Select %@ Folder…" : { + + }, + "Select All" : { + "comment" : "Main Menu Edit item" + }, + "Select Bookmarks HTML File…" : { + + }, + "Select data to import:" : { + "comment" : "Data Import section title for checkboxes of data type to import: Passwords or Bookmarks." + }, + "Select Passwords CSV File…" : { + + }, + "Select Profile:" : { + "comment" : "Browser Profile picker title for Data Import" + }, + "select.bitwarden.preferences" : { + "comment" : "Setup of the integration with Bitwarden app (up to and including macOS 12)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select Bitwarden → Preferences from the Mac menu bar." + } + } + } + }, + "select.bitwarden.settings" : { + "comment" : "Setup of the integration with Bitwarden app (macOS 13 and above)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select Bitwarden → Settings from the Mac menu bar." + } + } + } + }, + "Services" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "settings" : { + "comment" : "Menu item for opening settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Settings" + } + } + } + }, + "settings.hide-home-shortcut" : { + "comment" : "Settings Optionm to set Home Button visibility", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Home Button in Toolbar" + } + } + } + }, + "share.menu.item" : { + "comment" : "Menu item title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Share" + } + } + } + }, + "share.menu.item.qr.code" : { + "comment" : "Menu item title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Create QR Code" + } + } + } + }, + "sharing.more" : { + "comment" : "Sharing Menu -> More…", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "More…" + } + } + } + }, + "Show All" : { + "comment" : "Main Menu DuckDuckGo item" + }, + "Show Autofill Shortcut" : { + "comment" : "Main Menu View item" + }, + "Show Bookmarks Shortcut" : { + "comment" : "Main Menu View item" + }, + "Show Downloads Shortcut" : { + "comment" : "Main Menu View item" + }, + "Show left of the back button" : { + "comment" : "Main Menu > View > Home Button > left position item" + }, + "Show Next Tab" : { + "comment" : "Main Menu Window item" + }, + "Show Page Source" : { + "comment" : "Main Menu View-Developer item" + }, + "Show Previous Tab" : { + "comment" : "Main Menu Window item" + }, + "Show Resources" : { + "comment" : "Main Menu View-Developer item" + }, + "Show right of the reload button" : { + "comment" : "Main Menu > View > Home Button > right position item" + }, + "Show Spelling and Grammar" : { + "comment" : "Main Menu Edit-Spellingand item" + }, + "Show Substitutions" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "Smart Copy/Paste" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "Smart Dashes" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "Smart Links" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "Smart Quotes" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "Speech" : { + "comment" : "Main Menu Edit item" + }, + "Spelling and Grammar" : { + "comment" : "Main Menu Edit item" + }, + "Start Speaking" : { + "comment" : "Main Menu Edit-Speech item" + }, + "Stop" : { + "comment" : "Main Menu View item" + }, + "Stop Speaking" : { + "comment" : "Main Menu Edit-Speech item" + }, + "submit" : { + "comment" : "Submit button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Submit" + } + } + } + }, + "submit.report" : { + "comment" : "Submit Report button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Submit Report" + } + } + } + }, + "subscription.menu.item" : { + "comment" : "Title for Subscription item in the options menu", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Privacy Pro" + } + } + } + }, + "subscription.progress.view.completing.purchase" : { + "comment" : "Progress view title when completing the purchase", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Completing purchase..." + } + } + } + }, + "subscription.progress.view.purchasing.subscription" : { + "comment" : "Progress view title when starting the purchase", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Purchase in progress..." + } + } + } + }, + "subscription.progress.view.restoring.subscription" : { + "comment" : "Progress view title when restoring past subscription purchase", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restoring subscription..." + } + } + } + }, + "Substitutions" : { + "comment" : "Main Menu Edit item" + }, + "tab.bookmarks.title" : { + "comment" : "Tab bookmarks title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks" + } + } + } + }, + "tab.dbp.title" : { + "comment" : "Tab data broker protection title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Personal Information Removal" + } + } + } + }, + "tab.error.title" : { + "comment" : "Tab error title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Oops!" + } + } + } + }, + "tab.home.title" : { + "comment" : "Tab home title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New Tab" + } + } + } + }, + "tab.onboarding.title" : { + "comment" : "Tab onboarding title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Welcome" + } + } + } + }, + "tab.preferences.title" : { + "comment" : "Tab preferences title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Settings" + } + } + } + }, + "Text Replacement" : { + "comment" : "Main Menu Edit-Substitutions item" + }, + "That didn’t work either. Please submit a report to help us fix the issue." : { + "comment" : "Data import failure Report dialog title containing a message that not only automatic data import has failed failed but manual browser data import didn‘t work either." + }, + "The following information will be sent to DuckDuckGo. No personally identifiable information will be sent." : { + "comment" : "Data import failure Report dialog subtitle about the data being collected with the report." + }, + "The version of the browser you are trying to import from" : { + "comment" : "Data import failure Report dialog description of a report field providing version of a browser user is trying to import data from" + }, + "tooltip.addToFavorites" : { + "comment" : "Tooltip for add to favorites button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add to Favorites" + } + } + } + }, + "tooltip.application-menu.show" : { + "comment" : "Tooltip for the Application Menu button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open application menu" + } + } + } + }, + "tooltip.autofill.add-item" : { + "comment" : "Tooltip for the Add Item button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add item" + } + } + } + }, + "tooltip.autofill.more-options" : { + "comment" : "Tooltip for the More Options button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "More options" + } + } + } + }, + "tooltip.autofill.shortcut" : { + "comment" : "Tooltip for the autofill shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Autofill" + } + } + } + }, + "tooltip.bookmark.add" : { + "comment" : "Tooltip for the Add Bookmark button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmark this page" + } + } + } + }, + "tooltip.bookmark.edit" : { + "comment" : "Tooltip for the Edit Bookmark button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit bookmark" + } + } + } + }, + "tooltip.bookmarks.manage-bookmarks" : { + "comment" : "Tooltip for the Manage Bookmarks button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manage bookmarks" + } + } + } + }, + "tooltip.bookmarks.new-bookmark" : { + "comment" : "Tooltip for the New Bookmark button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New bookmark" + } + } + } + }, + "tooltip.bookmarks.new-folder" : { + "comment" : "Tooltip for the New Folder button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New folder" + } + } + } + }, + "tooltip.bookmarks.shortcut" : { + "comment" : "Tooltip for the bookmarks shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks" + } + } + } + }, + "tooltip.clearHistory" : { + "comment" : "Tooltip for burn button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear browsing history for %@" + } + } + } + }, + "tooltip.clearHistoryAndData" : { + "comment" : "Tooltip for burn button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear browsing history and data for %@" + } + } + } + }, + "tooltip.downloads.clear-download-history" : { + "comment" : "Tooltip for the Clear Downloads button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear download history" + } + } + } + }, + "tooltip.downloads.open-downloads-folder" : { + "comment" : "Tooltip for the Open Downloads Folder button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open downloads folder" + } + } + } + }, + "tooltip.downloads.shortcut" : { + "comment" : "Tooltip for the downloads shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads" + } + } + } + }, + "tooltip.find-in-page.close" : { + "comment" : "Tooltip for the Find In Page bar's Close button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close find bar" + } + } + } + }, + "tooltip.find-in-page.next" : { + "comment" : "Tooltip for the Find In Page bar's Next button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Next result" + } + } + } + }, + "tooltip.find-in-page.previous" : { + "comment" : "Tooltip for the Find In Page bar's Previous button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Previous result" + } + } + } + }, + "tooltip.fire.clear-browsing-history" : { + "comment" : "Tooltip for the Fire button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear browsing history" + } + } + } + }, + "tooltip.home.button" : { + "comment" : "Tooltip for the home button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Home" + } + } + } + }, + "tooltip.navigation.back" : { + "comment" : "Tooltip for the Back button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show the previous page\nHold to show history" + } + } + } + }, + "tooltip.navigation.forward" : { + "comment" : "Tooltip for the Forward button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show the next page\nHold to show history" + } + } + } + }, + "tooltip.navigation.refresh" : { + "comment" : "Tooltip for the Refresh button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reload this page" + } + } + } + }, + "tooltip.navigation.stop" : { + "comment" : "Tooltip for the Stop Navigation button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Stop loading this page" + } + } + } + }, + "tooltip.privacy-dashboard.show" : { + "comment" : "Tooltip for the Privacy Dashboard button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show the Privacy Dashboard and manage site settings" + } + } + } + }, + "tooltip.tab.new-tab" : { + "comment" : "Tooltip for the New Tab button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open a new tab" + } + } + } + }, + "Transformations" : { + "comment" : "Main Menu Edit item" + }, + "Try importing bookmarks manually instead." : { + "comment" : "Data import error subtitle: suggestion to import Bookmarks manually by selecting a CSV or HTML file." + }, + "Try importing passwords manually instead." : { + "comment" : "Data import error subtitle: suggestion to import Passwords manually by selecting a CSV or HTML file." + }, + "Undo" : { + "comment" : "Main Menu Edit item" + }, + "uninstall" : { + "comment" : "Uninstall button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Uninstall" + } + } + } + }, + "unpin.tab" : { + "comment" : "Menu item. Unpin as a verb", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unpin Tab" + } + } + } + }, + "unsupported.device.info.alert.header" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your version of macOS is no longer supported." + } + } + } + }, + "version" : { + "comment" : "Displays the version and build numbers", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Version %1$@ (%2$@)" + } + } + } + }, + "View" : { + "comment" : "Main Menu View" + }, + "vpn.advanced.settings.title" : { + "comment" : "VPN Advanced section title in VPN settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Advanced" + } + } + } + }, + "vpn.button.title.uninstall.vpn" : { + "comment" : "Uninstall VPN button title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Uninstall DuckDuckGo VPN..." + } + } + } + }, + "vpn.feedback-form.button.cancel" : { + "comment" : "Title for the Cancel button of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, + "vpn.feedback-form.button.done" : { + "comment" : "Title for the Done button of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Done" + } + } + } + }, + "vpn.feedback-form.button.submit" : { + "comment" : "Title for the Submit button of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Submit" + } + } + } + }, + "vpn.feedback-form.button.submitting" : { + "comment" : "Title for the Submitting state of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Submitting…" + } + } + } + }, + "vpn.feedback-form.category.browser-crash-or-freeze" : { + "comment" : "Title for the browser crash/freeze category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN causes browser to crash or freeze" + } + } + } + }, + "vpn.feedback-form.category.fails-to-connect" : { + "comment" : "Title for the 'VPN fails to connect' category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN fails to connect" + } + } + } + }, + "vpn.feedback-form.category.feature-request" : { + "comment" : "Title for the 'VPN feature request' category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN feature request" + } + } + } + }, + "vpn.feedback-form.category.issues-with-apps" : { + "comment" : "Title for the category 'VPN causes issues with other apps or websites' category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN causes issues with other apps or websites" + } + } + } + }, + "vpn.feedback-form.category.local-device-connectivity" : { + "comment" : "Title for the local device connectivity category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN won't let me connect to local device" + } + } + } + }, + "vpn.feedback-form.category.other" : { + "comment" : "Title for the 'other VPN feedback' category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Other VPN feedback" + } + } + } + }, + "vpn.feedback-form.category.select-category" : { + "comment" : "Title for the category selection state of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select a category" + } + } + } + }, + "vpn.feedback-form.category.too-slow" : { + "comment" : "Title for the 'VPN is too slow' category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN connection is too slow" + } + } + } + }, + "vpn.feedback-form.category.unable-to-install" : { + "comment" : "Title for the 'unable to install' category of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unable to install VPN" + } + } + } + }, + "vpn.feedback-form.sending-confirmation.description" : { + "comment" : "Title for the feedback sent view description of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your feedback will help us improve the\nDuckDuckGo VPN." + } + } + } + }, + "vpn.feedback-form.sending-confirmation.error" : { + "comment" : "Title for the feedback sending error text of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We couldn't send your feedback right now, please try again." + } + } + } + }, + "vpn.feedback-form.sending-confirmation.title" : { + "comment" : "Title for the feedback sent view title of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Thank you!" + } + } + } + }, + "vpn.feedback-form.text-1" : { + "comment" : "Text for the body of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please describe what's happening, what you expected to happen, and the steps that led to the issue:" + } + } + } + }, + "vpn.feedback-form.text-2" : { + "comment" : "Text for the body of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "In addition to the details entered into this form, your app issue report will contain:" + } + } + } + }, + "vpn.feedback-form.text-3" : { + "comment" : "Bullet text for the body of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "• Whether specific DuckDuckGo features are enabled" + } + } + } + }, + "vpn.feedback-form.text-4" : { + "comment" : "Bullet text for the body of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "• Aggregate DuckDuckGo app diagnostics" + } + } + } + }, + "vpn.feedback-form.text-5" : { + "comment" : "Text for the body of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "By clicking \"Submit\" I agree that DuckDuckGo may use the information in this report for purposes of improving the app's features." + } + } + } + }, + "vpn.feedback-form.title" : { + "comment" : "Title for each screen of the VPN feedback form", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Help Improve the DuckDuckGo VPN" + } + } + } + }, + "vpn.general.title" : { + "comment" : "General section title in VPN settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "General" + } + } + } + }, + "vpn.location.change.button.title" : { + "comment" : "Title of the VPN location preference change button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change..." + } + } + } + }, + "vpn.location.custom.section.title" : { + "comment" : "Title of the VPN location list cancel button\n Title of the VPN location list custom section", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Custom" + } + } + } + }, + "vpn.location.description.nearest" : { + "comment" : "Nearest city setting description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Nearest" + } + } + } + }, + "vpn.location.description.nearest.available" : { + "comment" : "Nearest available location setting description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Nearest Available" + } + } + } + }, + "vpn.location.list.title" : { + "comment" : "Title of the VPN location list screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN Location" + } + } + } + }, + "vpn.location.nearest.available.title" : { + "comment" : "Subtitle underneath the nearest available vpn location preference text.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Automatically connect to the nearest server we can find." + } + } + } + }, + "vpn.location.recommended.section.title" : { + "comment" : "Title of the VPN location list recommended section", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recommended" + } + } + } + }, + "vpn.location.submit.button.title" : { + "comment" : "Title of the VPN location list submit button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Submit" + } + } + } + }, + "vpn.location.title" : { + "comment" : "Location section title in VPN settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Location" + } + } + } + }, + "vpn.notifications.settings.title" : { + "comment" : "Notifications section title in VPN settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Notifications" + } + } + } + }, + "vpn.setting.description.always.on" : { + "comment" : "Always ON setting description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Automatically restores the VPN connection after interruption. For your security, this setting cannot be disabled." + } + } + } + }, + "vpn.setting.description.exclude.local.networks" : { + "comment" : "Exclude Local Networks setting description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bypass the VPN for local network connections, like to a printer." + } + } + } + }, + "vpn.setting.description.secure.dns" : { + "comment" : "Secure DNS setting description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Our VPN uses Secure DNS to keep your online activity private, so that your Internet provider can't see what websites you visit." + } + } + } + }, + "vpn.setting.title.connect.on.login" : { + "comment" : "Connect on Login setting title\n Display VPN status in the menu bar.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Connect on login" + } + } + } + }, + "vpn.setting.title.exclude.local.networks" : { + "comment" : "Exclude Local Networks setting title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Exclude local networks" + } + } + } + }, + "vpn.uninstall.alert.informative.text" : { + "comment" : "Informative text for the alert that comes up when the user decides to uninstall our VPN", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Uninstalling the DuckDuckGo VPN will disconnect the VPN and remove it from your device." + } + } + } + }, + "vpn.uninstall.alert.title" : { + "comment" : "Alert title when the user selects to uninstall our VPN", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to uninstall the VPN?" + } + } + } + }, + "We couldn‘t find any bookmarks." : { + "comment" : "Data import error message: Bookmarks weren‘t found." + }, + "We couldn‘t find any passwords." : { + "comment" : "Data import error message: Passwords weren‘t found." + }, + "We were unable to import bookmarks directly from %@." : { + "comment" : "Message when data import fails from a browser. %@ - a browser name" + }, + "We were unable to import passwords directly from %@." : { + "comment" : "Message when data import fails from a browser. %@ - a browser name" + }, + "web.tracking.protection.explenation" : { + "comment" : "feature explanation in settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "DuckDuckGo automatically blocks hidden trackers as you browse the web." + } + } + } + }, + "web.tracking.protection.title" : { + "comment" : "Web tracking protection settings section title", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Web Tracking Protection" + } + } + } + }, + "Window" : { + "comment" : "Main Menu " + }, + "You must be eligible to use this service." : { + + }, + "You'll be asked to enter your Primary Password for %@.\n\nImported passwords are encrypted and only stored on this computer." : { + "comment" : "Warning that Firefox-based browser name (%@) data import would require entering a Primary Password for the browser." + }, + "zoom" : { + "comment" : "Menu with Zooming commands", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Zoom" + } + } + } + }, + "Zoom In" : { + "comment" : "Main Menu View item" + }, + "Zoom Out" : { + "comment" : "Main Menu View item" + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/DuckDuckGo/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index 9c3fdd9ac3..584481ac9f 100644 --- a/DuckDuckGo/Menus/MainMenu.swift +++ b/DuckDuckGo/Menus/MainMenu.swift @@ -30,7 +30,7 @@ import NetworkProtection #endif #if SUBSCRIPTION -import Subscription +import SubscriptionUI #endif // swiftlint:disable:next type_body_length @@ -519,14 +519,14 @@ import Subscription } private func updateShortcutMenuItems() { - toggleAutofillShortcutMenuItem.title = LocalPinningManager.shared.toggleShortcutInterfaceTitle(for: .autofill) - toggleBookmarksShortcutMenuItem.title = LocalPinningManager.shared.toggleShortcutInterfaceTitle(for: .bookmarks) - toggleDownloadsShortcutMenuItem.title = LocalPinningManager.shared.toggleShortcutInterfaceTitle(for: .downloads) + toggleAutofillShortcutMenuItem.title = LocalPinningManager.shared.shortcutTitle(for: .autofill) + toggleBookmarksShortcutMenuItem.title = LocalPinningManager.shared.shortcutTitle(for: .bookmarks) + toggleDownloadsShortcutMenuItem.title = LocalPinningManager.shared.shortcutTitle(for: .downloads) #if NETWORK_PROTECTION if NetworkProtectionKeychainTokenStore().isFeatureActivated { toggleNetworkProtectionShortcutMenuItem.isHidden = false - toggleNetworkProtectionShortcutMenuItem.title = LocalPinningManager.shared.toggleShortcutInterfaceTitle(for: .networkProtection) + toggleNetworkProtectionShortcutMenuItem.title = LocalPinningManager.shared.shortcutTitle(for: .networkProtection) } else { toggleNetworkProtectionShortcutMenuItem.isHidden = true } diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 4a4c4e1f7d..88d6e141bd 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -159,7 +159,7 @@ extension AppDelegate { } @objc func openImportBrowserDataWindow(_ sender: Any?) { - DataImportViewController.show() + DataImportView.show() } @objc func openExportLogins(_ sender: Any?) { @@ -380,7 +380,9 @@ extension MainViewController { } @objc func toggleNetworkProtectionShortcut(_ sender: Any) { +#if NETWORK_PROTECTION LocalPinningManager.shared.togglePinning(for: .networkProtection) +#endif } // MARK: - History diff --git a/DuckDuckGo/NavigationBar/PinningManager.swift b/DuckDuckGo/NavigationBar/PinningManager.swift index 3824e04bab..64d29c104e 100644 --- a/DuckDuckGo/NavigationBar/PinningManager.swift +++ b/DuckDuckGo/NavigationBar/PinningManager.swift @@ -26,8 +26,11 @@ enum PinnableView: String { case autofill case bookmarks case downloads - case networkProtection case homeButton + +#if NETWORK_PROTECTION + case networkProtection +#endif } protocol PinningManager { @@ -37,6 +40,7 @@ protocol PinningManager { func wasManuallyToggled(_ view: PinnableView) -> Bool func pin(_ view: PinnableView) func unpin(_ view: PinnableView) + func shortcutTitle(for view: PinnableView) -> String } final class LocalPinningManager: PinningManager { @@ -111,21 +115,15 @@ final class LocalPinningManager: PinningManager { return pinnedViewStrings.contains(view.rawValue) } - func toggleShortcutInterfaceTitle(for view: PinnableView) -> String { + func shortcutTitle(for view: PinnableView) -> String { switch view { case .autofill: return isPinned(.autofill) ? UserText.hideAutofillShortcut : UserText.showAutofillShortcut case .bookmarks: return isPinned(.bookmarks) ? UserText.hideBookmarksShortcut : UserText.showBookmarksShortcut case .downloads: return isPinned(.downloads) ? UserText.hideDownloadsShortcut : UserText.showDownloadsShortcut case .homeButton: return "" - case .networkProtection: #if NETWORK_PROTECTION - if !networkProtectionFeatureActivation.isFeatureActivated { - assertionFailure("Tried to toggle Network Protection when it was not activated") - } - + case .networkProtection: return isPinned(.networkProtection) ? UserText.hideNetworkProtectionShortcut : UserText.showNetworkProtectionShortcut -#else - fatalError("Tried to get Network Protection interface title when NetP was disabled") #endif } } diff --git a/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift b/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift index 00633f870c..c19a2bff39 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift @@ -28,7 +28,7 @@ final class AddressBarViewController: NSViewController { @IBOutlet var activeBackgroundView: NSView! @IBOutlet var activeOuterBorderView: NSView! @IBOutlet var activeBackgroundViewWithSuggestions: NSView! - @IBOutlet var progressIndicator: ProgressView! + @IBOutlet var progressIndicator: LoadingProgressView! @IBOutlet var passiveTextFieldMinXConstraint: NSLayoutConstraint! @IBOutlet var activeTextFieldMinXConstraint: NSLayoutConstraint! private static let defaultActiveTextFieldMinX: CGFloat = 40 diff --git a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift index 5e0b3fff96..c4b090fa71 100644 --- a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift +++ b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift @@ -26,7 +26,7 @@ import NetworkProtection #endif #if SUBSCRIPTION -import Account +import Subscription #endif protocol OptionsButtonMenuDelegate: AnyObject { @@ -47,7 +47,7 @@ protocol OptionsButtonMenuDelegate: AnyObject { func optionsButtonMenuRequestedDataBrokerProtection(_ menu: NSMenu) #endif #if SUBSCRIPTION - func optionsButtonMenuRequestedSubscriptionPreferences(_ menu: NSMenu) + func optionsButtonMenuRequestedSubscriptionPurchasePage(_ menu: NSMenu) #endif } @@ -239,8 +239,8 @@ final class MoreOptionsMenu: NSMenu { } #if SUBSCRIPTION - @objc func openSubscriptionPreferences(_ sender: NSMenuItem) { - actionDelegate?.optionsButtonMenuRequestedSubscriptionPreferences(self) + @objc func openSubscriptionPurchasePage(_ sender: NSMenuItem) { + actionDelegate?.optionsButtonMenuRequestedSubscriptionPurchasePage(self) } #endif @@ -302,7 +302,11 @@ final class MoreOptionsMenu: NSMenu { var items: [NSMenuItem] = [] #if SUBSCRIPTION - items.append(contentsOf: AccountManager().isSignedIn ? makeActiveSubscriptionItems() : makeInactiveSubscriptionItems()) + if AccountManager().isUserAuthenticated { + items.append(contentsOf: makeActiveSubscriptionItems()) + } else if SubscriptionPurchaseEnvironment.canPurchase { + items.append(contentsOf: makeInactiveSubscriptionItems()) + } #else items.append(contentsOf: makeActiveSubscriptionItems()) // this only adds NETP and DBP (if enabled) #endif @@ -358,7 +362,7 @@ final class MoreOptionsMenu: NSMenu { #if SUBSCRIPTION private func makeInactiveSubscriptionItems() -> [NSMenuItem] { let privacyProItem = NSMenuItem(title: "", - action: #selector(openSubscriptionPreferences(_:)), + action: #selector(openSubscriptionPurchasePage(_:)), keyEquivalent: "") .targetting(self) .withImage(NSImage(named: "SubscriptionIcon")) diff --git a/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard b/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard index 770c129cce..d759cd30a8 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard +++ b/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard @@ -389,7 +389,7 @@ - + diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index 94d9cac9a5..41b0b70a02 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -28,7 +28,7 @@ import NetworkProtectionUI #endif #if SUBSCRIPTION -import Account +import SubscriptionUI #endif // swiftlint:disable:next type_body_length @@ -103,8 +103,6 @@ final class NavigationBarViewController: NSViewController { private var pinnedViewsNotificationCancellable: AnyCancellable? private var navigationButtonsCancellables = Set() private var downloadsCancellables = Set() - private var networkProtectionCancellable: AnyCancellable? - private var networkProtectionInterruptionCancellable: AnyCancellable? private var cancellables = Set() @UserDefaultsWrapper(key: .homeButtonPosition, defaultValue: .right) @@ -408,17 +406,9 @@ final class NavigationBarViewController: NSViewController { self.updateDownloadsButton(updatingFromPinnedViewsNotification: true) case .homeButton: self.updateHomeButton() - case .networkProtection: #if NETWORK_PROTECTION - guard NetworkProtectionKeychainTokenStore().isFeatureActivated else { - LocalPinningManager.shared.unpin(.networkProtection) - networkProtectionButtonModel.isPinned = false - return - } - - networkProtectionButtonModel.isPinned = LocalPinningManager.shared.isPinned(.networkProtection) -#else - assertionFailure("Tried to toggle NetP when the feature was disabled") + case .networkProtection: + networkProtectionButtonModel.updateVisibility() #endif } } else { @@ -656,7 +646,7 @@ final class NavigationBarViewController: NSViewController { private func updatePasswordManagementButton() { let menu = NSMenu() - let title = LocalPinningManager.shared.toggleShortcutInterfaceTitle(for: .autofill) + let title = LocalPinningManager.shared.shortcutTitle(for: .autofill) menu.addItem(withTitle: title, action: #selector(toggleAutofillPanelPinning), keyEquivalent: "") passwordManagementButton.menu = menu @@ -728,7 +718,7 @@ final class NavigationBarViewController: NSViewController { private func updateDownloadsButton(updatingFromPinnedViewsNotification: Bool = false) { let menu = NSMenu() - let title = LocalPinningManager.shared.toggleShortcutInterfaceTitle(for: .downloads) + let title = LocalPinningManager.shared.shortcutTitle(for: .downloads) menu.addItem(withTitle: title, action: #selector(toggleDownloadsPanelPinning(_:)), keyEquivalent: "") downloadsButton.menu = menu @@ -793,7 +783,7 @@ final class NavigationBarViewController: NSViewController { private func updateBookmarksButton() { let menu = NSMenu() - let title = LocalPinningManager.shared.toggleShortcutInterfaceTitle(for: .bookmarks) + let title = LocalPinningManager.shared.shortcutTitle(for: .bookmarks) menu.addItem(withTitle: title, action: #selector(toggleBookmarksPanelPinning(_:)), keyEquivalent: "") bookmarkListButton.menu = menu @@ -891,20 +881,20 @@ extension NavigationBarViewController: NSMenuDelegate { HomeButtonMenuFactory.addToMenu(menu) - let autofillTitle = LocalPinningManager.shared.toggleShortcutInterfaceTitle(for: .autofill) + let autofillTitle = LocalPinningManager.shared.shortcutTitle(for: .autofill) menu.addItem(withTitle: autofillTitle, action: #selector(toggleAutofillPanelPinning), keyEquivalent: "A") - let bookmarksTitle = LocalPinningManager.shared.toggleShortcutInterfaceTitle(for: .bookmarks) + let bookmarksTitle = LocalPinningManager.shared.shortcutTitle(for: .bookmarks) menu.addItem(withTitle: bookmarksTitle, action: #selector(toggleBookmarksPanelPinning), keyEquivalent: "K") - let downloadsTitle = LocalPinningManager.shared.toggleShortcutInterfaceTitle(for: .downloads) + let downloadsTitle = LocalPinningManager.shared.shortcutTitle(for: .downloads) menu.addItem(withTitle: downloadsTitle, action: #selector(toggleDownloadsPanelPinning), keyEquivalent: "J") #if NETWORK_PROTECTION let isPopUpWindow = view.window?.isPopUpWindow ?? false if !isPopUpWindow && networkProtectionFeatureActivation.isFeatureActivated { - let networkProtectionTitle = LocalPinningManager.shared.toggleShortcutInterfaceTitle(for: .networkProtection) + let networkProtectionTitle = LocalPinningManager.shared.shortcutTitle(for: .networkProtection) menu.addItem(withTitle: networkProtectionTitle, action: #selector(toggleNetworkProtectionPanelPinning), keyEquivalent: "N") } #endif @@ -927,7 +917,9 @@ extension NavigationBarViewController: NSMenuDelegate { @objc private func toggleNetworkProtectionPanelPinning(_ sender: NSMenuItem) { +#if NETWORK_PROTECTION LocalPinningManager.shared.togglePinning(for: .networkProtection) +#endif } // MARK: - Network Protection @@ -944,19 +936,38 @@ extension NavigationBarViewController: NSMenuDelegate { } } + /// Sets up the Network Protection button. + /// + /// This method should be run just once during the lifecycle of this view. + /// . private func setupNetworkProtectionButton() { - networkProtectionCancellable = networkProtectionButtonModel.$showButton + assert(networkProtectionButton.menu == nil) + + let menuItem = NSMenuItem(title: LocalPinningManager.shared.shortcutTitle(for: .networkProtection), action: #selector(toggleNetworkProtectionPanelPinning), target: self) + let menu = NSMenu(items: [menuItem]) + networkProtectionButton.menu = menu + + networkProtectionButtonModel.$shortcutTitle + .receive(on: RunLoop.main) + .sink { title in + menuItem.title = title + } + .store(in: &cancellables) + + networkProtectionButtonModel.$showButton .receive(on: RunLoop.main) .sink { [weak self] show in let isPopUpWindow = self?.view.window?.isPopUpWindow ?? false self?.networkProtectionButton.isHidden = isPopUpWindow || !show } + .store(in: &cancellables) - networkProtectionInterruptionCancellable = networkProtectionButtonModel.$buttonImage + networkProtectionButtonModel.$buttonImage .receive(on: RunLoop.main) .sink { [weak self] image in self?.networkProtectionButton.image = image } + .store(in: &cancellables) } #endif @@ -991,7 +1002,7 @@ extension NavigationBarViewController: OptionsButtonMenuDelegate { } func optionsButtonMenuRequestedBookmarkImportInterface(_ menu: NSMenu) { - DataImportViewController.show() + DataImportView.show() } func optionsButtonMenuRequestedBookmarkExportInterface(_ menu: NSMenu) { @@ -1029,8 +1040,8 @@ extension NavigationBarViewController: OptionsButtonMenuDelegate { } #if SUBSCRIPTION - func optionsButtonMenuRequestedSubscriptionPreferences(_ menu: NSMenu) { - WindowControllersManager.shared.showPreferencesTab(withSelectedPane: .subscription) + func optionsButtonMenuRequestedSubscriptionPurchasePage(_ menu: NSMenu) { + WindowControllersManager.shared.show(url: .purchaseSubscription, source: .ui, newTab: true) } #endif diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionAppEvents.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionAppEvents.swift index fc494b066e..48c5e2dfa1 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionAppEvents.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionAppEvents.swift @@ -121,6 +121,11 @@ final class NetworkProtectionAppEvents { versionStore.lastVersionRun = currentVersion } +#if DEBUG + // For debug builds we always want to restart the VPN and VPN menu app to ensure + // the latest is loaded + restartNetworkProtectionTunnelAndMenu(using: loginItemsManager) +#else // should‘ve been run at least once with NetP enabled guard let lastVersionRun = versionStore.lastVersionRun else { os_log(.info, log: .networkProtection, "No last version found for the NetP login items, skipping update") @@ -131,6 +136,7 @@ final class NetworkProtectionAppEvents { os_log(.info, log: .networkProtection, "App updated from %{public}s to %{public}s: updating login items", lastVersionRun, currentVersion) restartNetworkProtectionTunnelAndMenu(using: loginItemsManager) } +#endif } private func restartNetworkProtectionTunnelAndMenu(using loginItemsManager: LoginItemsManager) { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift index 3c9f1fda50..0d85b52574 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift @@ -54,17 +54,20 @@ final class NetworkProtectionNavBarButtonModel: NSObject, ObservableObject { private let pinningManager: PinningManager @Published - private(set) var showButton = false + private(set) var showButton = false { + didSet { + shortcutTitle = pinningManager.shortcutTitle(for: .networkProtection) + } + } + + @Published + private(set) var shortcutTitle: String @Published private(set) var buttonImage: NSImage? var isPinned: Bool { - didSet { - Task { @MainActor in - updateVisibility() - } - } + pinningManager.isPinned(.networkProtection) } // MARK: - NetP State @@ -92,7 +95,7 @@ final class NetworkProtectionNavBarButtonModel: NSObject, ObservableObject { ) self.iconPublisher = NetworkProtectionIconPublisher(statusReporter: networkProtectionStatusReporter, iconProvider: iconProvider) self.pinningManager = pinningManager - isPinned = pinningManager.isPinned(.networkProtection) + self.shortcutTitle = pinningManager.shortcutTitle(for: .networkProtection) isHavingConnectivityIssues = networkProtectionStatusReporter.connectivityIssuesObserver.recentValue buttonImage = .image(for: iconPublisher.icon) @@ -190,7 +193,7 @@ final class NetworkProtectionNavBarButtonModel: NSObject, ObservableObject { } @MainActor - private func updateVisibility() { + func updateVisibility() { // The button is visible in the case where NetP has not been activated, but the user has been invited and they haven't accepted T&Cs. let networkProtectionVisibility = DefaultNetworkProtectionVisibility() if networkProtectionVisibility.isNetworkProtectionVisible() { @@ -227,17 +230,17 @@ final class NetworkProtectionNavBarButtonModel: NSObject, ObservableObject { return } - switch status { - case .connecting, .connected, .reasserting, .disconnecting: - showButton = true - - pinNetworkProtectionToNavBarIfNeverPinnedBefore() - default: - showButton = false - } + showButton = false } } + // MARK: - Pinning + + @objc + func togglePin() { + pinningManager.togglePinning(for: .networkProtection) + } + /// We want to pin Network Protection to the navigation bar the first time it's enabled, and only /// if the user hasn't toggled it manually before. /// diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift index 6d811b056a..4727529c02 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Combine import Foundation import NetworkProtection import NetworkProtectionIPC @@ -61,7 +62,9 @@ final class NetworkProtectionNavBarPopoverManager { let onboardingStatusPublisher = UserDefaults.netP.networkProtectionOnboardingStatusPublisher _ = VPNSettings(defaults: .netP) - let popover = NetworkProtectionPopover(controller: controller, onboardingStatusPublisher: onboardingStatusPublisher, statusReporter: statusReporter) { + let popover = NetworkProtectionPopover(controller: controller, + onboardingStatusPublisher: onboardingStatusPublisher, + statusReporter: statusReporter) { let menuItems = [ NetworkProtectionStatusView.Model.MenuItem( name: UserText.networkProtectionNavBarStatusMenuVPNSettings, action: { diff --git a/DuckDuckGo/Preferences/Model/PreferencesSection.swift b/DuckDuckGo/Preferences/Model/PreferencesSection.swift index 4e32a5af5f..7200dd805d 100644 --- a/DuckDuckGo/Preferences/Model/PreferencesSection.swift +++ b/DuckDuckGo/Preferences/Model/PreferencesSection.swift @@ -19,6 +19,10 @@ import Foundation import SwiftUI +#if SUBSCRIPTION +import Subscription +#endif + struct PreferencesSection: Hashable, Identifiable { let id: PreferencesSectionIdentifier let panes: [PreferencePaneIdentifier] @@ -34,6 +38,12 @@ struct PreferencesSection: Hashable, Identifiable { panes.insert(.sync, at: generalIndex + 1) } } + + if !AccountManager().isUserAuthenticated && !SubscriptionPurchaseEnvironment.canPurchase { + if let subscriptionIndex = panes.firstIndex(of: .subscription) { + panes.remove(at: subscriptionIndex) + } + } #else var panes: [PreferencePaneIdentifier] = [.general, .appearance, .privacy, .autofill, .downloads] diff --git a/DuckDuckGo/Preferences/View/PreferencesRootView.swift b/DuckDuckGo/Preferences/View/PreferencesRootView.swift index 1294bac390..ae5a806cc3 100644 --- a/DuckDuckGo/Preferences/View/PreferencesRootView.swift +++ b/DuckDuckGo/Preferences/View/PreferencesRootView.swift @@ -22,9 +22,8 @@ import SwiftUIExtensions import SyncUI #if SUBSCRIPTION -import Account -import Purchase import Subscription +import SubscriptionUI #endif fileprivate extension Preferences.Const { @@ -101,18 +100,18 @@ extension Preferences { private func makeSubscriptionView() -> some View { let actionHandler = PreferencesSubscriptionActionHandlers(openURL: { url in WindowControllersManager.shared.show(url: url, source: .ui, newTab: true) - }, manageSubscriptionInAppStore: { - NSWorkspace.shared.open(URL(string: "macappstores://apps.apple.com/account/subscriptions")!) + }, changePlanOrBilling: { + self.changePlanOrBilling() }, openVPN: { - print("openVPN") + NotificationCenter.default.post(name: .openVPN, object: self, userInfo: nil) }, openPersonalInformationRemoval: { - print("openPersonalInformationRemoval") + NotificationCenter.default.post(name: .openPersonalInformationRemoval, object: self, userInfo: nil) }, openIdentityTheftRestoration: { - print("openIdentityTheftRestoration") + NotificationCenter.default.post(name: .openIdentityTheftRestoration, object: self, userInfo: nil) }) let sheetActionHandler = SubscriptionAccessActionHandlers(restorePurchases: { - AccountManager().signInByRestoringPastPurchases() + self.restorePurchases() }, openURLHandler: { url in WindowControllersManager.shared.show(url: url, source: .ui, newTab: true) }, goToSyncPreferences: { @@ -120,7 +119,51 @@ extension Preferences { }) let model = PreferencesSubscriptionModel(actionHandler: actionHandler, sheetActionHandler: sheetActionHandler) - return Subscription.PreferencesSubscriptionView(model: model) + return SubscriptionUI.PreferencesSubscriptionView(model: model) + } + + private func changePlanOrBilling() { + switch SubscriptionPurchaseEnvironment.current { + case .appStore: + NSWorkspace.shared.open(.manageSubscriptionsInAppStoreAppURL) + case .stripe: + Task { + guard let accessToken = AccountManager().accessToken, let externalID = AccountManager().externalID, + case let .success(response) = await SubscriptionService.getCustomerPortalURL(accessToken: accessToken, externalID: externalID) else { return } + guard let customerPortalURL = URL(string: response.customerPortalUrl) else { return } + + WindowControllersManager.shared.show(url: customerPortalURL, source: .ui, newTab: true) + } + } + } + + private func restorePurchases() { + if #available(macOS 12.0, *) { + Task { + let mainViewController = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController + let progressViewController = ProgressViewController(title: "Restoring subscription...") + + defer { mainViewController?.dismiss(progressViewController) } + + mainViewController?.presentAsSheet(progressViewController) + + guard case .success = await PurchaseManager.shared.syncAppleIDAccount() else { return } + + switch await AppStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + break + case .failure(let error): + switch error { + case .missingAccountOrTransactions: + WindowControllersManager.shared.lastKeyMainWindowController?.showSubscriptionNotFoundAlert() + case .subscriptionExpired: + WindowControllersManager.shared.lastKeyMainWindowController?.showSubscriptionInactiveAlert() + default: + WindowControllersManager.shared.lastKeyMainWindowController?.showSomethingWentWrongAlert() + } + } + } + } } #endif } diff --git a/DuckDuckGo/SecureVault/View/EditableTextView.swift b/DuckDuckGo/SecureVault/View/EditableTextView.swift index c91abf0bfb..80c3a91223 100644 --- a/DuckDuckGo/SecureVault/View/EditableTextView.swift +++ b/DuckDuckGo/SecureVault/View/EditableTextView.swift @@ -16,31 +16,42 @@ // limitations under the License. // +import AppKit import Foundation import SwiftUI struct EditableTextView: NSViewRepresentable { + var isEditable: Bool = true + @Binding var text: String - var isEditable: Bool = true - var font: NSFont? = .systemFont(ofSize: 14, weight: .regular) - var onEditingChanged: () -> Void = {} - var onCommit: () -> Void = {} - var onTextChange: (String) -> Void = { _ in } + var font: NSFont = .systemFont(ofSize: 13, weight: .regular) var maxLength: Int? + var insets: NSSize? + var cornerRadius: CGFloat = 0 + var backgroundColor: NSColor? = .textEditorBackgroundColor + var textColor: NSColor? = .textColor + var focusRingType: NSFocusRingType = .default + var isFocusedOnAppear: Bool = true func makeCoordinator() -> Coordinator { - Coordinator(self) + return Coordinator(self) } func makeNSView(context: Context) -> CustomTextView { let textView = CustomTextView( text: text, isEditable: isEditable, - font: font + font: font, + textColor: textColor, + insets: insets, + isFocusedOnAppear: isFocusedOnAppear, + focusRingType: focusRingType, + cornerRadius: cornerRadius, + backgroundColor: backgroundColor, + delegate: context.coordinator ) - textView.delegate = context.coordinator return textView } @@ -62,25 +73,16 @@ extension EditableTextView { self.parent = parent } - func textDidBeginEditing(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { - return - } - - self.parent.text = textView.string - self.parent.onEditingChanged() - } - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { - return - } + guard let textView = notification.object as? NSTextView else { return } if let maxLength = parent.maxLength, textView.string.count > maxLength { textView.string = String(textView.string.prefix(maxLength)) } - self.parent.text = textView.string + if self.parent.text != textView.string { + self.parent.text = textView.string + } self.selectedRanges = textView.selectedRanges } @@ -92,102 +94,171 @@ extension EditableTextView { final class CustomTextView: NSView { - private var isEditable: Bool - private var font: NSFont? - weak var delegate: NSTextViewDelegate? - var text: String { didSet { + guard textView.string != text else { return } textView.string = text } } var selectedRanges: [NSValue] = [] { didSet { - guard selectedRanges.count > 0 else { - return - } - + guard !selectedRanges.isEmpty else { return } textView.selectedRanges = selectedRanges } } - private lazy var scrollView: NSScrollView = { - let scrollView = NSScrollView() + private let isFocusedOnAppear: Bool - scrollView.drawsBackground = true - scrollView.borderType = .noBorder - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalRuler = false - scrollView.autoresizingMask = [.width, .height] - scrollView.translatesAutoresizingMaskIntoConstraints = false + let scrollView: NSScrollView + let textView: NSTextView + + // MARK: - Init + + init(text: String = "", isEditable: Bool, font: NSFont, textColor: NSColor? = nil, insets: NSSize? = nil, isFocusedOnAppear: Bool = false, focusRingType: NSFocusRingType = .default, cornerRadius: CGFloat = 0, backgroundColor: NSColor? = nil, delegate: NSTextViewDelegate? = nil, selectedRanges: [NSValue] = []) { - return scrollView - }() + self.text = text + self.selectedRanges = selectedRanges + self.isFocusedOnAppear = isFocusedOnAppear + + self.scrollView = RoundedCornersScrollView(cornerRadius: cornerRadius) - private lazy var textView: NSTextView = { - let contentSize = scrollView.contentSize let textStorage = NSTextStorage() let layoutManager = NSLayoutManager() textStorage.addLayoutManager(layoutManager) - let textContainer = NSTextContainer(containerSize: scrollView.frame.size) + let textContainer = NSTextContainer(containerSize: .zero) textContainer.widthTracksTextView = true - textContainer.containerSize = NSSize(width: contentSize.width, height: CGFloat.greatestFiniteMagnitude) + textContainer.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude) layoutManager.addTextContainer(textContainer) - let textView = NSTextView(frame: .zero, textContainer: textContainer) + self.textView = NSTextView(frame: .zero, textContainer: textContainer) + + super.init(frame: .zero) + + setupScrollView(cornerRadius: cornerRadius, focusRingType: focusRingType, backgroundColor: backgroundColor) + setupTextView(isEditable: isEditable, font: font, textColor: textColor, insets: insets, delegate: delegate) + setupScrollViewConstraints() + } + + required init?(coder: NSCoder) { + fatalError("CustomTextView: Bad initializer") + } + + private func setupScrollView(cornerRadius: CGFloat, focusRingType: NSFocusRingType, backgroundColor: NSColor?) { + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalRuler = false + scrollView.autoresizingMask = [.width, .height] + scrollView.translatesAutoresizingMaskIntoConstraints = false + if let backgroundColor { + scrollView.drawsBackground = true + scrollView.backgroundColor = backgroundColor + } else { + scrollView.drawsBackground = false + } + scrollView.focusRingType = focusRingType + if cornerRadius > 0 { + scrollView.wantsLayer = true + scrollView.layer!.cornerRadius = cornerRadius + } + } + + private func setupTextView(isEditable: Bool, font: NSFont, textColor: NSColor?, insets: NSSize?, delegate: NSTextViewDelegate?) { textView.autoresizingMask = .width - textView.backgroundColor = NSColor(named: "PWMEditingControlColor")! - textView.delegate = self.delegate - textView.drawsBackground = true - textView.font = self.font - textView.isEditable = self.isEditable + textView.delegate = delegate + textView.drawsBackground = false + textView.font = font + textView.isEditable = isEditable textView.isHorizontallyResizable = false textView.isVerticallyResizable = true textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - textView.minSize = NSSize(width: 0, height: contentSize.height) - textView.textColor = NSColor.labelColor + textView.minSize = NSSize(width: 0, height: scrollView.contentSize.height) textView.allowsUndo = true + textView.string = text + textView.wantsLayer = true - return textView - }() + if let textColor { + textView.textColor = textColor + } + if let insets { + textView.textContainerInset = insets + } + if !selectedRanges.isEmpty { + textView.selectedRanges = selectedRanges + } + } - // MARK: - Init + private func setupScrollViewConstraints() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + addSubview(scrollView) - init(text: String, isEditable: Bool, font: NSFont?) { - self.font = font - self.isEditable = isEditable - self.text = text + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor) + ]) - super.init(frame: .zero) + scrollView.documentView = textView } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + // MARK: - Life cycle + + override func viewDidMoveToWindow() { + if isFocusedOnAppear, let window { + window.makeFirstResponder(textView) + } } +} - // MARK: - Life cycle +final class RoundedCornersScrollView: NSScrollView { - override func viewWillDraw() { - super.viewWillDraw() - setupScrollViewConstraints() - setupTextView() + let cornerRadius: CGFloat + + init(frame: NSRect = .zero, cornerRadius: CGFloat) { + self.cornerRadius = cornerRadius + super.init(frame: frame) } - func setupScrollViewConstraints() { - scrollView.translatesAutoresizingMaskIntoConstraints = false - addSubview(scrollView) + required init?(coder: NSCoder) { + fatalError("RoudedCornersScrollView: Bad initializer") + } + override func drawFocusRingMask() { + NSBezierPath(roundedRect: bounds, xRadius: cornerRadius, yRadius: cornerRadius).fill() + } + +} + +#Preview { { + + struct PreviewView: View { + @State var text = """ NSLayoutConstraint.activate([ scrollView.topAnchor.constraint(equalTo: topAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), scrollView.leadingAnchor.constraint(equalTo: leadingAnchor) ]) - } + """ + + var body: some View { + VStack(spacing: 10) { + EditableTextView(text: $text, + font: .systemFont(ofSize: 18), + insets: NSSize(width: 15, height: 10), + cornerRadius: 15, + backgroundColor: .textBackgroundColor, + textColor: .purple, + focusRingType: .exterior, + isFocusedOnAppear: true) + + TextField("", text: .constant("")) + }.padding(EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)) + } - func setupTextView() { - scrollView.documentView = textView } -} + return PreviewView() + +}() } diff --git a/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift b/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift index 6eec5995d7..498203b1d2 100644 --- a/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift +++ b/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift @@ -293,7 +293,7 @@ final class PasswordManagementViewController: NSViewController { @IBAction func onImportClicked(_ sender: NSButton) { self.dismiss() - DataImportViewController.show() + DataImportView.show() } @IBAction func deviceAuthenticationRequested(_ sender: NSButton) { diff --git a/DuckDuckGo/Sharing/SharingMenu.swift b/DuckDuckGo/Sharing/SharingMenu.swift index 63d571972c..c3a6c1ec12 100644 --- a/DuckDuckGo/Sharing/SharingMenu.swift +++ b/DuckDuckGo/Sharing/SharingMenu.swift @@ -40,7 +40,7 @@ final class SharingMenu: NSMenu { self.items = services.map { service in NSMenuItem(service: service, target: self, action: #selector(sharingItemSelected)) } + [ - NSMenuItem(title: UserText.moreMenuItem, action: #selector(openSharingPreferences), target: self).withImage(.more) + NSMenuItem(title: UserText.moreMenuItem, action: #selector(openSharingPreferences), target: self).withImage(.sharedMoreMenu) ] } @@ -173,7 +173,7 @@ private extension NSMenuItem { private extension NSImage { - static var more: NSImage? { + static var sharedMoreMenu: NSImage? { let sharedMoreMenuImageSelector = NSSelectorFromString("sharedMoreMenuImage") guard NSSharingServicePicker.responds(to: sharedMoreMenuImageSelector) else { return nil } return NSSharingServicePicker.perform(sharedMoreMenuImageSelector)?.takeUnretainedValue() as? NSImage diff --git a/DuckDuckGo/Statistics/PixelArguments.swift b/DuckDuckGo/Statistics/PixelArguments.swift index 0157982970..0603d21f35 100644 --- a/DuckDuckGo/Statistics/PixelArguments.swift +++ b/DuckDuckGo/Statistics/PixelArguments.swift @@ -117,7 +117,7 @@ extension DataImportAction: CustomStringConvertible { var description: String { switch self { case .bookmarks: return "bookmarks" - case .logins: return "logins" + case .passwords: return "logins" case .favicons: return "favicons" case .generic: return "generic" } @@ -129,7 +129,10 @@ extension DataImport.Source: CustomStringConvertible { switch self { case .brave: return "source-brave" case .chrome: return "source-chrome" + case .chromium: return "source-chromium" + case .coccoc: return "source-coccoc" case .csv: return "source-csv" + case .bitwarden: return "source-bitwarden" case .lastPass: return "source-lastpass" case .onePassword7: return "source-1password" case .onePassword8: return "source-1password-8" @@ -138,6 +141,11 @@ extension DataImport.Source: CustomStringConvertible { case .safari: return "source-safari" case .safariTechnologyPreview: return "source-safari-technology-preview" case .bookmarksHTML: return "source-bookmarks-html" + case .opera: return "source-opera" + case .operaGX: return "source-operagx" + case .tor: return "source-tor" + case .vivaldi: return "source-vivaldi" + case .yandex: return "source-yandex" } } } diff --git a/DuckDuckGo/Statistics/PixelEvent.swift b/DuckDuckGo/Statistics/PixelEvent.swift index 407567a01b..4ffa0ad040 100644 --- a/DuckDuckGo/Statistics/PixelEvent.swift +++ b/DuckDuckGo/Statistics/PixelEvent.swift @@ -101,7 +101,7 @@ extension Pixel { case dailyOsVersionCounter - case dataImportFailed(any DataImportError) + case dataImportFailed(source: DataImport.Source, sourceVersion: String?, error: any DataImportError) case formAutofilled(kind: FormAutofillKind) case autofillItemSaved(kind: FormAutofillKind) @@ -376,10 +376,10 @@ extension Pixel.Event { case .dailyOsVersionCounter: return "m_mac_daily-os-version-counter" - case .dataImportFailed(let error) where error.action == .favicons: - return "m_mac_favicon-import-failed_\(error.source)" - case .dataImportFailed(let error): - return "m_mac_data-import-failed_\(error.action)_\(error.source)" + case .dataImportFailed(source: let source, sourceVersion: _, error: let error) where error.action == .favicons: + return "m_mac_favicon-import-failed_\(source)" + case .dataImportFailed(source: let source, sourceVersion: _, error: let error): + return "m_mac_data-import-failed_\(error.action)_\(source)" case .formAutofilled(kind: let kind): return "m_mac_autofill_\(kind)" diff --git a/DuckDuckGo/Statistics/PixelParameters.swift b/DuckDuckGo/Statistics/PixelParameters.swift index a2c30e5bd7..2ec4b22c8f 100644 --- a/DuckDuckGo/Statistics/PixelParameters.swift +++ b/DuckDuckGo/Statistics/PixelParameters.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Foundation import PixelKit extension Pixel.Event { @@ -26,7 +27,6 @@ extension Pixel.Event { return event.parameters case .debug(event: let debugEvent, error: let error): - var params = error?.pixelParameters ?? [:] if case let .assertionFailure(message, file, line) = debugEvent { @@ -37,8 +37,13 @@ extension Pixel.Event { return params - case .dataImportFailed(let error): - return error.pixelParameters + case .dataImportFailed(source: _, sourceVersion: let version, error: let error): + var params = error.pixelParameters + + if let version { + params[PixelKit.Parameters.sourceBrowserVersion] = version + } + return params case .launchInitial(let cohort): return [PixelKit.Parameters.experimentCohort: cohort] diff --git a/DuckDuckGo/Tab/TabExtensions/NavigationProtectionTabExtension.swift b/DuckDuckGo/Tab/TabExtensions/NavigationProtectionTabExtension.swift index 7e07ea8167..539f7a29e3 100644 --- a/DuckDuckGo/Tab/TabExtensions/NavigationProtectionTabExtension.swift +++ b/DuckDuckGo/Tab/TabExtensions/NavigationProtectionTabExtension.swift @@ -92,8 +92,8 @@ extension NavigationProtectionTabExtension: NavigationResponder { } guard !Task.isCancelled else { return .cancel } - if let url = await linkProtection.requestTrackingLinkRewrite(initiatingURL: navigationAction.sourceFrame.url, destinationURL: request.url!) { - request.url = url + if let newRequest = await linkProtection.requestTrackingLinkRewrite(initiatingURL: navigationAction.sourceFrame.url, destinationRequest: request) { + request = newRequest } guard !Task.isCancelled else { return .cancel } @@ -140,11 +140,11 @@ extension LinkProtection { @MainActor public func requestTrackingLinkRewrite(initiatingURL: URL?, - destinationURL: URL) async -> URL? { + destinationRequest: URLRequest) async -> URLRequest? { await withCheckedContinuation { continuation in let didRewriteLink = { - requestTrackingLinkRewrite(initiatingURL: initiatingURL, destinationURL: destinationURL, onStartExtracting: {}, onFinishExtracting: {}) { url in - continuation.resume(returning: url) // <--- + requestTrackingLinkRewrite(initiatingURL: initiatingURL, destinationRequest: destinationRequest, onStartExtracting: {}, onFinishExtracting: {}) { newRequest in + continuation.resume(returning: newRequest) // <--- } policyDecisionHandler: { allowNavigationAction in if allowNavigationAction { continuation.resume(returning: nil) diff --git a/DuckDuckGo/Tab/UserScripts/SubscriptionPagesUserScript.swift b/DuckDuckGo/Tab/UserScripts/SubscriptionPagesUserScript.swift index 4ebe7f8406..60d2b5ad1f 100644 --- a/DuckDuckGo/Tab/UserScripts/SubscriptionPagesUserScript.swift +++ b/DuckDuckGo/Tab/UserScripts/SubscriptionPagesUserScript.swift @@ -25,7 +25,8 @@ import Foundation import Navigation import WebKit import UserScript -import Account +import Subscription +import SubscriptionUI public extension Notification.Name { static let subscriptionPageCloseAndOpenPreferences = Notification.Name("com.duckduckgo.subscriptionPage.CloseAndOpenPreferences") @@ -74,20 +75,32 @@ extension SubscriptionPagesUserScript: WKScriptMessageHandler { } /// -/// Use Email sub-feature +/// Use Subscription sub-feature /// -struct SubscriptionPagesUseEmailFeature: Subfeature { - weak var broker: UserScriptMessageBroker? +final class SubscriptionPagesUseSubscriptionFeature: Subfeature { + var broker: UserScriptMessageBroker? var featureName = "useSubscription" - var messageOriginPolicy: MessageOriginPolicy = .all + var messageOriginPolicy: MessageOriginPolicy = .only(rules: [ + .exact(hostname: "duckduckgo.com"), + .exact(hostname: "abrown.duckduckgo.com") + ]) + + func with(broker: UserScriptMessageBroker) { + self.broker = broker + } func handler(forMethodNamed methodName: String) -> Subfeature.Handler? { switch methodName { case "getSubscription": return getSubscription case "setSubscription": return setSubscription case "backToSettings": return backToSettings + case "getSubscriptionOptions": return getSubscriptionOptions + case "subscriptionSelected": return subscriptionSelected + case "activateSubscription": return activateSubscription + case "featureSelected": return featureSelected + case "completeStripePayment": return completeStripePayment default: return nil } @@ -105,21 +118,12 @@ struct SubscriptionPagesUseEmailFeature: Subfeature { let token: String } - struct EmailProtectionValues: Codable { - enum CodingKeys: String, CodingKey { - case token - case user - case cohort - } - let token: String - let user: String - let cohort: String - } - func getSubscription(params: Any, original: WKScriptMessage) async throws -> Encodable? { - let token = AccountManager().shortLivedToken ?? "" - let subscription = Subscription(token: token) - return subscription + if let authToken = AccountManager().authToken, let accessToken = AccountManager().accessToken { + return Subscription(token: authToken) + } else { + return Subscription(token: "") + } } func setSubscription(params: Any, original: WKScriptMessage) async throws -> Encodable? { @@ -128,17 +132,283 @@ struct SubscriptionPagesUseEmailFeature: Subfeature { return nil } - AccountManager().exchangeTokenAndRefreshEntitlements(with: subscriptionValues.token) + let authToken = subscriptionValues.token + let accountManager = AccountManager() + if case let .success(accessToken) = await accountManager.exchangeAuthTokenToAccessToken(authToken), + case let .success(accountDetails) = await accountManager.fetchAccountDetails(with: accessToken) { + accountManager.storeAuthToken(token: authToken) + accountManager.storeAccount(token: accessToken, email: accountDetails.email, externalID: accountDetails.externalID) + } + return nil } func backToSettings(params: Any, original: WKScriptMessage) async throws -> Encodable? { + let accountManager = AccountManager() + if let accessToken = accountManager.accessToken, + case let .success(accountDetails) = await accountManager.fetchAccountDetails(with: accessToken) { + accountManager.storeAccount(token: accessToken, email: accountDetails.email, externalID: accountDetails.externalID) + } + DispatchQueue.main.async { NotificationCenter.default.post(name: .subscriptionPageCloseAndOpenPreferences, object: self) } return nil } + + func getSubscriptionOptions(params: Any, original: WKScriptMessage) async throws -> Encodable? { +#if STRIPE + switch await StripePurchaseFlow.subscriptionOptions() { + case .success(let subscriptionOptions): + return subscriptionOptions + case .failure: + return nil + } +#else + if #available(macOS 12.0, *) { + switch await AppStorePurchaseFlow.subscriptionOptions() { + case .success(let subscriptionOptions): + return subscriptionOptions + case .failure: + return nil + } + } + return nil +#endif + } + + // swiftlint:disable:next function_body_length + func subscriptionSelected(params: Any, original: WKScriptMessage) async throws -> Encodable? { + struct SubscriptionSelection: Decodable { + let id: String + } + + let message = original + +#if STRIPE + let emailAccessToken = try? EmailManager().getToken() + + switch await StripePurchaseFlow.prepareSubscriptionPurchase(emailAccessToken: emailAccessToken) { + case .success(let purchaseUpdate): + await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: purchaseUpdate) + case .failure: + await WindowControllersManager.shared.lastKeyMainWindowController?.showSomethingWentWrongAlert() + return nil + } +#else + if #available(macOS 12.0, *) { + let mainViewController = await WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController + let progressViewController = await ProgressViewController(title: UserText.purchasingSubscriptionTitle) + + defer { + Task { + await mainViewController?.dismiss(progressViewController) + } + } + + guard let subscriptionSelection: SubscriptionSelection = DecodableHelper.decode(from: params) else { + assertionFailure("SubscriptionPagesUserScript: expected JSON representation of SubscriptionSelection") + return nil + } + + os_log(.info, log: .subscription, "[Purchase] Starting purchase for: %{public}s", subscriptionSelection.id) + + await mainViewController?.presentAsSheet(progressViewController) + + // Check for active subscriptions + if await PurchaseManager.hasActiveSubscription() { + os_log(.info, log: .subscription, "[Purchase] Found active subscription during purchase") + await WindowControllersManager.shared.lastKeyMainWindowController?.showSubscriptionFoundAlert(originalMessage: message) + return nil + } + + let emailAccessToken = try? EmailManager().getToken() + + os_log(.info, log: .subscription, "[Purchase] Purchasing") + switch await AppStorePurchaseFlow.purchaseSubscription(with: subscriptionSelection.id, emailAccessToken: emailAccessToken) { + case .success: + break + case .failure(let error): + os_log(.error, log: .subscription, "[Purchase] Error: %{public}s", String(reflecting: error)) + await WindowControllersManager.shared.lastKeyMainWindowController?.showSomethingWentWrongAlert() + return nil + } + + await progressViewController.updateTitleText(UserText.completingPurchaseTitle) + + os_log(.info, log: .subscription, "[Purchase] Completing purchase") + + switch await AppStorePurchaseFlow.completeSubscriptionPurchase() { + case .success(let purchaseUpdate): + await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: purchaseUpdate) + case .failure(let error): + os_log(.error, log: .subscription, "[Purchase] Error: %{public}s", String(reflecting: error)) + await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: PurchaseUpdate(type: "completed")) + } + + os_log(.info, log: .subscription, "[Purchase] Purchase complete") + } +#endif + + return nil + } + + func activateSubscription(params: Any, original: WKScriptMessage) async throws -> Encodable? { + let message = original + + Task { @MainActor in + let actionHandlers = SubscriptionAccessActionHandlers( + restorePurchases: { + if #available(macOS 12.0, *) { + Task { + let mainViewController = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController + let progressViewController = ProgressViewController(title: UserText.restoringSubscriptionTitle) + + defer { mainViewController?.dismiss(progressViewController) } + + mainViewController?.presentAsSheet(progressViewController) + + guard case .success = await PurchaseManager.shared.syncAppleIDAccount() else { return } + + switch await AppStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + message.webView?.reload() + case .failure(let error): + switch error { + case .missingAccountOrTransactions: + WindowControllersManager.shared.lastKeyMainWindowController?.showSubscriptionNotFoundAlert() + case .subscriptionExpired: + WindowControllersManager.shared.lastKeyMainWindowController?.showSubscriptionInactiveAlert() + default: + WindowControllersManager.shared.lastKeyMainWindowController?.showSomethingWentWrongAlert() + } + } + } + } + }, + openURLHandler: { url in + WindowControllersManager.shared.show(url: url, source: .ui, newTab: true) + }, goToSyncPreferences: { + WindowControllersManager.shared.show(url: URL(string: "about:preferences/sync")!, source: .ui, newTab: true) + }) + + let vc = SubscriptionAccessViewController(actionHandlers: actionHandlers) + WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.presentAsSheet(vc) + } + + return nil + } + + func featureSelected(params: Any, original: WKScriptMessage) async throws -> Encodable? { + struct FeatureSelection: Codable { + let feature: String + } + + guard let featureSelection: FeatureSelection = DecodableHelper.decode(from: params) else { + assertionFailure("SubscriptionPagesUserScript: expected JSON representation of FeatureSelection") + return nil + } + + guard let subscriptionFeatureName = SubscriptionFeatureName(rawValue: featureSelection.feature) else { + assertionFailure("SubscriptionPagesUserScript: feature name does not matches mapping") + return nil + } + + switch subscriptionFeatureName { + case .privateBrowsing: + NotificationCenter.default.post(name: .openPrivateBrowsing, object: self, userInfo: nil) + case .privateSearch: + NotificationCenter.default.post(name: .openPrivateSearch, object: self, userInfo: nil) + case .emailProtection: + NotificationCenter.default.post(name: .openEmailProtection, object: self, userInfo: nil) + case .appTrackingProtection: + NotificationCenter.default.post(name: .openAppTrackingProtection, object: self, userInfo: nil) + case .vpn: + NotificationCenter.default.post(name: .openVPN, object: self, userInfo: nil) + case .personalInformationRemoval: + NotificationCenter.default.post(name: .openPersonalInformationRemoval, object: self, userInfo: nil) + case .identityTheftRestoration: + NotificationCenter.default.post(name: .openIdentityTheftRestoration, object: self, userInfo: nil) + } + + return nil + } + + func completeStripePayment(params: Any, original: WKScriptMessage) async throws -> Encodable? { + let mainViewController = await WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController + let progressViewController = await ProgressViewController(title: UserText.completingPurchaseTitle) + + await mainViewController?.presentAsSheet(progressViewController) + await StripePurchaseFlow.completeSubscriptionPurchase() + await mainViewController?.dismiss(progressViewController) + + return [String: String]() // cannot be nil + } + + // MARK: Push actions + + enum SubscribeActionName: String { + case onPurchaseUpdate + } + + @MainActor + func pushPurchaseUpdate(originalMessage: WKScriptMessage, purchaseUpdate: PurchaseUpdate) async { + pushAction(method: .onPurchaseUpdate, webView: originalMessage.webView!, params: purchaseUpdate) + } + + func pushAction(method: SubscribeActionName, webView: WKWebView, params: Encodable) { + let broker = UserScriptMessageBroker(context: SubscriptionPagesUserScript.context, requiresRunInPageContentWorld: true ) + broker.push(method: method.rawValue, params: params, for: self, into: webView) + } +} + +extension MainWindowController { + + @MainActor + func showSomethingWentWrongAlert(environment: SubscriptionPurchaseEnvironment.Environment = SubscriptionPurchaseEnvironment.current) { + guard let window else { return } + + switch environment { + case .appStore: + window.show(.somethingWentWrongAlert()) + case .stripe: + window.show(.somethingWentWrongStripeAlert()) + } + + } + + @MainActor + func showSubscriptionNotFoundAlert() { + guard let window else { return } + + window.show(.subscriptionNotFoundAlert(), firstButtonAction: { + WindowControllersManager.shared.show(url: .purchaseSubscription, source: .ui, newTab: true) + }) + } + + @MainActor + func showSubscriptionInactiveAlert() { + guard let window else { return } + + window.show(.subscriptionInactiveAlert(), firstButtonAction: { + WindowControllersManager.shared.show(url: .purchaseSubscription, source: .ui, newTab: true) + }) + } + + @MainActor + func showSubscriptionFoundAlert(originalMessage: WKScriptMessage) { + guard let window else { return } + + window.show(.subscriptionFoundAlert(), firstButtonAction: { + if #available(macOS 12.0, *) { + Task { + _ = await AppStoreRestoreFlow.restoreAccountFromPastPurchase() + originalMessage.webView?.reload() + } + } + }) + } } #endif diff --git a/DuckDuckGo/Tab/UserScripts/UserScripts.swift b/DuckDuckGo/Tab/UserScripts/UserScripts.swift index 88160621f0..456cddec71 100644 --- a/DuckDuckGo/Tab/UserScripts/UserScripts.swift +++ b/DuckDuckGo/Tab/UserScripts/UserScripts.swift @@ -86,7 +86,7 @@ final class UserScripts: UserScriptsProvider { } #if SUBSCRIPTION - subscriptionPagesUserScript.registerSubfeature(delegate: SubscriptionPagesUseEmailFeature()) + subscriptionPagesUserScript.registerSubfeature(delegate: SubscriptionPagesUseSubscriptionFeature()) userScripts.append(subscriptionPagesUserScript) #endif } diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index 1192e49e25..2b44563da5 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -934,7 +934,7 @@ extension BrowserTabViewController: BrowserTabSelectionDelegate { extension BrowserTabViewController: OnboardingDelegate { func onboardingDidRequestImportData(completion: @escaping () -> Void) { - DataImportViewController.show(completion: completion) + DataImportView.show(completion: completion) } func onboardingDidRequestSetDefault(completion: @escaping () -> Void) { diff --git a/DuckDuckGo/YoutubePlayer/DuckPlayer.swift b/DuckDuckGo/YoutubePlayer/DuckPlayer.swift index 309d507d96..3e8ceb959e 100644 --- a/DuckDuckGo/YoutubePlayer/DuckPlayer.swift +++ b/DuckDuckGo/YoutubePlayer/DuckPlayer.swift @@ -24,10 +24,6 @@ import Navigation import WebKit import UserScript -extension NSImage { - static let duckPlayer: NSImage = #imageLiteral(resourceName: "DuckPlayer") -} - enum DuckPlayerMode: Equatable, Codable { case enabled, alwaysAsk, disabled diff --git a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift index 71f37ea3ba..3654968a57 100644 --- a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift +++ b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift @@ -133,7 +133,10 @@ final class DuckDuckGoVPNAppDelegate: NSObject, NSApplicationDelegate { OnboardingStatus(rawValue: rawValue) ?? .default }.eraseToAnyPublisher() + let model = StatusBarMenuModel(vpnSettings: .init(defaults: .netP)) + return StatusBarMenu( + model: model, onboardingStatusPublisher: onboardingStatusPublisher, statusReporter: statusReporter, controller: tunnelController, diff --git a/LocalPackages/Account/Package.swift b/LocalPackages/Account/Package.swift deleted file mode 100644 index 49f48da084..0000000000 --- a/LocalPackages/Account/Package.swift +++ /dev/null @@ -1,36 +0,0 @@ -// swift-tools-version: 5.8 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "Account", - platforms: [ .macOS("11.4") ], - products: [ - .library( - name: "Account", - targets: ["Account"]), - ], - dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "98.0.1"), - .package(path: "../Purchase") - ], - targets: [ - .target( - name: "Account", - dependencies: [ - .product(name: "BrowserServicesKit", package: "BrowserServicesKit"), - .product(name: "Purchase", package: "Purchase") - ], - swiftSettings: [ - .define("DEBUG", .when(configuration: .debug)) - ], - plugins: [.plugin(name: "SwiftLintPlugin", package: "BrowserServicesKit")] - ), - .testTarget( - name: "AccountTests", - dependencies: ["Account"], - plugins: [.plugin(name: "SwiftLintPlugin", package: "BrowserServicesKit")] - ), - ] -) diff --git a/LocalPackages/Account/Sources/Account/AuthService.swift b/LocalPackages/Account/Sources/Account/AuthService.swift deleted file mode 100644 index 616451dec1..0000000000 --- a/LocalPackages/Account/Sources/Account/AuthService.swift +++ /dev/null @@ -1,185 +0,0 @@ -// -// AuthService.swift -// -// Copyright © 2023 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 -import Common - -public struct AuthService { - - public enum Error: Swift.Error { - case decodingError - case encodingError - case serverError(description: String) - case unknownServerError - case connectionError - } - - private static let baseURL = URL(string: "https://quackdev.duckduckgo.com/api/auth")! - - private static let session = { - let configuration = URLSessionConfiguration.ephemeral - return URLSession(configuration: configuration) - }() - - // MARK: - - - public static func getAccessToken(token: String) async -> Result { - await executeAPICall(method: "GET", endpoint: "access-token", headers: makeAuthorizationHeader(for: token)) - } - - public struct AccessTokenResponse: Decodable { - public let accessToken: String - } - - // MARK: - - - public static func validateToken(accessToken: String) async -> Result { - await executeAPICall(method: "GET", endpoint: "validate-token", headers: makeAuthorizationHeader(for: accessToken)) - } - - public struct ValidateTokenResponse: Decodable { - public let account: Account - - public struct Account: Decodable { - public let email: String? - let entitlements: [Entitlement] - public let externalID: String - - enum CodingKeys: String, CodingKey { - case email, entitlements, externalID = "externalId" // no underscores due to keyDecodingStrategy = .convertFromSnakeCase - } - } - - struct Entitlement: Decodable { - let id: Int - let name: String - let product: String - } - } - - // MARK: - - - public static func createAccount() async -> Result { - await executeAPICall(method: "POST", endpoint: "account/create") - } - - public struct CreateAccountResponse: Decodable { - public let authToken: String - public let externalID: String - public let status: String - - enum CodingKeys: String, CodingKey { - case authToken = "authToken", externalID = "externalId", status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase - } - } - - // MARK: - - - public static func storeLogin(signature: String) async -> Result { - let bodyDict = ["signature": signature, - "store": "apple_app_store"] - - guard let bodyData = try? JSONEncoder().encode(bodyDict) else { return .failure(.encodingError) } - return await executeAPICall(method: "POST", endpoint: "store-login", body: bodyData) - } - - public struct StoreLoginResponse: Decodable { - public let authToken: String - public let email: String - public let externalID: String - public let id: Int - public let status: String - - enum CodingKeys: String, CodingKey { - case authToken = "authToken", email, externalID = "externalId", id, status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase - } - } - - // MARK: - Private API - - private static func executeAPICall(method: String, endpoint: String, headers: [String: String]? = nil, body: Data? = nil) async -> Result where T: Decodable { - let request = makeAPIRequest(method: method, endpoint: endpoint, headers: headers, body: body) - - do { - let (data, urlResponse) = try await session.data(for: request) - - printDebugInfo(method: method, endpoint: endpoint, data: data, response: urlResponse) - - if let httpResponse = urlResponse as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) { - if let decodedResponse = decode(T.self, from: data) { - return .success(decodedResponse) - } else { - return .failure(.decodingError) - } - } else { - if let decodedResponse = decode(ErrorResponse.self, from: data) { - let errorDescription = [method, endpoint, urlResponse.httpStatusCodeAsString ?? "", decodedResponse.error].joined(separator: " ") - return .failure(.serverError(description: errorDescription)) - } else { - return .failure(.unknownServerError) - } - } - } catch { - os_log("AuthService error: %{public}@", log: .error, error.localizedDescription) - return .failure(.connectionError) - } - } - - struct ErrorResponse: Decodable { - let error: String - } - - private static func makeAPIRequest(method: String, endpoint: String, headers: [String: String]?, body: Data?) -> URLRequest { - let url = baseURL.appendingPathComponent(endpoint) - var request = URLRequest(url: url) - request.httpMethod = method - if let headers = headers { - request.allHTTPHeaderFields = headers - } - if let body = body { - request.httpBody = body - } - - return request - } - - private static func decode(_: T.Type, from data: Data) -> T? where T: Decodable { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - - return try? decoder.decode(T.self, from: data) - } - - private static func printDebugInfo(method: String, endpoint: String, data: Data, response: URLResponse) { - let statusCode = (response as? HTTPURLResponse)!.statusCode - let stringData = String(data: data, encoding: .utf8) ?? "" - os_log("[%d] %{public}@ /%{public}@ :: %{public}@", log: .authService, statusCode, method, endpoint, stringData) - } - - private static func makeAuthorizationHeader(for token: String) -> [String: String] { - ["Authorization": "Bearer " + token] - } -} - -extension URLResponse { - - var httpStatusCodeAsString: String? { - guard let httpStatusCode = (self as? HTTPURLResponse)?.statusCode else { return nil } - return String(httpStatusCode) - } -} diff --git a/LocalPackages/Account/Sources/Account/Logging.swift b/LocalPackages/Account/Sources/Account/Logging.swift deleted file mode 100644 index 890c38a846..0000000000 --- a/LocalPackages/Account/Sources/Account/Logging.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// Logging.swift -// -// Copyright © 2023 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 -import Common - -struct Logging { - - static let subsystem = "com.duckduckgo.macos.browser.account" - - fileprivate static let accountLoggingEnabled = true - fileprivate static let account: OSLog = OSLog(subsystem: subsystem, category: "Account") - - fileprivate static let authServiceLoggingEnabled = true - fileprivate static let authService: OSLog = OSLog(subsystem: subsystem, category: "Account : AuthService") - - fileprivate static let errorsLoggingEnabled = true - fileprivate static let error: OSLog = OSLog(subsystem: subsystem, category: "Account : Errors") -} - -extension OSLog { - - public static var account: OSLog { - Logging.accountLoggingEnabled ? Logging.account : .disabled - } - - public static var authService: OSLog { - Logging.authServiceLoggingEnabled ? Logging.authService : .disabled - } - - public static var error: OSLog { - Logging.errorsLoggingEnabled ? Logging.error : .disabled - } -} diff --git a/LocalPackages/DataBrokerProtection/Package.swift b/LocalPackages/DataBrokerProtection/Package.swift index 435391c9df..367711f3cb 100644 --- a/LocalPackages/DataBrokerProtection/Package.swift +++ b/LocalPackages/DataBrokerProtection/Package.swift @@ -29,7 +29,7 @@ let package = Package( targets: ["DataBrokerProtection"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "98.0.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "99.0.2"), .package(path: "../PixelKit"), .package(path: "../SwiftUIExtensions"), .package(path: "../XPCHelper") diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift index 13c5900dad..cf6565d59c 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift @@ -253,32 +253,32 @@ extension DataBrokerProtectionPixels: PixelKitEvent { public var name: String { switch self { - case .parentChildMatches: return "dbp_macos_parent-child-broker-matches" + case .parentChildMatches: return "m_mac_dbp_macos_parent-child-broker-matches" // SLO and SLI Pixels: https://app.asana.com/0/1203581873609357/1205337273100857/f // Stage Pixels - case .optOutStart: return "dbp_macos_optout_stage_start" - case .optOutEmailGenerate: return "dbp_macos_optout_stage_email-generate" - case .optOutCaptchaParse: return "dbp_macos_optout_stage_captcha-parse" - case .optOutCaptchaSend: return "dbp_macos_optout_stage_captcha-send" - case .optOutCaptchaSolve: return "dbp_macos_optout_stage_captcha-solve" - case .optOutSubmit: return "dbp_macos_optout_stage_submit" - case .optOutEmailReceive: return "dbp_macos_optout_stage_email-receive" - case .optOutEmailConfirm: return "dbp_macos_optout_stage_email-confirm" - case .optOutValidate: return "dbp_macos_optout_stage_validate" - case .optOutFinish: return "dbp_macos_optout_stage_finish" + case .optOutStart: return "m_mac_dbp_macos_optout_stage_start" + case .optOutEmailGenerate: return "m_mac_dbp_macos_optout_stage_email-generate" + case .optOutCaptchaParse: return "m_mac_dbp_macos_optout_stage_captcha-parse" + case .optOutCaptchaSend: return "m_mac_dbp_macos_optout_stage_captcha-send" + case .optOutCaptchaSolve: return "m_mac_dbp_macos_optout_stage_captcha-solve" + case .optOutSubmit: return "m_mac_dbp_macos_optout_stage_submit" + case .optOutEmailReceive: return "m_mac_dbp_macos_optout_stage_email-receive" + case .optOutEmailConfirm: return "m_mac_dbp_macos_optout_stage_email-confirm" + case .optOutValidate: return "m_mac_dbp_macos_optout_stage_validate" + case .optOutFinish: return "m_mac_dbp_macos_optout_stage_finish" // Process Pixels - case .optOutSubmitSuccess: return "dbp_macos_optout_process_submit-success" - case .optOutSuccess: return "dbp_macos_optout_process_success" - case .optOutFailure: return "dbp_macos_optout_process_failure" + case .optOutSubmitSuccess: return "m_mac_dbp_macos_optout_process_submit-success" + case .optOutSuccess: return "m_mac_dbp_macos_optout_process_success" + case .optOutFailure: return "m_mac_dbp_macos_optout_process_failure" // Scan/Search pixels: https://app.asana.com/0/1203581873609357/1205337273100855/f - case .scanSuccess: return "dbp_macos_search_stage_main_status_success" - case .scanFailed: return "dbp_macos_search_stage_main_status_failure" - case .scanError: return "dbp_macos_search_stage_main_status_error" + case .scanSuccess: return "m_mac_dbp_macos_search_stage_main_status_success" + case .scanFailed: return "m_mac_dbp_macos_search_stage_main_status_failure" + case .scanError: return "m_mac_dbp_macos_search_stage_main_status_error" // Debug Pixels - case .error: return "data_broker_error" + case .error: return "m_mac_data_broker_error" case .backgroundAgentStarted: return "m_mac_dbp_background-agent_started" case .backgroundAgentStartedStoppingDueToAnotherInstanceRunning: return "m_mac_dbp_background-agent_started_stopping-due-to-another-instance-running" diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionProfileTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionProfileTests.swift index 65cf08664c..616e1a5fa9 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionProfileTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionProfileTests.swift @@ -175,7 +175,7 @@ final class DataBrokerProtectionProfileTests: XCTestCase { birthYear: 1980 ) - await database.save(profile) + _=await database.save(profile) XCTAssertTrue(vault.wasSaveProfileQueryCalled) XCTAssertFalse(vault.wasUpdateProfileQueryCalled) XCTAssertFalse(vault.wasDeleteProfileQueryCalled) @@ -217,7 +217,7 @@ final class DataBrokerProtectionProfileTests: XCTestCase { birthYear: 1980 ) - await database.save(newProfile) + _=await database.save(newProfile) XCTAssertTrue(vault.wasSaveProfileQueryCalled) XCTAssertTrue(vault.wasUpdateProfileQueryCalled) diff --git a/LocalPackages/LoginItems/Package.swift b/LocalPackages/LoginItems/Package.swift index 53da2ff762..f9f32c0b06 100644 --- a/LocalPackages/LoginItems/Package.swift +++ b/LocalPackages/LoginItems/Package.swift @@ -13,7 +13,7 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "98.0.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "99.0.2"), ], targets: [ .target( diff --git a/LocalPackages/NetworkProtectionMac/Package.swift b/LocalPackages/NetworkProtectionMac/Package.swift index cd031f9386..661052b917 100644 --- a/LocalPackages/NetworkProtectionMac/Package.swift +++ b/LocalPackages/NetworkProtectionMac/Package.swift @@ -30,7 +30,7 @@ let package = Package( .library(name: "NetworkProtectionUI", targets: ["NetworkProtectionUI"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "98.0.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "99.0.2"), .package(path: "../XPCHelper"), .package(path: "../SwiftUIExtensions") ], diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Menu/NetworkProtectionStatusBarMenu.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Menu/StatusBarMenu.swift similarity index 56% rename from LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Menu/NetworkProtectionStatusBarMenu.swift rename to LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Menu/StatusBarMenu.swift index 890d1bee8f..e76ad3b34b 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Menu/NetworkProtectionStatusBarMenu.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Menu/StatusBarMenu.swift @@ -1,5 +1,5 @@ // -// NetworkProtectionStatusBarMenu.swift +// StatusBarMenu.swift // // Copyright © 2022 DuckDuckGo. All rights reserved. // @@ -24,9 +24,12 @@ import NetworkProtection /// Abstraction of the the Network Protection status bar menu with a simple interface. /// -public final class StatusBarMenu { +@objc +public final class StatusBarMenu: NSObject { public typealias MenuItem = NetworkProtectionStatusView.Model.MenuItem + private let model: StatusBarMenuModel + private let statusItem: NSStatusItem private let popover: NetworkProtectionPopover @@ -43,28 +46,60 @@ public final class StatusBarMenu { /// - statusItem: (meant for testing) this allows us to inject our own status `NSStatusItem` to make automated testing easier.. /// @MainActor - public init(statusItem: NSStatusItem? = nil, + public init(model: StatusBarMenuModel, + statusItem: NSStatusItem? = nil, onboardingStatusPublisher: OnboardingStatusPublisher, statusReporter: NetworkProtectionStatusReporter, controller: TunnelController, iconProvider: IconProvider, menuItems: @escaping () -> [MenuItem]) { - self.statusItem = statusItem ?? NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + self.model = model + let statusItem = statusItem ?? NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + self.statusItem = statusItem self.iconPublisher = NetworkProtectionIconPublisher(statusReporter: statusReporter, iconProvider: iconProvider) - popover = NetworkProtectionPopover(controller: controller, onboardingStatusPublisher: onboardingStatusPublisher, statusReporter: statusReporter, menuItems: menuItems) + popover = NetworkProtectionPopover(controller: controller, + onboardingStatusPublisher: onboardingStatusPublisher, + statusReporter: statusReporter, + menuItems: menuItems) popover.behavior = .transient - self.statusItem.button?.image = .image(for: iconPublisher.icon) - self.statusItem.button?.target = self - self.statusItem.button?.action = #selector(statusBarButtonTapped) + super.init() + + statusItem.button?.image = .image(for: iconPublisher.icon) + statusItem.button?.target = self + statusItem.button?.action = #selector(statusBarButtonTapped) + statusItem.button?.sendAction(on: [.leftMouseUp, .rightMouseUp]) subscribeToIconUpdates() } @objc private func statusBarButtonTapped() { + let isOptionKeyPressed = NSApp.currentEvent?.modifierFlags.contains(.option) ?? false + let isRightClick = NSApp.currentEvent?.type == .rightMouseUp + + guard !isRightClick else { + showContextMenu() + return + } + + togglePopover(isOptionKeyPressed: isOptionKeyPressed) + } + + private func subscribeToIconUpdates() { + iconPublisherCancellable = iconPublisher.$icon + .receive(on: DispatchQueue.main) + .sink { [weak self] icon in + + self?.statusItem.button?.image = .image(for: icon) + } + } + + // MARK: - Popover + + private func togglePopover(isOptionKeyPressed: Bool) { if popover.isShown { popover.close() } else { @@ -72,17 +107,25 @@ public final class StatusBarMenu { return } + popover.setShowsDebugInformation(isOptionKeyPressed) popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY) } } - private func subscribeToIconUpdates() { - iconPublisherCancellable = iconPublisher.$icon - .receive(on: DispatchQueue.main) - .sink { [weak self] icon in + // MARK: - Context - self?.statusItem.button?.image = .image(for: icon) + private func showContextMenu() { + if popover.isShown { + popover.close() } + + let menu = NSMenu() + menu.delegate = self + menu.items = model.contextMenuItems + + menu.popUp(positioning: nil, + at: .zero, + in: statusItem.button) } // MARK: - Showing & Hiding the menu @@ -95,3 +138,12 @@ public final class StatusBarMenu { statusItem.isVisible = false } } + +extension StatusBarMenu: NSMenuDelegate { + public func menuDidClose(_ menu: NSMenu) { + // We need to remove the context menu when it's closed because otherwise + // macOS will bypass our custom click-handling code and will proceed directly + // to always showing the context menu (ignoring if it's a left or right click). + statusItem.menu = nil + } +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Menu/StatusBarMenuModel.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Menu/StatusBarMenuModel.swift new file mode 100644 index 0000000000..5ecd0b1a4e --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Menu/StatusBarMenuModel.swift @@ -0,0 +1,46 @@ +// +// StatusBarMenuModel.swift +// +// Copyright © 2023 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 Foundation +import NetworkProtection + +public final class StatusBarMenuModel { + private let vpnSettings: VPNSettings + + public init(vpnSettings: VPNSettings) { + self.vpnSettings = vpnSettings + } + + var hideVPNMenu: NSMenuItem { + let item = NSMenuItem(title: "Don’t Show VPN in Menu Bar", action: #selector(hideVPNMenuItemAction), keyEquivalent: "") + item.target = self + return item + } + + var contextMenuItems: [NSMenuItem] { + [ + hideVPNMenu + ] + } + + @objc + private func hideVPNMenuItemAction() { + vpnSettings.showInMenuBar = false + } +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/NetworkProtectionPopover.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/NetworkProtectionPopover.swift index a4668c3484..691baeb348 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/NetworkProtectionPopover.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/NetworkProtectionPopover.swift @@ -17,6 +17,7 @@ // import AppKit +import Combine import Foundation import SwiftUI import NetworkProtection @@ -43,6 +44,7 @@ public final class NetworkProtectionPopover: NSPopover { public typealias MenuItem = NetworkProtectionStatusView.Model.MenuItem + private let debugInformationPublisher = CurrentValueSubject(false) private let statusReporter: NetworkProtectionStatusReporter public required init(controller: TunnelController, @@ -57,7 +59,11 @@ public final class NetworkProtectionPopover: NSPopover { self.animates = false self.behavior = .semitransient - setupContentController(controller: controller, onboardingStatusPublisher: onboardingStatusPublisher, statusReporter: statusReporter, menuItems: menuItems) + setupContentController(controller: controller, + onboardingStatusPublisher: onboardingStatusPublisher, + statusReporter: statusReporter, + debugInformationPublisher: debugInformationPublisher.eraseToAnyPublisher(), + menuItems: menuItems) } required init?(coder: NSCoder) { @@ -67,11 +73,13 @@ public final class NetworkProtectionPopover: NSPopover { private func setupContentController(controller: TunnelController, onboardingStatusPublisher: OnboardingStatusPublisher, statusReporter: NetworkProtectionStatusReporter, + debugInformationPublisher: AnyPublisher, menuItems: @escaping () -> [MenuItem]) { let model = NetworkProtectionStatusView.Model(controller: controller, onboardingStatusPublisher: onboardingStatusPublisher, statusReporter: statusReporter, + debugInformationPublisher: debugInformationPublisher, menuItems: menuItems) let view = NetworkProtectionStatusView(model: model).environment(\.dismiss, { [weak self] in @@ -93,4 +101,10 @@ public final class NetworkProtectionPopover: NSPopover { statusReporter.forceRefresh() super.show(relativeTo: positioningRect, of: positioningView, preferredEdge: preferredEdge) } + + // MARK: - Debug Information + + func setShowsDebugInformation(_ showsDebugInformation: Bool) { + debugInformationPublisher.send(showsDebugInformation) + } } diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationView.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationView.swift new file mode 100644 index 0000000000..34ea0bb2b1 --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationView.swift @@ -0,0 +1,90 @@ +// +// DebugInformationView.swift +// +// Copyright © 2023 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 SwiftUI +import SwiftUIExtensions +import Combine +import NetworkProtection + +public struct DebugInformationView: View { + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.isEnabled) private var isEnabled + @Environment(\.dismiss) private var dismiss + + // MARK: - Model + + /// The view model that this instance will use. + /// + @ObservedObject var model: DebugInformationViewModel + + // MARK: - Initializers + + public init(model: DebugInformationViewModel) { + self.model = model + } + + // MARK: - View Contents + + public var body: some View { + Group { + VStack(alignment: .leading, spacing: 0) { + informationRow(title: "Bundle Path", details: model.bundlePath) + informationRow(title: "Version", details: model.version) + } + + Divider() + .padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9)) + } + } + + // MARK: - Composite Views + + private func informationRow(title: String, details: String) -> some View { + VStack(spacing: 2) { + HStack { + Text(title) + .padding(.leading, 24) + .opacity(0.6) + .font(.system(size: 12, weight: .bold, design: .default)) + .fixedSize() + + Spacer() + } + + HStack { + Text(details) + .makeSelectable() + .multilineText() + .padding(.leading, 24) + .opacity(0.6) + .font(.system(size: 12, weight: .regular, design: .default)) + + Spacer() + } + } + .padding(EdgeInsets(top: 4, leading: 10, bottom: 4, trailing: 9)) + } + + // MARK: - Rows + + private func dividerRow() -> some View { + Divider() + .padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9)) + } +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationViewModel.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationViewModel.swift new file mode 100644 index 0000000000..f8433a1c28 --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationViewModel.swift @@ -0,0 +1,43 @@ +// +// DebugInformationViewModel.swift +// +// Copyright © 2023 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 NetworkProtection +import SwiftUI + +@MainActor +public final class DebugInformationViewModel: ObservableObject { + + var bundlePath: String + var version: String + + // MARK: - Initialization & Deinitialization + + public init(bundle: Bundle = .main) { + bundlePath = bundle.bundlePath + + // swiftlint:disable:next force_cast + let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String + + // swiftlint:disable:next force_cast + let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String + + version = shortVersion + " (build: " + buildNumber + ")" + } +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift index ab6d1fcd26..9b6789efb0 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift @@ -65,6 +65,10 @@ public struct NetworkProtectionStatusView: View { TunnelControllerView(model: model.tunnelControllerViewModel) .disabled(model.tunnelControllerViewDisabled) + if model.showDebugInformation { + DebugInformationView(model: DebugInformationViewModel()) + } + bottomMenuView() } .padding(5) diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift index b7d1dc3b86..9cf37ffe37 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift @@ -66,6 +66,15 @@ extension NetworkProtectionStatusView { /// private let statusReporter: NetworkProtectionStatusReporter + /// The debug information publisher + /// + private let debugInformationPublisher: AnyPublisher + + /// Whether we're showing debug information + /// + @Published + var showDebugInformation: Bool + // MARK: - Extra Menu Items public let menuItems: () -> [MenuItem] @@ -90,12 +99,14 @@ extension NetworkProtectionStatusView { public init(controller: TunnelController, onboardingStatusPublisher: OnboardingStatusPublisher, statusReporter: NetworkProtectionStatusReporter, + debugInformationPublisher: AnyPublisher, menuItems: @escaping () -> [MenuItem], runLoopMode: RunLoop.Mode? = nil) { self.tunnelController = controller self.onboardingStatusPublisher = onboardingStatusPublisher self.statusReporter = statusReporter + self.debugInformationPublisher = debugInformationPublisher self.menuItems = menuItems self.runLoopMode = runLoopMode @@ -107,12 +118,14 @@ extension NetworkProtectionStatusView { isHavingConnectivityIssues = statusReporter.connectivityIssuesObserver.recentValue lastTunnelErrorMessage = statusReporter.connectionErrorObserver.recentValue lastControllerErrorMessage = statusReporter.controllerErrorMessageObserver.recentValue + showDebugInformation = false // Particularly useful when unit testing with an initial status of our choosing. subscribeToStatusChanges() subscribeToConnectivityIssues() subscribeToTunnelErrorMessages() subscribeToControllerErrorMessages() + subscribeToDebugInformationChanges() onboardingStatusPublisher .receive(on: DispatchQueue.main) @@ -166,6 +179,14 @@ extension NetworkProtectionStatusView { }.store(in: &cancellables) } + private func subscribeToDebugInformationChanges() { + debugInformationPublisher + .removeDuplicates() + .receive(on: DispatchQueue.main) + .assign(to: \.showDebugInformation, onWeaklyHeld: self) + .store(in: &cancellables) + } + // MARK: - Connection Status: Errors @Published diff --git a/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/NetworkProtectionStatusBarMenuTests.swift b/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/NetworkProtectionStatusBarMenuTests.swift index 56842e070e..d557ef5e5e 100644 --- a/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/NetworkProtectionStatusBarMenuTests.swift +++ b/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/NetworkProtectionStatusBarMenuTests.swift @@ -42,8 +42,12 @@ final class StatusBarMenuTests: XCTestCase { @MainActor func testShowStatusBarMenu() { + let defaults = UserDefaults(suiteName: UUID().uuidString)! let item = NSStatusItem() + let model = StatusBarMenuModel(vpnSettings: .init(defaults: defaults)) + let menu = StatusBarMenu( + model: model, statusItem: item, onboardingStatusPublisher: Just(OnboardingStatus.completed).eraseToAnyPublisher(), statusReporter: MockNetworkProtectionStatusReporter(), @@ -58,8 +62,12 @@ final class StatusBarMenuTests: XCTestCase { @MainActor func testHideStatusBarMenu() { + let defaults = UserDefaults(suiteName: UUID().uuidString)! let item = NSStatusItem() + let model = StatusBarMenuModel(vpnSettings: .init(defaults: defaults)) + let menu = StatusBarMenu( + model: model, statusItem: item, onboardingStatusPublisher: Just(OnboardingStatus.completed).eraseToAnyPublisher(), statusReporter: MockNetworkProtectionStatusReporter(), diff --git a/LocalPackages/PixelKit/Package.swift b/LocalPackages/PixelKit/Package.swift index f4230052ea..f84c9c457f 100644 --- a/LocalPackages/PixelKit/Package.swift +++ b/LocalPackages/PixelKit/Package.swift @@ -20,7 +20,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "98.0.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "99.0.2"), ], targets: [ .target( diff --git a/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift b/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift index 70bf4aae8a..ffd9d88796 100644 --- a/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift +++ b/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift @@ -32,6 +32,7 @@ public extension PixelKit { public static let errorDesc = "d" public static let errorCount = "c" public static let errorSource = "error_source" + public static let sourceBrowserVersion = "source_browser_version" public static let underlyingErrorCode = "ue" public static let underlyingErrorDomain = "underlyingErrorDomain" public static let underlyingErrorDesc = "ud" diff --git a/LocalPackages/Purchase/.gitignore b/LocalPackages/Purchase/.gitignore deleted file mode 100644 index 3b29812086..0000000000 --- a/LocalPackages/Purchase/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/LocalPackages/Purchase/Package.swift b/LocalPackages/Purchase/Package.swift deleted file mode 100644 index a61123f147..0000000000 --- a/LocalPackages/Purchase/Package.swift +++ /dev/null @@ -1,33 +0,0 @@ -// swift-tools-version: 5.8 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "Purchase", - platforms: [ .macOS("11.4") ], - products: [ - .library( - name: "Purchase", - targets: ["Purchase"] - ), - ], - dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "98.0.1"), - ], - targets: [ - .target( - name: "Purchase", - dependencies: [], - swiftSettings: [ - .define("DEBUG", .when(configuration: .debug)) - ], - plugins: [.plugin(name: "SwiftLintPlugin", package: "BrowserServicesKit")] - ), - .testTarget( - name: "PurchaseTests", - dependencies: ["Purchase"], - plugins: [.plugin(name: "SwiftLintPlugin", package: "BrowserServicesKit")] - ), - ] -) diff --git a/LocalPackages/Purchase/Sources/Purchase/PurchaseManager.swift b/LocalPackages/Purchase/Sources/Purchase/PurchaseManager.swift deleted file mode 100644 index 9c2b94ce8c..0000000000 --- a/LocalPackages/Purchase/Sources/Purchase/PurchaseManager.swift +++ /dev/null @@ -1,240 +0,0 @@ -// -// PurchaseManager.swift -// -// Copyright © 2023 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 -import StoreKit - -@available(macOS 12.0, *) typealias Transaction = StoreKit.Transaction -@available(macOS 12.0, *) typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo -@available(macOS 12.0, *) typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState - -public enum StoreError: Error { - case failedVerification -} - -@available(macOS 12.0, *) -@MainActor -public final class PurchaseManager: ObservableObject { - - static let productIdentifiers = ["subscription.1week", "subscription.1month", "subscription.1year", - "review.subscription.1week", "review.subscription.1month", "review.subscription.1year"] - - public static let shared = PurchaseManager() - - @Published public private(set) var availableProducts: [Product] = [] - @Published public private(set) var purchasedProductIDs: [String] = [] - @Published public private(set) var purchaseQueue: [String] = [] - - @Published private(set) var subscriptionGroupStatus: RenewalState? - - private var transactionUpdates: Task? - private var storefrontChanges: Task? - - public init() { - transactionUpdates = observeTransactionUpdates() - storefrontChanges = observeStorefrontChanges() - } - - deinit { - transactionUpdates?.cancel() - storefrontChanges?.cancel() - } - - @MainActor - func restorePurchases() { - Task { - do { - purchaseQueue.removeAll() - - print("Before AppStore.sync()") - - try await AppStore.sync() - - print("After AppStore.sync()") - - await updatePurchasedProducts() - await updateAvailableProducts() - } catch { - print("AppStore.sync error: \(error)") - } - } - } - - @MainActor - public func updateAvailableProducts() async { - print(" -- [PurchaseManager] updateAvailableProducts()") - - do { - let availableProducts = try await Product.products(for: Self.productIdentifiers) - print(" -- [PurchaseManager] updateAvailableProducts(): fetched \(availableProducts.count) products") - - if self.availableProducts != availableProducts { - print("availableProducts changed!") - self.availableProducts = availableProducts - } - } catch { - print("Error updating available products: \(error)") - } - } - - @MainActor - public func fetchAvailableProducts() async -> [Product] { - print(" -- [PurchaseManager] fetchAvailableProducts()") - - do { - let availableProducts = try await Product.products(for: Self.productIdentifiers) - print(" -- [PurchaseManager] fetchAvailableProducts(): fetched \(availableProducts.count) products") - - return availableProducts - } catch { - print("Error fetching available products: \(error)") - return [] - } - } - - @MainActor - public func updatePurchasedProducts() async { - print(" -- [PurchaseManager] updatePurchasedProducts()") - - var purchasedSubscriptions: [String] = [] - - do { - for await result in Transaction.currentEntitlements { - let transaction = try checkVerified(result) - - guard transaction.productType == .autoRenewable else { continue } - guard transaction.revocationDate == nil else { continue } - - if let expirationDate = transaction.expirationDate, expirationDate > .now { - purchasedSubscriptions.append(transaction.productID) - - if let token = transaction.appAccountToken { - print(" -- [PurchaseManager] updatePurchasedProducts(): \(transaction.productID) -- custom UUID: \(token)" ) - } - } - } - } catch { - print("Error updating purchased products: \(error)") - } - - print(" -- [PurchaseManager] updatePurchasedProducts(): have \(purchasedSubscriptions.count) active subscriptions") - - if self.purchasedProductIDs != purchasedSubscriptions { - print("purchasedSubscriptions changed!") - self.purchasedProductIDs = purchasedSubscriptions - } - - subscriptionGroupStatus = try? await availableProducts.first?.subscription?.status.first?.state - } - - @MainActor - public static func mostRecentTransaction() async -> String? { - print(" -- [PurchaseManager] updatePurchasedProducts()") - - var transactions: [VerificationResult] = [] - - for await result in Transaction.all { - transactions.append(result) - } - - print(" -- [PurchaseManager] mostRecentTransaction(): fetched \(transactions.count) transactions") - - return transactions.first?.jwsRepresentation - } - - @MainActor - public func purchase(_ product: Product, customUUID: String) { - print(" -- [PurchaseManager] buy: \(product.displayName) (customUUID: \(customUUID))") - - print("purchaseQueue append!") - purchaseQueue.append(product.id) - - Task { - print(" -- [PurchaseManager] starting await task") - - var options: Set = Set() - - if let token = UUID(uuidString: customUUID) { - options.insert(.appAccountToken(token)) - } - - let result = try await product.purchase(options: options) - - print(" -- [PurchaseManager] receiving await task result") - purchaseQueue.removeAll() - print("purchaseQueue removeAll!") - - switch result { - case let .success(.verified(transaction)): - // Successful purchase - await transaction.finish() - await self.updatePurchasedProducts() - case let .success(.unverified(_, error)): - // Successful purchase but transaction/receipt can't be verified - // Could be a jailbroken phone - print("Error: \(error.localizedDescription)") - case .pending: - // Transaction waiting on SCA (Strong Customer Authentication) or - // approval from Ask to Buy - break - case .userCancelled: - break - @unknown default: - break - } - } - } - - private func checkVerified(_ result: VerificationResult) throws -> T { - // Check whether the JWS passes StoreKit verification. - switch result { - case .unverified: - // StoreKit parses the JWS, but it fails verification. - throw StoreError.failedVerification - case .verified(let safe): - // The result is verified. Return the unwrapped value. - return safe - } - } - - private func observeTransactionUpdates() -> Task { - - Task.detached { [unowned self] in - for await result in Transaction.updates { - print(" -- [PurchaseManager] observeTransactionUpdates()") - - if case .verified(let transaction) = result { - await transaction.finish() - } - - await self.updatePurchasedProducts() - } - } - } - - private func observeStorefrontChanges() -> Task { - - Task.detached { [unowned self] in - for await result in Storefront.updates { - print(" -- [PurchaseManager] observeStorefrontChanges(): \(result.countryCode)") - await updatePurchasedProducts() - await updateAvailableProducts() - } - } - } -} diff --git a/LocalPackages/Subscription/.gitignore b/LocalPackages/Subscription/.gitignore index 3b29812086..0023a53406 100644 --- a/LocalPackages/Subscription/.gitignore +++ b/LocalPackages/Subscription/.gitignore @@ -1,9 +1,8 @@ .DS_Store /.build /Packages -/*.xcodeproj xcuserdata/ DerivedData/ -.swiftpm/config/registries.json +.swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc diff --git a/LocalPackages/Subscription/Package.swift b/LocalPackages/Subscription/Package.swift index 02148554e7..ed2768e73d 100644 --- a/LocalPackages/Subscription/Package.swift +++ b/LocalPackages/Subscription/Package.swift @@ -12,21 +12,13 @@ let package = Package( targets: ["Subscription"]), ], dependencies: [ - .package(path: "../Account"), - .package(path: "../Purchase"), - .package(path: "../SwiftUIExtensions"), - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "98.0.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "99.0.2"), ], targets: [ .target( name: "Subscription", dependencies: [ - .product(name: "Account", package: "Account"), - .product(name: "Purchase", package: "Purchase"), - .product(name: "SwiftUIExtensions", package: "SwiftUIExtensions") - ], - resources: [ - .process("Resources") + .product(name: "BrowserServicesKit", package: "BrowserServicesKit"), ], swiftSettings: [ .define("DEBUG", .when(configuration: .debug)) diff --git a/LocalPackages/Account/Sources/Account/AccountManager.swift b/LocalPackages/Subscription/Sources/Subscription/AccountManager.swift similarity index 56% rename from LocalPackages/Account/Sources/Account/AccountManager.swift rename to LocalPackages/Subscription/Sources/Subscription/AccountManager.swift index 8c3fe49ba3..e234708385 100644 --- a/LocalPackages/Account/Sources/Account/AccountManager.swift +++ b/LocalPackages/Subscription/Sources/Subscription/AccountManager.swift @@ -17,12 +17,11 @@ // import Foundation -import Purchase import Common public extension Notification.Name { - static let accountDidSignIn = Notification.Name("com.duckduckgo.browserServicesKit.AccountDidSignIn") - static let accountDidSignOut = Notification.Name("com.duckduckgo.browserServicesKit.AccountDidSignOut") + static let accountDidSignIn = Notification.Name("com.duckduckgo.subscription.AccountDidSignIn") + static let accountDidSignOut = Notification.Name("com.duckduckgo.subscription.AccountDidSignOut") } public protocol AccountManagerKeychainAccessDelegate: AnyObject { @@ -31,7 +30,7 @@ public protocol AccountManagerKeychainAccessDelegate: AnyObject { public protocol AccountManaging { - var token: String? { get } + var accessToken: String? { get } } @@ -40,20 +39,20 @@ public class AccountManager: AccountManaging { private let storage: AccountStorage public weak var delegate: AccountManagerKeychainAccessDelegate? - public var isSignedIn: Bool { - return token != nil + public var isUserAuthenticated: Bool { + return accessToken != nil } public init(storage: AccountStorage = AccountKeychainStorage()) { self.storage = storage } - public var token: String? { + public var authToken: String? { do { - return try storage.getToken() + return try storage.getAuthToken() } catch { if let error = error as? AccountKeychainAccessError { - delegate?.accountManagerKeychainAccessFailed(accessType: .getToken, error: error) + delegate?.accountManagerKeychainAccessFailed(accessType: .getAuthToken, error: error) } else { assertionFailure("Expected AccountKeychainAccessError") } @@ -62,12 +61,12 @@ public class AccountManager: AccountManaging { } } - public var shortLivedToken: String? { + public var accessToken: String? { do { - return try storage.getShortLivedToken() + return try storage.getAccessToken() } catch { if let error = error as? AccountKeychainAccessError { - delegate?.accountManagerKeychainAccessFailed(accessType: .getShortLivedToken, error: error) + delegate?.accountManagerKeychainAccessFailed(accessType: .getAccessToken, error: error) } else { assertionFailure("Expected AccountKeychainAccessError") } @@ -104,12 +103,14 @@ public class AccountManager: AccountManaging { } } - public func storeShortLivedToken(token: String) { + public func storeAuthToken(token: String) { + os_log(.info, log: .subscription, "[AccountManager] storeAuthToken") + do { - try storage.store(shortLivedToken: token) + try storage.store(authToken: token) } catch { if let error = error as? AccountKeychainAccessError { - delegate?.accountManagerKeychainAccessFailed(accessType: .storeToken, error: error) + delegate?.accountManagerKeychainAccessFailed(accessType: .storeAuthToken, error: error) } else { assertionFailure("Expected AccountKeychainAccessError") } @@ -117,12 +118,13 @@ public class AccountManager: AccountManaging { } public func storeAccount(token: String, email: String?, externalID: String?) { - os_log("AccountManager: storeAccount token: %@ email: %@ externalID:%@", log: .account, token, email ?? "nil", externalID ?? "nil") + os_log(.info, log: .subscription, "[AccountManager] storeAccount") + do { - try storage.store(token: token) + try storage.store(accessToken: token) } catch { if let error = error as? AccountKeychainAccessError { - delegate?.accountManagerKeychainAccessFailed(accessType: .storeToken, error: error) + delegate?.accountManagerKeychainAccessFailed(accessType: .storeAccessToken, error: error) } else { assertionFailure("Expected AccountKeychainAccessError") } @@ -147,11 +149,12 @@ public class AccountManager: AccountManaging { assertionFailure("Expected AccountKeychainAccessError") } } - NotificationCenter.default.post(name: .accountDidSignIn, object: self, userInfo: nil) } public func signOut() { + os_log(.info, log: .subscription, "[AccountManager] signOut") + do { try storage.clearAuthenticationState() } catch { @@ -167,52 +170,81 @@ public class AccountManager: AccountManaging { // MARK: - - public func signInByRestoringPastPurchases() { - if #available(macOS 12.0, *) { - Task { - // Fetch most recent purchase - guard let jwsRepresentation = await PurchaseManager.mostRecentTransaction() else { return } - - // Do the store login to get short-lived token - let shortLivedToken: String - switch await AuthService.storeLogin(signature: jwsRepresentation) { - case .success(let response): - shortLivedToken = response.authToken - case .failure(let error): - os_log("AccountManager error: %{public}@", log: .error, error.localizedDescription) - return - } - - storeShortLivedToken(token: shortLivedToken) - exchangeTokenAndRefreshEntitlements(with: shortLivedToken) - } + public func hasEntitlement(for name: String) async -> Bool { + await fetchEntitlements().contains(name) + } + + public func fetchEntitlements() async -> [String] { + guard let accessToken else { return [] } + + switch await AuthService.validateToken(accessToken: accessToken) { + case .success(let response): + let entitlements = response.account.entitlements + return entitlements.map { $0.name } + + case .failure(let error): + os_log(.error, log: .subscription, "[AccountManager] fetchEntitlements error: %{public}@", error.localizedDescription) + return [] } } - public func exchangeTokenAndRefreshEntitlements(with shortLivedToken: String) { - Task { - // Exchange short-lived token to a long-lived one - let longLivedToken: String - switch await AuthService.getAccessToken(token: shortLivedToken) { - case .success(let response): - longLivedToken = response.accessToken - case .failure(let error): - os_log("AccountManager error: %{public}@", log: .error, error.localizedDescription) - return - } + public func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result { + switch await AuthService.getAccessToken(token: authToken) { + case .success(let response): + return .success(response.accessToken) + case .failure(let error): + os_log(.error, log: .subscription, "[AccountManager] exchangeAuthTokenToAccessToken error: %{public}@", error.localizedDescription) + return .failure(error) + } + } + + public typealias AccountDetails = (email: String?, externalID: String) + + public func fetchAccountDetails(with accessToken: String) async -> Result { + switch await AuthService.validateToken(accessToken: accessToken) { + case .success(let response): + return .success(AccountDetails(email: response.account.email, externalID: response.account.externalID)) + case .failure(let error): + os_log(.error, log: .subscription, "[AccountManager] fetchAccountDetails error: %{public}@", error.localizedDescription) + return .failure(error) + } + } + + public func checkSubscriptionState() async { + os_log(.info, log: .subscription, "[AccountManager] checkSubscriptionState") + + guard let token = accessToken else { return } - // Fetch entitlements and account details and store the data - switch await AuthService.validateToken(accessToken: longLivedToken) { - case .success(let response): - self.storeShortLivedToken(token: shortLivedToken) - self.storeAccount(token: longLivedToken, - email: response.account.email, - externalID: response.account.externalID) - - case .failure(let error): - os_log("AccountManager error: %{public}@", log: .error, error.localizedDescription) - return + if case .success(let response) = await SubscriptionService.getSubscriptionDetails(token: token) { + if !response.isSubscriptionActive { + signOut() } } } + + @discardableResult + public static func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool { + var count = 0 + var hasEntitlements = false + + repeat { + hasEntitlements = await !AccountManager().fetchEntitlements().isEmpty + + if hasEntitlements { + break + } else { + count += 1 + try? await Task.sleep(seconds: waitTime) + } + } while !hasEntitlements && count < retryCount + + return hasEntitlements + } +} + +extension Task where Success == Never, Failure == Never { + static func sleep(seconds: Double) async throws { + let duration = UInt64(seconds * 1_000_000_000) + try await Task.sleep(nanoseconds: duration) + } } diff --git a/LocalPackages/Account/Sources/Account/AccountStorage/AccountKeychainStorage.swift b/LocalPackages/Subscription/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift similarity index 89% rename from LocalPackages/Account/Sources/Account/AccountStorage/AccountKeychainStorage.swift rename to LocalPackages/Subscription/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift index a9c823dae4..19596f764e 100644 --- a/LocalPackages/Account/Sources/Account/AccountStorage/AccountKeychainStorage.swift +++ b/LocalPackages/Subscription/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift @@ -19,10 +19,10 @@ import Foundation public enum AccountKeychainAccessType: String { - case getToken - case storeToken - case getShortLivedToken - case storeShortLivedToken + case getAuthToken + case storeAuthToken + case getAccessToken + case storeAccessToken case getEmail case storeEmail case getExternalID @@ -52,34 +52,26 @@ public class AccountKeychainStorage: AccountStorage { public init() {} - public func getToken() throws -> String? { - try Self.getString(forField: .token) + public func getAuthToken() throws -> String? { + try Self.getString(forField: .authToken) } - public func store(token: String) throws { - try Self.set(string: token, forField: .token) + public func store(authToken: String) throws { + try Self.set(string: authToken, forField: .authToken) } - public func getShortLivedToken() throws -> String? { - try Self.getString(forField: .shortLivedToken) + public func getAccessToken() throws -> String? { + try Self.getString(forField: .accessToken) } - public func store(shortLivedToken: String) throws { - try Self.set(string: shortLivedToken, forField: .shortLivedToken) + public func store(accessToken: String) throws { + try Self.set(string: accessToken, forField: .accessToken) } public func getEmail() throws -> String? { try Self.getString(forField: .email) } - public func store(email: String?) throws { - if let email = email, !email.isEmpty { - try Self.set(string: email, forField: .email) - } else { - try Self.deleteItem(forField: .email) - } - } - public func getExternalID() throws -> String? { try Self.getString(forField: .externalID) } @@ -92,9 +84,17 @@ public class AccountKeychainStorage: AccountStorage { } } + public func store(email: String?) throws { + if let email = email, !email.isEmpty { + try Self.set(string: email, forField: .email) + } else { + try Self.deleteItem(forField: .email) + } + } + public func clearAuthenticationState() throws { - try Self.deleteItem(forField: .token) - try Self.deleteItem(forField: .shortLivedToken) + try Self.deleteItem(forField: .authToken) + try Self.deleteItem(forField: .accessToken) try Self.deleteItem(forField: .email) try Self.deleteItem(forField: .externalID) } @@ -108,8 +108,8 @@ private extension AccountKeychainStorage { multiple accounts/tokens at the same time */ enum AccountKeychainField: String, CaseIterable { - case token = "account.token" - case shortLivedToken = "account.shortLivedToken" + case authToken = "account.authToken" + case accessToken = "account.accessToken" case email = "account.email" case externalID = "account.external_id" diff --git a/LocalPackages/Account/Sources/Account/AccountStorage/AccountStorage.swift b/LocalPackages/Subscription/Sources/Subscription/AccountStorage/AccountStorage.swift similarity index 84% rename from LocalPackages/Account/Sources/Account/AccountStorage/AccountStorage.swift rename to LocalPackages/Subscription/Sources/Subscription/AccountStorage/AccountStorage.swift index c91fd9c956..06b5e05cb5 100644 --- a/LocalPackages/Account/Sources/Account/AccountStorage/AccountStorage.swift +++ b/LocalPackages/Subscription/Sources/Subscription/AccountStorage/AccountStorage.swift @@ -19,10 +19,10 @@ import Foundation public protocol AccountStorage: AnyObject { - func getToken() throws -> String? - func store(token: String) throws - func getShortLivedToken() throws -> String? - func store(shortLivedToken: String) throws + func getAuthToken() throws -> String? + func store(authToken: String) throws + func getAccessToken() throws -> String? + func store(accessToken: String) throws func getEmail() throws -> String? func store(email: String?) throws func getExternalID() throws -> String? diff --git a/LocalPackages/Subscription/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift b/LocalPackages/Subscription/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift new file mode 100644 index 0000000000..7b502d2147 --- /dev/null +++ b/LocalPackages/Subscription/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift @@ -0,0 +1,58 @@ +// +// AppStoreAccountManagementFlow.swift +// +// Copyright © 2023 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 +import StoreKit +import Common + +@available(macOS 12.0, iOS 15.0, *) +public final class AppStoreAccountManagementFlow { + + public enum Error: Swift.Error { + case noPastTransaction + case authenticatingWithTransactionFailed + } + + @discardableResult + public static func refreshAuthTokenIfNeeded() async -> Result { + os_log(.info, log: .subscription, "[AppStoreAccountManagementFlow] refreshAuthTokenIfNeeded") + + var authToken = AccountManager().authToken ?? "" + + // Check if auth token if still valid + if case let .failure(validateTokenError) = await AuthService.validateToken(accessToken: authToken) { + os_log(.error, log: .subscription, "[AppStoreAccountManagementFlow] validateToken error: %{public}s", String(reflecting: validateTokenError)) + + // In case of invalid token attempt store based authentication to obtain a new one + guard let lastTransactionJWSRepresentation = await PurchaseManager.mostRecentTransaction() else { return .failure(.noPastTransaction) } + + switch await AuthService.storeLogin(signature: lastTransactionJWSRepresentation) { + case .success(let response): + if response.externalID == AccountManager().externalID { + authToken = response.authToken + AccountManager().storeAuthToken(token: authToken) + } + case .failure(let storeLoginError): + os_log(.error, log: .subscription, "[AppStoreAccountManagementFlow] storeLogin error: %{public}s", String(reflecting: storeLoginError)) + return .failure(.authenticatingWithTransactionFailed) + } + } + + return .success(authToken) + } +} diff --git a/LocalPackages/Subscription/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift b/LocalPackages/Subscription/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift new file mode 100644 index 0000000000..ef822341b0 --- /dev/null +++ b/LocalPackages/Subscription/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift @@ -0,0 +1,113 @@ +// +// AppStorePurchaseFlow.swift +// +// Copyright © 2023 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 +import StoreKit +import Common + +@available(macOS 12.0, iOS 15.0, *) +public final class AppStorePurchaseFlow { + + public enum Error: Swift.Error { + case noProductsFound + case activeSubscriptionAlreadyPresent + case authenticatingWithTransactionFailed + case accountCreationFailed + case purchaseFailed + case missingEntitlements + } + + public static func subscriptionOptions() async -> Result { + os_log(.info, log: .subscription, "[AppStorePurchaseFlow] subscriptionOptions") + + let products = PurchaseManager.shared.availableProducts + + let monthly = products.first(where: { $0.id.contains("1month") }) + let yearly = products.first(where: { $0.id.contains("1year") }) + + guard let monthly, let yearly else { + os_log(.error, log: .subscription, "[AppStorePurchaseFlow] Error: noProductsFound") + return .failure(.noProductsFound) + } + + let options = [SubscriptionOption(id: monthly.id, cost: .init(displayPrice: monthly.displayPrice, recurrence: "monthly")), + SubscriptionOption(id: yearly.id, cost: .init(displayPrice: yearly.displayPrice, recurrence: "yearly"))] + + let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) } + + return .success(SubscriptionOptions(platform: SubscriptionPlatformName.macos.rawValue, + options: options, + features: features)) + } + + public static func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result { + os_log(.info, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription") + + let accountManager = AccountManager() + let externalID: String + + // Check for past transactions most recent + switch await AppStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + os_log(.info, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription -> restoreAccountFromPastPurchase: activeSubscriptionAlreadyPresent") + return .failure(.activeSubscriptionAlreadyPresent) + case .failure(let error): + os_log(.info, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription -> restoreAccountFromPastPurchase: %{public}s", String(reflecting: error)) + switch error { + case .subscriptionExpired(let expiredAccountDetails): + externalID = expiredAccountDetails.externalID + accountManager.storeAuthToken(token: expiredAccountDetails.authToken) + accountManager.storeAccount(token: expiredAccountDetails.accessToken, email: expiredAccountDetails.email, externalID: expiredAccountDetails.externalID) + default: + // No history, create new account + switch await AuthService.createAccount(emailAccessToken: emailAccessToken) { + case .success(let response): + externalID = response.externalID + + if case let .success(accessToken) = await accountManager.exchangeAuthTokenToAccessToken(response.authToken), + case let .success(accountDetails) = await accountManager.fetchAccountDetails(with: accessToken) { + accountManager.storeAuthToken(token: response.authToken) + accountManager.storeAccount(token: accessToken, email: accountDetails.email, externalID: accountDetails.externalID) + } + case .failure(let error): + os_log(.error, log: .subscription, "[AppStorePurchaseFlow] createAccount error: %{public}s", String(reflecting: error)) + return .failure(.accountCreationFailed) + } + } + } + + // Make the purchase + switch await PurchaseManager.shared.purchaseSubscription(with: subscriptionIdentifier, externalID: externalID) { + case .success: + return .success(()) + case .failure(let error): + os_log(.error, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription error: %{public}s", String(reflecting: error)) + AccountManager().signOut() + return .failure(.purchaseFailed) + } + } + + @discardableResult + public static func completeSubscriptionPurchase() async -> Result { + os_log(.info, log: .subscription, "[AppStorePurchaseFlow] completeSubscriptionPurchase") + + let result = await AccountManager.checkForEntitlements(wait: 2.0, retry: 20) + + return result ? .success(PurchaseUpdate(type: "completed")) : .failure(.missingEntitlements) + } +} diff --git a/LocalPackages/Subscription/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift b/LocalPackages/Subscription/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift new file mode 100644 index 0000000000..c0ee4df79c --- /dev/null +++ b/LocalPackages/Subscription/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift @@ -0,0 +1,99 @@ +// +// AppStoreRestoreFlow.swift +// +// Copyright © 2023 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 +import StoreKit +import Common + +@available(macOS 12.0, iOS 15.0, *) +public final class AppStoreRestoreFlow { + + public typealias RestoredAccountDetails = (authToken: String, accessToken: String, externalID: String, email: String?) + + public enum Error: Swift.Error { + case missingAccountOrTransactions + case pastTransactionAuthenticationError + case failedToObtainAccessToken + case failedToFetchAccountDetails + case failedToFetchSubscriptionDetails + case subscriptionExpired(accountDetails: RestoredAccountDetails) + } + + public static func restoreAccountFromPastPurchase() async -> Result { + os_log(.info, log: .subscription, "[AppStoreRestoreFlow] restoreAccountFromPastPurchase") + + guard let lastTransactionJWSRepresentation = await PurchaseManager.mostRecentTransaction() else { + os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: missingAccountOrTransactions") + return .failure(.missingAccountOrTransactions) + } + + let accountManager = AccountManager() + + // Do the store login to get short-lived token + let authToken: String + + switch await AuthService.storeLogin(signature: lastTransactionJWSRepresentation) { + case .success(let response): + authToken = response.authToken + case .failure: + os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: pastTransactionAuthenticationError") + return .failure(.pastTransactionAuthenticationError) + } + + let accessToken: String + let email: String? + let externalID: String + + switch await accountManager.exchangeAuthTokenToAccessToken(authToken) { + case .success(let exchangedAccessToken): + accessToken = exchangedAccessToken + case .failure: + os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: failedToObtainAccessToken") + return .failure(.failedToObtainAccessToken) + } + + switch await accountManager.fetchAccountDetails(with: accessToken) { + case .success(let accountDetails): + email = accountDetails.email + externalID = accountDetails.externalID + case .failure: + os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: failedToFetchAccountDetails") + return .failure(.failedToFetchAccountDetails) + } + + var isSubscriptionActive = false + + switch await SubscriptionService.getSubscriptionDetails(token: accessToken) { + case .success(let response): + isSubscriptionActive = response.isSubscriptionActive + case .failure: + os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: failedToFetchSubscriptionDetails") + return .failure(.failedToFetchSubscriptionDetails) + } + + if isSubscriptionActive { + accountManager.storeAuthToken(token: authToken) + accountManager.storeAccount(token: accessToken, email: email, externalID: externalID) + return .success(()) + } else { + let details = RestoredAccountDetails(authToken: authToken, accessToken: accessToken, externalID: externalID, email: email) + os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: subscriptionExpired") + return .failure(.subscriptionExpired(accountDetails: details)) + } + } +} diff --git a/LocalPackages/Subscription/Sources/Subscription/Flows/PurchaseFlow.swift b/LocalPackages/Subscription/Sources/Subscription/Flows/PurchaseFlow.swift new file mode 100644 index 0000000000..f32765ff96 --- /dev/null +++ b/LocalPackages/Subscription/Sources/Subscription/Flows/PurchaseFlow.swift @@ -0,0 +1,68 @@ +// +// PurchaseFlow.swift +// +// Copyright © 2023 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 + +public struct SubscriptionOptions: Encodable { + let platform: String + let options: [SubscriptionOption] + let features: [SubscriptionFeature] +} + +public struct SubscriptionOption: Encodable { + let id: String + let cost: SubscriptionOptionCost +} + +struct SubscriptionOptionCost: Encodable { + let displayPrice: String + let recurrence: String +} + +public struct SubscriptionFeature: Encodable { + let name: String +} + +// MARK: - + +public enum SubscriptionFeatureName: String, CaseIterable { + case privateBrowsing = "private-browsing" + case privateSearch = "private-search" + case emailProtection = "email-protection" + case appTrackingProtection = "app-tracking-protection" + case vpn = "vpn" + case personalInformationRemoval = "personal-information-removal" + case identityTheftRestoration = "identity-theft-restoration" +} + +public enum SubscriptionPlatformName: String { + case macos + case stripe +} + +// MARK: - + +public struct PurchaseUpdate: Codable { + let type: String + let token: String? + + public init(type: String, token: String? = nil) { + self.type = type + self.token = token + } +} diff --git a/LocalPackages/Subscription/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift b/LocalPackages/Subscription/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift new file mode 100644 index 0000000000..9bd19a0341 --- /dev/null +++ b/LocalPackages/Subscription/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift @@ -0,0 +1,96 @@ +// +// StripePurchaseFlow.swift +// +// Copyright © 2023 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 +import StoreKit +import Common + +public final class StripePurchaseFlow { + + public enum Error: Swift.Error { + case noProductsFound + case accountCreationFailed + } + + public static func subscriptionOptions() async -> Result { + os_log(.info, log: .subscription, "[StripePurchaseFlow] subscriptionOptions") + + guard case let .success(products) = await SubscriptionService.getProducts(), !products.isEmpty else { + os_log(.error, log: .subscription, "[StripePurchaseFlow] Error: noProductsFound") + return .failure(.noProductsFound) + } + + let currency = products.first?.currency ?? "USD" + + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = Locale(identifier: "en_US@currency=\(currency)") + + let options: [SubscriptionOption] = products.map { + var displayPrice = "\($0.price) \($0.currency)" + + if let price = Float($0.price), let formattedPrice = formatter.string(from: price as NSNumber) { + displayPrice = formattedPrice + } + + let cost = SubscriptionOptionCost(displayPrice: displayPrice, recurrence: $0.billingPeriod.lowercased()) + + return SubscriptionOption(id: $0.productId, + cost: cost) + } + + let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) } + + return .success(SubscriptionOptions(platform: SubscriptionPlatformName.stripe.rawValue, + options: options, + features: features)) + } + + public static func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result { + os_log(.info, log: .subscription, "[StripePurchaseFlow] prepareSubscriptionPurchase") + + var authToken: String = "" + + switch await AuthService.createAccount(emailAccessToken: emailAccessToken) { + case .success(let response): + authToken = response.authToken + AccountManager().storeAuthToken(token: authToken) + case .failure: + os_log(.error, log: .subscription, "[StripePurchaseFlow] Error: accountCreationFailed") + return .failure(.accountCreationFailed) + } + + return .success(PurchaseUpdate(type: "redirect", token: authToken)) + } + + public static func completeSubscriptionPurchase() async { + os_log(.info, log: .subscription, "[StripePurchaseFlow] completeSubscriptionPurchase") + + let accountManager = AccountManager() + + if let authToken = accountManager.authToken { + if case let .success(accessToken) = await accountManager.exchangeAuthTokenToAccessToken(authToken), + case let .success(accountDetails) = await accountManager.fetchAccountDetails(with: accessToken) { + accountManager.storeAuthToken(token: authToken) + accountManager.storeAccount(token: accessToken, email: accountDetails.email, externalID: accountDetails.externalID) + } + } + + await AccountManager.checkForEntitlements(wait: 2.0, retry: 5) + } +} diff --git a/LocalPackages/Subscription/Sources/Subscription/NSNotificationName+Subscription.swift b/LocalPackages/Subscription/Sources/Subscription/NSNotificationName+Subscription.swift new file mode 100644 index 0000000000..20fc2f7b17 --- /dev/null +++ b/LocalPackages/Subscription/Sources/Subscription/NSNotificationName+Subscription.swift @@ -0,0 +1,30 @@ +// +// NSNotificationName+Subscription.swift +// +// Copyright © 2023 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 + +public extension NSNotification.Name { + + static let openPrivateBrowsing = Notification.Name("com.duckduckgo.subscription.open.private-browsing") + static let openPrivateSearch = Notification.Name("com.duckduckgo.subscription.open.private-search") + static let openEmailProtection = Notification.Name("com.duckduckgo.subscription.open.email-protection") + static let openAppTrackingProtection = Notification.Name("com.duckduckgo.subscription.open.app-tracking-protection") + static let openVPN = Notification.Name("com.duckduckgo.subscription.open.vpn") + static let openPersonalInformationRemoval = Notification.Name("com.duckduckgo.subscription.open.personal-information-removal") + static let openIdentityTheftRestoration = Notification.Name("com.duckduckgo.subscription.open.identity-theft-restoration") +} diff --git a/LocalPackages/Subscription/Sources/Subscription/Preferences/PreferencesSubscriptionModel.swift b/LocalPackages/Subscription/Sources/Subscription/Preferences/PreferencesSubscriptionModel.swift deleted file mode 100644 index e231246b8e..0000000000 --- a/LocalPackages/Subscription/Sources/Subscription/Preferences/PreferencesSubscriptionModel.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// PreferencesSubscriptionModel.swift -// -// Copyright © 2023 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 -import Account -import Combine - -extension URL { - - static var purchaseSubscription: URL { - URL(string: "https://duckduckgo.com/about")! - } - - static var subscriptionFAQ: URL { - URL(string: "https://duckduckgo.com/about")! - } -} - -public final class PreferencesSubscriptionModel: ObservableObject { - - @Published var isSignedIn: Bool = false - var sheetModel: SubscriptionAccessModel - - private let notificationCenter: NotificationCenter - - private let accountManager: AccountManager - private var actionHandler: PreferencesSubscriptionActionHandlers - private let sheetActionHandler: SubscriptionAccessActionHandlers - private var cancellables = Set() - - public init(accountManager: AccountManager = AccountManager(), actionHandler: PreferencesSubscriptionActionHandlers, sheetActionHandler: SubscriptionAccessActionHandlers, notificationCenter: NotificationCenter = .default) { - - self.notificationCenter = notificationCenter - self.accountManager = accountManager - self.actionHandler = actionHandler - self.sheetActionHandler = sheetActionHandler - - let isSignedIn = accountManager.isSignedIn - self.isSignedIn = isSignedIn - sheetModel = isSignedIn ? ShareSubscriptionAccessModel(actionHandlers: sheetActionHandler, email: accountManager.email) : ActivateSubscriptionAccessModel(actionHandlers: sheetActionHandler) - - notificationCenter.publisher(for: .accountDidSignIn) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateSignInState(true) - } - .store(in: &cancellables) - - notificationCenter.publisher(for: .accountDidSignOut) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateSignInState(false) - } - .store(in: &cancellables) - } - - private func updateSignInState(_ isSignedIn: Bool) { - self.isSignedIn = isSignedIn - sheetModel = isSignedIn ? ShareSubscriptionAccessModel(actionHandlers: sheetActionHandler, email: accountManager.email) : ActivateSubscriptionAccessModel(actionHandlers: sheetActionHandler) - } - - @MainActor - func learnMoreAction() { - actionHandler.openURL(.purchaseSubscription) - } - - @MainActor - func changePlanOrBillingAction() { - actionHandler.manageSubscriptionInAppStore() - } - - @MainActor - func removeFromThisDeviceAction() { - accountManager.signOut() - } - - @MainActor - func openVPN() { - actionHandler.openVPN() - } - - @MainActor - func openPersonalInformationRemoval() { - actionHandler.openPersonalInformationRemoval() - } - - @MainActor - func openIdentityTheftRestoration() { - actionHandler.openIdentityTheftRestoration() - } - - @MainActor - func openFAQ() { - actionHandler.openURL(.subscriptionFAQ) - } -} - -public final class PreferencesSubscriptionActionHandlers { - var openURL: (URL) -> Void - var manageSubscriptionInAppStore: () -> Void - var openVPN: () -> Void - var openPersonalInformationRemoval: () -> Void - var openIdentityTheftRestoration: () -> Void - - public init(openURL: @escaping (URL) -> Void, manageSubscriptionInAppStore: @escaping () -> Void, openVPN: @escaping () -> Void, openPersonalInformationRemoval: @escaping () -> Void, openIdentityTheftRestoration: @escaping () -> Void) { - self.openURL = openURL - self.manageSubscriptionInAppStore = manageSubscriptionInAppStore - self.openVPN = openVPN - self.openPersonalInformationRemoval = openPersonalInformationRemoval - self.openIdentityTheftRestoration = openIdentityTheftRestoration - } -} diff --git a/LocalPackages/Subscription/Sources/Subscription/PurchaseManager.swift b/LocalPackages/Subscription/Sources/Subscription/PurchaseManager.swift new file mode 100644 index 0000000000..5b04b7f5cf --- /dev/null +++ b/LocalPackages/Subscription/Sources/Subscription/PurchaseManager.swift @@ -0,0 +1,260 @@ +// +// PurchaseManager.swift +// +// Copyright © 2023 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 +import StoreKit +import Common + +@available(macOS 12.0, iOS 15.0, *) typealias Transaction = StoreKit.Transaction +@available(macOS 12.0, iOS 15.0, *) typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo +@available(macOS 12.0, iOS 15.0, *) typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState + +public enum StoreError: Error { + case failedVerification +} + +enum PurchaseManagerError: Error { + case productNotFound + case externalIDisNotAValidUUID + case purchaseFailed + case transactionCannotBeVerified + case transactionPendingAuthentication + case purchaseCancelledByUser + case unknownError +} + +@available(macOS 12.0, iOS 15.0, *) +public final class PurchaseManager: ObservableObject { + + static let productIdentifiers = ["ios.subscription.1month", "ios.subscription.1year", + "subscription.1week", "subscription.1month", "subscription.1year", + "review.subscription.1week", "review.subscription.1month", "review.subscription.1year"] + + public static let shared = PurchaseManager() + + @Published public private(set) var availableProducts: [Product] = [] + @Published public private(set) var purchasedProductIDs: [String] = [] + @Published public private(set) var purchaseQueue: [String] = [] + + @Published private(set) var subscriptionGroupStatus: RenewalState? + + private var transactionUpdates: Task? + private var storefrontChanges: Task? + + public init() { + transactionUpdates = observeTransactionUpdates() + storefrontChanges = observeStorefrontChanges() + } + + deinit { + transactionUpdates?.cancel() + storefrontChanges?.cancel() + } + + @MainActor + @discardableResult + public func syncAppleIDAccount() async -> Result { + do { + purchaseQueue.removeAll() + + os_log(.info, log: .subscription, "[PurchaseManager] Before AppStore.sync()") + + try await AppStore.sync() + + os_log(.info, log: .subscription, "[PurchaseManager] After AppStore.sync()") + + await updatePurchasedProducts() + await updateAvailableProducts() + + return .success(()) + } catch { + os_log(.error, log: .subscription, "[PurchaseManager] Error: %{public}s", String(reflecting: error)) + return .failure(error) + } + } + + @MainActor + public func updateAvailableProducts() async { + os_log(.info, log: .subscription, "[PurchaseManager] updateAvailableProducts") + + do { + let availableProducts = try await Product.products(for: Self.productIdentifiers) + os_log(.info, log: .subscription, "[PurchaseManager] updateAvailableProducts fetched %d products", availableProducts.count) + + if self.availableProducts != availableProducts { + self.availableProducts = availableProducts + } + } catch { + os_log(.error, log: .subscription, "[PurchaseManager] Error: %{public}s", String(reflecting: error)) + } + } + + @MainActor + public func updatePurchasedProducts() async { + os_log(.info, log: .subscription, "[PurchaseManager] updatePurchasedProducts") + + var purchasedSubscriptions: [String] = [] + + do { + for await result in Transaction.currentEntitlements { + let transaction = try checkVerified(result) + + guard transaction.productType == .autoRenewable else { continue } + guard transaction.revocationDate == nil else { continue } + + if let expirationDate = transaction.expirationDate, expirationDate > .now { + purchasedSubscriptions.append(transaction.productID) + } + } + } catch { + os_log(.error, log: .subscription, "[PurchaseManager] Error: %{public}s", String(reflecting: error)) + } + + os_log(.info, log: .subscription, "[PurchaseManager] updatePurchasedProducts fetched %d active subscriptions", purchasedSubscriptions.count) + + if self.purchasedProductIDs != purchasedSubscriptions { + self.purchasedProductIDs = purchasedSubscriptions + } + + subscriptionGroupStatus = try? await availableProducts.first?.subscription?.status.first?.state + } + + @MainActor + public static func mostRecentTransaction() async -> String? { + os_log(.info, log: .subscription, "[PurchaseManager] mostRecentTransaction") + + var transactions: [VerificationResult] = [] + + for await result in Transaction.all { + transactions.append(result) + } + + os_log(.info, log: .subscription, "[PurchaseManager] mostRecentTransaction fetched %d transactions", transactions.count) + + return transactions.first?.jwsRepresentation + } + + @MainActor + public static func hasActiveSubscription() async -> Bool { + os_log(.info, log: .subscription, "[PurchaseManager] hasActiveSubscription") + + var transactions: [VerificationResult] = [] + + for await result in Transaction.currentEntitlements { + transactions.append(result) + } + + os_log(.info, log: .subscription, "[PurchaseManager] hasActiveSubscription fetched %d transactions", transactions.count) + + return !transactions.isEmpty + } + + @MainActor + public func purchaseSubscription(with identifier: String, externalID: String) async -> Result { + + guard let product = availableProducts.first(where: { $0.id == identifier }) else { return .failure(PurchaseManagerError.productNotFound) } + + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription %{public}s (%{public}s)", product.displayName, externalID) + + purchaseQueue.append(product.id) + + var options: Set = Set() + + if let token = UUID(uuidString: externalID) { + options.insert(.appAccountToken(token)) + } else { + os_log(.error, log: .subscription, "[PurchaseManager] Error: Failed to create UUID") + return .failure(PurchaseManagerError.externalIDisNotAValidUUID) + } + + let result: Product.PurchaseResult + do { + result = try await product.purchase(options: options) + } catch { + os_log(.error, log: .subscription, "[PurchaseManager] Error: %{public}s", String(reflecting: error)) + return .failure(PurchaseManagerError.purchaseFailed) + } + + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription complete") + + purchaseQueue.removeAll() + + switch result { + case let .success(.verified(transaction)): + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: success") + // Successful purchase + await transaction.finish() + await self.updatePurchasedProducts() + return .success(()) + case let .success(.unverified(_, error)): + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: success /unverified/ - %{public}s", String(reflecting: error)) + // Successful purchase but transaction/receipt can't be verified + // Could be a jailbroken phone + return .failure(PurchaseManagerError.transactionCannotBeVerified) + case .pending: + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: pending") + // Transaction waiting on SCA (Strong Customer Authentication) or + // approval from Ask to Buy + return .failure(PurchaseManagerError.transactionPendingAuthentication) + case .userCancelled: + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: user cancelled") + return .failure(PurchaseManagerError.purchaseCancelledByUser) + @unknown default: + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: unknown") + return .failure(PurchaseManagerError.unknownError) + } + } + + private func checkVerified(_ result: VerificationResult) throws -> T { + // Check whether the JWS passes StoreKit verification. + switch result { + case .unverified: + // StoreKit parses the JWS, but it fails verification. + throw StoreError.failedVerification + case .verified(let safe): + // The result is verified. Return the unwrapped value. + return safe + } + } + + private func observeTransactionUpdates() -> Task { + + Task.detached { [unowned self] in + for await result in Transaction.updates { + os_log(.info, log: .subscription, "[PurchaseManager] observeTransactionUpdates") + + if case .verified(let transaction) = result { + await transaction.finish() + } + + await self.updatePurchasedProducts() + } + } + } + + private func observeStorefrontChanges() -> Task { + + Task.detached { [unowned self] in + for await result in Storefront.updates { + os_log(.info, log: .subscription, "[PurchaseManager] observeStorefrontChanges: %s", result.countryCode) + await updatePurchasedProducts() + await updateAvailableProducts() + } + } + } +} diff --git a/LocalPackages/Subscription/Sources/Subscription/Services/APIService.swift b/LocalPackages/Subscription/Sources/Subscription/Services/APIService.swift new file mode 100644 index 0000000000..503ed1b18b --- /dev/null +++ b/LocalPackages/Subscription/Sources/Subscription/Services/APIService.swift @@ -0,0 +1,110 @@ +// +// APIService.swift +// +// Copyright © 2023 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 +import Common + +public enum APIServiceError: Swift.Error { + case decodingError + case encodingError + case serverError(description: String) + case unknownServerError + case connectionError +} + +struct ErrorResponse: Decodable { + let error: String +} + +public protocol APIService { + static var baseURL: URL { get } + static var session: URLSession { get } + static func executeAPICall(method: String, endpoint: String, headers: [String: String]?, body: Data?) async -> Result where T: Decodable +} + +public extension APIService { + + static func executeAPICall(method: String, endpoint: String, headers: [String: String]? = nil, body: Data? = nil) async -> Result where T: Decodable { + let request = makeAPIRequest(method: method, endpoint: endpoint, headers: headers, body: body) + + do { + let (data, urlResponse) = try await session.data(for: request) + + printDebugInfo(method: method, endpoint: endpoint, data: data, response: urlResponse) + + if let httpResponse = urlResponse as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) { + if let decodedResponse = decode(T.self, from: data) { + return .success(decodedResponse) + } else { + return .failure(.decodingError) + } + } else { + if let decodedResponse = decode(ErrorResponse.self, from: data) { + let errorDescription = "[\(endpoint)] \(urlResponse.httpStatusCodeAsString ?? ""): \(decodedResponse.error)" + return .failure(.serverError(description: errorDescription)) + } else { + return .failure(.unknownServerError) + } + } + } catch { + os_log(.error, log: .subscription, "Service error: %{public}@", error.localizedDescription) + return .failure(.connectionError) + } + } + + private static func makeAPIRequest(method: String, endpoint: String, headers: [String: String]?, body: Data?) -> URLRequest { + let url = baseURL.appendingPathComponent(endpoint) + var request = URLRequest(url: url) + request.httpMethod = method + if let headers = headers { + request.allHTTPHeaderFields = headers + } + if let body = body { + request.httpBody = body + } + + return request + } + + private static func decode(_: T.Type, from data: Data) -> T? where T: Decodable { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .millisecondsSince1970 + + return try? decoder.decode(T.self, from: data) + } + + private static func printDebugInfo(method: String, endpoint: String, data: Data, response: URLResponse) { + let statusCode = (response as? HTTPURLResponse)!.statusCode + let stringData = String(data: data, encoding: .utf8) ?? "" + + os_log(.info, log: .subscription, "[API] %d %{public}s /%{public}s :: %{public}s", statusCode, method, endpoint, stringData) + } + + static func makeAuthorizationHeader(for token: String) -> [String: String] { + ["Authorization": "Bearer " + token] + } +} + +extension URLResponse { + + var httpStatusCodeAsString: String? { + guard let httpStatusCode = (self as? HTTPURLResponse)?.statusCode else { return nil } + return String(httpStatusCode) + } +} diff --git a/LocalPackages/Subscription/Sources/Subscription/Services/AuthService.swift b/LocalPackages/Subscription/Sources/Subscription/Services/AuthService.swift new file mode 100644 index 0000000000..4e4755ad62 --- /dev/null +++ b/LocalPackages/Subscription/Sources/Subscription/Services/AuthService.swift @@ -0,0 +1,111 @@ +// +// AuthService.swift +// +// Copyright © 2023 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 +import Common + +public struct AuthService: APIService { + + public static let session = { + let configuration = URLSessionConfiguration.ephemeral + return URLSession(configuration: configuration) + }() + public static let baseURL = URL(string: "https://quackdev.duckduckgo.com/api/auth")! + + // MARK: - + + public static func getAccessToken(token: String) async -> Result { + await executeAPICall(method: "GET", endpoint: "access-token", headers: makeAuthorizationHeader(for: token)) + } + + public struct AccessTokenResponse: Decodable { + public let accessToken: String + } + + // MARK: - + + public static func validateToken(accessToken: String) async -> Result { + await executeAPICall(method: "GET", endpoint: "validate-token", headers: makeAuthorizationHeader(for: accessToken)) + } + + // swiftlint:disable nesting + public struct ValidateTokenResponse: Decodable { + public let account: Account + + public struct Account: Decodable { + public let email: String? + let entitlements: [Entitlement] + public let externalID: String + + enum CodingKeys: String, CodingKey { + case email, entitlements, externalID = "externalId" // no underscores due to keyDecodingStrategy = .convertFromSnakeCase + } + } + + struct Entitlement: Decodable { + let id: Int + let name: String + let product: String + } + } + // swiftlint:enable nesting + + // MARK: - + + public static func createAccount(emailAccessToken: String?) async -> Result { + var headers: [String: String]? + + if let emailAccessToken { + headers = makeAuthorizationHeader(for: emailAccessToken) + } + + return await executeAPICall(method: "POST", endpoint: "account/create", headers: headers) + } + + public struct CreateAccountResponse: Decodable { + public let authToken: String + public let externalID: String + public let status: String + + enum CodingKeys: String, CodingKey { + case authToken = "authToken", externalID = "externalId", status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase + } + } + + // MARK: - + + public static func storeLogin(signature: String) async -> Result { + let bodyDict = ["signature": signature, + "store": "apple_app_store"] + + guard let bodyData = try? JSONEncoder().encode(bodyDict) else { return .failure(.encodingError) } + return await executeAPICall(method: "POST", endpoint: "store-login", body: bodyData) + } + + public struct StoreLoginResponse: Decodable { + public let authToken: String + public let email: String + public let externalID: String + public let id: Int + public let status: String + + enum CodingKeys: String, CodingKey { + case authToken = "authToken", email, externalID = "externalId", id, status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase + } + } +} diff --git a/LocalPackages/Subscription/Sources/Subscription/Services/SubscriptionService.swift b/LocalPackages/Subscription/Sources/Subscription/Services/SubscriptionService.swift new file mode 100644 index 0000000000..96952d01be --- /dev/null +++ b/LocalPackages/Subscription/Sources/Subscription/Services/SubscriptionService.swift @@ -0,0 +1,87 @@ +// +// SubscriptionService.swift +// +// Copyright © 2023 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 +import Common + +public struct SubscriptionService: APIService { + + public static let session = { + let configuration = URLSessionConfiguration.ephemeral + return URLSession(configuration: configuration) + }() + public static let baseURL = URL(string: "https://subscriptions-dev.duckduckgo.com/api")! + + // MARK: - + + public static func getSubscriptionDetails(token: String) async -> Result { + let result: Result = await executeAPICall(method: "GET", endpoint: "subscription", headers: makeAuthorizationHeader(for: token)) + + switch result { + case .success(let response): + cachedSubscriptionDetailsResponse = response + case .failure: + cachedSubscriptionDetailsResponse = nil + } + + return result + } + + public struct GetSubscriptionDetailsResponse: Decodable { + public let productId: String + public let startedAt: Date + public let expiresOrRenewsAt: Date + public let platform: String + public let status: String + + public var isSubscriptionActive: Bool { + status.lowercased() != "expired" && status.lowercased() != "inactive" + } + } + + public static var cachedSubscriptionDetailsResponse: GetSubscriptionDetailsResponse? + + // MARK: - + + public static func getProducts() async -> Result { + await executeAPICall(method: "GET", endpoint: "products") + } + + public typealias GetProductsResponse = [GetProductsItem] + + public struct GetProductsItem: Decodable { + public let productId: String + public let productLabel: String + public let billingPeriod: String + public let price: String + public let currency: String + } + + // MARK: - + + public static func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result { + var headers = makeAuthorizationHeader(for: accessToken) + headers["externalAccountId"] = externalID + return await executeAPICall(method: "GET", endpoint: "checkout/portal", headers: headers) + } + + public struct GetCustomerPortalURLResponse: Decodable { + public let customerPortalUrl: String + } + +} diff --git a/LocalPackages/Subscription/Sources/Subscription/SubscriptionPurchaseEnvironment.swift b/LocalPackages/Subscription/Sources/Subscription/SubscriptionPurchaseEnvironment.swift new file mode 100644 index 0000000000..4414fd9650 --- /dev/null +++ b/LocalPackages/Subscription/Sources/Subscription/SubscriptionPurchaseEnvironment.swift @@ -0,0 +1,65 @@ +// +// SubscriptionPurchaseEnvironment.swift +// +// Copyright © 2023 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 +import Common + +public final class SubscriptionPurchaseEnvironment { + + public enum Environment: String { + case appStore, stripe + } + + public static var current: Environment = .appStore { + didSet { + os_log(.info, log: .subscription, "[SubscriptionPurchaseEnvironment] Setting to %{public}s", current.rawValue) + + canPurchase = false + + switch current { + case .appStore: + setupForAppStore() + case .stripe: + setupForStripe() + } + } + } + + public static var canPurchase: Bool = false { + didSet { + os_log(.info, log: .subscription, "[SubscriptionPurchaseEnvironment] canPurchase %{public}s", (canPurchase ? "true" : "false")) + } + } + + private static func setupForAppStore() { + if #available(macOS 12.0, iOS 15.0, *) { + Task { + await PurchaseManager.shared.updateAvailableProducts() + canPurchase = !PurchaseManager.shared.availableProducts.isEmpty + } + } + } + + private static func setupForStripe() { + Task { + if case let .success(products) = await SubscriptionService.getProducts() { + canPurchase = !products.isEmpty + } + } + } +} diff --git a/LocalPackages/Subscription/Sources/Subscription/URL+Subscription.swift b/LocalPackages/Subscription/Sources/Subscription/URL+Subscription.swift new file mode 100644 index 0000000000..78061d4a93 --- /dev/null +++ b/LocalPackages/Subscription/Sources/Subscription/URL+Subscription.swift @@ -0,0 +1,49 @@ +// +// URL+Subscription.swift +// +// Copyright © 2023 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 + +public extension URL { + + static var purchaseSubscription: URL { + URL(string: "https://abrown.duckduckgo.com/subscriptions/welcome")! + } + + static var subscriptionFAQ: URL { + URL(string: "https://duckduckgo.com/about")! + } + + // MARK: - Subscription Email + static var activateSubscriptionViaEmail: URL { + URL(string: "https://abrown.duckduckgo.com/subscriptions/activate")! + } + + static var addEmailToSubscription: URL { + URL(string: "https://abrown.duckduckgo.com/subscriptions/add-email")! + } + + static var manageSubscriptionEmail: URL { + URL(string: "https://abrown.duckduckgo.com/subscriptions/manage")! + } + + // MARK: - App Store app manage subscription URL + + static var manageSubscriptionsInAppStoreAppURL: URL { + URL(string: "macappstores://apps.apple.com/account/subscriptions")! + } +} diff --git a/LocalPackages/Subscription/Tests/SubscriptionTests/SubscriptionTests.swift b/LocalPackages/Subscription/Tests/SubscriptionTests/SubscriptionTests.swift index 16c144c04f..e209d149d3 100644 --- a/LocalPackages/Subscription/Tests/SubscriptionTests/SubscriptionTests.swift +++ b/LocalPackages/Subscription/Tests/SubscriptionTests/SubscriptionTests.swift @@ -20,10 +20,4 @@ import XCTest @testable import Subscription final class SubscriptionTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(Subscription().text, "Hello, World!") - } } diff --git a/LocalPackages/Account/.gitignore b/LocalPackages/SubscriptionUI/.gitignore similarity index 100% rename from LocalPackages/Account/.gitignore rename to LocalPackages/SubscriptionUI/.gitignore diff --git a/LocalPackages/SubscriptionUI/Package.swift b/LocalPackages/SubscriptionUI/Package.swift new file mode 100644 index 0000000000..04bd6b6e02 --- /dev/null +++ b/LocalPackages/SubscriptionUI/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "SubscriptionUI", + platforms: [ .macOS("11.4") ], + products: [ + .library( + name: "SubscriptionUI", + targets: ["SubscriptionUI"]), + ], + dependencies: [ + .package(path: "../Subscription"), + .package(path: "../SwiftUIExtensions") + ], + targets: [ + .target( + name: "SubscriptionUI", + dependencies: [ + .product(name: "Subscription", package: "Subscription"), + .product(name: "SwiftUIExtensions", package: "SwiftUIExtensions") + ], + resources: [ + .process("Resources") + ]), + .testTarget( + name: "SubscriptionUITests", + dependencies: ["SubscriptionUI"]), + ] +) diff --git a/LocalPackages/Subscription/Sources/Subscription/DebugMenu/DebugPurchaseModel.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/DebugMenu/DebugPurchaseModel.swift similarity index 65% rename from LocalPackages/Subscription/Sources/Subscription/DebugMenu/DebugPurchaseModel.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/DebugMenu/DebugPurchaseModel.swift index 7b5acc1eda..273a043618 100644 --- a/LocalPackages/Subscription/Sources/Subscription/DebugMenu/DebugPurchaseModel.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/DebugMenu/DebugPurchaseModel.swift @@ -18,17 +18,18 @@ import Foundation import StoreKit -import Purchase -import Account +import Subscription @available(macOS 12.0, *) public final class DebugPurchaseModel: ObservableObject { - var manager: PurchaseManager + var purchaseManager: PurchaseManager + var accountManager: AccountManager = AccountManager() + @Published var subscriptions: [SubscriptionRowModel] init(manager: PurchaseManager, subscriptions: [SubscriptionRowModel] = []) { - self.manager = manager + self.purchaseManager = manager self.subscriptions = subscriptions } @@ -36,20 +37,8 @@ public final class DebugPurchaseModel: ObservableObject { func purchase(_ product: Product) { print("Attempting purchase: \(product.displayName)") - if let externalID = AccountManager().externalID { - print("ExternalID: \(externalID)") - manager.purchase(product, customUUID: externalID) - } else { - Task { - switch await AuthService.createAccount() { - case .success(let response): - manager.purchase(product, customUUID: response.externalID) - AccountManager().exchangeTokenAndRefreshEntitlements(with: response.authToken) - case .failure(let error): - print("Error: \(error)") - return - } - } + Task { + await AppStorePurchaseFlow.purchaseSubscription(with: product.id, emailAccessToken: nil) } } } diff --git a/LocalPackages/Subscription/Sources/Subscription/DebugMenu/DebugPurchaseView.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/DebugMenu/DebugPurchaseView.swift similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/DebugMenu/DebugPurchaseView.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/DebugMenu/DebugPurchaseView.swift diff --git a/LocalPackages/Subscription/Sources/Subscription/DebugMenu/DebugPurchaseViewController.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/DebugMenu/DebugPurchaseViewController.swift similarity index 99% rename from LocalPackages/Subscription/Sources/Subscription/DebugMenu/DebugPurchaseViewController.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/DebugMenu/DebugPurchaseViewController.swift index dd2eae869c..0d9c34bcbb 100644 --- a/LocalPackages/Subscription/Sources/Subscription/DebugMenu/DebugPurchaseViewController.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/DebugMenu/DebugPurchaseViewController.swift @@ -20,7 +20,7 @@ import AppKit import SwiftUI import Combine import StoreKit -import Purchase +import Subscription @available(macOS 12.0, *) public final class DebugPurchaseViewController: NSViewController { diff --git a/LocalPackages/Subscription/Sources/Subscription/DebugMenu/SubscriptionDebugMenu.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/DebugMenu/SubscriptionDebugMenu.swift similarity index 52% rename from LocalPackages/Subscription/Sources/Subscription/DebugMenu/SubscriptionDebugMenu.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/DebugMenu/SubscriptionDebugMenu.swift index 91a92d5c18..7c4dbf2ed8 100644 --- a/LocalPackages/Subscription/Sources/Subscription/DebugMenu/SubscriptionDebugMenu.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/DebugMenu/SubscriptionDebugMenu.swift @@ -17,13 +17,23 @@ // import AppKit -import Account +import Subscription public final class SubscriptionDebugMenu: NSMenuItem { var currentViewController: () -> NSViewController? private let accountManager = AccountManager() + private var _purchaseManager: Any? + @available(macOS 12.0, *) + fileprivate var purchaseManager: PurchaseManager { + if _purchaseManager == nil { + _purchaseManager = PurchaseManager() + } + // swiftlint:disable:next force_cast + return _purchaseManager as! PurchaseManager + } + required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -42,18 +52,23 @@ public final class SubscriptionDebugMenu: NSMenuItem { menu.addItem(NSMenuItem(title: "Show account details", action: #selector(showAccountDetails), target: self)) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Validate Token", action: #selector(validateToken), target: self)) + menu.addItem(NSMenuItem(title: "Check Entitlements", action: #selector(checkEntitlements), target: self)) + menu.addItem(NSMenuItem(title: "Get Subscription Info", action: #selector(getSubscriptionDetails), target: self)) menu.addItem(NSMenuItem(title: "Restore Subscription from App Store transaction", action: #selector(restorePurchases), target: self)) menu.addItem(.separator()) if #available(macOS 12.0, *) { + menu.addItem(NSMenuItem(title: "Sync App Store AppleID Account (re- sign-in)", action: #selector(syncAppleIDAccount), target: self)) menu.addItem(NSMenuItem(title: "Purchase Subscription from App Store", action: #selector(showPurchaseView), target: self)) } - + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Error message #1", action: #selector(testError1), target: self)) + menu.addItem(NSMenuItem(title: "Error message #2", action: #selector(testError2), target: self)) return menu }() @objc func simulateSubscriptionActiveState() { - accountManager.storeAccount(token: "fake-token", email: "fake@email.com", externalID: "fake-externalID") + accountManager.storeAccount(token: "fake-token", email: "fake@email.com", externalID: "123") } @objc @@ -63,18 +78,17 @@ public final class SubscriptionDebugMenu: NSMenuItem { @objc func showAccountDetails() { - let title = accountManager.isSignedIn ? "Authenticated" : "Not Authenticated" - let message = accountManager.isSignedIn ? ["Token: \(accountManager.token ?? "")", - "Short-lived Token: \(accountManager.shortLivedToken ?? "")", - "Email: \(accountManager.email ?? "")", - "ExternalID: \(accountManager.externalID ?? "")"].joined(separator: "\n") : nil + let title = accountManager.isUserAuthenticated ? "Authenticated" : "Not Authenticated" + let message = accountManager.isUserAuthenticated ? ["AuthToken: \(accountManager.authToken ?? "")", + "AccessToken: \(accountManager.accessToken ?? "")", + "Email: \(accountManager.email ?? "")"].joined(separator: "\n") : nil showAlert(title: title, message: message) } @objc func validateToken() { Task { - guard let token = accountManager.token else { return } + guard let token = accountManager.accessToken else { return } switch await AuthService.validateToken(accessToken: token) { case .success(let response): showAlert(title: "Validate token", message: "\(response)") @@ -84,9 +98,73 @@ public final class SubscriptionDebugMenu: NSMenuItem { } } + @objc + func checkEntitlements() { + Task { + var results: [String] = [] + + for entitlementName in ["fake", "dummy1", "dummy2", "dummy3"] { + let result = await AccountManager().hasEntitlement(for: entitlementName) + let resultSummary = "Entitlement check for \(entitlementName): \(result)" + results.append(resultSummary) + print(resultSummary) + } + + showAlert(title: "Check Entitlements", message: results.joined(separator: "\n")) + } + } + + @objc + func getSubscriptionDetails() { + Task { + guard let token = accountManager.accessToken else { return } + switch await SubscriptionService.getSubscriptionDetails(token: token) { + case .success(let response): + showAlert(title: "Subscription info", message: "\(response)") + case .failure(let error): + showAlert(title: "Subscription info", message: "\(error)") + } + } + } + + @available(macOS 12.0, *) + @objc + func syncAppleIDAccount() { + Task { + await purchaseManager.syncAppleIDAccount() + } + } + @objc func restorePurchases(_ sender: Any?) { - accountManager.signInByRestoringPastPurchases() + if #available(macOS 12.0, *) { + Task { + await AppStoreRestoreFlow.restoreAccountFromPastPurchase() + } + } + } + + @objc + func testError1(_ sender: Any?) { + Task { @MainActor in + let alert = NSAlert() + alert.messageText = "Something Went Wrong" + alert.informativeText = "The App Store was not able to process your purchase. Please try again later." + alert.addButton(withTitle: "OK") + alert.runModal() + } + } + + @objc + func testError2(_ sender: Any?) { + Task { @MainActor in + let alert = NSAlert() + alert.messageText = "Subscription Not Found" + alert.informativeText = "The subscription associated with this Apple ID is no longer active." + alert.addButton(withTitle: "View Plans") + alert.addButton(withTitle: "Cancel") + alert.runModal() + } } @IBAction func showPurchaseView(_ sender: Any?) { diff --git a/LocalPackages/Subscription/Sources/Subscription/Extensions/RoundedBorder.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Extensions/RoundedBorder.swift similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Extensions/RoundedBorder.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Extensions/RoundedBorder.swift diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/NSAlert+Subscription.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/NSAlert+Subscription.swift new file mode 100644 index 0000000000..d79042d247 --- /dev/null +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/NSAlert+Subscription.swift @@ -0,0 +1,77 @@ +// +// NSAlert+Subscription.swift +// +// Copyright © 2021 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 +import AppKit + +public extension NSAlert { + + static func somethingWentWrongAlert() -> NSAlert { + let alert = NSAlert() + alert.messageText = UserText.somethingWentWrongAlertTitle + alert.informativeText = UserText.somethingWentWrongAlertDescription + alert.addButton(withTitle: UserText.okButtonTitle) + return alert + } + + static func somethingWentWrongStripeAlert() -> NSAlert { + let alert = NSAlert() + alert.messageText = UserText.somethingWentWrongAlertTitle + alert.informativeText = UserText.somethingWentWrongStripeAlertDescription + alert.addButton(withTitle: UserText.okButtonTitle) + return alert + } + + static func subscriptionNotFoundAlert() -> NSAlert { + let alert = NSAlert() + alert.messageText = UserText.subscriptionNotFoundAlertTitle + alert.informativeText = UserText.subscriptionNotFoundAlertDescription + alert.addButton(withTitle: UserText.viewPlansButtonTitle) + alert.addButton(withTitle: UserText.cancelButtonTitle) + return alert + } + + static func subscriptionInactiveAlert() -> NSAlert { + let alert = NSAlert() + alert.messageText = UserText.subscriptionInactiveAlertTitle + alert.informativeText = UserText.subscriptionInactiveAlertDescription + alert.addButton(withTitle: UserText.viewPlansButtonTitle) + alert.addButton(withTitle: UserText.cancelButtonTitle) + return alert + } + + static func subscriptionFoundAlert() -> NSAlert { + let alert = NSAlert() + alert.messageText = UserText.subscriptionFoundAlertTitle + alert.informativeText = UserText.subscriptionFoundAlertDescription + alert.addButton(withTitle: UserText.restoreButtonTitle) + alert.addButton(withTitle: UserText.cancelButtonTitle) + return alert + } +} + +public extension NSWindow { + + func show(_ alert: NSAlert, firstButtonAction: (() -> Void)? = nil) { + alert.beginSheetModal(for: self, completionHandler: { response in + if case .alertFirstButtonReturn = response { + firstButtonAction?() + } + }) + } +} diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift new file mode 100644 index 0000000000..be6d174845 --- /dev/null +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift @@ -0,0 +1,152 @@ +// +// PreferencesSubscriptionModel.swift +// +// Copyright © 2023 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 +import Subscription + +public final class PreferencesSubscriptionModel: ObservableObject { + + @Published var isUserAuthenticated: Bool = false + @Published var hasEntitlements: Bool = false + @Published var subscriptionDetails: String? + lazy var sheetModel: SubscriptionAccessModel = makeSubscriptionAccessModel() + + private let accountManager: AccountManager + private var actionHandler: PreferencesSubscriptionActionHandlers + private let sheetActionHandler: SubscriptionAccessActionHandlers + + public init(accountManager: AccountManager = AccountManager(), actionHandler: PreferencesSubscriptionActionHandlers, sheetActionHandler: SubscriptionAccessActionHandlers) { + self.accountManager = accountManager + self.actionHandler = actionHandler + self.sheetActionHandler = sheetActionHandler + + self.isUserAuthenticated = accountManager.isUserAuthenticated + self.hasEntitlements = self.isUserAuthenticated + + if let cachedDate = SubscriptionService.cachedSubscriptionDetailsResponse?.expiresOrRenewsAt { + updateDescription(for: cachedDate) + } + + NotificationCenter.default.addObserver(forName: .accountDidSignIn, object: nil, queue: .main) { _ in + self.updateUserAuthenticatedState(true) + } + + NotificationCenter.default.addObserver(forName: .accountDidSignOut, object: nil, queue: .main) { _ in + self.updateUserAuthenticatedState(false) + } + } + + private func makeSubscriptionAccessModel() -> SubscriptionAccessModel { + if accountManager.isUserAuthenticated { + ShareSubscriptionAccessModel(actionHandlers: sheetActionHandler, email: accountManager.email) + } else { + ActivateSubscriptionAccessModel(actionHandlers: sheetActionHandler) + } + } + + private func updateUserAuthenticatedState(_ isUserAuthenticated: Bool) { + self.isUserAuthenticated = isUserAuthenticated + sheetModel = makeSubscriptionAccessModel() + } + + @MainActor + func learnMoreAction() { + actionHandler.openURL(.purchaseSubscription) + } + + @MainActor + func changePlanOrBillingAction() { + actionHandler.changePlanOrBilling() + } + + @MainActor + func removeFromThisDeviceAction() { + accountManager.signOut() + } + + @MainActor + func openVPN() { + actionHandler.openVPN() + } + + @MainActor + func openPersonalInformationRemoval() { + actionHandler.openPersonalInformationRemoval() + } + + @MainActor + func openIdentityTheftRestoration() { + actionHandler.openIdentityTheftRestoration() + } + + @MainActor + func openFAQ() { + actionHandler.openURL(.subscriptionFAQ) + } + + @MainActor + func fetchAndUpdateSubscriptionDetails() { + Task { + guard let token = accountManager.accessToken else { return } + + if let cachedDate = SubscriptionService.cachedSubscriptionDetailsResponse?.expiresOrRenewsAt { + updateDescription(for: cachedDate) + self.hasEntitlements = cachedDate.timeIntervalSinceNow > 0 + } + + if case .success(let response) = await SubscriptionService.getSubscriptionDetails(token: token) { + if !response.isSubscriptionActive { + AccountManager().signOut() + return + } + + updateDescription(for: response.expiresOrRenewsAt) + } + + self.hasEntitlements = await AccountManager().hasEntitlement(for: "dummy1") + } + } + + private func updateDescription(for date: Date) { + self.subscriptionDetails = UserText.preferencesSubscriptionActiveCaption(formattedDate: dateFormatter.string(from: date)) + } + + private var dateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + + return dateFormatter + }() +} + +public final class PreferencesSubscriptionActionHandlers { + var openURL: (URL) -> Void + var changePlanOrBilling: () -> Void + var openVPN: () -> Void + var openPersonalInformationRemoval: () -> Void + var openIdentityTheftRestoration: () -> Void + + public init(openURL: @escaping (URL) -> Void, changePlanOrBilling: @escaping () -> Void, openVPN: @escaping () -> Void, openPersonalInformationRemoval: @escaping () -> Void, openIdentityTheftRestoration: @escaping () -> Void) { + self.openURL = openURL + self.changePlanOrBilling = changePlanOrBilling + self.openVPN = openVPN + self.openPersonalInformationRemoval = openPersonalInformationRemoval + self.openIdentityTheftRestoration = openIdentityTheftRestoration + } +} diff --git a/LocalPackages/Subscription/Sources/Subscription/Preferences/PreferencesSubscriptionView.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift similarity index 90% rename from LocalPackages/Subscription/Sources/Subscription/Preferences/PreferencesSubscriptionView.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift index a27aadf551..904fd141bc 100644 --- a/LocalPackages/Subscription/Sources/Subscription/Preferences/PreferencesSubscriptionView.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift @@ -64,13 +64,13 @@ public struct PreferencesSubscriptionView: View { .frame(height: 20) VStack { - if model.isSignedIn { + if model.isUserAuthenticated { UniversalHeaderView { Image("subscription-active-icon", bundle: .module) .padding(4) } content: { TextMenuItemHeader(text: UserText.preferencesSubscriptionActiveHeader) - TextMenuItemCaption(text: UserText.preferencesSubscriptionActiveCaption) + TextMenuItemCaption(text: model.subscriptionDetails ?? "") } buttons: { Button(UserText.addToAnotherDeviceButton) { showingSheet.toggle() } @@ -84,6 +84,10 @@ public struct PreferencesSubscriptionView: View { } .fixedSize() } + .onAppear { + model.fetchAndUpdateSubscriptionDetails() + } + } else { UniversalHeaderView { Image("subscription-inactive-icon", bundle: .module) @@ -107,8 +111,9 @@ public struct PreferencesSubscriptionView: View { SectionView(iconName: "vpn-service-icon", title: UserText.vpnServiceTitle, description: UserText.vpnServiceDescription, - buttonName: model.isSignedIn ? "Manage" : nil, - buttonAction: { model.openVPN() }) + buttonName: model.isUserAuthenticated ? "Manage" : nil, + buttonAction: { model.openVPN() }, + enabled: !model.isUserAuthenticated || model.hasEntitlements) Divider() .foregroundColor(Color.secondary) @@ -116,8 +121,9 @@ public struct PreferencesSubscriptionView: View { SectionView(iconName: "pir-service-icon", title: UserText.personalInformationRemovalServiceTitle, description: UserText.personalInformationRemovalServiceDescription, - buttonName: model.isSignedIn ? "View" : nil, - buttonAction: { model.openPersonalInformationRemoval() }) + buttonName: model.isUserAuthenticated ? "View" : nil, + buttonAction: { model.openPersonalInformationRemoval() }, + enabled: !model.isUserAuthenticated || model.hasEntitlements) Divider() .foregroundColor(Color.secondary) @@ -125,8 +131,9 @@ public struct PreferencesSubscriptionView: View { SectionView(iconName: "itr-service-icon", title: UserText.identityTheftRestorationServiceTitle, description: UserText.identityTheftRestorationServiceDescription, - buttonName: model.isSignedIn ? "View" : nil, - buttonAction: { model.openIdentityTheftRestoration() }) + buttonName: model.isUserAuthenticated ? "View" : nil, + buttonAction: { model.openIdentityTheftRestoration() }, + enabled: !model.isUserAuthenticated || model.hasEntitlements) } .padding(10) .roundedBorder() @@ -177,13 +184,15 @@ public struct SectionView: View { public var description: String public var buttonName: String? public var buttonAction: (() -> Void)? + public var enabled: Bool - public init(iconName: String, title: String, description: String, buttonName: String? = nil, buttonAction: (() -> Void)? = nil) { + public init(iconName: String, title: String, description: String, buttonName: String? = nil, buttonAction: (() -> Void)? = nil, enabled: Bool = true) { self.iconName = iconName self.title = title self.description = description self.buttonName = buttonName self.buttonAction = buttonAction + self.enabled = enabled } public var body: some View { @@ -215,6 +224,8 @@ public struct SectionView: View { } } .padding(.vertical, 7) + .disabled(!enabled) + .opacity(enabled ? 1.0 : 0.6) } } diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/ProgressViewController.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/ProgressViewController.swift new file mode 100644 index 0000000000..9f25cf85f2 --- /dev/null +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/ProgressViewController.swift @@ -0,0 +1,75 @@ +// +// ProgressViewController.swift +// +// Copyright © 2023 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 SwiftUI + +public final class ProgressViewController: NSViewController { + + private var progressView: ProgressView? + private var viewModel: ProgressViewModel + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public init(title: String) { + self.viewModel = ProgressViewModel(title: title) + super.init(nibName: nil, bundle: nil) + } + + public override func loadView() { + + let progressView = ProgressView(viewModel: viewModel) + let hostingView = NSHostingView(rootView: progressView) + + self.progressView = progressView + + view = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 160)) + hostingView.frame = view.bounds + hostingView.autoresizingMask = [.height, .width] + hostingView.translatesAutoresizingMaskIntoConstraints = true + + view.addSubview(hostingView) + } + + public func updateTitleText(_ text: String) { + self.viewModel.title = text + } +} + +final class ProgressViewModel: ObservableObject { + @Published var title: String + + init(title: String) { + self.title = title + } +} + +struct ProgressView: View { + + @ObservedObject var viewModel: ProgressViewModel + + public var body: some View { + VStack { + Text(viewModel.title).font(.title) + Spacer().frame(height: 32) + ActivityIndicator(isAnimating: .constant(true), style: .spinning) + } + } +} diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Colors/BadgeBackground.colorset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Colors/BadgeBackground.colorset/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Colors/BadgeBackground.colorset/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Colors/BadgeBackground.colorset/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Colors/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Colors/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Colors/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Colors/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Colors/TextPrimary.colorset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Colors/TextPrimary.colorset/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Colors/TextPrimary.colorset/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Colors/TextPrimary.colorset/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Colors/TextSecondary.colorset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Colors/TextSecondary.colorset/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Colors/TextSecondary.colorset/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Colors/TextSecondary.colorset/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/apple-id-icon.imageset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/apple-id-icon.imageset/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/apple-id-icon.imageset/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/apple-id-icon.imageset/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/apple-id-icon.imageset/apple-id-icon.pdf b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/apple-id-icon.imageset/apple-id-icon.pdf similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/apple-id-icon.imageset/apple-id-icon.pdf rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/apple-id-icon.imageset/apple-id-icon.pdf diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/email-icon.imageset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/email-icon.imageset/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/email-icon.imageset/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/email-icon.imageset/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/email-icon.imageset/email-icon.pdf b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/email-icon.imageset/email-icon.pdf similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/email-icon.imageset/email-icon.pdf rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/email-icon.imageset/email-icon.pdf diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/itr-service-icon.imageset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/itr-service-icon.imageset/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/itr-service-icon.imageset/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/itr-service-icon.imageset/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/itr-service-icon.imageset/itr-service-icon.pdf b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/itr-service-icon.imageset/itr-service-icon.pdf similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/itr-service-icon.imageset/itr-service-icon.pdf rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/itr-service-icon.imageset/itr-service-icon.pdf diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/pir-service-icon.imageset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/pir-service-icon.imageset/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/pir-service-icon.imageset/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/pir-service-icon.imageset/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/pir-service-icon.imageset/pir-service-icon.pdf b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/pir-service-icon.imageset/pir-service-icon.pdf similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/pir-service-icon.imageset/pir-service-icon.pdf rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/pir-service-icon.imageset/pir-service-icon.pdf diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/subscription-active-icon.imageset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-active-icon.imageset/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/subscription-active-icon.imageset/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-active-icon.imageset/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/subscription-active-icon.imageset/subscription-active-icon.pdf b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-active-icon.imageset/subscription-active-icon.pdf similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/subscription-active-icon.imageset/subscription-active-icon.pdf rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-active-icon.imageset/subscription-active-icon.pdf diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/subscription-inactive-icon.imageset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-inactive-icon.imageset/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/subscription-inactive-icon.imageset/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-inactive-icon.imageset/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/subscription-inactive-icon.imageset/subscription-inactive-icon.pdf b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-inactive-icon.imageset/subscription-inactive-icon.pdf similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/subscription-inactive-icon.imageset/subscription-inactive-icon.pdf rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-inactive-icon.imageset/subscription-inactive-icon.pdf diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/sync-icon.imageset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/sync-icon.imageset/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/sync-icon.imageset/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/sync-icon.imageset/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/sync-icon.imageset/sync-icon.pdf b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/sync-icon.imageset/sync-icon.pdf similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/sync-icon.imageset/sync-icon.pdf rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/sync-icon.imageset/sync-icon.pdf diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/vpn-service-icon.imageset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/vpn-service-icon.imageset/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/vpn-service-icon.imageset/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/vpn-service-icon.imageset/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/vpn-service-icon.imageset/vpn-service-icon.pdf b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/vpn-service-icon.imageset/vpn-service-icon.pdf similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Icons/vpn-service-icon.imageset/vpn-service-icon.pdf rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/vpn-service-icon.imageset/vpn-service-icon.pdf diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Images/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Images/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Images/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Images/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Images/Placeholder-96x64.imageset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Images/Placeholder-96x64.imageset/Contents.json similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Images/Placeholder-96x64.imageset/Contents.json rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Images/Placeholder-96x64.imageset/Contents.json diff --git a/LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Images/Placeholder-96x64.imageset/Placeholder-96x64.pdf b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Images/Placeholder-96x64.imageset/Placeholder-96x64.pdf similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/Resources/Subscription.xcassets/Images/Placeholder-96x64.imageset/Placeholder-96x64.pdf rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Images/Placeholder-96x64.imageset/Placeholder-96x64.pdf diff --git a/LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/Model/AccessChannel.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/AccessChannel.swift similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/Model/AccessChannel.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/AccessChannel.swift diff --git a/LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/Model/ActivateSubscriptionAccessModel.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ActivateSubscriptionAccessModel.swift similarity index 93% rename from LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/Model/ActivateSubscriptionAccessModel.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ActivateSubscriptionAccessModel.swift index 4b1e015989..cc7ce4ac98 100644 --- a/LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/Model/ActivateSubscriptionAccessModel.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ActivateSubscriptionAccessModel.swift @@ -18,13 +18,6 @@ import Foundation -extension URL { - - static var activateSubscriptionViaEmail: URL { - URL(string: "https://abrown.duckduckgo.com/subscriptions/activate")! - } -} - public final class ActivateSubscriptionAccessModel: SubscriptionAccessModel { public var actionHandlers: SubscriptionAccessActionHandlers public var title = UserText.activateModalTitle diff --git a/LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/Model/ShareSubscriptionAccessModel.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ShareSubscriptionAccessModel.swift similarity index 81% rename from LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/Model/ShareSubscriptionAccessModel.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ShareSubscriptionAccessModel.swift index 5f84e14c39..22c7822c7f 100644 --- a/LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/Model/ShareSubscriptionAccessModel.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ShareSubscriptionAccessModel.swift @@ -17,17 +17,7 @@ // import Foundation - -extension URL { - - static var addEmailToSubscription: URL { - URL(string: "https://abrown.duckduckgo.com/subscriptions/add-email")! - } - - static var manageSubscriptionEmail: URL { - URL(string: "https://abrown.duckduckgo.com/subscriptions/manage")! - } -} +import Subscription public final class ShareSubscriptionAccessModel: SubscriptionAccessModel { public var title = UserText.shareModalTitle @@ -74,7 +64,19 @@ public final class ShareSubscriptionAccessModel: SubscriptionAccessModel { case .appleID: actionHandlers.restorePurchases() case .email: - actionHandlers.openURLHandler(hasEmail ? .manageSubscriptionEmail : .addEmailToSubscription) + let url: URL = hasEmail ? .manageSubscriptionEmail : .addEmailToSubscription + + Task { + if SubscriptionPurchaseEnvironment.current == .appStore { + if #available(macOS 12.0, iOS 15.0, *) { + await AppStoreAccountManagementFlow.refreshAuthTokenIfNeeded() + } + } + + DispatchQueue.main.async { + self.actionHandlers.openURLHandler(url) + } + } case .sync: actionHandlers.goToSyncPreferences() } diff --git a/LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/Model/SubscriptionAccessModel.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/SubscriptionAccessModel.swift similarity index 91% rename from LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/Model/SubscriptionAccessModel.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/SubscriptionAccessModel.swift index aee70c96a9..bbc2922b8f 100644 --- a/LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/Model/SubscriptionAccessModel.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/SubscriptionAccessModel.swift @@ -17,6 +17,7 @@ // import Foundation +import Subscription public protocol SubscriptionAccessModel { var items: [AccessChannel] { get } @@ -31,7 +32,7 @@ public protocol SubscriptionAccessModel { } extension SubscriptionAccessModel { - public var items: [AccessChannel] { AccessChannel.allCases } + public var items: [AccessChannel] { SubscriptionPurchaseEnvironment.current == .appStore ? [.appleID, .email] : [.email] } public func descriptionHeader(for channel: AccessChannel) -> String? { nil } } diff --git a/LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/SubscriptionAccessRow.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/SubscriptionAccessRow.swift similarity index 100% rename from LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/SubscriptionAccessRow.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/SubscriptionAccessRow.swift diff --git a/LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/SubscriptionAccessView.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/SubscriptionAccessView.swift similarity index 97% rename from LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/SubscriptionAccessView.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/SubscriptionAccessView.swift index e25354b7e1..bfc0fc386f 100644 --- a/LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/SubscriptionAccessView.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/SubscriptionAccessView.swift @@ -26,12 +26,13 @@ public struct SubscriptionAccessView: View { private let dismissAction: (() -> Void)? - @State private var selection: AccessChannel? = .appleID + @State private var selection: AccessChannel? @State var fullHeight: CGFloat = 0.0 public init(model: SubscriptionAccessModel, dismiss: (() -> Void)? = nil) { self.model = model self.dismissAction = dismiss + _selection = State(initialValue: model.items.first) } public var body: some View { diff --git a/LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/SubscriptionAccessViewController.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/SubscriptionAccessViewController.swift similarity index 70% rename from LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/SubscriptionAccessViewController.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/SubscriptionAccessViewController.swift index 0e01f67ff2..103f9c927e 100644 --- a/LocalPackages/Subscription/Sources/Subscription/SubscriptionAccessView/SubscriptionAccessViewController.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/SubscriptionAccessViewController.swift @@ -17,33 +17,26 @@ // import AppKit +import Subscription import SwiftUI public final class SubscriptionAccessViewController: NSViewController { + private let accountManager: AccountManager + private var actionHandlers: SubscriptionAccessActionHandlers + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - public init() { + public init(accountManager: AccountManager = AccountManager(), actionHandlers: SubscriptionAccessActionHandlers) { + self.accountManager = accountManager + self.actionHandlers = actionHandlers super.init(nibName: nil, bundle: nil) } public override func loadView() { - let actionHandlers = SubscriptionAccessActionHandlers( - restorePurchases: { - // restore purchases - }, - openURLHandler: { _ in - // open URL here - }, goToSyncPreferences: { - // go to sync - }) - - let model = ActivateSubscriptionAccessModel(actionHandlers: actionHandlers) -// let model = ShareSubscriptionAccessModel(actionHandlers: actionHandlers) - - let subscriptionAccessView = SubscriptionAccessView(model: model, + let subscriptionAccessView = SubscriptionAccessView(model: makeSubscriptionAccessModel(), dismiss: { [weak self] in guard let self = self else { return } self.presentingViewController?.dismiss(self) @@ -60,4 +53,11 @@ public final class SubscriptionAccessViewController: NSViewController { view.addSubview(hostingView) } + private func makeSubscriptionAccessModel() -> SubscriptionAccessModel { + if accountManager.isUserAuthenticated { + ShareSubscriptionAccessModel(actionHandlers: actionHandlers, email: accountManager.email) + } else { + ActivateSubscriptionAccessModel(actionHandlers: actionHandlers) + } + } } diff --git a/LocalPackages/Subscription/Sources/Subscription/UserText.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift similarity index 77% rename from LocalPackages/Subscription/Sources/Subscription/UserText.swift rename to LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift index c183a6064e..c3c9d61d14 100644 --- a/LocalPackages/Subscription/Sources/Subscription/UserText.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift @@ -39,7 +39,10 @@ enum UserText { // MARK: Preferences when subscription is active static let preferencesSubscriptionActiveHeader = NSLocalizedString("subscription.preferences.subscription.active.header", value: "Privacy Pro is active on this device", comment: "Header for the subscription preferences pane when the subscription is active") - static let preferencesSubscriptionActiveCaption = NSLocalizedString("subscription.preferences.subscription.active.caption", value: "Your monthly Privacy Pro subscription renews on April 20, 2027.", comment: "Caption for the subscription preferences pane when the subscription is active") + static func preferencesSubscriptionActiveCaption(formattedDate: String) -> String { + let localized = NSLocalizedString("subscription.preferences.subscription.active.caption", value: "Your monthly Privacy Pro subscription renews on %@", comment: "Caption for the subscription preferences pane when the subscription is active") + return String(format: localized, formattedDate) + } static let addToAnotherDeviceButton = NSLocalizedString("subscription.preferences.add.to.another.device.button", value: "Add to Another Device…", comment: "Button to add subscription to another device") static let manageSubscriptionButton = NSLocalizedString("subscription.preferences.manage.subscription.button", value: "Manage Subscription", comment: "Button to manage subscription") @@ -86,4 +89,23 @@ enum UserText { static let manageEmailButton = NSLocalizedString("subscription.modal.manage.email.button", value: "Manage", comment: "Button for opening manage email address page") static let enterEmailButton = NSLocalizedString("subscription.modal.enter.email.button", value: "Enter Email", comment: "Button for opening page to enter email address") static let goToSyncSettingsButton = NSLocalizedString("subscription.modal.sync.settings.button", value: "Go to Sync Settings", comment: "Button to open sync settings") + + // MARK: - Alerts + static let okButtonTitle = NSLocalizedString("subscription.alert.button.ok", value: "OK", comment: "Alert button for confirming it") + static let cancelButtonTitle = NSLocalizedString("subscription.alert.button.cancel", value: "Cancel", comment: "Alert button for dismissing it") + static let viewPlansButtonTitle = NSLocalizedString("subscription.alert.button.view.plans", value: "View Plans", comment: "Alert button for viewing subscription plans") + static let restoreButtonTitle = NSLocalizedString("subscription.alert.button.restore", value: "Restore", comment: "Alert button for restoring past subscription purchases") + + static let somethingWentWrongAlertTitle = NSLocalizedString("subscription.alert.something.went.wrong.title", value: "Something Went Wrong", comment: "Alert title when unknown error has occurred") + static let somethingWentWrongAlertDescription = NSLocalizedString("subscription.alert.something.went.wrong.description", value: "The App Store was not able to process your purchase. Please try again later.", comment: "Alert message when unknown error has occurred") + static let somethingWentWrongStripeAlertDescription = NSLocalizedString("subscription.alert.something.went.wrong.stripe.description", value: "We were not able to start your purchase process. Please try again later.", comment: "Alert message when unknown error has occurred") + + static let subscriptionNotFoundAlertTitle = NSLocalizedString("subscription.alert.subscription.not.found.title", value: "Subscription Not Found", comment: "Alert title when subscription was not found") + static let subscriptionNotFoundAlertDescription = NSLocalizedString("subscription.alert.subscription.not.found.description", value: "We couldn’t find a subscription associated with this Apple ID.", comment: "Alert message when subscription was not found") + + static let subscriptionInactiveAlertTitle = NSLocalizedString("subscription.alert.subscription.inactive.title", value: "Subscription Not Found", comment: "Alert title when subscription was inactive") + static let subscriptionInactiveAlertDescription = NSLocalizedString("subscription.alert.subscription.inactive.description", value: "The subscription associated with this Apple ID is no longer active.", comment: "Alert message when subscription was inactive") + + static let subscriptionFoundAlertTitle = NSLocalizedString("subscription.alert.subscription.found.title", value: "Subscription Found", comment: "Alert title when subscription was found") + static let subscriptionFoundAlertDescription = NSLocalizedString("subscription.alert.subscription.found.description", value: "We found a subscription associated with this Apple ID.", comment: "Alert message when subscription was found") } diff --git a/LocalPackages/Account/Tests/AccountTests/AccountsTests.swift b/LocalPackages/SubscriptionUI/Tests/SubscriptionUITests/SubscriptionUITests.swift similarity index 65% rename from LocalPackages/Account/Tests/AccountTests/AccountsTests.swift rename to LocalPackages/SubscriptionUI/Tests/SubscriptionUITests/SubscriptionUITests.swift index a34e6849f8..2e2cf8dcee 100644 --- a/LocalPackages/Account/Tests/AccountTests/AccountsTests.swift +++ b/LocalPackages/SubscriptionUI/Tests/SubscriptionUITests/SubscriptionUITests.swift @@ -1,5 +1,5 @@ // -// AccountsTests.swift +// SubscriptionUITests.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -17,13 +17,7 @@ // import XCTest -@testable import Account +@testable import SubscriptionUI -final class AccountTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(Account().text, "Hello, World!") - } +final class SubscriptionUITests: XCTestCase { } diff --git a/LocalPackages/SwiftUIExtensions/Package.swift b/LocalPackages/SwiftUIExtensions/Package.swift index 7ca05a42ec..c15221a974 100644 --- a/LocalPackages/SwiftUIExtensions/Package.swift +++ b/LocalPackages/SwiftUIExtensions/Package.swift @@ -13,7 +13,7 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "98.0.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "99.0.2"), ], targets: [ .target( diff --git a/LocalPackages/SyncUI/Package.swift b/LocalPackages/SyncUI/Package.swift index 6f485526ca..dae7ebfe0d 100644 --- a/LocalPackages/SyncUI/Package.swift +++ b/LocalPackages/SyncUI/Package.swift @@ -13,7 +13,7 @@ let package = Package( ], dependencies: [ .package(path: "../SwiftUIExtensions"), - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "98.0.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "99.0.2"), ], targets: [ .target( diff --git a/LocalPackages/SystemExtensionManager/Package.swift b/LocalPackages/SystemExtensionManager/Package.swift index 722d4ae0a1..f4891de858 100644 --- a/LocalPackages/SystemExtensionManager/Package.swift +++ b/LocalPackages/SystemExtensionManager/Package.swift @@ -16,7 +16,7 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "98.0.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "99.0.2"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. diff --git a/LocalPackages/XPCHelper/Package.swift b/LocalPackages/XPCHelper/Package.swift index 74bc880ffe..dcedd7be38 100644 --- a/LocalPackages/XPCHelper/Package.swift +++ b/LocalPackages/XPCHelper/Package.swift @@ -30,7 +30,7 @@ let package = Package( .library(name: "XPCHelper", targets: ["XPCHelper"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "98.0.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "99.0.2"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. diff --git a/UnitTests/Common/Progress/ProgressEstimationTests.swift b/UnitTests/Common/Progress/ProgressEstimationTests.swift index a55d8317e1..0cf8c562c7 100644 --- a/UnitTests/Common/Progress/ProgressEstimationTests.swift +++ b/UnitTests/Common/Progress/ProgressEstimationTests.swift @@ -20,7 +20,7 @@ import XCTest @testable import DuckDuckGo_Privacy_Browser final class ProgressEstimationTests: XCTestCase { - typealias Event = ProgressView.ProgressEvent + typealias Event = LoadingProgressView.ProgressEvent let milestones = [ Event(progress: 0.25, interval: 0.0), @@ -34,7 +34,7 @@ final class ProgressEstimationTests: XCTestCase { func testWhenProgressIsStartedEstimationIsInitial() { let event = Event.nextStep(for: 0.0, lastProgressEvent: nil, milestones: milestones) - XCTAssertEqual(event, Event(progress: milestones[0].progress, interval: ProgressView.Constants.animationDuration)) + XCTAssertEqual(event, Event(progress: milestones[0].progress, interval: LoadingProgressView.Constants.animationDuration)) } func testWhenProgressIsFinishedThenNoNextStep() { @@ -73,7 +73,7 @@ final class ProgressEstimationTests: XCTestCase { func testWhenProgressMovesTooFastEstimationIsNotTooShort() { let event = Event.nextStep(for: 0.85, lastProgressEvent: Event(progress: 0.65, interval: 18.0 / 11.0), milestones: milestones) - XCTAssertEqual(event, Event(progress: 1.0, interval: milestones.last!.interval * ProgressView.Constants.minMultiplier)) + XCTAssertEqual(event, Event(progress: 1.0, interval: milestones.last!.interval * LoadingProgressView.Constants.minMultiplier)) } func testWhenProgressMovesSlowerEstimationIsLonger() { diff --git a/UnitTests/DataImport/BookmarksHTMLImporterTests.swift b/UnitTests/DataImport/BookmarksHTMLImporterTests.swift index cf4732e6b5..a96937270e 100644 --- a/UnitTests/DataImport/BookmarksHTMLImporterTests.swift +++ b/UnitTests/DataImport/BookmarksHTMLImporterTests.swift @@ -26,7 +26,7 @@ final class BookmarksHTMLImporterTests: XCTestCase { override func setUpWithError() throws { underlyingBookmarkImporter = MockBookmarkImporter(importBookmarks: { _, _ in - .init(successful: 0, duplicates: 0, failed: 0) + .init(successful: 0, duplicates: 0, failed: 0) }) } @@ -47,53 +47,28 @@ final class BookmarksHTMLImporterTests: XCTestCase { XCTAssertEqual(dataImporter.totalBookmarks, 0) } - func testWhenValidBookmarksFileIsLoadedThenBookmarksImportIsSuccessful() { - let importExpectation = expectation(description: "Import Bookmarks") - let completionExpectation = expectation(description: "Import Bookmarks Completion") - let expectedImportResult = BookmarkImportResult(successful: 0, duplicates: 0, failed: 0) - + func testWhenValidBookmarksFileIsLoadedThenBookmarksImportIsSuccessful() async { underlyingBookmarkImporter.importBookmarks = { (_, _) in - importExpectation.fulfill() - return expectedImportResult + .init(successful: 42, duplicates: 2, failed: 3) } dataImporter = .init(fileURL: bookmarksFileURL("bookmarks_safari.html"), bookmarkImporter: underlyingBookmarkImporter) - dataImporter.importData(types: [.bookmarks], from: nil) { result in - switch result { - case let .success(summary): - XCTAssertEqual(summary, .init(bookmarksResult: expectedImportResult)) - default: - XCTFail("unexpected import error") - } - completionExpectation.fulfill() - } + let result = await dataImporter.importData(types: [.bookmarks]).task.value - waitForExpectations(timeout: 1) + XCTAssertEqual(result, [.bookmarks: .success(.init(successful: 42, duplicate: 2, failed: 3))]) } - func testWhenInvalidBookmarksFileIsLoadedThenBookmarksImportReturnsFailure() { - let completionExpectation = expectation(description: "Import Bookmarks Completion") - let expectedImportResult = BookmarkImportResult(successful: 0, duplicates: 0, failed: 0) - + func testWhenInvalidBookmarksFileIsLoadedThenBookmarksImportReturnsFailure() async { underlyingBookmarkImporter.importBookmarks = { (_, _) in - XCTFail("unexpected import success") - return expectedImportResult + .init(successful: 0, duplicates: 0, failed: 0) } dataImporter = .init(fileURL: bookmarksFileURL("bookmarks_invalid.html"), bookmarkImporter: underlyingBookmarkImporter) - dataImporter.importData(types: [.bookmarks], from: nil) { result in - switch result { - case let .failure(error as BookmarkHTMLReader.ImportError): - XCTAssertEqual(error.type, .parseXml) - XCTAssertEqual((error.underlyingError as NSError?)?.domain, XMLParser.errorDomain) - default: - XCTFail("unexpected \(result)") - } - completionExpectation.fulfill() - } + let result = await dataImporter.importData(types: [.bookmarks]).task.value - waitForExpectations(timeout: 1) + XCTAssertEqual(result, [.bookmarks: .failure(BookmarkHTMLReader.ImportError(type: .parseXml, underlyingError: NSError(domain: XMLParser.errorDomain, code: XMLParser.ErrorCode.prematureDocumentEndError.rawValue)))]) } + } diff --git a/UnitTests/DataImport/BrowserProfileTests.swift b/UnitTests/DataImport/BrowserProfileTests.swift index 408d99aff2..1c2fb73f81 100644 --- a/UnitTests/DataImport/BrowserProfileTests.swift +++ b/UnitTests/DataImport/BrowserProfileTests.swift @@ -20,7 +20,7 @@ import Foundation import XCTest @testable import DuckDuckGo_Privacy_Browser -class BrowserProfileListTests: XCTestCase { +class BrowserProfileTests: XCTestCase { let mockURL = URL(string: "/Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/")! @@ -29,7 +29,7 @@ class BrowserProfileListTests: XCTestCase { let fileStore = FileStoreMock() let profile = DataImport.BrowserProfile(browser: .firefox, profileURL: profileURL, fileStore: fileStore) - XCTAssertFalse(profile.hasBrowserData) + XCTAssertTrue(profile.validateProfileData()?.containsValidData == false) } func testWhenBrowserProfileHasURLWithChromiumLoginData_ThenHasLoginDataIsTrue() { @@ -39,7 +39,7 @@ class BrowserProfileListTests: XCTestCase { fileStore.directoryStorage[profileURL.absoluteString] = ["Login Data"] - XCTAssertTrue(profile.hasBrowserData) + XCTAssertTrue(profile.validateProfileData()?.containsValidData == true) } func testWhenBrowserProfileHasURLWithFirefoxLoginData_ThenHasLoginDataIsTrue() { @@ -48,13 +48,13 @@ class BrowserProfileListTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .firefox, profileURL: profileURL, fileStore: fileStore) fileStore.directoryStorage[profileURL.absoluteString] = ["key4.db"] - XCTAssertFalse(profile.hasBrowserData) + XCTAssertTrue(profile.validateProfileData()?.containsValidData == false) fileStore.directoryStorage[profileURL.absoluteString] = ["logins.json"] - XCTAssertFalse(profile.hasBrowserData) + XCTAssertTrue(profile.validateProfileData()?.containsValidData == false) fileStore.directoryStorage[profileURL.absoluteString] = ["logins.json", "key4.db"] - XCTAssertTrue(profile.hasBrowserData) + XCTAssertTrue(profile.validateProfileData()?.containsValidData == true) } func testWhenGettingProfileName_AndProfileHasNoDetectedName_ThenTheDirectoryNameIsUsed() { @@ -89,7 +89,7 @@ class BrowserProfileListTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) XCTAssertEqual(profile.profileName, "User Name (profile@duck.com)") - XCTAssertTrue(profile.hasNonDefaultProfileName) + XCTAssertNotNil(profile.profilePreferences?.profileName) } func testWhenGettingProfileName_AndProfileHasNoDetectedChromiumName_ThenDetectedNameIsUsed() { @@ -111,7 +111,7 @@ class BrowserProfileListTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) XCTAssertEqual(profile.profileName, "ChromeProfile") - XCTAssertTrue(profile.hasNonDefaultProfileName) + XCTAssertNotNil(profile.profilePreferences?.profileName) } func testWhenGettingProfileName_AndChromiumPreferencesAreDetected_AndProfileNameIsSystemProfile_ThenProfileHasDefaultProfileName() { @@ -132,11 +132,121 @@ class BrowserProfileListTests: XCTestCase { let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) XCTAssertEqual(profile.profileName, "System Profile") - XCTAssertFalse(profile.hasNonDefaultProfileName) + XCTAssertEqual(profile.profilePreferences?.profileName, "ChromeProfile") } private func profile(named name: String) -> URL { return mockURL.appendingPathComponent(name) } + func testWhenLastChromiumVersionIsPresentInProfile_InstalledAppsReturnsMajorVersion() { + let profileURL = profile(named: "System Profile") + let fileStore = FileStoreMock() + + let json = """ + { + "profile": { + "created_by_version": "118.0.5993.54" + }, + "extensions": { + "last_chrome_version": "120.0.1111.42" + } + } + """ + + fileStore.storage["Preferences"] = json.utf8data + fileStore.directoryStorage[profileURL.absoluteString] = ["Preferences"] + + let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) + + XCTAssertEqual(profile.appVersion, "118.0.5993.54") + XCTAssertEqual(profile.installedAppsMajorVersionDescription(), "118") + XCTAssertEqual(DataImport.Source.chrome.installedAppsMajorVersionDescription(selectedProfile: profile), "118") + } + + func testWhenLastOperaVersionIsPresent_InstalledAppsReturnsMajorVersion() { + let profileURL = profile(named: "System Profile") + let fileStore = FileStoreMock() + + let json = """ + { + "profile": { + }, + "extensions": { + "last_opera_version": "117.0.5938.13" + } + } + """ + + fileStore.storage["Preferences"] = json.utf8data + fileStore.directoryStorage[profileURL.absoluteString] = ["Preferences"] + + let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) + + XCTAssertEqual(profile.appVersion, "117.0.5938.13") + XCTAssertEqual(profile.installedAppsMajorVersionDescription(), "117") + XCTAssertEqual(DataImport.Source.chrome.installedAppsMajorVersionDescription(selectedProfile: profile), "117") + } + + func testWhenLastChromiumVersionIsNotPresentInProfile_CreatedByVersionIsReturned() { + let profileURL = profile(named: "System Profile") + let fileStore = FileStoreMock() + + let json = """ + { + "profile": { + "created_by_version": "118.0.5993.54" + } + } + """ + + fileStore.storage["Preferences"] = json.utf8data + fileStore.directoryStorage[profileURL.absoluteString] = ["Preferences"] + + let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) + + XCTAssertEqual(profile.appVersion, "118.0.5993.54") + XCTAssertEqual(profile.installedAppsMajorVersionDescription(), "118") + XCTAssertEqual(DataImport.Source.chrome.installedAppsMajorVersionDescription(selectedProfile: profile), "118") + } + + func testWhenFirefoxLastVersionIsPresentInProfile_LastVersionIsReturned() { + let profileURL = profile(named: "Firefox.default") + let fileStore = FileStoreMock() + + let conf = """ + [Compatibility] + LastVersion = 118.0.1_20230927232528/20230927232528 + LastOSABI=Darwin_aarch64-gcc3 + LastPlatformDir=/Applications/Firefox.app/Contents/Resources + LastAppDir=/Applications/Firefox.app/Contents/Resources/browser + """ + + fileStore.storage["compatibility.ini"] = conf.utf8data + fileStore.directoryStorage[profileURL.absoluteString] = ["compatibility.ini"] + + let profile = DataImport.BrowserProfile(browser: .firefox, profileURL: profileURL, fileStore: fileStore) + + XCTAssertEqual(profile.appVersion, "118.0.1_20230927232528/20230927232528") + XCTAssertEqual(profile.installedAppsMajorVersionDescription(), "118") + XCTAssertEqual(DataImport.Source.chrome.installedAppsMajorVersionDescription(selectedProfile: profile), "118") + } + + func testWhenNoVersionInProfile_InstalledAppsVersionsReturned() { + let profileURL = profile(named: "System Profile") + let fileStore = FileStoreMock() + + let json = """ + { "profile": {} } + """ + + fileStore.storage["Preferences"] = json.utf8data + fileStore.directoryStorage[profileURL.absoluteString] = ["Preferences"] + + let profile = DataImport.BrowserProfile(browser: .chrome, profileURL: profileURL, fileStore: fileStore) + + XCTAssertNil(profile.appVersion) + XCTAssertEqual(profile.installedAppsMajorVersionDescription()?.sorted(), DataImport.Source.chrome.installedAppsMajorVersionDescription(selectedProfile: profile)?.sorted()) + } + } diff --git a/UnitTests/DataImport/CSVImporterTests.swift b/UnitTests/DataImport/CSVImporterTests.swift index 9c39e26414..8a096832d2 100644 --- a/UnitTests/DataImport/CSVImporterTests.swift +++ b/UnitTests/DataImport/CSVImporterTests.swift @@ -68,54 +68,26 @@ class CSVImporterTests: XCTestCase { XCTAssertEqual(logins, [ImportedLoginCredential(title: "Some Title", url: "duck.com", username: "username", password: "p4ssw0rd")]) } - func testWhenImportingCSVDataFromTheFileSystem_AndNoTitleIsIncluded_ThenLoginCredentialsAreImported() { + func testWhenImportingCSVDataFromTheFileSystem_AndNoTitleIsIncluded_ThenLoginCredentialsAreImported() async { let mockLoginImporter = MockLoginImporter() let file = "https://example.com/,username,password" let savedFileURL = temporaryFileCreator.persist(fileContents: file.data(using: .utf8)!, named: "test.csv")! - let csvImporter = CSVImporter(fileURL: savedFileURL, loginImporter: mockLoginImporter) - - let expectation = expectation(description: #function) - csvImporter.importData(types: [.logins], from: nil) { result in - switch result { - case .success(let summary): - let expectedSummary = DataImport.Summary(bookmarksResult: nil, - loginsResult: .completed(.init(successfulImports: ["username"], - duplicateImports: [], - failedImports: []))) - XCTAssertEqual(summary, expectedSummary) - XCTAssertEqual(mockLoginImporter.importedLogins, expectedSummary) - case .failure(let error): - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - - waitForExpectations(timeout: 1.0, handler: nil) + let csvImporter = CSVImporter(fileURL: savedFileURL, loginImporter: mockLoginImporter, defaultColumnPositions: nil) + + let result = await csvImporter.importData(types: [.passwords]).task.value + + XCTAssertEqual(result, [.passwords: .success(.init(successful: 1, duplicate: 0, failed: 0))]) } - func testWhenImportingCSVDataFromTheFileSystem_AndTitleIsIncluded_ThenLoginCredentialsAreImported() { + func testWhenImportingCSVDataFromTheFileSystem_AndTitleIsIncluded_ThenLoginCredentialsAreImported() async { let mockLoginImporter = MockLoginImporter() let file = "title,https://example.com/,username,password" let savedFileURL = temporaryFileCreator.persist(fileContents: file.data(using: .utf8)!, named: "test.csv")! - let csvImporter = CSVImporter(fileURL: savedFileURL, loginImporter: mockLoginImporter) - - let expectation = expectation(description: #function) - csvImporter.importData(types: [.logins], from: nil) { result in - switch result { - case .success(let summary): - let expectedSummary = DataImport.Summary(bookmarksResult: nil, - loginsResult: .completed(.init(successfulImports: ["username"], - duplicateImports: [], - failedImports: []))) - XCTAssertEqual(summary, expectedSummary) - XCTAssertEqual(mockLoginImporter.importedLogins, expectedSummary) - case .failure(let error): - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - - waitForExpectations(timeout: 1.0, handler: nil) + let csvImporter = CSVImporter(fileURL: savedFileURL, loginImporter: mockLoginImporter, defaultColumnPositions: nil) + + let result = await csvImporter.importData(types: [.passwords]).task.value + + XCTAssertEqual(result, [.passwords: .success(.init(successful: 1, duplicate: 0, failed: 0))]) } func testWhenInferringColumnPostions_AndColumnsAreValid_AndTitleIsIncluded_ThenPositionsAreCalculated() { @@ -146,3 +118,11 @@ class CSVImporterTests: XCTestCase { } } + +extension CSVImporter.ColumnPositions { + + init?(csvValues: [String]) { + self.init(csv: [csvValues, Array(repeating: "", count: csvValues.count)]) + } + +} diff --git a/UnitTests/DataImport/CSVParserTests.swift b/UnitTests/DataImport/CSVParserTests.swift index bdae7c30ba..b42875d5ac 100644 --- a/UnitTests/DataImport/CSVParserTests.swift +++ b/UnitTests/DataImport/CSVParserTests.swift @@ -22,46 +22,71 @@ import XCTest final class CSVParserTests: XCTestCase { - func testWhenParsingMultipleRowsThenMultipleArraysAreReturned() { - let string = "line 1\nline 2" - let parsed = CSVParser.parse(string: string) + func testWhenParsingMultipleRowsThenMultipleArraysAreReturned() throws { + let string = """ + line 1 + line 2 + """ + let parsed = try CSVParser().parse(string: string) XCTAssertEqual(parsed, [["line 1"], ["line 2"]]) } - func testWhenParsingRowsWithVariableNumbersOfEntriesThenParsingSucceeds() { - let string = "one\ntwo,three\nfour,five,six" - let parsed = CSVParser.parse(string: string) + func testControlCharactersAreIgnored() throws { + let string = """ + \u{FEFF}line\u{10} 1\u{10} + line 2\u{FEFF} + """ + let parsed = try CSVParser().parse(string: string) - XCTAssertEqual(parsed, [["one"], ["two", "three"], ["four", "five", "six"]]) + XCTAssertEqual(parsed, [["line 1"], ["line 2"]]) } - func testWhenParsingRowsSurroundedByQuotesThenQuotesAreRemoved() { + func testWhenParsingRowsWithVariableNumbersOfEntriesThenParsingSucceeds() throws { let string = """ - "url","username","password" + one + two;three; + four;five;six """ + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["one"], ["two", "three", ""], ["four", "five", "six"]]) + } - let parsed = CSVParser.parse(string: string) + func testWhenParsingRowsSurroundedByQuotesThenQuotesAreRemoved() throws { + let string = """ + "url","username", "password" + """ + " " + + let parsed = try CSVParser().parse(string: string) XCTAssertEqual(parsed, [["url", "username", "password"]]) } - func testWhenParsingRowsWithAnEscapedQuoteThenQuoteIsUnescaped() { + func testWhenParsingMalformedCSV_ParserThrows() { + let string = """ + "url","user"name","password" + """ + + XCTAssertThrowsError(try CSVParser().parse(string: string)) + } + + func testWhenParsingRowsWithAnEscapedQuoteThenQuoteIsUnescaped() throws { let string = """ - "url","username","password\\\"with\\\"quotes" + "url","username","password\\""with""quotes" """ - let parsed = CSVParser.parse(string: string) + let parsed = try CSVParser().parse(string: string) - XCTAssertEqual(parsed, [["url", "username", "password\"with\"quotes"]]) + XCTAssertEqual(parsed, [["url", "username", "password\\\"with\"quotes"]]) } - func testWhenParsingQuotedRowsContainingCommasThenTheyAreTreatedAsOneColumnEntry() { + func testWhenParsingQuotedRowsContainingCommasThenTheyAreTreatedAsOneColumnEntry() throws { let string = """ "url","username","password,with,commas" """ - let parsed = CSVParser.parse(string: string) + let parsed = try CSVParser().parse(string: string) XCTAssertEqual(parsed, [["url", "username", "password,with,commas"]]) } diff --git a/UnitTests/DataImport/ChromiumBookmarksReaderTests.swift b/UnitTests/DataImport/ChromiumBookmarksReaderTests.swift index 623a26be9f..577202c75f 100644 --- a/UnitTests/DataImport/ChromiumBookmarksReaderTests.swift +++ b/UnitTests/DataImport/ChromiumBookmarksReaderTests.swift @@ -23,7 +23,7 @@ import XCTest class ChromiumBookmarksReaderTests: XCTestCase { func testImportingBookmarks() { - let bookmarksReader = ChromiumBookmarksReader(chromiumDataDirectoryURL: resourceURL(), source: .chrome) + let bookmarksReader = ChromiumBookmarksReader(chromiumDataDirectoryURL: resourceURL()) let bookmarks = bookmarksReader.readBookmarks() guard case let .success(bookmarks) = bookmarks else { diff --git a/UnitTests/DataImport/ChromiumFaviconsReaderTests.swift b/UnitTests/DataImport/ChromiumFaviconsReaderTests.swift index e77edb0db2..1bf9e67935 100644 --- a/UnitTests/DataImport/ChromiumFaviconsReaderTests.swift +++ b/UnitTests/DataImport/ChromiumFaviconsReaderTests.swift @@ -23,7 +23,7 @@ import Foundation class ChromiumFaviconsReaderTests: XCTestCase { func testReadingFavicons() { - let faviconsReader = ChromiumFaviconsReader(chromiumDataDirectoryURL: resourceURL(), source: .chrome) + let faviconsReader = ChromiumFaviconsReader(chromiumDataDirectoryURL: resourceURL()) let favicons = faviconsReader.readFavicons() guard case let .success(favicons) = favicons else { diff --git a/UnitTests/DataImport/ChromiumLoginReaderTests.swift b/UnitTests/DataImport/ChromiumLoginReaderTests.swift index 1661808cfa..ba59c59bba 100644 --- a/UnitTests/DataImport/ChromiumLoginReaderTests.swift +++ b/UnitTests/DataImport/ChromiumLoginReaderTests.swift @@ -41,7 +41,6 @@ class ChromiumLoginReaderTests: XCTestCase { let reader = ChromiumLoginReader( chromiumDataDirectoryURL: ChromiumLoginStore.v32.databaseDirectoryURL, source: .chrome, - processName: "Chrome", decryptionKey: ChromiumLoginStore.v32.decryptionKey ) @@ -70,7 +69,6 @@ class ChromiumLoginReaderTests: XCTestCase { let reader = ChromiumLoginReader( chromiumDataDirectoryURL: ChromiumLoginStore.legacy.databaseDirectoryURL, source: .chrome, - processName: "Chrome", decryptionKey: ChromiumLoginStore.legacy.decryptionKey ) @@ -90,7 +88,6 @@ class ChromiumLoginReaderTests: XCTestCase { let reader = ChromiumLoginReader( chromiumDataDirectoryURL: ChromiumLoginStore.legacy.databaseDirectoryURL, source: .chrome, - processName: "Chrome", decryptionKeyPrompt: mockPrompt ) @@ -109,7 +106,6 @@ class ChromiumLoginReaderTests: XCTestCase { let reader = ChromiumLoginReader( chromiumDataDirectoryURL: ChromiumLoginStore.legacy.databaseDirectoryURL, source: .chrome, - processName: "Chrome", decryptionKeyPrompt: mockPrompt ) diff --git a/UnitTests/DataImport/DataImportMocks.swift b/UnitTests/DataImport/DataImportMocks.swift index 50df6cf6f5..7c83279142 100644 --- a/UnitTests/DataImport/DataImportMocks.swift +++ b/UnitTests/DataImport/DataImportMocks.swift @@ -20,13 +20,12 @@ import Foundation @testable import DuckDuckGo_Privacy_Browser final class MockLoginImporter: LoginImporter { + var importedLogins: DataImportSummary? - var importedLogins: DataImport.Summary? +func importLogins(_ logins: [DuckDuckGo_Privacy_Browser.ImportedLoginCredential], progressCallback: @escaping (Int) throws -> Void) throws -> DataImport.DataTypeSummary { + let summary = DataImport.DataTypeSummary(successful: logins.count, duplicate: 0, failed: 0) - func importLogins(_ logins: [ImportedLoginCredential]) throws -> DataImport.CompletedLoginsResult { - let summary = DataImport.CompletedLoginsResult(successfulImports: logins.map(\.username), duplicateImports: [], failedImports: []) - - self.importedLogins = .init(bookmarksResult: nil, loginsResult: .completed(summary)) + self.importedLogins = [.passwords: .success(summary)] return summary } @@ -36,10 +35,10 @@ struct BookmarkImportErrorMock: Error {} struct MockBookmarkImporter: BookmarkImporter { - func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarkImportResult { + func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarksImportSummary { return importBookmarks(bookmarks, source) } - var importBookmarks: (ImportedBookmarks, BookmarkImportSource) -> BookmarkImportResult + var importBookmarks: (ImportedBookmarks, BookmarkImportSource) -> BookmarksImportSummary } diff --git a/UnitTests/DataImport/DataImportSourceViewModelTests.swift b/UnitTests/DataImport/DataImportSourceViewModelTests.swift new file mode 100644 index 0000000000..54aa45dd7d --- /dev/null +++ b/UnitTests/DataImport/DataImportSourceViewModelTests.swift @@ -0,0 +1,69 @@ +// +// DataImportSourceViewModelTests.swift +// +// Copyright © 2023 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 +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class DataImportSourceViewModelTests: XCTestCase { + + func testImportSourcesContainsMandatorySources() { + let model = DataImportSourceViewModel(selectedSource: .csv) + + XCTAssertEqual(model.importSources.compactMap { $0 }, DataImport.Source.allCases.filter(\.canImportData)) + + XCTAssertTrue(model.importSources.contains(.safari)) + XCTAssertTrue(model.importSources.contains(.bitwarden)) + XCTAssertTrue(model.importSources.contains(.onePassword8)) + XCTAssertTrue(model.importSources.contains(.lastPass)) + XCTAssertTrue(model.importSources.contains(.csv)) + XCTAssertTrue(model.importSources.contains(.bookmarksHTML)) + + XCTAssertEqual(model.selectedSourceIndex, model.importSources.firstIndex(of: .csv)) + } + + func testSeparatorsBeforeOnePasswordAndCSVImportArePresent() { + let model = DataImportSourceViewModel(importSources: [ + .chrome, + .bitwarden, + .csv, + .bookmarksHTML, + .onePassword8, + .onePassword7, + .lastPass, + ], selectedSource: .bitwarden) + + XCTAssertEqual(model.importSources, [ + .chrome, + .bitwarden, + nil, + .csv, + .bookmarksHTML, + nil, + .onePassword8, + .onePassword7, + .lastPass, + ]) + } + + func testWhenUnavailableSelectedSourcePassed_selectedSourceIndexIs0() { + let model = DataImportSourceViewModel(importSources: [.csv], selectedSource: .bitwarden) + XCTAssertEqual(model.selectedSourceIndex, 0) + } + +} diff --git a/UnitTests/DataImport/DataImportViewModelTests.swift b/UnitTests/DataImport/DataImportViewModelTests.swift new file mode 100644 index 0000000000..ee6c5ad4b4 --- /dev/null +++ b/UnitTests/DataImport/DataImportViewModelTests.swift @@ -0,0 +1,1813 @@ +// +// DataImportViewModelTests.swift +// +// Copyright © 2023 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 Common +import Foundation +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +// swiftlint:disable:next type_body_length +@MainActor final class DataImportViewModelTests: XCTestCase { + + typealias Source = DataImport.Source + typealias BrowserProfileList = DataImport.BrowserProfileList + typealias BrowserProfile = DataImport.BrowserProfile + typealias DataType = DataImport.DataType + typealias DataTypeSummary = DataImport.DataTypeSummary + + var model: DataImportViewModel! + + override func tearDown() { + model = nil + importTask = nil + openPanelCallback = nil + } + + // MARK: - Tests + + // Validate supported DataType-s of all Import Sources + func testDataImportSourceSupportedDataTypes() { + for source in Source.allCases { + if source.initialScreen == .profileAndDataTypesPicker { + if source == .tor { + XCTAssertEqual(source.supportedDataTypes, [.bookmarks], source.importSourceName) + } else { + XCTAssertEqual(source.supportedDataTypes, [.bookmarks, .passwords], source.importSourceName) + } + } else { + if source == .bookmarksHTML { + XCTAssertEqual(source.supportedDataTypes, [.bookmarks], source.importSourceName) + } else { + XCTAssertEqual(source.supportedDataTypes, [.passwords], source.importSourceName) + } + } + } + } + + func testWhenPreferredImportSourcesAvailable_firstPreferredSourceIsSelected() { + model = DataImportViewModel(availableImportSources: [.safari, .csv, .bitwarden], preferredImportSources: [.firefox, .chrome, .bitwarden, .safari]) + XCTAssertEqual(model.importSource, .bitwarden) + } + + func testWhenModelIsInstantiated_initialScreenIsShown() { + for source in Source.allCases { + model = DataImportViewModel(importSource: source) + XCTAssertEqual(model.screen, source.initialScreen, "\(source)") + } + } + + func testImportTaskCancellation() async throws { + setupModel(with: .firefox, profiles: [BrowserProfile.test]) + + let e1 = expectation(description: "task started") + let e2 = expectation(description: "task cancelled") + self.importTask = { _, progress in + e1.fulfill() + await Task.yield() // let cancellation in + + do { + try? await Task.sleep(interval: 10) // forever + try progress(.importingPasswords(numberOfPasswords: nil, fraction: 0)) + } catch is CancellationError { + e2.fulfill() + } catch { + XCTFail("unexpected \(error)") + } + return [:] + } + let eDismissed = expectation(description: "dismissed") + Task { @MainActor in + await fulfillment(of: [e1], timeout: 1) + var model = self.model! + model.performAction(for: .cancel) { + eDismissed.fulfill() + } + } + + try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: ThirdPartyBrowser.firefox)) + await fulfillment(of: [e2, eDismissed], timeout: 0) + } + + // MARK: - Browser profiles + + func testWhenNoProfilesAreLoaded_selectedProfileIsNil() { + model = DataImportViewModel(importSource: .safari, loadProfiles: { source in + XCTAssertEqual(source, .safari) + return .init(browser: source, profiles: []) + }) + XCTAssertNil(model.selectedProfile) + } + + func testWhenProfilesAreLoaded_defaultProfileIsSelected() { + setupModel(with: .firefox, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + XCTAssertEqual(model.selectedProfile, .default(for: .firefox)) + } + + func testWhenInvalidProfilesArePresent_onlyValidProfilesShownAndFirstValidProfileSelected() { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { continue } + + model = DataImportViewModel(importSource: source, loadProfiles: { browser in + .init(browser: browser, profiles: [ + .default(for: browser), + .test(for: browser), + .test2(for: browser), + .test3(for: browser), + ]) { profile in + { // swiftlint:disable:this opening_brace + switch profile { + case .default(for: browser): .init(logins: .unavailable(path: "test"), bookmarks: .unavailable(path: "test")) + case .test(for: browser): .init(logins: .available, bookmarks: .unavailable(path: "test")) + case .test2(for: browser): .init(logins: .unavailable(path: "test"), bookmarks: .available) + default: .init(logins: .available, bookmarks: .available) + } + } + } + }) + + XCTAssertEqual(model.browserProfiles?.validImportableProfiles, [ + .test(for: browser), + .test2(for: browser), + .test3(for: browser), + ], "\(browser)") + XCTAssertEqual(model.selectedProfile, .test(for: browser), "\(browser)") + } + } + + func testWhenDefaultProfileIsInvalidAndOnlyOneValidProfileIsPresent_validProfileSelected() { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { continue } + + model = DataImportViewModel(importSource: source, loadProfiles: { browser in + .init(browser: browser, profiles: [ + .default(for: browser), + .test(for: browser), + ]) { profile in + { // swiftlint:disable:this opening_brace + switch profile { + case .default(for: browser): .init(logins: .unavailable(path: "test"), bookmarks: .unavailable(path: "test")) + default: .init(logins: .available, bookmarks: .available) + } + } + } + }) + + XCTAssertEqual(model.selectedProfile, .test(for: browser)) + } + } + + func testWhenNoValidProfilesPresent_noProfilesShownAndDefaultProfileSelected() { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { continue } + + model = DataImportViewModel(importSource: source, loadProfiles: { browser in + .init(browser: browser, profiles: [ + .default(for: browser), + .test(for: browser), + .test2(for: browser), + .test3(for: browser), + ]) { profile in + { // swiftlint:disable:this opening_brace + switch profile { + case .default(for: browser): .init(logins: .unavailable(path: "test"), bookmarks: .unavailable(path: "test")) + case .test(for: browser): .init(logins: .unavailable(path: "test"), bookmarks: .unavailable(path: "test")) + case .test2(for: browser): .init(logins: .unavailable(path: "test"), bookmarks: .unavailable(path: "test")) + default: .init(logins: .unavailable(path: "test"), bookmarks: .unavailable(path: "test")) + } + } + } + }) + + XCTAssertEqual(model.browserProfiles?.validImportableProfiles, [], "\(browser)") + XCTAssertEqual(model.selectedProfile, .default(for: browser), "\(browser)") + } + } + + func testWhenImportSourceChanged_AnotherDefaultProfileIsSelected() { + setupModel(with: .firefox, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + model.selectedProfile = .test(for: .firefox) + model.update(with: .chromium) + XCTAssertEqual(model.selectedProfile, .default(for: .chrome)) + } + + func testWhenNoDefaultProfileIsLoaded_firstProfileIsSelected() { + model = DataImportViewModel(importSource: .chrome, loadProfiles: { .init(browser: $0, profiles: [ .test(for: $0), .test2(for: $0) ]) }) + XCTAssertEqual(model.selectedProfile, .test(for: .chrome)) + } + + // MARK: - Buttons + + func testWhenNextButtonIsClicked_screenForTheButtonIsShown() { + setupModel(with: .safari) + model.performAction(.next(.fileImport(dataType: .bookmarks))) + XCTAssertEqual(model.screen, .fileImport(dataType: .bookmarks)) + } + + func testWhenNoDataTypesSelected_actionButtonDisabled() { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + model = DataImportViewModel(importSource: source) + + XCTAssertEqual(model.selectedDataTypes, source.supportedDataTypes) + for dataType in source.supportedDataTypes { + model.setDataType(dataType, selected: false) + } + XCTAssertEqual(model.selectedDataTypes, []) + + XCTAssertEqual(model.buttons, [.cancel, .initiateImport(disabled: true)]) + } + } + + func testWhenCancelButtonClicked_dismissIsCalled() { + model = DataImportViewModel(importSource: .safari) + + XCTAssertEqual(model.secondaryButton, .cancel) + let e = expectation(description: "dismiss called") + model.performAction(for: .cancel) { + e.fulfill() + } + + waitForExpectations(timeout: 0) + } + + func testWhenProfilesAreLoadedAndImporterCanImportStraightAway_buttonActionsAreCancelAndImport() { + model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in + ImporterMock() + }) + + XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) + XCTAssertEqual(model.actionButton, .initiateImport(disabled: false)) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenProfilesAreLoadedAndImporterRequiresKeyChainPassword_buttonActionsAreCancelAndMoreInfo() { + model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in + ImporterMock(keychainPasswordRequiredFor: [.passwords]) + }) + + XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) + XCTAssertEqual(model.actionButton, .next(.moreInfo)) + XCTAssertEqual(model.secondaryButton, .cancel) + + model.performAction(.next(.moreInfo)) + + XCTAssertEqual(model.screen, .moreInfo) + XCTAssertEqual(model.actionButton, .initiateImport(disabled: false)) + XCTAssertEqual(model.secondaryButton, .back) + + model.performAction(.back) + XCTAssertEqual(model.screen, Source.safari.initialScreen) + } + + func testWhenProfilesAreLoadedAndImporterRequiresKeyChainPasswordButPasswordsDataTypeNotSelected_buttonActionsAreCancelAndImport() { + model = DataImportViewModel(importSource: .safari, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in + ImporterMock(keychainPasswordRequiredFor: [.passwords]) + }) + model.setDataType(.passwords, selected: false) + + XCTAssertEqual(model.actionButton, .initiateImport(disabled: false)) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenFileImportSourceSelected_buttonActionsAreCancelAndNone() { + for source in Source.allCases where ThirdPartyBrowser.browser(for: source) == nil || source.isBrowser == false { + model = DataImportViewModel(importSource: source, loadProfiles: { + XCTAssertNotNil(ThirdPartyBrowser.browser(for: source), "Unexpected loadProfiles – \(source)") + return .init(browser: $0, profiles: [.test(for: $0)]) + }) + + XCTAssertEqual(model.screen, .fileImport(dataType: source.supportedDataTypes.first!, summary: []), "\(source)") + XCTAssertEqual(model.selectedDataTypes, source.supportedDataTypes, "\(source)") + XCTAssertNil(model.actionButton) + XCTAssertEqual(model.secondaryButton, .cancel, "\(source)") + } + } + + func testWhenNoProfilesAreLoaded_buttonActionsAreCancelAndProceedToFileImport() { + model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: []) }) + + XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) + XCTAssertEqual(model.actionButton, .next(.fileImport(dataType: .bookmarks))) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenNoProfilesAreLoadedAndBookmarksDataTypeUnselected_fileImportDataTypeChanges() { + model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: []) }) + + model.setDataType(.bookmarks, selected: false) + + XCTAssertEqual(model.selectedDataTypes, [.passwords]) + XCTAssertEqual(model.actionButton, .next(.fileImport(dataType: .passwords))) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenPasswordsDataTypeUnselected_fileImportDataTypeChanges() { + model = DataImportViewModel(importSource: .firefox, loadProfiles: { .init(browser: $0, profiles: []) }) + + model.setDataType(.passwords, selected: false) + + XCTAssertEqual(model.selectedDataTypes, [.bookmarks]) + XCTAssertEqual(model.actionButton, .next(.fileImport(dataType: .bookmarks))) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenImportSourceChanges_selectedDataTypesAreReset() { + setupModel(with: .safari, profiles: [BrowserProfile.test]) { _, _, _, _ in + ImporterMock(importableTypes: [.passwords, .bookmarks], keychainPasswordRequiredFor: [.passwords]) + } + + model.setDataType(.bookmarks, selected: false) + model.setDataType(.passwords, selected: false) + + model.update(with: .brave) + + XCTAssertEqual(model.selectedDataTypes, [.bookmarks, .passwords]) + XCTAssertEqual(model.actionButton, .next(.moreInfo)) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenImporterCannotImportPasswords_nextScreenIsFileImport() { + model = DataImportViewModel(importSource: .yandex, loadProfiles: { .init(browser: $0, profiles: [.test(for: $0)]) }, dataImporterFactory: { _, _, _, _ in + ImporterMock(importableTypes: [.bookmarks]) + }) + + model.setDataType(.bookmarks, selected: false) + + XCTAssertEqual(model.actionButton, .next(.fileImport(dataType: .passwords))) + XCTAssertEqual(model.secondaryButton, .cancel) + } + + func testWhenReadPermissionRequired_nextScreenIsReadPermission() { + setupModel(with: .safari, profiles: [BrowserProfile.test]) { _, _, _, _ in + ImporterMock { _, _ in + [.passwords: SafariBookmarksReader.ImportError(type: .readPlist, underlyingError: + CocoaError(.fileReadNoPermission, userInfo: [kCFErrorURLKey as String: URL.testCSV]))] + } + } + + XCTAssertEqual(model.buttons, [.cancel, .initiateImport(disabled: false)]) + model.performAction(.initiateImport(disabled: false)) + + let expectation = DataImportViewModel(importSource: .safari, screen: .getReadPermission(.testCSV)) + XCTAssertEqual(model.description, expectation.description) + } + + // MARK: - Import from browser profile + // MARK: Primary Password + + func testWhenImporterRequiresPrimaryPassword_passwordIsRequested() async throws { + var e: XCTestExpectation! + setupModel(with: .firefox, profiles: [BrowserProfile.test]) { _, _, _, p in + ImporterMock(password: p, accessValidator: { importer, dataTypes in + XCTAssertEqual(dataTypes, [.bookmarks, .passwords]) + if let password = importer.password { + XCTAssertEqual(password, p) + XCTAssertEqual(password, "password") + return [:] + } else { + return [.passwords: FirefoxLoginReader.ImportError(type: .requiresPrimaryPassword, underlyingError: nil)] + } + }, importTask: self.importTask) + } requestPrimaryPasswordCallback: { source in + XCTAssertEqual(source, .firefox) + e.fulfill() + return "password" + } + + e = expectation(description: "should request password") + try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: ThirdPartyBrowser.firefox), resultingWith: { + return [ + .bookmarks: .success(.init(successful: 1, duplicate: 1, failed: 1)), + .passwords: .success(.init(successful: 2, duplicate: 2, failed: 2)), + ] + }()) + await fulfillment(of: [e], timeout: 0) + + let expectation = DataImportViewModel(importSource: .firefox, screen: .summary([.bookmarks, .passwords]), summary: [.init(.bookmarks, .success(.init(successful: 1, duplicate: 1, failed: 1))), .init(.passwords, .success(.init(successful: 2, duplicate: 2, failed: 2)))]) + XCTAssertEqual(model.description, expectation.description) + + } + + func testWhenImporterRequiresPrimaryPasswordAndPasswordIsInvalid_passwordIsRequestedAgain() async throws { + var e: XCTestExpectation! + var e2: XCTestExpectation! + setupModel(with: .firefox, profiles: [BrowserProfile.test]) { _, _, _, p in + ImporterMock(password: p, accessValidator: { importer, _ in + if importer.password != nil { + return [:] + } else { + return [.passwords: FirefoxLoginReader.ImportError(type: .requiresPrimaryPassword, underlyingError: nil)] + } + }, importTask: self.importTask) + } requestPrimaryPasswordCallback: { source in + XCTAssertEqual(source, .firefox) + if let e2 { + e2.fulfill() + return "password" + } else { + e.fulfill() + return "invalid_password" + } + } + + e = expectation(description: "should request password") + self.importTask = { dataTypes, _ in + if e2 == nil { + XCTAssertEqual(dataTypes, [.bookmarks, .passwords], "first data import should contain both data types") + e2 = self.expectation(description: "should request password again") + return [ + .bookmarks: .success(.init(successful: 1, duplicate: 1, failed: 1)), + .passwords: .failure(FirefoxLoginReader.ImportError(type: .requiresPrimaryPassword, underlyingError: nil)), + ] + } else { + XCTAssertEqual(dataTypes, [.passwords], "second data import should contain only failed data type (passwords)") + return [ + .passwords: .success(.init(successful: 2, duplicate: 2, failed: 2)), + ] + } + } + try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: ThirdPartyBrowser.firefox)) + await fulfillment(of: [e, e2], timeout: 0) + + let expected = DataImportViewModel(importSource: .firefox, screen: .summary([.bookmarks, .passwords]), summary: [.init(.bookmarks, .success(.init(successful: 1, duplicate: 1, failed: 1))), .init(.passwords, .success(.init(successful: 2, duplicate: 2, failed: 2)))]) + XCTAssertEqual(model.description, expected.description) + } + + func testWhenImporterRequiresPrimaryPasswordButRejected_initialStateRestored() async throws { + let e = expectation(description: "should request password") + setupModel(with: .firefox, profiles: [BrowserProfile.test]) { _, _, _, p in + ImporterMock(password: p, accessValidator: { _, _ in + [.passwords: FirefoxLoginReader.ImportError(type: .requiresPrimaryPassword, underlyingError: nil)] + }, importTask: self.importTask) + } requestPrimaryPasswordCallback: { _ in + e.fulfill() + return nil + } + + try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: ThirdPartyBrowser.firefox)) + await fulfillment(of: [e], timeout: 0) + + let expected = DataImportViewModel(importSource: .firefox, screen: Source.firefox.initialScreen) + XCTAssertEqual(model.description, expected.description) + } + + func testWhenImporterRequiresKeychainPasswordButRejected_moreInfoScreenRestored() async throws { + setupModel(with: .brave, profiles: [BrowserProfile.test]) { _, _, _, p in + ImporterMock(password: p, keychainPasswordRequiredFor: [.passwords], accessValidator: { _, _ in + [.passwords: ChromiumLoginReader.ImportError(type: .userDeniedKeychainPrompt)] + }, importTask: self.importTask) + } + + model.performAction(.next(.moreInfo)) + try await initiateImport(of: [.bookmarks, .passwords], from: .test(for: ThirdPartyBrowser.brave)) + + let expected = DataImportViewModel(importSource: .brave, screen: .moreInfo) + XCTAssertEqual(model.description, expected.description) + } + + // MARK: Browser Sources: initial -> import -> bookmarks success… + + // initial -> import -> bookmarks success, passwords success -> summary + func testWhenBrowserBookmarksImportSucceedsPasswordsImportSucceeds_summaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 100, duplicate: 2, failed: 1)), + .passwords: .success(.init(successful: 13, duplicate: 42, failed: 3)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .summary([.bookmarks, .passwords]), summary: [.init(.bookmarks, .success(.init(successful: 100, duplicate: 2, failed: 1))), .init(.passwords, .success(.init(successful: 13, duplicate: 42, failed: 3)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks success, passwords failure -> file import + func testWhenBrowserBookmarksImportSucceedsPasswordsImportFails_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 10, duplicate: 0, failed: 0)), + .passwords: .failure(Failure(.passwords, .decryptionError)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords, summary: [.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 10, duplicate: 0, failed: 0))), .init(.passwords, .failure(Failure(.passwords, .decryptionError)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks success, no passwords imported -> file import + func testWhenBrowserBookmarksImportSucceedsNoPasswords_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 10, duplicate: 0, failed: 0)), + .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords, summary: [.bookmarks, .passwords]), summary: [.init(.bookmarks, .success(.init(successful: 10, duplicate: 0, failed: 0))), .init(.passwords, .success(.init(successful: 0, duplicate: 0, failed: 0)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks success, no passwords file found -> file import + func testWhenBrowserBookmarksImportSucceedsNoPasswordsFileError_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 42, duplicate: 1, failed: 0)), + .passwords: .failure(Failure(.passwords, .noData)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords, summary: [.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 42, duplicate: 1, failed: 0))), .init(.passwords, .failure(Failure(.passwords, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks success, passwords (nil) -> boookmarks summary [Next] + func testWhenBrowserBookmarksOnlyImportSucceeds_bookmarksSummaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 42, duplicate: 1, failed: 3)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .summary([.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 42, duplicate: 1, failed: 3)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + func testWhenFileImportOpenPanelIsRejected_fileImportScreenRestored() async throws { + setupModel(with: .firefox, profiles: [BrowserProfile.test], screen: .fileImport(dataType: .bookmarks)) + + openPanelCallback = { _ in + nil + } + try await initiateImport(of: [.bookmarks], fromFile: .testHTML) + + let expectation = DataImportViewModel(importSource: .firefox, screen: .fileImport(dataType: .bookmarks)) + XCTAssertEqual(model.description, expectation.description) + } + + // MARK: Browser Sources: initial -> import -> bookmarks failure… + + // initial -> import -> bookmarks failure, passwords success -> file import + func testWhenBrowserPasswordsImportSucceedsBookmarksImportFails_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .decryptionError)), + .passwords: .success(.init(successful: 10, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.passwords]), summary: [.init(.passwords, .success(.init(successful: 10, duplicate: 0, failed: 0))), .init(.bookmarks, .failure(Failure(.bookmarks, .decryptionError)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks failure, no passwords imported -> file import + func testWhenBrowserBookmarksImportFailsNoPasswords_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .decryptionError)), + .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.passwords]), summary: [.init(.passwords, .success(.init(successful: 0, duplicate: 0, failed: 0))), .init(.bookmarks, .failure(Failure(.bookmarks, .decryptionError)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks failure, passwords failure -> file import + func testWhenBrowserBookmarksImportFailsPasswordsImportFails_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .dataCorrupted)), + .passwords: .failure(Failure(.passwords, .keychainError)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks), summary: [.init(.bookmarks, .failure(Failure(.bookmarks, .dataCorrupted))), .init(.passwords, .failure(Failure(.passwords, .keychainError)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks failure, no passwords file found -> file import + func testWhenBrowserBookmarksImportFailsNoPasswordsFileError_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .dataCorrupted)), + .passwords: .failure(Failure(.passwords, .noData)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks), summary: [.init(.bookmarks, .failure(Failure(.bookmarks, .dataCorrupted))), .init(.passwords, .failure(Failure(.passwords, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> bookmarks failure, passwords (nil) -> file import + func testWhenBrowserBookmarksOnlyImportSucceeds_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .dataCorrupted)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks), summary: [.init(.bookmarks, .failure(Failure(.bookmarks, .dataCorrupted)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // MARK: Browser Sources: initial -> import -> no bookmarks… + + // initial -> import -> no bookmarks, passwords success -> file import + func testWhenBrowserNoBookmarksPasswordsImportSucceeds_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), + .passwords: .success(.init(successful: 42, duplicate: 1, failed: 1)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.bookmarks, .passwords]), summary: [.init(.bookmarks, .success(.init(successful: 0, duplicate: 0, failed: 0))), .init(.passwords, .success(.init(successful: 42, duplicate: 1, failed: 1)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks, passwords failuire -> file import + func testWhenBrowserNoBookmarksPasswordsImportFails_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), + .passwords: .failure(Failure(.passwords, .decryptionError)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 0, duplicate: 0, failed: 0))), .init(.passwords, .failure(Failure(.passwords, .decryptionError)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks, no passwords -> file import + func testWhenBrowserNoBookmarksNoPasswords_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), + .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.bookmarks, .passwords]), summary: [.init(.bookmarks, .success(.init(successful: 0, duplicate: 0, failed: 0))), .init(.passwords, .success(.init(successful: 0, duplicate: 0, failed: 0)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks, no passwords file found -> file import + func testWhenBrowserNoBookmarksNoPasswordsFileFound_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), + .passwords: .failure(Failure(.passwords, .noData)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 0, duplicate: 0, failed: 0))), .init(.passwords, .failure(Failure(.passwords, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks, passwords (nil) -> file import + func testWhenBrowserNoBookmarksOnly_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.bookmarks]), summary: [.init(.bookmarks, .success(.init(successful: 0, duplicate: 0, failed: 0)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // MARK: Browser Sources: initial -> import -> no bookmarks file found… + + // initial -> import -> no bookmarks file found, passwords success -> file import + func testWhenBrowserNoBookmarksFileFoundPasswordsImportSucceeds_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .noData)), + .passwords: .success(.init(successful: 42, duplicate: 1, failed: 1)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.passwords]), summary: [.init(.passwords, .success(.init(successful: 42, duplicate: 1, failed: 1))), .init(.bookmarks, .failure(Failure(.bookmarks, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks file found, passwords failuire -> file import + func testWhenBrowserNoBookmarksFileFoundPasswordsImportFails_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .noData)), + .passwords: .failure(Failure(.passwords, .decryptionError)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks), summary: [.init(.bookmarks, .failure(Failure(.bookmarks, .noData))), .init(.passwords, .failure(Failure(.passwords, .decryptionError)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks file found, no passwords -> file import + func testWhenBrowserNoBookmarksFileFoundNoPasswords_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .noData)), + .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks, summary: [.passwords]), summary: [.init(.passwords, .success(.init(successful: 0, duplicate: 0, failed: 0))), .init(.bookmarks, .failure(Failure(.bookmarks, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks file found, no passwords file found -> file import + func testWhenBrowserNoBookmarksFileFoundNoPasswordsFileFound_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .noData)), + .passwords: .failure(Failure(.passwords, .noData)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks), summary: [.init(.bookmarks, .failure(Failure(.bookmarks, .noData))), .init(.passwords, .failure(Failure(.passwords, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import -> no bookmarks file found, passwords (nil) -> file import + func testWhenBrowserNoBookmarksFileFoundOnly_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + .bookmarks: .failure(Failure(.bookmarks, .noData)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .bookmarks), summary: [.init(.bookmarks, .failure(Failure(.bookmarks, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // MARK: Browser Sources: initial -> import passwords… + + // initial -> import passwords -> passwords success -> summary + func testWhenBrowserOnlySelectedPasswordsImportSucceeds_summaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: [.passwords], from: .test(for: browser), resultingWith: [ + .passwords: .success(.init(successful: 1, duplicate: 2, failed: 3)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .summary([.passwords]), summary: [.init(.passwords, .success(.init(successful: 1, duplicate: 2, failed: 3)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import passwords -> passwords failure -> summary + func testWhenBrowserOnlySelectedPasswordsImportFails_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: [.passwords], from: .test(for: browser), resultingWith: [ + .passwords: .failure(Failure(.passwords, .dataCorrupted)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords), summary: [.init(.passwords, .failure(Failure(.passwords, .dataCorrupted)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import passwords -> no passwords + func testWhenBrowserOnlySelectedPasswordsResultsWithNoPasswords_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: [.passwords], from: .test(for: browser), resultingWith: [ + .passwords: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords, summary: [.passwords]), summary: [.init(.passwords, .success(.init(successful: 0, duplicate: 0, failed: 0)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import passwords -> no passwords file found + func testWhenBrowserOnlySelectedPasswordsImportResultsWithNoPasswordsFileFound_manualImportSuggested() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + guard let browser = ThirdPartyBrowser.browser(for: source) else { + XCTFail("no ThirdPartyBrowser for \(source)") + continue + } + + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + + try await initiateImport(of: [.passwords], from: .test(for: browser), resultingWith: [ + .passwords: .failure(Failure(.passwords, .noData)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords), summary: [.init(.passwords, .failure(Failure(.passwords, .noData)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // initial -> import passwords -> only file import supported for passwords -> [Next] -> file import + func testWhenBrowserOnlySelectedPasswordsCannotBeImported_manualImportSuggested() throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], dataImporterFactory: { src, dataType, _, _ in + XCTAssertEqual(src, source) + XCTAssertNil(dataType) + return ImporterMock(importableTypes: [.bookmarks]) + }) + + model.selectedDataTypes = [.passwords] + + XCTAssertEqual(model.actionButton, .next(.fileImport(dataType: .passwords)), source.rawValue) + XCTAssertEqual(model.secondaryButton, .cancel, source.rawValue) + + model.performAction(model.actionButton!) + + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords), summary: []) + XCTAssertEqual(model.description, expectation.description) + } + } + + // MARK: - File Import Sources + + // csv/html file import succeeds -> summary + func testWhenFileImportSourceImportSucceeds_summaryShown() async throws { + for source in Source.allCases where source.initialScreen.isFileImport { + setupModel(with: source) + + guard case .fileImport(dataType: let dataType, summary: []) = model.screen else { + XCTFail("\(source): unexpected initial screen: \(model.screen)") + continue + } + + XCTAssertEqual([dataType], source.supportedDataTypes) + XCTAssertEqual(model.selectedDataTypes, [dataType], source.rawValue) + XCTAssertEqual(model.buttons, [.cancel], source.rawValue) + + try await initiateImport(of: [dataType], fromFile: dataType == .passwords ? .testCSV : .testHTML, resultingWith: [ + dataType: .success(.init(successful: 42, duplicate: 12, failed: 3)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .summary([dataType]), summary: [.init(dataType, .success(.init(successful: 42, duplicate: 12, failed: 3)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // csv/html file import succeeds with 0 passwords/bookmarks imported -> summary + func testWhenFileImportSourceImportSucceedsWithNoDataFound_summaryShown() async throws { + for source in Source.allCases where source.initialScreen.isFileImport { + setupModel(with: source) + + guard case .fileImport(dataType: let dataType, summary: []) = model.screen else { + XCTFail("\(source): unexpected initial screen: \(model.screen)") + continue + } + + XCTAssertEqual(model.selectedDataTypes, [dataType], source.rawValue) + XCTAssertEqual(model.buttons, [.cancel], source.rawValue) + + try await initiateImport(of: [dataType], fromFile: dataType == .passwords ? .testCSV : .testHTML, resultingWith: [ + dataType: .success(.init(successful: 0, duplicate: 0, failed: 0)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .summary([dataType]), summary: [.init(dataType, .success(.init(successful: 0, duplicate: 0, failed: 0)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // csv/html file import fails -> feedback + func testWhenFileImportSourceImportFails_feedbackScreenShown() async throws { + for source in Source.allCases where source.initialScreen.isFileImport { + setupModel(with: source) + + guard case .fileImport(dataType: let dataType, summary: []) = model.screen else { + XCTFail("\(source): unexpected initial screen: \(model.screen)") + continue + } + + XCTAssertEqual(model.selectedDataTypes, [dataType], source.rawValue) + XCTAssertEqual(model.buttons, [.cancel], source.rawValue) + + try await initiateImport(of: [dataType], fromFile: dataType == .passwords ? .testCSV : .testHTML, resultingWith: [ + dataType: .failure(Failure(dataType.importAction, .dataCorrupted)), + ]) + + let expectation = DataImportViewModel(importSource: source, screen: .feedback, summary: [.init(dataType, .failure(Failure(dataType.importAction, .dataCorrupted)))]) + XCTAssertEqual(model.description, expectation.description) + } + } + + // MARK: - File Import after failure (or nil result for a data type) + + // all possible import summaries for combining + let bookmarksSummaries: [DataImportViewModel.DataTypeImportResult?] = [ + // bookmarks import didn‘t happen (or skipped) + nil, + // bookmarks import succeeded + .init(.bookmarks, .success(.init(successful: 42, duplicate: 3, failed: 1))), + // bookmarks import succeeded with no bookmarks imported + .init(.bookmarks, .success(.init(successful: 0, duplicate: 0, failed: 0))), + // bookmarks import failed with error + .init(.bookmarks, .failure(Failure(.bookmarks, .dataCorrupted))), + // bookmarks import failed with file not found + .init(.bookmarks, .failure(Failure(.bookmarks, .noData))), + ] + + let bookmarksResults: [DataImportResult?] = [ + .failure(Failure(.bookmarks, .dataCorrupted)), + .success(.init(successful: 5, duplicate: 4, failed: 3)), + .success(.init(successful: 0, duplicate: 0, failed: 0)), + nil, // skip + ] + + let passwordsSummaries: [DataImportViewModel.DataTypeImportResult?] = [ + // passwords import didn‘t happen (or skipped) + nil, + // passwords import succeeded + .init(.passwords, .success(.init(successful: 99, duplicate: 4, failed: 2))), + // passwords import succeeded with no passwords imported + .init(.passwords, .success(.init(successful: 0, duplicate: 0, failed: 0))), + // passwords import failed with error + .init(.passwords, .failure(Failure(.passwords, .keychainError))), + // passwords import failed with file not found + .init(.passwords, .failure(Failure(.passwords, .noData))), + ] + + let passwordsResults: [DataImportResult?] = [ + .failure(Failure(.passwords, .dataCorrupted)), + .success(.init(successful: 6, duplicate: 3, failed: 1)), + .success(.init(successful: 0, duplicate: 0, failed: 0)), + nil, // skip + ] + + func testWhenBrowsersBookmarksFileImportSucceedsAndNoPasswordsFileImportNeeded_summaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for bookmarksSummary in bookmarksSummaries + // initial bookmark import failed or ended with empty result + where bookmarksSummary?.result.isSuccess == false || (try? bookmarksSummary?.result.get())?.isEmpty == true { + + for passwordsSummary in passwordsSummaries + // passwords successfully imported + where (try? passwordsSummary?.result.get().isEmpty) == false { + + for result in bookmarksResults + // bookmarks file import successful (incl. empty), or skipped when initial result was empty + where result?.isSuccess == true || (result == nil && (try? bookmarksSummary?.result.get())?.isEmpty == true) { + + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .bookmarks, summary: []), + summary: [bookmarksSummary, passwordsSummary].compactMap { $0 }) + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") passwordsSummary: \(passwordsSummary?.description ?? "") result: \(result?.description ?? ".skip")" + + // run File Import (or skip) + if let result { + try await initiateImport(of: [.bookmarks], fromFile: .testHTML, resultingWith: [.bookmarks: result], xctDescr) + } else { + XCTAssertEqual(model.actionButton, .skip) + model.performAction(.skip) + } + + xctDescr = "\(source): " + xctDescr + + // expect Final Summary + let expectation = DataImportViewModel(importSource: source, screen: .summary([.bookmarks, .passwords]), summary: [bookmarksSummary, passwordsSummary, result.map { .init(.bookmarks, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .done, xctDescr) + XCTAssertNil(model.secondaryButton, xctDescr) + } + } + } + } + } + + func testWhenBrowsersOnlySelectedBookmarksFileImportSucceeds_summaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + for bookmarksSummary in bookmarksSummaries + // initial bookmark import failed or ended with empty result + where bookmarksSummary?.result.isSuccess == false || (try? bookmarksSummary?.result.get())?.isEmpty == true { + + for result in bookmarksResults + // bookmarks file import successful (incl. empty) + where result?.isSuccess == true { + + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .bookmarks, summary: []), + summary: [bookmarksSummary].compactMap { $0 }) + + if source.supportedDataTypes.contains(.passwords) { + model.selectedDataTypes = [.bookmarks] + } + + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") result: \(result?.description ?? ".skip")" + + // run File Import + try await initiateImport(of: [.bookmarks], fromFile: .testHTML, resultingWith: [.bookmarks: result!], xctDescr) + + xctDescr = "\(source): " + xctDescr + + // expect Final Summary + let expectation = DataImportViewModel(importSource: source, screen: .summary([.bookmarks]), summary: [bookmarksSummary, result.map { .init(.bookmarks, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .done, xctDescr) + XCTAssertNil(model.secondaryButton, xctDescr) + } + } + } + } + + func testWhenBrowsersBookmarksFileImportFailsAndNoPasswordsFileImportNeeded_feedbackShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for bookmarksSummary in bookmarksSummaries + // initial bookmark import failed or ended with empty result + where bookmarksSummary?.result.isSuccess == false || (try? bookmarksSummary?.result.get())?.isEmpty == true { + + for passwordsSummary in passwordsSummaries + // passwords successfully imported + where (try? passwordsSummary?.result.get().isEmpty) == false { + + for result in bookmarksResults + // bookmarks file import failed or skipped when initial result was a failure + where result?.isSuccess == false || (result == nil && bookmarksSummary?.result.isSuccess == false) { + + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .bookmarks, summary: []), + summary: [bookmarksSummary, passwordsSummary].compactMap { $0 }) + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") passwordsSummary: \(passwordsSummary?.description ?? "") result: \(result?.description ?? ".skip")" + + // run File Import (or skip) + if let result { + try await initiateImport(of: [.bookmarks], fromFile: .testHTML, resultingWith: [.bookmarks: result], xctDescr) + } else { + XCTAssertEqual(model.actionButton, .skip) + model.performAction(.skip) + } + + xctDescr = "\(source): " + xctDescr + + // expect Report Feedback + let expectation = DataImportViewModel(importSource: source, screen: .feedback, summary: [bookmarksSummary, passwordsSummary, result.map { .init(.bookmarks, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .submit, xctDescr) + XCTAssertEqual(model.secondaryButton, .cancel, xctDescr) + } + } + } + } + } + + func testWhenBrowsersOnlySelectedBookmarksFileImportFails_feedbackShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + for bookmarksSummary in bookmarksSummaries + // initial bookmark import failed or ended with empty result + where bookmarksSummary?.result.isSuccess == false || (try? bookmarksSummary?.result.get())?.isEmpty == true { + + for result in bookmarksResults + // bookmarks file import successful (incl. empty) + where result?.isSuccess == false { + + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .bookmarks, summary: []), + summary: [bookmarksSummary].compactMap { $0 }) + + if source.supportedDataTypes.contains(.passwords) { + model.selectedDataTypes = [.bookmarks] + } + + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") result: \(result?.description ?? ".skip")" + + // run File Import + try await initiateImport(of: [.bookmarks], fromFile: .testHTML, resultingWith: [.bookmarks: result!], xctDescr) + + xctDescr = "\(source): " + xctDescr + + // expect Report Feedback + let expectation = DataImportViewModel(importSource: source, screen: .feedback, summary: [bookmarksSummary, result.map { .init(.bookmarks, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .submit, xctDescr) + XCTAssertEqual(model.secondaryButton, .cancel, xctDescr) + } + } + } + } + + func testWhenBrowsersBookmarksFileImportFailsAndPasswordsFileImportIsNeeded_bookmarksSummaryWithNextButtonShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for bookmarksSummary in bookmarksSummaries + // initial bookmark import failed or ended with empty result + where bookmarksSummary?.result.isSuccess == false || (try? bookmarksSummary?.result.get())?.isEmpty == true { + + for passwordsSummary in passwordsSummaries + // passwords failed to import, not imported or empty data + where passwordsSummary?.result.isSuccess != true || (try? passwordsSummary?.result.get().isEmpty) == true { + + for result in bookmarksResults + // any bookmarks file import result except Skip + where result != nil { + + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .bookmarks, summary: []), + summary: [bookmarksSummary, passwordsSummary].compactMap { $0 }) + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") passwordsSummary: \(passwordsSummary?.description ?? "") result: \(result?.description ?? ".skip")" + + // run File Import (or skip) + try await initiateImport(of: [.bookmarks], fromFile: .testHTML, resultingWith: [.bookmarks: result!], xctDescr) + + xctDescr = "\(source): " + xctDescr + + // expect Bookmarks Import Summary screen + let expectation = DataImportViewModel(importSource: source, screen: .summary([.bookmarks]), summary: [bookmarksSummary, passwordsSummary, result.map { .init(.bookmarks, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + // [Next] -> passwords file import screen + XCTAssertEqual(model.actionButton, .next(.fileImport(dataType: .passwords)), xctDescr) + XCTAssertNil(model.secondaryButton, xctDescr) + } + } + } + } + } + + func testWhenBrowsersBookmarksFileImportSkippedAndPasswordsFileImportIsNeeded_passwordsFileImportShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for bookmarksSummary in bookmarksSummaries + // initial bookmark import failed or ended with empty result + where bookmarksSummary?.result.isSuccess == false || (try? bookmarksSummary?.result.get())?.isEmpty == true { + + for passwordsSummary in passwordsSummaries + // passwords failed to import, not imported or empty data + where passwordsSummary?.result.isSuccess != true || (try? passwordsSummary?.result.get().isEmpty) == true { + + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .bookmarks, summary: []), + summary: [bookmarksSummary, passwordsSummary].compactMap { $0 }) + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") passwordsSummary: \(passwordsSummary?.description ?? "")" + + // skip Bookmarks file import + XCTAssertEqual(model.actionButton, .skip) + model.performAction(.skip) + + xctDescr = "\(source): " + xctDescr + + // expect Bookmarks Import Summary screen + let expectation = DataImportViewModel(importSource: source, screen: .fileImport(dataType: .passwords), summary: [bookmarksSummary, passwordsSummary].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + // if no failures: Cancel button is shown + if bookmarksSummary?.result.isSuccess != false && passwordsSummary?.result.isSuccess != false { + XCTAssertEqual(model.actionButton, .cancel, xctDescr) + } else { + XCTAssertEqual(model.actionButton, .skip, xctDescr) + } + XCTAssertNil(model.secondaryButton, xctDescr) + } + } + } + } + + // MARK: File import after passwords failure + + func testWhenBrowsersPasswordsFileImportSucceeds_summaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for bookmarksSummary in bookmarksSummaries { + + for passwordsSummary in passwordsSummaries + // passwords import failed or empty + where passwordsSummary?.result.isSuccess == false || (try? passwordsSummary?.result.get().isEmpty) == false { + + for bookmarksFileImportSummary in bookmarksSummaries + // if bookmarks result was failure - append successful bookmarks file import result + where (bookmarksSummary?.result.isSuccess == false && bookmarksFileImportSummary?.result.isSuccess == true) + // if bookmarks file import summary was successful and non empty - don‘t append bookmarks file import result + // or if bookmarks file import was empty - and bookmarks file import skipped + || (bookmarksSummary?.result.isSuccess == true && bookmarksFileImportSummary == nil) { + + for result in passwordsResults + // passwords file import successful (incl. empty), or skipped when initial result was empty + where result?.isSuccess == true || (result == nil && (try? passwordsSummary?.result.get())?.isEmpty == true) { + + // setup model with pre-failed passwords import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .passwords, summary: []), + summary: [bookmarksSummary, passwordsSummary, bookmarksFileImportSummary].compactMap { $0 }) + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") passwordsSummary: \(passwordsSummary?.description ?? ""), bookmarksFileSummary: \(bookmarksFileImportSummary?.description ?? ".skip") result: \(result?.description ?? ".skip")" + + // run File Import (or skip) + if let result { + try await initiateImport(of: [.passwords], fromFile: .testCSV, resultingWith: [.passwords: result], xctDescr) + } else { + XCTAssertEqual(model.actionButton, .skip) + model.performAction(.skip) + } + + xctDescr = "\(source): " + xctDescr + + // expect Final Summary + let expectation = DataImportViewModel(importSource: source, screen: .summary([.bookmarks, .passwords]), summary: [bookmarksSummary, passwordsSummary, bookmarksFileImportSummary, result.map { .init(.passwords, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .done, xctDescr) + XCTAssertNil(model.secondaryButton, xctDescr) + } + } + } + } + } + } + + func testWhenBrowsersOnlySelectedPasswordsFileImportSucceeds_summaryShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for passwordsSummary in passwordsSummaries + // initial passwords import failed or ended with empty result + where passwordsSummary?.result.isSuccess == false || (try? passwordsSummary?.result.get())?.isEmpty == true { + + for result in passwordsResults + // passwords file import successful (incl. empty) + where result?.isSuccess == true { + + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .passwords, summary: []), + summary: [passwordsSummary].compactMap { $0 }) + + model.selectedDataTypes = [.passwords] + + var xctDescr = "passwordsSummary: \(passwordsSummary?.description ?? "") result: \(result?.description ?? ".skip")" + + // run File Import + try await initiateImport(of: [.passwords], fromFile: .testCSV, resultingWith: [.passwords: result!], xctDescr) + + xctDescr = "\(source): " + xctDescr + + // expect Final Summary + let expectation = DataImportViewModel(importSource: source, screen: .summary([.passwords]), summary: [passwordsSummary, result.map { .init(.passwords, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .done, xctDescr) + XCTAssertNil(model.secondaryButton, xctDescr) + } + } + } + } + + func testWhenBrowsersPasswordsFileImportFails_feedbackShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for bookmarksSummary in bookmarksSummaries { + + for passwordsSummary in passwordsSummaries + // passwords import failed or empty + where passwordsSummary?.result.isSuccess == false || (try? passwordsSummary?.result.get().isEmpty) == false { + + for bookmarksFileImportSummary in bookmarksSummaries + // if bookmarks result was failure - append successful bookmarks file import result + where (bookmarksSummary?.result.isSuccess == false && bookmarksFileImportSummary?.result.isSuccess == true) + // if bookmarks file import summary was successful and non empty - don‘t append bookmarks file import result + // or if bookmarks file import was empty - and bookmarks file import skipped + || (bookmarksSummary?.result.isSuccess == true && bookmarksFileImportSummary == nil) { + + for result in passwordsResults + // passwords file import failed or skipped when initial result was a failure + where result?.isSuccess == false || (result == nil && passwordsSummary?.result.isSuccess == false) { + + // setup model with pre-failed passwords import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .passwords, summary: []), + summary: [bookmarksSummary, passwordsSummary, bookmarksFileImportSummary].compactMap { $0 }) + var xctDescr = "bookmarksSummary: \(bookmarksSummary?.description ?? "") passwordsSummary: \(passwordsSummary?.description ?? ""), bookmarksFileSummary: \(bookmarksFileImportSummary?.description ?? ".skip") result: \(result?.description ?? ".skip")" + + // run File Import (or skip) + if let result { + try await initiateImport(of: [.passwords], fromFile: .testCSV, resultingWith: [.passwords: result], xctDescr) + } else { + XCTAssertEqual(model.actionButton, .skip) + model.performAction(.skip) + } + + xctDescr = "\(source): " + xctDescr + + // expect Report Feedback + let expectation = DataImportViewModel(importSource: source, screen: .feedback, summary: [bookmarksSummary, passwordsSummary, bookmarksFileImportSummary, result.map { .init(.passwords, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .submit, xctDescr) + XCTAssertEqual(model.secondaryButton, .cancel, xctDescr) + } + } + } + } + } + } + + func testWhenBrowsersOnlySelectedPasswordsFileImportFails_feedbackShown() async throws { + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker && source.supportedDataTypes.contains(.passwords) { + for passwordsSummary in passwordsSummaries + // initial passwords import failed or ended with empty result + where passwordsSummary?.result.isSuccess == false || (try? passwordsSummary?.result.get())?.isEmpty == true { + + for result in passwordsResults + // passwords file import failed + where result?.isSuccess == false { + + // setup model with pre-failed bookmarks import + setupModel(with: source, + profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2], + screen: .fileImport(dataType: .passwords, summary: []), + summary: [passwordsSummary].compactMap { $0 }) + + model.selectedDataTypes = [.passwords] + + var xctDescr = "passwordsSummary: \(passwordsSummary?.description ?? "") result: \(result?.description ?? ".skip")" + + // run File Import + try await initiateImport(of: [.passwords], fromFile: .testCSV, resultingWith: [.passwords: result!], xctDescr) + + xctDescr = "\(source): " + xctDescr + + // expect Report Feedback + let expectation = DataImportViewModel(importSource: source, screen: .feedback, summary: [passwordsSummary, result.map { .init(.passwords, $0) }].compactMap { $0 }) + XCTAssertEqual(model.description, expectation.description, xctDescr) + XCTAssertEqual(model.actionButton, .submit, xctDescr) + XCTAssertEqual(model.secondaryButton, .cancel, xctDescr) + } + } + } + } + + // MARK: - Feedback + + func testFeedbackSending() { + let summary: [DataImportViewModel.DataTypeImportResult] = [ + .init(.bookmarks, .success(.empty)), + .init(.bookmarks, .failure(Failure(.passwords, .dataCorrupted))), + .init(.passwords, .failure(Failure(.passwords, .keychainError))), + .init(.passwords, .failure(Failure(.passwords, .dataCorrupted))) + ] + let e = expectation(description: "report sent") + model = DataImportViewModel(importSource: .safari, screen: .feedback, summary: summary, reportSenderFactory: { + { report in + XCTAssertEqual(report.text, "Test text") + for dataTypeSummary in summary { + if case .failure(let error) = dataTypeSummary.result { + XCTAssertTrue(report.error.localizedDescription.contains(error.localizedDescription)) + } + } + XCTAssertEqual(report.importSourceDescription, Source.safari.importSourceName + " " + "\(SafariVersionReader.getMajorVersion() ?? 0)") + XCTAssertEqual(report.appVersion, "\(AppVersion.shared.versionNumber)") + XCTAssertEqual(report.osVersion, "\(ProcessInfo.processInfo.operatingSystemVersion)") + XCTAssertEqual(report.retryNumber, 2) + XCTAssertEqual(report.importSource, .safari) + + e.fulfill() + } + }) + + XCTAssertEqual(model.buttons, [.cancel, .submit]) + + model.reportModel.text = "Test text" + + let eDismissed = expectation(description: "dismissed") + model.performAction(for: .submit) { + eDismissed.fulfill() + } + waitForExpectations(timeout: 0) + } + + // MARK: - Helpers + + var openPanelCallback: ((DataType) -> URL?)? + + func setupModel(with source: Source, profiles: [(ThirdPartyBrowser) -> BrowserProfile] = [], screen: DataImportViewModel.Screen? = nil, summary: [DataImportViewModel.DataTypeImportResult] = [], dataImporterFactory: DataImportViewModel.DataImporterFactory? = nil, requestPrimaryPasswordCallback: ((DataImportViewModel.Source) -> String?)? = nil) { + model = DataImportViewModel(importSource: source, screen: screen, summary: summary, loadProfiles: { browser in + .init(browser: browser, profiles: profiles.map { $0(browser) }) { _ in + { // swiftlint:disable:this opening_brace + .init(logins: .available, bookmarks: .available) + } + } + }, dataImporterFactory: dataImporterFactory ?? self.dataImporter, requestPrimaryPasswordCallback: requestPrimaryPasswordCallback ?? { _ in nil }, openPanelCallback: { self.openPanelCallback!($0) }) + } + + func selectProfile(_ profile: BrowserProfile, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { + XCTAssertTrue(model.browserProfiles?.validImportableProfiles.contains(profile) == true, message().with("profile not available"), file: file, line: line) + model.selectedProfile = profile + } + + private func dataImporter(for source: DataImport.Source, fileDataType: DataImport.DataType?, url: URL, primaryPassword: String?) -> DataImporter { + XCTAssertEqual(source, model.importSource) + if case .fileImport(let dataType, summary: _) = model.screen { + XCTAssertEqual(dataType, fileDataType) + } else { + XCTAssertNil(fileDataType) + XCTAssertEqual(url, model.selectedProfile?.profileURL) + } + + return ImporterMock(password: primaryPassword, importTask: self.importTask) + } + + private var importTask: ((Set, DataImportProgressCallback) async -> DataImportSummary)! + func initiateImport(of dataTypes: Set, from profile: BrowserProfile? = nil, fromFile url: URL? = nil, resultingWith result: DataImportSummary? = nil, file: StaticString = #filePath, line: UInt = #line, _ descr: String? = nil, progress progressUpdateCallback: ((DataImportProgressEvent) -> Void)? = nil) async throws { + assert((profile != nil) != (url != nil), "must provide either profile or url") + + let source = model.importSource + let message: () -> String = { "\(source): \(profile?.profileName ?? url!.path)".with(descr) } + + if [.profileAndDataTypesPicker, .moreInfo].contains(model.screen) { + for dataType in DataType.allCases where model.selectedDataTypes.contains(dataType) != dataTypes.contains(dataType) { + model.setDataType(dataType, selected: dataTypes.contains(dataType)) + } + XCTAssertEqual(model.selectedDataTypes, dataTypes, message().with("selectedDataTypes"), file: file, line: line) + + if let profile { + selectProfile(profile, message(), file: file, line: line) + } + } else { + XCTAssertNil(profile, message().with("profile"), file: file, line: line) + } + + if let result { + self.importTask = { _, _ in result } + } + + var model: DataImportViewModel = self.model + if let url { // file import + XCTAssertEqual(dataTypes.count, 1, message().with("actionButton"), file: file, line: line) + if !source.isBrowser { + XCTAssertNil(model.actionButton) + } else if model.selectedDataTypes.isDisjoint(with: DataType.dataTypes(after: dataTypes.first!)), + model.summary(for: dataTypes.first!)?.isEmpty == true { + // no more data types available and import result is .success(.empty) + XCTAssertEqual(model.actionButton, .cancel, message().with("actionButton"), file: file, line: line) + } else if model.selectedDataTypes.isDisjoint(with: DataType.dataTypes(after: dataTypes.first!)), + !model.summary.contains(where: { $0.result.isSuccess == false }) { + // when no errors collected before - Cancel would be shown instead of Skip for Passwords Import + XCTAssertEqual(model.actionButton, .cancel) + } else if case .profileAndDataTypesPicker = model.importSource.initialScreen { + XCTAssertEqual(model.actionButton, .skip, message().with("actionButton"), file: file, line: line) + } + + if openPanelCallback == nil { + openPanelCallback = { dataType in + XCTAssertEqual(dataType, dataTypes.first!, message().with("file import dataType"), file: file, line: line) + self.openPanelCallback = nil + return url + } + } + model.selectFile() + + } else { + XCTAssertEqual(model.actionButton, .initiateImport(disabled: false), message().with("actionButton"), file: file, line: line) + model.performAction(.initiateImport(disabled: false)) + } + self.model = model + + while let importProgress = self.model.importProgress { + let taskStarted = expectation(description: "import task started") + let taskCompleted = expectation(description: "import task completed") + + let task = Task { + taskStarted.fulfill() + + for await event in importProgress { + switch event { + case .progress(let progressEvent): + progressUpdateCallback?(progressEvent) + case .completed(.success(let newModel)): + taskCompleted.fulfill() + + return newModel + } + } + return nil + } + + await Task.yield() + self.model = try await task.value ?? { throw CancellationError() }() + await fulfillment(of: [taskStarted, taskCompleted], timeout: 0.0) + } + } + +} + +private extension DataImport.BrowserProfile { + static func test(for browser: ThirdPartyBrowser) -> Self { + .init(browser: browser, profileURL: .profile(named: "Test Profile")) + } + static func test2(for browser: ThirdPartyBrowser) -> Self { + .init(browser: browser, profileURL: .profile(named: "Test Profile 2")) + } + static func test3(for browser: ThirdPartyBrowser) -> Self { + .init(browser: browser, profileURL: .profile(named: "Test Profile 3")) + } + static func `default`(for browser: ThirdPartyBrowser) -> Self { + switch browser { + case .firefox, .tor: + .init(browser: browser, profileURL: .profile(named: DataImport.BrowserProfileList.Constants.firefoxDefaultProfileName)) + default: + .init(browser: browser, profileURL: .profile(named: DataImport.BrowserProfileList.Constants.chromiumDefaultProfileName)) + } + + } +} + +private struct Failure: DataImportError, CustomStringConvertible { + + enum OperationType: Int { + case failure + } + + var action: DataImportAction + var type: OperationType = .failure + + var underlyingError: Error? + + var errorType: DataImport.ErrorType = .other + + init(_ action: DataImportAction, _ errorType: DataImport.ErrorType) { + self.action = action + self.errorType = errorType + } + + var description: String { + "Failure(.\(action.rawValue), .\(errorType))" + } + +} + +private class ImporterMock: DataImporter { + + var password: String? + + var importableTypes: [DataImport.DataType] + + var keychainPasswordRequiredFor: Set + + init(password: String? = nil, importableTypes: [DataImport.DataType] = [.bookmarks, .passwords], keychainPasswordRequiredFor: Set = [], accessValidator: ((ImporterMock, Set) -> [DataImport.DataType: any DataImportError]?)? = nil, importTask: ((Set, DataImportProgressCallback) async -> DataImportSummary)? = nil) { + self.password = password + self.importableTypes = importableTypes + self.keychainPasswordRequiredFor = keychainPasswordRequiredFor + self.accessValidator = accessValidator + self.importTask = importTask + } + func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool { + selectedDataTypes.intersects(keychainPasswordRequiredFor) + } + + var accessValidator: ((ImporterMock, Set) -> [DataImport.DataType: any DataImportError]?)? + + func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? { + accessValidator?(self, types) + } + + var importTask: ((Set, DataImportProgressCallback) async -> DataImportSummary)? + + func importData(types: Set) -> DataImportTask { + .detachedWithProgress { [importTask=importTask!]updateProgress in + await importTask(types, updateProgress) + } + } + +} + +extension DataImportViewModel { + @MainActor mutating func performAction(_ buttonType: ButtonType) { + performAction(for: buttonType, dismiss: { assertionFailure("Unexpected dismiss") }) + } +} + +extension DataImportViewModel: CustomStringConvertible { + public var description: String { + "DataImportViewModel(importSource: .\(importSource.rawValue), screen: \(screen)\(!summary.isEmpty ? ", summary: \(summary)" : ""))" + } +} + +extension DataImportViewModel.Screen: CustomStringConvertible { + public var description: String { + switch self { + case .profileAndDataTypesPicker: ".profileAndDataTypesPicker" + case .moreInfo: ".moreInfo" + case .getReadPermission(let url): "getReadPermission(\(url.path))" + case .fileImport(dataType: let dataType, summary: let summaryDataTypes): ".fileImport(dataType: .\(dataType)\(!summaryDataTypes.isEmpty ? ", summary: [\(summaryDataTypes.map { "." + $0.rawValue }.sorted().joined(separator: ", "))]" : ""))" + case .summary(let dataTypes): ".summary([\(dataTypes.map { "." + $0.rawValue }.sorted().joined(separator: ", "))])" + case .feedback: ".feedback" + } + } +} + +extension DataImportViewModel.ButtonType: CustomStringConvertible { + public var description: String { + switch self { + case .next(let screen): ".next(\(screen))" + case .initiateImport(disabled: let disabled): ".initiateImport\(disabled ? "(disabled)" : "")" + case .skip: ".skip" + case .cancel: ".cancel" + case .back: ".back" + case .done: ".done" + case .submit: ".submit" + } + } +} + +extension DataImportViewModel.DataTypeImportResult: CustomStringConvertible { + public var description: String { + ".init(.\(dataType), \(result))" + } +} + +extension DataImport.DataTypeSummary: CustomStringConvertible { + public var description: String { + ".init(successful: \(successful), duplicate: \(duplicate), failed: \(failed))" + } +} + +private extension String { + func with(_ addition: String?) -> String { + guard !self.isEmpty else { return addition ?? ""} + guard let addition, !addition.isEmpty else { return self } + return self + " - " + addition + } +} + +private extension URL { + static let mockURL = URL(fileURLWithPath: "/Users/Dax/Library/ApplicationSupport/BrowserCompany/Browser/") + + static let testCSV = URL(fileURLWithPath: "/Users/Dax/Downloads/passwords.csv") + static let testHTML = URL(fileURLWithPath: "/Users/Dax/Downloads/bookmarks.html") + + static func profile(named name: String) -> URL { + return mockURL.appendingPathComponent(name) + } +} diff --git a/UnitTests/DataImport/FileImportViewLocalizationTests.swift b/UnitTests/DataImport/FileImportViewLocalizationTests.swift new file mode 100644 index 0000000000..8c65ea0356 --- /dev/null +++ b/UnitTests/DataImport/FileImportViewLocalizationTests.swift @@ -0,0 +1,189 @@ +// +// FileImportViewLocalizationTests.swift +// +// Copyright © 2023 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 +import XCTest +import SwiftUI +@testable import DuckDuckGo_Privacy_Browser + +@available(macOS 13.0, *) +class FileImportViewLocalizationTests: XCTestCase { + + override func tearDown() { + Bundle.resetSwizzling() + customAssert = nil + customAssertionFailure = nil + } + + func testFileImportLocalizedStrings() throws { + // collect reference "Base" localization escaped format arguments + var referenceValues = [DataImport.Source: [DataImport.DataType: [String]]]() + for locale in [.base] + Bundle.main.availableLocalizations().filter({ $0 != .base }) { + // swizzle "locale".lproj bundle + setLocale(locale) + + for source in DataImport.Source.allCases { + for dataType in source.supportedDataTypes { + let e = expectation(description: "Button item should not be missing") + // build instructions + let items = fileImportInstructionsBuilder(source: source, dataType: dataType) { title in + // button title should not be empty + XCTAssertFalse(title.isEmpty, "\(locale).\(source.rawValue).\(dataType.rawValue).title") + // button should be present in items + e.fulfill() + return AnyView(EmptyView()) + } + waitForExpectations(timeout: 0) + + // find first .string (format) item + let formatItem = items.first + guard case .string(let format) = formatItem else { + XCTFail("First item should be Format String – \(source.rawValue).\(dataType.rawValue)") + continue + } + + // find all %-escaped sequences in the format + let formatItemsRegex = /%(?:\d+\$)?(?:lld|ld|d|s|@)/ + let formatArgs = format.matches(of: formatItemsRegex).enumerated().map { (idx, match) in + var value = String(match.output) + if !value.contains("\(idx + 1)") { + // if escape sequence has no index - insert it (convert %d to %4$d) + value.insert(contentsOf: "\(idx + 1)$", at: value.index(after: value.startIndex)) + } + return value + } + + switch locale { + case .base: + // write Base reference value + referenceValues[source, default: [:]][dataType] = formatArgs + default: + // format args should match but their positions may differ + XCTAssertEqual(referenceValues[source]![dataType]!.sorted(), formatArgs.sorted(), + "\(locale).\(source.rawValue).\(dataType.rawValue).formatArgs") + } + + // number of %@ arguments should match number of builder .image and .button arguments + XCTAssertEqual(referenceValues[source]![dataType]!.filter { $0.hasSuffix("@") }.count, + items.filter { if case .image = $0 { true } else if case .view = $0 { true } else { false } }.count, + "\(locale).\(source.rawValue).\(dataType.rawValue).imagesAndButtons") + + // number of %s arguments should match number of builder .string arguments + XCTAssertEqual(referenceValues[source]![dataType]!.filter { $0.hasSuffix("s") }.count, + items[1.../* 0 == format */].filter { if case .string = $0 { true } else { false } }.count, + "\(locale).\(source.rawValue).\(dataType.rawValue).strings") + +#if CI + customAssert = { condition, message, file, line in + XCTAssert(condition(), "\(locale).\(source.rawValue).\(dataType.rawValue).InstructionsView.assert: " + message(), file: file, line: line) + } + customAssertionFailure = { message, file, line in + XCTFail("\(locale).\(source.rawValue).\(dataType.rawValue).InstructionsView.assertionFailure: " + message(), file: file, line: line) + } +#endif + _=InstructionsView { + items + } // should not assert + } + } + } + } + + // Helper function to set the application's locale for testing + private func setLocale(_ identifier: String) { + let bundlePath = Bundle.mainBundle.path(forResource: identifier, ofType: "lproj")! + let testBundle = Bundle(path: bundlePath)! + Bundle.swizzleMainBundle(with: testBundle) + } +} + +extension InstructionsView.InstructionsItem: Hashable, CustomStringConvertible { + + public var description: String { + switch self { + case .string(let string): ".string(\"\(string)\")" + case .image(let image): ".image(\(image.representations.first!))" + case .view: ".view" + } + } + + public static func == (lhs: DuckDuckGo_Privacy_Browser.InstructionsView.InstructionsItem, rhs: DuckDuckGo_Privacy_Browser.InstructionsView.InstructionsItem) -> Bool { + switch lhs { + case .string(let value): if case .string(value) = rhs { return true } + case .image(let value1): if case .image(let value2) = rhs { return value1.tiffRepresentation! == value2.tiffRepresentation } + case .view: if case .view = rhs { return true } + } + return false + } + + public func hash(into hasher: inout Hasher) { + switch self { + case .string: + hasher.combine(1) + case .image(let image): + hasher.combine(2) + hasher.combine(image.tiffRepresentation!) + case .view: + hasher.combine(3) + } + } + +} + +private extension String { + static let base = "Base" +} + +private extension Bundle { + + static var mainBundle: Bundle = .main + static var testBundle: Bundle? + + func availableLocalizations() -> [String] { + try! FileManager.default.contentsOfDirectory(atPath: resourcePath!).compactMap { + guard $0.hasSuffix(".lproj") else { return nil } + return $0.dropping(suffix: ".lproj") + } + } + + static func swizzleMainBundle(with bundle: Bundle) { + if testBundle == nil { + mainBundle = Bundle.main + swizzleMainBundle() + } + + testBundle = bundle + } + + static func resetSwizzling() { + guard testBundle != nil else { return } + swizzleMainBundle() + } + + private static func swizzleMainBundle() { + let originalMethod = class_getClassMethod(self, #selector(getter: Bundle.main))! + let swizzledMethod = class_getClassMethod(self, #selector(getter: swizzledMain))! + + method_exchangeImplementations(originalMethod, swizzledMethod) + } + + @objc dynamic static var swizzledMain: Bundle { + testBundle! + } + +} diff --git a/UnitTests/DataImport/FirefoxDataImporterTests.swift b/UnitTests/DataImport/FirefoxDataImporterTests.swift index ca4bc0117a..b472dd0de1 100644 --- a/UnitTests/DataImport/FirefoxDataImporterTests.swift +++ b/UnitTests/DataImport/FirefoxDataImporterTests.swift @@ -23,34 +23,19 @@ import XCTest @MainActor class FirefoxDataImporterTests: XCTestCase { - func testWhenImportingWithoutAnyDataTypes_ThenSummaryIsEmpty() async { - let loginImporter = MockLoginImporter() - let faviconManager = FaviconManagerMock() - let bookmarkImporter = MockBookmarkImporter(importBookmarks: { _, _ in .init(successful: 0, duplicates: 0, failed: 0) }) - let importer = FirefoxDataImporter(loginImporter: loginImporter, bookmarkImporter: bookmarkImporter, faviconManager: faviconManager) - - let summary = await importer.importData(types: [], from: .init(browser: .firefox, profileURL: resourceURL())) - - if case let .success(summary) = summary { - XCTAssert(summary.isEmpty) - } else { - XCTFail("Received failure unexpectedly") - } - } - func testWhenImportingBookmarks_AndBookmarkImportSucceeds_ThenSummaryIsPopulated() async { let loginImporter = MockLoginImporter() let faviconManager = FaviconManagerMock() let bookmarkImporter = MockBookmarkImporter(importBookmarks: { _, _ in .init(successful: 1, duplicates: 2, failed: 3) }) - let importer = FirefoxDataImporter(loginImporter: loginImporter, bookmarkImporter: bookmarkImporter, faviconManager: faviconManager) + let importer = FirefoxDataImporter(profile: .init(browser: .firefox, profileURL: resourceURL()), primaryPassword: nil, loginImporter: loginImporter, bookmarkImporter: bookmarkImporter, faviconManager: faviconManager) - let summary = await importer.importData(types: [.bookmarks], from: .init(browser: .firefox, profileURL: resourceURL())) + let result = await importer.importData(types: [.bookmarks]) - if case let .success(summary) = summary { - XCTAssertEqual(summary.bookmarksResult?.successful, 1) - XCTAssertEqual(summary.bookmarksResult?.duplicates, 2) - XCTAssertEqual(summary.bookmarksResult?.failed, 3) - XCTAssertNil(summary.loginsResult) + XCTAssertNil(result[.passwords]) + if case let .success(bookmarks) = result[.bookmarks] { + XCTAssertEqual(bookmarks.successful, 1) + XCTAssertEqual(bookmarks.duplicate, 2) + XCTAssertEqual(bookmarks.failed, 3) } else { XCTFail("Received populated summary unexpectedly") } @@ -63,11 +48,7 @@ class FirefoxDataImporterTests: XCTestCase { } extension FirefoxDataImporter { - func importData(types: [DataImport.DataType], from profile: DataImport.BrowserProfile?) async -> DataImportResult { - return await withCheckedContinuation { continuation in - importData(types: types, from: profile) { result in - continuation.resume(returning: result) - } - } + func importData(types: Set) async -> DataImportSummary { + return await importData(types: types).task.value } } diff --git a/UnitTests/DataImport/FirefoxLoginReaderTests.swift b/UnitTests/DataImport/FirefoxLoginReaderTests.swift index cc590fb06b..83abe67b66 100644 --- a/UnitTests/DataImport/FirefoxLoginReaderTests.swift +++ b/UnitTests/DataImport/FirefoxLoginReaderTests.swift @@ -132,7 +132,7 @@ class FirefoxLoginReaderTests: XCTestCase { switch result { case .failure(let error as FirefoxLoginReader.ImportError): - XCTAssertEqual(error.type, .couldNotFindLoginsFile) + XCTAssertEqual(error.type, .couldNotDetermineFormat) default: XCTFail("Received unexpected \(result)") } diff --git a/UnitTests/DataImport/InstructionsFormatParserTests.swift b/UnitTests/DataImport/InstructionsFormatParserTests.swift new file mode 100644 index 0000000000..2b0bc5ca62 --- /dev/null +++ b/UnitTests/DataImport/InstructionsFormatParserTests.swift @@ -0,0 +1,131 @@ +// +// InstructionsFormatParserTests.swift +// +// Copyright © 2023 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 +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class InstructionsFormatParserTests: XCTestCase { + + func testOnlyTextLines() throws { + let format = """ + line 1 + line 2 + """ + let parsed = try InstructionsFormatParser().parse(format: format) + + XCTAssertEqual(parsed, [[.text("line 1")], [.text("line 2")]]) + print(parsed) + } + + func testLinesWithEscapedExpressions() throws { + let format = """ + %d line %s %@ + line %ld + %lld%s%@ line 3 %d + """ + let parsed = try InstructionsFormatParser().parse(format: format) + + XCTAssertEqual(parsed, [ + [.number(argIndex: 1), .text("line "), .string(argIndex: 2), .text(" "), .object(argIndex: 3)], + [.text("line "), .number(argIndex: 4)], + [.number(argIndex: 5), .string(argIndex: 6), .object(argIndex: 7), .text(" line 3 "), .number(argIndex: 8)], + ]) + print(parsed) + } + + func testLinesWithIndexedEscapedExpressions() throws { + let format = """ + %2$d line %3$s %1$@ + line %ld + %lld%7$s%6$@ line 3 %8$d + """ + let parsed = try InstructionsFormatParser().parse(format: format) + + XCTAssertEqual(parsed, [ + [.number(argIndex: 2), .text("line "), .string(argIndex: 3), .text(" "), .object(argIndex: 1)], + [.text("line "), .number(argIndex: 4)], + [.number(argIndex: 5), .string(argIndex: 7), .object(argIndex: 6), .text(" line 3 "), .number(argIndex: 8)], + ]) + print(parsed) + } + + func testItalicMarkdown() throws { + let format = "st_art_ some_text “_italic_text_” text_ __italic_text__ text" + let parsed = try InstructionsFormatParser().parse(format: format) + XCTAssertEqual(parsed, [[.text("st_art_ some_text “"), .text("italic_text", italic: true), .text("” text_ "), .text("italic_text", italic: true), .text(" text")]]) + } + + func testItalicMarkdownMultiline() throws { + let format = """ + some_text __italic_text_ + text_ __non_italic_text__ text + """ + let parsed = try InstructionsFormatParser().parse(format: format) + XCTAssertEqual(parsed, [ + [.text("some_text "), .text("italic_text_", italic: true)], + [.text("text_ ", italic: true), .text("non_italic_text__ text", italic: false)]]) + } + + func testBoldMarkdown() throws { + let format = "st*art* some*text *nonbold*text* text* **bold*text** text" + let parsed = try InstructionsFormatParser().parse(format: format) + XCTAssertEqual(parsed, [[.text("st*art* some*text *nonbold*text* text* "), .text("bold*text", bold: true), .text(" text")]]) + } + + func testBoldMarkdownMultiline() throws { + let format = """ + *some*text* **bold*text* + text* **non*bold*text** text **.csv** + """ + let parsed = try InstructionsFormatParser().parse(format: format) + XCTAssertEqual(parsed, [ + [.text("*some*text* "), .text("bold*text*", bold: true)], + [.text("text* ", bold: true), .text("non*bold*text** text ", bold: false), .text(".csv", bold: true)] + ]) + } + + func testMarkdownWithExpressions() throws { + let format = """ + %d Open **%s** + %d In a fresh tab, click %@ then **Google __Password__ Manager → Settings ** + %d Find “Export _Passwords_” and click **Download File** + %d __Save the* passwords **file** someplace__ you can_find it (e.g., Desktop) + %d %@ + """ + let parsed = try InstructionsFormatParser().parse(format: format) + + XCTAssertEqual(parsed[safe: 0], [.number(argIndex: 1), .text("Open "), .string(argIndex: 2, bold: true)]) + XCTAssertEqual(parsed[safe: 1], [.number(argIndex: 3), .text("In a fresh tab, click "), .object(argIndex: 4), .text(" then "), .text("Google ", bold: true), .text("Password", bold: true, italic: true), .text(" Manager → Settings ", bold: true)]) + XCTAssertEqual(parsed[safe: 2], [.number(argIndex: 5), .text("Find “Export "), .text("Passwords", italic: true), .text("” and click "), .text("Download File", bold: true)]) + XCTAssertEqual(parsed[safe: 3], [.number(argIndex: 6), .text("Save the* passwords ", italic: true), .text("file", bold: true, italic: true), .text(" someplace", italic: true), .text(" you can_find it (e.g., Desktop)")]) + XCTAssertEqual(parsed[safe: 4], [.number(argIndex: 7), .object(argIndex: 8)]) + XCTAssertNil(parsed[safe: 5]) + } + + func testInvalidEscapeSequencesThrow() { + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "text with %z escape char")) + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "%llld text with invalid digit")) + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "%.2f unsupported format")) + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "%.2d unsupported format")) + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "%f unsupported format")) + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "unexpected eof %")) + XCTAssertThrowsError(try InstructionsFormatParser().parse(format: "unexpected eol %\nasdf")) + } + +} diff --git a/UnitTests/DataImport/ThirdPartyBrowserTests.swift b/UnitTests/DataImport/ThirdPartyBrowserTests.swift index 469f16e2ab..5c63638a14 100644 --- a/UnitTests/DataImport/ThirdPartyBrowserTests.swift +++ b/UnitTests/DataImport/ThirdPartyBrowserTests.swift @@ -76,14 +76,11 @@ class ThirdPartyBrowserTests: XCTestCase { let mockApplicationSupportDirectoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(mockApplicationSupportDirectoryName) try mockApplicationSupportDirectory.writeToTemporaryDirectory() - guard let list = ThirdPartyBrowser.firefox.browserProfiles(supportDirectoryURL: mockApplicationSupportDirectoryURL) else { - XCTFail("Failed to get profile list") - return - } + let list = ThirdPartyBrowser.firefox.browserProfiles(applicationSupportURL: mockApplicationSupportDirectoryURL) - XCTAssertEqual(list.profiles.count, 2) + let validProfiles = list.profiles.filter { $0.validateProfileData()?.containsValidData == true } + XCTAssertEqual(validProfiles.count, 2) XCTAssertEqual(list.defaultProfile?.profileName, "default-release") - XCTAssertTrue(list.profiles.allSatisfy { profile in return profile.hasBrowserData }) } func testWhenGettingBrowserProfiles_AndFirefoxProfileOnlyHasBookmarksData_ThenFirefoxProfileIsReturned() throws { @@ -102,14 +99,11 @@ class ThirdPartyBrowserTests: XCTestCase { let mockApplicationSupportDirectoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(mockApplicationSupportDirectoryName) try mockApplicationSupportDirectory.writeToTemporaryDirectory() - guard let list = ThirdPartyBrowser.firefox.browserProfiles(supportDirectoryURL: mockApplicationSupportDirectoryURL) else { - XCTFail("Failed to get profile list") - return - } + let list = ThirdPartyBrowser.firefox.browserProfiles(applicationSupportURL: mockApplicationSupportDirectoryURL) - XCTAssertEqual(list.profiles.count, 1) + let validProfiles = list.profiles.filter { $0.validateProfileData()?.containsValidData == true } + XCTAssertEqual(validProfiles.count, 1) XCTAssertEqual(list.defaultProfile?.profileName, "default-release") - XCTAssertTrue(list.profiles.allSatisfy { profile in return profile.hasBrowserData }) } private func key4DatabaseURL() -> URL { diff --git a/UnitTests/HomePage/DataImportProviderTests.swift b/UnitTests/HomePage/DataImportProviderTests.swift index b2bc3e9271..72689aed8a 100644 --- a/UnitTests/HomePage/DataImportProviderTests.swift +++ b/UnitTests/HomePage/DataImportProviderTests.swift @@ -108,7 +108,8 @@ final class DataImportProviderTests: XCTestCase { } func testWhenNoPasswordsAndNoBookmarksDetectableAndSuccessImportThenDidImportIsTrue() { - DataImportViewController().successfulImportHappened = true + var model = DataImportViewModel() + model.successfulImportHappened = true XCTAssertTrue(provider.didImport) } diff --git a/UnitTests/HomePage/Mocks/MockBookmarkManager.swift b/UnitTests/HomePage/Mocks/MockBookmarkManager.swift index e598860f85..5f5d2f6513 100644 --- a/UnitTests/HomePage/Mocks/MockBookmarkManager.swift +++ b/UnitTests/HomePage/Mocks/MockBookmarkManager.swift @@ -79,8 +79,8 @@ class MockBookmarkManager: BookmarkManager { func moveFavorites(with objectUUIDs: [String], toIndex: Int?, completion: @escaping (Error?) -> Void) {} - func importBookmarks(_ bookmarks: DuckDuckGo_Privacy_Browser.ImportedBookmarks, source: DuckDuckGo_Privacy_Browser.BookmarkImportSource) -> DuckDuckGo_Privacy_Browser.BookmarkImportResult { - BookmarkImportResult(successful: 0, duplicates: 0, failed: 0) + func importBookmarks(_ bookmarks: DuckDuckGo_Privacy_Browser.ImportedBookmarks, source: DuckDuckGo_Privacy_Browser.BookmarkImportSource) -> DuckDuckGo_Privacy_Browser.BookmarksImportSummary { + BookmarksImportSummary(successful: 0, duplicates: 0, failed: 0) } func handleFavoritesAfterDisablingSync() {} diff --git a/UnitTests/YoutubePlayer/DuckPlayerTests.swift b/UnitTests/YoutubePlayer/DuckPlayerTests.swift index e87597ca27..ae6ac020b5 100644 --- a/UnitTests/YoutubePlayer/DuckPlayerTests.swift +++ b/UnitTests/YoutubePlayer/DuckPlayerTests.swift @@ -38,11 +38,11 @@ final class DuckPlayerTests: XCTestCase { let otherFaviconView = FaviconView(url: URL(string: "example.com")) duckPlayer.mode = .enabled - XCTAssertEqual(duckPlayer.image(for: duckPlayerFaviconView), NSImage.duckPlayer) + XCTAssertEqual(duckPlayer.image(for: duckPlayerFaviconView)?.tiffRepresentation, NSImage.duckPlayer.tiffRepresentation) XCTAssertNil(duckPlayer.image(for: otherFaviconView)) duckPlayer.mode = .alwaysAsk - XCTAssertEqual(duckPlayer.image(for: duckPlayerFaviconView), NSImage.duckPlayer) + XCTAssertEqual(duckPlayer.image(for: duckPlayerFaviconView)?.tiffRepresentation, NSImage.duckPlayer.tiffRepresentation) XCTAssertNil(duckPlayer.image(for: otherFaviconView)) duckPlayer.mode = .disabled diff --git a/scripts/archive.sh b/scripts/archive.sh index eacd115cb2..38ddb80c05 100755 --- a/scripts/archive.sh +++ b/scripts/archive.sh @@ -67,7 +67,7 @@ read_command_line_arguments() { subscription) app_name="DuckDuckGo Privacy Pro" scheme="DuckDuckGo Privacy Pro" - configuration="Release" + configuration="Review" ;; clear-keychain) clear_keychain