diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ca8a3a4038..a55d2f655c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -208,6 +208,9 @@ jobs: name: Make Release Build + # Dependabot doesn't have access to all secrets, so we skip this job + if: github.actor != 'dependabot[bot]' + strategy: matrix: scheme: [ "DuckDuckGo Privacy Browser", "DuckDuckGo Privacy Pro" ] diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index e7fb513c9b..4a78a03de3 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1679,6 +1679,10 @@ 3776582D27F71652009A6B35 /* WebsiteBreakageReportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776582C27F71652009A6B35 /* WebsiteBreakageReportTests.swift */; }; 3776582F27F82E62009A6B35 /* AutofillPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776582E27F82E62009A6B35 /* AutofillPreferences.swift */; }; 3776583127F8325B009A6B35 /* AutofillPreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776583027F8325B009A6B35 /* AutofillPreferencesTests.swift */; }; + 3778183D2AD6F86D00533759 /* FavoritesDisplayModeSyncHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377D801B2AB47FBB002AF251 /* FavoritesDisplayModeSyncHandler.swift */; }; + 377D801C2AB47FBB002AF251 /* FavoritesDisplayModeSyncHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377D801B2AB47FBB002AF251 /* FavoritesDisplayModeSyncHandler.swift */; }; + 377D801E2AB48189002AF251 /* FavoritesDisplayModeSyncHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377D801B2AB47FBB002AF251 /* FavoritesDisplayModeSyncHandler.swift */; }; + 377D801F2AB48191002AF251 /* FavoritesDisplayModeSyncHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377D801B2AB47FBB002AF251 /* FavoritesDisplayModeSyncHandler.swift */; }; 378205F62837CBA800D1D4AA /* SavedStateMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378205F52837CBA800D1D4AA /* SavedStateMock.swift */; }; 378205F8283BC6A600D1D4AA /* StartupPreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378205F7283BC6A600D1D4AA /* StartupPreferencesTests.swift */; }; 378205FB283C277800D1D4AA /* MainMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378205FA283C277800D1D4AA /* MainMenuTests.swift */; }; @@ -2850,6 +2854,8 @@ 5601FECD29B7973D00068905 /* TabBarViewItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5601FECC29B7973D00068905 /* TabBarViewItemTests.swift */; }; 5603D90629B7B746007F9F01 /* MockTabViewItemDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5603D90529B7B746007F9F01 /* MockTabViewItemDelegate.swift */; }; 5603D90729B7B746007F9F01 /* MockTabViewItemDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5603D90529B7B746007F9F01 /* MockTabViewItemDelegate.swift */; }; + 562984702AC4610100AC20EB /* SyncPreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5629846E2AC4610100AC20EB /* SyncPreferencesTests.swift */; }; + 562984712AC469E400AC20EB /* SyncPreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5629846E2AC4610100AC20EB /* SyncPreferencesTests.swift */; }; 56534DED29DF252C00121467 /* CapturingDefaultBrowserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56534DEC29DF252C00121467 /* CapturingDefaultBrowserProvider.swift */; }; 56534DEE29DF252C00121467 /* CapturingDefaultBrowserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56534DEC29DF252C00121467 /* CapturingDefaultBrowserProvider.swift */; }; 566B195D29CDB692007E38F4 /* MoreOptionsMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 566B195C29CDB692007E38F4 /* MoreOptionsMenuTests.swift */; }; @@ -3963,6 +3969,7 @@ 3776582C27F71652009A6B35 /* WebsiteBreakageReportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteBreakageReportTests.swift; sourceTree = ""; }; 3776582E27F82E62009A6B35 /* AutofillPreferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillPreferences.swift; sourceTree = ""; }; 3776583027F8325B009A6B35 /* AutofillPreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillPreferencesTests.swift; sourceTree = ""; }; + 377D801B2AB47FBB002AF251 /* FavoritesDisplayModeSyncHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesDisplayModeSyncHandler.swift; sourceTree = ""; }; 377E54382937B7C400780A0A /* DuckDuckGoAppStoreCI.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DuckDuckGoAppStoreCI.entitlements; sourceTree = ""; }; 378205F52837CBA800D1D4AA /* SavedStateMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedStateMock.swift; sourceTree = ""; }; 378205F7283BC6A600D1D4AA /* StartupPreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupPreferencesTests.swift; sourceTree = ""; }; @@ -4303,6 +4310,7 @@ 4BF6961F28BEEE8B00D402D4 /* LocalPinningManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPinningManagerTests.swift; sourceTree = ""; }; 5601FECC29B7973D00068905 /* TabBarViewItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarViewItemTests.swift; sourceTree = ""; }; 5603D90529B7B746007F9F01 /* MockTabViewItemDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTabViewItemDelegate.swift; sourceTree = ""; }; + 5629846E2AC4610100AC20EB /* SyncPreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPreferencesTests.swift; sourceTree = ""; }; 56534DEC29DF252C00121467 /* CapturingDefaultBrowserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturingDefaultBrowserProvider.swift; sourceTree = ""; }; 566B195C29CDB692007E38F4 /* MoreOptionsMenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreOptionsMenuTests.swift; sourceTree = ""; }; 566B196029CDB7C9007E38F4 /* CapturingOptionsButtonMenuDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturingOptionsButtonMenuDelegate.swift; sourceTree = ""; }; @@ -5471,6 +5479,7 @@ 3775913429AB99DA00E26367 /* Sync */ = { isa = PBXGroup; children = ( + 377D801A2AB47FA1002AF251 /* SettingSyncHandlers */, 370A34B02AB24E3700C77F7C /* SyncDebugMenu.swift */, 3775913529AB9A1C00E26367 /* SyncManagementDialogViewController.swift */, 373D9B4729EEAC1B00381FDD /* SyncMetadataDatabase.swift */, @@ -5492,6 +5501,14 @@ path = WebsiteBreakageReport; sourceTree = ""; }; + 377D801A2AB47FA1002AF251 /* SettingSyncHandlers */ = { + isa = PBXGroup; + children = ( + 377D801B2AB47FBB002AF251 /* FavoritesDisplayModeSyncHandler.swift */, + ); + path = SettingSyncHandlers; + sourceTree = ""; + }; 378205F9283C275E00D1D4AA /* Menus */ = { isa = PBXGroup; children = ( @@ -6498,6 +6515,14 @@ path = View; sourceTree = ""; }; + 5629846D2AC460DF00AC20EB /* Sync */ = { + isa = PBXGroup; + children = ( + 5629846E2AC4610100AC20EB /* SyncPreferencesTests.swift */, + ); + path = Sync; + sourceTree = ""; + }; 56534DEB29DF251C00121467 /* Mocks */ = { isa = PBXGroup; children = ( @@ -7179,6 +7204,7 @@ AA585D93248FD31400E9A3E2 /* UnitTests */ = { isa = PBXGroup; children = ( + 5629846D2AC460DF00AC20EB /* Sync */, B6A5A28C25B962CB00AA7ADA /* App */, 85F1B0C725EF9747004792B6 /* AppDelegate */, 4BF6962128C242E500D402D4 /* Autoconsent */, @@ -9910,7 +9936,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# We had issues where the Swift Package resources were not being added to the Agent Apps,\n# so we're manually coping them here.\n# It seems to be a known issue: https://forums.swift.org/t/swift-packages-resource-bundle-not-present-in-xcarchive-when-framework-using-said-package-is-archived/50084/2\ncp -RL \"${BUILT_PRODUCTS_DIR}\"/ContentScopeScripts_ContentScopeScripts.bundle \"${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\n"; + shellScript = "# We had issues where the Swift Package resources were not being added to the Agent Apps,\n# so we're manually coping them here.\n# It seems to be a known issue: https://forums.swift.org/t/swift-packages-resource-bundle-not-present-in-xcarchive-when-framework-using-said-package-is-archived/50084/2\ncp -RL \"${BUILT_PRODUCTS_DIR}\"/ContentScopeScripts_ContentScopeScripts.bundle \"${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\ncp -RL \"${BUILT_PRODUCTS_DIR}\"/DataBrokerProtection_DataBrokerProtection.bundle \"${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\n"; }; AA8EDF2824925E940071C2E8 /* Swift Lint */ = { isa = PBXShellScriptBuildPhase; @@ -10669,6 +10695,7 @@ 7B3618C42ADE77D2000D6154 /* NetworkProtectionNavBarPopoverManager.swift in Sources */, 3192A1EE2A4C4CFF0084EA89 /* NSStoryboardExtension.swift in Sources */, 3192A1EF2A4C4CFF0084EA89 /* PreferencesViewController.swift in Sources */, + 377D801E2AB48189002AF251 /* FavoritesDisplayModeSyncHandler.swift in Sources */, 3192A1F02A4C4CFF0084EA89 /* FireproofDomains.swift in Sources */, 3192A1F12A4C4CFF0084EA89 /* Database.swift in Sources */, 3192A1F22A4C4CFF0084EA89 /* HorizontallyCenteredLayout.swift in Sources */, @@ -10971,6 +10998,7 @@ 3706FB6B293F65D500E42796 /* HistoryEntry.swift in Sources */, 3706FB6C293F65D500E42796 /* FaviconStore.swift in Sources */, 3706FB6D293F65D500E42796 /* SuggestionListCharacteristics.swift in Sources */, + 377D801F2AB48191002AF251 /* FavoritesDisplayModeSyncHandler.swift in Sources */, 4B4D60C62A0C849600BCD287 /* NetworkProtectionInviteCodeViewModel.swift in Sources */, 3706FB6F293F65D500E42796 /* BookmarkListViewController.swift in Sources */, 3706FB70293F65D500E42796 /* SecureVaultLoginImporter.swift in Sources */, @@ -11564,6 +11592,7 @@ 3706FE79293F661700E42796 /* AppearancePreferencesTests.swift in Sources */, 3706FE7A293F661700E42796 /* FirePopoverViewModelTests.swift in Sources */, 3706FE7B293F661700E42796 /* HistoryStoringMock.swift in Sources */, + 562984702AC4610100AC20EB /* SyncPreferencesTests.swift in Sources */, 3706FE7C293F661700E42796 /* LocalBookmarkStoreTests.swift in Sources */, B6CA4825298CE4B70067ECCE /* AdClickAttributionTabExtensionTests.swift in Sources */, 3707C72D294B5D4100682A9F /* EmptyAttributionRulesProver.swift in Sources */, @@ -12040,6 +12069,7 @@ B68D21CA2ACBC971002DA3C2 /* MockPrivacyConfiguration.swift in Sources */, 4B957A462AC7AE700062CA31 /* WebsiteDataStore.swift in Sources */, 4B957A472AC7AE700062CA31 /* NetworkProtectionFeatureVisibility.swift in Sources */, + 3778183D2AD6F86D00533759 /* FavoritesDisplayModeSyncHandler.swift in Sources */, 4B957A482AC7AE700062CA31 /* PermissionContextMenu.swift in Sources */, 4B957A492AC7AE700062CA31 /* ContextMenuUserScript.swift in Sources */, 4B957A4A2AC7AE700062CA31 /* NSSavePanelExtension.swift in Sources */, @@ -13134,6 +13164,7 @@ 7B3618C22ADE75C8000D6154 /* NetworkProtectionNavBarPopoverManager.swift in Sources */, 859E7D6B27453BF3009C2B69 /* BookmarksExporter.swift in Sources */, 7B2DDCF82A93A8BB0039D884 /* NetworkProtectionAppEvents.swift in Sources */, + 377D801C2AB47FBB002AF251 /* FavoritesDisplayModeSyncHandler.swift in Sources */, 4B5FF67826B602B100D42879 /* FirefoxDataImporter.swift in Sources */, 37AFCE8B27DB69BC00471A10 /* PreferencesGeneralView.swift in Sources */, 37BF3F22286F0A7A00BD9014 /* PinnedTabsView.swift in Sources */, @@ -13385,6 +13416,7 @@ B630794226731F5400DCEE41 /* WKDownloadMock.swift in Sources */, B6C0B24626E9CB190031CB7F /* RunLoopExtensionTests.swift in Sources */, 56D145F129E6F06D00E3488A /* MockBookmarkManager.swift in Sources */, + 562984712AC469E400AC20EB /* SyncPreferencesTests.swift in Sources */, 317295D22AF058D3002C3206 /* MockWaitlistTermsAndConditionsActionHandler.swift in Sources */, B693956326F1C2A40015B914 /* FileDownloadManagerMock.swift in Sources */, B6C2C9EF276081AB005B7F0A /* DeallocationTests.swift in Sources */, @@ -14399,7 +14431,7 @@ repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 82.2.2; + version = 83.0.0; }; }; AA06B6B52672AF8100F541C5 /* XCRemoteSwiftPackageReference "Sparkle" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ef0c39428e..22bf0a3929 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" : { - "revision" : "989e306052bc284a1202fad1087f8b88e515a966", - "version" : "82.2.2" + "revision" : "f7e20cd37bbc0d25ae3c3f25ef52d319366613e7", + "version" : "83.0.0" } }, { @@ -129,7 +129,7 @@ { "identity" : "trackerradarkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/duckduckgo/TrackerRadarKit", + "location" : "https://github.com/duckduckgo/TrackerRadarKit.git", "state" : { "revision" : "4684440d03304e7638a2c8086895367e90987463", "version" : "1.2.1" diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index d381e0617c..5f7677a160 100644 --- a/DuckDuckGo/Application/AppDelegate.swift +++ b/DuckDuckGo/Application/AppDelegate.swift @@ -316,6 +316,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FileDownloadManagerDel let environment = defaultEnvironment #endif let syncDataProviders = SyncDataProviders(bookmarksDatabase: BookmarkDatabase.shared.db) + if bookmarksManager.didMigrateToFormFactorSpecificFavorites { + syncDataProviders.bookmarksAdapter.shouldResetBookmarksSyncTimestamp = true + } let syncService = DDGSync(dataProvidersSource: syncDataProviders, errorEvents: SyncErrorHandler(), log: OSLog.sync, environment: environment) syncService.initializeIfNeeded() syncDataProviders.setUpDatabaseCleaners(syncService: syncService) diff --git a/DuckDuckGo/Assets.xcassets/Images/Sync-Desktop-New-96x96.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Sync-Desktop-New-96x96.imageset/Contents.json new file mode 100644 index 0000000000..f0fc325fee --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Sync-Desktop-New-96x96.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Sync-Desktop-New-96x96.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Sync-Desktop-New-96x96.imageset/Sync-Desktop-New-96x96.svg b/DuckDuckGo/Assets.xcassets/Images/Sync-Desktop-New-96x96.imageset/Sync-Desktop-New-96x96.svg new file mode 100644 index 0000000000..ae51c48aac --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Sync-Desktop-New-96x96.imageset/Sync-Desktop-New-96x96.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Images/Sync-Pair-96x96.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Sync-Pair-96x96.imageset/Contents.json new file mode 100644 index 0000000000..404bb6f11e --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Sync-Pair-96x96.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Sync-Pair-96x96.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Sync-Pair-96x96.imageset/Sync-Pair-96x96.svg b/DuckDuckGo/Assets.xcassets/Images/Sync-Pair-96x96.imageset/Sync-Pair-96x96.svg new file mode 100644 index 0000000000..1efd4425d5 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Sync-Pair-96x96.imageset/Sync-Pair-96x96.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Images/Sync-setup-success.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Sync-setup-success.imageset/Contents.json new file mode 100644 index 0000000000..ab4690b34a --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Sync-setup-success.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Sync-setup-success.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Sync-setup-success.imageset/Sync-setup-success.svg b/DuckDuckGo/Assets.xcassets/Images/Sync-setup-success.imageset/Sync-setup-success.svg new file mode 100644 index 0000000000..7783f258ca --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Sync-setup-success.imageset/Sync-setup-success.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Images/SyncAllDevices.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/SyncAllDevices.imageset/Contents.json new file mode 100644 index 0000000000..9411745913 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/SyncAllDevices.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "SyncAllDevices.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/SyncAllDevices.imageset/SyncAllDevices.svg b/DuckDuckGo/Assets.xcassets/Images/SyncAllDevices.imageset/SyncAllDevices.svg new file mode 100644 index 0000000000..e348c0af82 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/SyncAllDevices.imageset/SyncAllDevices.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Images/SyncRecoveryPDF.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/SyncRecoveryPDF.imageset/Contents.json index 7a83bbf78f..d524b0c457 100644 --- a/DuckDuckGo/Assets.xcassets/Images/SyncRecoveryPDF.imageset/Contents.json +++ b/DuckDuckGo/Assets.xcassets/Images/SyncRecoveryPDF.imageset/Contents.json @@ -8,5 +8,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true } } diff --git a/DuckDuckGo/Bookmarks/Legacy/LegacyBookmarksStoreMigration.swift b/DuckDuckGo/Bookmarks/Legacy/LegacyBookmarksStoreMigration.swift index a829f9b03a..c5ecef1b7c 100644 --- a/DuckDuckGo/Bookmarks/Legacy/LegacyBookmarksStoreMigration.swift +++ b/DuckDuckGo/Bookmarks/Legacy/LegacyBookmarksStoreMigration.swift @@ -66,10 +66,10 @@ public class LegacyBookmarksStoreMigration { _ = LegacyBookmarkStore(context: source) // Prepare destination - BookmarkUtils.prepareFoldersStructure(in: destination) + BookmarkUtils.prepareLegacyFoldersStructure(in: destination) guard let newRoot = BookmarkUtils.fetchRootFolder(destination), - let newFavoritesRoot = BookmarkUtils.fetchFavoritesFolder(destination) else { + let newFavoritesRoot = BookmarkUtils.fetchLegacyFavoritesFolder(destination) else { if bookmarkRoots.isEmpty { Pixel.fire(.debug(event: .bookmarksCouldNotPrepareDatabase)) @@ -163,7 +163,7 @@ public class LegacyBookmarksStoreMigration { } catch { destination.reset() - BookmarkUtils.prepareFoldersStructure(in: destination) + BookmarkUtils.prepareLegacyFoldersStructure(in: destination) do { try destination.save(onErrorFire: .bookmarksMigrationCouldNotPrepareDatabaseOnFailedMigration) } catch { diff --git a/DuckDuckGo/Bookmarks/Model/Bookmark.swift b/DuckDuckGo/Bookmarks/Model/Bookmark.swift index 29034e1fee..25df130bcc 100644 --- a/DuckDuckGo/Bookmarks/Model/Bookmark.swift +++ b/DuckDuckGo/Bookmarks/Model/Bookmark.swift @@ -27,12 +27,13 @@ internal class BaseBookmarkEntity { return request } - static func favorite(with uuid: String) -> NSFetchRequest { + static func favorite(with uuid: String, favoritesFolder: BookmarkEntity) -> NSFetchRequest { let request = BookmarkEntity.fetchRequest() - request.predicate = NSPredicate(format: "%K == %@ AND %K != nil AND %K == NO AND %K == NO", + request.predicate = NSPredicate(format: "%K == %@ AND %K CONTAINS %@ AND %K == NO AND %K == NO", #keyPath(BookmarkEntity.uuid), uuid as CVarArg, - #keyPath(BookmarkEntity.favoriteFolder), + #keyPath(BookmarkEntity.favoriteFolders), + favoritesFolder, #keyPath(BookmarkEntity.isFolder), #keyPath(BookmarkEntity.isPendingDeletion)) return request @@ -57,7 +58,12 @@ internal class BaseBookmarkEntity { self.isFolder = isFolder } - static func from(managedObject: BookmarkEntity, parentFolderUUID: String? = nil) -> BaseBookmarkEntity? { + static func from( + managedObject: BookmarkEntity, + parentFolderUUID: String? = nil, + favoritesDisplayMode: FavoritesDisplayMode + ) -> BaseBookmarkEntity? { + guard let id = managedObject.uuid, let title = managedObject.title else { assertionFailure("\(#file): Failed to create BaseBookmarkEntity from BookmarkManagedObject") @@ -66,7 +72,7 @@ internal class BaseBookmarkEntity { if managedObject.isFolder { let children: [BaseBookmarkEntity] = managedObject.childrenArray.compactMap { - return BaseBookmarkEntity.from(managedObject: $0, parentFolderUUID: id) + return BaseBookmarkEntity.from(managedObject: $0, parentFolderUUID: id, favoritesDisplayMode: favoritesDisplayMode) } let folder = BookmarkFolder(id: id, title: title, parentFolderUUID: parentFolderUUID, children: children) @@ -81,7 +87,7 @@ internal class BaseBookmarkEntity { return Bookmark(id: id, url: url, title: title, - isFavorite: managedObject.isFavorite, + isFavorite: managedObject.isFavorite(on: favoritesDisplayMode.displayedFolder), parentFolderUUID: parentFolderUUID) } } diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift b/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift index d9333daeea..bbcad8d6bb 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift @@ -45,6 +45,9 @@ protocol BookmarkManager: AnyObject { func moveFavorites(with objectUUIDs: [String], toIndex: Int?, completion: @escaping (Error?) -> Void) func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarkImportResult + func handleFavoritesAfterDisablingSync() + var didMigrateToFormFactorSpecificFavorites: Bool { get } + // Wrapper definition in a protocol is not supported yet var listPublisher: Published.Publisher { get } var list: BookmarkList? { get } @@ -55,19 +58,40 @@ final class LocalBookmarkManager: BookmarkManager { static let shared = LocalBookmarkManager() - private init() {} + private init() { + self.subscribeToFavoritesDisplayMode() + } init(bookmarkStore: BookmarkStore, faviconManagement: FaviconManagement) { self.bookmarkStore = bookmarkStore self.faviconManagement = faviconManagement + self.subscribeToFavoritesDisplayMode() + } + + private func subscribeToFavoritesDisplayMode() { + favoritesDisplayMode = AppearancePreferences.shared.favoritesDisplayMode + favoritesDisplayModeCancellable = AppearancePreferences.shared.$favoritesDisplayMode + .dropFirst() + .sink { [weak self] displayMode in + self?.favoritesDisplayMode = displayMode + self?.bookmarkStore.applyFavoritesDisplayMode(displayMode) + self?.loadBookmarks() + } } @Published private(set) var list: BookmarkList? var listPublisher: Published.Publisher { $list } + var didMigrateToFormFactorSpecificFavorites: Bool { + bookmarkStore.didMigrateToFormFactorSpecificFavorites + } + private lazy var bookmarkStore: BookmarkStore = LocalBookmarkStore(bookmarkDatabase: BookmarkDatabase.shared) private lazy var faviconManagement: FaviconManagement = FaviconManager.shared + private var favoritesDisplayMode: FavoritesDisplayMode = .displayNative(.desktop) + private var favoritesDisplayModeCancellable: AnyCancellable? + // MARK: - Bookmarks func loadBookmarks() { @@ -300,17 +324,10 @@ final class LocalBookmarkManager: BookmarkManager { return results } - // MARK: - Debugging - - func resetBookmarks() { - guard let store = bookmarkStore as? LocalBookmarkStore else { - return - } + // MARK: - Sync - store.resetBookmarks { [self] _ in - self.loadBookmarks() - self.requestSync() - } + func handleFavoritesAfterDisablingSync() { + bookmarkStore.handleFavoritesAfterDisablingSync() } func requestSync() { @@ -322,4 +339,17 @@ final class LocalBookmarkManager: BookmarkManager { syncService.scheduler.notifyDataChanged() } } + + // MARK: - Debugging + + func resetBookmarks() { + guard let store = bookmarkStore as? LocalBookmarkStore else { + return + } + + store.resetBookmarks { [self] _ in + self.loadBookmarks() + self.requestSync() + } + } } diff --git a/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift b/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift index db698a4904..2babd05dff 100644 --- a/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift +++ b/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Bookmarks import Foundation enum BookmarkStoreFetchPredicateType { @@ -43,6 +44,8 @@ struct BookmarkImportResult: Equatable { protocol BookmarkStore { + func applyFavoritesDisplayMode(_ configuration: FavoritesDisplayMode) + func loadAll(type: BookmarkStoreFetchPredicateType, completion: @escaping ([BaseBookmarkEntity]?, Error?) -> Void) func save(bookmark: Bookmark, parent: BookmarkFolder?, index: Int?, completion: @escaping (Bool, Error?) -> Void) func save(folder: BookmarkFolder, parent: BookmarkFolder?, completion: @escaping (Bool, Error?) -> Void) @@ -56,4 +59,6 @@ protocol BookmarkStore { func moveFavorites(with objectUUIDs: [String], toIndex: Int?, completion: @escaping (Error?) -> Void) func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarkImportResult + func handleFavoritesAfterDisablingSync() + var didMigrateToFormFactorSpecificFavorites: Bool { get } } diff --git a/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift b/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift index 7db086d8dd..d9600852ec 100644 --- a/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift +++ b/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Combine import Common import Foundation import CoreData @@ -37,9 +38,11 @@ final class LocalBookmarkStore: BookmarkStore { } // Directly used in tests - init(contextProvider: @escaping () -> NSManagedObjectContext) { + init(contextProvider: @escaping () -> NSManagedObjectContext, appearancePreferences: AppearancePreferences = .shared) { self.contextProvider = contextProvider + favoritesDisplayMode = appearancePreferences.favoritesDisplayMode + migrateToFormFactorSpecificFavoritesFolders() removeInvalidBookmarkEntities() cacheReadOnlyTopLevelBookmarksFolders() } @@ -56,6 +59,9 @@ final class LocalBookmarkStore: BookmarkStore { case saveLoopError(Error?) } + private(set) var favoritesDisplayMode: FavoritesDisplayMode + private(set) var didMigrateToFormFactorSpecificFavorites: Bool = false + private let contextProvider: () -> NSManagedObjectContext /// All entities within the bookmarks store must exist under this root level folder. Because this value is used so frequently, it is cached here. @@ -201,6 +207,24 @@ final class LocalBookmarkStore: BookmarkStore { } } + private func migrateToFormFactorSpecificFavoritesFolders() { + let context = makeContext() + + context.performAndWait { + do { + BookmarkUtils.migrateToFormFactorSpecificFavorites(byCopyingExistingTo: .desktop, in: context) + + if context.hasChanges { + try context.save(onErrorFire: .bookmarksMigrationCouldNotPrepareMultipleFavoriteFolders) + didMigrateToFormFactorSpecificFavorites = true + } + } catch { + Thread.sleep(forTimeInterval: 1) + fatalError("Could not prepare Bookmarks DB structure") + } + } + } + private func cacheReadOnlyTopLevelBookmarksFolders() { let context = makeContext() context.performAndWait { @@ -209,12 +233,18 @@ final class LocalBookmarkStore: BookmarkStore { } self.rootLevelFolderObjectID = folder.objectID - self.favoritesFolderObjectID = BookmarkUtils.fetchFavoritesFolder(context)?.objectID + let favoritesFolderUUID = favoritesDisplayMode.displayedFolder.rawValue + self.favoritesFolderObjectID = BookmarkUtils.fetchFavoritesFolder(withUUID: favoritesFolderUUID, in: context)?.objectID } } // MARK: - Bookmarks + func applyFavoritesDisplayMode(_ displayMode: FavoritesDisplayMode) { + favoritesDisplayMode = displayMode + cacheReadOnlyTopLevelBookmarksFolders() + } + func loadAll(type: BookmarkStoreFetchPredicateType, completion: @escaping ([BaseBookmarkEntity]?, Error?) -> Void) { func mainQueueCompletion(bookmarks: [BaseBookmarkEntity]?, error: Error?) { DispatchQueue.main.async { @@ -247,7 +277,8 @@ final class LocalBookmarkStore: BookmarkStore { let entities: [BaseBookmarkEntity] = results.compactMap { entity in BaseBookmarkEntity.from(managedObject: entity, - parentFolderUUID: entity.parent?.uuid) + parentFolderUUID: entity.parent?.uuid, + favoritesDisplayMode: self.favoritesDisplayMode) } mainQueueCompletion(bookmarks: entities, error: nil) @@ -294,9 +325,8 @@ final class LocalBookmarkStore: BookmarkStore { parent: parentEntity, context: context) - if bookmark.isFavorite, - let favoritesFolder = self.favoritesRoot(in: context) { - bookmarkMO.addToFavorites(favoritesRoot: favoritesFolder) + if bookmark.isFavorite { + bookmarkMO.addToFavorites(with: favoritesDisplayMode, in: context) } } @@ -350,7 +380,8 @@ final class LocalBookmarkStore: BookmarkStore { throw BookmarkStoreError.missingEntity } - bookmarkMO.update(with: bookmark, favoritesFolder: self.favoritesRoot(in: context)) + let favoritesFolders = BookmarkUtils.fetchFavoritesFolders(for: favoritesDisplayMode, in: context) + bookmarkMO.update(with: bookmark, favoritesFoldersToAddFavorite: favoritesFolders, favoritesDisplayMode: favoritesDisplayMode) }) } catch { @@ -435,11 +466,11 @@ final class LocalBookmarkStore: BookmarkStore { throw BookmarkStoreError.missingEntity } - let favoritesRoot = self.favoritesRoot(in: context) + let favoritesFolders = BookmarkUtils.fetchFavoritesFolders(for: favoritesDisplayMode, in: context) bookmarkManagedObjects.forEach { managedObject in - if let entity = BaseBookmarkEntity.from(managedObject: managedObject, parentFolderUUID: nil) { + if let entity = BaseBookmarkEntity.from(managedObject: managedObject, parentFolderUUID: nil, favoritesDisplayMode: self.favoritesDisplayMode) { update(entity) - managedObject.update(with: entity, favoritesFolder: favoritesRoot) + managedObject.update(with: entity, favoritesFoldersToAddFavorite: favoritesFolders, favoritesDisplayMode: self.favoritesDisplayMode) } } }, onError: { [weak self] error in @@ -611,42 +642,47 @@ final class LocalBookmarkStore: BookmarkStore { throw BookmarkStoreError.storeDeallocated } - guard let favoritesFolder = self.favoritesRoot(in: context) else { + let displayedFavoritesFolderUUID = favoritesDisplayMode.displayedFolder.rawValue + guard let displayedFavoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: displayedFavoritesFolderUUID, in: context) else { throw BookmarkStoreError.missingFavoritesRoot } + let favoritesFolders = BookmarkUtils.fetchFavoritesFolders(for: favoritesDisplayMode, in: context) + let favoritesFoldersWithoutDisplayed = favoritesFolders.filter { $0.uuid != displayedFavoritesFolderUUID } + // Guarantee that bookmarks are fetched in the same order as the UUIDs. In the future, this should fetch all objects at once with a // batch fetch request and have them sorted in the correct order. let bookmarkManagedObjects: [BookmarkEntity] = objectUUIDs.compactMap { uuid in - let entityFetchRequest = BaseBookmarkEntity.favorite(with: uuid) + let entityFetchRequest = BaseBookmarkEntity.favorite(with: uuid, favoritesFolder: displayedFavoritesFolder) return (try? context.fetch(entityFetchRequest))?.first } - if let index = index, index < (favoritesFolder.favorites?.count ?? 0) { + if let index = index, index < (displayedFavoritesFolder.favorites?.count ?? 0) { var currentInsertionIndex = max(index, 0) for bookmarkManagedObject in bookmarkManagedObjects { var adjustedInsertionIndex = currentInsertionIndex - if let currentIndex = favoritesFolder.favorites?.index(of: bookmarkManagedObject), + if let currentIndex = displayedFavoritesFolder.favorites?.index(of: bookmarkManagedObject), currentInsertionIndex > currentIndex { adjustedInsertionIndex -= 1 } - bookmarkManagedObject.removeFromFavorites() - if adjustedInsertionIndex < (favoritesFolder.favorites?.count ?? 0) { + bookmarkManagedObject.removeFromFavorites(with: favoritesDisplayMode) + if adjustedInsertionIndex < (displayedFavoritesFolder.favorites?.count ?? 0) { bookmarkManagedObject.addToFavorites(insertAt: adjustedInsertionIndex, - favoritesRoot: favoritesFolder) + favoritesRoot: displayedFavoritesFolder) + bookmarkManagedObject.addToFavorites(folders: favoritesFoldersWithoutDisplayed) } else { - bookmarkManagedObject.addToFavorites(favoritesRoot: favoritesFolder) + bookmarkManagedObject.addToFavorites(folders: favoritesFolders) } currentInsertionIndex = adjustedInsertionIndex + 1 } } else { for bookmarkManagedObject in bookmarkManagedObjects { - bookmarkManagedObject.removeFromFavorites() - bookmarkManagedObject.addToFavorites(favoritesRoot: favoritesFolder) + bookmarkManagedObject.removeFromFavorites(with: favoritesDisplayMode) + bookmarkManagedObject.addToFavorites(folders: favoritesFolders) } } }, onError: { [weak self] error in @@ -758,6 +794,8 @@ final class LocalBookmarkStore: BookmarkStore { in context: NSManagedObjectContext) -> BookmarkImportResult { var total = BookmarkImportResult(successful: 0, duplicates: 0, failed: 0) + let favoritesFolders = BookmarkUtils.fetchFavoritesFolders(for: favoritesDisplayMode, in: context) + for bookmarkOrFolder in bookmarks { let bookmarkManagedObject: BookmarkEntity @@ -777,9 +815,8 @@ final class LocalBookmarkStore: BookmarkStore { } // Bookmarks from the bookmarks bar are imported as favorites - if let favoritesRoot = favoritesRoot(in: context), - bookmarkOrFolder.isDDGFavorite || (!bookmarkOrFolder.isFolder && markBookmarksAsFavorite == true) { - bookmarkManagedObject.addToFavorites(favoritesRoot: favoritesRoot) + if bookmarkOrFolder.isDDGFavorite || (!bookmarkOrFolder.isFolder && markBookmarksAsFavorite == true) { + bookmarkManagedObject.addToFavorites(folders: favoritesFolders) } if let children = bookmarkOrFolder.children { @@ -856,6 +893,26 @@ final class LocalBookmarkStore: BookmarkStore { } } + // MARK: - Sync + + func handleFavoritesAfterDisablingSync() { + applyChangesAndSave { [weak self] context in + guard let self else { + return + } + if self.favoritesDisplayMode.isDisplayUnified { + BookmarkUtils.copyFavorites(from: .unified, to: .desktop, clearingNonNativeFavoritesFolder: .mobile, in: context) + } else { + BookmarkUtils.copyFavorites(from: .desktop, to: .unified, clearingNonNativeFavoritesFolder: .mobile, in: context) + } + } onError: { error in + let nsError = error as NSError + let processedErrors = CoreDataErrorsParser.parse(error: nsError) + let params = processedErrors.errorPixelParameters + Pixel.fire(.debug(event: .favoritesCleanupFailed, error: error), withAdditionalParameters: params) + } onDidSave: {} + } + // MARK: - Concurrency func loadAll(type: BookmarkStoreFetchPredicateType) async -> Result<[BaseBookmarkEntity], Error> { @@ -964,9 +1021,9 @@ fileprivate extension BookmarkEntity { return false } - func update(with baseEntity: BaseBookmarkEntity, favoritesFolder: BookmarkEntity?) { + func update(with baseEntity: BaseBookmarkEntity, favoritesFoldersToAddFavorite: [BookmarkEntity], favoritesDisplayMode: FavoritesDisplayMode) { if let bookmark = baseEntity as? Bookmark { - update(with: bookmark, favoritesFolder: favoritesFolder) + update(with: bookmark, favoritesFoldersToAddFavorite: favoritesFoldersToAddFavorite, favoritesDisplayMode: favoritesDisplayMode) } else if let folder = baseEntity as? BookmarkFolder { update(with: folder) } else { @@ -974,15 +1031,13 @@ fileprivate extension BookmarkEntity { } } - func update(with bookmark: Bookmark, favoritesFolder: BookmarkEntity?) { + func update(with bookmark: Bookmark, favoritesFoldersToAddFavorite: [BookmarkEntity], favoritesDisplayMode: FavoritesDisplayMode) { url = bookmark.url title = bookmark.title if bookmark.isFavorite { - if let favoritesFolder = favoritesFolder { - addToFavorites(favoritesRoot: favoritesFolder) - } + addToFavorites(folders: favoritesFoldersToAddFavorite) } else { - removeFromFavorites() + removeFromFavorites(with: favoritesDisplayMode) } } diff --git a/DuckDuckGo/Common/Extensions/NSAlertExtension.swift b/DuckDuckGo/Common/Extensions/NSAlertExtension.swift index 6c0bd356bd..06d86aa2fc 100644 --- a/DuckDuckGo/Common/Extensions/NSAlertExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSAlertExtension.swift @@ -201,6 +201,26 @@ extension NSAlert { return alert } + static func syncBookmarksPaused() -> NSAlert { + let alert = NSAlert() + alert.messageText = UserText.syncBookmarkPausedAlertTitle + alert.informativeText = UserText.syncBookmarkPausedAlertDescription + alert.alertStyle = .warning + alert.addButton(withTitle: UserText.ok) + alert.addButton(withTitle: UserText.learnMore) + return alert + } + + static func syncCredentialsPaused() -> NSAlert { + let alert = NSAlert() + alert.messageText = UserText.syncCredentialsPausedAlertTitle + alert.informativeText = UserText.syncCredentialsPausedAlertDescription + alert.alertStyle = .warning + alert.addButton(withTitle: UserText.ok) + alert.addButton(withTitle: UserText.learnMore) + return alert + } + static func customConfigurationAlert(configurationUrl: String) -> NSAlert { let alert = NSAlert() alert.messageText = "Set custom configuration URL:" diff --git a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift index 7ecc00a798..637ed76b8c 100644 --- a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift +++ b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift @@ -163,7 +163,7 @@ extension UserText { static let dataBrokerProtectionWaitlistNotificationText = NSLocalizedString("data-broker-protection.waitlist.notification.text", value: "Open your invite", comment: "Title for Personal Information Removal waitlist notification") static let dataBrokerProtectionWaitlistJoinTitle = NSLocalizedString("data-broker-protection.waitlist.join.title", value: "Personal Information Removal Beta", comment: "Title for Personal Information Removal join waitlist screen") - static let dataBrokerProtectionWaitlistJoinSubtitle1 = NSLocalizedString("data-broker-protection.waitlist.join.subtitle.1", value: "Automatically scan and remove your data from 75+ sites that sell personal information with DuckDuckGo’s Personal Information Removal.", comment: "First subtitle for Personal Information Removal join waitlist screen") + static let dataBrokerProtectionWaitlistJoinSubtitle1 = NSLocalizedString("data-broker-protection.waitlist.join.subtitle.1", value: "Automatically scan and remove your data from 17+ sites that sell personal information with DuckDuckGo’s Personal Information Removal.", comment: "First subtitle for Personal Information Removal join waitlist screen") static let dataBrokerProtectionWaitlistJoinSubtitle2 = NSLocalizedString("data-broker-protection.waitlist.join.subtitle.2", value: "Join the waitlist, and we’ll notify you when it’s your turn.", comment: "Second subtitle for Personal Information Removal join waitlist screen") static let dataBrokerProtectionWaitlistJoinedTitle = NSLocalizedString("data-broker-protection.waitlist.joined.title", value: "You’re on the list!", comment: "Title for Personal Information Removal joined waitlist screen") @@ -172,7 +172,7 @@ extension UserText { static let dataBrokerProtectionWaitlistEnableNotifications = NSLocalizedString("data-broker-protection.waitlist.enable-notifications", value: "Want to get a notification when your Personal Information Removal invite is ready?", comment: "Enable notifications prompt for Personal Information Removal joined waitlist screen") static let dataBrokerProtectionWaitlistInvitedTitle = NSLocalizedString("data-broker-protection.waitlist.invited.title", value: "You’re invited to try\nPersonal Information Removal beta!", comment: "Title for Personal Information Removal invited screen") - static let dataBrokerProtectionWaitlistInvitedSubtitle = NSLocalizedString("data-broker-protection.waitlist.invited.subtitle", value: "Automatically find and remove your personal information – such as your name and address – from 75+ sites that store and sell it, reducing the risk of identity theft and spam.", comment: "Subtitle for Personal Information Removal invited screen") + static let dataBrokerProtectionWaitlistInvitedSubtitle = NSLocalizedString("data-broker-protection.waitlist.invited.subtitle", 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.", comment: "Subtitle for Personal Information Removal invited screen") static let dataBrokerProtectionWaitlistInvitedSection1Title = NSLocalizedString("data-broker-protection.waitlist.invited.section-1.title", value: "Continuous Scan and Removal", comment: "Title for section 1 of the Personal Information Removal invited screen") static let dataBrokerProtectionWaitlistInvitedSection1Subtitle = NSLocalizedString("data-broker-protection.waitlist.invited.section-1.subtitle", value: "Automatically scans for your info, requests its removal, and re-scans regularly to ensure it doesn’t reappear.", comment: "Subtitle for section 1 of the Personal Information Removal invited screen") diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 571c95e0d8..5ee923ae9d 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -427,6 +427,10 @@ struct UserText { static let general = NSLocalizedString("preferences.general", value: "General", comment: "Show general preferences") static let sync = NSLocalizedString("preferences.sync", value: "Sync", comment: "Show sync preferences") + static let syncBookmarkPausedAlertTitle = NSLocalizedString("alert.sync-bookmarks-paused-title", value: "Bookmarks Sync is Paused", comment: "Title for alert shown when sync bookmarks paused for too many items") + static let syncBookmarkPausedAlertDescription = NSLocalizedString("alert.sync-bookmarks-paused-description", value: "You have exceeded the bookmarks sync limit. Try deleting some bookmarks. Until this is resolved your bookmarks will not be backed up.", comment: "Description for alert shown when sync bookmarks paused for too many items") + static let syncCredentialsPausedAlertTitle = NSLocalizedString("alert.sync-credentials-paused-title", value: "Passwords Sync is Paused", comment: "Title for alert shown when sync credentials paused for too many items") + static let syncCredentialsPausedAlertDescription = NSLocalizedString("alert.sync-credentials-paused-description", value: "You have exceeded the passwords sync limit. Try deleting some passwords. Until this is resolved your passwords will not be backed up.", comment: "Description for alert shown when sync credentials paused for too many items") static let defaultBrowser = NSLocalizedString("preferences.default-browser", value: "Default Browser", comment: "Show default browser preferences") static let appearance = NSLocalizedString("preferences.appearance", value: "Appearance", comment: "Show appearance preferences") static let privacy = NSLocalizedString("preferences.privacy", value: "Privacy", comment: "Show privacy browser preferences") diff --git a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift index 4808e95d24..9a267858ea 100644 --- a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift +++ b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift @@ -168,6 +168,11 @@ public struct UserDefaultsWrapper { // Sync case syncEnvironment = "sync.environment" + case favoritesDisplayMode = "sync.favorites-display-mode" + case syncBookmarksPaused = "sync.bookmarks-paused" + case syncCredentialsPaused = "sync.credentials-paused" + case syncBookmarksPausedErrorDisplayed = "sync.bookmarks-paused-error-displayed" + case syncCredentialsPausedErrorDisplayed = "sync.credentials-paused-error-displayed" } enum RemovedKeys: String, CaseIterable { diff --git a/DuckDuckGo/DBP/DataBrokerProtectionAppEvents.swift b/DuckDuckGo/DBP/DataBrokerProtectionAppEvents.swift index 527bcf5d3e..1c584ba720 100644 --- a/DuckDuckGo/DBP/DataBrokerProtectionAppEvents.swift +++ b/DuckDuckGo/DBP/DataBrokerProtectionAppEvents.swift @@ -17,10 +17,12 @@ // import Foundation +import LoginItems struct DataBrokerProtectionAppEvents { func applicationDidFinishLaunching() { + let loginItemsManager = LoginItemsManager() let featureVisibility = DefaultDataBrokerProtectionFeatureVisibility() guard featureVisibility.isFeatureVisible() else { @@ -31,6 +33,8 @@ struct DataBrokerProtectionAppEvents { Task { try? await DataBrokerProtectionWaitlist().redeemDataBrokerProtectionInviteCodeIfAvailable() } + + restartBackgroundAgent(loginItemsManager: loginItemsManager) } func applicationDidBecomeActive() { @@ -67,4 +71,8 @@ struct DataBrokerProtectionAppEvents { includeAppVersionParameter: true) } } + + private func restartBackgroundAgent(loginItemsManager: LoginItemsManager) { + loginItemsManager.restartLoginItems([LoginItem.dbpBackgroundAgent], log: .dbp) + } } diff --git a/DuckDuckGo/DBP/DataBrokerProtectionDebugMenu.swift b/DuckDuckGo/DBP/DataBrokerProtectionDebugMenu.swift index c4e77414c2..04ae9cf6f1 100644 --- a/DuckDuckGo/DBP/DataBrokerProtectionDebugMenu.swift +++ b/DuckDuckGo/DBP/DataBrokerProtectionDebugMenu.swift @@ -70,7 +70,7 @@ final class DataBrokerProtectionDebugMenu: NSMenu { @objc private func resetWaitlistState() { DataBrokerProtectionWaitlist().waitlistStorage.deleteWaitlistState() - UserDefaultsAuthenticationData().reset() + KeychainAuthenticationData().reset() UserDefaults().removeObject(forKey: UserDefaultsWrapper.Key.dataBrokerProtectionTermsAndConditionsAccepted.rawValue) NotificationCenter.default.post(name: .dataBrokerProtectionWaitlistAccessChanged, object: nil) os_log("DBP waitlist state cleaned", log: .dataBrokerProtection) diff --git a/DuckDuckGo/DBP/DataBrokerProtectionManager.swift b/DuckDuckGo/DBP/DataBrokerProtectionManager.swift index 7b732c83fb..a83d37dd8c 100644 --- a/DuckDuckGo/DBP/DataBrokerProtectionManager.swift +++ b/DuckDuckGo/DBP/DataBrokerProtectionManager.swift @@ -26,7 +26,7 @@ public final class DataBrokerProtectionManager { static let shared = DataBrokerProtectionManager() - private let authenticationRepository: AuthenticationRepository = UserDefaultsAuthenticationData() + private let authenticationRepository: AuthenticationRepository = KeychainAuthenticationData() private let authenticationService: DataBrokerProtectionAuthenticationService = AuthenticationService() private let redeemUseCase: DataBrokerProtectionRedeemUseCase private let fakeBrokerFlag: DataBrokerDebugFlag = DataBrokerDebugFlagFakeBroker() diff --git a/DuckDuckGo/Feedback/Model/WebsiteBreakage.swift b/DuckDuckGo/Feedback/Model/WebsiteBreakage.swift index 15ed30bd26..7a61d2741a 100644 --- a/DuckDuckGo/Feedback/Model/WebsiteBreakage.swift +++ b/DuckDuckGo/Feedback/Model/WebsiteBreakage.swift @@ -37,11 +37,6 @@ struct WebsiteBreakage { case privacyDashboard = "dashboard" } - enum ProtectionsState: String { - case enabled = "1" - case disabled = "0" - } - let category: Category? let description: String? let siteUrlString: String @@ -55,7 +50,7 @@ struct WebsiteBreakage { let urlParametersRemoved: Bool let manufacturer: String let reportFlow: ReportFlow - let protectionsState: ProtectionsState + let protectionsState: Bool init( category: Category?, @@ -69,7 +64,7 @@ struct WebsiteBreakage { isGPCEnabled: Bool, ampURL: String, urlParametersRemoved: Bool, - protected: Bool, + protectionsState: Bool, manufacturer: String = "Apple", reportFlow: ReportFlow = .native ) { @@ -83,7 +78,7 @@ struct WebsiteBreakage { self.installedSurrogates = installedSurrogates self.isGPCEnabled = isGPCEnabled self.ampURL = ampURL - self.protectionsState = protected ? .enabled : .disabled + self.protectionsState = protectionsState self.urlParametersRemoved = urlParametersRemoved self.manufacturer = manufacturer self.reportFlow = reportFlow @@ -104,7 +99,7 @@ struct WebsiteBreakage { "os": osVersion, "manufacturer": manufacturer, "reportFlow": reportFlow.rawValue, - "protectionsState": protectionsState.rawValue + "protectionsState": protectionsState ? "true" : "false" ] } } diff --git a/DuckDuckGo/Feedback/View/FeedbackViewController.swift b/DuckDuckGo/Feedback/View/FeedbackViewController.swift index 502e62aadf..4043a88c7b 100644 --- a/DuckDuckGo/Feedback/View/FeedbackViewController.swift +++ b/DuckDuckGo/Feedback/View/FeedbackViewController.swift @@ -307,7 +307,7 @@ final class FeedbackViewController: NSViewController { let ampURL = currentTab?.linkProtection.lastAMPURLString ?? "" let urlParametersRemoved = currentTab?.linkProtection.urlParametersRemoved ?? false let configuration = ContentBlocking.shared.privacyConfigurationManager.privacyConfig - let protected = configuration.isFeature(.contentBlocking, enabledForDomain: currentTabUrl?.host) + let protectionsState = configuration.isFeature(.contentBlocking, enabledForDomain: currentTabUrl?.host) let websiteBreakage = WebsiteBreakage(category: selectedWebsiteBreakageCategory, description: browserFeedbackTextView.string, @@ -320,7 +320,7 @@ final class FeedbackViewController: NSViewController { isGPCEnabled: PrivacySecurityPreferences.shared.gpcEnabled, ampURL: ampURL, urlParametersRemoved: urlParametersRemoved, - protected: protected, + protectionsState: protectionsState, reportFlow: .native) websiteBreakageSender.sendWebsiteBreakage(websiteBreakage) } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtection+ConvenienceInitializers.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtection+ConvenienceInitializers.swift index b73b218a34..16cf31c323 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtection+ConvenienceInitializers.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtection+ConvenienceInitializers.swift @@ -25,9 +25,11 @@ import Common extension NetworkProtectionDeviceManager { static func create() -> NetworkProtectionDeviceManager { + let settings = TunnelSettings(defaults: .shared) + let networkClient = NetworkProtectionBackendClient(environment: settings.selectedEnvironment) let keyStore = NetworkProtectionKeychainKeyStore() let tokenStore = NetworkProtectionKeychainTokenStore() - return NetworkProtectionDeviceManager(tokenStore: tokenStore, keyStore: keyStore, errorEvents: .networkProtectionAppDebugEvents) + return NetworkProtectionDeviceManager(networkClient: networkClient, tokenStore: tokenStore, keyStore: keyStore, errorEvents: .networkProtectionAppDebugEvents) } } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift index a9d61924a2..6ff1f39b81 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift @@ -29,6 +29,8 @@ import SwiftUI @MainActor final class NetworkProtectionDebugMenu: NSMenu { + private let environmentMenu = NSMenu() + private let preferredServerMenu: NSMenu private let preferredServerAutomaticItem = NSMenuItem(title: "Automatic", action: #selector(NetworkProtectionDebugMenu.setSelectedServer)) @@ -87,6 +89,9 @@ final class NetworkProtectionDebugMenu: NSMenu { NSMenuItem(title: "Onboarding") .submenu(NetworkProtectionOnboardingMenu()) + NSMenuItem(title: "Environment") + .submenu(environmentMenu) + NSMenuItem(title: "Preferred Server").submenu(preferredServerMenu) NSMenuItem(title: "Registration Key") { @@ -148,6 +153,7 @@ final class NetworkProtectionDebugMenu: NSMenu { } preferredServerMenu.autoenablesItems = false + populateNetworkProtectionEnvironmentListMenuItems() populateNetworkProtectionServerListMenuItems() populateNetworkProtectionRegistrationKeyValidityMenuItems() @@ -293,6 +299,13 @@ final class NetworkProtectionDebugMenu: NSMenu { // MARK: Populating Menu Items + private func populateNetworkProtectionEnvironmentListMenuItems() { + environmentMenu.items = [ + NSMenuItem(title: "Production", action: #selector(setSelectedEnvironment(_:)), target: self, keyEquivalent: ""), + NSMenuItem(title: "Staging", action: #selector(setSelectedEnvironment(_:)), target: self, keyEquivalent: ""), + ] + } + private func populateNetworkProtectionServerListMenuItems() { let networkProtectionServerStore = NetworkProtectionServerListFileSystemStore(errorEvents: nil) let servers = (try? networkProtectionServerStore.storedNetworkProtectionServerList()) ?? [] @@ -381,12 +394,26 @@ final class NetworkProtectionDebugMenu: NSMenu { // MARK: - Menu State Update override func update() { + updateEnvironmentMenu() updatePreferredServerMenu() updateRekeyValidityMenu() updateNetworkProtectionMenuItemsState() updateNetworkProtectionItems() } + private func updateEnvironmentMenu() { + let selectedEnvironment = settings.selectedEnvironment + + switch selectedEnvironment { + case .production: + environmentMenu.items.first?.state = .on + environmentMenu.items.last?.state = .off + case .staging: + environmentMenu.items.first?.state = .off + environmentMenu.items.last?.state = .on + } + } + private func updatePreferredServerMenu() { let selectedServer = settings.selectedServer @@ -533,6 +560,28 @@ final class NetworkProtectionDebugMenu: NSMenu { return "" } } + + // MARK: Environment + @objc func setSelectedEnvironment(_ menuItem: NSMenuItem) { + let title = menuItem.title + let selectedEnvironment: TunnelSettings.SelectedEnvironment + + if title == "Staging" { + selectedEnvironment = .staging + } else { + selectedEnvironment = .production + } + + settings.selectedEnvironment = selectedEnvironment + + Task { + _ = try await NetworkProtectionDeviceManager.create().refreshServerList() + await MainActor.run { + populateNetworkProtectionServerListMenuItems() + } + settings.selectedServer = .automatic + } + } } extension NetworkProtectionDebugMenu: NSMenuDelegate { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift index 4c2babed28..5dca24962f 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift @@ -63,7 +63,7 @@ final class NetworkProtectionDebugUtilities { } func removeSystemExtensionAndAgents() async throws { - await networkProtectionFeatureDisabler.resetAllStateForVPNApp(uninstallSystemExtension: true) + await networkProtectionFeatureDisabler.removeSystemExtension() networkProtectionFeatureDisabler.disableLoginItems() } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift index bc5ac23bc7..916fa99d05 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift @@ -163,7 +163,8 @@ final class NetworkProtectionTunnelController: NetworkProtection.TunnelControlle case .setExcludeLocalNetworks(let excludeLocalNetworks): try await handleSetExcludeLocalNetworks(excludeLocalNetworks) case .setRegistrationKeyValidity, - .setSelectedServer: + .setSelectedServer, + .setSelectedEnvironment: // Intentional no-op as this is handled by the extension break } @@ -385,6 +386,7 @@ final class NetworkProtectionTunnelController: NetworkProtection.TunnelControlle options[NetworkProtectionOptionKey.activationAttemptId] = UUID().uuidString as NSString options[NetworkProtectionOptionKey.authToken] = try tokenStore.fetchToken() as NSString? + options[NetworkProtectionOptionKey.selectedEnvironment] = settings.selectedEnvironment.rawValue as? NSString options[NetworkProtectionOptionKey.selectedServer] = settings.selectedServer.stringValue as? NSString if case .custom(let keyValidity) = settings.registrationKeyValidity { diff --git a/DuckDuckGo/Preferences/Model/AppearancePreferences.swift b/DuckDuckGo/Preferences/Model/AppearancePreferences.swift index bde8cbda9e..c7637ba582 100644 --- a/DuckDuckGo/Preferences/Model/AppearancePreferences.swift +++ b/DuckDuckGo/Preferences/Model/AppearancePreferences.swift @@ -18,12 +18,15 @@ import Foundation import AppKit +import Bookmarks +import Common protocol AppearancePreferencesPersistor { var showFullURL: Bool { get set } var showAutocompleteSuggestions: Bool { get set } var currentThemeName: String { get set } var defaultPageZoom: CGFloat { get set } + var favoritesDisplayMode: String? { get set } var isFavoriteVisible: Bool { get set } var isContinueSetUpVisible: Bool { get set } var isRecentActivityVisible: Bool { get set } @@ -45,6 +48,9 @@ struct AppearancePreferencesUserDefaultsPersistor: AppearancePreferencesPersisto @UserDefaultsWrapper(key: .defaultPageZoom, defaultValue: DefaultZoomValue.percent100.rawValue) var defaultPageZoom: CGFloat + @UserDefaultsWrapper(key: .favoritesDisplayMode, defaultValue: FavoritesDisplayMode.displayNative(.desktop).description) + var favoritesDisplayMode: String? + @UserDefaultsWrapper(key: .homePageIsFavoriteVisible, defaultValue: true) var isFavoriteVisible: Bool @@ -139,6 +145,21 @@ enum ThemeName: String, Equatable, CaseIterable { } } +extension FavoritesDisplayMode: LosslessStringConvertible { + static let `default` = FavoritesDisplayMode.displayNative(.desktop) + + public init?(_ description: String) { + switch description { + case FavoritesDisplayMode.displayNative(.desktop).description: + self = .displayNative(.desktop) + case FavoritesDisplayMode.displayUnified(native: .desktop).description: + self = .displayUnified(native: .desktop) + default: + return nil + } + } +} + final class AppearancePreferences: ObservableObject { struct Notifications { @@ -166,6 +187,12 @@ final class AppearancePreferences: ObservableObject { } } + @Published var favoritesDisplayMode: FavoritesDisplayMode { + didSet { + persistor.favoritesDisplayMode = favoritesDisplayMode.description + } + } + @Published var defaultPageZoom: DefaultZoomValue { didSet { persistor.defaultPageZoom = defaultPageZoom.rawValue @@ -242,6 +269,7 @@ final class AppearancePreferences: ObservableObject { currentThemeName = .init(rawValue: persistor.currentThemeName) ?? .systemDefault showFullURL = persistor.showFullURL showAutocompleteSuggestions = persistor.showAutocompleteSuggestions + favoritesDisplayMode = persistor.favoritesDisplayMode.flatMap(FavoritesDisplayMode.init) ?? .default isFavoriteVisible = persistor.isFavoriteVisible isRecentActivityVisible = persistor.isRecentActivityVisible isContinueSetUpVisible = persistor.isContinueSetUpVisible @@ -252,4 +280,14 @@ final class AppearancePreferences: ObservableObject { } private var persistor: AppearancePreferencesPersistor + + private func requestSync() { + Task { @MainActor in + guard let syncService = (NSApp.delegate as? AppDelegate)?.syncService else { + return + } + os_log(.debug, log: OSLog.sync, "Requesting sync if enabled") + syncService.scheduler.notifyDataChanged() + } + } } diff --git a/DuckDuckGo/Preferences/Model/PreferencesSection.swift b/DuckDuckGo/Preferences/Model/PreferencesSection.swift index d864752138..c0377154d8 100644 --- a/DuckDuckGo/Preferences/Model/PreferencesSection.swift +++ b/DuckDuckGo/Preferences/Model/PreferencesSection.swift @@ -88,6 +88,11 @@ enum PreferencePaneIdentifier: String, Equatable, Hashable, Identifiable { case .general: return UserText.general case .sync: + var isSyncBookmarksPaused = UserDefaults.standard.bool(forKey: UserDefaultsWrapper.Key.syncBookmarksPaused.rawValue) + var isSyncCredentialsPaused = UserDefaults.standard.bool(forKey: UserDefaultsWrapper.Key.syncCredentialsPaused.rawValue) + if isSyncBookmarksPaused || isSyncCredentialsPaused { + return UserText.sync + " ⚠️" + } return UserText.sync case .appearance: return UserText.appearance diff --git a/DuckDuckGo/Preferences/Model/SyncPreferences.swift b/DuckDuckGo/Preferences/Model/SyncPreferences.swift index a1eef46bce..8cd4b0b4f8 100644 --- a/DuckDuckGo/Preferences/Model/SyncPreferences.swift +++ b/DuckDuckGo/Preferences/Model/SyncPreferences.swift @@ -38,10 +38,15 @@ extension SyncDevice { final class SyncPreferences: ObservableObject, SyncUI.ManagementViewModel { + struct Consts { + static let syncPausedStateChanged = Notification.Name("com.duckduckgo.app.SyncPausedStateChanged") + } + var isSyncEnabled: Bool { syncService.account != nil } + @Published var codeToDisplay: String? let managementDialogModel: ManagementDialogModel @Published var devices: [SyncDevice] = [] @@ -51,39 +56,49 @@ final class SyncPreferences: ObservableObject, SyncUI.ManagementViewModel { @Published var isCreatingAccount: Bool = false + @Published var isUnifiedFavoritesEnabled: Bool { + didSet { + AppearancePreferences.shared.favoritesDisplayMode = isUnifiedFavoritesEnabled ? .displayUnified(native: .desktop) : .displayNative(.desktop) + if shouldRequestSyncOnFavoritesOptionChange { + syncService.scheduler.notifyDataChanged() + } else { + shouldRequestSyncOnFavoritesOptionChange = true + } + if managementDialogModel.isUnifiedFavoritesEnabled != isUnifiedFavoritesEnabled { + managementDialogModel.isUnifiedFavoritesEnabled = isUnifiedFavoritesEnabled + } + } + } + + @Published var isSyncBookmarksPaused: Bool + + @Published var isSyncCredentialsPaused: Bool + + private var shouldRequestSyncOnFavoritesOptionChange: Bool = true + var recoveryCode: String? { syncService.account?.recoveryCode } @MainActor - func presentEnableSyncDialog() { - presentDialog(for: .enableSync) + func presentRecoverSyncAccountDialog() { + presentDialog(for: .recoverAccount) } @MainActor - func presentRecoverSyncAccountDialog() { - presentDialog(for: .recoverAccount) + func presentManuallyEnterCodeDialog() { + presentDialog(for: .manuallyEnterCode) } @MainActor - func presentTurnOffSyncConfirmDialog() { - presentDialog(for: .turnOffSync) + func presentShowTextCodeDialog() { + let code: String = recoveryCode ?? codeToDisplay ?? "" + presentDialog(for: .showTextCode(code)) } @MainActor - func presentShowOrEnterCodeDialog() { - Task { @MainActor in - self.$devices - .removeDuplicates() - .dropFirst() - .prefix(1) - .sink { [weak self] value in - self?.presentDialog(for: .deviceSynced(value.filter { !$0.isCurrent })) - self?.objectWillChange.send() - }.store(in: &cancellables) - managementDialogModel.codeToDisplay = syncService.account?.recoveryCode - presentDialog(for: .syncAnotherDevice) - } + func presentTurnOffSyncConfirmDialog() { + presentDialog(for: .turnOffSync) } @MainActor @@ -101,16 +116,50 @@ final class SyncPreferences: ObservableObject, SyncUI.ManagementViewModel { do { try await syncService.disconnect() managementDialogModel.endFlow() + UserDefaults.standard.set(false, forKey: UserDefaultsWrapper.Key.syncBookmarksPaused.rawValue) + UserDefaults.standard.set(false, forKey: UserDefaultsWrapper.Key.syncCredentialsPaused.rawValue) } catch { errorMessage = String(describing: error) } } } - init(syncService: DDGSyncing) { + @MainActor + func manageBookmarks() { + guard let mainVC = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController else { return } + mainVC.showManageBookmarks(self) + } + + @MainActor + func manageLogins() { + guard let parentWindowController = WindowControllersManager.shared.lastKeyMainWindowController else { return } + guard let navigationViewController = parentWindowController.mainViewController.navigationBarViewController else { return } + navigationViewController.showPasswordManagerPopover(selectedCategory: .allItems) + } + + init(syncService: DDGSyncing, apperancePreferences: AppearancePreferences = .shared, managementDialogModel: ManagementDialogModel = ManagementDialogModel()) { self.syncService = syncService - self.managementDialogModel = ManagementDialogModel() + + self.isUnifiedFavoritesEnabled = apperancePreferences.favoritesDisplayMode.isDisplayUnified + isSyncBookmarksPaused = UserDefaultsWrapper(key: .syncBookmarksPaused, defaultValue: false).wrappedValue + isSyncCredentialsPaused = UserDefaultsWrapper(key: .syncCredentialsPaused, defaultValue: false).wrappedValue + + self.managementDialogModel = managementDialogModel self.managementDialogModel.delegate = self + self.managementDialogModel.isUnifiedFavoritesEnabled = isUnifiedFavoritesEnabled + + apperancePreferences.$favoritesDisplayMode + .map(\.isDisplayUnified) + .sink { [weak self] isUnifiedFavoritesEnabled in + guard let self else { + return + } + if self.isUnifiedFavoritesEnabled != isUnifiedFavoritesEnabled { + self.shouldRequestSyncOnFavoritesOptionChange = false + self.isUnifiedFavoritesEnabled = isUnifiedFavoritesEnabled + } + } + .store(in: &cancellables) syncService.authStatePublisher .removeDuplicates() @@ -135,6 +184,14 @@ final class SyncPreferences: ObservableObject, SyncUI.ManagementViewModel { self?.onEndFlow() } .store(in: &cancellables) + + NotificationCenter.default.publisher(for: Self.Consts.syncPausedStateChanged) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.isSyncBookmarksPaused = UserDefaultsWrapper(key: .syncBookmarksPaused, defaultValue: false).wrappedValue + self?.isSyncCredentialsPaused = UserDefaultsWrapper(key: .syncCredentialsPaused, defaultValue: false).wrappedValue + } + .store(in: &cancellables) } // MARK: - Private @@ -178,6 +235,10 @@ final class SyncPreferences: ObservableObject, SyncUI.ManagementViewModel { return } + guard case .normal = NSApp.runType else { + return + } + let syncViewController = SyncManagementDialogViewController(managementDialogModel) let syncWindowController = syncViewController.wrappedInWindowController() @@ -216,6 +277,8 @@ extension SyncPreferences: ManagementDialogModelDelegate { managementDialogModel.endFlow() do { try await syncService.deleteAccount() + UserDefaults.standard.set(false, forKey: UserDefaultsWrapper.Key.syncBookmarksPaused.rawValue) + UserDefaults.standard.set(false, forKey: UserDefaultsWrapper.Key.syncCredentialsPaused.rawValue) } catch { managementDialogModel.errorMessage = String(describing: error) } @@ -240,23 +303,19 @@ extension SyncPreferences: ManagementDialogModelDelegate { } @MainActor - private func loginAndShowPresentedDialog(_ recoveryKey: SyncCode.RecoveryKey) async throws { + private func loginAndShowPresentedDialog(_ recoveryKey: SyncCode.RecoveryKey, isActiveDevice: Bool) async throws { let device = deviceInfo() let knownDevices = Set(self.devices.map { $0.id }) let devices = try await syncService.login(recoveryKey, deviceName: device.name, deviceType: device.type) mapDevices(devices) let syncedDevices = self.devices.filter { !knownDevices.contains($0.id) && !$0.isCurrent } + let isSecondDevice = syncedDevices.count == 1 managementDialogModel.endFlow() - presentDialog(for: .deviceSynced(syncedDevices)) + presentDialog(for: .deviceSynced(syncedDevices, shouldShowOptions: isActiveDevice && isSecondDevice)) } - @MainActor func turnOnSync() { - presentDialog(for: .askToSyncAnotherDevice) - } - - func dontSyncAnotherDeviceNow() { Task { @MainActor in isCreatingAccount = true defer { @@ -272,7 +331,31 @@ extension SyncPreferences: ManagementDialogModelDelegate { } } - func recoverDevice(using recoveryCode: String) { + func startPollingForRecoveryKey() { + Task { @MainActor in + do { + self.connector = try syncService.remoteConnect() + self.codeToDisplay = connector?.code + if let recoveryKey = try await connector?.pollForRecoveryKey() { + try await loginAndShowPresentedDialog(recoveryKey, isActiveDevice: false) + } else { + // Polling was likeley cancelled elsewhere (e.g. dialog closed) + return + } + } catch { + if syncService.account == nil { + managementDialogModel.errorMessage = String(describing: error) + } + } + } + } + + func stopPollingForRecoveryKey() { + self.connector?.stopPolling() + self.connector = nil + } + + func recoverDevice(using recoveryCode: String, isActiveDevice: Bool) { Task { @MainActor in do { guard let syncCode = try? SyncCode.decodeBase64String(recoveryCode) else { @@ -281,7 +364,7 @@ extension SyncPreferences: ManagementDialogModelDelegate { } if let recoveryKey = syncCode.recovery { // This will error if the account already exists, we don't have good UI for this just now - try await loginAndShowPresentedDialog(recoveryKey) + try await loginAndShowPresentedDialog(recoveryKey, isActiveDevice: isActiveDevice) } else if let connectKey = syncCode.connect { if syncService.account == nil { let device = deviceInfo() @@ -289,6 +372,21 @@ extension SyncPreferences: ManagementDialogModelDelegate { } try await syncService.transmitRecoveryKey(connectKey) + self.$devices + .removeDuplicates() + .dropFirst() + .prefix(1) + .sink { [weak self] devices in + guard let self else { return } + let thisDeviceName = deviceInfo().name + var syncedDevices: [SyncDevice] = [] + for device in devices where device.name != thisDeviceName { + syncedDevices.append(device) + } + + self.managementDialogModel.endFlow() + presentDialog(for: .deviceSynced(syncedDevices, shouldShowOptions: devices.count == 2)) + }.store(in: &cancellables) // The UI will update when the devices list changes. } else { @@ -301,24 +399,6 @@ extension SyncPreferences: ManagementDialogModelDelegate { } } - func presentSyncAnotherDeviceDialog() { - Task { @MainActor in - do { - self.connector = try syncService.remoteConnect() - managementDialogModel.codeToDisplay = connector?.code - presentDialog(for: .syncAnotherDevice) - if let recoveryKey = try await connector?.pollForRecoveryKey() { - try await loginAndShowPresentedDialog(recoveryKey) - } else { - // Polling was likeley cancelled elsewhere (e.g. dialog closed) - return - } - } catch { - managementDialogModel.errorMessage = String(describing: error) - } - } - } - @MainActor func presentDeleteAccount() { presentDialog(for: .deleteAccount(devices)) @@ -326,6 +406,11 @@ extension SyncPreferences: ManagementDialogModelDelegate { @MainActor func confirmSetupComplete() { + presentDialog(for: .firstDeviceSetup) + } + + @MainActor + func presentSaveRecoveryPDF() { presentDialog(for: .saveRecoveryPDF) } diff --git a/DuckDuckGo/Preferences/View/PreferencesAppearanceView.swift b/DuckDuckGo/Preferences/View/PreferencesAppearanceView.swift index ef03bd7225..632d8c6d36 100644 --- a/DuckDuckGo/Preferences/View/PreferencesAppearanceView.swift +++ b/DuckDuckGo/Preferences/View/PreferencesAppearanceView.swift @@ -18,6 +18,7 @@ import SwiftUI import SwiftUIExtensions +import Bookmarks extension Preferences { diff --git a/DuckDuckGo/Preferences/View/PreferencesRootView.swift b/DuckDuckGo/Preferences/View/PreferencesRootView.swift index 076250e3ad..570ef2efe0 100644 --- a/DuckDuckGo/Preferences/View/PreferencesRootView.swift +++ b/DuckDuckGo/Preferences/View/PreferencesRootView.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Common import SwiftUI import SwiftUIExtensions import SyncUI @@ -124,9 +125,21 @@ struct SyncView: View { var body: some View { if let syncService = NSApp.delegateTyped.syncService { SyncUI.ManagementView(model: SyncPreferences(syncService: syncService)) + .onAppear { + requestSync() + } } else { FailedAssertionView("Failed to initialize Sync Management View") } } + private func requestSync() { + Task { @MainActor in + guard let syncService = (NSApp.delegate as? AppDelegate)?.syncService else { + return + } + os_log(.debug, log: OSLog.sync, "Requesting sync if enabled") + syncService.scheduler.notifyDataChanged() + } + } } diff --git a/DuckDuckGo/PrivacyDashboard/WebsiteBreakageReporter.swift b/DuckDuckGo/PrivacyDashboard/WebsiteBreakageReporter.swift index a9c5e964d5..de8d030f3d 100644 --- a/DuckDuckGo/PrivacyDashboard/WebsiteBreakageReporter.swift +++ b/DuckDuckGo/PrivacyDashboard/WebsiteBreakageReporter.swift @@ -43,7 +43,7 @@ final class WebsiteBreakageReporter { // current domain's protection status let configuration = ContentBlocking.shared.privacyConfigurationManager.privacyConfig - let protected = configuration.isFeature(.contentBlocking, enabledForDomain: currentTab?.content.url?.host) + let protectionsState = configuration.isFeature(.contentBlocking, enabledForDomain: currentTab?.content.url?.host) let websiteBreakage = WebsiteBreakage(category: WebsiteBreakage.Category(rawValue: category.lowercased()), description: description, @@ -56,7 +56,7 @@ final class WebsiteBreakageReporter { isGPCEnabled: PrivacySecurityPreferences.shared.gpcEnabled, ampURL: ampURL, urlParametersRemoved: urlParametersRemoved, - protected: protected, + protectionsState: protectionsState, reportFlow: reportFlow) return websiteBreakage } diff --git a/DuckDuckGo/Statistics/PixelEvent.swift b/DuckDuckGo/Statistics/PixelEvent.swift index f87959945b..4e0081ab03 100644 --- a/DuckDuckGo/Statistics/PixelEvent.swift +++ b/DuckDuckGo/Statistics/PixelEvent.swift @@ -172,6 +172,12 @@ extension Pixel { case networkProtectionRemoteMessageDismissed(messageID: String) case networkProtectionRemoteMessageOpened(messageID: String) + // Sync + case syncBookmarksCountLimitExceededDaily + case syncCredentialsCountLimitExceededDaily + case syncBookmarksRequestSizeLimitExceededDaily + case syncCredentialsRequestSizeLimitExceededDaily + // DataBroker Protection Waitlist case dataBrokerProtectionWaitlistUserActive case dataBrokerProtectionWaitlistEntryPointMenuItemDisplayed @@ -303,6 +309,7 @@ extension Pixel { case bookmarksMigrationCouldNotPrepareDatabase case bookmarksMigrationCouldNotPrepareDatabaseOnFailedMigration case bookmarksMigrationCouldNotRemoveOldStore + case bookmarksMigrationCouldNotPrepareMultipleFavoriteFolders case syncSentUnauthenticatedRequest case syncMetadataCouldNotLoadDatabase @@ -315,6 +322,7 @@ extension Pixel { case bookmarksCleanupFailed case bookmarksCleanupAttemptedWhileSyncWasEnabled + case favoritesCleanupFailed case credentialsDatabaseCleanupFailed case credentialsCleanupAttemptedWhileSyncWasEnabled @@ -482,6 +490,13 @@ extension Pixel.Event { return "m_mac_netp_remote_message_dismissed_\(messageID)" case .networkProtectionRemoteMessageOpened(let messageID): return "m_mac_netp_remote_message_opened_\(messageID)" + + // Sync + case .syncBookmarksCountLimitExceededDaily: return "m.mac.sync_bookmarks_count_limit_exceeded_daily" + case .syncCredentialsCountLimitExceededDaily: return "m.mac.sync_credentials_count_limit_exceeded_daily" + case .syncBookmarksRequestSizeLimitExceededDaily: return "m.mac.sync_bookmarks_request_size_limit_exceeded_daily" + case .syncCredentialsRequestSizeLimitExceededDaily: return "m.mac.sync_credentials_request_size_limit_exceeded_daily" + case .dataBrokerProtectionWaitlistUserActive: return "m_mac_dbp_waitlist_user_active" case .dataBrokerProtectionWaitlistEntryPointMenuItemDisplayed: @@ -496,7 +511,8 @@ extension Pixel.Event { return "m_mac_dbp_imp_terms" case .dataBrokerProtectionWaitlistTermsAndConditionsAccepted: return "m_mac_dbp_ev_terms_accepted" - // 28-day Home Button + + // 28-day Home Button case .homeButtonHidden: return "m_mac_home_button_hidden" case .homeButtonLeft: @@ -719,6 +735,8 @@ extension Pixel.Event.Debug { case .bookmarksMigrationCouldNotPrepareDatabaseOnFailedMigration: return "bookmarks_migration_could_not_prepare_database_on_failed_migration" case .bookmarksMigrationCouldNotRemoveOldStore: return "bookmarks_migration_could_not_remove_old_store" + case .bookmarksMigrationCouldNotPrepareMultipleFavoriteFolders: + return "bookmarks_migration_could_not_prepare_multiple_favorite_folders" case .syncSentUnauthenticatedRequest: return "sync_sent_unauthenticated_request" case .syncMetadataCouldNotLoadDatabase: return "sync_metadata_could_not_load_database" @@ -731,6 +749,7 @@ extension Pixel.Event.Debug { case .bookmarksCleanupFailed: return "bookmarks_cleanup_failed" case .bookmarksCleanupAttemptedWhileSyncWasEnabled: return "bookmarks_cleanup_attempted_while_sync_was_enabled" + case .favoritesCleanupFailed: return "favorites_cleanup_failed" case .credentialsDatabaseCleanupFailed: return "credentials_database_cleanup_failed" case .credentialsCleanupAttemptedWhileSyncWasEnabled: return "credentials_cleanup_attempted_while_sync_was_enabled" diff --git a/DuckDuckGo/Statistics/PixelParameters.swift b/DuckDuckGo/Statistics/PixelParameters.swift index e3881ef063..94af4615be 100644 --- a/DuckDuckGo/Statistics/PixelParameters.swift +++ b/DuckDuckGo/Statistics/PixelParameters.swift @@ -114,6 +114,10 @@ extension Pixel.Event { .networkProtectionRemoteMessageDisplayed, .networkProtectionRemoteMessageDismissed, .networkProtectionRemoteMessageOpened, + .syncBookmarksCountLimitExceededDaily, + .syncCredentialsCountLimitExceededDaily, + .syncBookmarksRequestSizeLimitExceededDaily, + .syncCredentialsRequestSizeLimitExceededDaily, .dataBrokerProtectionWaitlistUserActive, .dataBrokerProtectionWaitlistEntryPointMenuItemDisplayed, .dataBrokerProtectionWaitlistIntroDisplayed, diff --git a/DuckDuckGo/Sync/SettingSyncHandlers/FavoritesDisplayModeSyncHandler.swift b/DuckDuckGo/Sync/SettingSyncHandlers/FavoritesDisplayModeSyncHandler.swift new file mode 100644 index 0000000000..cbfa5444ea --- /dev/null +++ b/DuckDuckGo/Sync/SettingSyncHandlers/FavoritesDisplayModeSyncHandler.swift @@ -0,0 +1,47 @@ +// +// FavoritesDisplayModeSyncHandler.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 Bookmarks +import Combine +import Foundation +import SyncDataProviders + +final class FavoritesDisplayModeSyncHandler: FavoritesDisplayModeSyncHandlerBase { + + override func getValue() throws -> String? { + preferences.favoritesDisplayMode.description + } + + override func setValue(_ value: String?) throws { + if let value, let displayMode = FavoritesDisplayMode(value) { + DispatchQueue.main.async { + self.preferences.favoritesDisplayMode = displayMode + } + } + } + + override var valueDidChangePublisher: AnyPublisher { + preferences.$favoritesDisplayMode.dropFirst().asVoid().eraseToAnyPublisher() + } + + init(_ preferences: AppearancePreferences = .shared) { + self.preferences = preferences + } + + private let preferences: AppearancePreferences +} diff --git a/DuckDuckGo/Sync/SyncBookmarksAdapter.swift b/DuckDuckGo/Sync/SyncBookmarksAdapter.swift index 555523b65b..7508e9f728 100644 --- a/DuckDuckGo/Sync/SyncBookmarksAdapter.swift +++ b/DuckDuckGo/Sync/SyncBookmarksAdapter.swift @@ -27,8 +27,30 @@ final class SyncBookmarksAdapter { private(set) var provider: BookmarksProvider? let databaseCleaner: BookmarkDatabaseCleaner + var shouldResetBookmarksSyncTimestamp: Bool = false { + willSet { + assert(provider == nil, "Setting this value has no effect after provider has been instantiated") + } + } + + @UserDefaultsWrapper(key: .syncBookmarksPaused, defaultValue: false) + private var isSyncBookmarksPaused: Bool { + didSet { + NotificationCenter.default.post(name: SyncPreferences.Consts.syncPausedStateChanged, object: nil) + } + } - init(database: CoreDataDatabase) { + @UserDefaultsWrapper(key: .syncBookmarksPausedErrorDisplayed, defaultValue: false) + private var didShowBookmarksSyncPausedError: Bool + + init( + database: CoreDataDatabase, + bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, + appearancePreferences: AppearancePreferences = .shared + ) { + self.database = database + self.bookmarkManager = bookmarkManager + self.appearancePreferences = appearancePreferences databaseCleaner = BookmarkDatabaseCleaner( bookmarkDatabase: database, errorEvents: BookmarksCleanupErrorHandling(), @@ -40,6 +62,7 @@ final class SyncBookmarksAdapter { databaseCleaner.cleanUpDatabaseNow() if shouldEnable { databaseCleaner.scheduleRegularCleaning() + handleFavoritesAfterDisablingSync() } else { databaseCleaner.cancelCleaningSchedule() } @@ -52,15 +75,34 @@ final class SyncBookmarksAdapter { let provider = BookmarksProvider( database: database, - metadataStore: metadataStore, - syncDidUpdateData: LocalBookmarkManager.shared.loadBookmarks - ) + metadataStore: metadataStore) { [weak self] in + LocalBookmarkManager.shared.loadBookmarks() + self?.isSyncBookmarksPaused = false + self?.didShowBookmarksSyncPausedError = false + } + if shouldResetBookmarksSyncTimestamp { + provider.lastSyncTimestamp = nil + } syncErrorCancellable = provider.syncErrorPublisher - .sink { error in + .sink { [weak self] error in switch error { case let syncError as SyncError: Pixel.fire(.debug(event: .syncBookmarksFailed, error: syncError)) + switch syncError { + case .unexpectedStatusCode(409): + // If bookmarks count limit has been exceeded + self?.isSyncBookmarksPaused = true + Pixel.fire(.syncBookmarksCountLimitExceededDaily, limitTo: .dailyFirst) + self?.showSyncPausedAlert() + case .unexpectedStatusCode(413): + // If bookmarks request size limit has been exceeded + self?.isSyncBookmarksPaused = true + Pixel.fire(.syncBookmarksRequestSizeLimitExceededDaily, limitTo: .dailyFirst) + self?.showSyncPausedAlert() + default: + break + } default: let nsError = error as NSError if nsError.domain != NSURLErrorDomain { @@ -75,5 +117,34 @@ final class SyncBookmarksAdapter { self.provider = provider } + private func showSyncPausedAlert() { + guard !didShowBookmarksSyncPausedError else { return } + Task { + await MainActor.run { + let alert = NSAlert.syncBookmarksPaused() + let response = alert.runModal() + didShowBookmarksSyncPausedError = true + + switch response { + case .alertSecondButtonReturn: + alert.window.sheetParent?.endSheet(alert.window) + WindowControllersManager.shared.showPreferencesTab(withSelectedPane: .sync) + default: + break + } + } + } + } + + private func handleFavoritesAfterDisablingSync() { + bookmarkManager.handleFavoritesAfterDisablingSync() + if appearancePreferences.favoritesDisplayMode.isDisplayUnified { + appearancePreferences.favoritesDisplayMode = .displayNative(.desktop) + } + } + private var syncErrorCancellable: AnyCancellable? + private let bookmarkManager: BookmarkManager + private let database: CoreDataDatabase + private let appearancePreferences: AppearancePreferences } diff --git a/DuckDuckGo/Sync/SyncCredentialsAdapter.swift b/DuckDuckGo/Sync/SyncCredentialsAdapter.swift index 40a404b52e..0aff7ced9a 100644 --- a/DuckDuckGo/Sync/SyncCredentialsAdapter.swift +++ b/DuckDuckGo/Sync/SyncCredentialsAdapter.swift @@ -29,6 +29,16 @@ final class SyncCredentialsAdapter { let databaseCleaner: CredentialsDatabaseCleaner let syncDidCompletePublisher: AnyPublisher + @UserDefaultsWrapper(key: .syncCredentialsPaused, defaultValue: false) + private var isSyncCredentialsPaused: Bool { + didSet { + NotificationCenter.default.post(name: SyncPreferences.Consts.syncPausedStateChanged, object: nil) + } + } + + @UserDefaultsWrapper(key: .syncCredentialsPausedErrorDisplayed, defaultValue: false) + private var didShowCredentialsSyncPausedError: Bool + init(secureVaultFactory: AutofillVaultFactory = AutofillSecureVaultFactory) { syncDidCompletePublisher = syncDidCompleteSubject.eraseToAnyPublisher() databaseCleaner = CredentialsDatabaseCleaner( @@ -60,14 +70,30 @@ final class SyncCredentialsAdapter { metadataStore: metadataStore, syncDidUpdateData: { [weak self] in self?.syncDidCompleteSubject.send() + self?.isSyncCredentialsPaused = false + self?.didShowCredentialsSyncPausedError = false } ) syncErrorCancellable = provider.syncErrorPublisher - .sink { error in + .sink { [weak self] error in switch error { case let syncError as SyncError: Pixel.fire(.debug(event: .syncCredentialsFailed, error: syncError)) + switch syncError { + case .unexpectedStatusCode(409): + // If credentials count limit has been exceeded + self?.isSyncCredentialsPaused = true + Pixel.fire(.syncCredentialsCountLimitExceededDaily, limitTo: .dailyFirst) + self?.showSyncPausedAlert() + case .unexpectedStatusCode(413): + // If credentials request size limit has been exceeded + self?.isSyncCredentialsPaused = true + Pixel.fire(.syncCredentialsRequestSizeLimitExceededDaily, limitTo: .dailyFirst) + self?.showSyncPausedAlert() + default: + break + } default: let nsError = error as NSError if nsError.domain != NSURLErrorDomain { @@ -88,6 +114,25 @@ final class SyncCredentialsAdapter { } } + private func showSyncPausedAlert() { + guard !didShowCredentialsSyncPausedError else { return } + Task { + await MainActor.run { + let alert = NSAlert.syncCredentialsPaused() + let response = alert.runModal() + didShowCredentialsSyncPausedError = true + + switch response { + case .alertSecondButtonReturn: + alert.window.sheetParent?.endSheet(alert.window) + WindowControllersManager.shared.showPreferencesTab(withSelectedPane: .sync) + default: + break + } + } + } + } + private var syncDidCompleteSubject = PassthroughSubject() private var syncErrorCancellable: AnyCancellable? } diff --git a/DuckDuckGo/Sync/SyncDataProviders.swift b/DuckDuckGo/Sync/SyncDataProviders.swift index 310f3e2dfd..d75d674bd8 100644 --- a/DuckDuckGo/Sync/SyncDataProviders.swift +++ b/DuckDuckGo/Sync/SyncDataProviders.swift @@ -63,6 +63,7 @@ final class SyncDataProviders: DataProvidersSource { .removeDuplicates() syncAuthStateDidChangeCancellable = syncAuthStateDidChangePublisher + .receive(on: DispatchQueue.main) .sink { [weak self] isSyncDisabled in self?.bookmarksAdapter.cleanUpDatabaseAndUpdateSchedule(shouldEnable: isSyncDisabled) self?.credentialsAdapter.cleanUpDatabaseAndUpdateSchedule(shouldEnable: isSyncDisabled) diff --git a/DuckDuckGo/Sync/SyncSettingsAdapter.swift b/DuckDuckGo/Sync/SyncSettingsAdapter.swift index c457620343..32c24602f6 100644 --- a/DuckDuckGo/Sync/SyncSettingsAdapter.swift +++ b/DuckDuckGo/Sync/SyncSettingsAdapter.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Bookmarks import BrowserServicesKit import Combine import Common @@ -23,6 +24,10 @@ import DDGSync import Persistence import SyncDataProviders +extension SettingsProvider.Setting { + static let favoritesDisplayMode = SettingsProvider.Setting(key: "favorites_display_mode") +} + final class SyncSettingsAdapter { private(set) var provider: SettingsProvider? @@ -42,7 +47,7 @@ final class SyncSettingsAdapter { let provider = SettingsProvider( metadataDatabase: metadataDatabase, metadataStore: metadataStore, - emailManager: emailManager, + settingsHandlers: [FavoritesDisplayModeSyncHandler(), EmailProtectionSyncHandler(emailManager: emailManager)], syncDidUpdateData: { [weak self] in self?.syncDidCompleteSubject.send() } diff --git a/DuckDuckGo/Waitlist/NetworkProtectionFeatureDisabler.swift b/DuckDuckGo/Waitlist/NetworkProtectionFeatureDisabler.swift index 6f21a07abc..6458ed80e4 100644 --- a/DuckDuckGo/Waitlist/NetworkProtectionFeatureDisabler.swift +++ b/DuckDuckGo/Waitlist/NetworkProtectionFeatureDisabler.swift @@ -61,21 +61,26 @@ final class NetworkProtectionFeatureDisabler: NetworkProtectionFeatureDisabling /// func disable(keepAuthToken: Bool, uninstallSystemExtension: Bool) { Task { + // To disable NetP we need the login item to be running + // This should be fine though as we'll disable them further down below + enableLoginItems() + + // Allow some time for the login items to fully launch + try? await Task.sleep(interval: 0.5) + unpinNetworkProtection() if uninstallSystemExtension { - await resetAllStateForVPNApp(uninstallSystemExtension: uninstallSystemExtension) + await removeSystemExtension() } - disableLoginItems() - - await resetNetworkExtensionState() + await removeVPNConfiguration() - // ☝️ Take care of resetting all state within the extension first, and wait half a second + // We want to give some time for the login item to reset state before disabling it try? await Task.sleep(interval: 0.5) - // 👇 And only afterwards turn off the tunnel and remove it from preferences - await stopTunnel() + disableLoginItems() + resetUserDefaults() if !keepAuthToken { @@ -84,12 +89,16 @@ final class NetworkProtectionFeatureDisabler: NetworkProtectionFeatureDisabling } } + private func enableLoginItems() { + loginItemsManager.enableLoginItems(LoginItemsManager.networkProtectionLoginItems, log: log) + } + func disableLoginItems() { loginItemsManager.disableLoginItems(LoginItemsManager.networkProtectionLoginItems) } - func resetAllStateForVPNApp(uninstallSystemExtension: Bool) async { - await ipcClient.resetAll(uninstallSystemExtension: uninstallSystemExtension) + func removeSystemExtension() async { + await ipcClient.debugCommand(.removeSystemExtension) #if NETP_SYSTEM_EXTENSION userDefaults.networkProtectionOnboardingStatusRawValue = OnboardingStatus.default.rawValue @@ -104,15 +113,11 @@ final class NetworkProtectionFeatureDisabler: NetworkProtectionFeatureDisabling try NetworkProtectionKeychainTokenStore().deleteToken() } - private func resetNetworkExtensionState() async { - if let activeSession = try? await ConnectionSessionUtilities.activeSession() { - try? activeSession.sendProviderMessage(.resetAllState) { - os_log("Status was reset in the extension", log: self.log) - } - } - } + private func removeVPNConfiguration() async { + // Remove the agent VPN configuration + await ipcClient.debugCommand(.removeVPNConfiguration) - private func stopTunnel() async { + // Remove the legacy (local) configuration let tunnels = try? await NETunnelProviderManager.loadAllFromPreferences() if let tunnels = tunnels { diff --git a/DuckDuckGo/Waitlist/Waitlist.swift b/DuckDuckGo/Waitlist/Waitlist.swift index bcb983f487..079b7aef4b 100644 --- a/DuckDuckGo/Waitlist/Waitlist.swift +++ b/DuckDuckGo/Waitlist/Waitlist.swift @@ -274,7 +274,7 @@ struct DataBrokerProtectionWaitlist: Waitlist { store: WaitlistKeychainStore(waitlistIdentifier: Self.identifier), request: ProductWaitlistRequest(productName: Self.apiProductName), redeemUseCase: RedeemUseCase(), - redeemAuthenticationRepository: UserDefaultsAuthenticationData() + redeemAuthenticationRepository: KeychainAuthenticationData() ) } diff --git a/DuckDuckGoDBPBackgroundAgent/DataBrokerProtectionBackgroundManager.swift b/DuckDuckGoDBPBackgroundAgent/DataBrokerProtectionBackgroundManager.swift index 36135d8277..8ee2447123 100644 --- a/DuckDuckGoDBPBackgroundAgent/DataBrokerProtectionBackgroundManager.swift +++ b/DuckDuckGoDBPBackgroundAgent/DataBrokerProtectionBackgroundManager.swift @@ -26,7 +26,7 @@ public final class DataBrokerProtectionBackgroundManager { static let shared = DataBrokerProtectionBackgroundManager() - private let authenticationRepository: AuthenticationRepository = UserDefaultsAuthenticationData() + private let authenticationRepository: AuthenticationRepository = KeychainAuthenticationData() private let authenticationService: DataBrokerProtectionAuthenticationService = AuthenticationService() private let redeemUseCase: DataBrokerProtectionRedeemUseCase private let fakeBrokerFlag: DataBrokerDebugFlag = DataBrokerDebugFlagFakeBroker() diff --git a/DuckDuckGoVPN/TunnelControllerIPCService.swift b/DuckDuckGoVPN/TunnelControllerIPCService.swift index 7d6f8263dc..3210cf2be2 100644 --- a/DuckDuckGoVPN/TunnelControllerIPCService.swift +++ b/DuckDuckGoVPN/TunnelControllerIPCService.swift @@ -108,10 +108,25 @@ extension TunnelControllerIPCService: IPCServerInterface { } func debugCommand(_ command: DebugCommand) async { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession(networkExtensionBundleID: Bundle.main.networkExtensionBundleID) else { - return + if let activeSession = try? await ConnectionSessionUtilities.activeSession(networkExtensionBundleID: Bundle.main.networkExtensionBundleID) { + + // First give a chance to the extension to process the command, since some commands + // may remove the VPN configuration or deactivate the extension. + try? await activeSession.sendProviderRequest(.debugCommand(command)) } - try? await activeSession.sendProviderRequest(.debugCommand(command)) + switch command { + case .removeSystemExtension: + await VPNConfigurationManager().removeVPNConfiguration() + try? await networkExtensionController.deactivateSystemExtension() + case .expireRegistrationKey: + // Intentional no-op: handled by the extension + break + case .sendTestNotification: + // Intentional no-op: handled by the extension + break + case .removeVPNConfiguration: + await VPNConfigurationManager().removeVPNConfiguration() + } } } diff --git a/LocalPackages/Account/Package.swift b/LocalPackages/Account/Package.swift index d043dce7f6..39f7617180 100644 --- a/LocalPackages/Account/Package.swift +++ b/LocalPackages/Account/Package.swift @@ -12,7 +12,7 @@ let package = Package( targets: ["Account"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "82.2.2"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "83.0.0"), .package(path: "../Purchase") ], targets: [ diff --git a/LocalPackages/DataBrokerProtection/Package.swift b/LocalPackages/DataBrokerProtection/Package.swift index f76cf45020..5f09a9854e 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: "82.2.2"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "83.0.0"), .package(path: "../PixelKit"), .package(path: "../SwiftUIExtensions"), .package(path: "../XPCHelper") diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Services/RedeemCodeServices.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Services/RedeemCodeServices.swift index 44379cb43c..c036b1a971 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Services/RedeemCodeServices.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Services/RedeemCodeServices.swift @@ -63,7 +63,7 @@ public final class RedeemUseCase: DataBrokerProtectionRedeemUseCase { private let authenticationRepository: AuthenticationRepository public init(authenticationService: DataBrokerProtectionAuthenticationService = AuthenticationService(), - authenticationRepository: AuthenticationRepository = UserDefaultsAuthenticationData()) { + authenticationRepository: AuthenticationRepository = KeychainAuthenticationData()) { self.authenticationService = authenticationService self.authenticationRepository = authenticationRepository } @@ -93,38 +93,101 @@ public final class RedeemUseCase: DataBrokerProtectionRedeemUseCase { } } -// ⚠️ NOTE: This is just a temporary solution. We should not store the access token on User Defaults. -// The access token will be saved in the secure database once we have that in place. -public final class UserDefaultsAuthenticationData: AuthenticationRepository { - struct Keys { - static let accessTokenKey = "dbp:accessTokenKey" - static let inviteCodeKey = "dbp:inviteCodeKey" +public final class KeychainAuthenticationData: AuthenticationRepository { + enum DBPWaitlistKeys: String { + case accessTokenKey = "dbp:accessTokenKey" + case inviteCodeKey = "dbp:inviteCodeKey" } + let keychainPrefix = Bundle.main.bundleIdentifier ?? "com.duckduckgo" + // Initialize this constant with the DBP API Dev Access Token on Bitwarden if you do not want to use the redeem endpoint. private let developmentToken: String? = nil public init() {} public func getInviteCode() -> String? { - UserDefaults.standard.string(forKey: Keys.inviteCodeKey) + return getString(forField: .inviteCodeKey) } public func getAccessToken() -> String? { - UserDefaults.standard.string(forKey: Keys.accessTokenKey) ?? developmentToken + getString(forField: .accessTokenKey) } public func save(accessToken: String) { - UserDefaults.standard.set(accessToken, forKey: Keys.accessTokenKey) + add(string: accessToken, forField: .accessTokenKey) } public func save(inviteCode: String) { - UserDefaults.standard.set(inviteCode, forKey: Keys.inviteCodeKey) + add(string: inviteCode, forField: .inviteCodeKey) } public func reset() { - UserDefaults.standard.removeObject(forKey: Keys.inviteCodeKey) - UserDefaults.standard.removeObject(forKey: Keys.accessTokenKey) + deleteItem(forField: .inviteCodeKey) + deleteItem(forField: .accessTokenKey) + } + + // MARK: - Keychain Read + + private func getString(forField field: DBPWaitlistKeys) -> String? { + guard let data = retrieveData(forField: field), + let string = String(data: data, encoding: String.Encoding.utf8) else { + return nil + } + return string + } + + private func retrieveData(forField field: DBPWaitlistKeys) -> Data? { + var query = defaultAttributes(serviceName: keychainServiceName(for: field)) + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecReturnData as String] = true + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let existingItem = item as? Data else { + return nil + } + + return existingItem + } + + // MARK: - Keychain Write + + private func add(string: String, forField field: DBPWaitlistKeys) { + guard let stringData = string.data(using: .utf8) else { + return + } + + deleteItem(forField: field) + add(data: stringData, forField: field) + } + + private func add(data: Data, forField field: DBPWaitlistKeys) { + var query = defaultAttributes(serviceName: keychainServiceName(for: field)) + query[kSecValueData as String] = data + SecItemAdd(query as CFDictionary, nil) + } + + private func deleteItem(forField field: DBPWaitlistKeys) { + let query = defaultAttributes(serviceName: keychainServiceName(for: field)) + SecItemDelete(query as CFDictionary) + } + + // MARK: - + + private func defaultAttributes(serviceName: String) -> [String: Any] { + return [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrSynchronizable as String: false, + kSecUseDataProtectionKeychain as String: true, + kSecAttrAccessGroup as String: Bundle.main.appGroupName, + kSecAttrService as String: serviceName, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + } + + func keychainServiceName(for field: DBPWaitlistKeys) -> String { + [keychainPrefix, "waitlist", field.rawValue].joined(separator: ".") } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift index a8a4442a2b..ad7e17335c 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift @@ -60,7 +60,10 @@ enum DBPUISendableMethodName: String { } struct DBPUICommunicationLayer: Subfeature { - var messageOriginPolicy: MessageOriginPolicy = .all + var messageOriginPolicy: MessageOriginPolicy = .only(rules: [ + .exact(hostname: "use-devtesting18.duckduckgo.com"), + .exact(hostname: "duckduckgo.com") + ]) var featureName: String = "dbpuiCommunication" weak var broker: UserScriptMessageBroker? diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/InviteCodeFlow/DataBrokerProtectionInviteCodeViewModels.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/InviteCodeFlow/DataBrokerProtectionInviteCodeViewModels.swift index 3a6e78cff0..a59110cd64 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/InviteCodeFlow/DataBrokerProtectionInviteCodeViewModels.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/InviteCodeFlow/DataBrokerProtectionInviteCodeViewModels.swift @@ -64,7 +64,7 @@ final class DataBrokerProtectionInviteCodeViewModel: InviteCodeViewModel { @Published var showProgressView = false init(delegate: DataBrokerProtectionInviteCodeViewModelDelegate, - authenticationRepository: AuthenticationRepository = UserDefaultsAuthenticationData(), + authenticationRepository: AuthenticationRepository = KeychainAuthenticationData(), authenticationService: DataBrokerProtectionAuthenticationService = AuthenticationService()) { self.delegate = delegate self.redeemUseCase = RedeemUseCase(authenticationService: authenticationService, authenticationRepository: authenticationRepository) diff --git a/LocalPackages/NetworkProtectionMac/Package.swift b/LocalPackages/NetworkProtectionMac/Package.swift index e3cadda03f..78194f666c 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: "82.2.2"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "83.0.0"), .package(path: "../XPCHelper"), .package(path: "../SwiftUIExtensions") ], diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift index 7479b0e8cf..dd34206618 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift @@ -95,30 +95,22 @@ extension TunnelControllerIPCClient: IPCServerInterface { }) } - public func resetAll(uninstallSystemExtension: Bool) async { - xpc.execute(call: { server in - Task { - await server.resetAll(uninstallSystemExtension: uninstallSystemExtension) - } - }, xpcReplyErrorHandler: { _ in - // Intentional no-op as there's no completion block - // If you add a completion block, please remember to call it here too! - }) - } - public func debugCommand(_ command: DebugCommand) async { guard let payload = try? JSONEncoder().encode(command) else { return } - xpc.execute(call: { server in - Task { - await server.debugCommand(payload) - } - }, xpcReplyErrorHandler: { _ in - // Intentional no-op as there's no completion block - // If you add a completion block, please remember to call it here too! - }) + await withCheckedContinuation { continuation in + xpc.execute(call: { server in + server.debugCommand(payload) { + continuation.resume() + } + }, xpcReplyErrorHandler: { _ in + // Intentional no-op as there's no completion block + // If you add a completion block, please remember to call it here too! + continuation.resume() + }) + } } } diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift index ccabc5f144..8ef52836e8 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift @@ -37,10 +37,6 @@ public protocol IPCServerInterface: AnyObject { /// func stop() - /// Resets all of Network Protection's state that's handled by the server - /// - func resetAll(uninstallSystemExtension: Bool) async - /// Debug commands /// func debugCommand(_ command: DebugCommand) async @@ -67,13 +63,9 @@ protocol XPCServerInterface { /// func stop() - /// Resets all of Network Protection's state that's handled by the server - /// - func resetAll(uninstallSystemExtension: Bool) async - /// Debug commands /// - func debugCommand(_ payload: Data) async + func debugCommand(_ payload: Data, completion: @escaping () -> Void) } public final class TunnelControllerIPCServer { @@ -156,15 +148,15 @@ extension TunnelControllerIPCServer: XPCServerInterface { serverDelegate?.stop() } - func resetAll(uninstallSystemExtension: Bool) async { - await serverDelegate?.resetAll(uninstallSystemExtension: uninstallSystemExtension) - } - - func debugCommand(_ payload: Data) async { + func debugCommand(_ payload: Data, completion: @escaping () -> Void) { guard let command = try? JSONDecoder().decode(DebugCommand.self, from: payload) else { + completion() return } - await serverDelegate?.debugCommand(command) + Task { + await serverDelegate?.debugCommand(command) + completion() + } } } diff --git a/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/ButtonStyles.swift b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/ButtonStyles.swift index e730ca941c..ac9637056f 100644 --- a/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/ButtonStyles.swift +++ b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/ButtonStyles.swift @@ -68,6 +68,38 @@ public struct DefaultActionButtonStyle: ButtonStyle { } } +public struct DismissActionButtonStyle: ButtonStyle { + @Environment(\.colorScheme) var colorScheme + + public init() {} + + public func makeBody(configuration: Self.Configuration) -> some View { + let backgroundColor = configuration.isPressed ? Color(NSColor.windowBackgroundColor) : Color(NSColor.controlColor) + let labelColor = Color.primary + let outerShadowOpacity = colorScheme == .dark ? 0.8 : 0.0 + + configuration.label + .lineLimit(1) + .font(.custom("SFProText-Regular", size: 13)) + .frame(minWidth: 44) // OK buttons will match the width of "Cancel" at least in English + .padding(.top, 2.5) + .padding(.bottom, 3) + .padding(.horizontal, 7.5) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(backgroundColor) + .shadow(color: .black.opacity(0.1), radius: 0.1, x: 0, y: 1) + .shadow(color: .primary.opacity(outerShadowOpacity), radius: 0.1, x: 0, y: -0.6) + ) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(Color.black.opacity(0.1), lineWidth: 1) + ) + .foregroundColor(labelColor) + + } +} + public struct DestructiveActionButtonStyle: ButtonStyle { public let enabled: Bool diff --git a/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/PreferencePaneSection.swift b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/PreferencePaneSection.swift index ca9d3a6c34..928b489ddc 100644 --- a/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/PreferencePaneSection.swift +++ b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/PreferencePaneSection.swift @@ -21,15 +21,17 @@ import SwiftUI public struct PreferencePaneSection: View where Content: View { public let spacing: CGFloat + public let verticalPadding: CGFloat @ViewBuilder public let content: () -> Content - public init(spacing: CGFloat = 12, @ViewBuilder content: @escaping () -> Content) { + public init(spacing: CGFloat = 12, vericalPadding: CGFloat = 20, @ViewBuilder content: @escaping () -> Content) { self.spacing = spacing + self.verticalPadding = vericalPadding self.content = content } public var body: some View { VStack(alignment: .leading, spacing: spacing, content: content) - .padding(.vertical, 20) + .padding(.vertical, verticalPadding) } } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift index 078c051ca8..4e6c023147 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift @@ -20,16 +20,15 @@ import Foundation import Combine public protocol ManagementDialogModelDelegate: AnyObject { - func turnOnSync() - func dontSyncAnotherDeviceNow() - func recoverDevice(using recoveryCode: String) - func presentSyncAnotherDeviceDialog() - func confirmSetupComplete() + var isUnifiedFavoritesEnabled: Bool { get set } + + func recoverDevice(using recoveryCode: String, isActiveDevice: Bool) func saveRecoveryPDF() func turnOffSync() func updateDeviceName(_ name: String) func removeDevice(_ device: SyncDevice) func deleteAccount() + func presentSaveRecoveryPDF() } public final class ManagementDialogModel: ObservableObject { @@ -39,6 +38,13 @@ public final class ManagementDialogModel: ObservableObject { @Published public var shouldShowErrorMessage: Bool = false @Published public var errorMessage: String? + @Published public var isUnifiedFavoritesEnabled: Bool = false { + didSet { + if delegate?.isUnifiedFavoritesEnabled != isUnifiedFavoritesEnabled { + delegate?.isUnifiedFavoritesEnabled = isUnifiedFavoritesEnabled + } + } + } public weak var delegate: ManagementDialogModelDelegate? diff --git a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementViewModel.swift b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementViewModel.swift index 7ab008ea1f..7a9cbf6bd2 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementViewModel.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementViewModel.swift @@ -24,18 +24,28 @@ public protocol ManagementViewModel: ObservableObject { var isCreatingAccount: Bool { get } var shouldShowErrorMessage: Bool { get set } var errorMessage: String? { get } + var isSyncBookmarksPaused: Bool { get } + var isSyncCredentialsPaused: Bool { get } var recoveryCode: String? { get } + var codeToDisplay: String? { get } var devices: [SyncDevice] { get } + var isUnifiedFavoritesEnabled: Bool { get set } - func presentEnableSyncDialog() + func presentShowTextCodeDialog() + func presentManuallyEnterCodeDialog() func presentRecoverSyncAccountDialog() func presentTurnOffSyncConfirmDialog() func presentDeleteAccount() - func presentShowOrEnterCodeDialog() func presentDeviceDetails(_ device: SyncDevice) func presentRemoveDevice(_ device: SyncDevice) func saveRecoveryPDF() func refreshDevices() + func turnOnSync() + func startPollingForRecoveryKey() + func stopPollingForRecoveryKey() + + func manageBookmarks() + func manageLogins() } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/AskToSyncAnotherDeviceView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/AskToSyncAnotherDeviceView.swift deleted file mode 100644 index 6a939f364d..0000000000 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/AskToSyncAnotherDeviceView.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// AskToSyncAnotherDeviceView.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 - -struct AskToSyncAnotherDeviceView: View where ViewModel: ManagementDialogModel { - - @EnvironmentObject var model: ViewModel - - var body: some View { - SyncDialog { - VStack(spacing: 20) { - Image("SyncAnotherDeviceDialog") - Text(UserText.syncAnotherDeviceTitle) - .font(.system(size: 17, weight: .bold)) - Text(UserText.syncAnotherDeviceExplanation1) - .multilineTextAlignment(.center) - Text(UserText.syncAnotherDeviceExplanation2) - .multilineTextAlignment(.center) - } - } buttons: { - Button(UserText.notNow) { - model.delegate?.dontSyncAnotherDeviceNow() - model.endFlow() - } - Button(UserText.syncAnotherDevice) { - model.delegate?.presentSyncAnotherDeviceDialog() - } - .buttonStyle(DefaultActionButtonStyle(enabled: true)) - } - .frame(width: 360, height: 314) - } -} diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/DeleteAccountView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/DeleteAccountView.swift index 634fd75897..8153a8fa3d 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/DeleteAccountView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/DeleteAccountView.swift @@ -45,6 +45,7 @@ struct DeleteAccountView: View { Button(UserText.cancel) { model.endFlow() } + .buttonStyle(DismissActionButtonStyle()) Button(UserText.deleteAccountButton) { model.delegate?.deleteAccount() } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/DeviceDetailsView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/DeviceDetailsView.swift index 8816fdf9a6..353a5881b5 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/DeviceDetailsView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/DeviceDetailsView.swift @@ -58,7 +58,7 @@ struct DeviceDetailsView: View { Button(UserText.cancel) { model.endFlow() } - + .buttonStyle(DismissActionButtonStyle()) Button(UserText.ok) { submit() } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/DeviceSyncedView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/DeviceSyncedView.swift new file mode 100644 index 0000000000..dcde5a5c88 --- /dev/null +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/DeviceSyncedView.swift @@ -0,0 +1,137 @@ +// +// DeviceSyncedView.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 DeviceSyncedView: View { + @EnvironmentObject var model: ManagementDialogModel + + let devices: [SyncDevice] + let shouldShowOptions: Bool + let isSingleDevice: Bool + var height: CGFloat { + if shouldShowOptions { + return 400 + } else { + return 250 + } + } + var title: String { + if isSingleDevice { + return UserText.allSetDialogTitle + } + return UserText.deviceSynced + } + + var body: some View { + SyncDialog(spacing: 20.0) { + VStack(alignment: .center, spacing: 20) { + Image("Sync-setup-success") + Text(title) + .font(.system(size: 17, weight: .bold)) + VStack(alignment: .center) { + if isSingleDevice { + SingleDeviceSetTextView() + } else { + NewDeviceSyncedView(devices: devices) + } + } + if shouldShowOptions { + OptionsView() + } + } + .frame(width: 320) + } buttons: { + Button(UserText.next) { + if isSingleDevice { + model.delegate?.presentSaveRecoveryPDF() + } else { + model.endFlow() + } + } + } + .padding(.vertical, 20) + .frame(width: 360, + height: height) + } + + struct SingleDeviceSetTextView: View { + var body: some View { + Text(UserText.allSetDialogCaption) + .frame(width: 320, alignment: .center) + .multilineTextAlignment(.center) + .fixedSize() + } + } + + struct NewDeviceSyncedView: View { + let devices: [SyncDevice] + var body: some View { + if devices.count > 1 { + VStack(alignment: .center) { + Text(UserText.multipleDeviceSyncedExplanation) + Text("\(devices.count + 1) ") + .fontWeight(.bold) + + + Text(UserText.devicesWord) + .fontWeight(.bold) + } + } else { + VStack(alignment: .center) { + Text(UserText.deviceSyncedExplanation) + Text("\(devices[0].name)") + .fontWeight(.bold) + } + } + } + } + + struct OptionsView: View { + @EnvironmentObject var model: ManagementDialogModel + var body: some View { + VStack(spacing: 8) { + Text(UserText.optionsSectionDialogTitle) + .font(.system(size: 11)) + .foregroundColor(Color("BlackWhite60")) + VStack { + Toggle(isOn: $model.isUnifiedFavoritesEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text(UserText.shareFavoritesOptionTitle) + .font(.system(size: 13)) + .foregroundColor(Color("BlackWhite80")) + Text(UserText.shareFavoritesOptionCaption) + .font(.system(size: 11)) + .foregroundColor(Color("BlackWhite60")) + .frame(width: 254) + .fixedSize() + } + .frame(width: 254) + } + .padding(.bottom, 13) + .padding(.top, 7) + .padding(.horizontal, 16) + .frame(height: 65) + .toggleStyle(.switch) + .roundedBorder() + } + .frame(width: 320) + } + .padding(.top, 32) + } + } +} diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/EnableSyncView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/EnableSyncView.swift deleted file mode 100644 index e3db4c87af..0000000000 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/EnableSyncView.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// EnableSyncView.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 - -struct EnableSyncView: View { - @EnvironmentObject var model: ManagementDialogModel - - var body: some View { - SyncDialog { - VStack(spacing: 20) { - Image("SyncTurnOnDialog") - Text(UserText.turnOnSyncQuestion) - .font(.system(size: 17, weight: .bold)) - Text(UserText.turnOnSyncExplanation1) - .multilineTextAlignment(.center) - Text(UserText.turnOnSyncExplanation2) - .multilineTextAlignment(.center) - } - } buttons: { - Button(UserText.cancel) { - model.endFlow() - } - Button(UserText.turnOnSync) { - model.delegate?.turnOnSync() - } - .buttonStyle(DefaultActionButtonStyle(enabled: true)) - } - .frame(width: 360, height: 314) - } -} diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/internal/EnterCodeView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/EnterCodeView.swift similarity index 96% rename from LocalPackages/SyncUI/Sources/SyncUI/Views/internal/EnterCodeView.swift rename to LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/EnterCodeView.swift index 557c1ec20d..f35941ec1a 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/internal/EnterCodeView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/EnterCodeView.swift @@ -31,6 +31,8 @@ struct EnterCodeView: View { var body: some View { VStack(spacing: 20) { Text(instructions) + .frame(width: 400) + .fixedSize() .multilineTextAlignment(.center) SyncKeyView(text: recoveryCodeModel.recoveryCode) diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/RecoverAccountView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/RecoverAccountView.swift index be8f8614f0..13e9a33aa9 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/RecoverAccountView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/RecoverAccountView.swift @@ -22,33 +22,45 @@ import SwiftUIExtensions struct RecoverAccountView: View { @EnvironmentObject var model: ManagementDialogModel @EnvironmentObject var recoveryCodeModel: RecoveryCodeViewModel + let isRecovery: Bool + let isActiveDevice: Bool + var instructionText: String { + if isRecovery { + return UserText.recoverSyncedDataExplanation + } + return UserText.manuallyEnterCodeExplanation + + } + var titleText: String { + if isRecovery { + return UserText.recoverSyncedDataTitle + } + return UserText.manuallyEnterCodeTitle + } func submitRecoveryCode() { - model.delegate?.recoverDevice(using: recoveryCodeModel.recoveryCode) + model.delegate?.recoverDevice(using: recoveryCodeModel.recoveryCode, isActiveDevice: isActiveDevice) } var body: some View { SyncDialog(spacing: 20.0) { - Text(UserText.recoverSyncedDataTitle) + Text(titleText) .font(.system(size: 17, weight: .bold)) EnterCodeView( - instructions: UserText.recoverSyncedDataExplanation, + instructions: instructionText, buttonCaption: UserText.pasteFromClipboard) { submitRecoveryCode() }.environmentObject(recoveryCodeModel) + .frame(height: 256) } buttons: { Button(UserText.cancel) { model.endFlow() } - Button(UserText.submit) { - submitRecoveryCode() - } - .buttonStyle(DefaultActionButtonStyle(enabled: !recoveryCodeModel.shouldDisableSubmitButton)) - .disabled(recoveryCodeModel.shouldDisableSubmitButton) + .buttonStyle(DismissActionButtonStyle()) } - .frame(width: 480, height: 432) + .frame(width: 480, height: 390) } } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/RemoveDeviceView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/RemoveDeviceView.swift index e10aa976e4..0260d1b26d 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/RemoveDeviceView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/RemoveDeviceView.swift @@ -44,7 +44,7 @@ struct RemoveDeviceView: View { Button(UserText.cancel) { model.endFlow() } - + .buttonStyle(DismissActionButtonStyle()) Button(UserText.removeDeviceConfirmButton) { model.delegate?.removeDevice(device) } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/ShowTextCodeView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/ShowTextCodeView.swift new file mode 100644 index 0000000000..8d3af279af --- /dev/null +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/ShowTextCodeView.swift @@ -0,0 +1,83 @@ +// +// ShowTextCodeView.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 + +struct ShowTextCodeView: View { + @EnvironmentObject var model: ManagementDialogModel + @State private var shareButtonFrame: CGRect = .zero // Store the frame of the share button + let code: String + + var body: some View { + SyncDialog(spacing: 20.0) { + Text(UserText.showTextCodeTitle) + .font(.system(size: 17, weight: .bold)) + VStack(alignment: .center, spacing: 20) { + Text(UserText.showTextCodeCaption) + .multilineTextAlignment(.center) + .frame(width: 280) + .fixedSize() + SyncKeyView(text: code) + .frame(width: 213) + HStack(alignment: .center, spacing: 10) { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(code, forType: .string) + } label: { + HStack { + Image("Copy") + Text(UserText.copy) + } + } + .buttonStyle(CopyPasteButtonStyle()) + Button { + shareContent(code) + } label: { + HStack { + Image("Share") + Text(UserText.share) + } + } + .buttonStyle(CopyPasteButtonStyle()) + .background(GeometryReader { geometry in + Color.clear.onAppear { + shareButtonFrame = geometry.frame(in: .global) + } + }) + } + } + .padding(20) + .roundedBorder() + } buttons: { + Button(UserText.done) { + model.endFlow() + } + } + } + + private func shareContent(_ sharedText: String) { + guard let shareButtonSuperview = NSApp.keyWindow?.contentView, + let shareButtonGlobalFrame = NSApp.keyWindow?.convertToScreen(shareButtonFrame) else { + return + } + let sharingPicker = NSSharingServicePicker(items: [sharedText]) + + sharingPicker.show(relativeTo: shareButtonGlobalFrame, of: shareButtonSuperview, preferredEdge: .maxY) + } +} diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/SyncAnotherDeviceView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/SyncAnotherDeviceView.swift deleted file mode 100644 index 58ca90f560..0000000000 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/SyncAnotherDeviceView.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// SyncAnotherDeviceView.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 - -struct SyncAnotherDeviceView: View { - @EnvironmentObject var model: ManagementDialogModel - @EnvironmentObject var recoveryCodeModel: RecoveryCodeViewModel - - enum Mode: Hashable { - case showCode, enterCode - } - - @State var selectedMode: Mode = .showCode - - func submitRecoveryCode() { - if !recoveryCodeModel.recoveryCode.isEmpty { - model.delegate?.recoverDevice(using: recoveryCodeModel.recoveryCode) - } - } - - var body: some View { - SyncDialog(spacing: 20.0) { - Text(UserText.syncNewDevice) - .font(.system(size: 17, weight: .bold)) - - Picker("", selection: $selectedMode) { - Text(UserText.showCode).tag(Mode.showCode) - Text(UserText.enterCode).tag(Mode.enterCode) - } - .pickerStyle(.segmented) - - switch selectedMode { - case .showCode: - ShowCodeView().environmentObject(model) - case .enterCode: - EnterCodeView( - instructions: UserText.syncNewDeviceEnterCodeInstructions, - buttonCaption: UserText.pasteFromClipboard) { - submitRecoveryCode() - }.environmentObject(recoveryCodeModel) - } - } buttons: { - switch selectedMode { - case .showCode: - Button(UserText.cancel) { - model.endFlow() - } - case .enterCode: - Button(UserText.cancel) { - model.endFlow() - } - Button(UserText.submit) { - submitRecoveryCode() - } - .buttonStyle(DefaultActionButtonStyle(enabled: !recoveryCodeModel.shouldDisableSubmitButton)) - .disabled(recoveryCodeModel.shouldDisableSubmitButton) - } - } - .frame(width: 480, height: 432) - } - -} - -private struct ShowCodeView: View { - @EnvironmentObject var model: ManagementDialogModel - - var body: some View { - VStack(spacing: 20) { - Text(UserText.syncNewDeviceShowCodeInstructions) - .multilineTextAlignment(.center) - - HStack(alignment: .top, spacing: 20) { - QRCode(string: model.codeToDisplay ?? "", size: .init(width: 164, height: 164)) - - VStack { - SyncKeyView(text: model.codeToDisplay ?? "") - - Spacer() - - HStack { - Spacer() - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(model.codeToDisplay ?? "", forType: .string) - } label: { - HStack { - Image("Copy") - Text(UserText.copy) - } - } - .buttonStyle(CopyPasteButtonStyle()) - } - } - .frame(maxHeight: .infinity) - } - } - .padding(20) - .roundedBorder() - } -} diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/SyncSetupCompleteView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/SyncSetupCompleteView.swift deleted file mode 100644 index 3e17a09e18..0000000000 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/SyncSetupCompleteView.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// SyncSetupCompleteView.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 SyncSetupCompleteView: View { - @EnvironmentObject var model: ManagementDialogModel - - let devices: [SyncDevice] - - var body: some View { - SyncDialog(spacing: 20.0) { - VStack(spacing: 20) { - Image("SyncSetupComplete") - Text(UserText.deviceSynced) - .font(.system(size: 17, weight: .bold)) - Text(UserText.deviceSyncedExplanation) - .multilineTextAlignment(.center) - - ScrollView { - SyncedDevicesList(devices: devices) - } - - } - } buttons: { - Button(UserText.next) { - model.delegate?.confirmSetupComplete() - } - } - .frame(width: 360, - // Grow with the number of devices, up to a point - height: min(410, 258 + (CGFloat(devices.count) * 44))) - - } -} diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/TurnOffSyncView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/TurnOffSyncView.swift index 6d32bfeee5..6b038f1e1d 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/TurnOffSyncView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/Dialogs/TurnOffSyncView.swift @@ -36,6 +36,7 @@ struct TurnOffSyncView: View { Button(UserText.cancel) { model.endFlow() } + .buttonStyle(DismissActionButtonStyle()) Button(UserText.turnOff) { model.delegate?.turnOffSync() } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift index 41ec3b01b1..ee5e55a4db 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift @@ -19,16 +19,16 @@ import SwiftUI public enum ManagementDialogKind: Equatable { - case enableSync case recoverAccount case deleteAccount(_ devices: [SyncDevice]) - case askToSyncAnotherDevice - case syncAnotherDevice - case deviceSynced(_ devices: [SyncDevice]) + case deviceSynced(_ devices: [SyncDevice], shouldShowOptions: Bool) case saveRecoveryPDF case turnOffSync case deviceDetails(_ device: SyncDevice) case removeDevice(_ device: SyncDevice) + case showTextCode(_ code: String) + case manuallyEnterCode + case firstDeviceSetup } public struct ManagementDialog: View { @@ -54,16 +54,14 @@ public struct ManagementDialog: View { @ViewBuilder var content: some View { Group { switch model.currentDialog { - case .enableSync: - EnableSyncView() - case .askToSyncAnotherDevice: - AskToSyncAnotherDeviceView() case .recoverAccount: - RecoverAccountView() - case .syncAnotherDevice: - SyncAnotherDeviceView() - case .deviceSynced(let devices): - SyncSetupCompleteView(devices: devices) + RecoverAccountView(isRecovery: true, isActiveDevice: false) + case .manuallyEnterCode: + RecoverAccountView(isRecovery: false, isActiveDevice: true) + case .deviceSynced(let devices, let shouldShowOptions): + DeviceSyncedView(devices: devices, shouldShowOptions: shouldShowOptions, isSingleDevice: false) + case .firstDeviceSetup: + DeviceSyncedView(devices: [], shouldShowOptions: false, isSingleDevice: true) case .saveRecoveryPDF: SaveRecoveryPDFView() case .turnOffSync: @@ -74,6 +72,8 @@ public struct ManagementDialog: View { RemoveDeviceView(device: device) case .deleteAccount(let devices): DeleteAccountView(devices: devices) + case .showTextCode(let code): + ShowTextCodeView(code: code) default: EmptyView() diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView.swift index d5feb03a4a..fb9f778462 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView.swift @@ -23,6 +23,7 @@ enum Const { enum Fonts { static let preferencePaneTitle: Font = .title2.weight(.semibold) static let preferencePaneSectionHeader: Font = .title3.weight(.semibold) + static let preferencePaneOptionTitle: Font = .title3 static let preferencePaneCaption: Font = .subheadline } } @@ -51,15 +52,18 @@ public struct ManagementView: View where ViewModel: ManagementViewMod .background(RoundedRectangle(cornerRadius: 8).foregroundColor(.yellow)) .padding(.bottom, 10) - Text(UserText.sync) - .font(Const.Fonts.preferencePaneTitle) + VStack(alignment: .leading, spacing: 8) { + Text(UserText.sync) + .font(Const.Fonts.preferencePaneTitle) + .padding(.horizontal, 16) - if model.isSyncEnabled { - SyncEnabledView() - .environmentObject(model) - } else { - SyncSetupView() - .environmentObject(model) + if model.isSyncEnabled { + SyncEnabledView() + .environmentObject(model) + } else { + SyncSetupView() + .environmentObject(model) + } } } .alert(isPresented: $model.shouldShowErrorMessage) { diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncEnabledView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncEnabledView.swift index bb99d5e72b..298b18c99e 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncEnabledView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncEnabledView.swift @@ -23,45 +23,132 @@ struct SyncEnabledView: View where ViewModel: ManagementViewModel { @EnvironmentObject var model: ViewModel var body: some View { - PreferencePaneSection { + VStack(alignment: .leading, spacing: 16) { + if model.isSyncBookmarksPaused { + syncPaused(for: .bookmarks) + } + if model.isSyncCredentialsPaused { + syncPaused(for: .credentials) + } + } + .padding(.top, 20) + + PreferencePaneSection(vericalPadding: 12) { SyncStatusView() .environmentObject(model) + .frame(width: 513, alignment: .topLeading) } - PreferencePaneSection { + PreferencePaneSection(vericalPadding: 12) { Text(UserText.syncedDevices) .font(Const.Fonts.preferencePaneSectionHeader) - + .padding(.horizontal, 16) SyncedDevicesView() .environmentObject(model) + .frame(width: 513, alignment: .topLeading) } - PreferencePaneSection { + PreferencePaneSection(vericalPadding: 12) { Text(UserText.syncNewDevice) .font(Const.Fonts.preferencePaneSectionHeader) - - SyncNewDeviceView() + .padding(.horizontal, 16) + SyncSetupSyncAnotherDeviceCardView(code: model.recoveryCode ?? "") .environmentObject(model) } - PreferencePaneSection { - Text(UserText.recovery) + PreferencePaneSection(vericalPadding: 12) { + Text(UserText.optionsSectionTitle) .font(Const.Fonts.preferencePaneSectionHeader) + .padding(.horizontal, 16) + Toggle(isOn: $model.isUnifiedFavoritesEnabled) { + HStack { + IconOnBackground(image: NSImage(imageLiteralResourceName: "SyncAllDevices")) + VStack(alignment: .leading, spacing: 8) { + Text(UserText.shareFavoritesOptionTitle) + .font(Const.Fonts.preferencePaneOptionTitle) + Text(UserText.shareFavoritesOptionCaption) + .font(Const.Fonts.preferencePaneCaption) + .foregroundColor(Color("BlackWhite60")) + } + Spacer(minLength: 30) + } + } + .padding(.horizontal, 16) + .toggleStyle(.switch) + .padding(.vertical, 12) + .roundedBorder() + .frame(width: 513, alignment: .topLeading) + } - HStack(alignment: .top, spacing: 12) { - Text(UserText.recoveryInstructions) - .fixMultilineScrollableText() - Spacer() - Button(UserText.saveRecoveryPDF) { - model.saveRecoveryPDF() + PreferencePaneSection(vericalPadding: 12) { + VStack(alignment: .leading, spacing: 6) { + Text(UserText.recovery) + .font(Const.Fonts.preferencePaneSectionHeader) + HStack(alignment: .top, spacing: 12) { + Text(UserText.recoveryInstructions) + .fixMultilineScrollableText() + Spacer() + Button(UserText.saveRecoveryPDF) { + model.saveRecoveryPDF() + } } } + .padding(.horizontal, 16) + .frame(width: 513, alignment: .topLeading) } - PreferencePaneSection { + PreferencePaneSection(vericalPadding: 12) { Button(UserText.turnOffAndDeleteServerData) { model.presentDeleteAccount() } + .padding(16) + } + } + + @ViewBuilder + func syncPaused(for itemType: LimitedItemType) -> some View { + var description: String { + switch itemType { + case .bookmarks: + return UserText.bookmarksLimitExceededDescription + case .credentials: + return UserText.credentialsLimitExceededDescription + } + } + var actionTitle: String { + switch itemType { + case .bookmarks: + return UserText.bookmarksLimitExceededAction + case .credentials: + return UserText.credentialsLimitExceededAction + } } + PreferencePaneSection(vericalPadding: 16) { + HStack(alignment: .top, spacing: 8) { + Text("⚠️") + VStack(alignment: .leading, spacing: 8) { + Text(UserText.syncLimitExceededTitle) + .bold() + Text(description) + Button(actionTitle) { + switch itemType { + case .bookmarks: + model.manageBookmarks() + case .credentials: + model.manageLogins() + } + } + .padding(.top, 8) + } + } + .padding(.horizontal, 16) + } + .frame(width: 512, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 8).foregroundColor(Color("AlertBubbleBackground"))) + } + + enum LimitedItemType { + case bookmarks + case credentials } } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncNewDeviceView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncNewDeviceView.swift deleted file mode 100644 index 15c93283ab..0000000000 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncNewDeviceView.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// SyncNewDeviceView.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 - -struct SyncNewDeviceView: View where ViewModel: ManagementViewModel { - @EnvironmentObject var model: ViewModel - - var body: some View { - HStack(alignment: .top, spacing: 20) { - QRCode(string: model.recoveryCode ?? "", size: .init(width: 192, height: 192)) - - VStack { - Text(UserText.syncNewDeviceInstructions) - .fixMultilineScrollableText() - - Spacer() - - HStack { - Spacer() - TextButton(UserText.showOrEnterCode) { - model.presentShowOrEnterCodeDialog() - } - } - } - .frame(maxHeight: .infinity) - } - .padding(20) - .roundedBorder() - } -} diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncSetupView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncSetupView.swift index ea588a228a..a1bf1c1c63 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncSetupView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncSetupView.swift @@ -23,35 +23,132 @@ struct SyncSetupView: View where ViewModel: ManagementViewModel { @EnvironmentObject var model: ViewModel var body: some View { + Text(UserText.syncSetupExplanation) + .fixMultilineScrollableText() + .padding(.horizontal, 16) PreferencePaneSection { - HStack(alignment: .top, spacing: 12) { - Text(UserText.syncSetupExplanation) - .fixMultilineScrollableText() - Spacer() + VStack(alignment: .leading, spacing: 12) { Group { if model.isCreatingAccount { ProgressView() } else { - Button(UserText.turnOnSyncWithEllipsis) { - model.presentEnableSyncDialog() + VStack(alignment: .leading, spacing: 24) { + SyncSetupSyncAnotherDeviceCardView(code: model.codeToDisplay ?? "") + .environmentObject(model) + .onAppear { + model.startPollingForRecoveryKey() + } + .onDisappear { + model.stopPollingForRecoveryKey() + } + SyncSetupStartCardView() + SyncSetupRecoverCardView() + Text(UserText.syncSetUpFooter) + .font(.system(size: 11)) + .foregroundColor(Color("GreyTextColor")) + .padding(.horizontal, 16) } } }.frame(minWidth: 100) } } + } - PreferencePaneSection { +} + +// MARK: - Card Views +extension SyncSetupView { + struct SyncSetupStartCardView: View { + @EnvironmentObject var model: ViewModel + var body: some View { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(UserText.syncFirstDeviceSetUpCardTitle) + .fontWeight(.semibold) + Text(UserText.syncFirstDeviceSetUpCardExplanation) + .foregroundColor(Color("GreyTextColor")) + } + Button(UserText.syncFirstDeviceSetUpActionTitle) { + model.turnOnSync() + } + } + .frame(width: 424, alignment: .topLeading) + Image("Sync-Desktop-New-96x96") + } + .padding(16) + .roundedBorder() + } + } + + struct SyncSetupRecoverCardView: View { + @EnvironmentObject var model: ViewModel + var body: some View { HStack { - Spacer() - Image("SyncSetup") + Button(UserText.syncRecoverDataActionTitle) { + model.presentRecoverSyncAccountDialog() + } Spacer() } + .padding(16) + .frame(width: 512) + .roundedBorder() + } + } +} + +// MARK: - QRCodeView +struct QRCodeView: View { + let recoveryCode: String + + var body: some View { + VStack(alignment: .center) { + QRCode(string: recoveryCode, size: .init(width: 160, height: 160)) + Text("Scan this QR code with another device") + .foregroundColor(Color("GreyTextColor")) } + .padding(.vertical, 16) + .frame(width: 480) + .background(ZStack { + RoundedRectangle(cornerRadius: 8) + .stroke(Color("BlackWhite10"), lineWidth: 1) + RoundedRectangle(cornerRadius: 8) + .fill(Color("ClearColor")) + }) + } +} - PreferencePaneSection { - TextButton(UserText.recoverSyncedData) { - model.presentRecoverSyncAccountDialog() +struct SyncSetupSyncAnotherDeviceCardView: View where ViewModel: ManagementViewModel { + @EnvironmentObject var model: ViewModel + let code: String + + var body: some View { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 16) { + Text(UserText.syncAddDeviceCardExplanation) + .foregroundColor(Color("GreyTextColor")) + QRCodeView(recoveryCode: code) + VStack(alignment: .leading, spacing: 8) { + Text(UserText.syncAddDeviceCardActionsExplanation) + .foregroundColor(Color("GreyTextColor")) + Text(UserText.syncAddDeviceShowTextActionTitle) + .fontWeight(.semibold) + .foregroundColor(Color("LinkBlueColor")) + .onTapGesture { + model.presentShowTextCodeDialog() + } + Text(UserText.syncAddDeviceEnterCodeActionTitle) + .fontWeight(.semibold) + .foregroundColor(Color("LinkBlueColor")) + .onTapGesture { + model.presentManuallyEnterCodeDialog() + } + } } + .frame(width: 424, alignment: .topLeading) + Image("Sync-Pair-96x96") } + .padding(16) + .roundedBorder() } } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncedDevicesView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncedDevicesView.swift index 75065251b1..856756a190 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncedDevicesView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementView/SyncedDevicesView.swift @@ -55,6 +55,14 @@ struct SyncedDeviceIcon: View { } } + var body: some View { + IconOnBackground(image: image) + } +} + +struct IconOnBackground: View { + var image: NSImage + var body: some View { ZStack { RoundedRectangle(cornerRadius: 4) diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/internal/CopyPasteButtonStyle.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/internal/CopyPasteButtonStyle.swift index 2abcd9206e..4ccce8e91c 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/internal/CopyPasteButtonStyle.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/internal/CopyPasteButtonStyle.swift @@ -33,7 +33,7 @@ struct CopyPasteButtonStyle: ButtonStyle { let outerShadowOpacity = colorScheme == .dark ? 0.8 : 0.0 configuration.label - .padding(.horizontal, 12) + .padding(.horizontal, 24) .padding(.vertical, verticalPadding) .background( RoundedRectangle(cornerRadius: 5, style: .continuous) diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/internal/SyncDialog.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/internal/SyncDialog.swift index 8828774033..248d6c94d2 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/internal/SyncDialog.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/internal/SyncDialog.swift @@ -49,7 +49,7 @@ struct SyncDialog: View where Content: View, Buttons: View { } .padding(.top, spacing) .padding(.bottom, 16.0) - .frame(minWidth: 360, minHeight: 298, idealHeight: 314) + .frame(minWidth: 360, idealHeight: 314) } } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift b/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift index 3a5ce1fa72..078affc8f1 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift @@ -26,22 +26,34 @@ enum UserText { static let submit = NSLocalizedString("submit", value: "Submit", comment: "Submit button") static let next = NSLocalizedString("next", value: "Next", comment: "Next button") static let copy = NSLocalizedString("copy", value: "Copy", comment: "Copy button") + static let share = NSLocalizedString("share", value: "Share", comment: "Share button") static let pasteFromClipboard = NSLocalizedString("paste-from-clipboard", value: "Paste from Clipboard", comment: "Paste button") + static let done = NSLocalizedString("done", value: "Done", comment: "Done button") - static let sync = NSLocalizedString("preferences.sync", value: "Sync", comment: "Show sync preferences") + static let sync = NSLocalizedString("preferences.sync", value: "Sync and Back Up", comment: "Show sync preferences") + static let syncSetupExplanation = NSLocalizedString("preferences.sync.setup-explanation", value: "Securely sync and back up your bookmarks and Logins.", comment: "Sync setup explanation") + + static let syncAddDeviceCardExplanation = NSLocalizedString("preferences.sync.add-device-explanation", value: "To sync with another device, open the DuckDuckGo app on that device. Navigate to Settings > Sync & Back Up and scan the QR code below.", comment: "Sync add device explanation") + static let syncAddDeviceCardActionsExplanation = NSLocalizedString("preferences.sync.add-device-actions-explanation", value: "Can't scan the QR code? Copy and paste the text code instead.", comment: "Sync add device actions explanation") + static let syncAddDeviceShowTextActionTitle = NSLocalizedString("preferences.sync.add-device-show-text-action-title", value: "Show Text Code", comment: "Sync add device show text action title") + static let syncAddDeviceEnterCodeActionTitle = NSLocalizedString("preferences.sync.add-device-enter-code-action-title", value: "Enter Text Code", comment: "Sync add device enter code action title") + + static let syncFirstDeviceSetUpCardTitle = NSLocalizedString("preferences.sync.first-device-setup-title", value: "Single-Device Setup", comment: "Sync first device setup title") + static let syncFirstDeviceSetUpCardExplanation = NSLocalizedString("preferences.sync.first-device-setup-explanation", value: "Set up this device now, sync with other devices later.", comment: "Sync add device enter code action explanation") + static let syncFirstDeviceSetUpActionTitle = NSLocalizedString("preferences.sync.first-device-setup-action-title", value: "Start Sync & Back Up", comment: "Sync first device setup action title") + + static let syncRecoverDataActionTitle = NSLocalizedString("preferences.sync.recover-data-action-title", value: "Recover Your Data", comment: "Sync recover data action title") + + static let syncSetUpFooter = NSLocalizedString("preferences.sync.setup-footer", value: "Your data is end-to-end encrypted, and DuckDuckGo does not have access to the decryption key.", comment: "Sync setup footer") - static let syncSetupExplanation = NSLocalizedString("preferences.sync.setup-explanation", value: "Sync your bookmarks and Autofill logins across your devices and save an encrypted backup on DuckDuckGo’s servers.", comment: "Sync setup explanation") - static let turnOnSync = NSLocalizedString("preferences.sync.turn-on", value: "Turn on Sync", comment: "Enable sync button caption") - static let turnOnSyncWithEllipsis = NSLocalizedString("preferences.sync.turn-on-ellipsis", value: "Turn on Sync...", comment: "Enable sync button caption") static let turnOff = NSLocalizedString("preferences.sync.turn-off", value: "Turn Off", comment: "Turn off sync confirmation dialog button title") static let turnOffSync = NSLocalizedString("preferences.sync.turn-off.ellipsis", value: "Turn off Sync...", comment: "Disable sync button caption") static let turnOffSyncConfirmTitle = NSLocalizedString("preferences.sync.turn-off.confirm.title", value: "Turn Off Sync?", comment: "Turn off sync confirmation dialog title") static let turnOffSyncConfirmMessage = NSLocalizedString("preferences.sync.turn-off.confirm.message", value: "This device will no longer be able to access your synced data.", comment: "Turn off sync confirmation dialog message") static let turnOffAndDeleteServerData = NSLocalizedString("preferences.sync.turn-off-and-delete-data", value: "Turn Off and Delete Server Data", comment: "Disable and delete data sync button caption") - static let recoverSyncedData = NSLocalizedString("preferences.sync.recover", value: "Recover synced data with backup code", comment: "Caption for a button to recover synced data") static let syncConnected = NSLocalizedString("preferences.sync.connected", value: "Connected", comment: "Sync state") static let syncedDevices = NSLocalizedString("preferences.sync.synced-devices", value: "Synced Devices", comment: "Settings section title") - static let syncNewDevice = NSLocalizedString("preferences.sync.sync-new-device", value: "Sync New Device", comment: "Settings section title") + static let syncNewDevice = NSLocalizedString("preferences.sync.sync-new-device", value: "Sync with Another Device", comment: "Settings section title") static let thisDevice = NSLocalizedString("preferences.sync.this-device", value: "This Device", comment: "Indicator of a current user's device on the list") static let currentDeviceDetails = NSLocalizedString("preferences.sync.current-device-details", value: "Details...", comment: "Sync Settings device details button") static let removeDeviceButton = NSLocalizedString("preferences.sync.remove-device", value: "Remove...", comment: "Button to remove a device") @@ -54,33 +66,27 @@ enum UserText { return String(format: localized, deviceName) } - static let syncNewDeviceInstructions = NSLocalizedString("prefrences.sync.sync-new-device-instructions", value: "Go to Settings > Sync in the DuckDuckGo App on a different device and scan the image on the left to connect instantly.", comment: "Instructions for adding a new device to sync") - static let showOrEnterCode = NSLocalizedString("prefrences.sync.show-or-enter-code", value: "Show or Enter Code", comment: "Button caption in Sync's add new device screen") static let recovery = NSLocalizedString("prefrences.sync.recovery", value: "Recovery", comment: "Sync settings section title") static let recoveryInstructions = NSLocalizedString("prefrences.sync.recovery-instructions", value: "If you lose your device, you will need this recovery code to restore your synced data.", comment: "Instructions on how to restore synced data") static let saveRecoveryPDF = NSLocalizedString("prefrences.sync.save-recovery-pdf", value: "Save Recovery PDF", comment: "Caption for a button to save Sync recovery PDF") - static let turnOnSyncQuestion = NSLocalizedString("preferences.sync.turn-on-question", value: "Turn on Sync?", comment: "Sync setup dialog title") - static let turnOnSyncExplanation1 = NSLocalizedString("preferences.sync.turn-on-explanation1", value: "This will save an encrypted backup of your bookmarks and Autofill logins on DuckDuckGo’s servers, which can be synced with your other devices.", comment: "Sync setup dialog content") - static let turnOnSyncExplanation2 = NSLocalizedString("preferences.sync.turn-on-explanation2", value: "The decryption key is stored on your device and cannot be read by DuckDuckGo.", comment: "Sync setup dialog content") - - static let recoverSyncedDataTitle = NSLocalizedString("preferences.sync.recover-synced-data", value: "Recover Synced Data", comment: "Sync setup dialog title") - static let recoverSyncedDataExplanation = NSLocalizedString("preferences.sync.recover-synced-data-explanation", value: "Enter the code on your recovery PDF or another synced device below to recover your synced data.", comment: "Sync setup dialog content") + static let showTextCodeTitle = NSLocalizedString("prefrences.sync.show-text-code-dialog-title", value: "Text Code", comment: "Title for show text code dialog") + static let showTextCodeCaption = NSLocalizedString("prefrences.sync.show-text-code-dialog-caption", value: "Use this code after choosing \"Enter Text Code\" during sync setup on another device", comment: "Caption for show text code dialog") - static let syncAnotherDeviceTitle = NSLocalizedString("preferences.sync.sync-another-device-question", value: "Sync Another Device?", comment: "Sync setup dialog title") - static let syncAnotherDeviceExplanation1 = NSLocalizedString("preferences.sync.sync-another-device-explanation1", value: "Your bookmarks and Autofill logins will be backed up! Would you like to sync with another device now?", comment: "Sync setup dialog content") - static let syncAnotherDeviceExplanation2 = NSLocalizedString("preferences.sync.sync-another-device-explanation2", value: "If you’ve already set up Sync on another device, this will allow you to combine bookmarks and Autofill logins from both devices into a single backup.", comment: "Sync setup dialog content") - static let syncAnotherDevice = NSLocalizedString("preferences.sync.sync-another-device", value: "Sync Another Device", comment: "Button caption") + static let allSetDialogTitle = NSLocalizedString("prefrences.sync.all-set-dyalog-title", value: "All Set!", comment: "Title for all set dialog title") + static let allSetDialogCaption = NSLocalizedString("prefrences.all-set-dyalog-caption", value: "You can sync this device’s bookmarks and Logins with additional devices at any time from the\n Sync & Back Up menu in Settings.", comment: "Caption for all set dialog") - static let showCode = NSLocalizedString("prefrences.sync.show-code", value: "Show Code", comment: "Button caption in Sync's add new device screen") - static let enterCode = NSLocalizedString("prefrences.sync.enter-code", value: "Enter Code", comment: "Button caption in Sync's add new device screen") - static let syncNewDeviceShowCodeInstructions = NSLocalizedString("prefrences.sync.sync-new-device-show-code-instructions", value: "Go to Settings > Sync in the DuckDuckGo App on a different device and select Scan or Manually Enter Code to sync.", comment: "Instructions for adding a new device to sync") - static let syncNewDeviceEnterCodeInstructions = NSLocalizedString("prefrences.sync.sync-new-device-enter-code-instructions", value: "Enter the code on your Recovery PDF, or another synced device below to recover your synced data.", comment: "Instructions for adding a new device to sync") + static let recoverSyncedDataTitle = NSLocalizedString("preferences.sync.recover-synced-data", value: "Enter Recovery Code", comment: "Recover Sync data dialog title") + static let recoverSyncedDataExplanation = NSLocalizedString("preferences.sync.recover-synced-data-explanation", value: "Enter the code from your Recovery PDF.", comment: "Recover Sync data dialog content") + static let manuallyEnterCodeTitle = NSLocalizedString("preferences.sync.manually-enter-code-title", value: "Enter Text Code", comment: "Sync manually enter codee dialog title") + static let manuallyEnterCodeExplanation = NSLocalizedString("preferences.sync.manually-enter-code-explanation", value: "Enter the code found in Settings > Sync & Back Up > Show Text Code on another synced device, to sync this device.", comment: "Sync manually enter codee dialog content") static let deviceSynced = NSLocalizedString("prefrences.sync.device-synced", value: "Device Synced!", comment: "Sync setup dialog title") - static let deviceSyncedExplanation = NSLocalizedString("prefrences.sync.device-synced-explanation", value: "Your bookmarks and Autofill logins are now syncing with this device.", comment: "Sync setup completion confirmation") + static let deviceSyncedExplanation = NSLocalizedString("prefrences.sync.device-synced-explanation", value: "Your bookmarks and Logins are now syncing with", comment: "Sync setup completion confirmation") + static let multipleDeviceSyncedExplanation = NSLocalizedString("prefrences.sync.multiple-device-synced-explanation", value: "Your bookmarks and Logins are now syncing on", comment: "Sync setup completion confirmation") + static let devicesWord = NSLocalizedString("prefrences.sync.multiple-device-synced-explanation-device word", value: "devices", comment: "Sync setup completion confirmation device word") - static let recoveryPDFExplanation1 = NSLocalizedString("prefrences.sync.recovery-pdf-explanation1", value: "If you lose access to your devices, you will need the code recover your synced data. You can save this code to your device as a PDF.", comment: "Sync recovery PDF explanation") + static let recoveryPDFExplanation1 = NSLocalizedString("prefrences.sync.recovery-pdf-explanation1", value: "If you lose access to your devices, you will need this code to recover your synced data. You can save this code to your device as a PDF.", comment: "Sync recovery PDF explanation 2") static let recoveryPDFExplanation2 = NSLocalizedString("prefrences.sync.recovery-pdf-explanation2", value: "Anyone with access to this code can access your synced data, so please keep it in a safe place.", comment: "Sync recovery PDF explanation") static let deviceDetailsTitle = NSLocalizedString("prefrences.sync.device-details.title", value: "Device Details", comment: "The title of the device details dialog") @@ -91,4 +97,14 @@ enum UserText { static let deleteAccountMessage = NSLocalizedString("prefrences.sync.delete-account.message", value: "These devices will be disconnected and your synced data will be deleted from the server.", comment: "Message for delete account") static let deleteAccountButton = NSLocalizedString("prefrences.sync.delete-account.button", value: "Delete Data", comment: "Label for delete account button") + static let optionsSectionDialogTitle = NSLocalizedString("prefrences.sync.options-section-dialog-title", value: "Options", comment: "Title for options settings in dialog") + static let optionsSectionTitle = NSLocalizedString("prefrences.sync.options-section-title", value: "Settings", comment: "Title for options settings") + static let shareFavoritesOptionTitle = NSLocalizedString("prefrences.sync.share-favorite-option-title", value: "Share Favorites", comment: "Title for share favorite option") + static let shareFavoritesOptionCaption = NSLocalizedString("prefrences.sync.share-favorite-option-caption", value: "Use the same favorites on all devices. Leave off to keep mobile and desktop favorites separate.", comment: "Caption for share favorite option") + + static let syncLimitExceededTitle = NSLocalizedString("prefrences.sync.limit-exceeded-title", value: "Sync Paused", comment: "Title for sync limits exceeded warning") + static let bookmarksLimitExceededDescription = NSLocalizedString("prefrences.sync.bookmarks-limit-exceeded-description", value: "Bookmark limit exceeded. Delete some to resume syncing.", comment: "Description for sync bookmarks limits exceeded warning") + static let credentialsLimitExceededDescription = NSLocalizedString("prefrences.sync.credentials-limit-exceeded-description", value: "Logins limit exceeded. Delete some to resume syncing.", comment: "Description for sync credentials limits exceeded warning") + static let bookmarksLimitExceededAction = NSLocalizedString("prefrences.sync.bookmarks-limit-exceeded-action", value: "Manage Bookmarks", comment: "Button title for sync bookmarks limits exceeded warning to manage bookmarks") + static let credentialsLimitExceededAction = NSLocalizedString("prefrences.sync.credentials-limit-exceeded-action", value: "Manage Logins", comment: "Button title for sync credentials limits exceeded warning to manage logins") } diff --git a/Submodules/privacy-reference-tests b/Submodules/privacy-reference-tests index 0d23f76801..7519c3d430 160000 --- a/Submodules/privacy-reference-tests +++ b/Submodules/privacy-reference-tests @@ -1 +1 @@ -Subproject commit 0d23f76801c2e73ae7d5ed7daa4af4aca5beec73 +Subproject commit 7519c3d430e5dcef75b6128bfdadb0de3f463a49 diff --git a/UnitTests/Bookmarks/Model/BookmarkMigrationTests.swift b/UnitTests/Bookmarks/Model/BookmarkMigrationTests.swift index 2f727454e0..43b1c4fb3e 100644 --- a/UnitTests/Bookmarks/Model/BookmarkMigrationTests.swift +++ b/UnitTests/Bookmarks/Model/BookmarkMigrationTests.swift @@ -169,7 +169,7 @@ class BookmarksMigrationTests: XCTestCase { LegacyBookmarksStoreMigration.setupAndMigrate(from: sourceStack.viewContext, to: context) XCTAssertNotNil(BookmarkUtils.fetchRootFolder(context)) - XCTAssertNotNil(BookmarkUtils.fetchFavoritesFolder(context)) + XCTAssertNotNil(BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.unified.rawValue, in: context)) // Simulate subsequent app instantiations LegacyBookmarksStoreMigration.setupAndMigrate(from: sourceStack.viewContext, to: context) @@ -189,7 +189,7 @@ class BookmarksMigrationTests: XCTestCase { let context = destinationStack.makeContext(concurrencyType: .mainQueueConcurrencyType) LegacyBookmarksStoreMigration.setupAndMigrate(from: sourceStack.viewContext, to: context) - let favoritesRoot = BookmarkUtils.fetchFavoritesFolder(context) + let favoritesRoot = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.unified.rawValue, in: context) XCTAssertNotNil(BookmarkUtils.fetchRootFolder(context)) XCTAssertNotNil(favoritesRoot) @@ -201,6 +201,7 @@ class BookmarksMigrationTests: XCTestCase { let topLevel = BookmarkListViewModel(bookmarksDatabase: destinationStack, parentID: nil, + favoritesDisplayMode: .displayUnified(native: .desktop), errorEvents: .init(mapping: { event, _, _, _ in XCTFail("Unexpected error: \(event)") })) @@ -213,7 +214,7 @@ class BookmarksMigrationTests: XCTestCase { let bookOne = topLevel.bookmarks[0] XCTAssertEqual(bookOne.isFolder, false) - XCTAssertEqual(bookOne.isFavorite, false) + XCTAssertEqual(bookOne.isFavorite(on: .unified), false) XCTAssertEqual(bookOne.title, "One") let folderA = topLevel.bookmarks[1] @@ -222,14 +223,14 @@ class BookmarksMigrationTests: XCTestCase { let favFour = topLevel.bookmarks[2] XCTAssertEqual(favFour.isFolder, false) - XCTAssertEqual(favFour.isFavorite, true) + XCTAssertEqual(favFour.isFavorite(on: .unified), true) XCTAssertEqual(favFour.title, "Four") XCTAssertEqual(favFour.url, url(for: "four").absoluteString) let folderAContents = folderA.childrenArray XCTAssertEqual(folderAContents[1].isFolder, false) - XCTAssertEqual(folderAContents[1].isFavorite, true) + XCTAssertEqual(folderAContents[1].isFavorite(on: .unified), true) XCTAssertEqual(folderAContents[1].title, "Two") let folderB = folderAContents[0] @@ -239,7 +240,7 @@ class BookmarksMigrationTests: XCTestCase { let folderBContents = folderB.childrenArray XCTAssertEqual(folderBContents.count, 1) XCTAssertEqual(folderBContents[0].isFolder, false) - XCTAssertEqual(folderBContents[0].isFavorite, true) + XCTAssertEqual(folderBContents[0].isFavorite(on: .unified), true) XCTAssertEqual(folderBContents[0].title, "Three") } diff --git a/UnitTests/Bookmarks/Model/BookmarkTests.swift b/UnitTests/Bookmarks/Model/BookmarkTests.swift index 35a293b664..cd21752a06 100644 --- a/UnitTests/Bookmarks/Model/BookmarkTests.swift +++ b/UnitTests/Bookmarks/Model/BookmarkTests.swift @@ -41,7 +41,10 @@ class BookmarkTests: XCTestCase { url: "https://example.com/", parent: rootFolder, context: context) - guard let bookmark = BaseBookmarkEntity.from(managedObject: bookmarkManagedObject) as? Bookmark else { + guard let bookmark = BaseBookmarkEntity.from( + managedObject: bookmarkManagedObject, + favoritesDisplayMode: .displayNative(.desktop) + ) as? Bookmark else { XCTFail("Failed to create Bookmark from managed object") return } @@ -53,7 +56,10 @@ class BookmarkTests: XCTestCase { func testWhenInitializingBaseBookmarkEntityFromBookmarkManagedObject_AndBookmarkIsFolder_ThenFolderIsCreated() { let folderManagedObject = BookmarkEntity.makeFolder(title: "Folder", parent: rootFolder, context: context) - guard let folder = BaseBookmarkEntity.from(managedObject: folderManagedObject) as? BookmarkFolder else { + guard let folder = BaseBookmarkEntity.from( + managedObject: folderManagedObject, + favoritesDisplayMode: .displayNative(.desktop) + ) as? BookmarkFolder else { XCTFail("Failed to create Folder from managed object") return } @@ -72,7 +78,10 @@ class BookmarkTests: XCTestCase { parent: folderManagedObject, context: context) - guard let folder = BaseBookmarkEntity.from(managedObject: folderManagedObject) as? BookmarkFolder else { + guard let folder = BaseBookmarkEntity.from( + managedObject: folderManagedObject, + favoritesDisplayMode: .displayNative(.desktop) + ) as? BookmarkFolder else { XCTFail("Failed to create Folder from managed object") return } @@ -80,7 +89,9 @@ class BookmarkTests: XCTestCase { XCTAssertEqual(folder.children.count, 1) XCTAssertEqual(folder.childFolders.count, 0) XCTAssertEqual(folder.childBookmarks.count, 1) - XCTAssertEqual(folder.children, [BaseBookmarkEntity.from(managedObject: bookmarkManagedObject)]) + XCTAssertEqual(folder.children, [ + BaseBookmarkEntity.from(managedObject: bookmarkManagedObject, favoritesDisplayMode: .displayNative(.desktop)) + ]) XCTAssertNil(folder.parentFolderUUID) let childBookmark = folder.children.first as? Bookmark diff --git a/UnitTests/Bookmarks/Services/BookmarkStoreMock.swift b/UnitTests/Bookmarks/Services/BookmarkStoreMock.swift index 194441ab3b..e3573c717b 100644 --- a/UnitTests/Bookmarks/Services/BookmarkStoreMock.swift +++ b/UnitTests/Bookmarks/Services/BookmarkStoreMock.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Bookmarks import Foundation import XCTest @@ -108,4 +109,7 @@ final class BookmarkStoreMock: BookmarkStore { updateFavoriteIndexCalled = true } + func applyFavoritesDisplayMode(_ configuration: FavoritesDisplayMode) {} + func handleFavoritesAfterDisablingSync() {} + var didMigrateToFormFactorSpecificFavorites: Bool = false } diff --git a/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift b/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift index d1745e3be0..176c73c807 100644 --- a/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift +++ b/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift @@ -490,7 +490,7 @@ final class LocalBookmarkStoreTests: XCTestCase { } XCTAssertEqual(topLevelEntities.count, 2) - XCTAssertFalse(topLevelEntities.map(\.id).contains(BookmarkEntity.Constants.favoritesFolderID)) + XCTAssertFalse(topLevelEntities.map(\.id).contains(FavoritesFolderID.unified.rawValue)) } func testWhenBookmarkIsMarkedAsFavorite_ThenItDoesNotChangeParentFolder() async { @@ -742,6 +742,191 @@ final class LocalBookmarkStoreTests: XCTestCase { initialParentFolder: initialParentFolder) } + // MARK: Favorites Display Mode + + func testDisplayNativeMode_WhenBookmarkIsFavorited_ThenItIsAddedToNativeAndUnifiedFolders() async throws { + let context = container.viewContext + let bookmarkStore = LocalBookmarkStore(context: context) + bookmarkStore.applyFavoritesDisplayMode(.displayNative(.desktop)) + + let bookmark = Bookmark(id: UUID().uuidString, url: "https://example1.com", title: "Example", isFavorite: true) + _ = await bookmarkStore.save(bookmark: bookmark, parent: nil, index: nil) + + context.performAndWait { + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + let bookmarkMO = rootFolder.childrenArray.first! + XCTAssertEqual(Set(bookmarkMO.favoritedOn), Set([.desktop, .unified])) + } + } + + func testDisplayNativeMode_WhenNonNativeFavoriteIsFavoritedThenItIsAddedToNativeFolder() async throws { + let context = container.viewContext + let bookmarkStore = LocalBookmarkStore(context: context) + bookmarkStore.applyFavoritesDisplayMode(.displayNative(.desktop)) + + context.performAndWait { + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + var bookmarkMO = BookmarkEntity.makeBookmark(title: "Example", url: "https://example1.com", parent: rootFolder, context: context) + bookmarkMO.addToFavorites(with: .displayNative(.mobile), in: context) + try! context.save() + + var bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark + + bookmark.isFavorite = true + bookmarkStore.update(bookmark: bookmark) + + bookmarkMO = rootFolder.childrenArray.first! + XCTAssertEqual(Set(bookmarkMO.favoritedOn), Set(FavoritesFolderID.allCases)) + } + } + + func testDisplayNativeMode_WhenNonNativeBrokenFavoriteIsFavoritedThenItIsAddedToNativeAndUnifiedFolder() async throws { + let context = container.viewContext + let bookmarkStore = LocalBookmarkStore(context: context) + bookmarkStore.applyFavoritesDisplayMode(.displayNative(.desktop)) + + context.performAndWait { + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + var bookmarkMO = BookmarkEntity.makeBookmark(title: "Example", url: "https://example1.com", parent: rootFolder, context: context) + let nonNativeFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.mobile.rawValue, in: context)! + bookmarkMO.addToFavorites(folders: [nonNativeFolder]) + try! context.save() + + var bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark + + bookmark.isFavorite = true + bookmarkStore.update(bookmark: bookmark) + + bookmarkMO = rootFolder.childrenArray.first! + XCTAssertEqual(Set(bookmarkMO.favoritedOn), Set(FavoritesFolderID.allCases)) + } + } + + func testDisplayNativeMode_WhenFavoriteIsUnfavoritedThenItIsRemovedFromNativeAndUnifiedFolder() async throws { + let context = container.viewContext + let bookmarkStore = LocalBookmarkStore(context: context) + bookmarkStore.applyFavoritesDisplayMode(.displayNative(.desktop)) + + context.performAndWait { + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + var bookmarkMO = BookmarkEntity.makeBookmark(title: "Example", url: "https://example1.com", parent: rootFolder, context: context) + bookmarkMO.addToFavorites(with: bookmarkStore.favoritesDisplayMode, in: context) + try! context.save() + + var bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark + + bookmark.isFavorite = false + bookmarkStore.update(bookmark: bookmark) + + bookmarkMO = rootFolder.childrenArray.first! + XCTAssertTrue(bookmarkMO.favoritedOn.isEmpty) + } + } + + func testDisplayNativeMode_WhenAllFormFactorsFavoriteIsUnfavoritedThenItIsOnlyRemovedFromNativeFolder() async throws { + let context = container.viewContext + let bookmarkStore = LocalBookmarkStore(context: context) + bookmarkStore.applyFavoritesDisplayMode(.displayNative(.desktop)) + + context.performAndWait { + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + var bookmarkMO = BookmarkEntity.makeBookmark(title: "Example", url: "https://example1.com", parent: rootFolder, context: context) + bookmarkMO.addToFavorites(with: bookmarkStore.favoritesDisplayMode, in: context) + let nonNativeFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.mobile.rawValue, in: context)! + bookmarkMO.addToFavorites(folders: [nonNativeFolder]) + try! context.save() + + var bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark + + bookmark.isFavorite = false + bookmarkStore.update(bookmark: bookmark) + + bookmarkMO = rootFolder.childrenArray.first! + XCTAssertEqual(Set(bookmarkMO.favoritedOn), Set([.mobile, .unified])) + } + } + + func testDisplayUnifiedMode_WhenBookmarkIsFavoritedThenItIsAddedToNativeAndUnifiedFolders() async throws { + let context = container.viewContext + let bookmarkStore = LocalBookmarkStore(context: context) + bookmarkStore.applyFavoritesDisplayMode(.displayUnified(native: .desktop)) + + let bookmark = Bookmark(id: UUID().uuidString, url: "https://example1.com", title: "Example", isFavorite: true) + _ = await bookmarkStore.save(bookmark: bookmark, parent: nil, index: nil) + + context.performAndWait { + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + let bookmarkMO = rootFolder.childrenArray.first! + XCTAssertEqual(Set(bookmarkMO.favoritedOn), Set([.desktop, .unified])) + } + } + + func testDisplayUnifiedMode_WhenNonNativeFavoriteIsUnfavoritedThenItIsRemovedFromAllFolders() async throws { + let context = container.viewContext + let bookmarkStore = LocalBookmarkStore(context: context) + bookmarkStore.applyFavoritesDisplayMode(.displayUnified(native: .desktop)) + + context.performAndWait { + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + var bookmarkMO = BookmarkEntity.makeBookmark(title: "Example", url: "https://example1.com", parent: rootFolder, context: context) + bookmarkMO.addToFavorites(with: .displayNative(.mobile), in: context) + try! context.save() + + var bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark + + bookmark.isFavorite = true + bookmarkStore.update(bookmark: bookmark) + + bookmarkMO = rootFolder.childrenArray.first! + XCTAssertEqual(Set(bookmarkMO.favoritedOn), Set(FavoritesFolderID.allCases)) + } + } + + func testDisplayUnifiedMode_WhenNonNativeBrokenFavoriteIsFavoritedThenItIsAddedToNativeAndUnifiedFolder() async throws { + let context = container.viewContext + let bookmarkStore = LocalBookmarkStore(context: context) + bookmarkStore.applyFavoritesDisplayMode(.displayUnified(native: .desktop)) + + context.performAndWait { + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + var bookmarkMO = BookmarkEntity.makeBookmark(title: "Example", url: "https://example1.com", parent: rootFolder, context: context) + let nonNativeFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.mobile.rawValue, in: context)! + bookmarkMO.addToFavorites(folders: [nonNativeFolder]) + try! context.save() + + var bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark + + bookmark.isFavorite = true + bookmarkStore.update(bookmark: bookmark) + + bookmarkMO = rootFolder.childrenArray.first! + XCTAssertEqual(Set(bookmarkMO.favoritedOn), Set(FavoritesFolderID.allCases)) + } + } + + func testDisplayUnifiedMode_WhenAllFormFactorsFavoriteIsUnfavoritedThenItIsRemovedFromAllFolders() async throws { + let context = container.viewContext + let bookmarkStore = LocalBookmarkStore(context: context) + bookmarkStore.applyFavoritesDisplayMode(.displayUnified(native: .desktop)) + + context.performAndWait { + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + var bookmarkMO = BookmarkEntity.makeBookmark(title: "Example", url: "https://example1.com", parent: rootFolder, context: context) + bookmarkMO.addToFavorites(with: bookmarkStore.favoritesDisplayMode, in: context) + let nonNativeFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.mobile.rawValue, in: context)! + bookmarkMO.addToFavorites(folders: [nonNativeFolder]) + try! context.save() + + var bookmark = Bookmark.from(managedObject: bookmarkMO, favoritesDisplayMode: bookmarkStore.favoritesDisplayMode) as! Bookmark + + bookmark.isFavorite = false + bookmarkStore.update(bookmark: bookmark) + + bookmarkMO = rootFolder.childrenArray.first! + XCTAssertTrue(bookmarkMO.favoritedOn.isEmpty) + } + } + // MARK: Import func testWhenBookmarksAreImported_AndNoDuplicatesExist_ThenBookmarksAreImported() { diff --git a/UnitTests/HomePage/Mocks/MockBookmarkManager.swift b/UnitTests/HomePage/Mocks/MockBookmarkManager.swift index 5f272f55d8..886517c90f 100644 --- a/UnitTests/HomePage/Mocks/MockBookmarkManager.swift +++ b/UnitTests/HomePage/Mocks/MockBookmarkManager.swift @@ -85,6 +85,10 @@ class MockBookmarkManager: BookmarkManager { BookmarkImportResult(successful: 0, duplicates: 0, failed: 0) } + func handleFavoritesAfterDisablingSync() {} + + var didMigrateToFormFactorSpecificFavorites: Bool = false + @Published var list: BookmarkList? var listPublisher: Published.Publisher { $list } diff --git a/UnitTests/Preferences/AppearancePreferencesTests.swift b/UnitTests/Preferences/AppearancePreferencesTests.swift index 7ba1b64ccc..111b345684 100644 --- a/UnitTests/Preferences/AppearancePreferencesTests.swift +++ b/UnitTests/Preferences/AppearancePreferencesTests.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Bookmarks import XCTest @testable import DuckDuckGo_Privacy_Browser @@ -26,6 +27,7 @@ struct AppearancePreferencesPersistorMock: AppearancePreferencesPersistor { var showFullURL: Bool var showAutocompleteSuggestions: Bool var currentThemeName: String + var favoritesDisplayMode: String? var defaultPageZoom: CGFloat var showBookmarksBar: Bool var bookmarksBarAppearance: BookmarksBarAppearance @@ -35,6 +37,7 @@ struct AppearancePreferencesPersistorMock: AppearancePreferencesPersistor { showFullURL: Bool = false, showAutocompleteSuggestions: Bool = true, currentThemeName: String = ThemeName.systemDefault.rawValue, + favoritesDisplayMode: String? = FavoritesDisplayMode.displayNative(.desktop).description, defaultPageZoom: CGFloat = DefaultZoomValue.percent100.rawValue, isContinueSetUpVisible: Bool = true, isFavoriteVisible: Bool = true, @@ -46,6 +49,7 @@ struct AppearancePreferencesPersistorMock: AppearancePreferencesPersistor { self.showFullURL = showFullURL self.showAutocompleteSuggestions = showAutocompleteSuggestions self.currentThemeName = currentThemeName + self.favoritesDisplayMode = favoritesDisplayMode self.defaultPageZoom = defaultPageZoom self.isContinueSetUpVisible = isContinueSetUpVisible self.isFavoriteVisible = isFavoriteVisible @@ -64,6 +68,7 @@ final class AppearancePreferencesTests: XCTestCase { showFullURL: false, showAutocompleteSuggestions: true, currentThemeName: ThemeName.systemDefault.rawValue, + favoritesDisplayMode: FavoritesDisplayMode.displayNative(.desktop).description, defaultPageZoom: DefaultZoomValue.percent100.rawValue, isContinueSetUpVisible: true, isFavoriteVisible: true, @@ -75,6 +80,7 @@ final class AppearancePreferencesTests: XCTestCase { XCTAssertEqual(model.showFullURL, false) XCTAssertEqual(model.showAutocompleteSuggestions, true) XCTAssertEqual(model.currentThemeName, ThemeName.systemDefault) + XCTAssertEqual(model.favoritesDisplayMode, .displayNative(.desktop)) XCTAssertEqual(model.defaultPageZoom, DefaultZoomValue.percent100) XCTAssertEqual(model.isFavoriteVisible, true) XCTAssertEqual(model.isContinueSetUpVisible, true) @@ -86,6 +92,7 @@ final class AppearancePreferencesTests: XCTestCase { showFullURL: true, showAutocompleteSuggestions: false, currentThemeName: ThemeName.light.rawValue, + favoritesDisplayMode: FavoritesDisplayMode.displayUnified(native: .desktop).description, defaultPageZoom: DefaultZoomValue.percent50.rawValue, isContinueSetUpVisible: false, isFavoriteVisible: false, @@ -96,6 +103,7 @@ final class AppearancePreferencesTests: XCTestCase { XCTAssertEqual(model.showFullURL, true) XCTAssertEqual(model.showAutocompleteSuggestions, false) XCTAssertEqual(model.currentThemeName, ThemeName.light) + XCTAssertEqual(model.favoritesDisplayMode, .displayUnified(native: .desktop)) XCTAssertEqual(model.defaultPageZoom, DefaultZoomValue.percent50) XCTAssertEqual(model.isFavoriteVisible, false) XCTAssertEqual(model.isContinueSetUpVisible, false) diff --git a/UnitTests/PrivacyReferenceTests/BrokenSiteReportingReferenceTests.swift b/UnitTests/PrivacyReferenceTests/BrokenSiteReportingReferenceTests.swift index 6ff60ecf19..c54e0557f6 100644 --- a/UnitTests/PrivacyReferenceTests/BrokenSiteReportingReferenceTests.swift +++ b/UnitTests/PrivacyReferenceTests/BrokenSiteReportingReferenceTests.swift @@ -32,8 +32,10 @@ final class BrokenSiteReportingReferenceTests: XCTestCase { private func makeURLRequest(with parameters: [String: String]) -> URLRequest { APIRequest.Headers.setUserAgent("") + var params = parameters + params["test"] = "1" let configuration = APIRequest.Configuration(url: URL.pixelUrl(forPixelNamed: Pixel.Event.brokenSiteReport.name), - queryParameters: parameters, + queryParameters: params, allowedQueryReservedCharacters: WebsiteBreakageSender.allowedQueryReservedCharacters) return configuration.request } @@ -63,7 +65,7 @@ final class BrokenSiteReportingReferenceTests: XCTestCase { isGPCEnabled: test.gpcEnabled ?? false, ampURL: "", urlParametersRemoved: false, - protected: true, + protectionsState: true, manufacturer: test.manufacturer ?? "") let request = makeURLRequest(with: breakage.requestParameters) diff --git a/UnitTests/Sync/SyncPreferencesTests.swift b/UnitTests/Sync/SyncPreferencesTests.swift new file mode 100644 index 0000000000..0cecb0fefd --- /dev/null +++ b/UnitTests/Sync/SyncPreferencesTests.swift @@ -0,0 +1,236 @@ +// +// SyncPreferencesTests.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 XCTest +@testable import DDGSync +import Combine +@testable import DuckDuckGo_Privacy_Browser +@testable import BrowserServicesKit +import SyncUI + +final class SyncPreferencesTests: XCTestCase { + + let scheduler = CapturingScheduler() + let managementDialogModel = ManagementDialogModel() + var ddgSyncing: MockDDGSyncing! + var appearancePersistor = MockPersistor() + var appearancePreferences: AppearancePreferences! + var syncPreferences: SyncPreferences! + var testRecoveryCode = "some code" + + override func setUp() { + appearancePreferences = AppearancePreferences(persistor: appearancePersistor) + ddgSyncing = MockDDGSyncing(authState: .inactive, scheduler: scheduler, isSyncInProgress: false) + syncPreferences = SyncPreferences(syncService: ddgSyncing, apperancePreferences: appearancePreferences, managementDialogModel: managementDialogModel) + } + + override func tearDown() { + ddgSyncing = nil + syncPreferences = nil + } + + func testOnInitDelegateIsSet() { + XCTAssertNotNil(managementDialogModel.delegate) + } + + func testSyncIsEnabledReturnsCorrectValue() { + XCTAssertFalse(syncPreferences.isSyncEnabled) + + ddgSyncing.account = SyncAccount(deviceId: "some device", deviceName: "", deviceType: "", userId: "", primaryKey: Data(), secretKey: Data(), token: nil, state: .active) + + XCTAssertTrue(syncPreferences.isSyncEnabled) + } + + func testCorrectRecoveryCodeIsReturned() { + let account = SyncAccount(deviceId: "some device", deviceName: "", deviceType: "", userId: "", primaryKey: Data(), secretKey: Data(), token: nil, state: .active) + ddgSyncing.account = account + + XCTAssertEqual(syncPreferences.recoveryCode, account.recoveryCode) + } + + @MainActor func testOnPresentRecoverSyncAccountDialogThenRecoverAccountDialogShown() { + syncPreferences.presentRecoverSyncAccountDialog() + + XCTAssertEqual(managementDialogModel.currentDialog, .recoverAccount) + } + + @MainActor func testOnPresentManuallyEnterCodeDialogThenManuallyEnterCodeShown() { + syncPreferences.presentManuallyEnterCodeDialog() + + XCTAssertEqual(managementDialogModel.currentDialog, .manuallyEnterCode) + } + + @MainActor func testOnPresentShowTextCodeDialogThenShowTextCodeShown() { + syncPreferences.codeToDisplay = "adfasdf" + syncPreferences.presentShowTextCodeDialog() + + XCTAssertEqual(managementDialogModel.currentDialog, .showTextCode(syncPreferences.codeToDisplay ?? "")) + } + + @MainActor func testOnPresentTurnOffSyncConfirmDialogThenTurnOffSyncShown() { + syncPreferences.presentTurnOffSyncConfirmDialog() + + XCTAssertEqual(managementDialogModel.currentDialog, .turnOffSync) + } + + @MainActor func testOnPresentRemoveDeviceThenRemoveDEviceShown() { + let device = SyncDevice(kind: .desktop, name: "test", id: "test") + syncPreferences.presentRemoveDevice(device) + + XCTAssertEqual(managementDialogModel.currentDialog, .removeDevice(device)) + } + + @MainActor func testOnTurnOffSyncThenSyncServiceIsDisconnected() { + let expectation = XCTestExpectation(description: "Disconnect completed") + Task { + syncPreferences.turnOffSync() + XCTAssertNil(managementDialogModel.currentDialog) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + XCTAssertTrue(ddgSyncing.disconnectCalled) + } + +} + +class MockDDGSyncing: DDGSyncing { + let registeredDevices = [RegisteredDevice(id: "1", name: "Device 1", type: "desktop"), RegisteredDevice(id: "2", name: "Device 2", type: "mobile"), RegisteredDevice(id: "3", name: "Device 1", type: "desktop")] + var disconnectCalled = false + + var dataProvidersSource: DataProvidersSource? + + @Published var authState: SyncAuthState = .inactive + + var authStatePublisher: AnyPublisher { + $authState.eraseToAnyPublisher() + } + + var account: SyncAccount? + + var scheduler: Scheduling + + @Published var isSyncInProgress: Bool + + var isSyncInProgressPublisher: AnyPublisher { + $isSyncInProgress.eraseToAnyPublisher() + } + + init(dataProvidersSource: DataProvidersSource? = nil, authState: SyncAuthState, account: SyncAccount? = nil, scheduler: Scheduling, isSyncInProgress: Bool) { + self.dataProvidersSource = dataProvidersSource + self.authState = authState + self.account = account + self.scheduler = scheduler + self.isSyncInProgress = isSyncInProgress + } + + func initializeIfNeeded() { + } + + func createAccount(deviceName: String, deviceType: String) async throws { + } + + func login(_ recoveryKey: SyncCode.RecoveryKey, deviceName: String, deviceType: String) async throws -> [RegisteredDevice] { + return [] + } + + func remoteConnect() throws -> RemoteConnecting { + return MockRemoteConnecting() + } + + func transmitRecoveryKey(_ connectCode: SyncCode.ConnectCode) async throws { + } + + func disconnect() async throws { + disconnectCalled = true + } + + func disconnect(deviceId: String) async throws { + } + + func fetchDevices() async throws -> [RegisteredDevice] { + return registeredDevices + } + + func updateDeviceName(_ name: String) async throws -> [RegisteredDevice] { + return [] + } + + func deleteAccount() async throws { + } + + var serverEnvironment: ServerEnvironment = .production + + func updateServerEnvironment(_ serverEnvironment: ServerEnvironment) { + } +} + +class CapturingScheduler: Scheduling { + var notifyDataChangedCalled = false + + func notifyDataChanged() { + notifyDataChangedCalled = true + } + + func notifyAppLifecycleEvent() { + } + + func requestSyncImmediately() { + } + + func cancelSyncAndSuspendSyncQueue() { + } + + func resumeSyncQueue() { + } +} + +struct MockRemoteConnecting: RemoteConnecting { + var code: String = "" + + func pollForRecoveryKey() async throws -> SyncCode.RecoveryKey? { + return nil + } + + func stopPolling() { + } +} + +struct MockPersistor: AppearancePreferencesPersistor { + var homeButtonPosition: HomeButtonPosition = .hidden + + var showFullURL: Bool = false + + var showAutocompleteSuggestions: Bool = false + + var currentThemeName: String = "" + + var defaultPageZoom: CGFloat = 1.0 + + var favoritesDisplayMode: String? + + var isFavoriteVisible: Bool = true + + var isContinueSetUpVisible: Bool = true + + var isRecentActivityVisible: Bool = true + + var showBookmarksBar: Bool = false + + var bookmarksBarAppearance: BookmarksBarAppearance = .alwaysOn + +} diff --git a/UnitTests/WebsiteBreakageReport/WebsiteBreakageReportTests.swift b/UnitTests/WebsiteBreakageReport/WebsiteBreakageReportTests.swift index 8860b95dcf..dbba082bc3 100644 --- a/UnitTests/WebsiteBreakageReport/WebsiteBreakageReportTests.swift +++ b/UnitTests/WebsiteBreakageReport/WebsiteBreakageReportTests.swift @@ -40,7 +40,7 @@ class WebsiteBreakageReportTests: XCTestCase { isGPCEnabled: true, ampURL: "https://example.test", urlParametersRemoved: false, - protected: true + protectionsState: true ) let urlRequest = makeURLRequest(with: breakage.requestParameters) @@ -58,7 +58,7 @@ class WebsiteBreakageReportTests: XCTestCase { XCTAssertEqual(queryItems[valueFor: "tds"], "abc123") XCTAssertEqual(queryItems[valueFor: "blockedTrackers"], "bad.tracker.test,tracking.test") XCTAssertEqual(queryItems[valueFor: "surrogates"], "surrogate.domain.test") - XCTAssertEqual(queryItems[valueFor: "protectionsState"], "1") + XCTAssertEqual(queryItems[valueFor: "protectionsState"], "true") } func testThatNativeAppSpecificFieldsAreReported() throws { @@ -79,7 +79,7 @@ class WebsiteBreakageReportTests: XCTestCase { isGPCEnabled: true, ampURL: "https://example.test", urlParametersRemoved: false, - protected: true, + protectionsState: true, manufacturer: "IBM" ) @@ -98,7 +98,7 @@ class WebsiteBreakageReportTests: XCTestCase { XCTAssertEqual(queryItems[valueFor: "tds"], "abc123") XCTAssertEqual(queryItems[valueFor: "blockedTrackers"], "bad.tracker.test,tracking.test") XCTAssertEqual(queryItems[valueFor: "surrogates"], "surrogate.domain.test") - XCTAssertEqual(queryItems[valueFor: "protectionsState"], "1") + XCTAssertEqual(queryItems[valueFor: "protectionsState"], "true") XCTAssertEqual(queryItems[valueFor: "manufacturer"], "IBM") XCTAssertEqual(queryItems[valueFor: "os"], "12") XCTAssertEqual(queryItems[valueFor: "gpc"], "true") @@ -106,8 +106,10 @@ class WebsiteBreakageReportTests: XCTestCase { func makeURLRequest(with parameters: [String: String]) -> URLRequest { APIRequest.Headers.setUserAgent("") + var params = parameters + params["test"] = "1" let configuration = APIRequest.Configuration(url: URL.pixelUrl(forPixelNamed: Pixel.Event.brokenSiteReport.name), - queryParameters: parameters, + queryParameters: params, allowedQueryReservedCharacters: WebsiteBreakageSender.allowedQueryReservedCharacters) return configuration.request }