From 92948321f544c00bcdef7c23ebec739b84f71a8a Mon Sep 17 00:00:00 2001 From: Tom Strba <57389842+tomasstrba@users.noreply.github.com> Date: Wed, 11 Oct 2023 15:20:25 +0200 Subject: [PATCH 01/31] Xcode 15 + SDK changes (#528) Task/Issue URL: https://app.asana.com/0/0/1205641398750303/f Description: Changing the Xcode version and adjusting the code based on the SDK changes. --- .github/workflows/pr.yml | 2 +- Sources/Common/Extensions/URLExtension.swift | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index db793b253..c01c1f27b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -54,7 +54,7 @@ jobs: ${{ runner.os }}-spm- - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_14.3.app/Contents/Developer + run: sudo xcode-select -s /Applications/Xcode_15.0.app/Contents/Developer - name: Install xcbeautify continue-on-error: true diff --git a/Sources/Common/Extensions/URLExtension.swift b/Sources/Common/Extensions/URLExtension.swift index 9f4e25730..8739d1644 100644 --- a/Sources/Common/Extensions/URLExtension.swift +++ b/Sources/Common/Extensions/URLExtension.swift @@ -158,10 +158,26 @@ extension URL { s = scheme.separated() + s.dropFirst(scheme.separated().count - 1) } - if let url = URL(string: s) { +#if os(macOS) + let url: URL? + let urlWithScheme: URL? + if #available(macOS 14.0, *) { + // Making sure string is strictly valid according to the RFC + url = URL(string: s, encodingInvalidCharacters: false) + urlWithScheme = URL(string: NavigationalScheme.http.separated() + s, encodingInvalidCharacters: false) + } else { + url = URL(string: s) + urlWithScheme = URL(string: NavigationalScheme.http.separated() + s) + } +#else + let url = URL(string: s) + let urlWithScheme = URL(string: NavigationalScheme.http.separated() + s) +#endif + + if let url { // if URL has domain:port or user:password@domain mistakengly interpreted as a scheme if url.navigationalScheme != .mailto, - let urlWithScheme = URL(string: NavigationalScheme.http.separated() + s), + let urlWithScheme, urlWithScheme.port != nil || urlWithScheme.user != nil { // could be a local domain but user needs to use the protocol to specify that // make exception for "localhost" From 20ef8a4b3791a57d475cd848afdecb85fa010a5e Mon Sep 17 00:00:00 2001 From: David Harbage Date: Wed, 11 Oct 2023 10:08:37 -0400 Subject: [PATCH 02/31] bump C-S-S to 4.39.0 (#531) Task/Issue URL: https://app.asana.com/0/0/1205693429987634/f iOS PR: duckduckgo/iOS#2085 macOS PR: duckduckgo/macos-browser#1749 What kind of version bump will this require?: Minor Description: Upgrades to later C-S-S to pull in an element hiding fix for an issue that could potentially lead to some hiding rules not being applied. As per https://github.com/duckduckgo/content-scope-scripts/releases, this is the only change impacting iOS/macOS between 4.37.0 and 4.39.0. --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 74a23b98c..0c1f29246 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "74b6142c016be354144f28551de41b50c4864b1f", - "version" : "4.37.0" + "revision" : "aa279a3b006a0b1e009707311283c7fcaed24fb7", + "version" : "4.39.0" } }, { diff --git a/Package.swift b/Package.swift index 5de8252e1..05ee5a8c6 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,7 @@ let package = Package( .package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "1.2.1"), .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.2.0"), .package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "2.1.0"), - .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "4.37.0"), + .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "4.39.0"), .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "1.4.0"), .package(url: "https://github.com/httpswift/swifter.git", exact: "1.5.0"), .package(url: "https://github.com/duckduckgo/bloom_cpp.git", exact: "3.0.0"), From 7cd525e28fe6d38f5dd4df85658a4ee6c688eebc Mon Sep 17 00:00:00 2001 From: Dax Mobile <44842493+daxmobile@users.noreply.github.com> Date: Thu, 12 Oct 2023 07:02:53 +1100 Subject: [PATCH 03/31] Update autofill to 8.4.2 (#530) Co-authored-by: GioSensation --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 0c1f29246..46c243c63 100644 --- a/Package.resolved +++ b/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/duckduckgo-autofill.git", "state" : { - "revision" : "55466f10a339843cb79a27af62d5d61c030740b2", - "version" : "8.4.1" + "revision" : "6dd7d696d4e666cedb2f1890a46fe53615226646", + "version" : "8.4.2" } }, { diff --git a/Package.swift b/Package.swift index 05ee5a8c6..a10ae7fc0 100644 --- a/Package.swift +++ b/Package.swift @@ -31,7 +31,7 @@ let package = Package( .library(name: "SecureStorage", targets: ["SecureStorage"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "8.4.1"), + .package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "8.4.2"), .package(url: "https://github.com/duckduckgo/GRDB.swift.git", exact: "2.2.0"), .package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "1.2.1"), .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.2.0"), From 89cd93fe9e394f518131bc26d4a3d02c96ee07e6 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Thu, 12 Oct 2023 17:30:27 +0200 Subject: [PATCH 04/31] Ignore form factor specific favorites if they're received in Sync response (#529) Task/Issue URL: https://app.asana.com/0/414235014887631/1205688495536992/f Description: Until FFS favorites support is released, deliberately ignore any FFS favorites folders received in Sync response. --- .../internal/BookmarksResponseHandler.swift | 6 +- ...pecificFavoritesFoldersIgnoringTests.swift | 142 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 Tests/SyncDataProvidersTests/Bookmarks/FormFactorSpecificFavoritesFoldersIgnoringTests.swift diff --git a/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift b/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift index 51dab4d10..451178aec 100644 --- a/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift +++ b/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift @@ -23,6 +23,9 @@ import DDGSync import Foundation final class BookmarksResponseHandler { + // Before form-factor-specific favorites is supported, we deliberately ignore FFS folders. + static let ignoredFoldersUUIDs: Set = ["desktop_favorites_root", "mobile_favorites_root"] + let clientTimestamp: Date? let received: [SyncableBookmarkAdapter] let context: NSManagedObjectContext @@ -84,10 +87,11 @@ final class BookmarksResponseHandler { self.favoritesUUIDs = favoritesUUIDs let foldersWithoutParent = Set(parentFoldersToChildren.keys).subtracting(childrenToParents.keys) + .subtracting(Self.ignoredFoldersUUIDs) topLevelFoldersSyncables = foldersWithoutParent.compactMap { syncablesByUUID[$0] } bookmarkSyncablesWithoutParent = allUUIDs.subtracting(childrenToParents.keys) - .subtracting(foldersWithoutParent + [BookmarkEntity.Constants.favoritesFolderID]) + .subtracting(foldersWithoutParent + [BookmarkEntity.Constants.favoritesFolderID] + Self.ignoredFoldersUUIDs) .compactMap { syncablesByUUID[$0] } BookmarkEntity.fetchBookmarks(with: allReceivedIDs, in: context) diff --git a/Tests/SyncDataProvidersTests/Bookmarks/FormFactorSpecificFavoritesFoldersIgnoringTests.swift b/Tests/SyncDataProvidersTests/Bookmarks/FormFactorSpecificFavoritesFoldersIgnoringTests.swift new file mode 100644 index 000000000..9e0defdb0 --- /dev/null +++ b/Tests/SyncDataProvidersTests/Bookmarks/FormFactorSpecificFavoritesFoldersIgnoringTests.swift @@ -0,0 +1,142 @@ +// +// FormFactorSpecificFavoritesFoldersIgnoringTests.swift +// DuckDuckGo +// +// 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 +import Bookmarks +import BookmarksTestsUtils +import Common +import DDGSync +import Persistence +@testable import SyncDataProviders + +private extension Syncable { + static func desktopFavoritesFolder(favorites: [String]) -> Syncable { + .folder(id: "desktop_favorites_root", children: favorites) + } + + static func mobileFavoritesFolder(favorites: [String]) -> Syncable { + .folder(id: "mobile_favorites_root", children: favorites) + } +} + +final class FormFactorSpecificFavoritesFoldersIgnoringTests: BookmarksProviderTestsBase { + + func testThatDesktopFavoritesFolderIsIgnored() async throws { + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", isFavorite: true) + Bookmark(id: "2") + } + + let received: [Syncable] = [.desktopFavoritesFolder(favorites: ["1", "2"])] + + let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", isFavorite: true) + Bookmark(id: "2") + }) + } + + func testThatMobileFavoritesFolderIsIgnored() async throws { + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", isFavorite: true) + Bookmark(id: "2") + } + + let received: [Syncable] = [.mobileFavoritesFolder(favorites: ["1", "2"])] + + let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", isFavorite: true) + Bookmark(id: "2") + }) + } + + func testThatDesktopFavoritesFolderDoesNotAffectReceivedFavoritesFolder() async throws { + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", isFavorite: true) + Bookmark(id: "2") + } + + let received: [Syncable] = [ + .favoritesFolder(favorites: ["1", "2"]), + .desktopFavoritesFolder(favorites: ["1", "2"]) + ] + + let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", isFavorite: true) + Bookmark(id: "2", isFavorite: true) + }) + } + + func testThatMobileFavoritesFolderDoesNotAffectReceivedFavoritesFolder() async throws { + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", isFavorite: true) + Bookmark(id: "2") + } + + let received: [Syncable] = [ + .favoritesFolder(favorites: ["1", "2"]), + .mobileFavoritesFolder(favorites: ["1", "2"]) + ] + + let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", isFavorite: true) + Bookmark(id: "2", isFavorite: true) + }) + } + + // MARK: - Helpers + + func createEntitiesAndHandleSyncResponse( + with bookmarkTree: BookmarkTree, + sent: [Syncable] = [], + received: [Syncable], + clientTimestamp: Date = Date(), + serverTimestamp: String = "1234", + in context: NSManagedObjectContext + ) async throws -> BookmarkEntity { + + context.performAndWait { + BookmarkUtils.prepareFoldersStructure(in: context) + bookmarkTree.createEntities(in: context) + try! context.save() + } + + try await provider.handleSyncResponse(sent: sent, received: received, clientTimestamp: Date(), serverTimestamp: "1234", crypter: crypter) + + var rootFolder: BookmarkEntity! + + context.performAndWait { + context.refreshAllObjects() + rootFolder = BookmarkUtils.fetchRootFolder(context) + } + + return rootFolder + } +} From 4cf8e857cd78e15c64ba37839634970fc675947c Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Wed, 18 Oct 2023 10:19:17 +0200 Subject: [PATCH 05/31] Append build number to metricKit crash version (#534) * Append build number to crashes * Remove accidental import --- Sources/Crashes/CrashCollection.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Crashes/CrashCollection.swift b/Sources/Crashes/CrashCollection.swift index 8a32db66c..9fa65323b 100644 --- a/Sources/Crashes/CrashCollection.swift +++ b/Sources/Crashes/CrashCollection.swift @@ -41,7 +41,7 @@ public struct CrashCollection { .flatMap { $0 } .forEach { completion([ - "appVersion": $0.applicationVersion, + "appVersion": "\($0.applicationVersion).\($0.metaData.applicationBuildVersion)", "code": "\($0.exceptionCode ?? -1)", "type": "\($0.exceptionType ?? -1)", "signal": "\($0.signal ?? -1)" From 57d8ed94cf503fdbd348420e821df00142660333 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Thu, 19 Oct 2023 16:23:10 +0200 Subject: [PATCH 06/31] bump C-S-S for duckplayer on big sur (#538) (#540) Task/Issue URL: https://app.asana.com/0/1177771139624306/1205745530256000/f Description: Fix for Duck Player on BigSur - no other changes. --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 46c243c63..faa809b51 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "aa279a3b006a0b1e009707311283c7fcaed24fb7", - "version" : "4.39.0" + "revision" : "254b23cf292140498650421bb31fd05740f4579b", + "version" : "4.40.0" } }, { diff --git a/Package.swift b/Package.swift index a10ae7fc0..34891efc0 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,7 @@ let package = Package( .package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "1.2.1"), .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.2.0"), .package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "2.1.0"), - .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "4.39.0"), + .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "4.40.0"), .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "1.4.0"), .package(url: "https://github.com/httpswift/swifter.git", exact: "1.5.0"), .package(url: "https://github.com/duckduckgo/bloom_cpp.git", exact: "3.0.0"), From c427dc63421c4b394f54c245de8e5665bcd6966a Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Sun, 22 Oct 2023 08:44:54 +0200 Subject: [PATCH 07/31] Improved IPC support (#536) Task/Issue URL: https://app.asana.com/0/0/1205738275545702/f iOS PR: https://github.com/duckduckgo/iOS/pull/2101 macOS PR: https://github.com/duckduckgo/macos-browser/pull/1690 What kind of version bump will this require?: Major ## Description Offers improved IPC support in BSK. --- Package.swift | 3 + .../.xccurrentversion | 5 +- .../Controllers/TunnelController.swift | 4 + .../ExtensionMessage/ExtensionMessage.swift | 25 ++ .../ExtensionMessage/ExtensionRequest.swift | 29 +++ .../PacketTunnelProvider.swift | 115 +++++---- .../Session/ConnectionSessionUtilities.swift | 52 +++- .../UserDefaults+enforceRoutes.swift | 45 ++++ .../UserDefaults+excludeLocalNetworks.swift | 45 ++++ .../UserDefaults+includeAllNetworks.swift | 45 ++++ ...UserDefaults+registrationKeyValidity.swift | 72 ++++++ .../UserDefaults+selectedServer.swift | 72 ++++++ .../Settings/TunnelSettings.swift | 230 ++++++++++++++++++ .../NetworkProtection/StartupOptions.swift | 4 +- ...NetworkProtectionSelectedServerStore.swift | 18 +- .../Controllers/MockTunnelController.swift | 4 + 16 files changed, 700 insertions(+), 68 deletions(-) create mode 100644 Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift create mode 100644 Sources/NetworkProtection/Settings/Extensions/UserDefaults+enforceRoutes.swift create mode 100644 Sources/NetworkProtection/Settings/Extensions/UserDefaults+excludeLocalNetworks.swift create mode 100644 Sources/NetworkProtection/Settings/Extensions/UserDefaults+includeAllNetworks.swift create mode 100644 Sources/NetworkProtection/Settings/Extensions/UserDefaults+registrationKeyValidity.swift create mode 100644 Sources/NetworkProtection/Settings/Extensions/UserDefaults+selectedServer.swift create mode 100644 Sources/NetworkProtection/Settings/TunnelSettings.swift diff --git a/Package.swift b/Package.swift index 34891efc0..c9154ad9e 100644 --- a/Package.swift +++ b/Package.swift @@ -196,6 +196,9 @@ let package = Package( .target(name: "WireGuardC"), .product(name: "WireGuard", package: "wireguard-apple"), "Common" + ], + swiftSettings: [ + .define("DEBUG", .when(configuration: .debug)) ]), .target( name: "SecureStorage", diff --git a/Sources/BrowserServicesKit/SmarterEncryption/Store/HTTPSUpgrade.xcdatamodeld/.xccurrentversion b/Sources/BrowserServicesKit/SmarterEncryption/Store/HTTPSUpgrade.xcdatamodeld/.xccurrentversion index 7ae5865f4..0c67376eb 100644 --- a/Sources/BrowserServicesKit/SmarterEncryption/Store/HTTPSUpgrade.xcdatamodeld/.xccurrentversion +++ b/Sources/BrowserServicesKit/SmarterEncryption/Store/HTTPSUpgrade.xcdatamodeld/.xccurrentversion @@ -1,8 +1,5 @@ - - _XCCurrentVersionName - HTTPSUpgrade 3.xcdatamodel - + diff --git a/Sources/NetworkProtection/Controllers/TunnelController.swift b/Sources/NetworkProtection/Controllers/TunnelController.swift index d41e873f3..d95f1348c 100644 --- a/Sources/NetworkProtection/Controllers/TunnelController.swift +++ b/Sources/NetworkProtection/Controllers/TunnelController.swift @@ -31,4 +31,8 @@ public protocol TunnelController { /// Stops the VPN connection used for Network Protection /// func stop() async + + /// Whether the tunnel is connected + /// + var isConnected: Bool { get async } } diff --git a/Sources/NetworkProtection/ExtensionMessage/ExtensionMessage.swift b/Sources/NetworkProtection/ExtensionMessage/ExtensionMessage.swift index e749efff5..57b478cfa 100644 --- a/Sources/NetworkProtection/ExtensionMessage/ExtensionMessage.swift +++ b/Sources/NetworkProtection/ExtensionMessage/ExtensionMessage.swift @@ -22,6 +22,11 @@ public enum ExtensionMessage: RawRepresentable { public typealias RawValue = Data enum Name: UInt8 { + // This is actually an improved way to send messages. + // Please avoid adding new messages to this enum, and instead + // add them to `ExtensionRequest` + case request = 255 + case resetAllState = 0 case getRuntimeConfiguration case getLastErrorMessage @@ -40,6 +45,11 @@ public enum ExtensionMessage: RawRepresentable { case simulateConnectionInterruption } + // This is actually an improved way to send messages. + // Please avoid adding new messages to this enum, and instead + // add them to `ExtensionRequest` + case request(_ request: ExtensionRequest) + // important: Preserve this order because Message Name is represented by Int value case resetAllState case getRuntimeConfiguration @@ -62,6 +72,12 @@ public enum ExtensionMessage: RawRepresentable { public init?(rawValue data: Data) { let name = data.first.flatMap(Name.init(rawValue:)) switch name { + case .request: + guard let request = try? JSONDecoder().decode(ExtensionRequest.self, from: data[1...]) else { + return nil + } + + self = .request(request) case .resetAllState: self = .resetAllState case .getRuntimeConfiguration: @@ -127,6 +143,7 @@ public enum ExtensionMessage: RawRepresentable { // TO BE: Replaced with auto case name generating Macro when Xcode 15 private var name: Name { switch self { + case .request: return .request case .resetAllState: return .resetAllState case .getRuntimeConfiguration: return .getRuntimeConfiguration case .getLastErrorMessage: return .getLastErrorMessage @@ -149,6 +166,14 @@ public enum ExtensionMessage: RawRepresentable { public var rawValue: Data { var encoder: (inout Data) -> Void = { _ in } switch self { + case .request(let request): + encoder = { + do { + try $0.append(JSONEncoder().encode(request)) + } catch { + assertionFailure("could not encode request: \(error)") + } + } case .setSelectedServer(.some(let serverName)): encoder = { $0.append(ExtensionMessageString(serverName).rawValue) diff --git a/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift b/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift new file mode 100644 index 000000000..06f016f83 --- /dev/null +++ b/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift @@ -0,0 +1,29 @@ +// +// ExtensionRequest.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public enum DebugCommand: Codable { + case expireRegistrationKey + case sendTestNotification +} + +public enum ExtensionRequest: Codable { + case changeTunnelSetting(_ change: TunnelSettings.Change) + case debugCommand(_ command: DebugCommand) +} diff --git a/Sources/NetworkProtection/PacketTunnelProvider.swift b/Sources/NetworkProtection/PacketTunnelProvider.swift index 49e7b5976..0a7006ad8 100644 --- a/Sources/NetworkProtection/PacketTunnelProvider.swift +++ b/Sources/NetworkProtection/PacketTunnelProvider.swift @@ -111,9 +111,11 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { return self.protocolConfiguration.enforceRoutes || self.protocolConfiguration.includeAllNetworks } - // MARK: - Server Selection + // MARK: - Tunnel Settings + + private let settings = TunnelSettings(defaults: .standard) - let selectedServerStore = NetworkProtectionSelectedServerUserDefaultsStore() + // MARK: - Server Selection public var lastSelectedServerInfo: NetworkProtectionServerInfo? { didSet { @@ -137,25 +139,6 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private let tokenStore: NetworkProtectionTokenStore - /// This is for overriding the defaults. A `nil` value means NetP will just use the defaults. - /// - private var keyValidity: TimeInterval? - - private static let defaultRetryInterval: TimeInterval = .minutes(1) - - /// Normally we'll retry using the default interval, but since we can override the key validity interval for testing purposes - /// we'll retry sooner if it's been overridden with values lower than the default retry interval. - /// - /// In practical terms this means that if the validity interval is 15 secs, the retry will also be 15 secs instead of 1 minute. - /// - private var retryInterval: TimeInterval { - guard let keyValidity = keyValidity else { - return Self.defaultRetryInterval - } - - return keyValidity > Self.defaultRetryInterval ? Self.defaultRetryInterval : keyValidity - } - private func resetRegistrationKey() { os_log("Resetting the current registration key", log: .networkProtectionKeyManagement) keyStore.resetCurrentKeyPair() @@ -182,17 +165,13 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { self.resetRegistrationKey() do { - try await updateTunnelConfiguration(selectedServer: selectedServerStore.selectedServer, reassert: false) + try await updateTunnelConfiguration(selectedServer: settings.selectedServer, reassert: false) } catch { os_log("Rekey attempt failed. This is not an error if you're using debug Key Management options: %{public}@", log: .networkProtectionKeyManagement, type: .error, String(describing: error)) } } private func setKeyValidity(_ interval: TimeInterval?) { - guard keyValidity != interval else { - return - } - if let interval { let firstExpirationDate = Date().addingTimeInterval(interval) @@ -200,9 +179,13 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { log: .networkProtectionKeyManagement, String(describing: interval), String(describing: firstExpirationDate)) + + settings.registrationKeyValidity = .custom(interval) } else { os_log("Resetting key validity interval", log: .networkProtectionKeyManagement) + + settings.registrationKeyValidity = .automatic } keyStore.setValidityInterval(interval) @@ -387,11 +370,11 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func loadSelectedServer(from options: StartupOptions) { switch options.selectedServer { case .set(let selectedServer): - selectedServerStore.selectedServer = selectedServer + settings.selectedServer = selectedServer case .useExisting: break case .reset: - selectedServerStore.selectedServer = .automatic + settings.selectedServer = .automatic } } @@ -491,15 +474,15 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { let onDemand = options.startupMethod == .automaticOnDemand os_log("Starting tunnel %{public}@", log: .networkProtection, options.startupMethod.debugDescription) - startTunnel(selectedServer: selectedServerStore.selectedServer, onDemand: onDemand, completionHandler: completionHandler) + startTunnel(selectedServer: settings.selectedServer, onDemand: onDemand, completionHandler: completionHandler) } - private func startTunnel(selectedServer: SelectedNetworkProtectionServer, onDemand: Bool, completionHandler: @escaping (Error?) -> Void) { + private func startTunnel(selectedServer: TunnelSettings.SelectedServer, onDemand: Bool, completionHandler: @escaping (Error?) -> Void) { Task { let serverSelectionMethod: NetworkProtectionServerSelectionMethod - switch selectedServerStore.selectedServer { + switch settings.selectedServer { case .automatic: serverSelectionMethod = .automatic case .endpoint(let serverName): @@ -633,10 +616,10 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // MARK: - Tunnel Configuration - public func updateTunnelConfiguration(selectedServer: SelectedNetworkProtectionServer, reassert: Bool = true) async throws { + public func updateTunnelConfiguration(selectedServer: TunnelSettings.SelectedServer, reassert: Bool = true) async throws { let serverSelectionMethod: NetworkProtectionServerSelectionMethod - switch selectedServerStore.selectedServer { + switch settings.selectedServer { case .automatic: serverSelectionMethod = .automatic case .endpoint(let serverName): @@ -717,6 +700,8 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } switch message { + case .request(let request): + handleRequest(request) case .expireRegistrationKey: handleExpireRegistrationKey(completionHandler: completionHandler) case .getLastErrorMessage: @@ -736,7 +721,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { case .resetAllState: handleResetAllState(completionHandler: completionHandler) case .triggerTestNotification: - handleTriggerTestNotification(completionHandler: completionHandler) + handleSendTestNotification(completionHandler: completionHandler) case .setExcludedRoutes(let excludedRoutes): setExcludedRoutes(excludedRoutes, completionHandler: completionHandler) case .setIncludedRoutes(let includedRoutes): @@ -752,6 +737,54 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } } + // MARK: - App Requests: Handling + + private func handleRequest(_ request: ExtensionRequest, completionHandler: ((Data?) -> Void)? = nil) { + switch request { + case .changeTunnelSetting(let change): + handleSettingsChange(change, completionHandler: completionHandler) + case .debugCommand(let command): + handleDebugCommand(command, completionHandler: completionHandler) + } + } + + private func handleSettingsChange(_ change: TunnelSettings.Change, completionHandler: ((Data?) -> Void)? = nil) { + + settings.apply(change: change) + + switch change { + case .setSelectedServer(let selectedServer): + let serverSelectionMethod: NetworkProtectionServerSelectionMethod + + switch selectedServer { + case .automatic: + serverSelectionMethod = .automatic + case .endpoint(let serverName): + serverSelectionMethod = .preferredServer(serverName: serverName) + } + + Task { + try? await updateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod) + completionHandler?(nil) + } + case .setIncludeAllNetworks, + .setEnforceRoutes, + .setExcludeLocalNetworks, + .setRegistrationKeyValidity: + // Intentional no-op, as some setting changes don't require any further operation + break + } + } + + private func handleDebugCommand(_ command: DebugCommand, completionHandler: ((Data?) -> Void)? = nil) { + switch command { + case .expireRegistrationKey: + handleExpireRegistrationKey(completionHandler: completionHandler) + case .sendTestNotification: + handleSendTestNotification(completionHandler: completionHandler) + } + } + // MARK: - App Messages: Handling private func handleExpireRegistrationKey(completionHandler: ((Data?) -> Void)? = nil) { @@ -794,20 +827,20 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func handleSetSelectedServer(_ serverName: String?, completionHandler: ((Data?) -> Void)? = nil) { Task { guard let serverName else { - if case .endpoint = selectedServerStore.selectedServer { - selectedServerStore.selectedServer = .automatic + if case .endpoint = settings.selectedServer { + settings.selectedServer = .automatic try? await updateTunnelConfiguration(serverSelectionMethod: .automatic) } completionHandler?(nil) return } - guard selectedServerStore.selectedServer.stringValue != serverName else { + guard settings.selectedServer.stringValue != serverName else { completionHandler?(nil) return } - selectedServerStore.selectedServer = .endpoint(serverName) + settings.selectedServer = .endpoint(serverName) try? await updateTunnelConfiguration(serverSelectionMethod: .preferredServer(serverName: serverName)) completionHandler?(nil) } @@ -830,7 +863,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } } - private func handleTriggerTestNotification(completionHandler: ((Data?) -> Void)? = nil) { + private func handleSendTestNotification(completionHandler: ((Data?) -> Void)? = nil) { notificationsPresenter.showTestNotification() completionHandler?(nil) } @@ -838,7 +871,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func setExcludedRoutes(_ excludedRoutes: [IPAddressRange], completionHandler: ((Data?) -> Void)? = nil) { Task { self.excludedRoutes = excludedRoutes - try? await updateTunnelConfiguration(selectedServer: selectedServerStore.selectedServer, reassert: false) + try? await updateTunnelConfiguration(selectedServer: settings.selectedServer, reassert: false) completionHandler?(nil) } } @@ -846,7 +879,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func setIncludedRoutes(_ includedRoutes: [IPAddressRange], completionHandler: ((Data?) -> Void)? = nil) { Task { self.includedRoutes = includedRoutes - try? await updateTunnelConfiguration(selectedServer: selectedServerStore.selectedServer, reassert: false) + try? await updateTunnelConfiguration(selectedServer: settings.selectedServer, reassert: false) completionHandler?(nil) } } diff --git a/Sources/NetworkProtection/Session/ConnectionSessionUtilities.swift b/Sources/NetworkProtection/Session/ConnectionSessionUtilities.swift index d446c81dd..951c6a2bb 100644 --- a/Sources/NetworkProtection/Session/ConnectionSessionUtilities.swift +++ b/Sources/NetworkProtection/Session/ConnectionSessionUtilities.swift @@ -22,6 +22,24 @@ import NetworkExtension /// These are only usable from the App that owns the tunnel. /// public class ConnectionSessionUtilities { + public static func activeSession(networkExtensionBundleID: String) async throws -> NETunnelProviderSession? { + let managers = try await NETunnelProviderManager.loadAllFromPreferences() + + guard let manager = managers.first(where: { + ($0.protocolConfiguration as? NETunnelProviderProtocol)?.providerBundleIdentifier == networkExtensionBundleID + }) else { + // No active connection, this is acceptable + return nil + } + + guard let session = manager.connection as? NETunnelProviderSession else { + // The active connection is not running, so there's no session, this is acceptable + return nil + } + + return session + } + public static func activeSession() async throws -> NETunnelProviderSession? { let managers = try await NETunnelProviderManager.loadAllFromPreferences() @@ -53,6 +71,13 @@ public extension NETunnelProviderSession { // MARK: - ExtensionMessage + func sendProviderMessage(_ message: ExtensionMessage, + responseHandler: @escaping () -> Void) throws { + try sendProviderMessage(message.rawValue) { _ in + responseHandler() + } + } + func sendProviderMessage(_ message: ExtensionMessage, responseHandler: @escaping (T?) -> Void) throws where T.RawValue == Data { try sendProviderMessage(message.rawValue) { response in @@ -60,11 +85,20 @@ public extension NETunnelProviderSession { } } - func sendProviderMessage(_ message: ExtensionMessage) async throws -> T? where T.RawValue == Data { + func sendProviderRequest(_ request: ExtensionRequest) async throws { + try await sendProviderMessage(.request(request)) + } + + func sendProviderRequest(_ request: ExtensionRequest) async throws -> T? where T.RawValue == Data { + + try await sendProviderMessage(.request(request)) + } + + func sendProviderMessage(_ message: ExtensionMessage) async throws { try await withCheckedThrowingContinuation { continuation in do { - try sendProviderMessage(message) { response in - continuation.resume(returning: response) + try sendProviderMessage(message) { + continuation.resume() } } catch { continuation.resume(throwing: error) @@ -72,9 +106,15 @@ public extension NETunnelProviderSession { } } - func sendProviderMessage(_ message: ExtensionMessage, completionHandler: (() -> Void)? = nil) throws { - try sendProviderMessage(message.rawValue) { _ in - completionHandler?() + func sendProviderMessage(_ message: ExtensionMessage) async throws -> T? where T.RawValue == Data { + try await withCheckedThrowingContinuation { continuation in + do { + try sendProviderMessage(message) { response in + continuation.resume(returning: response) + } + } catch { + continuation.resume(throwing: error) + } } } } diff --git a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+enforceRoutes.swift b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+enforceRoutes.swift new file mode 100644 index 000000000..553f8625a --- /dev/null +++ b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+enforceRoutes.swift @@ -0,0 +1,45 @@ +// +// UserDefaults+enforceRoutes.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +extension UserDefaults { + private var enforceRoutesKey: String { + "networkProtectionSettingEnforceRoutes" + } + + @objc + dynamic var networkProtectionSettingEnforceRoutes: Bool { + get { + bool(forKey: enforceRoutesKey) + } + + set { + set(newValue, forKey: enforceRoutesKey) + } + } + + var networkProtectionSettingEnforceRoutesPublisher: AnyPublisher { + publisher(for: \.networkProtectionSettingEnforceRoutes).eraseToAnyPublisher() + } + + func resetNetworkProtectionSettingEnforceRoutes() { + removeObject(forKey: enforceRoutesKey) + } +} diff --git a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+excludeLocalNetworks.swift b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+excludeLocalNetworks.swift new file mode 100644 index 000000000..75df3458d --- /dev/null +++ b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+excludeLocalNetworks.swift @@ -0,0 +1,45 @@ +// +// UserDefaults+excludeLocalNetworks.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +extension UserDefaults { + private var excludeLocalNetworksKey: String { + "networkProtectionSettingExcludeLocalNetworks" + } + + @objc + dynamic var networkProtectionSettingExcludeLocalNetworks: Bool { + get { + bool(forKey: excludeLocalNetworksKey) + } + + set { + set(newValue, forKey: excludeLocalNetworksKey) + } + } + + var networkProtectionSettingExcludeLocalNetworksPublisher: AnyPublisher { + publisher(for: \.networkProtectionSettingExcludeLocalNetworks).eraseToAnyPublisher() + } + + func resetNetworkProtectionSettingExcludeLocalNetworks() { + removeObject(forKey: excludeLocalNetworksKey) + } +} diff --git a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+includeAllNetworks.swift b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+includeAllNetworks.swift new file mode 100644 index 000000000..4178af108 --- /dev/null +++ b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+includeAllNetworks.swift @@ -0,0 +1,45 @@ +// +// UserDefaults+includeAllNetworks.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +extension UserDefaults { + private var includeAllNetworksKey: String { + "networkProtectionSettingIncludeAllNetworks" + } + + @objc + dynamic var networkProtectionSettingIncludeAllNetworks: Bool { + get { + bool(forKey: includeAllNetworksKey) + } + + set { + set(newValue, forKey: includeAllNetworksKey) + } + } + + var networkProtectionSettingIncludeAllNetworksPublisher: AnyPublisher { + publisher(for: \.networkProtectionSettingIncludeAllNetworks).eraseToAnyPublisher() + } + + func resetNetworkProtectionSettingIncludeAllNetworks() { + removeObject(forKey: includeAllNetworksKey) + } +} diff --git a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+registrationKeyValidity.swift b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+registrationKeyValidity.swift new file mode 100644 index 000000000..63ca7ddc8 --- /dev/null +++ b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+registrationKeyValidity.swift @@ -0,0 +1,72 @@ +// +// UserDefaults+registrationKeyValidity.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +extension UserDefaults { + private var registrationKeyValidityKey: String { + "networkProtectionSettingRegistrationKeyValidityRawValue" + } + + @objc + dynamic var networkProtectionSettingRegistrationKeyValidityRawValue: NSNumber? { + get { + value(forKey: registrationKeyValidityKey) as? NSNumber + } + + set { + set(newValue, forKey: registrationKeyValidityKey) + } + } + + private func registrationKeyValidityFromRawValue(_ rawValue: NSNumber?) -> TunnelSettings.RegistrationKeyValidity { + guard let timeInterval = networkProtectionSettingRegistrationKeyValidityRawValue?.doubleValue else { + return .automatic + } + + return .custom(timeInterval) + } + + var networkProtectionSettingRegistrationKeyValidity: TunnelSettings.RegistrationKeyValidity { + get { + registrationKeyValidityFromRawValue(networkProtectionSettingRegistrationKeyValidityRawValue) + } + + set { + switch newValue { + case .automatic: + networkProtectionSettingRegistrationKeyValidityRawValue = nil + case .custom(let timeInterval): + networkProtectionSettingRegistrationKeyValidityRawValue = NSNumber(value: timeInterval) + } + } + } + + var networkProtectionSettingRegistrationKeyValidityPublisher: AnyPublisher { + let registrationKeyValidityFromRawValue = self.registrationKeyValidityFromRawValue + + return publisher(for: \.networkProtectionSettingRegistrationKeyValidityRawValue).map { serverName in + registrationKeyValidityFromRawValue(serverName) + }.eraseToAnyPublisher() + } + + func resetNetworkProtectionSettingRegistrationKeyValidity() { + networkProtectionSettingRegistrationKeyValidityRawValue = nil + } +} diff --git a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+selectedServer.swift b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+selectedServer.swift new file mode 100644 index 000000000..4c2ad0246 --- /dev/null +++ b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+selectedServer.swift @@ -0,0 +1,72 @@ +// +// UserDefaults+selectedServer.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +extension UserDefaults { + private var selectedServerKey: String { + "networkProtectionSettingSelectedServerRawValue" + } + + @objc + dynamic var networkProtectionSettingSelectedServerRawValue: String? { + get { + value(forKey: selectedServerKey) as? String + } + + set { + set(newValue, forKey: selectedServerKey) + } + } + + private func selectedServerFromRawValue(_ rawValue: String?) -> TunnelSettings.SelectedServer { + guard let selectedEndpoint = networkProtectionSettingSelectedServerRawValue else { + return .automatic + } + + return .endpoint(selectedEndpoint) + } + + var networkProtectionSettingSelectedServer: TunnelSettings.SelectedServer { + get { + selectedServerFromRawValue(networkProtectionSettingSelectedServerRawValue) + } + + set { + switch newValue { + case .automatic: + networkProtectionSettingSelectedServerRawValue = nil + case .endpoint(let serverName): + networkProtectionSettingSelectedServerRawValue = serverName + } + } + } + + var networkProtectionSettingSelectedServerPublisher: AnyPublisher { + let selectedServerFromRawValue = self.selectedServerFromRawValue + + return publisher(for: \.networkProtectionSettingSelectedServerRawValue).map { serverName in + selectedServerFromRawValue(serverName) + }.eraseToAnyPublisher() + } + + func resetNetworkProtectionSettingSelectedServer() { + networkProtectionSettingSelectedServerRawValue = nil + } +} diff --git a/Sources/NetworkProtection/Settings/TunnelSettings.swift b/Sources/NetworkProtection/Settings/TunnelSettings.swift new file mode 100644 index 000000000..69871318f --- /dev/null +++ b/Sources/NetworkProtection/Settings/TunnelSettings.swift @@ -0,0 +1,230 @@ +// +// TunnelSettings.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +/// Persists and publishes changes to tunnel settings. +/// +/// It's strongly recommended to use shared `UserDefaults` to initialize this class, as `TunnelSettingsUpdater` +/// can then detect settings changes using KVO even if they're applied by a different process or even by the user through +/// the command line. +/// +public final class TunnelSettings { + + public enum Change: Codable { + case setIncludeAllNetworks(_ includeAllNetworks: Bool) + case setEnforceRoutes(_ enforceRoutes: Bool) + case setExcludeLocalNetworks(_ excludeLocalNetworks: Bool) + case setRegistrationKeyValidity(_ validity: RegistrationKeyValidity) + case setSelectedServer(_ selectedServer: SelectedServer) + } + + public enum RegistrationKeyValidity: Codable { + case automatic + case custom(_ timeInterval: TimeInterval) + } + + public enum SelectedServer: Codable, Equatable { + case automatic + case endpoint(String) + + public var stringValue: String? { + switch self { + case .automatic: return nil + case .endpoint(let endpoint): return endpoint + } + } + } + + private let defaults: UserDefaults + + private(set) public lazy var changePublisher: AnyPublisher = { + + let includeAllNetworksPublisher = includeAllNetworksPublisher.map { includeAllNetworks in + Change.setIncludeAllNetworks(includeAllNetworks) + }.eraseToAnyPublisher() + + let enforceRoutesPublisher = enforceRoutesPublisher.map { enforceRoutes in + Change.setEnforceRoutes(enforceRoutes) + }.eraseToAnyPublisher() + + let excludeLocalNetworksPublisher = excludeLocalNetworksPublisher.map { excludeLocalNetworks in + Change.setExcludeLocalNetworks(excludeLocalNetworks) + }.eraseToAnyPublisher() + + let registrationKeyValidityPublisher = registrationKeyValidityPublisher.map { validity in + Change.setRegistrationKeyValidity(validity) + }.eraseToAnyPublisher() + + let serverChangePublisher = selectedServerPublisher.map { server in + Change.setSelectedServer(server) + }.eraseToAnyPublisher() + + return Publishers.MergeMany( + includeAllNetworksPublisher, + enforceRoutesPublisher, + excludeLocalNetworksPublisher, + serverChangePublisher).eraseToAnyPublisher() + }() + + public init(defaults: UserDefaults) { + self.defaults = defaults + } + + // MARK: - Resetting to Defaults + + public func resetToDefaults() { + defaults.resetNetworkProtectionSettingEnforceRoutes() + defaults.resetNetworkProtectionSettingExcludeLocalNetworks() + defaults.resetNetworkProtectionSettingIncludeAllNetworks() + defaults.resetNetworkProtectionSettingRegistrationKeyValidity() + defaults.resetNetworkProtectionSettingSelectedServer() + } + + // MARK: - Applying Changes + + public func apply(change: Change) { + switch change { + case .setEnforceRoutes(let enforceRoutes): + self.enforceRoutes = enforceRoutes + case .setExcludeLocalNetworks(let excludeLocalNetworks): + self.excludeLocalNetworks = excludeLocalNetworks + case .setIncludeAllNetworks(let includeAllNetworks): + self.includeAllNetworks = includeAllNetworks + case .setRegistrationKeyValidity(let registrationKeyValidity): + self.registrationKeyValidity = registrationKeyValidity + case .setSelectedServer(let selectedServer): + self.selectedServer = selectedServer + } + } + + // MARK: - Enforce Routes + + public var includeAllNetworksPublisher: AnyPublisher { + defaults.networkProtectionSettingIncludeAllNetworksPublisher + } + + public var includeAllNetworks: Bool { + get { + defaults.networkProtectionSettingIncludeAllNetworks + } + + set { + defaults.networkProtectionSettingIncludeAllNetworks = newValue + } + } + + // MARK: - Enforce Routes + + public var enforceRoutesPublisher: AnyPublisher { + defaults.networkProtectionSettingEnforceRoutesPublisher + } + + public var enforceRoutes: Bool { + get { + defaults.networkProtectionSettingEnforceRoutes + } + + set { + defaults.networkProtectionSettingEnforceRoutes = newValue + } + } + + // MARK: - Exclude Local Routes + + public var excludeLocalNetworksPublisher: AnyPublisher { + defaults.networkProtectionSettingExcludeLocalNetworksPublisher + } + + public var excludeLocalNetworks: Bool { + get { + defaults.networkProtectionSettingExcludeLocalNetworks + } + + set { + defaults.networkProtectionSettingExcludeLocalNetworks = newValue + } + } + + // MARK: - Registration Key Validity + + public var registrationKeyValidityPublisher: AnyPublisher { + defaults.networkProtectionSettingRegistrationKeyValidityPublisher + } + + public var registrationKeyValidity: RegistrationKeyValidity { + get { + defaults.networkProtectionSettingRegistrationKeyValidity + } + + set { + defaults.networkProtectionSettingRegistrationKeyValidity = newValue + } + } + + private var networkProtectionSettingRegistrationKeyValidityDefault: TimeInterval { + .days(2) + } + + // MARK: - Server Selection + + public var selectedServerPublisher: AnyPublisher { + defaults.networkProtectionSettingSelectedServerPublisher + } + + public var selectedServer: SelectedServer { + get { + defaults.networkProtectionSettingSelectedServer + } + + set { + defaults.networkProtectionSettingSelectedServer = newValue + } + } + + // MARK: - Routes + + public enum ExclusionListItem { + case section(String) + case exclusion(range: NetworkProtection.IPAddressRange, description: String? = nil, `default`: Bool) + } + + public let exclusionList: [ExclusionListItem] = [ + .section("IPv4 Local Routes"), + + .exclusion(range: "10.0.0.0/8" /* 255.0.0.0 */, description: "disabled for enforceRoutes", default: true), + .exclusion(range: "172.16.0.0/12" /* 255.240.0.0 */, default: true), + .exclusion(range: "192.168.0.0/16" /* 255.255.0.0 */, default: true), + .exclusion(range: "169.254.0.0/16" /* 255.255.0.0 */, description: "Link-local", default: true), + .exclusion(range: "127.0.0.0/8" /* 255.0.0.0 */, description: "Loopback", default: true), + .exclusion(range: "224.0.0.0/4" /* 240.0.0.0 (corrected subnet mask) */, description: "Multicast", default: true), + .exclusion(range: "100.64.0.0/16" /* 255.255.0.0 */, description: "Shared Address Space", default: true), + + .section("IPv6 Local Routes"), + .exclusion(range: "fe80::/10", description: "link local", default: false), + .exclusion(range: "ff00::/8", description: "multicast", default: false), + .exclusion(range: "fc00::/7", description: "local unicast", default: false), + .exclusion(range: "::1/128", description: "loopback", default: false), + + .section("duckduckgo.com"), + .exclusion(range: "52.142.124.215/32", default: false), + .exclusion(range: "52.250.42.157/32", default: false), + .exclusion(range: "40.114.177.156/32", default: false), + ] +} diff --git a/Sources/NetworkProtection/StartupOptions.swift b/Sources/NetworkProtection/StartupOptions.swift index dff73aa0e..aa8cff895 100644 --- a/Sources/NetworkProtection/StartupOptions.swift +++ b/Sources/NetworkProtection/StartupOptions.swift @@ -94,7 +94,7 @@ struct StartupOptions { let simulateCrash: Bool let simulateMemoryCrash: Bool let keyValidity: StoredOption - let selectedServer: StoredOption + let selectedServer: StoredOption let authToken: StoredOption let enableTester: StoredOption @@ -150,7 +150,7 @@ struct StartupOptions { } } - private static func readSelectedServer(from options: [String: Any], resetIfNil: Bool) -> StoredOption { + private static func readSelectedServer(from options: [String: Any], resetIfNil: Bool) -> StoredOption { StoredOption(resetIfNil: resetIfNil) { guard let serverName = options[NetworkProtectionOptionKey.selectedServer] as? String else { diff --git a/Sources/NetworkProtection/Storage/NetworkProtectionSelectedServerStore.swift b/Sources/NetworkProtection/Storage/NetworkProtectionSelectedServerStore.swift index 2f6da4f8b..d93197465 100644 --- a/Sources/NetworkProtection/Storage/NetworkProtectionSelectedServerStore.swift +++ b/Sources/NetworkProtection/Storage/NetworkProtectionSelectedServerStore.swift @@ -17,25 +17,13 @@ // import Foundation - +/* protocol NetworkProtectionSelectedServerStore: AnyObject { - + var selectedServer: SelectedNetworkProtectionServer { get set } func reset() } -public enum SelectedNetworkProtectionServer: Equatable { - case automatic - case endpoint(String) - - public var stringValue: String? { - switch self { - case .automatic: return nil - case .endpoint(let endpoint): return endpoint - } - } -} - public final class NetworkProtectionSelectedServerUserDefaultsStore: NetworkProtectionSelectedServerStore { private enum Constants { @@ -77,4 +65,4 @@ public final class NetworkProtectionSelectedServerUserDefaultsStore: NetworkProt userDefaults.removeObject(forKey: Constants.selectedServerKey) } -} +}*/ diff --git a/Sources/NetworkProtectionTestUtils/Controllers/MockTunnelController.swift b/Sources/NetworkProtectionTestUtils/Controllers/MockTunnelController.swift index 5ca2b42cc..bb36af2a2 100644 --- a/Sources/NetworkProtectionTestUtils/Controllers/MockTunnelController.swift +++ b/Sources/NetworkProtectionTestUtils/Controllers/MockTunnelController.swift @@ -32,4 +32,8 @@ public final class MockTunnelController: TunnelController { public func stop() async { didCallStop = true } + + public var isConnected: Bool { + true + } } From dd595d952e0076a7a01d086ed2424838dcd985af Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Mon, 23 Oct 2023 12:09:22 +0100 Subject: [PATCH 08/31] feat(dashboard): updating feedback form (#533) * new privacy dashboard * exact * lint * released dashboard version --------- Co-authored-by: Shane Osbourne --- Package.resolved | 4 ++-- Package.swift | 2 +- .../PrivacyDashboardController.swift | 6 ++--- .../PrivacyDashboardUserScript.swift | 24 +++++++++++++++---- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/Package.resolved b/Package.resolved index faa809b51..f8e42a9d9 100644 --- a/Package.resolved +++ b/Package.resolved @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/privacy-dashboard", "state" : { - "revision" : "51e2b46f413bf3ef18afefad631ca70f2c25ef70", - "version" : "1.4.0" + "revision" : "b4ac92a444e79d5651930482623b9f6dc9265667", + "version" : "2.0.0" } }, { diff --git a/Package.swift b/Package.swift index c9154ad9e..ccc29c9c9 100644 --- a/Package.swift +++ b/Package.swift @@ -37,7 +37,7 @@ let package = Package( .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.2.0"), .package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "2.1.0"), .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "4.40.0"), - .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "1.4.0"), + .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "2.0.0"), .package(url: "https://github.com/httpswift/swifter.git", exact: "1.5.0"), .package(url: "https://github.com/duckduckgo/bloom_cpp.git", exact: "3.0.0"), .package(url: "https://github.com/duckduckgo/wireguard-apple", exact: "1.1.1") diff --git a/Sources/PrivacyDashboard/PrivacyDashboardController.swift b/Sources/PrivacyDashboard/PrivacyDashboardController.swift index aefd7452d..41cb6e3eb 100644 --- a/Sources/PrivacyDashboard/PrivacyDashboardController.swift +++ b/Sources/PrivacyDashboard/PrivacyDashboardController.swift @@ -27,7 +27,7 @@ public enum PrivacyDashboardOpenSettingsTarget: String { } public protocol PrivacyDashboardControllerDelegate: AnyObject { - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didChangeProtectionSwitch isEnabled: Bool) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didChangeProtectionSwitch protectionState: ProtectionState) func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didRequestOpenUrlInNewTab url: URL) func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didRequestOpenSettings target: PrivacyDashboardOpenSettingsTarget) @@ -239,8 +239,8 @@ extension PrivacyDashboardController: PrivacyDashboardUserScriptDelegate { delegate?.privacyDashboardController(self, didRequestOpenSettings: settingsTarget) } - func userScript(_ userScript: PrivacyDashboardUserScript, didChangeProtectionStateTo isProtected: Bool) { - delegate?.privacyDashboardController(self, didChangeProtectionSwitch: isProtected) + func userScript(_ userScript: PrivacyDashboardUserScript, didChangeProtectionState protectionState: ProtectionState) { + delegate?.privacyDashboardController(self, didChangeProtectionSwitch: protectionState) } func userScript(_ userScript: PrivacyDashboardUserScript, didRequestOpenUrlInNewTab url: URL) { diff --git a/Sources/PrivacyDashboard/UserScript/PrivacyDashboardUserScript.swift b/Sources/PrivacyDashboard/UserScript/PrivacyDashboardUserScript.swift index d062bf373..77b9c5c5e 100644 --- a/Sources/PrivacyDashboard/UserScript/PrivacyDashboardUserScript.swift +++ b/Sources/PrivacyDashboard/UserScript/PrivacyDashboardUserScript.swift @@ -20,9 +20,10 @@ import Foundation import WebKit import TrackerRadarKit import UserScript +import Common protocol PrivacyDashboardUserScriptDelegate: AnyObject { - func userScript(_ userScript: PrivacyDashboardUserScript, didChangeProtectionStateTo protectionState: Bool) + func userScript(_ userScript: PrivacyDashboardUserScript, didChangeProtectionState protectionState: ProtectionState) func userScript(_ userScript: PrivacyDashboardUserScript, setHeight height: Int) func userScriptDidRequestClosing(_ userScript: PrivacyDashboardUserScript) func userScriptDidRequestShowReportBrokenSite(_ userScript: PrivacyDashboardUserScript) @@ -38,6 +39,20 @@ public enum PrivacyDashboardTheme: String, Encodable { case dark } +public struct ProtectionState: Decodable { + public let isProtected: Bool + public let eventOrigin: EventOrigin + + public struct EventOrigin: Decodable { + public let screen: EventOriginScreen + } + + public enum EventOriginScreen: String, Decodable { + case primaryScreen + case breakageForm + } +} + final class PrivacyDashboardUserScript: NSObject, StaticUserScript { enum MessageNames: String, CaseIterable { @@ -91,12 +106,13 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { // MARK: - JS message handlers private func handleSetProtection(message: WKScriptMessage) { - guard let isProtected = message.body as? Bool else { - assertionFailure("privacyDashboardSetProtection: expected Bool") + + guard let protectionState: ProtectionState = DecodableHelper.decode(from: message.messageBody) else { + assertionFailure("privacyDashboardSetProtection: expected ProtectionState") return } - delegate?.userScript(self, didChangeProtectionStateTo: isProtected) + delegate?.userScript(self, didChangeProtectionState: protectionState) } private func handleSetSize(message: WKScriptMessage) { From 02970cc544a4b90682a354e6273ee0f1c0f7e667 Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Mon, 23 Oct 2023 13:12:25 +0100 Subject: [PATCH 09/31] linting (#542) Co-authored-by: Shane Osbourne --- Sources/PrivacyDashboard/PrivacyDashboardController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/PrivacyDashboard/PrivacyDashboardController.swift b/Sources/PrivacyDashboard/PrivacyDashboardController.swift index 41cb6e3eb..085302444 100644 --- a/Sources/PrivacyDashboard/PrivacyDashboardController.swift +++ b/Sources/PrivacyDashboard/PrivacyDashboardController.swift @@ -27,7 +27,8 @@ public enum PrivacyDashboardOpenSettingsTarget: String { } public protocol PrivacyDashboardControllerDelegate: AnyObject { - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didChangeProtectionSwitch protectionState: ProtectionState) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didChangeProtectionSwitch protectionState: ProtectionState) func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didRequestOpenUrlInNewTab url: URL) func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didRequestOpenSettings target: PrivacyDashboardOpenSettingsTarget) From 8f7a94a70812862203955b3d2bbb909420fa55dd Mon Sep 17 00:00:00 2001 From: Dax Mobile <44842493+daxmobile@users.noreply.github.com> Date: Fri, 27 Oct 2023 23:05:02 +1100 Subject: [PATCH 10/31] Update autofill to 9.0.0 (#537) Co-authored-by: alistairjcbrown --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index f8e42a9d9..8a2d1a4ad 100644 --- a/Package.resolved +++ b/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/duckduckgo-autofill.git", "state" : { - "revision" : "6dd7d696d4e666cedb2f1890a46fe53615226646", - "version" : "8.4.2" + "revision" : "c8e895c8fd50dc76e8d8dc827a636ad77b7f46ff", + "version" : "9.0.0" } }, { diff --git a/Package.swift b/Package.swift index ccc29c9c9..b6c16482d 100644 --- a/Package.swift +++ b/Package.swift @@ -31,7 +31,7 @@ let package = Package( .library(name: "SecureStorage", targets: ["SecureStorage"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "8.4.2"), + .package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "9.0.0"), .package(url: "https://github.com/duckduckgo/GRDB.swift.git", exact: "2.2.0"), .package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "1.2.1"), .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.2.0"), From 8768193257dd1f461218ed2a8d7893156bde4bda Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Tue, 31 Oct 2023 00:12:37 +0100 Subject: [PATCH 11/31] Secure vault app group support (#532) Task/Issue URL: https://app.asana.com/0/0/1205630353434502/f macOS PR: https://github.com/duckduckgo/macos-browser/pull/1713 iOS PR: Since I'm merging against a feature branch, the iOS PR is currently not necessary. What kind of version bump will this require?: None since this is aiming at a feature branch ## Description Fixes some issues with `BloomFilterWrapper` when linking BSK in more than one App target. --- Package.swift | 10 ++++- .../BloomFilterObjC.mm} | 6 +-- .../include/BloomFilterObjC.h} | 2 +- .../include/module.private.modulemap} | 2 +- .../BloomFilterWrapper.swift | 40 +++++++++++++++++++ .../SmarterEncryption/HTTPSUpgradeStore.swift | 1 + .../Store/AppHTTPSUpgradeStore.swift | 2 +- .../SecureStorageDatabaseProvider.swift | 12 +++++- .../BloomFilterWrapperTest.swift | 6 +-- .../HTTPSUpgradeStoreMock.swift | 1 + ...DBSecureStorageDatabaseProviderTests.swift | 6 +++ 11 files changed, 75 insertions(+), 13 deletions(-) rename Sources/{BloomFilterWrapper/BloomFilterWrapper.mm => BloomFilterObjC/BloomFilterObjC.mm} (93%) rename Sources/{BloomFilterWrapper/include/BloomFilterWrapper.h => BloomFilterObjC/include/BloomFilterObjC.h} (95%) rename Sources/{BloomFilterWrapper/include/module.modulemap => BloomFilterObjC/include/module.private.modulemap} (55%) create mode 100644 Sources/BloomFilterWrapper/BloomFilterWrapper.swift diff --git a/Package.swift b/Package.swift index b6c16482d..506b907ff 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,7 @@ let package = Package( .library(name: "DDGSync", targets: ["DDGSync"]), .library(name: "Persistence", targets: ["Persistence"]), .library(name: "Bookmarks", targets: ["Bookmarks"]), + .library(name: "BloomFilterWrapper", targets: ["BloomFilterWrapper"]), .library(name: "UserScript", targets: ["UserScript"]), .library(name: "Crashes", targets: ["Crashes"]), .library(name: "ContentBlocking", targets: ["ContentBlocking"]), @@ -87,11 +88,16 @@ let package = Package( "Bookmarks" ] ), - .target( - name: "BloomFilterWrapper", + .target( + name: "BloomFilterObjC", dependencies: [ .product(name: "BloomFilter", package: "bloom_cpp") ]), + .target( + name: "BloomFilterWrapper", + dependencies: [ + "BloomFilterObjC" + ]), .target( name: "Crashes" ), diff --git a/Sources/BloomFilterWrapper/BloomFilterWrapper.mm b/Sources/BloomFilterObjC/BloomFilterObjC.mm similarity index 93% rename from Sources/BloomFilterWrapper/BloomFilterWrapper.mm rename to Sources/BloomFilterObjC/BloomFilterObjC.mm index f3f3dbb74..c32bf6dc8 100644 --- a/Sources/BloomFilterWrapper/BloomFilterWrapper.mm +++ b/Sources/BloomFilterObjC/BloomFilterObjC.mm @@ -17,15 +17,15 @@ // limitations under the License. // -#import "BloomFilterWrapper.h" +#import "BloomFilterObjC.h" #import "BloomFilter.hpp" -@interface BloomFilterWrapper() { +@interface BloomFilterObjC() { BloomFilter *filter; } @end -@implementation BloomFilterWrapper +@implementation BloomFilterObjC - (instancetype)initFromPath:(NSString*)path withBitCount:(int)bitCount andTotalItems:(int)totalItems { self = [super init]; diff --git a/Sources/BloomFilterWrapper/include/BloomFilterWrapper.h b/Sources/BloomFilterObjC/include/BloomFilterObjC.h similarity index 95% rename from Sources/BloomFilterWrapper/include/BloomFilterWrapper.h rename to Sources/BloomFilterObjC/include/BloomFilterObjC.h index 87c7b8371..3be291b38 100644 --- a/Sources/BloomFilterWrapper/include/BloomFilterWrapper.h +++ b/Sources/BloomFilterObjC/include/BloomFilterObjC.h @@ -18,7 +18,7 @@ // #import -@interface BloomFilterWrapper : NSObject +@interface BloomFilterObjC: NSObject - (instancetype)initFromPath:(NSString*)path withBitCount:(int)bitCount andTotalItems:(int)totalItems; - (instancetype)initWithTotalItems:(int)count errorRate:(double)errorRate; - (void)dealloc; diff --git a/Sources/BloomFilterWrapper/include/module.modulemap b/Sources/BloomFilterObjC/include/module.private.modulemap similarity index 55% rename from Sources/BloomFilterWrapper/include/module.modulemap rename to Sources/BloomFilterObjC/include/module.private.modulemap index 4ccf806f5..e275f75d5 100644 --- a/Sources/BloomFilterWrapper/include/module.modulemap +++ b/Sources/BloomFilterObjC/include/module.private.modulemap @@ -1,4 +1,4 @@ module BloomFilterWrapper { - header "BloomFilterWrapper.h" + header "BloomFilterObjC.h" export * } diff --git a/Sources/BloomFilterWrapper/BloomFilterWrapper.swift b/Sources/BloomFilterWrapper/BloomFilterWrapper.swift new file mode 100644 index 000000000..562007b76 --- /dev/null +++ b/Sources/BloomFilterWrapper/BloomFilterWrapper.swift @@ -0,0 +1,40 @@ +// +// BloomFilterWrapper.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +@_implementationOnly import BloomFilterObjC + +public final class BloomFilterWrapper { + private let bloomFilter: BloomFilterObjC + + public init(fromPath path: String, withBitCount bitCount: Int32, andTotalItems totalItems: Int32) { + bloomFilter = BloomFilterObjC(fromPath: path, withBitCount: bitCount, andTotalItems: totalItems) + } + + public init(totalItems count: Int32, errorRate: Double) { + bloomFilter = BloomFilterObjC(totalItems: count, errorRate: errorRate) + } + + public func add(_ entry: String) { + bloomFilter.add(entry) + } + + public func contains(_ entry: String) -> Bool { + bloomFilter.contains(entry) + } +} diff --git a/Sources/BrowserServicesKit/SmarterEncryption/HTTPSUpgradeStore.swift b/Sources/BrowserServicesKit/SmarterEncryption/HTTPSUpgradeStore.swift index 25732a529..99bdb8a07 100644 --- a/Sources/BrowserServicesKit/SmarterEncryption/HTTPSUpgradeStore.swift +++ b/Sources/BrowserServicesKit/SmarterEncryption/HTTPSUpgradeStore.swift @@ -17,6 +17,7 @@ // import BloomFilterWrapper +import Foundation public protocol HTTPSUpgradeStore { diff --git a/Sources/BrowserServicesKit/SmarterEncryption/Store/AppHTTPSUpgradeStore.swift b/Sources/BrowserServicesKit/SmarterEncryption/Store/AppHTTPSUpgradeStore.swift index 1a497611d..714f5b1f5 100644 --- a/Sources/BrowserServicesKit/SmarterEncryption/Store/AppHTTPSUpgradeStore.swift +++ b/Sources/BrowserServicesKit/SmarterEncryption/Store/AppHTTPSUpgradeStore.swift @@ -102,7 +102,7 @@ public struct AppHTTPSUpgradeStore: HTTPSUpgradeStore { let wrapper = BloomFilterWrapper(fromPath: bloomFilterDataURL.path, withBitCount: Int32(specification.bitCount), andTotalItems: Int32(specification.totalEntries)) - return BloomFilter(wrapper: wrapper!, specification: specification) + return BloomFilter(wrapper: wrapper, specification: specification) } func loadStoredBloomFilterSpecification() -> HTTPSBloomFilterSpecification? { diff --git a/Sources/SecureStorage/SecureStorageDatabaseProvider.swift b/Sources/SecureStorage/SecureStorageDatabaseProvider.swift index 963b14294..f8e3cd723 100644 --- a/Sources/SecureStorage/SecureStorageDatabaseProvider.swift +++ b/Sources/SecureStorage/SecureStorageDatabaseProvider.swift @@ -104,10 +104,18 @@ open class GRDBSecureStorageDatabaseProvider: SecureStorageDatabaseProvider { try FileManager.default.moveItem(at: newDbFile, to: databaseURL) } - public static func databaseFilePath(directoryName: String, fileName: String) -> URL { + public static func databaseFilePath(directoryName: String, fileName: String, appGroupIdentifier: String? = nil) -> URL { let fm = FileManager.default - let subDir = fm.applicationSupportDirectoryForComponent(named: directoryName) + let subDir: URL + if let appGroupIdentifier = appGroupIdentifier { + guard let dir = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else { + fatalError("Failed to get appGroup for identifier \(appGroupIdentifier)") + } + subDir = dir.appendingPathComponent(directoryName) + } else { + subDir = fm.applicationSupportDirectoryForComponent(named: directoryName) + } var isDir: ObjCBool = false if !fm.fileExists(atPath: subDir.path, isDirectory: &isDir) { diff --git a/Tests/BrowserServicesKitTests/SmarterEncryption/BloomFilterWrapperTest.swift b/Tests/BrowserServicesKitTests/SmarterEncryption/BloomFilterWrapperTest.swift index 0c805ae97..79699b66c 100644 --- a/Tests/BrowserServicesKitTests/SmarterEncryption/BloomFilterWrapperTest.swift +++ b/Tests/BrowserServicesKitTests/SmarterEncryption/BloomFilterWrapperTest.swift @@ -32,12 +32,12 @@ class BloomFilterWrapperTest: XCTestCase { } func testWhenBloomFilterEmptyThenContainsIsFalse() { - let testee = BloomFilterWrapper(totalItems: Int32(Constants.filterElementCount), errorRate: Constants.targetErrorRate)! + let testee = BloomFilterWrapper(totalItems: Int32(Constants.filterElementCount), errorRate: Constants.targetErrorRate) XCTAssertFalse(testee.contains("abc")) } func testWhenBloomFilterContainsElementThenContainsIsTrue() { - let testee = BloomFilterWrapper(totalItems: Int32(Constants.filterElementCount), errorRate: Constants.targetErrorRate)! + let testee = BloomFilterWrapper(totalItems: Int32(Constants.filterElementCount), errorRate: Constants.targetErrorRate) testee.add("abc") XCTAssertTrue(testee.contains("abc")) } @@ -46,7 +46,7 @@ class BloomFilterWrapperTest: XCTestCase { let bloomData = createRandomStrings(count: Constants.filterElementCount) let testData = bloomData + createRandomStrings(count: Constants.additionalTestDataElementCount) - let testee = BloomFilterWrapper(totalItems: Int32(bloomData.count), errorRate: Constants.targetErrorRate)! + let testee = BloomFilterWrapper(totalItems: Int32(bloomData.count), errorRate: Constants.targetErrorRate) bloomData.forEach { testee.add($0) } var falsePositives = 0, truePositives = 0, falseNegatives = 0, trueNegatives = 0 diff --git a/Tests/BrowserServicesKitTests/SmarterEncryption/HTTPSUpgradeStoreMock.swift b/Tests/BrowserServicesKitTests/SmarterEncryption/HTTPSUpgradeStoreMock.swift index 081d26770..f14339161 100644 --- a/Tests/BrowserServicesKitTests/SmarterEncryption/HTTPSUpgradeStoreMock.swift +++ b/Tests/BrowserServicesKitTests/SmarterEncryption/HTTPSUpgradeStoreMock.swift @@ -17,6 +17,7 @@ // limitations under the License. // +import Foundation @testable import BrowserServicesKit @testable import BloomFilterWrapper diff --git a/Tests/SecureStorageTests/GRDBSecureStorageDatabaseProviderTests.swift b/Tests/SecureStorageTests/GRDBSecureStorageDatabaseProviderTests.swift index bdb92425c..23b6c7afc 100644 --- a/Tests/SecureStorageTests/GRDBSecureStorageDatabaseProviderTests.swift +++ b/Tests/SecureStorageTests/GRDBSecureStorageDatabaseProviderTests.swift @@ -126,6 +126,12 @@ final class GRDBSecureStorageDatabaseProviderTests: XCTestCase { let databaseFilePath = GRDBSecureStorageDatabaseProvider.databaseFilePath(directoryName: "Test", fileName: "Database.db") XCTAssert(databaseFilePath.absoluteString.hasSuffix("Test/Database.db")) + + let databaseFilePathAppGroup = GRDBSecureStorageDatabaseProvider.databaseFilePath(directoryName: "Test", fileName: "Database.db", appGroupIdentifier: "TEST") + + XCTAssert(databaseFilePathAppGroup.absoluteString.hasSuffix("Test/Database.db")) + + XCTAssertNotEqual(databaseFilePath, databaseFilePathAppGroup) } func createTemporaryFileURL() -> URL { From 71e916d070cedcba9ccb3ce9431797260bf5cbea Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 31 Oct 2023 16:58:49 +0100 Subject: [PATCH 12/31] NetP iOS notifications settings (#541) * Add helpers for toggling netP notifications * Extract a protocol for the settings store * Add MockNetworkProtectionNotificationsSettingsStore * Update Sources/NetworkProtection/Storage/NetworkProtectionNotificationsSettingsStore.swift Co-authored-by: Sam Symons * Remove dead property --------- Co-authored-by: Sam Symons --- ...workProtectionNotificationsPresenter.swift | 0 ...ficationsPresenterTogglableDecorator.swift | 65 +++++++++++++++++++ ...ProtectionNotificationsSettingsStore.swift | 48 ++++++++++++++ ...ProtectionNotificationsSettingsStore.swift | 25 +++++++ 4 files changed, 138 insertions(+) rename Sources/NetworkProtection/Status/{ => UserNotifications}/NetworkProtectionNotificationsPresenter.swift (100%) create mode 100644 Sources/NetworkProtection/Status/UserNotifications/NetworkProtectionNotificationsPresenterTogglableDecorator.swift create mode 100644 Sources/NetworkProtection/Storage/NetworkProtectionNotificationsSettingsStore.swift create mode 100644 Sources/NetworkProtectionTestUtils/Storage/MockNetworkProtectionNotificationsSettingsStore.swift diff --git a/Sources/NetworkProtection/Status/NetworkProtectionNotificationsPresenter.swift b/Sources/NetworkProtection/Status/UserNotifications/NetworkProtectionNotificationsPresenter.swift similarity index 100% rename from Sources/NetworkProtection/Status/NetworkProtectionNotificationsPresenter.swift rename to Sources/NetworkProtection/Status/UserNotifications/NetworkProtectionNotificationsPresenter.swift diff --git a/Sources/NetworkProtection/Status/UserNotifications/NetworkProtectionNotificationsPresenterTogglableDecorator.swift b/Sources/NetworkProtection/Status/UserNotifications/NetworkProtectionNotificationsPresenterTogglableDecorator.swift new file mode 100644 index 000000000..4e3bff243 --- /dev/null +++ b/Sources/NetworkProtection/Status/UserNotifications/NetworkProtectionNotificationsPresenterTogglableDecorator.swift @@ -0,0 +1,65 @@ +// +// NetworkProtectionNotificationsPresenterTogglableDecorator.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +final public class NetworkProtectionNotificationsPresenterTogglableDecorator: NetworkProtectionNotificationsPresenter { + private let notificationSettingsStore: NetworkProtectionNotificationsSettingsStore + private let wrappeePresenter: NetworkProtectionNotificationsPresenter + + public init(notificationSettingsStore: NetworkProtectionNotificationsSettingsStore, wrappee: NetworkProtectionNotificationsPresenter) { + self.notificationSettingsStore = notificationSettingsStore + self.wrappeePresenter = wrappee + } + + public func showConnectedNotification(serverLocation: String?) { + guard notificationSettingsStore.alertsEnabled else { + return + } + wrappeePresenter.showConnectedNotification(serverLocation: serverLocation) + } + + public func showReconnectingNotification() { + guard notificationSettingsStore.alertsEnabled else { + return + } + wrappeePresenter.showReconnectingNotification() + } + + public func showConnectionFailureNotification() { + guard notificationSettingsStore.alertsEnabled else { + return + } + wrappeePresenter.showConnectionFailureNotification() + } + + public func showSupersededNotification() { + guard notificationSettingsStore.alertsEnabled else { + return + } + wrappeePresenter.showSupersededNotification() + } + + public func showTestNotification() { + guard notificationSettingsStore.alertsEnabled else { + return + } + wrappeePresenter.showTestNotification() + } +} diff --git a/Sources/NetworkProtection/Storage/NetworkProtectionNotificationsSettingsStore.swift b/Sources/NetworkProtection/Storage/NetworkProtectionNotificationsSettingsStore.swift new file mode 100644 index 000000000..2e4384fed --- /dev/null +++ b/Sources/NetworkProtection/Storage/NetworkProtectionNotificationsSettingsStore.swift @@ -0,0 +1,48 @@ +// +// NetworkProtectionNotificationsSettingsStore.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public protocol NetworkProtectionNotificationsSettingsStore { + var alertsEnabled: Bool { get set } +} + +final public class NetworkProtectionNotificationsSettingsUserDefaultsStore: NetworkProtectionNotificationsSettingsStore { + private enum Key { + static let alerts = "com.duckduckgo.vpnNotificationSettings.alertsEnabled" + } + + private let userDefaults: UserDefaults + + public init(userDefaults: UserDefaults) { + self.userDefaults = userDefaults + } + + public var alertsEnabled: Bool { + get { + guard self.userDefaults.object(forKey: Key.alerts) != nil else { + return true + } + return self.userDefaults.bool(forKey: Key.alerts) + } + set { + self.userDefaults.set(newValue, forKey: Key.alerts) + } + } +} diff --git a/Sources/NetworkProtectionTestUtils/Storage/MockNetworkProtectionNotificationsSettingsStore.swift b/Sources/NetworkProtectionTestUtils/Storage/MockNetworkProtectionNotificationsSettingsStore.swift new file mode 100644 index 000000000..dcaf3adf3 --- /dev/null +++ b/Sources/NetworkProtectionTestUtils/Storage/MockNetworkProtectionNotificationsSettingsStore.swift @@ -0,0 +1,25 @@ +// +// MockNetworkProtectionNotificationsSettingsStore.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import NetworkProtection + +public class MockNetworkProtectionNotificationsSettingsStore: NetworkProtectionNotificationsSettingsStore { + public var alertsEnabled: Bool = false +} From 56202052de9be402a459d723065213429688c502 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Nov 2023 10:48:40 +0100 Subject: [PATCH 13/31] Bump Tests/BrowserServicesKitTests/Resources/privacy-reference-tests (#543) Bumps [Tests/BrowserServicesKitTests/Resources/privacy-reference-tests](https://github.com/duckduckgo/privacy-reference-tests) from `0d23f76` to `2e73221`. - [Release notes](https://github.com/duckduckgo/privacy-reference-tests/releases) - [Commits](https://github.com/duckduckgo/privacy-reference-tests/compare/0d23f76801c2e73ae7d5ed7daa4af4aca5beec73...2e73221f9b5d872e05199db6b29f140406c909ae) --- updated-dependencies: - dependency-name: Tests/BrowserServicesKitTests/Resources/privacy-reference-tests dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Tests/BrowserServicesKitTests/Resources/privacy-reference-tests | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests b/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests index 0d23f7680..2e73221f9 160000 --- a/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests +++ b/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests @@ -1 +1 @@ -Subproject commit 0d23f76801c2e73ae7d5ed7daa4af4aca5beec73 +Subproject commit 2e73221f9b5d872e05199db6b29f140406c909ae From 86e4aba326ce06585b842ab13023ce08f86ac424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81yp?= Date: Sat, 4 Nov 2023 13:51:12 +0100 Subject: [PATCH 14/31] Alert user about abnormal app conditions (#539) Task/Issue URL: https://app.asana.com/0/72649045549333/1204420573063618/f iOS PR: duckduckgo/iOS#2110 What kind of version bump will this require?: Patch Description: Introduce better handling of critical errors and adjust some EmailManager code to handle errors within. --- .../ContentBlockerRulesManager.swift | 2 ++ .../ContentBlockerRulesSourceManager.swift | 17 ++++++++-- .../Email/EmailManager.swift | 34 ++++++++++++------- .../EmailProtectionSyncHandler.swift | 4 +-- .../Email/EmailManagerTests.swift | 4 ++- 5 files changed, 43 insertions(+), 18 deletions(-) diff --git a/Sources/BrowserServicesKit/ContentBlocking/ContentBlockerRulesManager.swift b/Sources/BrowserServicesKit/ContentBlocking/ContentBlockerRulesManager.swift index 13fda8930..52c02d635 100644 --- a/Sources/BrowserServicesKit/ContentBlocking/ContentBlockerRulesManager.swift +++ b/Sources/BrowserServicesKit/ContentBlocking/ContentBlockerRulesManager.swift @@ -116,6 +116,7 @@ public class ContentBlockerRulesManager: CompiledRuleListsSource { public var updatesPublisher: AnyPublisher { updatesSubject.eraseToAnyPublisher() } + public var onCriticalError: (() -> Void)? private let errorReporting: EventMapping? private let getLog: () -> OSLog @@ -288,6 +289,7 @@ public class ContentBlockerRulesManager: CompiledRuleListsSource { sourceManager = ContentBlockerRulesSourceManager(rulesList: rulesList, exceptionsSource: self.exceptionsSource, errorReporting: self.errorReporting, + onCriticalError: self.onCriticalError, log: log) self.sourceManagers[rulesList.name] = sourceManager } diff --git a/Sources/BrowserServicesKit/ContentBlocking/ContentBlockerRulesSourceManager.swift b/Sources/BrowserServicesKit/ContentBlocking/ContentBlockerRulesSourceManager.swift index 3a93bee2f..b67712c6f 100644 --- a/Sources/BrowserServicesKit/ContentBlocking/ContentBlockerRulesSourceManager.swift +++ b/Sources/BrowserServicesKit/ContentBlocking/ContentBlockerRulesSourceManager.swift @@ -96,6 +96,7 @@ public class ContentBlockerRulesSourceManager { public private(set) var fallbackTDSFailure = false private let errorReporting: EventMapping? + private let onCriticalError: (() -> Void)? private let getLog: () -> OSLog private var log: OSLog { getLog() @@ -105,10 +106,12 @@ public class ContentBlockerRulesSourceManager { init(rulesList: ContentBlockerRulesList, exceptionsSource: ContentBlockerRulesExceptionsSource, errorReporting: EventMapping? = nil, + onCriticalError: (() -> Void)? = nil, log: @escaping @autoclosure () -> OSLog = .disabled) { self.rulesList = rulesList self.exceptionsSource = exceptionsSource self.errorReporting = errorReporting + self.onCriticalError = onCriticalError self.getLog = log } @@ -186,7 +189,7 @@ public class ContentBlockerRulesSourceManager { compilationFailed(for: input, with: error, brokenSources: brokenSources) return } - + compilationFailed(for: input, with: error, brokenSources: brokenSources) } @@ -196,6 +199,7 @@ public class ContentBlockerRulesSourceManager { private func compilationFailed(for input: ContentBlockerRulesSourceIdentifiers, with error: Error, brokenSources: RulesSourceBreakageInfo) { + if input.tdsIdentifier != rulesList.fallbackTrackerData.etag { os_log(.debug, log: log, "Falling back to embedded TDS") // We failed compilation for non-embedded TDS, marking it as broken. @@ -243,10 +247,19 @@ public class ContentBlockerRulesSourceManager { parameters: params, onComplete: { _ in if input.name == DefaultContentBlockerRulesListsSource.Constants.trackerDataSetRulesListName { - fatalError("Could not compile embedded rules list") + self.handleCriticalError() } }) fallbackTDSFailure = true } } + + private func handleCriticalError() { + if let onCriticalError = self.onCriticalError { + onCriticalError() + } else { + fatalError("Could not compile embedded rules list") + } + } + } diff --git a/Sources/BrowserServicesKit/Email/EmailManager.swift b/Sources/BrowserServicesKit/Email/EmailManager.swift index 7cc298919..a5f88c9a9 100644 --- a/Sources/BrowserServicesKit/Email/EmailManager.swift +++ b/Sources/BrowserServicesKit/Email/EmailManager.swift @@ -104,8 +104,10 @@ public protocol EmailManagerRequestDelegate: AnyObject { httpBody: Data?, timeoutInterval: TimeInterval) async throws -> Data - func emailManagerKeychainAccessFailed(accessType: EmailKeychainAccessType, error: EmailKeychainAccessError) - + func emailManagerKeychainAccessFailed(_ emailManager: EmailManager, + accessType: EmailKeychainAccessType, + error: EmailKeychainAccessError) + } // swiftlint:enable function_parameter_count @@ -164,6 +166,7 @@ public class EmailManager { public enum NotificationParameter { public static let cohort = "cohort" + public static let isForcedSignOut = "isForcedSignOut" } private lazy var emailUrls = EmailUrls() @@ -184,7 +187,7 @@ public class EmailManager { return try storage.getUsername() } catch { if let error = error as? EmailKeychainAccessError { - requestDelegate?.emailManagerKeychainAccessFailed(accessType: .getUsername, error: error) + requestDelegate?.emailManagerKeychainAccessFailed(self, accessType: .getUsername, error: error) } else { assertionFailure("Expected EmailKeychainAccessFailure") } @@ -203,7 +206,7 @@ public class EmailManager { return try storage.getToken() } catch { if let error = error as? EmailKeychainAccessError { - requestDelegate?.emailManagerKeychainAccessFailed(accessType: .getToken, error: error) + requestDelegate?.emailManagerKeychainAccessFailed(self, accessType: .getToken, error: error) } else { assertionFailure("Expected EmailKeychainAccessFailure") } @@ -222,7 +225,7 @@ public class EmailManager { return try storage.getAlias() } catch { if let error = error as? EmailKeychainAccessError { - requestDelegate?.emailManagerKeychainAccessFailed(accessType: .getAlias, error: error) + requestDelegate?.emailManagerKeychainAccessFailed(self, accessType: .getAlias, error: error) } else { assertionFailure("Expected EmailKeychainAccessFailure") } @@ -241,7 +244,7 @@ public class EmailManager { return try storage.getCohort() } catch { if let error = error as? EmailKeychainAccessError { - requestDelegate?.emailManagerKeychainAccessFailed(accessType: .getCohort, error: error) + requestDelegate?.emailManagerKeychainAccessFailed(self, accessType: .getCohort, error: error) } else { assertionFailure("Expected EmailKeychainAccessFailure") } @@ -260,7 +263,7 @@ public class EmailManager { return try storage.getLastUseDate() ?? "" } catch { if let error = error as? EmailKeychainAccessError { - requestDelegate?.emailManagerKeychainAccessFailed(accessType: .getLastUseData, error: error) + requestDelegate?.emailManagerKeychainAccessFailed(self, accessType: .getLastUseData, error: error) } else { assertionFailure("Expected EmailKeychainAccessFailure") } @@ -281,7 +284,7 @@ public class EmailManager { try storage.store(lastUseDate: dateString) } catch { if let error = error as? EmailKeychainAccessError { - requestDelegate?.emailManagerKeychainAccessFailed(accessType: .storeLastUseDate, error: error) + requestDelegate?.emailManagerKeychainAccessFailed(self, accessType: .storeLastUseDate, error: error) } else { assertionFailure("Expected EmailKeychainAccessFailure") } @@ -314,7 +317,7 @@ public class EmailManager { dateFormatter.timeZone = TimeZone(identifier: "America/New_York") // Use ET time zone } - public func signOut() throws { + public func signOut(isForced: Bool = false) throws { Self.lock.lock() defer { Self.lock.unlock() @@ -331,12 +334,13 @@ public class EmailManager { if let currentCohortValue = currentCohortValue { notificationParameters[NotificationParameter.cohort] = currentCohortValue } + notificationParameters[NotificationParameter.isForcedSignOut] = isForced ? "true" : nil NotificationCenter.default.post(name: .emailDidSignOut, object: self, userInfo: notificationParameters) } catch { if let error = error as? EmailKeychainAccessError { - self.requestDelegate?.emailManagerKeychainAccessFailed(accessType: .deleteAuthenticationState, error: error) + self.requestDelegate?.emailManagerKeychainAccessFailed(self, accessType: .deleteAuthenticationState, error: error) } else { assertionFailure("Expected EmailKeychainAccessFailure") } @@ -344,6 +348,10 @@ public class EmailManager { } } + public func forceSignOut() { + try? signOut(isForced: true) + } + public func emailAddressFor(_ alias: String) -> String { return alias + "@" + Self.emailDomain } @@ -540,7 +548,7 @@ public extension EmailManager { } catch { if let error = error as? EmailKeychainAccessError { - requestDelegate?.emailManagerKeychainAccessFailed(accessType: .storeTokenUsernameCohort, error: error) + requestDelegate?.emailManagerKeychainAccessFailed(self, accessType: .storeTokenUsernameCohort, error: error) } else { assertionFailure("Expected EmailKeychainAccessFailure") } @@ -597,7 +605,7 @@ private extension EmailManager { try storage.deleteAlias() } catch { if let error = error as? EmailKeychainAccessError { - self.requestDelegate?.emailManagerKeychainAccessFailed(accessType: .deleteAlias, error: error) + self.requestDelegate?.emailManagerKeychainAccessFailed(self, accessType: .deleteAlias, error: error) } else { assertionFailure("Expected EmailKeychainAccessFailure") } @@ -642,7 +650,7 @@ private extension EmailManager { try self.storage.store(alias: alias) } catch { if let error = error as? EmailKeychainAccessError { - self.requestDelegate?.emailManagerKeychainAccessFailed(accessType: .storeAlias, error: error) + self.requestDelegate?.emailManagerKeychainAccessFailed(self, accessType: .storeAlias, error: error) } else { assertionFailure("Expected EmailKeychainAccessFailure") } diff --git a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/EmailProtectionSyncHandler.swift b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/EmailProtectionSyncHandler.swift index c983be43a..e0bd5838e 100644 --- a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/EmailProtectionSyncHandler.swift +++ b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/EmailProtectionSyncHandler.swift @@ -28,7 +28,7 @@ public protocol EmailManagerSyncSupporting: AnyObject { func getToken() throws -> String? func signIn(username: String, token: String) throws - func signOut() throws + func signOut(isForced: Bool) throws var userDidToggleEmailProtectionPublisher: AnyPublisher { get } } @@ -60,7 +60,7 @@ class EmailProtectionSyncHandler: SettingsSyncHandling { func setValue(_ value: String?) throws { guard let value, let valueData = value.data(using: .utf8) else { - try emailManager.signOut() + try emailManager.signOut(isForced: false) return } diff --git a/Tests/BrowserServicesKitTests/Email/EmailManagerTests.swift b/Tests/BrowserServicesKitTests/Email/EmailManagerTests.swift index d4cccb1e2..44a23bf87 100644 --- a/Tests/BrowserServicesKitTests/Email/EmailManagerTests.swift +++ b/Tests/BrowserServicesKitTests/Email/EmailManagerTests.swift @@ -391,7 +391,9 @@ class MockEmailManagerRequestDelegate: EmailManagerRequestDelegate { var keychainAccessErrorAccessType: EmailKeychainAccessType? var keychainAccessError: EmailKeychainAccessError? - func emailManagerKeychainAccessFailed(accessType: EmailKeychainAccessType, error: EmailKeychainAccessError) { + func emailManagerKeychainAccessFailed(_ emailManager: EmailManager, + accessType: EmailKeychainAccessType, + error: EmailKeychainAccessError) { keychainAccessErrorAccessType = accessType keychainAccessError = error } From 0ac6d8e2153bec4ddd4e983915da6db09fcbed05 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Tue, 7 Nov 2023 13:29:47 +0100 Subject: [PATCH 15/31] Fix syncing empty favorites folders (#546) Task/Issue URL: https://app.asana.com/0/414235014887631/1205843304285892/f Description: Update bookmarks sync response handler to actually process favorites also when an empty folder is received from the server. --- .../internal/BookmarksResponseHandler.swift | 51 ++++++++++--------- ...marksRegularSyncResponseHandlerTests.swift | 48 +++++++++++++++++ 2 files changed, 76 insertions(+), 23 deletions(-) diff --git a/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift b/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift index 451178aec..5c69cb8c8 100644 --- a/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift +++ b/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift @@ -36,7 +36,7 @@ final class BookmarksResponseHandler { let topLevelFoldersSyncables: [SyncableBookmarkAdapter] let bookmarkSyncablesWithoutParent: [SyncableBookmarkAdapter] - let favoritesUUIDs: [String] + let favoritesUUIDs: [String]? var entitiesByUUID: [String: BookmarkEntity] = [:] var idsOfItemsThatRetainModifiedAt = Set() @@ -57,7 +57,7 @@ final class BookmarksResponseHandler { var allUUIDs: Set = [] var childrenToParents: [String: String] = [:] var parentFoldersToChildren: [String: [String]] = [:] - var favoritesUUIDs: [String] = [] + var favoritesUUIDs: [String]? self.received.forEach { syncable in guard let uuid = syncable.uuid else { @@ -113,33 +113,38 @@ final class BookmarksResponseHandler { } try processOrphanedBookmarks() - // populate favorites - if !favoritesUUIDs.isEmpty { - guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) else { - // Error - unable to process favorites - return - } + processReceivedFavorites() + } - // For non-first sync we rely fully on the server response - if !shouldDeduplicateEntities { - favoritesFolder.favoritesArray.forEach { $0.removeFromFavorites() } - } else if !favoritesFolder.favoritesArray.isEmpty { - // If we're deduplicating and there are favorires locally, we'll need to sync favorites folder back later. - // Let's keep its modifiedAt. - idsOfItemsThatRetainModifiedAt.insert(BookmarkEntity.Constants.favoritesFolderID) - } + // MARK: - Private - favoritesUUIDs.forEach { uuid in - if let bookmark = entitiesByUUID[uuid] { - bookmark.removeFromFavorites() - bookmark.addToFavorites(favoritesRoot: favoritesFolder) - } + private func processReceivedFavorites() { + guard let favoritesUUIDs else { + return + } + + guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) else { + // Error - unable to process favorites + return + } + + // For non-first sync we rely fully on the server response + if !shouldDeduplicateEntities { + favoritesFolder.favoritesArray.forEach { $0.removeFromFavorites() } + } else if !favoritesFolder.favoritesArray.isEmpty { + // If we're deduplicating and there are favorites locally, we'll need to sync favorites folder back later. + // Let's keep its modifiedAt. + idsOfItemsThatRetainModifiedAt.insert(BookmarkEntity.Constants.favoritesFolderID) + } + + favoritesUUIDs.forEach { uuid in + if let bookmark = entitiesByUUID[uuid] { + bookmark.removeFromFavorites() + bookmark.addToFavorites(favoritesRoot: favoritesFolder) } } } - // MARK: - Private - private func processTopLevelFolder(_ topLevelFolderSyncable: SyncableBookmarkAdapter) throws { guard let topLevelFolderUUID = topLevelFolderSyncable.uuid else { return diff --git a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksRegularSyncResponseHandlerTests.swift b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksRegularSyncResponseHandlerTests.swift index 424873249..211cb3c3d 100644 --- a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksRegularSyncResponseHandlerTests.swift +++ b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksRegularSyncResponseHandlerTests.swift @@ -158,6 +158,54 @@ final class BookmarksRegularSyncResponseHandlerTests: BookmarksProviderTestsBase }) } + func testWhenPayloadDoesNotContainFavoritesFolderThenFavoritesAreNotAffected() async throws { + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", isFavorite: true) + Folder(id: "2") { + Bookmark(id: "3", isFavorite: true) + } + } + + let received: [Syncable] = [ + .rootFolder(children: ["1", "2", "4"]), + .bookmark(id: "4") + ] + + let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", isFavorite: true) + Folder(id: "2") { + Bookmark(id: "3", isFavorite: true) + } + Bookmark(id: "4") + }) + } + + func testWhenPayloadContainsEmptyFavoritesFolderThenAllFavoritesAreRemoved() async throws { + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", isFavorite: true) + Folder(id: "2") { + Bookmark(id: "3", isFavorite: true) + } + } + + let received: [Syncable] = [ + .favoritesFolder(favorites: []) + ] + + let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + Folder(id: "2") { + Bookmark(id: "3") + } + }) + } + func testThatSinglePayloadCanCreateReorderAndOrphanBookmarks() async throws { let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) From 989e306052bc284a1202fad1087f8b88e515a966 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Wed, 8 Nov 2023 11:10:51 -0800 Subject: [PATCH 16/31] Add a check for the DAU pixel when the bandwidth analyzer runs a test. (#553) Task/Issue URL: https://app.asana.com/0/0/1205909514194474/f iOS PR: duckduckgo/iOS#2134 macOS PR: duckduckgo/macos-browser#1820 What kind of version bump will this require?: Patch Optional: Tech Design URL: CC: Description: This PR updates the NetP pixel to be more reliable. It was previously only sent when rekey was called, but rekeying can happen without going through this function at all, so now we check whether we can send the pixel any time the connection tester has completed a test. --- Sources/NetworkProtection/PacketTunnelProvider.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/NetworkProtection/PacketTunnelProvider.swift b/Sources/NetworkProtection/PacketTunnelProvider.swift index 0a7006ad8..458e45c8c 100644 --- a/Sources/NetworkProtection/PacketTunnelProvider.swift +++ b/Sources/NetworkProtection/PacketTunnelProvider.swift @@ -201,6 +201,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { return } + // This provides a more frequent active user pixel check + providerEvents.fire(.userBecameActive) + await rekeyIfExpired() } } From f2936a65ef7685fe9c39d6a996c8391cdb3d95ff Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 9 Nov 2023 12:25:14 +0000 Subject: [PATCH 17/31] Add DBP feature (#551) --- .../PrivacyConfig/Features/PrivacyFeature.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift index f18486497..efab8d425 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift @@ -41,6 +41,7 @@ public enum PrivacyFeature: String { case incontextSignup case newTabContinueSetUp case networkProtection + case dbp } /// An abstraction to be implemented by any "subfeature" of a given `PrivacyConfiguration` feature. @@ -73,3 +74,12 @@ public enum NetworkProtectionSubfeature: String, Equatable, PrivacySubfeature { case waitlist case waitlistBetaActive } + +public enum DBPSubfeature: String, Equatable, PrivacySubfeature { + public var parent: PrivacyFeature { + .dbp + } + + case waitlist + case waitlistBetaActive +} From f827698b60e9eceef2f437fdb5e356d9b71d19fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Nov 2023 18:21:14 +0100 Subject: [PATCH 18/31] Bump Tests/BrowserServicesKitTests/Resources/privacy-reference-tests from `2e73221` to `7519c3d` (#556) Bumps Tests/BrowserServicesKitTests/Resources/privacy-reference-tests from 2e73221 to 7519c3d. --- README.md | 2 +- Tests/BrowserServicesKitTests/Resources/privacy-reference-tests | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 347968f49..84147e822 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # BrowserServicesKit - + ## We are hiring! DuckDuckGo is growing fast and we continue to expand our fully distributed team. We embrace diverse perspectives, and seek out passionate, self-motivated people, committed to our shared vision of raising the standard of trust online. If you are a senior software engineer capable in either iOS or Android, visit our [careers](https://duckduckgo.com/hiring/#open) page to find out more about our openings! diff --git a/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests b/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests index 2e73221f9..7519c3d43 160000 --- a/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests +++ b/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests @@ -1 +1 @@ -Subproject commit 2e73221f9b5d872e05199db6b29f140406c909ae +Subproject commit 7519c3d430e5dcef75b6128bfdadb0de3f463a49 From c4d5f6df0340f0a5c109dcded9801ab676de7db5 Mon Sep 17 00:00:00 2001 From: Anh Do <18567+quanganhdo@users.noreply.github.com> Date: Thu, 9 Nov 2023 13:24:42 -0500 Subject: [PATCH 19/31] Add selected environment preference (#544) * Add selected environment preference * Fixes resetting the system extension * Add selectedEnvironment to StartupOptions * Fixes some edge cases in resetting NetP * Move endpointURL to TunnelSettings.SelectedEnvironment --------- Co-authored-by: Diego Rey Mendez --- .../ExtensionMessage/ExtensionRequest.swift | 2 + .../NetworkProtectionOptionKey.swift | 1 + .../Networking/NetworkProtectionClient.swift | 17 +++--- .../PacketTunnelProvider.swift | 48 ++++++++++++---- .../UserDefaults+selectedEnvironment.swift | 57 +++++++++++++++++++ .../Settings/TunnelSettings.swift | 43 +++++++++++++- .../NetworkProtection/StartupOptions.swift | 13 +++++ .../VPNConfigurationManager.swift | 36 ++++++++++++ 8 files changed, 196 insertions(+), 21 deletions(-) create mode 100644 Sources/NetworkProtection/Settings/Extensions/UserDefaults+selectedEnvironment.swift create mode 100644 Sources/NetworkProtection/VPNConfiguration/VPNConfigurationManager.swift diff --git a/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift b/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift index 06f016f83..0b26465ce 100644 --- a/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift +++ b/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift @@ -20,6 +20,8 @@ import Foundation public enum DebugCommand: Codable { case expireRegistrationKey + case removeSystemExtension + case removeVPNConfiguration case sendTestNotification } diff --git a/Sources/NetworkProtection/NetworkProtectionOptionKey.swift b/Sources/NetworkProtection/NetworkProtectionOptionKey.swift index 0d2a0f65a..086a1700a 100644 --- a/Sources/NetworkProtection/NetworkProtectionOptionKey.swift +++ b/Sources/NetworkProtection/NetworkProtectionOptionKey.swift @@ -20,6 +20,7 @@ import Foundation public enum NetworkProtectionOptionKey { public static let keyValidity = "keyValidity" + public static let selectedEnvironment = "selectedEnvironment" public static let selectedServer = "selectedServer" public static let authToken = "authToken" public static let isOnDemand = "is-on-demand" diff --git a/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift b/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift index ac1321ef5..7d3a6ba21 100644 --- a/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift +++ b/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift @@ -74,25 +74,20 @@ struct RedeemResponse: Decodable { public final class NetworkProtectionBackendClient: NetworkProtectionClient { - enum Constants { - static let productionEndpoint = URL(string: "https://controller.netp.duckduckgo.com")! - static let stagingEndpoint = URL(string: "https://staging.netp.duckduckgo.com")! - } - private enum DecoderError: Error { case failedToDecode(key: String) } var serversURL: URL { - Constants.productionEndpoint.appending("/servers") + endpointURL.appending("/servers") } var registerKeyURL: URL { - Constants.productionEndpoint.appending("/register") + endpointURL.appending("/register") } var redeemURL: URL { - Constants.productionEndpoint.appending("/redeem") + endpointURL.appending("/redeem") } private let decoder: JSONDecoder = { @@ -114,7 +109,11 @@ public final class NetworkProtectionBackendClient: NetworkProtectionClient { return decoder }() - public init() {} + private let endpointURL: URL + + public init(environment: TunnelSettings.SelectedEnvironment = .default) { + endpointURL = environment.endpointURL + } public func getServers(authToken: String) async -> Result<[NetworkProtectionServer], NetworkProtectionClientError> { var request = URLRequest(url: serversURL) diff --git a/Sources/NetworkProtection/PacketTunnelProvider.swift b/Sources/NetworkProtection/PacketTunnelProvider.swift index 458e45c8c..0343d2f17 100644 --- a/Sources/NetworkProtection/PacketTunnelProvider.swift +++ b/Sources/NetworkProtection/PacketTunnelProvider.swift @@ -348,6 +348,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func load(options: StartupOptions) throws { loadKeyValidity(from: options) + loadSelectedEnvironment(from: options) loadSelectedServer(from: options) loadTesterEnabled(from: options) try loadAuthToken(from: options) @@ -370,6 +371,17 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } } + private func loadSelectedEnvironment(from options: StartupOptions) { + switch options.selectedEnvironment { + case .set(let selectedEnvironment): + settings.selectedEnvironment = selectedEnvironment + case .useExisting: + break + case .reset: + settings.selectedEnvironment = .default + } + } + private func loadSelectedServer(from options: StartupOptions) { switch options.selectedServer { case .set(let selectedServer): @@ -477,10 +489,13 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { let onDemand = options.startupMethod == .automaticOnDemand os_log("Starting tunnel %{public}@", log: .networkProtection, options.startupMethod.debugDescription) - startTunnel(selectedServer: settings.selectedServer, onDemand: onDemand, completionHandler: completionHandler) + startTunnel(environment: settings.selectedEnvironment, + selectedServer: settings.selectedServer, + onDemand: onDemand, + completionHandler: completionHandler) } - private func startTunnel(selectedServer: TunnelSettings.SelectedServer, onDemand: Bool, completionHandler: @escaping (Error?) -> Void) { + private func startTunnel(environment: TunnelSettings.SelectedEnvironment, selectedServer: TunnelSettings.SelectedServer, onDemand: Bool, completionHandler: @escaping (Error?) -> Void) { Task { let serverSelectionMethod: NetworkProtectionServerSelectionMethod @@ -494,7 +509,8 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { do { os_log("🔵 Generating tunnel config", log: .networkProtection, type: .info) - let tunnelConfiguration = try await generateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod, + let tunnelConfiguration = try await generateTunnelConfiguration(environment: environment, + serverSelectionMethod: serverSelectionMethod, includedRoutes: includedRoutes ?? [], excludedRoutes: excludedRoutes ?? []) startTunnel(with: tunnelConfiguration, onDemand: onDemand, completionHandler: completionHandler) @@ -619,7 +635,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // MARK: - Tunnel Configuration - public func updateTunnelConfiguration(selectedServer: TunnelSettings.SelectedServer, reassert: Bool = true) async throws { + public func updateTunnelConfiguration(environment: TunnelSettings.SelectedEnvironment = .default, selectedServer: TunnelSettings.SelectedServer, reassert: Bool = true) async throws { let serverSelectionMethod: NetworkProtectionServerSelectionMethod switch settings.selectedServer { @@ -629,12 +645,13 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { serverSelectionMethod = .preferredServer(serverName: serverName) } - try await updateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod, reassert: reassert) + try await updateTunnelConfiguration(environment: environment, serverSelectionMethod: serverSelectionMethod, reassert: reassert) } - public func updateTunnelConfiguration(serverSelectionMethod: NetworkProtectionServerSelectionMethod, reassert: Bool = true) async throws { + public func updateTunnelConfiguration(environment: TunnelSettings.SelectedEnvironment = .default, serverSelectionMethod: NetworkProtectionServerSelectionMethod, reassert: Bool = true) async throws { - let tunnelConfiguration = try await generateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod, + let tunnelConfiguration = try await generateTunnelConfiguration(environment: environment, + serverSelectionMethod: serverSelectionMethod, includedRoutes: includedRoutes ?? [], excludedRoutes: excludedRoutes ?? []) @@ -666,12 +683,14 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } } - private func generateTunnelConfiguration(serverSelectionMethod: NetworkProtectionServerSelectionMethod, includedRoutes: [IPAddressRange], excludedRoutes: [IPAddressRange]) async throws -> TunnelConfiguration { + private func generateTunnelConfiguration(environment: TunnelSettings.SelectedEnvironment = .default, serverSelectionMethod: NetworkProtectionServerSelectionMethod, includedRoutes: [IPAddressRange], excludedRoutes: [IPAddressRange]) async throws -> TunnelConfiguration { let configurationResult: (TunnelConfiguration, NetworkProtectionServerInfo) do { - let deviceManager = NetworkProtectionDeviceManager(tokenStore: tokenStore, + let networkClient = NetworkProtectionBackendClient(environment: environment) + let deviceManager = NetworkProtectionDeviceManager(networkClient: networkClient, + tokenStore: tokenStore, keyStore: keyStore, errorEvents: debugEvents) @@ -767,13 +786,14 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } Task { - try? await updateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod) + try? await updateTunnelConfiguration(environment: settings.selectedEnvironment, serverSelectionMethod: serverSelectionMethod) completionHandler?(nil) } case .setIncludeAllNetworks, .setEnforceRoutes, .setExcludeLocalNetworks, - .setRegistrationKeyValidity: + .setRegistrationKeyValidity, + .setSelectedEnvironment: // Intentional no-op, as some setting changes don't require any further operation break } @@ -781,10 +801,16 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func handleDebugCommand(_ command: DebugCommand, completionHandler: ((Data?) -> Void)? = nil) { switch command { + case .removeSystemExtension: + // Since the system extension is being removed we may as well reset all state + handleResetAllState(completionHandler: completionHandler) case .expireRegistrationKey: handleExpireRegistrationKey(completionHandler: completionHandler) case .sendTestNotification: handleSendTestNotification(completionHandler: completionHandler) + case .removeVPNConfiguration: + // Since the VPN configuration is being removed we may as well reset all state + handleResetAllState(completionHandler: completionHandler) } } diff --git a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+selectedEnvironment.swift b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+selectedEnvironment.swift new file mode 100644 index 000000000..4d8daf78a --- /dev/null +++ b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+selectedEnvironment.swift @@ -0,0 +1,57 @@ +// +// UserDefaults+selectedEnvironment.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +extension UserDefaults { + private var selectedEnvironmentKey: String { + "networkProtectionSettingSelectedEnvironmentRawValue" + } + + @objc + dynamic var networkProtectionSettingSelectedEnvironmentRawValue: String { + get { + value(forKey: selectedEnvironmentKey) as? String ?? TunnelSettings.SelectedEnvironment.default.rawValue + } + + set { + set(newValue, forKey: selectedEnvironmentKey) + } + } + + var networkProtectionSettingSelectedEnvironment: TunnelSettings.SelectedEnvironment { + get { + TunnelSettings.SelectedEnvironment(rawValue: networkProtectionSettingSelectedEnvironmentRawValue) ?? .default + } + + set { + networkProtectionSettingSelectedEnvironmentRawValue = newValue.rawValue + } + } + + var networkProtectionSettingSelectedEnvironmentPublisher: AnyPublisher { + publisher(for: \.networkProtectionSettingSelectedEnvironmentRawValue).map { value in + TunnelSettings.SelectedEnvironment(rawValue: value) ?? .default + }.eraseToAnyPublisher() + } + + func resetNetworkProtectionSettingSelectedEnvironment() { + networkProtectionSettingSelectedEnvironment = .default + } +} diff --git a/Sources/NetworkProtection/Settings/TunnelSettings.swift b/Sources/NetworkProtection/Settings/TunnelSettings.swift index 69871318f..ca6d7ad22 100644 --- a/Sources/NetworkProtection/Settings/TunnelSettings.swift +++ b/Sources/NetworkProtection/Settings/TunnelSettings.swift @@ -33,6 +33,7 @@ public final class TunnelSettings { case setExcludeLocalNetworks(_ excludeLocalNetworks: Bool) case setRegistrationKeyValidity(_ validity: RegistrationKeyValidity) case setSelectedServer(_ selectedServer: SelectedServer) + case setSelectedEnvironment(_ selectedEnvironment: SelectedEnvironment) } public enum RegistrationKeyValidity: Codable { @@ -52,6 +53,22 @@ public final class TunnelSettings { } } + public enum SelectedEnvironment: String, Codable { + case production + case staging + + public static var `default`: SelectedEnvironment = .production + + public var endpointURL: URL { + switch self { + case .production: + return URL(string: "https://controller.netp.duckduckgo.com")! + case .staging: + return URL(string: "https://staging1.netp.duckduckgo.com")! + } + } + } + private let defaults: UserDefaults private(set) public lazy var changePublisher: AnyPublisher = { @@ -76,11 +93,16 @@ public final class TunnelSettings { Change.setSelectedServer(server) }.eraseToAnyPublisher() + let environmentChangePublisher = selectedEnvironmentPublisher.map { environment in + Change.setSelectedEnvironment(environment) + }.eraseToAnyPublisher() + return Publishers.MergeMany( includeAllNetworksPublisher, enforceRoutesPublisher, excludeLocalNetworksPublisher, - serverChangePublisher).eraseToAnyPublisher() + serverChangePublisher, + environmentChangePublisher).eraseToAnyPublisher() }() public init(defaults: UserDefaults) { @@ -95,6 +117,7 @@ public final class TunnelSettings { defaults.resetNetworkProtectionSettingIncludeAllNetworks() defaults.resetNetworkProtectionSettingRegistrationKeyValidity() defaults.resetNetworkProtectionSettingSelectedServer() + defaults.resetNetworkProtectionSettingSelectedEnvironment() } // MARK: - Applying Changes @@ -111,6 +134,8 @@ public final class TunnelSettings { self.registrationKeyValidity = registrationKeyValidity case .setSelectedServer(let selectedServer): self.selectedServer = selectedServer + case .setSelectedEnvironment(let selectedEnvironment): + self.selectedEnvironment = selectedEnvironment } } @@ -198,6 +223,22 @@ public final class TunnelSettings { } } + // MARK: - Environment + + public var selectedEnvironmentPublisher: AnyPublisher { + defaults.networkProtectionSettingSelectedEnvironmentPublisher + } + + public var selectedEnvironment: SelectedEnvironment { + get { + defaults.networkProtectionSettingSelectedEnvironment + } + + set { + defaults.networkProtectionSettingSelectedEnvironment = newValue + } + } + // MARK: - Routes public enum ExclusionListItem { diff --git a/Sources/NetworkProtection/StartupOptions.swift b/Sources/NetworkProtection/StartupOptions.swift index aa8cff895..0adea7924 100644 --- a/Sources/NetworkProtection/StartupOptions.swift +++ b/Sources/NetworkProtection/StartupOptions.swift @@ -94,6 +94,7 @@ struct StartupOptions { let simulateCrash: Bool let simulateMemoryCrash: Bool let keyValidity: StoredOption + let selectedEnvironment: StoredOption let selectedServer: StoredOption let authToken: StoredOption let enableTester: StoredOption @@ -121,6 +122,7 @@ struct StartupOptions { authToken = Self.readAuthToken(from: options, resetIfNil: resetStoredOptionsIfNil) enableTester = Self.readEnableTester(from: options, resetIfNil: resetStoredOptionsIfNil) keyValidity = Self.readKeyValidity(from: options, resetIfNil: resetStoredOptionsIfNil) + selectedEnvironment = Self.readSelectedEnvironment(from: options, resetIfNil: resetStoredOptionsIfNil) selectedServer = Self.readSelectedServer(from: options, resetIfNil: resetStoredOptionsIfNil) } @@ -150,6 +152,17 @@ struct StartupOptions { } } + private static func readSelectedEnvironment(from options: [String: Any], resetIfNil: Bool) -> StoredOption { + + StoredOption(resetIfNil: resetIfNil) { + guard let environment = options[NetworkProtectionOptionKey.selectedEnvironment] as? String else { + return nil + } + + return TunnelSettings.SelectedEnvironment(rawValue: environment) ?? .default + } + } + private static func readSelectedServer(from options: [String: Any], resetIfNil: Bool) -> StoredOption { StoredOption(resetIfNil: resetIfNil) { diff --git a/Sources/NetworkProtection/VPNConfiguration/VPNConfigurationManager.swift b/Sources/NetworkProtection/VPNConfiguration/VPNConfigurationManager.swift new file mode 100644 index 000000000..1e376fbb1 --- /dev/null +++ b/Sources/NetworkProtection/VPNConfiguration/VPNConfigurationManager.swift @@ -0,0 +1,36 @@ +// +// VPNConfigurationManager.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import NetworkExtension + +public final class VPNConfigurationManager { + + public init() {} + + public func removeVPNConfiguration() async { + let tunnels = try? await NETunnelProviderManager.loadAllFromPreferences() + + if let tunnels = tunnels { + for tunnel in tunnels { + tunnel.connection.stopVPNTunnel() + try? await tunnel.removeFromPreferences() + } + } + } +} From f7e20cd37bbc0d25ae3c3f25ef52d319366613e7 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Fri, 10 Nov 2023 16:52:23 +0100 Subject: [PATCH 20/31] Sync form factor specific favorites (#511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1201493110486074/1204926049616866/f Tech Design URL: https://app.asana.com/0/481882893211075/1205418608802079/f Description: This change introduces form factor specific favorites to be used when Sync is enabled. 1 favorites folder is replaced by 3 folders – mobile, desktop and unified. Users always see only 1 folder and it's their form-factor-specific one. Only users with Sync enabled get to choose whether they want to see the form-factor-specific favorites or unified (desktop + mobile) favorites. The setting itself is synced between devices in the Sync account. BookmarkEntity's isFavorite is replaced with isFavorite(on:) taking folder as a parameter. API for adding to favorites and removing from favorites was updated to take multiple favorites folders as needed. FavoritesDisplayMode enum is introduced to manage display mode in clients. Bookmarks and Favorites related view models used on iOS are updated to take favorites display mode and reload their data as the mode changes. In SyncDataProviders, an abstract SettingSyncHandler class was added to support adding an arbitrary setting (key-value pair) to Sync. It's subclassed by EmailProtectionSyncHandler and FavoritesDisplayModeSyncHandlerBase (that needs to be subclassed in client apps due to differences in User Defaults storage between iOS and macOS apps). --- .../Bookmarks/BookmarkEditorViewModel.swift | 18 +- Sources/Bookmarks/BookmarkEntity.swift | 81 ++++-- Sources/Bookmarks/BookmarkListViewModel.swift | 33 ++- Sources/Bookmarks/BookmarkUtils.swift | 143 +++++++++-- Sources/Bookmarks/BookmarksModel.swift | 19 +- .../.xccurrentversion | 2 +- .../BookmarksModel 4.xcdatamodel/contents | 26 ++ Sources/Bookmarks/FavoriteListViewModel.swift | 45 +++- Sources/Bookmarks/FavoritesDisplayMode.swift | 138 +++++++++++ .../BookmarkCoreDataImporter.swift | 19 +- .../Bookmarks/MenuBookmarksViewModel.swift | 33 ++- .../BookmarksTestsUtils/BookmarkTree.swift | 32 +-- .../internal/BookmarksResponseHandler.swift | 61 ++--- .../internal/ReceivedBookmarksIndex.swift | 84 ------- .../internal/SyncableBookmarkAdapter.swift | 2 +- .../Settings/SettingsProvider.swift | 34 ++- .../EmailProtectionSyncHandler.swift | 28 +-- .../FavoritesDisplayModeSyncHandlerBase.swift | 32 +++ .../SettingSyncHandler.swift | 57 +++++ ...ndling.swift => SettingSyncHandling.swift} | 14 +- .../internal/SettingsResponseHandler.swift | 4 +- .../BookmarksTests/BookmarkEntityTests.swift | 4 +- .../BookmarkListViewModelTests.swift | 232 +++++++++++++++++- Tests/BookmarksTests/BookmarkUtilsTests.swift | 166 +++++++++++++ .../FavoriteListViewModelTests.swift | 117 ++++++++- ...marksInitialSyncResponseHandlerTests.swift | 32 +-- .../Bookmarks/BookmarksProviderTests.swift | 14 +- ...marksRegularSyncResponseHandlerTests.swift | 107 ++++++-- ...pecificFavoritesFoldersIgnoringTests.swift | 142 ----------- .../helpers/SyncableBookmarksExtension.swift | 10 +- .../SyncDataProvidersTests/CryptingMock.swift | 6 +- ...tingsInitialSyncResponseHandlerTests.swift | 66 ++++- .../Settings/SettingsProviderTests.swift | 181 +++++++++++++- ...tingsRegularSyncResponseHandlerTests.swift | 47 +++- .../helpers/SettingsProviderTestsBase.swift | 10 +- .../helpers/SyncableSettingsExtension.swift | 11 +- .../helpers/TestSettingSyncHandler.swift | 61 +++++ 37 files changed, 1636 insertions(+), 475 deletions(-) create mode 100644 Sources/Bookmarks/BookmarksModel.xcdatamodeld/BookmarksModel 4.xcdatamodel/contents create mode 100644 Sources/Bookmarks/FavoritesDisplayMode.swift delete mode 100644 Sources/SyncDataProviders/Bookmarks/internal/ReceivedBookmarksIndex.swift create mode 100644 Sources/SyncDataProviders/Settings/SettingsSyncHandlers/FavoritesDisplayModeSyncHandlerBase.swift create mode 100644 Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingSyncHandler.swift rename Sources/SyncDataProviders/Settings/SettingsSyncHandlers/{SettingsSyncHandling.swift => SettingSyncHandling.swift} (84%) create mode 100644 Tests/BookmarksTests/BookmarkUtilsTests.swift delete mode 100644 Tests/SyncDataProvidersTests/Bookmarks/FormFactorSpecificFavoritesFoldersIgnoringTests.swift create mode 100644 Tests/SyncDataProvidersTests/Settings/helpers/TestSettingSyncHandler.swift diff --git a/Sources/Bookmarks/BookmarkEditorViewModel.swift b/Sources/Bookmarks/BookmarkEditorViewModel.swift index b7d8cb1ea..c680380b3 100644 --- a/Sources/Bookmarks/BookmarkEditorViewModel.swift +++ b/Sources/Bookmarks/BookmarkEditorViewModel.swift @@ -31,11 +31,15 @@ public class BookmarkEditorViewModel: ObservableObject { } let context: NSManagedObjectContext + public let favoritesDisplayMode: FavoritesDisplayMode @Published public var bookmark: BookmarkEntity @Published public var locations = [Location]() - lazy var favoritesFolder: BookmarkEntity! = BookmarkUtils.fetchFavoritesFolder(context) + lazy var favoritesFolder: BookmarkEntity! = BookmarkUtils.fetchFavoritesFolder( + withUUID: favoritesDisplayMode.displayedFolder.rawValue, + in: context + ) private var observer: NSObjectProtocol? private let subject = PassthroughSubject() @@ -59,11 +63,13 @@ public class BookmarkEditorViewModel: ObservableObject { public init(editingEntityID: NSManagedObjectID, bookmarksDatabase: CoreDataDatabase, + favoritesDisplayMode: FavoritesDisplayMode, errorEvents: EventMapping?) { externalUpdates = subject.eraseToAnyPublisher() self.errorEvents = errorEvents self.context = bookmarksDatabase.makeContext(concurrencyType: .mainQueueConcurrencyType) + self.favoritesDisplayMode = favoritesDisplayMode guard let entity = context.object(with: editingEntityID) as? BookmarkEntity else { // For sync, this is valid scenario in case of a timing issue @@ -84,11 +90,13 @@ public class BookmarkEditorViewModel: ObservableObject { public init(creatingFolderWithParentID parentFolderID: NSManagedObjectID?, bookmarksDatabase: CoreDataDatabase, + favoritesDisplayMode: FavoritesDisplayMode, errorEvents: EventMapping?) { externalUpdates = subject.eraseToAnyPublisher() self.errorEvents = errorEvents self.context = bookmarksDatabase.makeContext(concurrencyType: .mainQueueConcurrencyType) + self.favoritesDisplayMode = favoritesDisplayMode let parent: BookmarkEntity? if let parentFolderID = parentFolderID { @@ -173,13 +181,13 @@ public class BookmarkEditorViewModel: ObservableObject { } public func removeFromFavorites() { - assert(bookmark.isFavorite) - bookmark.removeFromFavorites() + assert(bookmark.isFavorite(on: favoritesDisplayMode.displayedFolder)) + bookmark.removeFromFavorites(with: favoritesDisplayMode) } public func addToFavorites() { - assert(!bookmark.isFavorite) - bookmark.addToFavorites(favoritesRoot: favoritesFolder) + assert(!bookmark.isFavorite(on: favoritesDisplayMode.displayedFolder)) + bookmark.addToFavorites(with: favoritesDisplayMode, in: context) } public func setParentWithID(_ parentID: NSManagedObjectID) { diff --git a/Sources/Bookmarks/BookmarkEntity.swift b/Sources/Bookmarks/BookmarkEntity.swift index f73d03592..26b16a11a 100644 --- a/Sources/Bookmarks/BookmarkEntity.swift +++ b/Sources/Bookmarks/BookmarkEntity.swift @@ -21,12 +21,28 @@ import Foundation import CoreData +/** + * This enum defines available favorites folders with their UUIDs as raw value. + */ +public enum FavoritesFolderID: String, CaseIterable { + /// Mobile form factor favorites folder + case mobile = "mobile_favorites_root" + /// Desktop form factor favorites folder + case desktop = "desktop_favorites_root" + /// Unified (mobile + desktop) favorites folder + case unified = "favorites_root" +} + @objc(BookmarkEntity) public class BookmarkEntity: NSManagedObject { public enum Constants { public static let rootFolderID = "bookmarks_root" - public static let favoritesFolderID = "favorites_root" + public static let favoriteFoldersIDs: Set = Set(FavoritesFolderID.allCases.map(\.rawValue)) + } + + public static func isValidFavoritesFolderID(_ value: String) -> Bool { + FavoritesFolderID.allCases.contains { $0.rawValue == value } } public enum Error: Swift.Error { @@ -49,7 +65,7 @@ public class BookmarkEntity: NSManagedObject { @NSManaged public var url: String? @NSManaged public var uuid: String? @NSManaged public var children: NSOrderedSet? - @NSManaged fileprivate(set) public var favoriteFolder: BookmarkEntity? + @NSManaged public fileprivate(set) var favoriteFolders: NSSet? @NSManaged public fileprivate(set) var favorites: NSOrderedSet? @NSManaged public var parent: BookmarkEntity? @@ -58,8 +74,12 @@ public class BookmarkEntity: NSManagedObject { /// In-memory flag. When set to `false`, disables adjusting `modifiedAt` on `willSave()`. It's reset to `true` on `didSave()`. public var shouldManageModifiedAt: Bool = true - public var isFavorite: Bool { - favoriteFolder != nil + public func isFavorite(on platform: FavoritesFolderID) -> Bool { + favoriteFoldersSet.contains { $0.uuid == platform.rawValue } + } + + public var favoritedOn: [FavoritesFolderID] { + favoriteFoldersSet.compactMap(\.uuid).compactMap(FavoritesFolderID.init) } public convenience init(context moc: NSManagedObjectContext) { @@ -81,7 +101,7 @@ public class BookmarkEntity: NSManagedObject { guard !changedKeys.isEmpty, !changedKeys.contains(NSStringFromSelector(#selector(getter: modifiedAt))) else { return } - if isInserted && (uuid == Constants.rootFolderID || uuid == Constants.favoritesFolderID) { + if isInserted, let uuid, uuid == Constants.rootFolderID || Self.isValidFavoritesFolderID(uuid) { return } modifiedAt = Date() @@ -123,6 +143,10 @@ public class BookmarkEntity: NSManagedObject { return children.filter { $0.isPendingDeletion == false } } + public var favoriteFoldersSet: Set { + return favoriteFolders.flatMap(Set.init) ?? [] + } + public static func makeFolder(title: String, parent: BookmarkEntity, insertAtBeginning: Bool = false, @@ -168,9 +192,21 @@ public class BookmarkEntity: NSManagedObject { root.addToFavorites(self) } } - - public func removeFromFavorites() { - favoriteFolder = nil + + public func addToFavorites(folders: [BookmarkEntity]) { + for root in folders { + root.addToFavorites(self) + } + } + + public func removeFromFavorites(folders: [BookmarkEntity]) { + for root in folders { + root.removeFromFavorites(self) + } + } + + public func removeFromFavorites(favoritesRoot: BookmarkEntity) { + favoritesRoot.removeFromFavorites(self) } public func markPendingDeletion() { @@ -200,20 +236,12 @@ extension BookmarkEntity { func validate() throws { try validateThatFoldersDoNotHaveURLs() try validateThatFolderHierarchyHasNoCycles() - try validateFavoritesStatus() try validateFavoritesFolder() } - func validateFavoritesStatus() throws { - let isInFavoriteCollection = favoriteFolder != nil - if isFavorite != isInFavoriteCollection { - throw Error.invalidFavoritesStatus - } - } - func validateFavoritesFolder() throws { - if let favoritesFolderID = favoriteFolder?.uuid, - favoritesFolderID != Constants.favoritesFolderID { + let uuids = Set(favoriteFoldersSet.compactMap(\.uuid)) + guard uuids.isSubset(of: Constants.favoriteFoldersIDs) else { throw Error.invalidFavoritesFolder } } @@ -296,6 +324,23 @@ extension BookmarkEntity { } +// MARK: Generated accessors for favoriteFolders +extension BookmarkEntity { + + @objc(addFavoriteFoldersObject:) + @NSManaged private func addToFavoriteFolders(_ value: BookmarkEntity) + + @objc(removeFavoriteFoldersObject:) + @NSManaged private func removeFromFavoriteFolders(_ value: BookmarkEntity) + + @objc(addFavoriteFolders:) + @NSManaged private func addToFavoriteFolders(_ values: NSSet) + + @objc(removeFavoriteFolders:) + @NSManaged private func removeFromFavoriteFolders(_ values: NSSet) + +} + extension BookmarkEntity: Identifiable { } diff --git a/Sources/Bookmarks/BookmarkListViewModel.swift b/Sources/Bookmarks/BookmarkListViewModel.swift index c5f8452bb..a2bb9a3d2 100644 --- a/Sources/Bookmarks/BookmarkListViewModel.swift +++ b/Sources/Bookmarks/BookmarkListViewModel.swift @@ -27,6 +27,11 @@ public class BookmarkListViewModel: BookmarkListInteracting, ObservableObject { public let currentFolder: BookmarkEntity? let context: NSManagedObjectContext + public var favoritesDisplayMode: FavoritesDisplayMode { + didSet { + reloadData() + } + } public var bookmarks = [BookmarkEntity]() @@ -40,11 +45,13 @@ public class BookmarkListViewModel: BookmarkListInteracting, ObservableObject { public init(bookmarksDatabase: CoreDataDatabase, parentID: NSManagedObjectID?, + favoritesDisplayMode: FavoritesDisplayMode, errorEvents: EventMapping?) { self.externalUpdates = self.subject.eraseToAnyPublisher() self.localUpdates = self.localSubject.eraseToAnyPublisher() self.errorEvents = errorEvents self.context = bookmarksDatabase.makeContext(concurrencyType: .mainQueueConcurrencyType) + self.favoritesDisplayMode = favoritesDisplayMode if let parentID = parentID { if let bookmark = (try? context.existingObject(with: parentID)) as? BookmarkEntity { @@ -75,19 +82,19 @@ public class BookmarkListViewModel: BookmarkListInteracting, ObservableObject { } } - // swiftlint:disable:next function_parameter_count - public func createBookmark(title: String, - url: String, - folder: BookmarkEntity, - folderIndex: Int, - favoritesFolder: BookmarkEntity?, - favoritesIndex: Int?) { + public func createBookmark( + title: String, + url: String, + folder: BookmarkEntity, + folderIndex: Int, + favoritesFoldersAndIndexes: [BookmarkEntity: Int] + ) { let bookmark = BookmarkEntity.makeBookmark(title: title, url: url, parent: folder, context: context) if let addedIndex = folder.childrenArray.firstIndex(of: bookmark) { moveBookmark(bookmark, fromIndex: addedIndex, toIndex: folderIndex) } - if let favoritesFolder, let favoritesIndex { - bookmark.addToFavorites(insertAt: favoritesIndex, favoritesRoot: favoritesFolder) + for (favoritesFolder, index) in favoritesFoldersAndIndexes { + bookmark.addToFavorites(insertAt: index, favoritesRoot: favoritesFolder) } save() } @@ -115,10 +122,10 @@ public class BookmarkListViewModel: BookmarkListInteracting, ObservableObject { } public func toggleFavorite(_ bookmark: BookmarkEntity) { - if bookmark.isFavorite { - bookmark.removeFromFavorites() - } else if let folder = BookmarkUtils.fetchFavoritesFolder(context) { - bookmark.addToFavorites(favoritesRoot: folder) + if bookmark.isFavorite(on: favoritesDisplayMode.displayedFolder) { + bookmark.removeFromFavorites(with: favoritesDisplayMode) + } else { + bookmark.addToFavorites(with: favoritesDisplayMode, in: context) } save() } diff --git a/Sources/Bookmarks/BookmarkUtils.swift b/Sources/Bookmarks/BookmarkUtils.swift index 4d47b2bc2..236b0f2c5 100644 --- a/Sources/Bookmarks/BookmarkUtils.swift +++ b/Sources/Bookmarks/BookmarkUtils.swift @@ -30,21 +30,38 @@ public struct BookmarkUtils { return try? context.fetch(request).first } - public static func fetchFavoritesFolder(_ context: NSManagedObjectContext) -> BookmarkEntity? { + public static func fetchFavoritesFolders(for displayMode: FavoritesDisplayMode, in context: NSManagedObjectContext) -> [BookmarkEntity] { + fetchFavoritesFolders(withUUIDs: displayMode.folderUUIDs, in: context) + } + + public static func fetchFavoritesFolder(withUUID uuid: String, in context: NSManagedObjectContext) -> BookmarkEntity? { + assert(BookmarkEntity.isValidFavoritesFolderID(uuid)) + let request = BookmarkEntity.fetchRequest() - request.predicate = NSPredicate(format: "%K == %@", #keyPath(BookmarkEntity.uuid), BookmarkEntity.Constants.favoritesFolderID) + request.predicate = NSPredicate(format: "%K == %@", #keyPath(BookmarkEntity.uuid), uuid) request.returnsObjectsAsFaults = false request.fetchLimit = 1 - + return try? context.fetch(request).first } + public static func fetchFavoritesFolders(withUUIDs uuids: Set, in context: NSManagedObjectContext) -> [BookmarkEntity] { + assert(uuids.allSatisfy { BookmarkEntity.isValidFavoritesFolderID($0) }) + + let request = BookmarkEntity.fetchRequest() + request.predicate = NSPredicate(format: "%K in %@", #keyPath(BookmarkEntity.uuid), uuids) + request.returnsObjectsAsFaults = false + request.fetchLimit = uuids.count + + return (try? context.fetch(request)) ?? [] + } + public static func fetchOrphanedEntities(_ context: NSManagedObjectContext) -> [BookmarkEntity] { let request = BookmarkEntity.fetchRequest() request.predicate = NSPredicate( format: "NOT %K IN %@ AND %K == NO AND %K == nil", #keyPath(BookmarkEntity.uuid), - [BookmarkEntity.Constants.rootFolderID, BookmarkEntity.Constants.favoritesFolderID], + BookmarkEntity.Constants.favoriteFoldersIDs.union([BookmarkEntity.Constants.rootFolderID]), #keyPath(BookmarkEntity.isPendingDeletion), #keyPath(BookmarkEntity.parent) ) @@ -55,24 +72,81 @@ public struct BookmarkUtils { } public static func prepareFoldersStructure(in context: NSManagedObjectContext) { - - func insertRootFolder(uuid: String, into context: NSManagedObjectContext) { - let folder = BookmarkEntity(entity: BookmarkEntity.entity(in: context), - insertInto: context) - folder.uuid = uuid - folder.title = uuid - folder.isFolder = true - } - + if fetchRootFolder(context) == nil { insertRootFolder(uuid: BookmarkEntity.Constants.rootFolderID, into: context) } - - if fetchFavoritesFolder(context) == nil { - insertRootFolder(uuid: BookmarkEntity.Constants.favoritesFolderID, into: context) + + for uuid in BookmarkEntity.Constants.favoriteFoldersIDs where fetchFavoritesFolder(withUUID: uuid, in: context) == nil { + insertRootFolder(uuid: uuid, into: context) + } + } + + public static func migrateToFormFactorSpecificFavorites(byCopyingExistingTo folderID: FavoritesFolderID, in context: NSManagedObjectContext) { + assert(folderID != .unified, "You must specify either desktop or mobile folder") + + guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.unified.rawValue, in: context) else { + return + } + + if BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.desktop.rawValue, in: context) == nil { + let desktopFavoritesFolder = insertRootFolder(uuid: FavoritesFolderID.desktop.rawValue, into: context) + + if folderID == .desktop { + favoritesFolder.favoritesArray.forEach { bookmark in + bookmark.addToFavorites(favoritesRoot: desktopFavoritesFolder) + } + } else { + desktopFavoritesFolder.shouldManageModifiedAt = false + } + } + + if BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.mobile.rawValue, in: context) == nil { + let mobileFavoritesFolder = insertRootFolder(uuid: FavoritesFolderID.mobile.rawValue, into: context) + + if folderID == .mobile { + favoritesFolder.favoritesArray.forEach { bookmark in + bookmark.addToFavorites(favoritesRoot: mobileFavoritesFolder) + } + } else { + mobileFavoritesFolder.shouldManageModifiedAt = false + } + } + } + + public static func copyFavorites( + from sourceFolderID: FavoritesFolderID, + to targetFolderID: FavoritesFolderID, + clearingNonNativeFavoritesFolder nonNativeFolderID: FavoritesFolderID, + in context: NSManagedObjectContext + ) { + assert(nonNativeFolderID != .unified, "You must specify either desktop or mobile folder") + assert(Set([sourceFolderID, targetFolderID, nonNativeFolderID]).count == 3, "You must pass 3 different folder IDs to this function") + assert([sourceFolderID, targetFolderID].contains(FavoritesFolderID.unified), "You must copy to or from a unified folder") + + let allFavoritesFolders = BookmarkUtils.fetchFavoritesFolders(withUUIDs: Set(FavoritesFolderID.allCases.map(\.rawValue)), in: context) + assert(allFavoritesFolders.count == FavoritesFolderID.allCases.count, "Favorites folders missing") + + guard let sourceFavoritesFolder = allFavoritesFolders.first(where: { $0.uuid == sourceFolderID.rawValue }), + let targetFavoritesFolder = allFavoritesFolders.first(where: { $0.uuid == targetFolderID.rawValue }), + let nonNativeFormFactorFavoritesFolder = allFavoritesFolders.first(where: { $0.uuid == nonNativeFolderID.rawValue }) + else { + return + } + + nonNativeFormFactorFavoritesFolder.favoritesArray.forEach { bookmark in + bookmark.removeFromFavorites(favoritesRoot: nonNativeFormFactorFavoritesFolder) + } + + targetFavoritesFolder.favoritesArray.forEach { bookmark in + bookmark.removeFromFavorites(favoritesRoot: targetFavoritesFolder) + } + + sourceFavoritesFolder.favoritesArray.forEach { bookmark in + bookmark.addToFavorites(favoritesRoot: targetFavoritesFolder) } } - + public static func fetchBookmark(for url: URL, predicate: NSPredicate = NSPredicate(value: true), context: NSManagedObjectContext) -> BookmarkEntity? { @@ -101,4 +175,39 @@ public struct BookmarkUtils { return (try? context.fetch(request)) ?? [] } + + // MARK: Internal + + @discardableResult + static func insertRootFolder(uuid: String, into context: NSManagedObjectContext) -> BookmarkEntity { + let folder = BookmarkEntity(entity: BookmarkEntity.entity(in: context), + insertInto: context) + folder.uuid = uuid + folder.title = uuid + folder.isFolder = true + + return folder + } +} + +// MARK: - Legacy Migration Support + +extension BookmarkUtils { + + public static func prepareLegacyFoldersStructure(in context: NSManagedObjectContext) { + + if fetchRootFolder(context) == nil { + insertRootFolder(uuid: BookmarkEntity.Constants.rootFolderID, into: context) + } + + if fetchLegacyFavoritesFolder(context) == nil { + insertRootFolder(uuid: legacyFavoritesFolderID, into: context) + } + } + + public static func fetchLegacyFavoritesFolder(_ context: NSManagedObjectContext) -> BookmarkEntity? { + fetchFavoritesFolder(withUUID: legacyFavoritesFolderID, in: context) + } + + static let legacyFavoritesFolderID = FavoritesFolderID.unified.rawValue } diff --git a/Sources/Bookmarks/BookmarksModel.swift b/Sources/Bookmarks/BookmarksModel.swift index c83a42931..85084c283 100644 --- a/Sources/Bookmarks/BookmarksModel.swift +++ b/Sources/Bookmarks/BookmarksModel.swift @@ -28,8 +28,10 @@ public protocol BookmarkStoring { func reloadData() } -public protocol BookmarkListInteracting: BookmarkStoring { - +public protocol BookmarkListInteracting: BookmarkStoring, AnyObject { + + var favoritesDisplayMode: FavoritesDisplayMode { get set } + var currentFolder: BookmarkEntity? { get } var bookmarks: [BookmarkEntity] { get } var totalBookmarksCount: Int { get } @@ -48,15 +50,16 @@ public protocol BookmarkListInteracting: BookmarkStoring { func countBookmarksForDomain(_ domain: String) -> Int - // swiftlint:disable:next function_parameter_count - func createBookmark(title: String, url: String, folder: BookmarkEntity, folderIndex: Int, favoritesFolder: BookmarkEntity?, favoritesIndex: Int?) + func createBookmark(title: String, url: String, folder: BookmarkEntity, folderIndex: Int, favoritesFoldersAndIndexes: [BookmarkEntity: Int]) } -public protocol FavoritesListInteracting: BookmarkStoring { - +public protocol FavoritesListInteracting: BookmarkStoring, AnyObject { + + var favoritesDisplayMode: FavoritesDisplayMode { get set } + var favorites: [BookmarkEntity] { get } - + func favorite(at index: Int) -> BookmarkEntity? func removeFavorite(_ favorite: BookmarkEntity) @@ -68,6 +71,8 @@ public protocol FavoritesListInteracting: BookmarkStoring { public protocol MenuBookmarksInteracting { + var favoritesDisplayMode: FavoritesDisplayMode { get set } + func createOrToggleFavorite(title: String, url: URL) func createBookmark(title: String, url: URL) diff --git a/Sources/Bookmarks/BookmarksModel.xcdatamodeld/.xccurrentversion b/Sources/Bookmarks/BookmarksModel.xcdatamodeld/.xccurrentversion index f6574b696..9ebe855d9 100644 --- a/Sources/Bookmarks/BookmarksModel.xcdatamodeld/.xccurrentversion +++ b/Sources/Bookmarks/BookmarksModel.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - BookmarksModel 3.xcdatamodel + BookmarksModel 4.xcdatamodel diff --git a/Sources/Bookmarks/BookmarksModel.xcdatamodeld/BookmarksModel 4.xcdatamodel/contents b/Sources/Bookmarks/BookmarksModel.xcdatamodeld/BookmarksModel 4.xcdatamodel/contents new file mode 100644 index 000000000..539012d01 --- /dev/null +++ b/Sources/Bookmarks/BookmarksModel.xcdatamodeld/BookmarksModel 4.xcdatamodel/contents @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/Bookmarks/FavoriteListViewModel.swift b/Sources/Bookmarks/FavoriteListViewModel.swift index 291135443..f5dfbe33b 100644 --- a/Sources/Bookmarks/FavoriteListViewModel.swift +++ b/Sources/Bookmarks/FavoriteListViewModel.swift @@ -27,6 +27,12 @@ public class FavoritesListViewModel: FavoritesListInteracting, ObservableObject let context: NSManagedObjectContext public var favorites = [BookmarkEntity]() + public var favoritesDisplayMode: FavoritesDisplayMode { + didSet { + _favoritesFolder = nil + reloadData() + } + } private var observer: NSObjectProtocol? private let subject = PassthroughSubject() @@ -36,12 +42,28 @@ public class FavoritesListViewModel: FavoritesListInteracting, ObservableObject private let errorEvents: EventMapping? - public init(bookmarksDatabase: CoreDataDatabase, - errorEvents: EventMapping?) { + private var _favoritesFolder: BookmarkEntity? + private var favoriteFolder: BookmarkEntity? { + if _favoritesFolder == nil { + _favoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: favoritesDisplayMode.displayedFolder.rawValue, in: context) + + if _favoritesFolder == nil { + errorEvents?.fire(.fetchingRootItemFailed(.favorites)) + } + } + return _favoritesFolder + } + + public init( + bookmarksDatabase: CoreDataDatabase, + errorEvents: EventMapping?, + favoritesDisplayMode: FavoritesDisplayMode + ) { self.externalUpdates = self.subject.eraseToAnyPublisher() self.localUpdates = self.localSubject.eraseToAnyPublisher() + self.favoritesDisplayMode = favoritesDisplayMode self.errorEvents = errorEvents - + self.context = bookmarksDatabase.makeContext(concurrencyType: .mainQueueConcurrencyType) refresh() registerForChanges() @@ -78,13 +100,13 @@ public class FavoritesListViewModel: FavoritesListInteracting, ObservableObject } private func refresh() { - guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) else { + guard let favoriteFolder else { errorEvents?.fire(.fetchingRootItemFailed(.favorites)) favorites = [] return } - readFavorites(with: favoritesFolder) + readFavorites(with: favoriteFolder) } public func favorite(at index: Int) -> BookmarkEntity? { @@ -97,12 +119,12 @@ public class FavoritesListViewModel: FavoritesListInteracting, ObservableObject } public func removeFavorite(_ favorite: BookmarkEntity) { - guard let favoriteFolder = favorite.favoriteFolder else { - errorEvents?.fire(.missingParent(.favorite)) + guard let favoriteFolder else { + errorEvents?.fire(.fetchingRootItemFailed(.favorites)) return } - favorite.removeFromFavorites() + favorite.removeFromFavorites(with: favoritesDisplayMode) save() @@ -112,8 +134,8 @@ public class FavoritesListViewModel: FavoritesListInteracting, ObservableObject public func moveFavorite(_ favorite: BookmarkEntity, fromIndex: Int, toIndex: Int) { - guard let favoriteFolder = favorite.favoriteFolder else { - errorEvents?.fire(.missingParent(.favorite)) + guard let favoriteFolder else { + errorEvents?.fire(.fetchingRootItemFailed(.favorites)) return } @@ -160,7 +182,6 @@ public class FavoritesListViewModel: FavoritesListInteracting, ObservableObject } private func readFavorites(with favoritesFolder: BookmarkEntity) { - favorites = (favoritesFolder.favorites?.array as? [BookmarkEntity] ?? []) - .filter { !$0.isPendingDeletion } + favorites = favoritesFolder.favoritesArray } } diff --git a/Sources/Bookmarks/FavoritesDisplayMode.swift b/Sources/Bookmarks/FavoritesDisplayMode.swift new file mode 100644 index 000000000..8c1058857 --- /dev/null +++ b/Sources/Bookmarks/FavoritesDisplayMode.swift @@ -0,0 +1,138 @@ +// +// FavoritesDisplayMode.swift +// DuckDuckGo +// +// 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 CoreData +import Foundation + +/** + * This enum defines which set of favorites should be displayed to the user. + * + * Users only ever see one set of favorites at a time, and as long as Sync + * is not enabled, it's the one corresponding to the local device (native) + * form factor, i.e. `mobile` on iOS and iPadOS and `desktop` on macOS. + * + * When Sync is enabled, users get to choose between displaying their native + * form factor folder, or a unified folder that contains favorites from + * both mobile and desktop combined. + */ +public enum FavoritesDisplayMode: Equatable { + /** + * Display native form factor favorites. + * + * This case takes a parameter that specifies the native form factor. + * It's up to the client app to define its native form factor. + * + * Using a parameter gives the flexibility of overriding the form factor + * on a given client in the future (e.g. treat `desktop` as native form + * factor on the iPad). + */ + case displayNative(FavoritesFolderID) + + /** + * Display unified favorites (mobile + desktop combined) + * + * This case takes a parameter that specifies the native form factor. + * It's required because all favorites that are added to or deleted from + * the unified folder need also to be added to or deleted from their + * respective native form factor folder. + */ + case displayUnified(native: FavoritesFolderID) + + /// Returns true if the current mode is to display unified folder. + public var isDisplayUnified: Bool { + switch self { + case .displayNative: + return false + case .displayUnified: + return true + } + } + + /// Returns the UUID of a folder that is displayed for a given display mode. + public var displayedFolder: FavoritesFolderID { + switch self { + case .displayNative(let platform): + return platform + case .displayUnified: + return .unified + } + } + + /// Returns the UUID of a native favorites folder for a given display mode. + public var nativeFolder: FavoritesFolderID { + switch self { + case .displayNative(let native), .displayUnified(let native): + return native + } + } + + /// Returns UUIDs of folders that all favorites must be added to in the current display mode. + var folderUUIDs: Set { + [nativeFolder.rawValue, FavoritesFolderID.unified.rawValue] + } +} + +extension FavoritesDisplayMode: CustomStringConvertible { + public var description: String { + switch self { + case .displayNative: + return "display_native" + case .displayUnified: + return "display_all" + } + } +} + +extension BookmarkEntity { + + /** + * Adds sender to favorites according to `displayMode` passed as argument. + */ + public func addToFavorites(with displayMode: FavoritesDisplayMode, in context: NSManagedObjectContext) { + let folders = BookmarkUtils.fetchFavoritesFolders(withUUIDs: displayMode.folderUUIDs, in: context) + addToFavorites(folders: folders) + } + + /** + * Removes sender from favorites according to `displayMode` passed as argument. + * + * When current mode is to display unified favorites - a favorite is removed from all folders. + * When current mode is to display native form factor - it's removed from the native form factor + * folder, and if it's not favorites on non-native form factor then also removed from unified folder. + */ + public func removeFromFavorites(with displayMode: FavoritesDisplayMode) { + let affectedFolders: [BookmarkEntity] = { + let isFavoritedOnlyOnNativeFormFactor = Set(favoriteFoldersSet.compactMap(\.uuid)) == displayMode.folderUUIDs + if displayMode.isDisplayUnified || isFavoritedOnlyOnNativeFormFactor { + return Array(favoriteFoldersSet) + } + if let nativeFolder = favoriteFoldersSet.first(where: { $0.uuid == displayMode.nativeFolder.rawValue }) { + return [nativeFolder] + } + return [] + }() + + assert(!affectedFolders.isEmpty) + + if !affectedFolders.isEmpty { + removeFromFavorites(folders: affectedFolders) + } + } + +} diff --git a/Sources/Bookmarks/ImportExport/BookmarkCoreDataImporter.swift b/Sources/Bookmarks/ImportExport/BookmarkCoreDataImporter.swift index 74ba5eccd..4a9202039 100644 --- a/Sources/Bookmarks/ImportExport/BookmarkCoreDataImporter.swift +++ b/Sources/Bookmarks/ImportExport/BookmarkCoreDataImporter.swift @@ -23,9 +23,11 @@ import Persistence public class BookmarkCoreDataImporter { let context: NSManagedObjectContext + let favoritesDisplayMode: FavoritesDisplayMode - public init(database: CoreDataDatabase) { + public init(database: CoreDataDatabase, favoritesDisplayMode: FavoritesDisplayMode) { self.context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + self.favoritesDisplayMode = favoritesDisplayMode } public func importBookmarks(_ bookmarks: [BookmarkOrFolder]) async throws { @@ -34,8 +36,9 @@ public class BookmarkCoreDataImporter { context.performAndWait { () -> Void in do { - guard let topLevelBookmarksFolder = BookmarkUtils.fetchRootFolder(context), - let topLevelFavoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) else { + let favoritesFolders = BookmarkUtils.fetchFavoritesFolders(for: favoritesDisplayMode, in: context) + + guard let topLevelBookmarksFolder = BookmarkUtils.fetchRootFolder(context) else { throw BookmarksCoreDataError.fetchingExistingItemFailed } @@ -43,7 +46,7 @@ public class BookmarkCoreDataImporter { try recursivelyCreateEntities(from: bookmarks, parent: topLevelBookmarksFolder, - favoritesRoot: topLevelFavoritesFolder, + favoritesFolders: favoritesFolders, bookmarkURLToIDMap: &bookmarkURLToIDMap) try context.save() continuation.resume() @@ -85,7 +88,7 @@ public class BookmarkCoreDataImporter { private func recursivelyCreateEntities(from bookmarks: [BookmarkOrFolder], parent: BookmarkEntity, - favoritesRoot: BookmarkEntity, + favoritesFolders: [BookmarkEntity], bookmarkURLToIDMap: inout [String: NSManagedObjectID]) throws { for bookmarkOrFolder in bookmarks { if bookmarkOrFolder.isInvalidBookmark { @@ -100,20 +103,20 @@ public class BookmarkCoreDataImporter { if let children = bookmarkOrFolder.children { try recursivelyCreateEntities(from: children, parent: folder, - favoritesRoot: favoritesRoot, + favoritesFolders: favoritesFolders, bookmarkURLToIDMap: &bookmarkURLToIDMap) } case .favorite: if let url = bookmarkOrFolder.url { if let objectID = bookmarkURLToIDMap[url.absoluteString], let bookmark = try? context.existingObject(with: objectID) as? BookmarkEntity { - bookmark.addToFavorites(favoritesRoot: favoritesRoot) + bookmark.addToFavorites(folders: favoritesFolders) } else { let newFavorite = BookmarkEntity.makeBookmark(title: bookmarkOrFolder.name, url: url.absoluteString, parent: parent, context: context) - newFavorite.addToFavorites(favoritesRoot: favoritesRoot) + newFavorite.addToFavorites(folders: favoritesFolders) bookmarkURLToIDMap[url.absoluteString] = newFavorite.objectID } } diff --git a/Sources/Bookmarks/MenuBookmarksViewModel.swift b/Sources/Bookmarks/MenuBookmarksViewModel.swift index a36ad8621..edb25fcad 100644 --- a/Sources/Bookmarks/MenuBookmarksViewModel.swift +++ b/Sources/Bookmarks/MenuBookmarksViewModel.swift @@ -24,7 +24,12 @@ import Persistence public class MenuBookmarksViewModel: MenuBookmarksInteracting { let context: NSManagedObjectContext - + public var favoritesDisplayMode: FavoritesDisplayMode = .displayNative(.mobile) { + didSet { + _favoritesFolder = nil + } + } + private var _rootFolder: BookmarkEntity? private var rootFolder: BookmarkEntity? { if _rootFolder == nil { @@ -40,7 +45,7 @@ public class MenuBookmarksViewModel: MenuBookmarksInteracting { private var _favoritesFolder: BookmarkEntity? private var favoritesFolder: BookmarkEntity? { if _favoritesFolder == nil { - _favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) + _favoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: favoritesDisplayMode.displayedFolder.rawValue, in: context) if _favoritesFolder == nil { errorEvents?.fire(.fetchingRootItemFailed(.menu)) @@ -53,8 +58,7 @@ public class MenuBookmarksViewModel: MenuBookmarksInteracting { private let errorEvents: EventMapping? - public init(bookmarksDatabase: CoreDataDatabase, - errorEvents: EventMapping?) { + public init(bookmarksDatabase: CoreDataDatabase, errorEvents: EventMapping?) { self.errorEvents = errorEvents self.context = bookmarksDatabase.makeContext(concurrencyType: .mainQueueConcurrencyType) registerForChanges() @@ -92,25 +96,24 @@ public class MenuBookmarksViewModel: MenuBookmarksInteracting { } public func createOrToggleFavorite(title: String, url: URL) { - guard let favoritesFolder = favoritesFolder, - let rootFolder = rootFolder else { + guard let rootFolder = rootFolder else { return } let queriedBookmark = favorite(for: url) ?? bookmark(for: url) if let bookmark = queriedBookmark { - if bookmark.isFavorite { - bookmark.removeFromFavorites() + if bookmark.isFavorite(on: favoritesDisplayMode.displayedFolder) { + bookmark.removeFromFavorites(with: favoritesDisplayMode) } else { - bookmark.addToFavorites(favoritesRoot: favoritesFolder) + bookmark.addToFavorites(with: favoritesDisplayMode, in: context) } } else { let favorite = BookmarkEntity.makeBookmark(title: title, url: url.absoluteString, parent: rootFolder, context: context) - favorite.addToFavorites(favoritesRoot: favoritesFolder) + favorite.addToFavorites(with: favoritesDisplayMode, in: context) } save() @@ -128,10 +131,14 @@ public class MenuBookmarksViewModel: MenuBookmarksInteracting { } public func favorite(for url: URL) -> BookmarkEntity? { - BookmarkUtils.fetchBookmark(for: url, + guard let favoritesFolder else { + return nil + } + return BookmarkUtils.fetchBookmark(for: url, predicate: NSPredicate( - format: "%K != nil AND %K == NO", - #keyPath(BookmarkEntity.favoriteFolder), + format: "ANY %K CONTAINS %@ AND %K == NO", + #keyPath(BookmarkEntity.favoriteFolders), + favoritesFolder, #keyPath(BookmarkEntity.isPendingDeletion) ), context: context) diff --git a/Sources/BookmarksTestsUtils/BookmarkTree.swift b/Sources/BookmarksTestsUtils/BookmarkTree.swift index 50d3526d4..e50347c4d 100644 --- a/Sources/BookmarksTestsUtils/BookmarkTree.swift +++ b/Sources/BookmarksTestsUtils/BookmarkTree.swift @@ -60,7 +60,7 @@ public struct ModifiedAtConstraint { } public enum BookmarkTreeNode { - case bookmark(id: String, name: String?, url: String?, isFavorite: Bool, modifiedAt: Date?, isDeleted: Bool, isOrphaned: Bool, modifiedAtConstraint: ModifiedAtConstraint?) + case bookmark(id: String, name: String?, url: String?, favoritedOn: [FavoritesFolderID], modifiedAt: Date?, isDeleted: Bool, isOrphaned: Bool, modifiedAtConstraint: ModifiedAtConstraint?) case folder(id: String, name: String?, children: [BookmarkTreeNode], modifiedAt: Date?, isDeleted: Bool, isOrphaned: Bool, modifiedAtConstraint: ModifiedAtConstraint?) public var id: String { @@ -126,17 +126,17 @@ public struct Bookmark: BookmarkTreeNodeConvertible { var id: String var name: String? var url: String? - var isFavorite: Bool + var favoritedOn: [FavoritesFolderID] var modifiedAt: Date? var isDeleted: Bool var isOrphaned: Bool var modifiedAtConstraint: ModifiedAtConstraint? - public init(_ name: String? = nil, id: String? = nil, url: String? = nil, isFavorite: Bool = false, modifiedAt: Date? = nil, isDeleted: Bool = false, isOrphaned: Bool = false, modifiedAtConstraint: ModifiedAtConstraint? = nil) { + public init(_ name: String? = nil, id: String? = nil, url: String? = nil, favoritedOn: [FavoritesFolderID] = [], modifiedAt: Date? = nil, isDeleted: Bool = false, isOrphaned: Bool = false, modifiedAtConstraint: ModifiedAtConstraint? = nil) { self.id = id ?? UUID().uuidString self.name = name ?? id self.url = (url ?? name) ?? id - self.isFavorite = isFavorite + self.favoritedOn = favoritedOn self.modifiedAt = modifiedAt self.isDeleted = isDeleted self.modifiedAtConstraint = modifiedAtConstraint @@ -144,7 +144,7 @@ public struct Bookmark: BookmarkTreeNodeConvertible { } public func asBookmarkTreeNode() -> BookmarkTreeNode { - .bookmark(id: id, name: name, url: url, isFavorite: isFavorite, modifiedAt: modifiedAt, isDeleted: isDeleted, isOrphaned: isOrphaned, modifiedAtConstraint: modifiedAtConstraint) + .bookmark(id: id, name: name, url: url, favoritedOn: favoritedOn, modifiedAt: modifiedAt, isDeleted: isDeleted, isOrphaned: isOrphaned, modifiedAtConstraint: modifiedAtConstraint) } } @@ -203,14 +203,14 @@ public struct BookmarkTree { public func createEntitiesForCheckingModifiedAt(in context: NSManagedObjectContext) -> (BookmarkEntity, [BookmarkEntity], [String: ModifiedAtConstraint]) { let rootFolder = BookmarkUtils.fetchRootFolder(context)! rootFolder.modifiedAt = modifiedAt - let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context)! + let favoritesFolders = BookmarkUtils.fetchFavoritesFolders(withUUIDs: Set(FavoritesFolderID.allCases.map(\.rawValue)), in: context) var orphans = [BookmarkEntity]() var modifiedAtConstraints = [String: ModifiedAtConstraint]() if let modifiedAtConstraint { modifiedAtConstraints[BookmarkEntity.Constants.rootFolderID] = modifiedAtConstraint } for bookmarkTreeNode in bookmarkTreeNodes { - let (entity, checks) = BookmarkEntity.makeWithModifiedAtConstraints(with: bookmarkTreeNode, rootFolder: rootFolder, favoritesFolder: favoritesFolder, in: context) + let (entity, checks) = BookmarkEntity.makeWithModifiedAtConstraints(with: bookmarkTreeNode, rootFolder: rootFolder, favoritesFolders: favoritesFolders, in: context) if bookmarkTreeNode.isOrphaned { orphans.append(entity) } @@ -230,12 +230,12 @@ public struct BookmarkTree { public extension BookmarkEntity { @discardableResult - static func make(with treeNode: BookmarkTreeNode, rootFolder: BookmarkEntity, favoritesFolder: BookmarkEntity, in context: NSManagedObjectContext) -> BookmarkEntity { - makeWithModifiedAtConstraints(with: treeNode, rootFolder: rootFolder, favoritesFolder: favoritesFolder, in: context).0 + static func make(with treeNode: BookmarkTreeNode, rootFolder: BookmarkEntity, favoritesFolders: [BookmarkEntity], in context: NSManagedObjectContext) -> BookmarkEntity { + makeWithModifiedAtConstraints(with: treeNode, rootFolder: rootFolder, favoritesFolders: favoritesFolders, in: context).0 } @discardableResult - static func makeWithModifiedAtConstraints(with treeNode: BookmarkTreeNode, rootFolder: BookmarkEntity, favoritesFolder: BookmarkEntity, in context: NSManagedObjectContext) -> (BookmarkEntity, [String: ModifiedAtConstraint]) { + static func makeWithModifiedAtConstraints(with treeNode: BookmarkTreeNode, rootFolder: BookmarkEntity, favoritesFolders: [BookmarkEntity], in context: NSManagedObjectContext) -> (BookmarkEntity, [String: ModifiedAtConstraint]) { var entity: BookmarkEntity! var queues: [[BookmarkTreeNode]] = [[treeNode]] @@ -250,7 +250,7 @@ public extension BookmarkEntity { let node = queue.removeFirst() switch node { - case .bookmark(let id, let name, let url, let isFavorite, let modifiedAt, let isDeleted, let isOrphaned, let modifiedAtConstraint): + case .bookmark(let id, let name, let url, let favoritedOn, let modifiedAt, let isDeleted, let isOrphaned, let modifiedAtConstraint): let bookmarkEntity = BookmarkEntity(context: context) if entity == nil { entity = bookmarkEntity @@ -261,9 +261,13 @@ public extension BookmarkEntity { bookmarkEntity.url = url bookmarkEntity.modifiedAt = modifiedAt modifiedAtConstraints[id] = modifiedAtConstraint - if isFavorite { - bookmarkEntity.addToFavorites(favoritesRoot: favoritesFolder) + + for platform in favoritedOn { + if let favoritesFolder = favoritesFolders.first(where: { $0.uuid == platform.rawValue }) { + bookmarkEntity.addToFavorites(favoritesRoot: favoritesFolder) + } } + if isDeleted { bookmarkEntity.markPendingDeletion() } @@ -341,7 +345,7 @@ public extension XCTestCase { XCTAssertEqual(expectedNode.isFolder, thisNode.isFolder, "isFolder mismatch for \(thisUUID)", file: file, line: line) XCTAssertEqual(expectedNode.isPendingDeletion, thisNode.isPendingDeletion, "isPendingDeletion mismatch for \(thisUUID)", file: file, line: line) XCTAssertEqual(expectedNode.children?.count, thisNode.children?.count, "children count mismatch for \(thisUUID)", file: file, line: line) - XCTAssertEqual(expectedNode.isFavorite, thisNode.isFavorite, "isFavorite mismatch for \(thisUUID)", file: file, line: line) + XCTAssertEqual(Set(expectedNode.favoritedOn), Set(thisNode.favoritedOn), "favoritedOn mismatch for \(thisUUID)", file: file, line: line) if withTimestamps { if let modifiedAtConstraint = modifiedAtConstraints[thisUUID] { modifiedAtConstraint.check(thisNode.modifiedAt) diff --git a/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift b/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift index 5c69cb8c8..ce4353f3c 100644 --- a/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift +++ b/Sources/SyncDataProviders/Bookmarks/internal/BookmarksResponseHandler.swift @@ -23,9 +23,6 @@ import DDGSync import Foundation final class BookmarksResponseHandler { - // Before form-factor-specific favorites is supported, we deliberately ignore FFS folders. - static let ignoredFoldersUUIDs: Set = ["desktop_favorites_root", "mobile_favorites_root"] - let clientTimestamp: Date? let received: [SyncableBookmarkAdapter] let context: NSManagedObjectContext @@ -36,7 +33,7 @@ final class BookmarksResponseHandler { let topLevelFoldersSyncables: [SyncableBookmarkAdapter] let bookmarkSyncablesWithoutParent: [SyncableBookmarkAdapter] - let favoritesUUIDs: [String]? + let favoritesUUIDsByFolderUUID: [String: [String]] var entitiesByUUID: [String: BookmarkEntity] = [:] var idsOfItemsThatRetainModifiedAt = Set() @@ -57,7 +54,7 @@ final class BookmarksResponseHandler { var allUUIDs: Set = [] var childrenToParents: [String: String] = [:] var parentFoldersToChildren: [String: [String]] = [:] - var favoritesUUIDs: [String]? + var favoritesUUIDsByFolderUUID: [String: [String]] = [:] self.received.forEach { syncable in guard let uuid = syncable.uuid else { @@ -70,8 +67,8 @@ final class BookmarksResponseHandler { allUUIDs.formUnion(syncable.children) } - if uuid == BookmarkEntity.Constants.favoritesFolderID { - favoritesUUIDs = syncable.children + if BookmarkEntity.isValidFavoritesFolderID(uuid) { + favoritesUUIDsByFolderUUID[uuid] = syncable.children } else { if syncable.isFolder { parentFoldersToChildren[uuid] = syncable.children @@ -84,14 +81,13 @@ final class BookmarksResponseHandler { self.allReceivedIDs = allUUIDs self.receivedByUUID = syncablesByUUID - self.favoritesUUIDs = favoritesUUIDs + self.favoritesUUIDsByFolderUUID = favoritesUUIDsByFolderUUID let foldersWithoutParent = Set(parentFoldersToChildren.keys).subtracting(childrenToParents.keys) - .subtracting(Self.ignoredFoldersUUIDs) topLevelFoldersSyncables = foldersWithoutParent.compactMap { syncablesByUUID[$0] } bookmarkSyncablesWithoutParent = allUUIDs.subtracting(childrenToParents.keys) - .subtracting(foldersWithoutParent + [BookmarkEntity.Constants.favoritesFolderID] + Self.ignoredFoldersUUIDs) + .subtracting(foldersWithoutParent.union(BookmarkEntity.Constants.favoriteFoldersIDs)) .compactMap { syncablesByUUID[$0] } BookmarkEntity.fetchBookmarks(with: allReceivedIDs, in: context) @@ -119,28 +115,35 @@ final class BookmarksResponseHandler { // MARK: - Private private func processReceivedFavorites() { - guard let favoritesUUIDs else { - return - } + for (favoritesFolderUUID, favoritesUUIDs) in favoritesUUIDsByFolderUUID { + guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: favoritesFolderUUID, in: context) else { + // Error - unable to process favorites + return + } - guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) else { - // Error - unable to process favorites - return - } +// guard let favoritesUUIDs else { +// return +// } +// +// guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) else { +// // Error - unable to process favorites +// return +// } - // For non-first sync we rely fully on the server response - if !shouldDeduplicateEntities { - favoritesFolder.favoritesArray.forEach { $0.removeFromFavorites() } - } else if !favoritesFolder.favoritesArray.isEmpty { - // If we're deduplicating and there are favorites locally, we'll need to sync favorites folder back later. - // Let's keep its modifiedAt. - idsOfItemsThatRetainModifiedAt.insert(BookmarkEntity.Constants.favoritesFolderID) - } + // For non-first sync we rely fully on the server response + if !shouldDeduplicateEntities { + favoritesFolder.favoritesArray.forEach { $0.removeFromFavorites(favoritesRoot: favoritesFolder) } + } else if !favoritesFolder.favoritesArray.isEmpty { + // If we're deduplicating and there are favorites locally, we'll need to sync favorites folder back later. + // Let's keep its modifiedAt. + idsOfItemsThatRetainModifiedAt.insert(favoritesFolderUUID) + } - favoritesUUIDs.forEach { uuid in - if let bookmark = entitiesByUUID[uuid] { - bookmark.removeFromFavorites() - bookmark.addToFavorites(favoritesRoot: favoritesFolder) + favoritesUUIDs.forEach { uuid in + if let bookmark = entitiesByUUID[uuid] { + bookmark.removeFromFavorites(favoritesRoot: favoritesFolder) + bookmark.addToFavorites(favoritesRoot: favoritesFolder) + } } } } diff --git a/Sources/SyncDataProviders/Bookmarks/internal/ReceivedBookmarksIndex.swift b/Sources/SyncDataProviders/Bookmarks/internal/ReceivedBookmarksIndex.swift deleted file mode 100644 index 727b05bca..000000000 --- a/Sources/SyncDataProviders/Bookmarks/internal/ReceivedBookmarksIndex.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// ReceivedBookmarksIndex.swift -// DuckDuckGo -// -// 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 CoreData -import DDGSync -import Foundation - -struct ReceivedBookmarksIndex { - let receivedByUUID: [String: SyncableBookmarkAdapter] - let allReceivedIDs: Set - - let topLevelFoldersSyncables: [SyncableBookmarkAdapter] - let bookmarkSyncablesWithoutParent: [SyncableBookmarkAdapter] - let favoritesUUIDs: [String] - - var entitiesByUUID: [String: BookmarkEntity] = [:] - - init(received: [SyncableBookmarkAdapter], in context: NSManagedObjectContext) { - var syncablesByUUID: [String: SyncableBookmarkAdapter] = [:] - var allUUIDs: Set = [] - var childrenToParents: [String: String] = [:] - var parentFoldersToChildren: [String: [String]] = [:] - var favoritesUUIDs: [String] = [] - - received.forEach { syncable in - guard let uuid = syncable.uuid else { - return - } - syncablesByUUID[uuid] = syncable - - allUUIDs.insert(uuid) - if syncable.isFolder { - allUUIDs.formUnion(syncable.children) - } - - if uuid == BookmarkEntity.Constants.favoritesFolderID { - favoritesUUIDs = syncable.children - } else { - if syncable.isFolder { - parentFoldersToChildren[uuid] = syncable.children - } - syncable.children.forEach { child in - childrenToParents[child] = uuid - } - } - } - - self.allReceivedIDs = allUUIDs - self.receivedByUUID = syncablesByUUID - self.favoritesUUIDs = favoritesUUIDs - - let foldersWithoutParent = Set(parentFoldersToChildren.keys).subtracting(childrenToParents.keys) - topLevelFoldersSyncables = foldersWithoutParent.compactMap { syncablesByUUID[$0] } - - bookmarkSyncablesWithoutParent = allUUIDs.subtracting(childrenToParents.keys) - .subtracting(foldersWithoutParent + [BookmarkEntity.Constants.favoritesFolderID]) - .compactMap { syncablesByUUID[$0] } - - BookmarkEntity.fetchBookmarks(with: allReceivedIDs, in: context) - .forEach { bookmark in - guard let uuid = bookmark.uuid else { - return - } - entitiesByUUID[uuid] = bookmark - } - } -} diff --git a/Sources/SyncDataProviders/Bookmarks/internal/SyncableBookmarkAdapter.swift b/Sources/SyncDataProviders/Bookmarks/internal/SyncableBookmarkAdapter.swift index 5619ed41e..40a1df9ff 100644 --- a/Sources/SyncDataProviders/Bookmarks/internal/SyncableBookmarkAdapter.swift +++ b/Sources/SyncDataProviders/Bookmarks/internal/SyncableBookmarkAdapter.swift @@ -79,7 +79,7 @@ extension Syncable { payload["title"] = try encrypt(title) } if bookmark.isFolder { - if bookmark.uuid == BookmarkEntity.Constants.favoritesFolderID { + if BookmarkEntity.Constants.favoriteFoldersIDs.contains(uuid) { payload["folder"] = [ "children": bookmark.favoritesArray.map(\.uuid) ] diff --git a/Sources/SyncDataProviders/Settings/SettingsProvider.swift b/Sources/SyncDataProviders/Settings/SettingsProvider.swift index c6cf2cccd..fc59bb1fd 100644 --- a/Sources/SyncDataProviders/Settings/SettingsProvider.swift +++ b/Sources/SyncDataProviders/Settings/SettingsProvider.swift @@ -27,7 +27,7 @@ import Persistence /** * Error that may occur while updating timestamp when a setting changes. * - * This error should be published via `SettingsSyncHandling.errorPublisher` + * This error should be published via `SettingSyncHandling.errorPublisher` * whenever settings metadata database fails to save changes after updating * timestamp for a given setting. * @@ -42,42 +42,50 @@ public struct SettingsSyncMetadataSaveError: Error { } // swiftlint:disable:next type_body_length -public final class SettingsProvider: DataProvider, SettingsSyncHandlingDelegate { +public final class SettingsProvider: DataProvider, SettingSyncHandlingDelegate { public struct Setting: Hashable { - let key: String + public let key: String + + public init(key: String) { + self.key = key + } } public convenience init( metadataDatabase: CoreDataDatabase, metadataStore: SyncMetadataStore, - emailManager: EmailManagerSyncSupporting, + settingsHandlers: [SettingSyncHandler], syncDidUpdateData: @escaping () -> Void ) { - let emailProtectionSyncHandler = EmailProtectionSyncHandler(emailManager: emailManager) + let settingsHandlersBySetting = settingsHandlers.reduce(into: [Setting: any SettingSyncHandling]()) { partialResult, handler in + partialResult[handler.setting] = handler + } + + let settingsHandlers = settingsHandlersBySetting self.init( metadataDatabase: metadataDatabase, metadataStore: metadataStore, - settingsHandlers: [ - .emailProtectionGeneration: emailProtectionSyncHandler - ], + settingsHandlersBySetting: settingsHandlers, syncDidUpdateData: syncDidUpdateData ) register(errorPublisher: errorSubject.eraseToAnyPublisher()) - emailProtectionSyncHandler.delegate = self + settingsHandlers.values.forEach { handler in + handler.delegate = self + } } init( metadataDatabase: CoreDataDatabase, metadataStore: SyncMetadataStore, - settingsHandlers: [Setting: any SettingsSyncHandling], + settingsHandlersBySetting: [Setting: any SettingSyncHandling], syncDidUpdateData: @escaping () -> Void ) { self.metadataDatabase = metadataDatabase - self.settingsHandlers = settingsHandlers + self.settingsHandlers = settingsHandlersBySetting super.init(feature: .init(name: "settings"), metadataStore: metadataStore, syncDidUpdateData: syncDidUpdateData) } @@ -295,7 +303,7 @@ public final class SettingsProvider: DataProvider, SettingsSyncHandlingDelegate return idsOfItemsToClearModifiedAt } - func syncHandlerDidUpdateSettingValue(_ handler: SettingsSyncHandling) { + func syncHandlerDidUpdateSettingValue(_ handler: SettingSyncHandling) { updateMetadataTimestamp(for: handler.setting) } @@ -332,7 +340,7 @@ public final class SettingsProvider: DataProvider, SettingsSyncHandlingDelegate } private let metadataDatabase: CoreDataDatabase - private let settingsHandlers: [Setting: any SettingsSyncHandling] + private let settingsHandlers: [Setting: any SettingSyncHandling] private let errorSubject = PassthroughSubject() enum Const { diff --git a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/EmailProtectionSyncHandler.swift b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/EmailProtectionSyncHandler.swift index e0bd5838e..861ffc3d1 100644 --- a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/EmailProtectionSyncHandler.swift +++ b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/EmailProtectionSyncHandler.swift @@ -37,17 +37,18 @@ extension SettingsProvider.Setting { static let emailProtectionGeneration = SettingsProvider.Setting(key: "email_protection_generation") } -class EmailProtectionSyncHandler: SettingsSyncHandling { +public final class EmailProtectionSyncHandler: SettingSyncHandler { struct Payload: Codable { let username: String let personalAccessToken: String } - let setting: SettingsProvider.Setting = .emailProtectionGeneration - weak var delegate: SettingsSyncHandlingDelegate? + public override var setting: SettingsProvider.Setting { + .emailProtectionGeneration + } - func getValue() throws -> String? { + public override func getValue() throws -> String? { guard let user = try emailManager.getUsername() else { return nil } @@ -58,7 +59,7 @@ class EmailProtectionSyncHandler: SettingsSyncHandling { return String(bytes: data, encoding: .utf8) } - func setValue(_ value: String?) throws { + public override func setValue(_ value: String?) throws { guard let value, let valueData = value.data(using: .utf8) else { try emailManager.signOut(isForced: false) return @@ -68,19 +69,14 @@ class EmailProtectionSyncHandler: SettingsSyncHandling { try emailManager.signIn(username: payload.username, token: payload.personalAccessToken) } - init(emailManager: EmailManagerSyncSupporting) { - self.emailManager = emailManager + public override var valueDidChangePublisher: AnyPublisher { + emailManager.userDidToggleEmailProtectionPublisher + } - emailProtectionStatusDidChangeCancellable = self.emailManager.userDidToggleEmailProtectionPublisher - .sink { [weak self] in - guard let self else { - return - } - assert(self.delegate != nil, "delegate has not been set for \(type(of: self))") - self.delegate?.syncHandlerDidUpdateSettingValue(self) - } + public init(emailManager: EmailManagerSyncSupporting) { + self.emailManager = emailManager + super.init() } private let emailManager: EmailManagerSyncSupporting - private var emailProtectionStatusDidChangeCancellable: AnyCancellable? } diff --git a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/FavoritesDisplayModeSyncHandlerBase.swift b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/FavoritesDisplayModeSyncHandlerBase.swift new file mode 100644 index 000000000..0414e1da3 --- /dev/null +++ b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/FavoritesDisplayModeSyncHandlerBase.swift @@ -0,0 +1,32 @@ +// +// FavoritesDisplayModeSyncHandlerBase.swift +// DuckDuckGo +// +// 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 Foundation + +extension SettingsProvider.Setting { + static let favoritesDisplayMode = SettingsProvider.Setting(key: "favorites_display_mode") +} + +open class FavoritesDisplayModeSyncHandlerBase: SettingSyncHandler { + + open override var setting: SettingsProvider.Setting { + .favoritesDisplayMode + } +} diff --git a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingSyncHandler.swift b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingSyncHandler.swift new file mode 100644 index 000000000..643699ec9 --- /dev/null +++ b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingSyncHandler.swift @@ -0,0 +1,57 @@ +// +// SettingSyncHandler.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +open class SettingSyncHandler: SettingSyncHandling { + + open var setting: SettingsProvider.Setting { + assertionFailure("implementation missing for \(#function)") + return .init(key: "") + } + + open var valueDidChangePublisher: AnyPublisher { + assertionFailure("implementation missing for \(#function)") + return Empty().eraseToAnyPublisher() + } + + open func getValue() throws -> String? { + assertionFailure("implementation missing for \(#function)") + return nil + } + + open func setValue(_ value: String?) throws { + assertionFailure("implementation missing for \(#function)") + } + + public init() { + valueDidChangeCancellable = valueDidChangePublisher + .sink { [weak self] in + guard let self else { + return + } + assert(self.delegate != nil, "delegate has not been set for \(type(of: self))") + self.delegate?.syncHandlerDidUpdateSettingValue(self) + } + } + + weak var delegate: SettingSyncHandlingDelegate? + private var valueDidChangeCancellable: AnyCancellable? +} diff --git a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingsSyncHandling.swift b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingSyncHandling.swift similarity index 84% rename from Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingsSyncHandling.swift rename to Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingSyncHandling.swift index fb0174a16..5f4dbc8b0 100644 --- a/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingsSyncHandling.swift +++ b/Sources/SyncDataProviders/Settings/SettingsSyncHandlers/SettingSyncHandling.swift @@ -1,5 +1,5 @@ // -// SettingsSyncHandling.swift +// SettingSyncHandling.swift // DuckDuckGo // // Copyright © 2023 DuckDuckGo. All rights reserved. @@ -32,7 +32,9 @@ import Persistence * * fine-tune Sync behavior for the setting, * * track changes to setting's value and notify delegate accordingly. */ -protocol SettingsSyncHandling { +protocol SettingSyncHandling: AnyObject { + + var valueDidChangePublisher: AnyPublisher { get } /** * Returns setting identifier that this handler supports. * @@ -59,19 +61,19 @@ protocol SettingsSyncHandling { * * The delegate must be set, otherwise an assertion failure is called. */ - var delegate: SettingsSyncHandlingDelegate? { get set } + var delegate: SettingSyncHandlingDelegate? { get set } } /** - * Protocol defining delegate interface for Settings Sync Handler. + * Protocol defining delegate interface for Setting Sync Handler. * * It's implemented by SettingsProvider which owns Settings Sync Handlers * and sets itself as their delegate. */ -protocol SettingsSyncHandlingDelegate: AnyObject { +protocol SettingSyncHandlingDelegate: AnyObject { /** * This function must be called whenever setting's value changes for a given Setting Sync Handler. */ - func syncHandlerDidUpdateSettingValue(_ handler: SettingsSyncHandling) + func syncHandlerDidUpdateSettingValue(_ handler: SettingSyncHandling) } diff --git a/Sources/SyncDataProviders/Settings/internal/SettingsResponseHandler.swift b/Sources/SyncDataProviders/Settings/internal/SettingsResponseHandler.swift index 9fe3a4cae..7576a690b 100644 --- a/Sources/SyncDataProviders/Settings/internal/SettingsResponseHandler.swift +++ b/Sources/SyncDataProviders/Settings/internal/SettingsResponseHandler.swift @@ -38,7 +38,7 @@ final class SettingsResponseHandler { init( received: [Syncable], clientTimestamp: Date? = nil, - settingsHandlers: [SettingsProvider.Setting: any SettingsSyncHandling], + settingsHandlers: [SettingsProvider.Setting: any SettingSyncHandling], context: NSManagedObjectContext, crypter: Crypting, deduplicateEntities: Bool @@ -119,5 +119,5 @@ final class SettingsResponseHandler { } } - private let settingsHandlers: [SettingsProvider.Setting: any SettingsSyncHandling] + private let settingsHandlers: [SettingsProvider.Setting: any SettingSyncHandling] } diff --git a/Tests/BookmarksTests/BookmarkEntityTests.swift b/Tests/BookmarksTests/BookmarkEntityTests.swift index bc08ea5de..7583c758a 100644 --- a/Tests/BookmarksTests/BookmarkEntityTests.swift +++ b/Tests/BookmarksTests/BookmarkEntityTests.swift @@ -56,10 +56,10 @@ final class BookmarkEntityTests: XCTestCase { try! context.save() let rootFolder = BookmarkUtils.fetchRootFolder(context)! - let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context)! + let favoritesFolders = BookmarkEntity.Constants.favoriteFoldersIDs.map { BookmarkUtils.fetchFavoritesFolder(withUUID: $0, in: context)! } XCTAssertNil(rootFolder.modifiedAt) - XCTAssertNil(favoritesFolder.modifiedAt) + XCTAssertTrue(favoritesFolders.allSatisfy { $0.modifiedAt == nil }) } } diff --git a/Tests/BookmarksTests/BookmarkListViewModelTests.swift b/Tests/BookmarksTests/BookmarkListViewModelTests.swift index 0776e2add..19d6ffeb8 100644 --- a/Tests/BookmarksTests/BookmarkListViewModelTests.swift +++ b/Tests/BookmarksTests/BookmarkListViewModelTests.swift @@ -65,7 +65,12 @@ final class BookmarkListViewModelTests: XCTestCase { try! context.save() } - bookmarkListViewModel = BookmarkListViewModel(bookmarksDatabase: bookmarksDatabase, parentID: nil, errorEvents: eventMapping) + bookmarkListViewModel = BookmarkListViewModel( + bookmarksDatabase: bookmarksDatabase, + parentID: nil, + favoritesDisplayMode: .displayNative(.mobile), + errorEvents: eventMapping + ) } override func tearDown() { @@ -308,6 +313,231 @@ final class BookmarkListViewModelTests: XCTestCase { XCTAssertEqual(firedEvents, [.orphanedBookmarksPresent]) } } + + func testDisplayNativeMode_WhenBookmarkIsFavoritedThenItIsAddedToNativeAndUnifiedFolders() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + }) + } + } + + func testDisplayNativeMode_WhenNonNativeFavoriteIsFavoritedThenItIsAddedToNativeFolder() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + }) + } + } + + func testDisplayNativeMode_WhenNonNativeBrokenFavoriteIsFavoritedThenItIsAddedToNativeAndUnifiedFolder() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + }) + } + } + + func testDisplayNativeMode_WhenFavoriteIsUnfavoritedThenItIsRemovedFromNativeAndUnifiedFolder() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + }) + } + } + + func testDisplayNativeMode_WhenAllFormFactorsFavoriteIsUnfavoritedThenItIsOnlyRemovedFromNativeFolder() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop, .unified]) + }) + } + } + + func testDisplayAllMode_WhenBookmarkIsFavoritedThenItIsAddedToNativeAndUnifiedFolders() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayUnified(native: .mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + }) + } + } + + func testDisplayAllMode_WhenNonNativeFavoriteIsUnfavoritedThenItIsRemovedFromAllFolders() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayUnified(native: .mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + }) + } + } + + func testDisplayAllMode_WhenNonNativeBrokenFavoriteIsFavoritedThenItIsAddedToNativeAndUnifiedFolder() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayUnified(native: .mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + }) + } + } + + func testDisplayAllMode_WhenAllFormFactorsFavoriteIsUnfavoritedThenItIsRemovedFromAllFolders() async throws { + + bookmarkListViewModel.favoritesDisplayMode = .displayUnified(native: .mobile) + let context = bookmarkListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + bookmarkListViewModel.reloadData() + bookmarkListViewModel.toggleFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + }) + } + } } extension BookmarkEntity { diff --git a/Tests/BookmarksTests/BookmarkUtilsTests.swift b/Tests/BookmarksTests/BookmarkUtilsTests.swift new file mode 100644 index 000000000..75266145b --- /dev/null +++ b/Tests/BookmarksTests/BookmarkUtilsTests.swift @@ -0,0 +1,166 @@ +// +// BookmarkUtilsTests.swift +// DuckDuckGo +// +// 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 BookmarksTestsUtils +import XCTest +import Persistence +@testable import Bookmarks + +final class BookmarkUtilsTests: XCTestCase { + var bookmarksDatabase: CoreDataDatabase! + var location: URL! + + override func setUp() { + super.setUp() + + location = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + + let bundle = Bookmarks.bundle + guard let model = CoreDataDatabase.loadModel(from: bundle, named: "BookmarksModel") else { + XCTFail("Failed to load model") + return + } + bookmarksDatabase = CoreDataDatabase(name: className, containerLocation: location, model: model) + bookmarksDatabase.loadStore() + } + + override func tearDown() { + super.tearDown() + + try? bookmarksDatabase.tearDown(deleteStores: true) + bookmarksDatabase = nil + try? FileManager.default.removeItem(at: location) + } + + func testThatMigrationToFormFactorSpecificFavoritesAddsFavoritesToNativeFolder() async throws { + + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + BookmarkUtils.insertRootFolder(uuid: BookmarkEntity.Constants.rootFolderID, into: context) + BookmarkUtils.insertRootFolder(uuid: FavoritesFolderID.unified.rawValue, into: context) + try! context.save() + } + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2", favoritedOn: [.unified]) + Bookmark(id: "3", favoritedOn: [.unified]) + Bookmark(id: "4", favoritedOn: [.unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + + try! context.save() + + BookmarkUtils.migrateToFormFactorSpecificFavorites(byCopyingExistingTo: .mobile, in: context) + + try! context.save() + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) + }) + } + } + + func testCopyFavoritesWhenDisablingSyncInDisplayNativeMode() async throws { + + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + context.performAndWait { + BookmarkUtils.prepareFoldersStructure(in: context) + try! context.save() + } + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) + Bookmark(id: "5", favoritedOn: [.desktop, .unified]) + Bookmark(id: "6", favoritedOn: [.desktop, .unified]) + Bookmark(id: "7", favoritedOn: [.desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + + try! context.save() + + BookmarkUtils.copyFavorites(from: .mobile, to: .unified, clearingNonNativeFavoritesFolder: .desktop, in: context) + + try! context.save() + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) + Bookmark(id: "5") + Bookmark(id: "6") + Bookmark(id: "7") + }) + } + } + + func testCopyFavoritesWhenDisablingSyncInDisplayAllMode() async throws { + + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + context.performAndWait { + BookmarkUtils.prepareFoldersStructure(in: context) + try! context.save() + } + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) + Bookmark(id: "5", favoritedOn: [.desktop, .unified]) + Bookmark(id: "6", favoritedOn: [.desktop, .unified]) + Bookmark(id: "7", favoritedOn: [.desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + + try! context.save() + + BookmarkUtils.copyFavorites(from: .unified, to: .mobile, clearingNonNativeFavoritesFolder: .desktop, in: context) + + try! context.save() + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) + Bookmark(id: "5", favoritedOn: [.mobile, .unified]) + Bookmark(id: "6", favoritedOn: [.mobile, .unified]) + Bookmark(id: "7", favoritedOn: [.mobile, .unified]) + }) + } + } +} diff --git a/Tests/BookmarksTests/FavoriteListViewModelTests.swift b/Tests/BookmarksTests/FavoriteListViewModelTests.swift index 49a4ccae3..f01cb2e21 100644 --- a/Tests/BookmarksTests/FavoriteListViewModelTests.swift +++ b/Tests/BookmarksTests/FavoriteListViewModelTests.swift @@ -52,8 +52,11 @@ final class FavoriteListViewModelTests: XCTestCase { try! context.save() } - favoriteListViewModel = FavoritesListViewModel(bookmarksDatabase: bookmarksDatabase, - errorEvents: eventMapping) + favoriteListViewModel = FavoritesListViewModel( + bookmarksDatabase: bookmarksDatabase, + errorEvents: eventMapping, + favoritesDisplayMode: .displayNative(.mobile) + ) } override func tearDown() { @@ -70,10 +73,10 @@ final class FavoriteListViewModelTests: XCTestCase { let context = favoriteListViewModel.context let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true, isDeleted: true) - Bookmark(id: "2", isFavorite: true) - Bookmark(id: "3", isFavorite: true, isDeleted: true) - Bookmark(id: "4", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified], isDeleted: true) + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified], isDeleted: true) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) } context.performAndWait { @@ -81,7 +84,8 @@ final class FavoriteListViewModelTests: XCTestCase { try! context.save() - let rootFavoriteFolder = BookmarkUtils.fetchFavoritesFolder(context)! + let favoriteFolderUUID = favoriteListViewModel.favoritesDisplayMode.displayedFolder.rawValue + let rootFavoriteFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: favoriteFolderUUID, in: context)! XCTAssertEqual(rootFavoriteFolder.favoritesArray.map(\.title), ["2", "4"]) let bookmark = BookmarkEntity.fetchBookmark(withUUID: "2", context: context)! @@ -94,4 +98,103 @@ final class FavoriteListViewModelTests: XCTestCase { } } + func testDisplayNativeMode_WhenFavoriteIsUnfavoritedThenItIsRemovedFromNativeAndUnifiedFolder() async throws { + + favoriteListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = favoriteListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + favoriteListViewModel.reloadData() + favoriteListViewModel.removeFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + }) + } + } + + func testDisplayNativeMode_WhenAllFormFactorsFavoriteIsUnfavoritedThenItIsOnlyRemovedFromNativeFolder() async throws { + + favoriteListViewModel.favoritesDisplayMode = .displayNative(.mobile) + let context = favoriteListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + favoriteListViewModel.reloadData() + favoriteListViewModel.removeFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop, .unified]) + }) + } + } + + func testDisplayAllMode_WhenNonNativeFavoriteIsUnfavoritedThenItIsRemovedFromAllFolders() async throws { + + favoriteListViewModel.favoritesDisplayMode = .displayUnified(native: .mobile) + let context = favoriteListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + favoriteListViewModel.reloadData() + favoriteListViewModel.removeFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + }) + } + } + + func testDisplayAllMode_WhenAllFormFactorsFavoriteIsUnfavoritedThenItIsRemovedFromAllFolders() async throws { + + favoriteListViewModel.favoritesDisplayMode = .displayUnified(native: .mobile) + let context = favoriteListViewModel.context + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop, .unified]) + } + + context.performAndWait { + bookmarkTree.createEntities(in: context) + try! context.save() + + let bookmark = BookmarkEntity.fetchBookmark(withUUID: "1", context: context)! + + favoriteListViewModel.reloadData() + favoriteListViewModel.removeFavorite(bookmark) + + let rootFolder = BookmarkUtils.fetchRootFolder(context)! + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1") + }) + } + } } diff --git a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksInitialSyncResponseHandlerTests.swift b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksInitialSyncResponseHandlerTests.swift index 0f223b06e..47e7e3b60 100644 --- a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksInitialSyncResponseHandlerTests.swift +++ b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksInitialSyncResponseHandlerTests.swift @@ -137,21 +137,23 @@ final class BookmarksInitialSyncResponseHandlerTests: BookmarksProviderTestsBase let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) } let received: [Syncable] = [ .rootFolder(children: ["1", "2", "3"]), .favoritesFolder(favorites: ["1", "2", "3"]), + .mobileFavoritesFolder(favorites: ["1", "2"]), + .desktopFavoritesFolder(favorites: ["3"]), .bookmark(id: "3") ] let rootFolder = try await createEntitiesAndHandleInitialSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.desktop, .unified]) }) } @@ -159,21 +161,22 @@ final class BookmarksInitialSyncResponseHandlerTests: BookmarksProviderTestsBase let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "4", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) } let received: [Syncable] = [ .rootFolder(children: ["3"]), .favoritesFolder(favorites: ["3"]), + .mobileFavoritesFolder(favorites: ["3"]), .bookmark(id: "3") ] let rootFolder = try await createEntitiesAndHandleInitialSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "4", isFavorite: true) - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "4", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) }) } @@ -350,24 +353,25 @@ final class BookmarksInitialSyncResponseHandlerTests: BookmarksProviderTestsBase let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) } let received: [Syncable] = [ .rootFolder(children: ["2"]), .favoritesFolder(favorites: ["2"]), + .mobileFavoritesFolder(favorites: ["2"]), .bookmark(id: "2") ] let rootFolder = try await createEntitiesAndHandleInitialSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) }) var favoritesFolder: BookmarkEntity! context.performAndWait { - favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context) + favoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.unified.rawValue, in: context) } XCTAssertNotNil(favoritesFolder.modifiedAt) } diff --git a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksProviderTests.swift b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksProviderTests.swift index deb15d360..9cac99338 100644 --- a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksProviderTests.swift +++ b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksProviderTests.swift @@ -41,7 +41,7 @@ internal class BookmarksProviderTests: BookmarksProviderTestsBase { let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark("Bookmark 1", id: "1", isFavorite: true) + Bookmark("Bookmark 1", id: "1", favoritedOn: [.mobile, .unified]) Bookmark("Bookmark 2", id: "2") Folder("Folder", id: "3") { Bookmark("Bookmark 4", id: "4") @@ -65,7 +65,7 @@ internal class BookmarksProviderTests: BookmarksProviderTestsBase { let rootFolder = BookmarkUtils.fetchRootFolder(context)! assertEquivalent(rootFolder, BookmarkTree(modifiedAtConstraint: .notNil()) { - Bookmark("Bookmark 1", id: "1", isFavorite: true, modifiedAtConstraint: .notNil()) + Bookmark("Bookmark 1", id: "1", favoritedOn: [.mobile, .unified], modifiedAtConstraint: .notNil()) Bookmark("Bookmark 2", id: "2", modifiedAtConstraint: .notNil()) Folder("Folder", id: "3", modifiedAtConstraint: .notNil()) { Bookmark("Bookmark 4", id: "4", modifiedAtConstraint: .notNil()) @@ -74,8 +74,8 @@ internal class BookmarksProviderTests: BookmarksProviderTestsBase { Bookmark("Bookmark 6", id: "6", modifiedAtConstraint: .notNil()) }) - let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(context)! - XCTAssertNotNil(favoritesFolder.modifiedAt) + let favoritesFolders = BookmarkUtils.fetchFavoritesFolders(for: .displayUnified(native: .mobile), in: context) + XCTAssertTrue(favoritesFolders.allSatisfy { $0.modifiedAt != nil }) } } @@ -83,7 +83,7 @@ internal class BookmarksProviderTests: BookmarksProviderTestsBase { let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) } context.performAndWait { @@ -97,7 +97,7 @@ internal class BookmarksProviderTests: BookmarksProviderTestsBase { XCTAssertEqual( Set(changedObjects.compactMap(\.uuid)), - Set([BookmarkEntity.Constants.favoritesFolderID, BookmarkEntity.Constants.rootFolderID, "1"]) + BookmarkEntity.Constants.favoriteFoldersIDs.union(["1", BookmarkEntity.Constants.rootFolderID]) ) } @@ -105,7 +105,7 @@ internal class BookmarksProviderTests: BookmarksProviderTestsBase { let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) Folder(id: "2") { Bookmark(id: "3") Bookmark(id: "4") diff --git a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksRegularSyncResponseHandlerTests.swift b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksRegularSyncResponseHandlerTests.swift index 211cb3c3d..21ea98364 100644 --- a/Tests/SyncDataProvidersTests/Bookmarks/BookmarksRegularSyncResponseHandlerTests.swift +++ b/Tests/SyncDataProvidersTests/Bookmarks/BookmarksRegularSyncResponseHandlerTests.swift @@ -115,21 +115,22 @@ final class BookmarksRegularSyncResponseHandlerTests: BookmarksProviderTestsBase let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) } let received: [Syncable] = [ .rootFolder(children: ["1", "2", "3"]), .favoritesFolder(favorites: ["1", "2", "3"]), + .mobileFavoritesFolder(favorites: ["1", "2", "3"]), .bookmark(id: "3") ] let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) + Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) }) } @@ -137,7 +138,7 @@ final class BookmarksRegularSyncResponseHandlerTests: BookmarksProviderTestsBase let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) Folder(id: "2") { Bookmark(id: "3") } @@ -145,64 +146,66 @@ final class BookmarksRegularSyncResponseHandlerTests: BookmarksProviderTestsBase let received: [Syncable] = [ .favoritesFolder(favorites: ["1", "3"]), + .mobileFavoritesFolder(favorites: ["1", "3"]), .folder(id: "2", children: ["3"]), .bookmark(id: "3") ] let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) Folder(id: "2") { - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) } }) } - func testWhenPayloadDoesNotContainFavoritesFolderThenFavoritesAreNotAffected() async throws { + func testWhenPayloadContainsEmptyFavoritesFolderThenAllFavoritesAreRemoved() async throws { let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) Folder(id: "2") { - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) } } let received: [Syncable] = [ - .rootFolder(children: ["1", "2", "4"]), - .bookmark(id: "4") + .favoritesFolder(favorites: []), + .mobileFavoritesFolder(favorites: []) ] let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1") Folder(id: "2") { - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "3") } - Bookmark(id: "4") }) } - func testWhenPayloadContainsEmptyFavoritesFolderThenAllFavoritesAreRemoved() async throws { + func testWhenPayloadDoesNotContainFavoritesFolderThenFavoritesAreNotAffected() async throws { let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) Folder(id: "2") { - Bookmark(id: "3", isFavorite: true) + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) } } let received: [Syncable] = [ - .favoritesFolder(favorites: []) + .rootFolder(children: ["1", "2", "4"]), + .bookmark(id: "4") ] let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1") + Bookmark(id: "1", favoritedOn: [.mobile, .unified]) Folder(id: "2") { - Bookmark(id: "3") + Bookmark(id: "3", favoritedOn: [.mobile, .unified]) } + Bookmark(id: "4") }) } @@ -520,6 +523,66 @@ final class BookmarksRegularSyncResponseHandlerTests: BookmarksProviderTestsBase }) } + // MARK: - Invalid Favorites Form Factors + + func testWhenMobileOnlyFavoriteIsReceivedThenItIsSaved() async throws { + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2") + } + + let received: [Syncable] = [ + .mobileFavoritesFolder(favorites: ["1"]) + ] + + let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile]) + Bookmark(id: "2") + }) + } + + func testWhenUnifiedOnlyFavoriteIsReceivedThenItIsSaved() async throws { + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2") + } + + let received: [Syncable] = [ + .favoritesFolder(favorites: ["1"]) + ] + + let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.unified]) + Bookmark(id: "2") + }) + } + + func testWhenNonUnifiedFavoriteIsReceivedThenItIsSaved() async throws { + let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let bookmarkTree = BookmarkTree { + Bookmark(id: "1") + Bookmark(id: "2") + } + + let received: [Syncable] = [ + .mobileFavoritesFolder(favorites: ["1"]), + .desktopFavoritesFolder(favorites: ["1", "2"]) + ] + + let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) + assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { + Bookmark(id: "1", favoritedOn: [.mobile, .desktop]) + Bookmark(id: "2", favoritedOn: [.desktop]) + }) + } + // MARK: - Helpers func createEntitiesAndHandleSyncResponse( diff --git a/Tests/SyncDataProvidersTests/Bookmarks/FormFactorSpecificFavoritesFoldersIgnoringTests.swift b/Tests/SyncDataProvidersTests/Bookmarks/FormFactorSpecificFavoritesFoldersIgnoringTests.swift deleted file mode 100644 index 9e0defdb0..000000000 --- a/Tests/SyncDataProvidersTests/Bookmarks/FormFactorSpecificFavoritesFoldersIgnoringTests.swift +++ /dev/null @@ -1,142 +0,0 @@ -// -// FormFactorSpecificFavoritesFoldersIgnoringTests.swift -// DuckDuckGo -// -// 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 -import Bookmarks -import BookmarksTestsUtils -import Common -import DDGSync -import Persistence -@testable import SyncDataProviders - -private extension Syncable { - static func desktopFavoritesFolder(favorites: [String]) -> Syncable { - .folder(id: "desktop_favorites_root", children: favorites) - } - - static func mobileFavoritesFolder(favorites: [String]) -> Syncable { - .folder(id: "mobile_favorites_root", children: favorites) - } -} - -final class FormFactorSpecificFavoritesFoldersIgnoringTests: BookmarksProviderTestsBase { - - func testThatDesktopFavoritesFolderIsIgnored() async throws { - let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) - - let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2") - } - - let received: [Syncable] = [.desktopFavoritesFolder(favorites: ["1", "2"])] - - let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) - assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2") - }) - } - - func testThatMobileFavoritesFolderIsIgnored() async throws { - let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) - - let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2") - } - - let received: [Syncable] = [.mobileFavoritesFolder(favorites: ["1", "2"])] - - let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) - assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2") - }) - } - - func testThatDesktopFavoritesFolderDoesNotAffectReceivedFavoritesFolder() async throws { - let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) - - let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2") - } - - let received: [Syncable] = [ - .favoritesFolder(favorites: ["1", "2"]), - .desktopFavoritesFolder(favorites: ["1", "2"]) - ] - - let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) - assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) - }) - } - - func testThatMobileFavoritesFolderDoesNotAffectReceivedFavoritesFolder() async throws { - let context = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) - - let bookmarkTree = BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2") - } - - let received: [Syncable] = [ - .favoritesFolder(favorites: ["1", "2"]), - .mobileFavoritesFolder(favorites: ["1", "2"]) - ] - - let rootFolder = try await createEntitiesAndHandleSyncResponse(with: bookmarkTree, received: received, in: context) - assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { - Bookmark(id: "1", isFavorite: true) - Bookmark(id: "2", isFavorite: true) - }) - } - - // MARK: - Helpers - - func createEntitiesAndHandleSyncResponse( - with bookmarkTree: BookmarkTree, - sent: [Syncable] = [], - received: [Syncable], - clientTimestamp: Date = Date(), - serverTimestamp: String = "1234", - in context: NSManagedObjectContext - ) async throws -> BookmarkEntity { - - context.performAndWait { - BookmarkUtils.prepareFoldersStructure(in: context) - bookmarkTree.createEntities(in: context) - try! context.save() - } - - try await provider.handleSyncResponse(sent: sent, received: received, clientTimestamp: Date(), serverTimestamp: "1234", crypter: crypter) - - var rootFolder: BookmarkEntity! - - context.performAndWait { - context.refreshAllObjects() - rootFolder = BookmarkUtils.fetchRootFolder(context) - } - - return rootFolder - } -} diff --git a/Tests/SyncDataProvidersTests/Bookmarks/helpers/SyncableBookmarksExtension.swift b/Tests/SyncDataProvidersTests/Bookmarks/helpers/SyncableBookmarksExtension.swift index 2e81571f5..bf30700f4 100644 --- a/Tests/SyncDataProvidersTests/Bookmarks/helpers/SyncableBookmarksExtension.swift +++ b/Tests/SyncDataProvidersTests/Bookmarks/helpers/SyncableBookmarksExtension.swift @@ -26,7 +26,15 @@ extension Syncable { } static func favoritesFolder(favorites: [String]) -> Syncable { - .folder(id: BookmarkEntity.Constants.favoritesFolderID, children: favorites) + .folder(id: FavoritesFolderID.unified.rawValue, children: favorites) + } + + static func mobileFavoritesFolder(favorites: [String]) -> Syncable { + .folder(id: FavoritesFolderID.mobile.rawValue, children: favorites) + } + + static func desktopFavoritesFolder(favorites: [String]) -> Syncable { + .folder(id: FavoritesFolderID.desktop.rawValue, children: favorites) } static func bookmark(_ title: String? = nil, id: String, url: String? = nil, lastModified: String? = nil, isDeleted: Bool = false) -> Syncable { diff --git a/Tests/SyncDataProvidersTests/CryptingMock.swift b/Tests/SyncDataProvidersTests/CryptingMock.swift index 846b95263..32810c95e 100644 --- a/Tests/SyncDataProvidersTests/CryptingMock.swift +++ b/Tests/SyncDataProvidersTests/CryptingMock.swift @@ -23,14 +23,16 @@ import Foundation struct CryptingMock: Crypting { + static let reservedFolderIDs = Set(FavoritesFolderID.allCases.map(\.rawValue)).union([BookmarkEntity.Constants.rootFolderID]) + var _encryptAndBase64Encode: (String) throws -> String = { value in - if [BookmarkEntity.Constants.favoritesFolderID, BookmarkEntity.Constants.rootFolderID].contains(value) { + if Self.reservedFolderIDs.contains(value) { return value } return "encrypted_\(value)" } var _base64DecodeAndDecrypt: (String) throws -> String = { value in - if [BookmarkEntity.Constants.favoritesFolderID, BookmarkEntity.Constants.rootFolderID].contains(value) { + if Self.reservedFolderIDs.contains(value) { return value } return value.dropping(prefix: "encrypted_") diff --git a/Tests/SyncDataProvidersTests/Settings/SettingsInitialSyncResponseHandlerTests.swift b/Tests/SyncDataProvidersTests/Settings/SettingsInitialSyncResponseHandlerTests.swift index 0561df720..7ef3b9007 100644 --- a/Tests/SyncDataProvidersTests/Settings/SettingsInitialSyncResponseHandlerTests.swift +++ b/Tests/SyncDataProvidersTests/Settings/SettingsInitialSyncResponseHandlerTests.swift @@ -63,7 +63,7 @@ final class SettingsInitialSyncResponseHandlerTests: SettingsProviderTestsBase { XCTAssertNil(emailManagerStorage.mockToken) } - func testThatEmailProtectionIsEnabledLocallyAndRemotelyThenRemoteStateIsApplied() async throws { + func testWhenEmailProtectionIsEnabledLocallyAndRemotelyThenRemoteStateIsApplied() async throws { let emailManager = EmailManager(storage: emailManagerStorage) try emailManager.signIn(username: "dax-local", token: "secret-token-local") @@ -98,4 +98,68 @@ final class SettingsInitialSyncResponseHandlerTests: SettingsProviderTestsBase { XCTAssertEqual(emailManagerStorage.mockUsername, "dax") XCTAssertEqual(emailManagerStorage.mockToken, "secret-token") } + + func testThatSettingStateIsAppliedLocally() async throws { + testSettingSyncHandler.syncedValue = nil + + let received: [Syncable] = [ + .testSetting("remote") + ] + + try await handleInitialSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "remote") + } + + func testThatDeletedSettingIsIgnoredWhenLocallyIsNil() async throws { + testSettingSyncHandler.syncedValue = nil + + let received: [Syncable] = [ + .testSettingDeleted() + ] + + try await handleInitialSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertNil(testSettingSyncHandler.syncedValue) + } + + func testWhenSettingIsNotNilLocallyAndRemotelyThenRemoteStateIsApplied() async throws { + testSettingSyncHandler.syncedValue = "local" + + let received: [Syncable] = [ + .testSetting("remote") + ] + + try await handleInitialSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "remote") + } + + func testThatSettingValueIsDeduplicated() async throws { + testSettingSyncHandler.syncedValue = "local" + + let received: [Syncable] = [ + .testSetting("local") + ] + + try await handleInitialSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "local") + } } diff --git a/Tests/SyncDataProvidersTests/Settings/SettingsProviderTests.swift b/Tests/SyncDataProvidersTests/Settings/SettingsProviderTests.swift index a553dc79e..902726e38 100644 --- a/Tests/SyncDataProvidersTests/Settings/SettingsProviderTests.swift +++ b/Tests/SyncDataProvidersTests/Settings/SettingsProviderTests.swift @@ -37,7 +37,7 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertEqual(provider.lastSyncTimestamp, "12345") } - func testThatPrepareForFirstSyncClearsLastSyncTimestampAndSetsModifiedAtForEmailSettings() throws { + func testThatPrepareForFirstSyncClearsLastSyncTimestampAndSetsModifiedAtForAllSettings() throws { try emailManager.signIn(username: "dax", token: "secret-token") @@ -50,7 +50,7 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertNil(provider.lastSyncTimestamp) settingsMetadata = fetchAllSettingsMetadata(in: context) - XCTAssertEqual(settingsMetadata.count, 1) + XCTAssertEqual(settingsMetadata.count, 2) XCTAssertTrue(settingsMetadata.allSatisfy { $0.lastModified != nil }) } @@ -67,6 +67,23 @@ final class SettingsProviderTests: SettingsProviderTestsBase { ) } + func testThatFetchChangedObjectsReturnsTestSettingWithNonNilModifiedAt() async throws { + + testSettingSyncHandler.syncedValue = "1" + + let changedObjects = try await provider.fetchChangedObjects(encryptedUsing: crypter).map(SyncableSettingAdapter.init) + + XCTAssertEqual( + Set(changedObjects.compactMap(\.uuid)), + Set([SettingsProvider.Setting.testSetting.key]) + ) + } + + func testThatFetchChangedObjectsReturnsEmptyArrayWhenNothingHasChanged() async throws { + let changedObjects = try await provider.fetchChangedObjects(encryptedUsing: crypter).map(SyncableSettingAdapter.init) + XCTAssertTrue(changedObjects.isEmpty) + } + func testWhenEmailProtectionIsDisabledThenFetchChangedObjectsContainsDeletedSyncable() async throws { let otherEmailManager = EmailManager(storage: MockEmailManagerStorage()) @@ -82,6 +99,21 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertEqual(syncable.uuid, SettingsProvider.Setting.emailProtectionGeneration.key) } + func testWhenTestSettingIsClearedThenFetchChangedObjectsContainsDeletedSyncable() async throws { + + testSettingSyncHandler.syncedValue = "1" + testSettingSyncHandler.syncedValue = nil + + let changedObjects = try await provider.fetchChangedObjects(encryptedUsing: crypter).map(SyncableSettingAdapter.init) + + XCTAssertEqual(changedObjects.count, 1) + + let syncable = try XCTUnwrap(changedObjects.first) + + XCTAssertTrue(syncable.isDeleted) + XCTAssertEqual(syncable.uuid, SettingsProvider.Setting.testSetting.key) + } + func testThatSigninInToEmailProtectionStateUpdatesSyncMetadataTimestamp() async throws { try provider.prepareForFirstSync() @@ -89,9 +121,9 @@ final class SettingsProviderTests: SettingsProviderTestsBase { let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let initialSettingsMetadata = fetchAllSettingsMetadata(in: context) - let initialEmailMetadata = try XCTUnwrap(initialSettingsMetadata.first) + let initialEmailMetadata = try XCTUnwrap(initialSettingsMetadata.first(where: { $0.key == SettingsProvider.Setting.emailProtectionGeneration.key })) let initialTimestamp = initialEmailMetadata.lastModified - XCTAssertEqual(initialSettingsMetadata.count, 1) + XCTAssertEqual(initialSettingsMetadata.count, 2) XCTAssertNotNil(initialTimestamp) try await Task.sleep(nanoseconds: 1000) @@ -102,9 +134,9 @@ final class SettingsProviderTests: SettingsProviderTestsBase { context.refreshAllObjects() let settingsMetadata = fetchAllSettingsMetadata(in: context) - let emailMetadata = try XCTUnwrap(settingsMetadata.first) + let emailMetadata = try XCTUnwrap(settingsMetadata.first(where: { $0.key == SettingsProvider.Setting.emailProtectionGeneration.key })) let timestamp = emailMetadata.lastModified - XCTAssertEqual(settingsMetadata.count, 1) + XCTAssertEqual(settingsMetadata.count, 2) XCTAssertNotNil(timestamp) XCTAssertTrue(timestamp! > initialTimestamp!) @@ -119,9 +151,9 @@ final class SettingsProviderTests: SettingsProviderTestsBase { let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let initialSettingsMetadata = fetchAllSettingsMetadata(in: context) - let initialEmailMetadata = try XCTUnwrap(initialSettingsMetadata.first) + let initialEmailMetadata = try XCTUnwrap(initialSettingsMetadata.first(where: { $0.key == SettingsProvider.Setting.emailProtectionGeneration.key })) let initialTimestamp = initialEmailMetadata.lastModified - XCTAssertEqual(initialSettingsMetadata.count, 1) + XCTAssertEqual(initialSettingsMetadata.count, 2) XCTAssertNotNil(initialTimestamp) try await Task.sleep(nanoseconds: 1000) @@ -132,9 +164,36 @@ final class SettingsProviderTests: SettingsProviderTestsBase { context.refreshAllObjects() let settingsMetadata = fetchAllSettingsMetadata(in: context) - let emailMetadata = try XCTUnwrap(settingsMetadata.first) + let emailMetadata = try XCTUnwrap(settingsMetadata.first(where: { $0.key == SettingsProvider.Setting.emailProtectionGeneration.key })) let timestamp = emailMetadata.lastModified - XCTAssertEqual(settingsMetadata.count, 1) + XCTAssertEqual(settingsMetadata.count, 2) + XCTAssertNotNil(timestamp) + + XCTAssertTrue(timestamp! > initialTimestamp!) + } + + func testThatUpdatingSettingValueUpdatesSyncMetadataTimestamp() async throws { + + try provider.prepareForFirstSync() + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + + let initialSettingsMetadata = fetchAllSettingsMetadata(in: context) + let initialTestSettingMetadata = try XCTUnwrap(initialSettingsMetadata.first(where: { $0.key == SettingsProvider.Setting.testSetting.key })) + let initialTimestamp = initialTestSettingMetadata.lastModified + XCTAssertEqual(initialSettingsMetadata.count, 2) + XCTAssertNotNil(initialTimestamp) + + try await Task.sleep(nanoseconds: 1000) + + testSettingSyncHandler.syncedValue = "1" + + context.refreshAllObjects() + + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first(where: { $0.key == SettingsProvider.Setting.testSetting.key })) + let timestamp = testSettingMetadata.lastModified + XCTAssertEqual(settingsMetadata.count, 2) XCTAssertNotNil(timestamp) XCTAssertTrue(timestamp! > initialTimestamp!) @@ -169,11 +228,11 @@ final class SettingsProviderTests: SettingsProviderTestsBase { let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) let settingsMetadata = fetchAllSettingsMetadata(in: context) - let emailMetadata = try XCTUnwrap(settingsMetadata.first) + let emailMetadata = try XCTUnwrap(settingsMetadata.first(where: { $0.key == SettingsProvider.Setting.emailProtectionGeneration.key })) XCTAssertNil(emailMetadata.lastModified) } - func testThatInitialSyncClearsLastModifiedForDeduplicatedCredential() async throws { + func testThatInitialSyncClearsLastModifiedForDeduplicatedEmailProtectionSetting() async throws { let date = Date() @@ -192,6 +251,24 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertNil(emailMetadata.lastModified) } + func testThatInitialSyncClearsLastModifiedForDeduplicatedSetting() async throws { + + let date = Date() + + testSettingSyncHandler.syncedValue = "1" + + let received: [Syncable] = [ + .testSetting("1") + ] + + try await provider.handleInitialSyncResponse(received: received, clientTimestamp: date.addingTimeInterval(1), serverTimestamp: "1234", crypter: crypter) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + } + func testWhenThereIsMergeConflictDuringInitialSyncThenSyncResponseHandlingIsRetried() async throws { let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) @@ -250,6 +327,24 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertEqual(emailManagerStorage.mockToken, "secret-token2") } + func testWhenSettingDeleteIsSentAndUpdateIsReceivedThenSettingIsNotDeleted() async throws { + testSettingSyncHandler.syncedValue = "local" + testSettingSyncHandler.syncedValue = nil + + let received: [Syncable] = [ + .testSetting("remote") + ] + + let sent = try await provider.fetchChangedObjects(encryptedUsing: crypter) + try await provider.handleSyncResponse(sent: sent, received: received, clientTimestamp: Date(), serverTimestamp: "1234", crypter: crypter) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "remote") + } + func testWhenEmailProtectionWasSentAndThenDisabledLocallyAndAnUpdateIsReceivedThenEmailProtectionIsDisabled() async throws { let emailManager = EmailManager(storage: emailManagerStorage) @@ -274,6 +369,28 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertNil(emailManagerStorage.mockToken) } + func testWhenSettingWasSentAndThenDeletedLocallyAndAnUpdateIsReceivedThenSettingIsDeleted() async throws { + + testSettingSyncHandler.syncedValue = "local" + + let sent = try await provider.fetchChangedObjects(encryptedUsing: crypter) + + testSettingSyncHandler.syncedValue = nil + + let received: [Syncable] = [ + .testSetting("remote") + ] + + try await provider.handleSyncResponse(sent: sent, received: received, clientTimestamp: Date().advanced(by: -1), serverTimestamp: "1234", crypter: crypter) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + XCTAssertEqual(settingsMetadata.count, 1) + XCTAssertEqual(settingsMetadata.first?.key, SettingsProvider.Setting.testSetting.key) + XCTAssertNotNil(settingsMetadata.first?.lastModified) + XCTAssertNil(testSettingSyncHandler.syncedValue) + } + func testWhenEmailProtectionWasEnabledLocallyAfterStartingSyncThenRemoteChangesAreDropped() async throws { let sent = try await provider.fetchChangedObjects(encryptedUsing: crypter) @@ -296,6 +413,26 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertEqual(emailManagerStorage.mockToken, "secret-token") } + func testWhenSettingWasUpdatedLocallyAfterStartingSyncThenRemoteChangesAreDropped() async throws { + + let sent = try await provider.fetchChangedObjects(encryptedUsing: crypter) + + testSettingSyncHandler.syncedValue = "local" + + let received: [Syncable] = [ + .testSetting("remote") + ] + + try await provider.handleSyncResponse(sent: sent, received: received, clientTimestamp: Date().advanced(by: -1), serverTimestamp: "1234", crypter: crypter) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + XCTAssertEqual(settingsMetadata.count, 1) + XCTAssertEqual(settingsMetadata.first?.key, SettingsProvider.Setting.testSetting.key) + XCTAssertNotNil(settingsMetadata.first?.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "local") + } + func testWhenEmailProtectionWasEnabledLocallyAfterStartingSyncThenRemoteDisableIsDropped() async throws { let sent = try await provider.fetchChangedObjects(encryptedUsing: crypter) @@ -318,6 +455,26 @@ final class SettingsProviderTests: SettingsProviderTestsBase { XCTAssertEqual(emailManagerStorage.mockToken, "secret-token") } + func testWhenSettingWasUpdatedLocallyAfterStartingSyncThenRemoteDeleteIsDropped() async throws { + + let sent = try await provider.fetchChangedObjects(encryptedUsing: crypter) + + testSettingSyncHandler.syncedValue = "local" + + let received: [Syncable] = [ + .testSettingDeleted() + ] + + try await provider.handleSyncResponse(sent: sent, received: received, clientTimestamp: Date().advanced(by: -1), serverTimestamp: "1234", crypter: crypter) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + XCTAssertEqual(settingsMetadata.count, 1) + XCTAssertEqual(settingsMetadata.first?.key, SettingsProvider.Setting.testSetting.key) + XCTAssertNotNil(settingsMetadata.first?.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "local") + } + func testWhenThereIsMergeConflictDuringRegularSyncThenSyncResponseHandlingIsRetried() async throws { let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) diff --git a/Tests/SyncDataProvidersTests/Settings/SettingsRegularSyncResponseHandlerTests.swift b/Tests/SyncDataProvidersTests/Settings/SettingsRegularSyncResponseHandlerTests.swift index 4db01e946..8d6219617 100644 --- a/Tests/SyncDataProvidersTests/Settings/SettingsRegularSyncResponseHandlerTests.swift +++ b/Tests/SyncDataProvidersTests/Settings/SettingsRegularSyncResponseHandlerTests.swift @@ -59,7 +59,7 @@ final class SettingsRegularSyncResponseHandlerTests: SettingsProviderTestsBase { XCTAssertNil(emailManagerStorage.mockToken) } - func testThatEmailProtectionIsEnabledLocallyAndRemotelyThenRemoteStateIsApplied() async throws { + func testWhenEmailProtectionIsEnabledLocallyAndRemotelyThenRemoteStateIsApplied() async throws { let emailManager = EmailManager(storage: emailManagerStorage) try emailManager.signIn(username: "dax-local", token: "secret-token-local") @@ -76,4 +76,49 @@ final class SettingsRegularSyncResponseHandlerTests: SettingsProviderTestsBase { XCTAssertEqual(emailManagerStorage.mockUsername, "dax-remote") XCTAssertEqual(emailManagerStorage.mockToken, "secret-token-remote") } + + func testThatSettingStateIsApplied() async throws { + let received: [Syncable] = [ + .testSetting("remote") + ] + + try await handleSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + XCTAssertTrue(settingsMetadata.isEmpty) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "remote") + } + + func testThatSettingDeletedStateIsApplied() async throws { + testSettingSyncHandler.syncedValue = "local" + + let received: [Syncable] = [ + .testSettingDeleted() + ] + + try await handleSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertNil(testSettingSyncHandler.syncedValue) + } + + func testWhenSettingIsSetLocallyAndRemotelyThenRemoteStateIsApplied() async throws { + testSettingSyncHandler.syncedValue = "local" + + let received: [Syncable] = [ + .testSetting("remote") + ] + + try await handleSyncResponse(received: received) + + let context = metadataDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) + let settingsMetadata = fetchAllSettingsMetadata(in: context) + let testSettingMetadata = try XCTUnwrap(settingsMetadata.first) + XCTAssertNil(testSettingMetadata.lastModified) + XCTAssertEqual(testSettingSyncHandler.syncedValue, "remote") + } } diff --git a/Tests/SyncDataProvidersTests/Settings/helpers/SettingsProviderTestsBase.swift b/Tests/SyncDataProvidersTests/Settings/helpers/SettingsProviderTestsBase.swift index 218394170..081e6e494 100644 --- a/Tests/SyncDataProvidersTests/Settings/helpers/SettingsProviderTestsBase.swift +++ b/Tests/SyncDataProvidersTests/Settings/helpers/SettingsProviderTestsBase.swift @@ -111,6 +111,7 @@ internal class SettingsProviderTestsBase: XCTestCase { var metadataDatabaseLocation: URL! var crypter = CryptingMock() var provider: SettingsProvider! + var testSettingSyncHandler: TestSettingSyncHandler! func setUpSyncMetadataDatabase() { metadataDatabaseLocation = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -127,14 +128,17 @@ internal class SettingsProviderTestsBase: XCTestCase { override func setUpWithError() throws { super.setUp() - setUpSyncMetadataDatabase() - emailManagerStorage = MockEmailManagerStorage() emailManager = EmailManager(storage: emailManagerStorage) + let emailProtectionSyncHandler = EmailProtectionSyncHandler(emailManager: emailManager) + testSettingSyncHandler = .init() + + setUpSyncMetadataDatabase() + provider = SettingsProvider( metadataDatabase: metadataDatabase, metadataStore: LocalSyncMetadataStore(database: metadataDatabase), - emailManager: emailManager, + settingsHandlers: [emailProtectionSyncHandler, testSettingSyncHandler], syncDidUpdateData: {} ) } diff --git a/Tests/SyncDataProvidersTests/Settings/helpers/SyncableSettingsExtension.swift b/Tests/SyncDataProvidersTests/Settings/helpers/SyncableSettingsExtension.swift index 0948e32f9..57efdae4b 100644 --- a/Tests/SyncDataProvidersTests/Settings/helpers/SyncableSettingsExtension.swift +++ b/Tests/SyncDataProvidersTests/Settings/helpers/SyncableSettingsExtension.swift @@ -17,6 +17,7 @@ // limitations under the License. // +import Bookmarks import BrowserServicesKit import DDGSync import Foundation @@ -44,6 +45,14 @@ extension Syncable { } static func emailProtectionDeleted() -> Syncable { - return Self.settings(SettingsProvider.Setting.emailProtectionGeneration, value: nil, isDeleted: true) + Self.settings(SettingsProvider.Setting.emailProtectionGeneration, value: nil, isDeleted: true) + } + + static func testSetting(_ value: String, lastModified: String? = nil, isDeleted: Bool = false) -> Syncable { + Self.settings(SettingsProvider.Setting.testSetting, value: "encrypted_\(value)", lastModified: lastModified, isDeleted: isDeleted) + } + + static func testSettingDeleted() -> Syncable { + Self.settings(SettingsProvider.Setting.testSetting, value: nil, isDeleted: true) } } diff --git a/Tests/SyncDataProvidersTests/Settings/helpers/TestSettingSyncHandler.swift b/Tests/SyncDataProvidersTests/Settings/helpers/TestSettingSyncHandler.swift new file mode 100644 index 000000000..f7448e249 --- /dev/null +++ b/Tests/SyncDataProvidersTests/Settings/helpers/TestSettingSyncHandler.swift @@ -0,0 +1,61 @@ +// +// TestSettingHandler.swift +// DuckDuckGo +// +// 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 + +extension SettingsProvider.Setting { + static let testSetting = SettingsProvider.Setting(key: "test_setting") +} + +final class TestSettingSyncHandler: SettingSyncHandler { + + override var setting: SettingsProvider.Setting { + .testSetting + } + + override func getValue() throws -> String? { + syncedValue + } + + override func setValue(_ value: String?) throws { + DispatchQueue.main.async { + self.notifyValueDidChange = false + self.syncedValue = value + } + } + + override var valueDidChangePublisher: AnyPublisher { + $syncedValue.dropFirst().map({ _ in }) + .filter { [weak self] in + self?.notifyValueDidChange == true + } + .eraseToAnyPublisher() + } + + @Published var syncedValue: String? { + didSet { + notifyValueDidChange = true + } + } + + private var notifyValueDidChange: Bool = true +} From b84d6acd2b23128b828aa9f8dde1634e8712dc51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Nov 2023 13:03:12 +0100 Subject: [PATCH 21/31] Bump Tests/BrowserServicesKitTests/Resources/privacy-reference-tests (#559) Bumps [Tests/BrowserServicesKitTests/Resources/privacy-reference-tests](https://github.com/duckduckgo/privacy-reference-tests) from `7519c3d` to `a3acc21`. - [Release notes](https://github.com/duckduckgo/privacy-reference-tests/releases) - [Commits](https://github.com/duckduckgo/privacy-reference-tests/compare/7519c3d430e5dcef75b6128bfdadb0de3f463a49...a3acc2194758bec0f01f57dd0c5f106de01a354e) --- updated-dependencies: - dependency-name: Tests/BrowserServicesKitTests/Resources/privacy-reference-tests dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Tests/BrowserServicesKitTests/Resources/privacy-reference-tests | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests b/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests index 7519c3d43..a3acc2194 160000 --- a/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests +++ b/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests @@ -1 +1 @@ -Subproject commit 7519c3d430e5dcef75b6128bfdadb0de3f463a49 +Subproject commit a3acc2194758bec0f01f57dd0c5f106de01a354e From 94a48a6d97878f51a08ccfc8274d03e79f18de3a Mon Sep 17 00:00:00 2001 From: Sam Macbeth Date: Tue, 14 Nov 2023 10:32:45 +0100 Subject: [PATCH 22/31] Fix timing issues with tracker surrogate injection (#558) * Target triggering to specific script tags * Tidy up and remove dead code * Move all onload event into try catch block, so it only triggers when surrogate loading is successful --- .../ContentBlocking/UserScripts/surrogates.js | 123 +++--------------- 1 file changed, 20 insertions(+), 103 deletions(-) diff --git a/Sources/BrowserServicesKit/ContentBlocking/UserScripts/surrogates.js b/Sources/BrowserServicesKit/ContentBlocking/UserScripts/surrogates.js index c76d0ff71..b15153721 100644 --- a/Sources/BrowserServicesKit/ContentBlocking/UserScripts/surrogates.js +++ b/Sources/BrowserServicesKit/ContentBlocking/UserScripts/surrogates.js @@ -1,5 +1,5 @@ // -// contentblocker.js +// surrogates.js // DuckDuckGo // // Copyright © 2017 DuckDuckGo. All rights reserved. @@ -466,8 +466,6 @@ } } - const loadedSurrogates = {} - // private function loadSurrogate (surrogatePattern) { trackers.surrogateList[surrogatePattern]() @@ -475,7 +473,6 @@ // public function shouldBlock (trackerUrl, type, element) { - seenUrls.add(trackerUrl) const startTime = performance.now() if (!blockingEnabled) { @@ -508,15 +505,15 @@ if (element && element.onerror) { element.onerror = () => {} } - if (!loadedSurrogates[result.matchedRule.surrogate]) { + try { loadSurrogate(result.matchedRule.surrogate) - loadedSurrogates[result.matchedRule.surrogate] = true // Trigger a load event on the original element if (element && element.onload) { element.onload(new Event('load')) } + } catch (e) { + duckduckgoDebugMessaging.log(`error loading surrogate: ${e.toString()}`) } - const pageUrl = window.location.href surrogateInjected({ url: trackerUrl, @@ -538,108 +535,28 @@ return false } - const seenUrls = new Set() - function hasNotSeen (url) { - // Ignore elements with no url - if (!url) { - return false - } - return !seenUrls.has(url) - } - - function processPage () { - [...document.scripts].filter((el) => hasNotSeen(el.src)).forEach((el) => { - if (shouldBlock(el.src, 'script', el)) { - duckduckgoDebugMessaging.log('blocking load') - } - }); - [...document.images].filter((el) => hasNotSeen(el.src)).forEach((el) => { - // If the image's natural width is zero, then it has not loaded so we - // can assume that it may have been blocked. - if (el.naturalWidth === 0) { - if (shouldBlock(el.src, 'image', el)) { - duckduckgoDebugMessaging.log('blocking load') - } - } - }); - [...document.querySelectorAll('link')].filter((el) => hasNotSeen(el.href)).forEach((el) => { - if (shouldBlock(el.href, el.rel, el)) { - duckduckgoDebugMessaging.log('blocking load') - } - }); - [...document.querySelectorAll('iframe')].filter((el) => hasNotSeen(el.src)).forEach((el) => { - if (shouldBlock(el.src, 'subdocument', el)) { - duckduckgoDebugMessaging.log('blocking load') - } - }) - } - - function debounce (func, wait) { - let timeout - return function () { - clearTimeout(timeout) - timeout = setTimeout(() => { - func.apply(this, arguments) - }, wait) - } - } - - const observer = new MutationObserver(debounce((mutations, o) => { - processPage() - }, 100)) - const rootElement = document.body || document.documentElement - observer.observe(rootElement, { childList: true, subtree: true }); - - // Init - (function () { - duckduckgoDebugMessaging.log('installing load detection') - window.addEventListener('load', function (event) { - processPage() - }, false) - - try { - duckduckgoDebugMessaging.log('installing image src detection') - - const originalImageSrc = Object.getOwnPropertyDescriptor(Image.prototype, 'src') - Object.defineProperty(Image.prototype, 'src', { - writable: true, // Needs to be writable for the content blocking rules script. Will be locked down in that script - get: function () { - return originalImageSrc.get.call(this) - }, - set: function (value) { - const instance = this - if (shouldBlock(value, 'image')) { - duckduckgoDebugMessaging.log('blocking image src: ' + value) - } else { - originalImageSrc.set.call(instance, value) + const observer = new MutationObserver((records) => { + for (const record of records) { + record.addedNodes.forEach((node) => { + if (node instanceof HTMLScriptElement) { + if (shouldBlock(node.src, 'script', node)) { + duckduckgoDebugMessaging.log('blocking load') } } }) - } catch (error) { - duckduckgoDebugMessaging.log('failed to install image src detection') - } - - try { - duckduckgoDebugMessaging.log('installing xhr detection') - - const xhr = XMLHttpRequest.prototype - const originalOpen = xhr.open - - xhr.open = function () { - const args = arguments - const url = arguments[1] - if (shouldBlock(url, 'xmlhttprequest')) { - args[1] = 'about:blank' + if (record.target instanceof HTMLScriptElement) { + if (shouldBlock(record.target.src, 'script', record.target)) { + duckduckgoDebugMessaging.log('blocking load') } - duckduckgoDebugMessaging.log('sending xhr ' + url + ' to ' + args[1]) - return originalOpen.apply(this, args) } - } catch (error) { - duckduckgoDebugMessaging.log('failed to install xhr detection') } - - duckduckgoDebugMessaging.log('content blocking initialised') - })() + }) + const rootElement = document.body || document.documentElement + observer.observe(rootElement, { + childList: true, + subtree: true, + attributeFilter: ['src'] + }); return { shouldBlock: shouldBlock From 9c2c7f39679a1f4441fec95fda86f4c089724e2e Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 14 Nov 2023 17:37:07 +0100 Subject: [PATCH 23/31] BSK changes for NetP iOS Geoswitching (#557) * Move Backend Client to internal * Add locations request to client and tidy mocks * Allow country and city in register request * Add selected location storage to userdefaults * Handle setting selected location * Basic location list repository * Add some tests * Add some tests for the list repo * Point to prod * Improve register body initialiser * Make client internal again * Remove unnecessary passing of server selection * Swiftlint * Inject setting not setttings * Swiftlint * More swiftlint * Oh wait, yes. Another swiftlint issue * Remove unnecessary decoding key definitions --- Package.swift | 6 +- .../Diagnostics/NetworkProtectionError.swift | 2 + ...kProtectionCodeRedemptionCoordinator.swift | 18 ++- .../Models/NetworkProtectionLocation.swift | 29 +++++ .../NetworkProtectionSelectedLocation.swift | 29 +++++ .../NetworkProtectionDeviceManager.swift | 54 +++++--- .../Networking/NetworkProtectionClient.swift | 81 ++++++++++-- .../PacketTunnelProvider.swift | 76 +++++++---- ...workProtectionLocationListRepository.swift | 66 ++++++++++ .../UserDefaults+selectedLocation.swift | 88 +++++++++++++ .../Settings/TunnelSettings.swift | 36 ++++++ ...eRedemptionCoordinatorTestExtensions.swift | 2 +- .../MockNetworkProtectionTokenStore.swift | 4 + .../MockNetworkProtectionClient.swift | 32 ++++- .../Mocks/NetworkProtectionClientMocks.swift | 58 --------- .../NetworkProtectionClientTests.swift | 73 +++++++++-- .../NetworkProtectionDeviceManagerTests.swift | 95 ++++++-------- ...LocationListCompositeRepositoryTests.swift | 119 ++++++++++++++++++ .../Resources/locations-endpoint.json | 22 ++++ Tests/NetworkProtectionTests/TestData.swift | 4 + 20 files changed, 709 insertions(+), 185 deletions(-) create mode 100644 Sources/NetworkProtection/Models/NetworkProtectionLocation.swift create mode 100644 Sources/NetworkProtection/Models/NetworkProtectionSelectedLocation.swift create mode 100644 Sources/NetworkProtection/Repositories/NetworkProtectionLocationListRepository.swift create mode 100644 Sources/NetworkProtection/Settings/Extensions/UserDefaults+selectedLocation.swift delete mode 100644 Tests/NetworkProtectionTests/Mocks/NetworkProtectionClientMocks.swift create mode 100644 Tests/NetworkProtectionTests/Repositories/NetworkProtectionLocationListCompositeRepositoryTests.swift create mode 100644 Tests/NetworkProtectionTests/Resources/locations-endpoint.json diff --git a/Package.swift b/Package.swift index 506b907ff..c2279e3bb 100644 --- a/Package.swift +++ b/Package.swift @@ -314,11 +314,13 @@ let package = Package( .testTarget( name: "NetworkProtectionTests", dependencies: [ - "NetworkProtection" + "NetworkProtection", + "NetworkProtectionTestUtils" ], resources: [ .copy("Resources/servers-original-endpoint.json"), - .copy("Resources/servers-updated-endpoint.json") + .copy("Resources/servers-updated-endpoint.json"), + .copy("Resources/locations-endpoint.json") ] ), .testTarget( diff --git a/Sources/NetworkProtection/Diagnostics/NetworkProtectionError.swift b/Sources/NetworkProtection/Diagnostics/NetworkProtectionError.swift index 1cd93c3ad..11856ca30 100644 --- a/Sources/NetworkProtection/Diagnostics/NetworkProtectionError.swift +++ b/Sources/NetworkProtection/Diagnostics/NetworkProtectionError.swift @@ -34,6 +34,8 @@ public enum NetworkProtectionError: LocalizedError { // Client errors case failedToFetchServerList(Error?) case failedToParseServerListResponse(Error) + case failedToFetchLocationList(Error?) + case failedToParseLocationListResponse(Error) case failedToEncodeRegisterKeyRequest case failedToFetchRegisteredServers(Error?) case failedToParseRegisteredServersResponse(Error) diff --git a/Sources/NetworkProtection/FeatureActivation/NetworkProtectionCodeRedemptionCoordinator.swift b/Sources/NetworkProtection/FeatureActivation/NetworkProtectionCodeRedemptionCoordinator.swift index 277a0d4bc..acecf18df 100644 --- a/Sources/NetworkProtection/FeatureActivation/NetworkProtectionCodeRedemptionCoordinator.swift +++ b/Sources/NetworkProtection/FeatureActivation/NetworkProtectionCodeRedemptionCoordinator.swift @@ -32,10 +32,20 @@ public final class NetworkProtectionCodeRedemptionCoordinator: NetworkProtection private let versionStore: NetworkProtectionLastVersionRunStore private let errorEvents: EventMapping - public init(networkClient: NetworkProtectionClient = NetworkProtectionBackendClient(), - tokenStore: NetworkProtectionTokenStore, - versionStore: NetworkProtectionLastVersionRunStore = .init(), - errorEvents: EventMapping) { + convenience public init(environment: TunnelSettings.SelectedEnvironment, + tokenStore: NetworkProtectionTokenStore, + versionStore: NetworkProtectionLastVersionRunStore = .init(), + errorEvents: EventMapping) { + self.init(networkClient: NetworkProtectionBackendClient(environment: environment), + tokenStore: tokenStore, + versionStore: versionStore, + errorEvents: errorEvents) + } + + init(networkClient: NetworkProtectionClient, + tokenStore: NetworkProtectionTokenStore, + versionStore: NetworkProtectionLastVersionRunStore = .init(), + errorEvents: EventMapping) { self.networkClient = networkClient self.tokenStore = tokenStore self.versionStore = versionStore diff --git a/Sources/NetworkProtection/Models/NetworkProtectionLocation.swift b/Sources/NetworkProtection/Models/NetworkProtectionLocation.swift new file mode 100644 index 000000000..6317d1ea9 --- /dev/null +++ b/Sources/NetworkProtection/Models/NetworkProtectionLocation.swift @@ -0,0 +1,29 @@ +// +// NetworkProtectionLocation.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public struct NetworkProtectionLocation: Codable, Equatable, Sendable { + public let country: String + public let cities: [City] + + public struct City: Codable, Equatable, Sendable { + public let name: String + } +} diff --git a/Sources/NetworkProtection/Models/NetworkProtectionSelectedLocation.swift b/Sources/NetworkProtection/Models/NetworkProtectionSelectedLocation.swift new file mode 100644 index 000000000..df3badda8 --- /dev/null +++ b/Sources/NetworkProtection/Models/NetworkProtectionSelectedLocation.swift @@ -0,0 +1,29 @@ +// +// NetworkProtectionSelectedLocation.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public struct NetworkProtectionSelectedLocation: Codable, Equatable { + public let country: String + public let city: String? + + public init(country: String, city: String? = nil) { + self.country = country + self.city = city + } +} diff --git a/Sources/NetworkProtection/NetworkProtectionDeviceManager.swift b/Sources/NetworkProtection/NetworkProtectionDeviceManager.swift index b9c062913..cb9a3c577 100644 --- a/Sources/NetworkProtection/NetworkProtectionDeviceManager.swift +++ b/Sources/NetworkProtection/NetworkProtectionDeviceManager.swift @@ -24,6 +24,7 @@ public enum NetworkProtectionServerSelectionMethod { case automatic case preferredServer(serverName: String) case avoidServer(serverName: String) + case preferredLocation(NetworkProtectionSelectedLocation) } public protocol NetworkProtectionDeviceManagement { @@ -43,11 +44,23 @@ public actor NetworkProtectionDeviceManager: NetworkProtectionDeviceManagement { private let errorEvents: EventMapping? - public init(networkClient: NetworkProtectionClient = NetworkProtectionBackendClient(), + public init(environment: TunnelSettings.SelectedEnvironment, tokenStore: NetworkProtectionTokenStore, keyStore: NetworkProtectionKeyStore, serverListStore: NetworkProtectionServerListStore? = nil, errorEvents: EventMapping?) { + self.init(networkClient: NetworkProtectionBackendClient(environment: environment), + tokenStore: tokenStore, + keyStore: keyStore, + serverListStore: serverListStore, + errorEvents: errorEvents) + } + + init(networkClient: NetworkProtectionClient, + tokenStore: NetworkProtectionTokenStore, + keyStore: NetworkProtectionKeyStore, + serverListStore: NetworkProtectionServerListStore? = nil, + errorEvents: EventMapping?) { self.networkClient = networkClient self.tokenStore = tokenStore self.keyStore = keyStore @@ -118,39 +131,45 @@ public actor NetworkProtectionDeviceManager: NetworkProtectionDeviceManagement { } } - /// Registers the client with a server following the specified server selection method. Returns the precise server that was selected and the keyPair to use - /// for the tunnel configuration. - /// - /// - Parameters: - /// - selectionMethod: the server selection method - /// - keyPair: the key pair that was used to register with the server, and that should be used to configure the tunnel - /// - /// - Throws:`NetworkProtectionError` - /// + // Registers the client with a server following the specified server selection method. Returns the precise server that was selected and the keyPair to use + // for the tunnel configuration. + // + // - Parameters: + // - selectionMethod: the server selection method + // - keyPair: the key pair that was used to register with the server, and that should be used to configure the tunnel + // + // - Throws:`NetworkProtectionError` + // This cannot be a doc comment because of the swiftlint command below + // swiftlint:disable cyclomatic_complexity private func register(selectionMethod: NetworkProtectionServerSelectionMethod) async throws -> (server: NetworkProtectionServer, keyPair: KeyPair) { guard let token = try? tokenStore.fetchToken() else { throw NetworkProtectionError.noAuthTokenFound } + var keyPair = keyStore.currentKeyPair() - let selectedServerName: String? + let serverSelection: RegisterServerSelection let excludedServerName: String? switch selectionMethod { case .automatic: - selectedServerName = nil + serverSelection = .automatic excludedServerName = nil case .preferredServer(let serverName): - selectedServerName = serverName + serverSelection = .server(name: serverName) excludedServerName = nil case .avoidServer(let serverToAvoid): - selectedServerName = nil + serverSelection = .automatic excludedServerName = serverToAvoid + case .preferredLocation(let location): + serverSelection = .location(country: location.country, city: location.city) + excludedServerName = nil } - var keyPair = keyStore.currentKeyPair() + let requestBody = RegisterKeyRequestBody(publicKey: keyPair.publicKey, + serverSelection: serverSelection) + let registeredServersResult = await networkClient.register(authToken: token, - publicKey: keyPair.publicKey, - withServerNamed: selectedServerName) + requestBody: requestBody) let selectedServer: NetworkProtectionServer switch registeredServersResult { @@ -193,6 +212,7 @@ public actor NetworkProtectionDeviceManager: NetworkProtectionDeviceManagement { return (cachedServer, keyPair) } } + // swiftlint:enable cyclomatic_complexity /// Retrieves the first cached server that's registered with the specified key pair. /// diff --git a/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift b/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift index 7d3a6ba21..ade571ef4 100644 --- a/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift +++ b/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift @@ -18,15 +18,17 @@ import Foundation -public protocol NetworkProtectionClient { +protocol NetworkProtectionClient { func redeem(inviteCode: String) async -> Result + func getLocations(authToken: String) async -> Result<[NetworkProtectionLocation], NetworkProtectionClientError> func getServers(authToken: String) async -> Result<[NetworkProtectionServer], NetworkProtectionClientError> func register(authToken: String, - publicKey: PublicKey, - withServerNamed serverName: String?) async -> Result<[NetworkProtectionServer], NetworkProtectionClientError> + requestBody: RegisterKeyRequestBody) async -> Result<[NetworkProtectionServer], NetworkProtectionClientError> } public enum NetworkProtectionClientError: Error, NetworkProtectionErrorConvertible { + case failedToFetchLocationList(Error?) + case failedToParseLocationListResponse(Error) case failedToFetchServerList(Error?) case failedToParseServerListResponse(Error) case failedToEncodeRegisterKeyRequest @@ -40,6 +42,8 @@ public enum NetworkProtectionClientError: Error, NetworkProtectionErrorConvertib var networkProtectionError: NetworkProtectionError { switch self { + case .failedToFetchLocationList(let error): return .failedToFetchLocationList(error) + case .failedToParseLocationListResponse(let error): return .failedToParseLocationListResponse(error) case .failedToFetchServerList(let error): return .failedToFetchServerList(error) case .failedToParseServerListResponse(let error): return .failedToParseServerListResponse(error) case .failedToEncodeRegisterKeyRequest: return .failedToEncodeRegisterKeyRequest @@ -57,13 +61,35 @@ public enum NetworkProtectionClientError: Error, NetworkProtectionErrorConvertib struct RegisterKeyRequestBody: Encodable { let publicKey: String let server: String? + let country: String? + let city: String? - init(publicKey: PublicKey, server: String?) { + init(publicKey: PublicKey, + serverSelection: RegisterServerSelection) { self.publicKey = publicKey.base64Key - self.server = server + switch serverSelection { + case .automatic: + server = nil + country = nil + city = nil + case .server(let name): + server = name + country = nil + city = nil + case .location(let country, let city): + server = nil + self.country = country + self.city = city + } } } +enum RegisterServerSelection { + case automatic + case server(name: String) + case location(country: String, city: String?) +} + struct RedeemRequestBody: Encodable { let code: String } @@ -72,7 +98,7 @@ struct RedeemResponse: Decodable { let token: String } -public final class NetworkProtectionBackendClient: NetworkProtectionClient { +final class NetworkProtectionBackendClient: NetworkProtectionClient { private enum DecoderError: Error { case failedToDecode(key: String) @@ -82,6 +108,10 @@ public final class NetworkProtectionBackendClient: NetworkProtectionClient { endpointURL.appending("/servers") } + var locationsURL: URL { + endpointURL.appending("/locations") + } + var registerKeyURL: URL { endpointURL.appending("/register") } @@ -111,11 +141,38 @@ public final class NetworkProtectionBackendClient: NetworkProtectionClient { private let endpointURL: URL - public init(environment: TunnelSettings.SelectedEnvironment = .default) { + init(environment: TunnelSettings.SelectedEnvironment = .default) { endpointURL = environment.endpointURL } - public func getServers(authToken: String) async -> Result<[NetworkProtectionServer], NetworkProtectionClientError> { + func getLocations(authToken: String) async -> Result<[NetworkProtectionLocation], NetworkProtectionClientError> { + var request = URLRequest(url: locationsURL) + request.setValue("bearer \(authToken)", forHTTPHeaderField: "Authorization") + let downloadedData: Data + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let response = response as? HTTPURLResponse else { + return .failure(.failedToFetchLocationList(nil)) + } + switch response.statusCode { + case 200: downloadedData = data + case 401: return .failure(.invalidAuthToken) + default: return .failure(.failedToFetchLocationList(nil)) + } + } catch { + return .failure(NetworkProtectionClientError.failedToFetchLocationList(error)) + } + + do { + let decodedLocations = try decoder.decode([NetworkProtectionLocation].self, from: downloadedData) + return .success(decodedLocations) + } catch { + return .failure(NetworkProtectionClientError.failedToParseLocationListResponse(error)) + } + } + + func getServers(authToken: String) async -> Result<[NetworkProtectionServer], NetworkProtectionClientError> { var request = URLRequest(url: serversURL) request.setValue("bearer \(authToken)", forHTTPHeaderField: "Authorization") let downloadedData: Data @@ -142,10 +199,8 @@ public final class NetworkProtectionBackendClient: NetworkProtectionClient { } } - public func register(authToken: String, - publicKey: PublicKey, - withServerNamed serverName: String? = nil) async -> Result<[NetworkProtectionServer], NetworkProtectionClientError> { - let requestBody = RegisterKeyRequestBody(publicKey: publicKey, server: serverName) + func register(authToken: String, + requestBody: RegisterKeyRequestBody) async -> Result<[NetworkProtectionServer], NetworkProtectionClientError> { let requestBodyData: Data do { @@ -184,7 +239,7 @@ public final class NetworkProtectionBackendClient: NetworkProtectionClient { } } - public func redeem(inviteCode: String) async -> Result { + func redeem(inviteCode: String) async -> Result { let requestBody = RedeemRequestBody(code: inviteCode) let requestBodyData: Data do { diff --git a/Sources/NetworkProtection/PacketTunnelProvider.swift b/Sources/NetworkProtection/PacketTunnelProvider.swift index 0343d2f17..c7893f632 100644 --- a/Sources/NetworkProtection/PacketTunnelProvider.swift +++ b/Sources/NetworkProtection/PacketTunnelProvider.swift @@ -113,7 +113,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // MARK: - Tunnel Settings - private let settings = TunnelSettings(defaults: .standard) + private let settings: TunnelSettings // MARK: - Server Selection @@ -165,7 +165,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { self.resetRegistrationKey() do { - try await updateTunnelConfiguration(selectedServer: settings.selectedServer, reassert: false) + try await updateTunnelConfiguration(reassert: false) } catch { os_log("Rekey attempt failed. This is not an error if you're using debug Key Management options: %{public}@", log: .networkProtectionKeyManagement, type: .error, String(describing: error)) } @@ -299,7 +299,8 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { keychainType: KeychainType, tokenStore: NetworkProtectionTokenStore, debugEvents: EventMapping?, - providerEvents: EventMapping) { + providerEvents: EventMapping, + tunnelSettings: TunnelSettings = TunnelSettings(defaults: .standard)) { os_log("[+] PacketTunnelProvider", log: .networkProtectionMemoryLog, type: .debug) self.notificationsPresenter = notificationsPresenter @@ -309,8 +310,14 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { self.providerEvents = providerEvents self.tunnelHealth = tunnelHealthStore self.controllerErrorStore = controllerErrorStore + self.settings = tunnelSettings super.init() + + tunnelSettings.changePublisher + .sink { [weak self] change in + self?.handleSettingsChange(change) + }.store(in: &cancellables) } deinit { @@ -489,28 +496,39 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { let onDemand = options.startupMethod == .automaticOnDemand os_log("Starting tunnel %{public}@", log: .networkProtection, options.startupMethod.debugDescription) - startTunnel(environment: settings.selectedEnvironment, - selectedServer: settings.selectedServer, + startTunnel(environment: settings.selectedEnvironment, onDemand: onDemand, completionHandler: completionHandler) } - private func startTunnel(environment: TunnelSettings.SelectedEnvironment, selectedServer: TunnelSettings.SelectedServer, onDemand: Bool, completionHandler: @escaping (Error?) -> Void) { + var currentServerSelectionMethod: NetworkProtectionServerSelectionMethod { + var serverSelectionMethod: NetworkProtectionServerSelectionMethod - Task { - let serverSelectionMethod: NetworkProtectionServerSelectionMethod + switch settings.selectedLocation { + case .nearest: + serverSelectionMethod = .automatic + case .location(let networkProtectionSelectedLocation): + serverSelectionMethod = .preferredLocation(networkProtectionSelectedLocation) + } - switch settings.selectedServer { - case .automatic: - serverSelectionMethod = .automatic - case .endpoint(let serverName): - serverSelectionMethod = .preferredServer(serverName: serverName) - } + switch settings.selectedServer { + case .automatic: + break + case .endpoint(let string): + // Selecting a specific server will override locations setting + // Only available in debug + serverSelectionMethod = .preferredServer(serverName: string) + } + return serverSelectionMethod + } + + private func startTunnel(environment: TunnelSettings.SelectedEnvironment, onDemand: Bool, completionHandler: @escaping (Error?) -> Void) { + Task { do { os_log("🔵 Generating tunnel config", log: .networkProtection, type: .info) let tunnelConfiguration = try await generateTunnelConfiguration(environment: environment, - serverSelectionMethod: serverSelectionMethod, + serverSelectionMethod: currentServerSelectionMethod, includedRoutes: includedRoutes ?? [], excludedRoutes: excludedRoutes ?? []) startTunnel(with: tunnelConfiguration, onDemand: onDemand, completionHandler: completionHandler) @@ -635,7 +653,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // MARK: - Tunnel Configuration - public func updateTunnelConfiguration(environment: TunnelSettings.SelectedEnvironment = .default, selectedServer: TunnelSettings.SelectedServer, reassert: Bool = true) async throws { + public func updateTunnelConfiguration(environment: TunnelSettings.SelectedEnvironment = .default, reassert: Bool = true) async throws { let serverSelectionMethod: NetworkProtectionServerSelectionMethod switch settings.selectedServer { @@ -764,16 +782,18 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func handleRequest(_ request: ExtensionRequest, completionHandler: ((Data?) -> Void)? = nil) { switch request { case .changeTunnelSetting(let change): - handleSettingsChange(change, completionHandler: completionHandler) + handleSettingChangeAppRequest(change, completionHandler: completionHandler) case .debugCommand(let command): handleDebugCommand(command, completionHandler: completionHandler) } } - private func handleSettingsChange(_ change: TunnelSettings.Change, completionHandler: ((Data?) -> Void)? = nil) { - + private func handleSettingChangeAppRequest(_ change: TunnelSettings.Change, completionHandler: ((Data?) -> Void)? = nil) { settings.apply(change: change) + handleSettingsChange(change, completionHandler: completionHandler) + } + private func handleSettingsChange(_ change: TunnelSettings.Change, completionHandler: ((Data?) -> Void)? = nil) { switch change { case .setSelectedServer(let selectedServer): let serverSelectionMethod: NetworkProtectionServerSelectionMethod @@ -789,6 +809,20 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { try? await updateTunnelConfiguration(environment: settings.selectedEnvironment, serverSelectionMethod: serverSelectionMethod) completionHandler?(nil) } + case .setSelectedLocation(let selectedLocation): + let serverSelectionMethod: NetworkProtectionServerSelectionMethod + + switch selectedLocation { + case .nearest: + serverSelectionMethod = .automatic + case .location(let location): + serverSelectionMethod = .preferredLocation(location) + } + + Task { + try? await updateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod) + completionHandler?(nil) + } case .setIncludeAllNetworks, .setEnforceRoutes, .setExcludeLocalNetworks, @@ -900,7 +934,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func setExcludedRoutes(_ excludedRoutes: [IPAddressRange], completionHandler: ((Data?) -> Void)? = nil) { Task { self.excludedRoutes = excludedRoutes - try? await updateTunnelConfiguration(selectedServer: settings.selectedServer, reassert: false) + try? await updateTunnelConfiguration(reassert: false) completionHandler?(nil) } } @@ -908,7 +942,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func setIncludedRoutes(_ includedRoutes: [IPAddressRange], completionHandler: ((Data?) -> Void)? = nil) { Task { self.includedRoutes = includedRoutes - try? await updateTunnelConfiguration(selectedServer: settings.selectedServer, reassert: false) + try? await updateTunnelConfiguration(reassert: false) completionHandler?(nil) } } diff --git a/Sources/NetworkProtection/Repositories/NetworkProtectionLocationListRepository.swift b/Sources/NetworkProtection/Repositories/NetworkProtectionLocationListRepository.swift new file mode 100644 index 000000000..46d64efc6 --- /dev/null +++ b/Sources/NetworkProtection/Repositories/NetworkProtectionLocationListRepository.swift @@ -0,0 +1,66 @@ +// +// NetworkProtectionLocationListRepository.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public protocol NetworkProtectionLocationListRepository { + func fetchLocationList() async throws -> [NetworkProtectionLocation] +} + +final public class NetworkProtectionLocationListCompositeRepository: NetworkProtectionLocationListRepository { + @MainActor private static var locationList: [NetworkProtectionLocation] = [] + private let client: NetworkProtectionClient + private let tokenStore: NetworkProtectionTokenStore + + convenience public init(environment: TunnelSettings.SelectedEnvironment, tokenStore: NetworkProtectionTokenStore) { + self.init( + client: NetworkProtectionBackendClient(environment: environment), + tokenStore: tokenStore + ) + } + + init(client: NetworkProtectionClient, tokenStore: NetworkProtectionTokenStore) { + self.client = client + self.tokenStore = tokenStore + } + + @MainActor + public func fetchLocationList() async throws -> [NetworkProtectionLocation] { + guard Self.locationList.isEmpty else { + return Self.locationList + } + do { + guard let authToken = try tokenStore.fetchToken() else { + throw NetworkProtectionError.noAuthTokenFound + } + Self.locationList = try await client.getLocations(authToken: authToken).get() + } catch let error as NetworkProtectionErrorConvertible { + throw error.networkProtectionError + } catch let error as NetworkProtectionError { + throw error + } catch { + throw NetworkProtectionError.unhandledError(function: #function, line: #line, error: error) + } + return Self.locationList + } + + @MainActor + static func clearCache() { + locationList = [] + } +} diff --git a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+selectedLocation.swift b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+selectedLocation.swift new file mode 100644 index 000000000..82a543f60 --- /dev/null +++ b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+selectedLocation.swift @@ -0,0 +1,88 @@ +// +// UserDefaults+selectedLocation.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +extension UserDefaults { + final class StorableLocation: NSObject, Codable { + let country: String + let city: String? + + init(country: String, city: String?) { + self.country = country + self.city = city + } + } + + @objc + dynamic var networkProtectionSettingSelectedLocationStorageValue: StorableLocation? { + get { + guard let data = data(forKey: #keyPath(networkProtectionSettingSelectedLocationStorageValue)) else { return nil } + do { + return try JSONDecoder().decode(StorableLocation?.self, from: data) + } catch { + assertionFailure("Errored while decoding location") + return nil + } + } + + set { + do { + let data = try JSONEncoder().encode(newValue) + set(data, forKey: #keyPath(networkProtectionSettingSelectedLocationStorageValue)) + } catch { + assertionFailure("Errored while encoding location") + } + } + } + + private static func selectedLocationFromStorageValue(_ storageValue: StorableLocation?) -> TunnelSettings.SelectedLocation { + guard let storageValue else { + return .nearest + } + let selectedLocation = NetworkProtectionSelectedLocation(country: storageValue.country, city: storageValue.city) + + return .location(selectedLocation) + } + + var networkProtectionSettingSelectedLocation: TunnelSettings.SelectedLocation { + get { + Self.selectedLocationFromStorageValue(networkProtectionSettingSelectedLocationStorageValue) + } + + set { + switch newValue { + case .nearest: + networkProtectionSettingSelectedLocationStorageValue = nil + case .location(let location): + networkProtectionSettingSelectedLocationStorageValue = StorableLocation(country: location.country, city: location.city) + } + } + } + + var networkProtectionSettingSelectedLocationPublisher: AnyPublisher { + return publisher(for: \.networkProtectionSettingSelectedLocationStorageValue) + .map(Self.selectedLocationFromStorageValue(_:)) + .eraseToAnyPublisher() + } + + func resetNetworkProtectionSettingSelectedLocation() { + networkProtectionSettingSelectedLocationStorageValue = nil + } +} diff --git a/Sources/NetworkProtection/Settings/TunnelSettings.swift b/Sources/NetworkProtection/Settings/TunnelSettings.swift index ca6d7ad22..3aee2b58a 100644 --- a/Sources/NetworkProtection/Settings/TunnelSettings.swift +++ b/Sources/NetworkProtection/Settings/TunnelSettings.swift @@ -33,6 +33,7 @@ public final class TunnelSettings { case setExcludeLocalNetworks(_ excludeLocalNetworks: Bool) case setRegistrationKeyValidity(_ validity: RegistrationKeyValidity) case setSelectedServer(_ selectedServer: SelectedServer) + case setSelectedLocation(_ selectedLocation: SelectedLocation) case setSelectedEnvironment(_ selectedEnvironment: SelectedEnvironment) } @@ -53,6 +54,18 @@ public final class TunnelSettings { } } + public enum SelectedLocation: Codable, Equatable { + case nearest + case location(NetworkProtectionSelectedLocation) + + public var location: NetworkProtectionSelectedLocation? { + switch self { + case .nearest: return nil + case .location(let location): return location + } + } + } + public enum SelectedEnvironment: String, Codable { case production case staging @@ -93,6 +106,10 @@ public final class TunnelSettings { Change.setSelectedServer(server) }.eraseToAnyPublisher() + let locationChangePublisher = selectedLocationPublisher.map { location in + Change.setSelectedLocation(location) + }.eraseToAnyPublisher() + let environmentChangePublisher = selectedEnvironmentPublisher.map { environment in Change.setSelectedEnvironment(environment) }.eraseToAnyPublisher() @@ -102,6 +119,7 @@ public final class TunnelSettings { enforceRoutesPublisher, excludeLocalNetworksPublisher, serverChangePublisher, + locationChangePublisher, environmentChangePublisher).eraseToAnyPublisher() }() @@ -134,6 +152,8 @@ public final class TunnelSettings { self.registrationKeyValidity = registrationKeyValidity case .setSelectedServer(let selectedServer): self.selectedServer = selectedServer + case .setSelectedLocation(let selectedLocation): + self.selectedLocation = selectedLocation case .setSelectedEnvironment(let selectedEnvironment): self.selectedEnvironment = selectedEnvironment } @@ -223,6 +243,22 @@ public final class TunnelSettings { } } + // MARK: - Location Selection + + public var selectedLocationPublisher: AnyPublisher { + defaults.networkProtectionSettingSelectedLocationPublisher + } + + public var selectedLocation: SelectedLocation { + get { + defaults.networkProtectionSettingSelectedLocation + } + + set { + defaults.networkProtectionSettingSelectedLocation = newValue + } + } + // MARK: - Environment public var selectedEnvironmentPublisher: AnyPublisher { diff --git a/Sources/NetworkProtectionTestUtils/FeatureActivation/NetworkProtectionCodeRedemptionCoordinatorTestExtensions.swift b/Sources/NetworkProtectionTestUtils/FeatureActivation/NetworkProtectionCodeRedemptionCoordinatorTestExtensions.swift index 3d4e1b929..7e52003b9 100644 --- a/Sources/NetworkProtectionTestUtils/FeatureActivation/NetworkProtectionCodeRedemptionCoordinatorTestExtensions.swift +++ b/Sources/NetworkProtectionTestUtils/FeatureActivation/NetworkProtectionCodeRedemptionCoordinatorTestExtensions.swift @@ -18,7 +18,7 @@ // import Foundation -import NetworkProtection +@testable import NetworkProtection import Common public extension NetworkProtectionCodeRedemptionCoordinator { diff --git a/Sources/NetworkProtectionTestUtils/KeyManagement/MockNetworkProtectionTokenStore.swift b/Sources/NetworkProtectionTestUtils/KeyManagement/MockNetworkProtectionTokenStore.swift index a79b3d1eb..e313ed9ba 100644 --- a/Sources/NetworkProtectionTestUtils/KeyManagement/MockNetworkProtectionTokenStore.swift +++ b/Sources/NetworkProtectionTestUtils/KeyManagement/MockNetworkProtectionTokenStore.swift @@ -22,6 +22,10 @@ import NetworkProtection public final class MockNetworkProtectionTokenStorage: NetworkProtectionTokenStore { + public init() { + + } + var spyToken: String? var storeError: Error? diff --git a/Sources/NetworkProtectionTestUtils/Networking/MockNetworkProtectionClient.swift b/Sources/NetworkProtectionTestUtils/Networking/MockNetworkProtectionClient.swift index d6ffa268d..a4d4a840e 100644 --- a/Sources/NetworkProtectionTestUtils/Networking/MockNetworkProtectionClient.swift +++ b/Sources/NetworkProtectionTestUtils/Networking/MockNetworkProtectionClient.swift @@ -18,12 +18,29 @@ // import Foundation -import NetworkProtection +@testable import NetworkProtection // swiftlint:disable line_length public final class MockNetworkProtectionClient: NetworkProtectionClient { + public init() { + } + + public var spyGetLocationsAuthToken: String? + public var stubGetLocations: Result<[NetworkProtection.NetworkProtectionLocation], NetworkProtection.NetworkProtectionClientError> = .success([]) + public var getLocationsCalled: Bool { + spyGetLocationsAuthToken != nil + } + + public func getLocations(authToken: String) async -> Result<[NetworkProtection.NetworkProtectionLocation], NetworkProtection.NetworkProtectionClientError> { + spyGetLocationsAuthToken = authToken + return stubGetLocations + } + public var spyRedeemInviteCode: String? public var stubRedeem: Result = .success("") + public var redeemCalled: Bool { + spyRedeemInviteCode != nil + } public func redeem(inviteCode: String) async -> Result { spyRedeemInviteCode = inviteCode @@ -32,18 +49,23 @@ public final class MockNetworkProtectionClient: NetworkProtectionClient { public var spyGetServersAuthToken: String? public var stubGetServers: Result<[NetworkProtection.NetworkProtectionServer], NetworkProtection.NetworkProtectionClientError> = .success([]) + public var getServersCalled: Bool { + spyGetServersAuthToken != nil + } public func getServers(authToken: String) async -> Result<[NetworkProtection.NetworkProtectionServer], NetworkProtection.NetworkProtectionClientError> { spyGetServersAuthToken = authToken return stubGetServers } - // swiftlint:disable:next large_tuple - public var spyRegister: (authToken: String, publicKey: NetworkProtection.PublicKey, serverName: String?)? + public var spyRegister: (authToken: String, requestBody: NetworkProtection.RegisterKeyRequestBody)? + public var registerCalled: Bool { + spyRegister != nil + } public var stubRegister: Result<[NetworkProtection.NetworkProtectionServer], NetworkProtection.NetworkProtectionClientError> = .success([]) - public func register(authToken: String, publicKey: NetworkProtection.PublicKey, withServerNamed serverName: String?) async -> Result<[NetworkProtection.NetworkProtectionServer], NetworkProtection.NetworkProtectionClientError> { - spyRegister = (authToken: authToken, publicKey: publicKey, serverName: serverName) + public func register(authToken: String, requestBody: NetworkProtection.RegisterKeyRequestBody) async -> Result<[NetworkProtection.NetworkProtectionServer], NetworkProtection.NetworkProtectionClientError> { + spyRegister = (authToken: authToken, requestBody: requestBody) return stubRegister } } diff --git a/Tests/NetworkProtectionTests/Mocks/NetworkProtectionClientMocks.swift b/Tests/NetworkProtectionTests/Mocks/NetworkProtectionClientMocks.swift deleted file mode 100644 index 989a5e3c9..000000000 --- a/Tests/NetworkProtectionTests/Mocks/NetworkProtectionClientMocks.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// NetworkProtectionClientMocks.swift -// -// Copyright © 2021 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation -import XCTest -@testable import NetworkProtection - -final class NetworkProtectionMockClient: NetworkProtectionClient { - var redeemReturnValue: Result - var redeemCalled = false - - func redeem(inviteCode: String) async -> Result { - redeemCalled = true - return redeemReturnValue - } - - var getServersReturnValue: Result<[NetworkProtectionServer], NetworkProtectionClientError> - var registerServersReturnValue: Result<[NetworkProtectionServer], NetworkProtectionClientError> - - var getServersCalled = false - var registerCalled = false - - internal init(getServersReturnValue: Result<[NetworkProtectionServer], NetworkProtectionClientError>, - registerServersReturnValue: Result<[NetworkProtectionServer], NetworkProtectionClientError>, - redeemReturnValue: Result) { - self.getServersReturnValue = getServersReturnValue - self.registerServersReturnValue = registerServersReturnValue - self.redeemReturnValue = redeemReturnValue - } - - func getServers(authToken: String) async -> Result<[NetworkProtectionServer], NetworkProtectionClientError> { - getServersCalled = true - return getServersReturnValue - } - - func register(authToken: String, - publicKey: PublicKey, - withServerNamed serverName: String?) async -> Result<[NetworkProtectionServer], NetworkProtectionClientError> { - registerCalled = true - return registerServersReturnValue - } - -} diff --git a/Tests/NetworkProtectionTests/NetworkProtectionClientTests.swift b/Tests/NetworkProtectionTests/NetworkProtectionClientTests.swift index 6ea6b0a09..32a8d8c44 100644 --- a/Tests/NetworkProtectionTests/NetworkProtectionClientTests.swift +++ b/Tests/NetworkProtectionTests/NetworkProtectionClientTests.swift @@ -20,6 +20,16 @@ import XCTest @testable import NetworkProtection final class NetworkProtectionClientTests: XCTestCase { + var testDefaults: UserDefaults! + var tunnelSettings: TunnelSettings! + var client: NetworkProtectionBackendClient! + + override func setUp() { + super.setUp() + testDefaults = UserDefaults(suiteName: "com.duckduckgo.browserserviceskit.tests.\(String(describing: type(of: self)))")! + tunnelSettings = TunnelSettings(defaults: testDefaults) + client = NetworkProtectionBackendClient(environment: .default) + } override class func setUp() { super.setUp() @@ -45,12 +55,12 @@ final class NetworkProtectionClientTests: XCTestCase { // MARK: register func testRegister401Response_ThrowsInvalidTokenError() async { - let client = NetworkProtectionBackendClient() let emptyData = "".data(using: .utf8)! MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.registerKeyURL, statusCode: 401)!, .success(emptyData)) - let result = await client.register(authToken: "anAuthToken", publicKey: .testData, withServerNamed: "Mock Server") + let body = RegisterKeyRequestBody(publicKey: .testData, serverSelection: .server(name: "MockServer")) + let result = await client.register(authToken: "anAuthToken", requestBody: body) guard case .failure(let error) = result, case .invalidAuthToken = error else { XCTFail("Expected an invalidAuthToken error to be thrown") @@ -61,7 +71,6 @@ final class NetworkProtectionClientTests: XCTestCase { // MARK: servers func testGetServer401Response_ThrowsInvalidTokenError() async { - let client = NetworkProtectionBackendClient() let emptyData = "".data(using: .utf8)! MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.serversURL, statusCode: 401)!, .success(emptyData)) @@ -77,7 +86,6 @@ final class NetworkProtectionClientTests: XCTestCase { // MARK: redeem(inviteCode:) func testRedeemSuccess() async { - let client = NetworkProtectionBackendClient() let token = "a6s7ad6ad76aasa7s6a" let successData = redeemSuccessData(token: token) MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: 200)!, @@ -89,7 +97,6 @@ final class NetworkProtectionClientTests: XCTestCase { } func testRedeem400Response() async { - let client = NetworkProtectionBackendClient() let emptyData = "".data(using: .utf8)! MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: 400)!, .success(emptyData)) @@ -103,7 +110,6 @@ final class NetworkProtectionClientTests: XCTestCase { } func testRedeemNon200Or400Response() async { - let client = NetworkProtectionBackendClient() let emptyData = "".data(using: .utf8)! for code in [401, 304, 500] { @@ -120,7 +126,6 @@ final class NetworkProtectionClientTests: XCTestCase { } func testRedeemDecodeFailure() async { - let client = NetworkProtectionBackendClient() let undecodableData = "sdfghj".data(using: .utf8)! MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: 200)!, .success(undecodableData)) @@ -140,6 +145,60 @@ final class NetworkProtectionClientTests: XCTestCase { } """.data(using: .utf8)! } + + // MARK: locations(authToken:) + + func testLocationsSuccess() async { + let successData = TestData.mockLocations + MockURLProtocol.stubs[client.locationsURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: 200)!, + .success(successData)) + + let result = await client.getLocations(authToken: "DH76F8S") + + XCTAssertEqual(try? result.get().count, 2) + } + + func testLocations401Response() async { + let emptyData = "".data(using: .utf8)! + MockURLProtocol.stubs[client.locationsURL] = (response: HTTPURLResponse(url: client.locationsURL, statusCode: 401)!, + .success(emptyData)) + + let result = await client.getLocations(authToken: "DH76F8S") + + guard case .failure(let error) = result, case .invalidAuthToken = error else { + XCTFail("Expected an invalidAuthToken error to be thrown") + return + } + } + + func testLocationsNon200Or400Response() async { + let emptyData = "".data(using: .utf8)! + + for code in [304, 500] { + MockURLProtocol.stubs[client.locationsURL] = (response: HTTPURLResponse(url: client.locationsURL, statusCode: code)!, + .success(emptyData)) + + let result = await client.getLocations(authToken: "DH76F8S") + + guard case .failure(let error) = result, case .failedToFetchLocationList = error else { + XCTFail("Expected a failedToFetchLocationList error to be thrown") + return + } + } + } + + func testLocationsDecodeFailure() async { + let undecodableData = "sdfghj".data(using: .utf8)! + MockURLProtocol.stubs[client.locationsURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: 200)!, + .success(undecodableData)) + + let result = await client.getLocations(authToken: "DH76F8S") + + guard case .failure(let error) = result, case .failedToParseLocationListResponse = error else { + XCTFail("Expected a failedToRedeemInviteCode error to be thrown") + return + } + } } extension HTTPURLResponse { diff --git a/Tests/NetworkProtectionTests/NetworkProtectionDeviceManagerTests.swift b/Tests/NetworkProtectionTests/NetworkProtectionDeviceManagerTests.swift index a8849b0d7..53c7e12c9 100644 --- a/Tests/NetworkProtectionTests/NetworkProtectionDeviceManagerTests.swift +++ b/Tests/NetworkProtectionTests/NetworkProtectionDeviceManagerTests.swift @@ -19,20 +19,31 @@ import Foundation import XCTest @testable import NetworkProtection +@testable import NetworkProtectionTestUtils final class NetworkProtectionDeviceManagerTests: XCTestCase { var tokenStore: NetworkProtectionTokenStoreMock! var keyStore: NetworkProtectionKeyStoreMock! + var networkClient: MockNetworkProtectionClient! var temporaryURL: URL! var serverListStore: NetworkProtectionServerListFileSystemStore! + var manager: NetworkProtectionDeviceManager! override func setUp() { super.setUp() tokenStore = NetworkProtectionTokenStoreMock() tokenStore.token = "initialtoken" keyStore = NetworkProtectionKeyStoreMock() + networkClient = MockNetworkProtectionClient() temporaryURL = temporaryFileURL() serverListStore = NetworkProtectionServerListFileSystemStore(fileURL: temporaryURL, errorEvents: nil) + manager = NetworkProtectionDeviceManager( + networkClient: networkClient, + tokenStore: tokenStore, + keyStore: keyStore, + serverListStore: serverListStore, + errorEvents: nil + ) } override func tearDown() { @@ -40,24 +51,14 @@ final class NetworkProtectionDeviceManagerTests: XCTestCase { keyStore = nil temporaryURL = nil serverListStore = nil + manager = nil + networkClient = nil super.tearDown() } func testDeviceManager() async { let server = NetworkProtectionServer.mockRegisteredServer - let networkClient = NetworkProtectionMockClient( - getServersReturnValue: .success([server]), - registerServersReturnValue: .success([server]), - redeemReturnValue: .success("IamANauthTOKEN") - ) - - let manager = NetworkProtectionDeviceManager( - networkClient: networkClient, - tokenStore: tokenStore, - keyStore: keyStore, - serverListStore: serverListStore, - errorEvents: nil - ) + networkClient.stubRegister = .success([server]) let configuration: (TunnelConfiguration, NetworkProtectionServerInfo) @@ -80,19 +81,9 @@ final class NetworkProtectionDeviceManagerTests: XCTestCase { func testWhenGeneratingTunnelConfig_AndNoServersAreStored_ThenPrivateKeyIsCreated_AndRegisterEndpointIsCalled() async { let server = NetworkProtectionServer.mockBaseServer let registeredServer = NetworkProtectionServer.mockRegisteredServer - let networkClient = NetworkProtectionMockClient( - getServersReturnValue: .success([server]), - registerServersReturnValue: .success([registeredServer]), - redeemReturnValue: .success("IamANauthTOKEN") - ) - let manager = NetworkProtectionDeviceManager( - networkClient: networkClient, - tokenStore: tokenStore, - keyStore: keyStore, - serverListStore: serverListStore, - errorEvents: nil - ) + networkClient.stubGetServers = .success([server]) + networkClient.stubRegister = .success([registeredServer]) XCTAssertNil(try? keyStore.storedPrivateKey()) XCTAssertEqual(try? serverListStore.storedNetworkProtectionServerList(), []) @@ -105,24 +96,30 @@ final class NetworkProtectionDeviceManagerTests: XCTestCase { XCTAssertTrue(networkClient.registerCalled) } + func testWhenGeneratingTunnelConfig_AndServerSelectionIsUsingLocation_MakesRequestWithCountryAndCity() async { + let server = NetworkProtectionServer.mockBaseServer + networkClient.stubRegister = .success([server]) + + let preferredLocation = NetworkProtectionSelectedLocation(country: "Some country", city: "Some city") + _ = try? await manager.generateTunnelConfiguration(selectionMethod: .preferredLocation(preferredLocation)) + + XCTAssertEqual(networkClient.spyRegister?.requestBody.city, preferredLocation.city) + XCTAssertEqual(networkClient.spyRegister?.requestBody.country, preferredLocation.country) + } + + func testWhenGeneratingTunnelConfig_AndServerSelectionIsUsingPrerredServer_MakesRequestWithServer() async { + let server = NetworkProtectionServer.mockBaseServer + networkClient.stubRegister = .success([server]) + + _ = try? await manager.generateTunnelConfiguration(selectionMethod: .preferredServer(serverName: server.serverName)) + + XCTAssertEqual(networkClient.spyRegister?.requestBody.server, server.serverName) + } + func testWhenGeneratingTunnelConfig_storedAuthTokenIsInvalidOnGettingServers_deletesToken() async { _ = NetworkProtectionServer.mockRegisteredServer - let keyStore = NetworkProtectionKeyStoreMock() - let temporaryURL = temporaryFileURL() - let serverListStore = NetworkProtectionServerListFileSystemStore(fileURL: temporaryURL, errorEvents: nil) - let networkClient = NetworkProtectionMockClient( - getServersReturnValue: .failure(.invalidAuthToken), - registerServersReturnValue: .failure(.invalidAuthToken), - redeemReturnValue: .success("IamANauthTOKEN") - ) - let manager = NetworkProtectionDeviceManager( - networkClient: networkClient, - tokenStore: tokenStore, - keyStore: keyStore, - serverListStore: serverListStore, - errorEvents: nil - ) + networkClient.stubRegister = .failure(.invalidAuthToken) XCTAssertNotNil(tokenStore.token) @@ -132,23 +129,7 @@ final class NetworkProtectionDeviceManagerTests: XCTestCase { } func testWhenGeneratingTunnelConfig_storedAuthTokenIsInvalidOnRegisteringServer_deletesToken() async { - let server = NetworkProtectionServer.mockRegisteredServer - let keyStore = NetworkProtectionKeyStoreMock() - let temporaryURL = temporaryFileURL() - let serverListStore = NetworkProtectionServerListFileSystemStore(fileURL: temporaryURL, errorEvents: nil) - let networkClient = NetworkProtectionMockClient( - getServersReturnValue: .success([server]), - registerServersReturnValue: .failure(.invalidAuthToken), - redeemReturnValue: .success("IamANauthTOKEN") - ) - - let manager = NetworkProtectionDeviceManager( - networkClient: networkClient, - tokenStore: tokenStore, - keyStore: keyStore, - serverListStore: serverListStore, - errorEvents: nil - ) + networkClient.stubRegister = .failure(.invalidAuthToken) XCTAssertNotNil(tokenStore.token) diff --git a/Tests/NetworkProtectionTests/Repositories/NetworkProtectionLocationListCompositeRepositoryTests.swift b/Tests/NetworkProtectionTests/Repositories/NetworkProtectionLocationListCompositeRepositoryTests.swift new file mode 100644 index 000000000..33317e305 --- /dev/null +++ b/Tests/NetworkProtectionTests/Repositories/NetworkProtectionLocationListCompositeRepositoryTests.swift @@ -0,0 +1,119 @@ +// +// NetworkProtectionLocationListCompositeRepositoryTests.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest +@testable import NetworkProtection +@testable import NetworkProtectionTestUtils + +class NetworkProtectionLocationListCompositeRepositoryTests: XCTestCase { + var repository: NetworkProtectionLocationListCompositeRepository! + var client: MockNetworkProtectionClient! + var tokenStore: MockNetworkProtectionTokenStorage! + + override func setUp() { + super.setUp() + client = MockNetworkProtectionClient() + tokenStore = MockNetworkProtectionTokenStorage() + repository = NetworkProtectionLocationListCompositeRepository(client: client, tokenStore: tokenStore) + } + + @MainActor + override func tearDown() { + NetworkProtectionLocationListCompositeRepository.clearCache() + client = nil + tokenStore = nil + repository = nil + super.tearDown() + } + + func testFetchLocationList_firstCall_fetchesAndReturnsList() async throws { + let expectedToken = "aToken" + let expectedList: [NetworkProtectionLocation] = [ + .testData(country: "US", cities: [ + .testData(name: "New York"), + .testData(name: "Los Angeles") + ]) + ] + client.stubGetLocations = .success(expectedList) + tokenStore.stubFetchToken = expectedToken + let locations = try await repository.fetchLocationList() + XCTAssertEqual(expectedToken, client.spyGetLocationsAuthToken) + XCTAssertEqual(expectedList, locations) + } + + func testFetchLocationList_secondCall_returnsCachedList() async throws { + let expectedToken = "aToken" + let expectedList: [NetworkProtectionLocation] = [ + .testData(country: "DE", cities: [ + .testData(name: "Berlin") + ]) + ] + client.stubGetLocations = .success(expectedList) + tokenStore.stubFetchToken = expectedToken + _ = try await repository.fetchLocationList() + client.spyGetLocationsAuthToken = nil + let locations = try await repository.fetchLocationList() + + XCTAssertEqual(expectedList, locations) + XCTAssertFalse(client.getLocationsCalled) + } + + func testFetchLocationList_noAuthToken_throwsError() async throws { + client.stubGetLocations = .success([.testData()]) + tokenStore.stubFetchToken = nil + var errorResult: NetworkProtectionError? + do { + _ = try await repository.fetchLocationList() + } catch let error as NetworkProtectionError { + errorResult = error + } + + switch errorResult { + case .noAuthTokenFound: + break + default: + XCTFail("Expected noAuthTokenFound error") + } + } + + func testFetchLocationList_fetchThrows_throwsError() async throws { + client.stubGetLocations = .failure(.failedToFetchLocationList(nil)) + var errorResult: Error? + do { + _ = try await repository.fetchLocationList() + } catch let error as NetworkProtectionError { + errorResult = error + } + + XCTAssertNotNil(errorResult) + } +} + +private extension NetworkProtectionLocation { + static func testData(country: String = "", cities: [City] = []) -> NetworkProtectionLocation { + return Self.init(country: country, cities: cities) + } +} + +private extension NetworkProtectionLocation.City { + static func testData(name: String = "") -> NetworkProtectionLocation.City { + Self.init(name: name) + } +} diff --git a/Tests/NetworkProtectionTests/Resources/locations-endpoint.json b/Tests/NetworkProtectionTests/Resources/locations-endpoint.json new file mode 100644 index 000000000..8359c9e7f --- /dev/null +++ b/Tests/NetworkProtectionTests/Resources/locations-endpoint.json @@ -0,0 +1,22 @@ +[ + { + "country":"us", + "cities": + [ + { + "name":"Newark" + }, + { + "name":"El Segundo" + } + ] + }, + { + "country":"nl", + "cities": [ + { + "name":"Rotterdam" + } + ] + } +] diff --git a/Tests/NetworkProtectionTests/TestData.swift b/Tests/NetworkProtectionTests/TestData.swift index ba16bcf08..351eed9b1 100644 --- a/Tests/NetworkProtectionTests/TestData.swift +++ b/Tests/NetworkProtectionTests/TestData.swift @@ -28,6 +28,10 @@ final class TestData { return loadData(named: "servers-updated-endpoint.json")! } + static var mockLocations: Data { + return loadData(named: "locations-endpoint.json")! + } + private static func loadData(named name: String) -> Data? { guard let resourceUrl = Bundle.module.resourceURL else { return nil } From f22eb40967b9dc6a2efb2e24487e2bbece768f3c Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Wed, 15 Nov 2023 12:48:55 +0100 Subject: [PATCH 24/31] Removing an exception that I think was merged by mistake (#563) --- .../Diagnostics/NetworkProtectionConnectionTester.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift index e4bbb77e4..2a059b5b6 100644 --- a/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift +++ b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift @@ -268,8 +268,6 @@ final class NetworkProtectionConnectionTester { if onlyVPNIsDown { os_log("👎 VPN is DOWN", log: log) await handleDisconnected(isStartupTest: isStartupTest) - os_log("Throwing exception", log: log) - throw TesterError.connectionTestFailed } else { os_log("👍 VPN: \(vpnIsConnected ? "UP" : "DOWN") local: \(localIsConnected ? "UP" : "DOWN")", log: log) From 641018cef1a3a13e9fdeb490b18639d1cb25569f Mon Sep 17 00:00:00 2001 From: amddg44 Date: Thu, 16 Nov 2023 17:07:13 +0100 Subject: [PATCH 25/31] Autofill "Never Save for this Site" (#555) Task/Issue URL: https://app.asana.com/0/0/1205783642285427/f iOS PR: duckduckgo/iOS#2104 macOS PR: duckduckgo/macos-browser#1827 What kind of version bump will this require?: Minor Description: Adds Autofill "Never Save for this Site" support for iOS --- Package.resolved | 4 +- Package.swift | 2 +- .../AutofillUserScript+SecureVault.swift | 11 ++ .../AutofillUserScript+SourceProvider.swift | 118 ++++++++++++++---- .../Autofill/AutofillUserScript.swift | 6 +- .../ContentScopeUserScript.swift | 2 +- .../AutofillDatabaseProvider.swift | 84 +++++++++++++ .../SecureVault/AutofillSecureVault.swift | 45 +++++++ .../SecureVault/SecureVaultManager.swift | 19 ++- .../SecureVault/SecureVaultModels.swift | 12 ++ .../AutofillEmailUserScriptTests.swift | 6 +- ...utofillUserScriptSourceProviderTests.swift | 57 +++++++++ .../AutofillVaultUserScriptTests.swift | 19 +++ .../MockAutofillDatabaseProvider.swift | 28 +++++ .../SecureVault/SecureVaultManagerTests.swift | 2 + .../SecureVault/SecureVaultTests.swift | 20 +++ 16 files changed, 405 insertions(+), 30 deletions(-) create mode 100644 Tests/BrowserServicesKitTests/Autofill/AutofillUserScriptSourceProviderTests.swift diff --git a/Package.resolved b/Package.resolved index 8a2d1a4ad..f99c535b3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/duckduckgo-autofill.git", "state" : { - "revision" : "c8e895c8fd50dc76e8d8dc827a636ad77b7f46ff", - "version" : "9.0.0" + "revision" : "93677cc02cfe650ce7f417246afd0e8e972cd83e", + "version" : "10.0.0" } }, { diff --git a/Package.swift b/Package.swift index c2279e3bb..d48542daa 100644 --- a/Package.swift +++ b/Package.swift @@ -32,7 +32,7 @@ let package = Package( .library(name: "SecureStorage", targets: ["SecureStorage"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "9.0.0"), + .package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "10.0.0"), .package(url: "https://github.com/duckduckgo/GRDB.swift.git", exact: "2.2.0"), .package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "1.2.1"), .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.2.0"), diff --git a/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift b/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift index 5399d41ac..2acd30a95 100644 --- a/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift +++ b/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift @@ -67,6 +67,9 @@ public protocol AutofillSecureVaultDelegate: AnyObject { func autofillUserScript(_: AutofillUserScript, didRequestCredentialsForDomain domain: String, completionHandler: @escaping ([SecureVaultModels.WebsiteCredentials], SecureVaultModels.CredentialsProvider) -> Void) + func autofillUserScript(_: AutofillUserScript, didRequestRuntimeConfigurationForDomain domain: String, + completionHandler: @escaping (String?) -> Void) + func autofillUserScriptDidOfferGeneratedPassword(_: AutofillUserScript, password: String, completionHandler: @escaping (Bool) -> Void) @@ -429,6 +432,14 @@ extension AutofillUserScript { // MARK: - Message Handlers + func getRuntimeConfiguration(_ message: UserScriptMessage, _ replyHandler: @escaping MessageReplyHandler) { + let domain = hostForMessage(message) + + vaultDelegate?.autofillUserScript(self, didRequestRuntimeConfigurationForDomain: domain, completionHandler: { response in + replyHandler(response) + }) + } + func getAvailableInputTypes(_ message: UserScriptMessage, _ replyHandler: @escaping MessageReplyHandler) { let domain = hostForMessage(message) let email = emailDelegate?.autofillUserScriptDidRequestSignedInStatus(self) ?? false diff --git a/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SourceProvider.swift b/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SourceProvider.swift index c4ae795a1..ed553daf7 100644 --- a/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SourceProvider.swift +++ b/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SourceProvider.swift @@ -25,40 +25,114 @@ public protocol AutofillUserScriptSourceProvider { } public class DefaultAutofillSourceProvider: AutofillUserScriptSourceProvider { - - private var sourceStr: String - + + private struct ProviderData { + var privacyConfig: Data + var userUnprotectedDomains: Data + var userPreferences: Data + } + + let privacyConfigurationManager: PrivacyConfigurationManaging + let properties: ContentScopeProperties + private var sourceStr: String = "" + public var source: String { return sourceStr } - + public init(privacyConfigurationManager: PrivacyConfigurationManaging, properties: ContentScopeProperties) { + self.privacyConfigurationManager = privacyConfigurationManager + self.properties = properties + } + + public func loadJS() { + guard let replacements = buildReplacementsString() else { + sourceStr = "" + return + } + sourceStr = AutofillUserScript.loadJS("assets/autofill", from: Autofill.bundle, withReplacements: replacements) + } + + public func buildRuntimeConfigResponse() -> String? { + guard let providerData = buildReplacementsData(), + let privacyConfigJson = String(data: providerData.privacyConfig, encoding: .utf8), + let userUnprotectedDomainsString = String(data: providerData.userUnprotectedDomains, encoding: .utf8), + let userPreferencesString = String(data: providerData.userPreferences, encoding: .utf8) else { + return nil + } + + return """ + { + "success": { + "contentScope": \(privacyConfigJson), + "userUnprotectedDomains": \(userUnprotectedDomainsString), + "userPreferences": \(userPreferencesString) + } + } + """ + } + + private func buildReplacementsString() -> [String: String]? { var replacements: [String: String] = [:] - #if os(macOS) - replacements["// INJECT isApp HERE"] = "isApp = true;" - #endif +#if os(macOS) + replacements["// INJECT isApp HERE"] = "isApp = true;" +#endif if #available(iOS 14, macOS 11, *) { replacements["// INJECT hasModernWebkitAPI HERE"] = "hasModernWebkitAPI = true;" - - #if os(macOS) - replacements["// INJECT supportsTopFrame HERE"] = "supportsTopFrame = true;" - #endif + +#if os(macOS) + replacements["// INJECT supportsTopFrame HERE"] = "supportsTopFrame = true;" +#endif } - - guard let privacyConfigJson = String(data: privacyConfigurationManager.currentConfig, encoding: .utf8), - let userUnprotectedDomains = try? JSONEncoder().encode(privacyConfigurationManager.privacyConfig.userUnprotectedDomains), - let userUnprotectedDomainsString = String(data: userUnprotectedDomains, encoding: .utf8), - let jsonProperties = try? JSONEncoder().encode(properties), - let jsonPropertiesString = String(data: jsonProperties, encoding: .utf8) - else { - sourceStr = "" - return + + guard let providerData = buildReplacementsData(), + let privacyConfigJson = String(data: providerData.privacyConfig, encoding: .utf8), + let userUnprotectedDomainsString = String(data: providerData.userUnprotectedDomains, encoding: .utf8), + let userPreferencesString = String(data: providerData.userPreferences, encoding: .utf8) else { + return nil } + replacements["// INJECT contentScope HERE"] = "contentScope = " + privacyConfigJson + ";" replacements["// INJECT userUnprotectedDomains HERE"] = "userUnprotectedDomains = " + userUnprotectedDomainsString + ";" - replacements["// INJECT userPreferences HERE"] = "userPreferences = " + jsonPropertiesString + ";" + replacements["// INJECT userPreferences HERE"] = "userPreferences = " + userPreferencesString + ";" + return replacements + } - sourceStr = AutofillUserScript.loadJS("assets/autofill", from: Autofill.bundle, withReplacements: replacements) + private func buildReplacementsData() -> ProviderData? { + guard let userUnprotectedDomains = try? JSONEncoder().encode(privacyConfigurationManager.privacyConfig.userUnprotectedDomains), + let jsonProperties = try? JSONEncoder().encode(properties) else { + return nil + } + return ProviderData(privacyConfig: privacyConfigurationManager.currentConfig, + userUnprotectedDomains: userUnprotectedDomains, + userPreferences: jsonProperties) + } + + public class Builder { + private var privacyConfigurationManager: PrivacyConfigurationManaging + private var properties: ContentScopeProperties + private var sourceStr: String = "" + private var shouldLoadJS: Bool = false + + public init(privacyConfigurationManager: PrivacyConfigurationManaging, properties: ContentScopeProperties) { + self.privacyConfigurationManager = privacyConfigurationManager + self.properties = properties + } + + public func build() -> DefaultAutofillSourceProvider { + let provider = DefaultAutofillSourceProvider(privacyConfigurationManager: privacyConfigurationManager, properties: properties) + + if shouldLoadJS { + provider.loadJS() + } + + return provider + } + + public func withJSLoading() -> Builder { + self.shouldLoadJS = true + return self + } } } diff --git a/Sources/BrowserServicesKit/Autofill/AutofillUserScript.swift b/Sources/BrowserServicesKit/Autofill/AutofillUserScript.swift index f9958171c..5b9ad133e 100644 --- a/Sources/BrowserServicesKit/Autofill/AutofillUserScript.swift +++ b/Sources/BrowserServicesKit/Autofill/AutofillUserScript.swift @@ -47,6 +47,7 @@ public class AutofillUserScript: NSObject, UserScript, UserScriptMessageEncrypti case pmHandlerOpenManageIdentities case pmHandlerOpenManagePasswords + case getRuntimeConfiguration case getAvailableInputTypes case getAutofillData case storeFormData @@ -68,6 +69,8 @@ public class AutofillUserScript: NSObject, UserScript, UserScriptMessageEncrypti /// once the user selects a field to open, we store field type and other contextual information to be initialized into the top autofill. public var serializedInputContext: String? + public var sessionKey: String? + public weak var emailDelegate: AutofillEmailDelegate? public weak var vaultDelegate: AutofillSecureVaultDelegate? @@ -131,7 +134,8 @@ public class AutofillUserScript: NSObject, UserScript, UserScriptMessageEncrypti case .emailHandlerCheckAppSignedInStatus: return emailCheckSignedInStatus case .pmHandlerGetAutofillInitData: return pmGetAutoFillInitData - + + case .getRuntimeConfiguration: return getRuntimeConfiguration case .getAvailableInputTypes: return getAvailableInputTypes case .getAutofillData: return getAutofillData case .storeFormData: return pmStoreData diff --git a/Sources/BrowserServicesKit/ContentScopeScript/ContentScopeUserScript.swift b/Sources/BrowserServicesKit/ContentScopeScript/ContentScopeUserScript.swift index a3dacf036..920b069b7 100644 --- a/Sources/BrowserServicesKit/ContentScopeScript/ContentScopeUserScript.swift +++ b/Sources/BrowserServicesKit/ContentScopeScript/ContentScopeUserScript.swift @@ -60,7 +60,7 @@ public struct ContentScopeFeatureToggles: Encodable { public let credentialsSaving: Bool - public let passwordGeneration: Bool + public var passwordGeneration: Bool public let inlineIconCredentials: Bool public let thirdPartyCredentialsProvider: Bool diff --git a/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift b/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift index aea2ce96f..f4ff9d6ab 100644 --- a/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift +++ b/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift @@ -35,6 +35,12 @@ public protocol AutofillDatabaseProvider: SecureStorageDatabaseProvider { func websiteAccountsForTopLevelDomain(_ eTLDplus1: String) throws -> [SecureVaultModels.WebsiteAccount] func deleteWebsiteCredentialsForAccountId(_ accountId: Int64) throws + func neverPromptWebsites() throws -> [SecureVaultModels.NeverPromptWebsites] + func hasNeverPromptWebsitesFor(domain: String) throws -> Bool + @discardableResult + func storeNeverPromptWebsite(_ neverPromptWebsite: SecureVaultModels.NeverPromptWebsites) throws -> Int64 + func deleteAllNeverPromptWebsites() throws + func notes() throws -> [SecureVaultModels.Note] func noteForNoteId(_ noteId: Int64) throws -> SecureVaultModels.Note? @discardableResult @@ -93,6 +99,7 @@ public final class DefaultAutofillDatabaseProvider: GRDBSecureStorageDatabasePro migrator.registerMigration("v9", migrate: Self.migrateV9(database:)) migrator.registerMigration("v10", migrate: Self.migrateV10(database:)) migrator.registerMigration("v11", migrate: Self.migrateV11(database:)) + migrator.registerMigration("v12", migrate: Self.migrateV12(database:)) } } } @@ -335,6 +342,54 @@ public final class DefaultAutofillDatabaseProvider: GRDBSecureStorageDatabasePro } } + // MARK: NeverPromptWebsites + + public func neverPromptWebsites() throws -> [SecureVaultModels.NeverPromptWebsites] { + try db.read { + try SecureVaultModels.NeverPromptWebsites.fetchAll($0) + } + } + + public func hasNeverPromptWebsitesFor(domain: String) throws -> Bool { + let neverPromptWebsite = try db.read { + try SecureVaultModels.NeverPromptWebsites + .filter(SecureVaultModels.NeverPromptWebsites.Columns.domain.like(domain)) + .fetchOne($0) + } + return neverPromptWebsite != nil + } + + public func storeNeverPromptWebsite(_ neverPromptWebsite: SecureVaultModels.NeverPromptWebsites) throws -> Int64 { + if let id = neverPromptWebsite.id { + try updateNeverPromptWebsite(neverPromptWebsite, usingId: id) + return id + } else { + return try insertNeverPromptWebsite(neverPromptWebsite) + } + } + + public func deleteAllNeverPromptWebsites() throws { + try db.write { + try $0.execute(sql: """ + DELETE FROM + \(SecureVaultModels.NeverPromptWebsites.databaseTableName) + """) + } + } + + func updateNeverPromptWebsite(_ neverPromptWebsite: SecureVaultModels.NeverPromptWebsites, usingId id: Int64) throws { + try db.write { + try neverPromptWebsite.update($0) + } + } + + func insertNeverPromptWebsite(_ neverPromptWebsite: SecureVaultModels.NeverPromptWebsites) throws -> Int64 { + try db.write { + try neverPromptWebsite.insert($0) + return $0.lastInsertedRowID + } + } + // MARK: Notes public func notes() throws -> [SecureVaultModels.Note] { @@ -886,6 +941,15 @@ extension DefaultAutofillDatabaseProvider { } } + static func migrateV12(database: Database) throws { + + try database.create(table: SecureVaultModels.NeverPromptWebsites.databaseTableName) { + $0.autoIncrementedPrimaryKey(SecureVaultModels.NeverPromptWebsites.Columns.id.name) + + $0.column(SecureVaultModels.NeverPromptWebsites.Columns.domain.name, .text) + } + } + // Refresh password comparison hashes static private func updatePasswordHashes(database: Database) throws { let accountRows = try Row.fetchCursor(database, sql: "SELECT * FROM \(SecureVaultModels.WebsiteAccount.databaseTableName)") @@ -1027,6 +1091,26 @@ extension SecureVaultModels.WebsiteCredentials { } +extension SecureVaultModels.NeverPromptWebsites: PersistableRecord, FetchableRecord { + + public enum Columns: String, ColumnExpression { + case id, domain + } + + public init(row: Row) { + id = row[Columns.id] + domain = row[Columns.domain] + } + + public func encode(to container: inout PersistenceContainer) { + container[Columns.id] = id + container[Columns.domain] = domain + } + + public static var databaseTableName: String = "never_prompt_websites" + +} + extension SecureVaultModels.CreditCard: PersistableRecord, FetchableRecord { enum Columns: String, ColumnExpression { diff --git a/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift b/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift index ee51040e8..a0a3256e4 100644 --- a/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift +++ b/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift @@ -65,6 +65,12 @@ public protocol AutofillSecureVault: SecureVault { func storeWebsiteCredentials(_ credentials: SecureVaultModels.WebsiteCredentials) throws -> Int64 func deleteWebsiteCredentialsFor(accountId: Int64) throws + func neverPromptWebsites() throws -> [SecureVaultModels.NeverPromptWebsites] + func hasNeverPromptWebsitesFor(domain: String) throws -> Bool + @discardableResult + func storeNeverPromptWebsites(_ neverPromptWebsite: SecureVaultModels.NeverPromptWebsites) throws -> Int64 + func deleteAllNeverPromptWebsites() throws + func notes() throws -> [SecureVaultModels.Note] func noteFor(id: Int64) throws -> SecureVaultModels.Note? @discardableResult @@ -361,6 +367,45 @@ public class DefaultAutofillSecureVault: AutofillSe } } + // MARK: NeverPromptWebsites + + public func neverPromptWebsites() throws -> [SecureVaultModels.NeverPromptWebsites] { + lock.lock() + defer { + lock.unlock() + } + + do { + return try self.providers.database.neverPromptWebsites() + } catch { + throw SecureStorageError.databaseError(cause: error) + } + } + + public func hasNeverPromptWebsitesFor(domain: String) throws -> Bool { + lock.lock() + defer { + lock.unlock() + } + do { + return try self.providers.database.hasNeverPromptWebsitesFor(domain: domain) + } catch { + throw SecureStorageError.databaseError(cause: error) + } + } + + public func storeNeverPromptWebsites(_ neverPromptWebsite: SecureVaultModels.NeverPromptWebsites) throws -> Int64 { + return try executeThrowingDatabaseOperation { + return try self.providers.database.storeNeverPromptWebsite(neverPromptWebsite) + } + } + + public func deleteAllNeverPromptWebsites() throws { + try executeThrowingDatabaseOperation { + try self.providers.database.deleteAllNeverPromptWebsites() + } + } + // MARK: - Notes public func notes() throws -> [SecureVaultModels.Note] { diff --git a/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift b/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift index b420ff4ca..3b7b44652 100644 --- a/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift +++ b/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift @@ -71,6 +71,10 @@ public protocol SecureVaultManagerDelegate: AnyObject, SecureVaultErrorReporting func secureVaultManager(_: SecureVaultManager, didRequestPasswordManagerForDomain domain: String) + func secureVaultManager(_: SecureVaultManager, + didRequestRuntimeConfigurationForDomain domain: String, + completionHandler: @escaping (String?) -> Void) + func secureVaultManager(_: SecureVaultManager, didReceivePixel: AutofillUserScript.JSPixel) } @@ -227,7 +231,12 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { autosaveAccount = nil autosaveAccountCreatedInSession = false } - + + // Do not autosave anything if user has requested to never be prompted to save credentials for this domain + if let neverPrompt = try vault?.hasNeverPromptWebsitesFor(domain: domain), neverPrompt { + return + } + // Validate the existing account exists and matches the domain and fetch the credentials if let stringId = autosaveAccount?.id, let id = Int64(stringId), @@ -526,6 +535,14 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { delegate?.secureVaultManager(self, didReceivePixel: pixel) } + public func autofillUserScript(_: AutofillUserScript, + didRequestRuntimeConfigurationForDomain domain: String, + completionHandler: @escaping (String?) -> Void) { + delegate?.secureVaultManager(self, didRequestRuntimeConfigurationForDomain: domain, completionHandler: { response in + completionHandler(response) + }) + } + /// Stores autogenerated credentials sent by the AutofillUserScript, or updates an existing row in the database if credentials already exist. func storeOrUpdateAutogeneratedCredentials(domain: String, autofillData: AutofillUserScript.DetectedAutofillData) throws { diff --git a/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift b/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift index 15b738ee7..d9a35ccce 100644 --- a/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift +++ b/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift @@ -162,6 +162,18 @@ public struct SecureVaultModels { } + public struct NeverPromptWebsites { + public var id: Int64? + public var domain: String + + public init(id: Int64? = nil, + domain: String) { + self.id = id + self.domain = domain + } + + } + public struct CreditCard { private enum Constants { diff --git a/Tests/BrowserServicesKitTests/Autofill/AutofillEmailUserScriptTests.swift b/Tests/BrowserServicesKitTests/Autofill/AutofillEmailUserScriptTests.swift index c174cd525..03363bafe 100644 --- a/Tests/BrowserServicesKitTests/Autofill/AutofillEmailUserScriptTests.swift +++ b/Tests/BrowserServicesKitTests/Autofill/AutofillEmailUserScriptTests.swift @@ -39,8 +39,10 @@ class AutofillEmailUserScriptTests: XCTestCase { """.data(using: .utf8)! let privacyConfig = AutofillTestHelper.preparePrivacyConfig(embeddedConfig: embeddedConfig) let properties = ContentScopeProperties(gpcEnabled: false, sessionKey: "1234", featureToggles: ContentScopeFeatureToggles.allTogglesOn) - let sourceProvider = DefaultAutofillSourceProvider(privacyConfigurationManager: privacyConfig, - properties: properties) + let sourceProvider = DefaultAutofillSourceProvider.Builder(privacyConfigurationManager: privacyConfig, + properties: properties) + .withJSLoading() + .build() return AutofillUserScript(scriptSourceProvider: sourceProvider, encrypter: MockEncrypter(), hostProvider: SecurityOriginHostProvider()) }() let userContentController = WKUserContentController() diff --git a/Tests/BrowserServicesKitTests/Autofill/AutofillUserScriptSourceProviderTests.swift b/Tests/BrowserServicesKitTests/Autofill/AutofillUserScriptSourceProviderTests.swift new file mode 100644 index 000000000..5f0fc7e57 --- /dev/null +++ b/Tests/BrowserServicesKitTests/Autofill/AutofillUserScriptSourceProviderTests.swift @@ -0,0 +1,57 @@ +// +// AutofillUserScriptSourceProviderTests.swift +// DuckDuckGo +// +// 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 +import BrowserServicesKit + +final class AutofillUserScriptSourceProviderTests: XCTestCase { + + let embeddedConfig = + """ + { + "features": { + "autofill": { + "status": "enabled", + "exceptions": [] + } + }, + "unprotectedTemporary": [] + } + """.data(using: .utf8)! + lazy var privacyConfig = AutofillTestHelper.preparePrivacyConfig(embeddedConfig: embeddedConfig) + let properties = ContentScopeProperties(gpcEnabled: false, sessionKey: "1234", featureToggles: ContentScopeFeatureToggles.allTogglesOn) + + func testWhenBuildWithLoadJSThenSourceStrIsBuilt() { + let autofillSourceProvider = DefaultAutofillSourceProvider.Builder(privacyConfigurationManager: privacyConfig, + properties: properties) + .withJSLoading() + .build() + XCTAssertFalse(autofillSourceProvider.source.isEmpty) + } + + func testWhenBuildRuntimeConfigurationThenConfigurationIsBuilt() { + let runtimeConfiguration = DefaultAutofillSourceProvider.Builder(privacyConfigurationManager: privacyConfig, + properties: properties) + .build() + .buildRuntimeConfigResponse() + + XCTAssertNotNil(runtimeConfiguration) + XCTAssertFalse(runtimeConfiguration!.isEmpty) + } +} diff --git a/Tests/BrowserServicesKitTests/Autofill/AutofillVaultUserScriptTests.swift b/Tests/BrowserServicesKitTests/Autofill/AutofillVaultUserScriptTests.swift index 68e3c1f8c..5f1e54f1e 100644 --- a/Tests/BrowserServicesKitTests/Autofill/AutofillVaultUserScriptTests.swift +++ b/Tests/BrowserServicesKitTests/Autofill/AutofillVaultUserScriptTests.swift @@ -447,6 +447,19 @@ class AutofillVaultUserScriptTests: XCTestCase { XCTAssertEqual(delegate.lastDomain, "example.com") } + + func testWhenGetRuntimeConfigurationIsCalled_ThenDelegateIsCalled() { + let delegate = MockSecureVaultDelegate() + userScript.vaultDelegate = delegate + + let mockWebView = MockWebView() + let message = MockUserScriptMessage(name: "getRuntimeConfiguration", body: encryptedMessagingParams, + host: "example.com", webView: mockWebView) + + userScript.processEncryptedMessage(message, from: userContentController) + + XCTAssertEqual(delegate.lastDomain, "example.com") + } func testWhenInitializingAutofillData_WhenCredentialsAreProvidedWithoutAUsername_ThenAutofillDataIsStillInitialized() { let password = "password" @@ -557,6 +570,7 @@ class MockSecureVaultDelegate: AutofillSecureVaultDelegate { case didRequestStoreDataForDomain case didRequestAccountsForDomain case didRequestCredentialsForDomain + case didRequestRuntimeConfigurationForDomain } var receivedCallbacks: [CallbackType] = [] @@ -638,6 +652,11 @@ class MockSecureVaultDelegate: AutofillSecureVaultDelegate { } + func autofillUserScript(_: BrowserServicesKit.AutofillUserScript, didRequestRuntimeConfigurationForDomain domain: String, completionHandler: @escaping (String?) -> Void) { + lastDomain = domain + receivedCallbacks.append(.didRequestRuntimeConfigurationForDomain) + } + func autofillUserScriptDidOfferGeneratedPassword(_: BrowserServicesKit.AutofillUserScript, password: String, completionHandler: @escaping (Bool) -> Void) { } diff --git a/Tests/BrowserServicesKitTests/SecureVault/MockAutofillDatabaseProvider.swift b/Tests/BrowserServicesKitTests/SecureVault/MockAutofillDatabaseProvider.swift index bfb5fa939..c4dd9956d 100644 --- a/Tests/BrowserServicesKitTests/SecureVault/MockAutofillDatabaseProvider.swift +++ b/Tests/BrowserServicesKitTests/SecureVault/MockAutofillDatabaseProvider.swift @@ -24,6 +24,7 @@ import GRDB internal class MockAutofillDatabaseProvider: AutofillDatabaseProvider { var _accounts = [SecureVaultModels.WebsiteAccount]() + var _neverPromptWebsites = [SecureVaultModels.NeverPromptWebsites]() var _notes = [SecureVaultModels.Note]() var _identities = [Int64: SecureVaultModels.Identity]() var _creditCards = [Int64: SecureVaultModels.CreditCard]() @@ -181,4 +182,31 @@ internal class MockAutofillDatabaseProvider: AutofillDatabaseProvider { [] } + func neverPromptWebsites() throws -> [SecureVaultModels.NeverPromptWebsites] { + return _neverPromptWebsites + } + + func hasNeverPromptWebsitesFor(domain: String) throws -> Bool { + return !_neverPromptWebsites.filter { $0.domain == domain }.isEmpty + } + + func storeNeverPromptWebsite(_ neverPromptWebsite: SecureVaultModels.NeverPromptWebsites) throws -> Int64 { + if let neverPromptWebsiteId = neverPromptWebsite.id { + _neverPromptWebsites.append(neverPromptWebsite) + return neverPromptWebsiteId + } else { + return -1 + } + } + + func deleteAllNeverPromptWebsites() throws { + _neverPromptWebsites.removeAll() + } + + func updateNeverPromptWebsite(_ neverPromptWebsite: SecureVaultModels.NeverPromptWebsites) throws { + } + + func insertNeverPromptWebsite(_ neverPromptWebsite: SecureVaultModels.NeverPromptWebsites) throws { + } + } diff --git a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultManagerTests.swift b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultManagerTests.swift index 88be89f21..d5b675fee 100644 --- a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultManagerTests.swift +++ b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultManagerTests.swift @@ -757,6 +757,8 @@ private class MockSecureVaultManagerDelegate: SecureVaultManagerDelegate { func secureVaultManager(_: SecureVaultManager, didRequestPasswordManagerForDomain domain: String) {} + func secureVaultManager(_: SecureVaultManager, didRequestRuntimeConfigurationForDomain domain: String, completionHandler: @escaping (String?) -> Void) {} + func secureVaultManager(_: SecureVaultManager, didReceivePixel: AutofillUserScript.JSPixel) {} } diff --git a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultTests.swift b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultTests.swift index 8c7e77de0..42d77442a 100644 --- a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultTests.swift +++ b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultTests.swift @@ -225,4 +225,24 @@ class SecureVaultTests: XCTestCase { } } + func testWhenRetrievingNeverPromptWebsites_ThenDatabaseIsCalled() throws { + mockDatabaseProvider._neverPromptWebsites = [ + .init(domain: "example.com") + ] + + let neverPromptWebsites = try testVault.neverPromptWebsites() + XCTAssertEqual(neverPromptWebsites.count, 1) + XCTAssertEqual(neverPromptWebsites.first?.domain, "example.com") + } + + func testWhenDeletingAllNeverPromptWebsites_ThenDatabaseIsCalled() throws { + mockDatabaseProvider._neverPromptWebsites = [ + .init(domain: "example.com") + ] + + try testVault.deleteAllNeverPromptWebsites() + + let neverPromptWebsites = try testVault.neverPromptWebsites() + XCTAssertEqual(neverPromptWebsites.count, 0) + } } From b97c451037f7a24aef6389be8252df301d2294cd Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Fri, 17 Nov 2023 00:51:13 +0100 Subject: [PATCH 26/31] Fix NetP connectivity issues. (#567) Task/Issue URL: https://app.asana.com/0/0/1205973361141861/f iOS PR: https://github.com/duckduckgo/iOS/pull/2173 macOS PR: https://github.com/duckduckgo/macos-browser/pull/1866 What kind of version bump will this require?: Major/Minor/Patch ## Description Resolves several connectivity issues in NetP. --- .../PacketTunnelProvider.swift | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/Sources/NetworkProtection/PacketTunnelProvider.swift b/Sources/NetworkProtection/PacketTunnelProvider.swift index c7893f632..c18f90a46 100644 --- a/Sources/NetworkProtection/PacketTunnelProvider.swift +++ b/Sources/NetworkProtection/PacketTunnelProvider.swift @@ -315,6 +315,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { super.init() tunnelSettings.changePublisher + .receive(on: DispatchQueue.main) .sink { [weak self] change in self?.handleSettingsChange(change) }.store(in: &cancellables) @@ -653,6 +654,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // MARK: - Tunnel Configuration + @MainActor public func updateTunnelConfiguration(environment: TunnelSettings.SelectedEnvironment = .default, reassert: Bool = true) async throws { let serverSelectionMethod: NetworkProtectionServerSelectionMethod @@ -666,6 +668,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { try await updateTunnelConfiguration(environment: environment, serverSelectionMethod: serverSelectionMethod, reassert: reassert) } + @MainActor public func updateTunnelConfiguration(environment: TunnelSettings.SelectedEnvironment = .default, serverSelectionMethod: NetworkProtectionServerSelectionMethod, reassert: Bool = true) async throws { let tunnelConfiguration = try await generateTunnelConfiguration(environment: environment, @@ -701,6 +704,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } } + @MainActor private func generateTunnelConfiguration(environment: TunnelSettings.SelectedEnvironment = .default, serverSelectionMethod: NetworkProtectionServerSelectionMethod, includedRoutes: [IPAddressRange], excludedRoutes: [IPAddressRange]) async throws -> TunnelConfiguration { let configurationResult: (TunnelConfiguration, NetworkProtectionServerInfo) @@ -806,7 +810,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } Task { - try? await updateTunnelConfiguration(environment: settings.selectedEnvironment, serverSelectionMethod: serverSelectionMethod) + if case .connected = connectionStatus { + try? await updateTunnelConfiguration(environment: settings.selectedEnvironment, serverSelectionMethod: serverSelectionMethod) + } completionHandler?(nil) } case .setSelectedLocation(let selectedLocation): @@ -820,7 +826,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } Task { - try? await updateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod) + if case .connected = connectionStatus { + try? await updateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod) + } completionHandler?(nil) } case .setIncludeAllNetworks, @@ -892,7 +900,10 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { guard let serverName else { if case .endpoint = settings.selectedServer { settings.selectedServer = .automatic - try? await updateTunnelConfiguration(serverSelectionMethod: .automatic) + + if case .connected = connectionStatus { + try? await updateTunnelConfiguration(serverSelectionMethod: .automatic) + } } completionHandler?(nil) return @@ -904,7 +915,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } settings.selectedServer = .endpoint(serverName) - try? await updateTunnelConfiguration(serverSelectionMethod: .preferredServer(serverName: serverName)) + if case .connected = connectionStatus { + try? await updateTunnelConfiguration(serverSelectionMethod: .preferredServer(serverName: serverName)) + } completionHandler?(nil) } } @@ -934,7 +947,10 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func setExcludedRoutes(_ excludedRoutes: [IPAddressRange], completionHandler: ((Data?) -> Void)? = nil) { Task { self.excludedRoutes = excludedRoutes - try? await updateTunnelConfiguration(reassert: false) + + if case .connected = connectionStatus { + try? await updateTunnelConfiguration(reassert: false) + } completionHandler?(nil) } } @@ -942,7 +958,10 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func setIncludedRoutes(_ includedRoutes: [IPAddressRange], completionHandler: ((Data?) -> Void)? = nil) { Task { self.includedRoutes = includedRoutes - try? await updateTunnelConfiguration(reassert: false) + + if case .connected = connectionStatus { + try? await updateTunnelConfiguration(reassert: false) + } completionHandler?(nil) } } From af63158c03de7a47fb9e2cbcd97fbd28a64ab1fd Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Wed, 22 Nov 2023 22:02:13 +0000 Subject: [PATCH 27/31] Updating C-S-S to 4.52.0 for DBP (#568) * css + dbp * released version --------- Co-authored-by: Shane Osbourne --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index f99c535b3..05a228e36 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "254b23cf292140498650421bb31fd05740f4579b", - "version" : "4.40.0" + "revision" : "b7ad9843e70cede0c2ca9c4260d970f62cb28156", + "version" : "4.52.0" } }, { diff --git a/Package.swift b/Package.swift index d48542daa..8f561e30c 100644 --- a/Package.swift +++ b/Package.swift @@ -37,7 +37,7 @@ let package = Package( .package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "1.2.1"), .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.2.0"), .package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "2.1.0"), - .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "4.40.0"), + .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "4.52.0"), .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "2.0.0"), .package(url: "https://github.com/httpswift/swifter.git", exact: "1.5.0"), .package(url: "https://github.com/duckduckgo/bloom_cpp.git", exact: "3.0.0"), From bdac80f60c3ac54b6ac0dc54c60a8942f208a05e Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Thu, 23 Nov 2023 10:01:23 +0000 Subject: [PATCH 28/31] Breakage report improvements (#566) Task/Issue URL: https://app.asana.com/0/1205842942115003/1205692741026215/f iOS PR: https://github.com/duckduckgo/iOS/pull/2168 macOS PR: https://github.com/duckduckgo/macos-browser/pull/1864 What kind of version bump will this require?: Major (Braking changes in privacy-dashboard) **Description**: The previous implementation of the `Report Broken Site` page was different between iOS and macOS, with this PR I have updated and aligned the PrivacyDashboardController so the web implementation of the `Report Broken Site` can be used in both iOS and macOS. The changes are mostly around delegates and which User script is handled for each platform. - Splitting the delegate into 3 new delegates: `PrivacyDashboardNavigationDelegate`, `PrivacyDashboardReportBrokenSiteDelegate`, `PrivacyDashboardControllerDelegate` --- Package.swift | 2 +- .../Model/CookieConsentInfo.swift | 2 +- .../Model/ProtectionStatus.swift | 2 +- .../PrivacyDashboard/Model/TrackerInfo.swift | 10 +- .../PrivacyDashboardController.swift | 124 +++++++++++------- Sources/PrivacyDashboard/PrivacyInfo.swift | 2 +- .../PrivacyDashboardUserScript.swift | 76 +++++------ .../ViewModel/ServerTrustViewModel.swift | 52 ++++---- 8 files changed, 149 insertions(+), 121 deletions(-) diff --git a/Package.swift b/Package.swift index 8f561e30c..b90368633 100644 --- a/Package.swift +++ b/Package.swift @@ -37,8 +37,8 @@ let package = Package( .package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "1.2.1"), .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.2.0"), .package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "2.1.0"), + .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "3.1.0" ), .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "4.52.0"), - .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "2.0.0"), .package(url: "https://github.com/httpswift/swifter.git", exact: "1.5.0"), .package(url: "https://github.com/duckduckgo/bloom_cpp.git", exact: "3.0.0"), .package(url: "https://github.com/duckduckgo/wireguard-apple", exact: "1.1.1") diff --git a/Sources/PrivacyDashboard/Model/CookieConsentInfo.swift b/Sources/PrivacyDashboard/Model/CookieConsentInfo.swift index c86872932..475d24931 100644 --- a/Sources/PrivacyDashboard/Model/CookieConsentInfo.swift +++ b/Sources/PrivacyDashboard/Model/CookieConsentInfo.swift @@ -24,7 +24,7 @@ public struct CookieConsentInfo: Encodable { let optoutFailed: Bool? let selftestFailed: Bool? let configurable = true - + public init(consentManaged: Bool, cosmetic: Bool?, optoutFailed: Bool?, selftestFailed: Bool?) { self.consentManaged = consentManaged self.cosmetic = cosmetic diff --git a/Sources/PrivacyDashboard/Model/ProtectionStatus.swift b/Sources/PrivacyDashboard/Model/ProtectionStatus.swift index 22020c81b..2fc310faa 100644 --- a/Sources/PrivacyDashboard/Model/ProtectionStatus.swift +++ b/Sources/PrivacyDashboard/Model/ProtectionStatus.swift @@ -20,7 +20,7 @@ import Foundation public struct ProtectionStatus: Encodable { - + let unprotectedTemporary: Bool let enabledFeatures: [String] let allowlisted: Bool diff --git a/Sources/PrivacyDashboard/Model/TrackerInfo.swift b/Sources/PrivacyDashboard/Model/TrackerInfo.swift index 9782b349a..8b5b051b7 100644 --- a/Sources/PrivacyDashboard/Model/TrackerInfo.swift +++ b/Sources/PrivacyDashboard/Model/TrackerInfo.swift @@ -26,11 +26,11 @@ public struct TrackerInfo: Encodable { case requests case installedSurrogates } - + public private (set) var trackers = Set() private(set) var thirdPartyRequests = Set() public private(set) var installedSurrogates = Set() - + public init() { } // MARK: - Collecting detected elements @@ -43,12 +43,12 @@ public struct TrackerInfo: Encodable { public mutating func add(detectedThirdPartyRequest request: DetectedRequest) { thirdPartyRequests.insert(request) } - + public mutating func addInstalledSurrogateHost(_ host: String, for tracker: DetectedRequest, onPageWithURL url: URL) { guard tracker.pageUrl == url.absoluteString else { return } installedSurrogates.insert(host) } - + // MARK: - Helper accessors public var trackersBlocked: [DetectedRequest] { @@ -67,5 +67,5 @@ public struct TrackerInfo: Encodable { try container.encode(allRequests, forKey: .requests) try container.encode(installedSurrogates, forKey: .installedSurrogates) } - + } diff --git a/Sources/PrivacyDashboard/PrivacyDashboardController.swift b/Sources/PrivacyDashboard/PrivacyDashboardController.swift index 085302444..b3df002c8 100644 --- a/Sources/PrivacyDashboard/PrivacyDashboardController.swift +++ b/Sources/PrivacyDashboard/PrivacyDashboardController.swift @@ -26,58 +26,77 @@ public enum PrivacyDashboardOpenSettingsTarget: String { case cookiePopupManagement = "cpm" } -public protocol PrivacyDashboardControllerDelegate: AnyObject { - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - didChangeProtectionSwitch protectionState: ProtectionState) - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didRequestOpenUrlInNewTab url: URL) - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - didRequestOpenSettings target: PrivacyDashboardOpenSettingsTarget) - +/// Navigation delegate for the pages provided by the PrivacyDashboardController +public protocol PrivacyDashboardNavigationDelegate: AnyObject { #if os(iOS) func privacyDashboardControllerDidTapClose(_ privacyDashboardController: PrivacyDashboardController) - func privacyDashboardControllerDidRequestShowReportBrokenSite(_ privacyDashboardController: PrivacyDashboardController) #endif - -#if os(macOS) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didSetHeight height: Int) - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, +} + +/// `Report broken site` web page delegate +public protocol PrivacyDashboardReportBrokenSiteDelegate: AnyObject { + + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didRequestSubmitBrokenSiteReportWithCategory category: String, description: String) - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - didSetPermission permissionName: String, - to state: PermissionAuthorizationState) - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - setPermission permissionName: String, - paused: Bool) -#endif + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + reportBrokenSiteDidChangeProtectionSwitch protectionState: ProtectionState) +} + +/// `Privacy Dasboard` web page delegate +public protocol PrivacyDashboardControllerDelegate: AnyObject { + + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didChangeProtectionSwitch protectionState: ProtectionState) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didRequestOpenUrlInNewTab url: URL) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didRequestOpenSettings target: PrivacyDashboardOpenSettingsTarget) + func privacyDashboardControllerDidRequestShowReportBrokenSite(_ privacyDashboardController: PrivacyDashboardController) +#if os(macOS) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didSetPermission permissionName: String, to state: PermissionAuthorizationState) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + setPermission permissionName: String, paused: Bool) +#endif } -@MainActor -public final class PrivacyDashboardController: NSObject { +/// This controller provides two type of user experiences +/// 1- `Privacy Dashboard` with the possibility to navigate to the `Report broken site` page +/// 2- Direct access to the `Report broken site` page +/// Which flow is used is decided at `setup(...)` time, where if `reportBrokenSiteOnly` is true then the `Report broken site` page is opened directly. +@MainActor public final class PrivacyDashboardController: NSObject { - public weak var delegate: PrivacyDashboardControllerDelegate? + // Delegates + public weak var privacyDashboardDelegate: PrivacyDashboardControllerDelegate? + public weak var privacyDashboardNavigationDelegate: PrivacyDashboardNavigationDelegate? + public weak var privacyDashboardReportBrokenSiteDelegate: PrivacyDashboardReportBrokenSiteDelegate? @Published public var theme: PrivacyDashboardTheme? public var preferredLocale: String? @Published public var allowedPermissions: [AllowedPermission] = [] - public private(set) weak var privacyInfo: PrivacyInfo? - private weak var webView: WKWebView? + private weak var webView: WKWebView? private let privacyDashboardScript = PrivacyDashboardUserScript() private var cancellables = Set() - + public init(privacyInfo: PrivacyInfo?) { self.privacyInfo = privacyInfo } - public func setup(for webView: WKWebView) { + /// Configure the webview for showing `Privacy Dasboard` or `Report broken site` + /// - Parameters: + /// - webView: The webview to use + /// - reportBrokenSiteOnly: true = `Report broken site`, false = `Privacy Dasboard` + public func setup(for webView: WKWebView, reportBrokenSiteOnly: Bool) { self.webView = webView - webView.navigationDelegate = self setupPrivacyDashboardUserScript() - loadPrivacyDashboardHTML() + loadPrivacyDashboardHTML(reportBrokenSiteOnly: reportBrokenSiteOnly) } public func updatePrivacyInfo(_ privacyInfo: PrivacyInfo?) { @@ -112,20 +131,25 @@ public final class PrivacyDashboardController: NSObject { privacyDashboardScript.delegate = self webView.configuration.userContentController.addUserScript(privacyDashboardScript.makeWKUserScriptSync()) - + privacyDashboardScript.messageNames.forEach { messageName in webView.configuration.userContentController.add(privacyDashboardScript, name: messageName) } } - private func loadPrivacyDashboardHTML() { - guard let url = Bundle.privacyDashboardURL else { return } + private func loadPrivacyDashboardHTML(reportBrokenSiteOnly: Bool) { + guard var url = Bundle.privacyDashboardURL else { return } + + if reportBrokenSiteOnly { + url = url.appendingParameter(name: "screen", value: ProtectionState.EventOriginScreen.breakageForm.rawValue) + } + webView?.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent().deletingLastPathComponent()) } } extension PrivacyDashboardController: WKNavigationDelegate { - + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { subscribeToDataModelChanges() @@ -155,7 +179,7 @@ extension PrivacyDashboardController: WKNavigationDelegate { }) .store(in: &cancellables) } - + private func subscribeToTrackerInfo() { privacyInfo?.$trackerInfo .receive(on: DispatchQueue.main) @@ -234,53 +258,57 @@ extension PrivacyDashboardController: WKNavigationDelegate { } extension PrivacyDashboardController: PrivacyDashboardUserScriptDelegate { - + func userScript(_ userScript: PrivacyDashboardUserScript, didRequestOpenSettings target: String) { let settingsTarget = PrivacyDashboardOpenSettingsTarget(rawValue: target) ?? .general - delegate?.privacyDashboardController(self, didRequestOpenSettings: settingsTarget) + privacyDashboardDelegate?.privacyDashboardController(self, didRequestOpenSettings: settingsTarget) } - + func userScript(_ userScript: PrivacyDashboardUserScript, didChangeProtectionState protectionState: ProtectionState) { - delegate?.privacyDashboardController(self, didChangeProtectionSwitch: protectionState) + + switch protectionState.eventOrigin.screen { + case .primaryScreen: + privacyDashboardDelegate?.privacyDashboardController(self, didChangeProtectionSwitch: protectionState) + case .breakageForm: + privacyDashboardReportBrokenSiteDelegate?.privacyDashboardController(self, reportBrokenSiteDidChangeProtectionSwitch: protectionState) + } + } func userScript(_ userScript: PrivacyDashboardUserScript, didRequestOpenUrlInNewTab url: URL) { - delegate?.privacyDashboardController(self, didRequestOpenUrlInNewTab: url) + privacyDashboardDelegate?.privacyDashboardController(self, didRequestOpenUrlInNewTab: url) } - + func userScriptDidRequestClosing(_ userScript: PrivacyDashboardUserScript) { #if os(iOS) - delegate?.privacyDashboardControllerDidTapClose(self) + privacyDashboardNavigationDelegate?.privacyDashboardControllerDidTapClose(self) #endif } func userScriptDidRequestShowReportBrokenSite(_ userScript: PrivacyDashboardUserScript) { #if os(iOS) - delegate?.privacyDashboardControllerDidRequestShowReportBrokenSite(self) + privacyDashboardDelegate?.privacyDashboardControllerDidRequestShowReportBrokenSite(self) #endif } func userScript(_ userScript: PrivacyDashboardUserScript, setHeight height: Int) { -#if os(macOS) - delegate?.privacyDashboardController(self, didSetHeight: height) -#endif + privacyDashboardNavigationDelegate?.privacyDashboardController(self, didSetHeight: height) } func userScript(_ userScript: PrivacyDashboardUserScript, didRequestSubmitBrokenSiteReportWithCategory category: String, description: String) { -#if os(macOS) - delegate?.privacyDashboardController(self, didRequestSubmitBrokenSiteReportWithCategory: category, description: description) -#endif + privacyDashboardReportBrokenSiteDelegate?.privacyDashboardController(self, didRequestSubmitBrokenSiteReportWithCategory: category, + description: description) } func userScript(_ userScript: PrivacyDashboardUserScript, didSetPermission permission: String, to state: PermissionAuthorizationState) { #if os(macOS) - delegate?.privacyDashboardController(self, didSetPermission: permission, to: state) + privacyDashboardDelegate?.privacyDashboardController(self, didSetPermission: permission, to: state) #endif } func userScript(_ userScript: PrivacyDashboardUserScript, setPermission permission: String, paused: Bool) { #if os(macOS) - delegate?.privacyDashboardController(self, setPermission: permission, paused: paused) + privacyDashboardDelegate?.privacyDashboardController(self, setPermission: permission, paused: paused) #endif } } diff --git a/Sources/PrivacyDashboard/PrivacyInfo.swift b/Sources/PrivacyDashboard/PrivacyInfo.swift index 41d937793..fd488e382 100644 --- a/Sources/PrivacyDashboard/PrivacyInfo.swift +++ b/Sources/PrivacyDashboard/PrivacyInfo.swift @@ -35,7 +35,7 @@ public final class PrivacyInfo { self.url = url self.parentEntity = parentEntity self.protectionStatus = protectionStatus - + trackerInfo = TrackerInfo() } diff --git a/Sources/PrivacyDashboard/UserScript/PrivacyDashboardUserScript.swift b/Sources/PrivacyDashboard/UserScript/PrivacyDashboardUserScript.swift index 77b9c5c5e..1df481f4d 100644 --- a/Sources/PrivacyDashboard/UserScript/PrivacyDashboardUserScript.swift +++ b/Sources/PrivacyDashboard/UserScript/PrivacyDashboardUserScript.swift @@ -42,11 +42,11 @@ public enum PrivacyDashboardTheme: String, Encodable { public struct ProtectionState: Decodable { public let isProtected: Bool public let eventOrigin: EventOrigin - + public struct EventOrigin: Decodable { public let screen: EventOriginScreen } - + public enum EventOriginScreen: String, Decodable { case primaryScreen case breakageForm @@ -54,7 +54,7 @@ public struct ProtectionState: Decodable { } final class PrivacyDashboardUserScript: NSObject, StaticUserScript { - + enum MessageNames: String, CaseIterable { case privacyDashboardSetProtection case privacyDashboardSetSize @@ -66,21 +66,21 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { case privacyDashboardSetPermission case privacyDashboardSetPermissionPaused } - + static var injectionTime: WKUserScriptInjectionTime { .atDocumentStart } static var forMainFrameOnly: Bool { false } static var source: String = "" static var script: WKUserScript = PrivacyDashboardUserScript.makeWKUserScript() var messageNames: [String] { MessageNames.allCases.map(\.rawValue) } - + weak var delegate: PrivacyDashboardUserScriptDelegate? - + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let messageType = MessageNames(rawValue: message.name) else { assertionFailure("PrivacyDashboardUserScript: unexpected message name \(message.name)") return } - + switch messageType { case .privacyDashboardSetProtection: handleSetProtection(message: message) @@ -104,14 +104,14 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { } // MARK: - JS message handlers - + private func handleSetProtection(message: WKScriptMessage) { - + guard let protectionState: ProtectionState = DecodableHelper.decode(from: message.messageBody) else { assertionFailure("privacyDashboardSetProtection: expected ProtectionState") return } - + delegate?.userScript(self, didChangeProtectionState: protectionState) } @@ -121,10 +121,10 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("privacyDashboardSetHeight: expected height to be an Int") return } - + delegate?.userScript(self, setHeight: height) } - + private func handleClose() { delegate?.userScriptDidRequestClosing(self) } @@ -140,7 +140,7 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("privacyDashboardSetHeight: expected { category: String, description: String }") return } - + delegate?.userScript(self, didRequestSubmitBrokenSiteReportWithCategory: category, description: description) } @@ -152,10 +152,10 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("handleOpenUrlInNewTab: expected { url: '...' } ") return } - + delegate?.userScript(self, didRequestOpenUrlInNewTab: url) } - + private func handleOpenSettings(message: WKScriptMessage) { guard let dict = message.body as? [String: Any], let target = dict["target"] as? String @@ -163,10 +163,10 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("handleOpenSettings: expected { target: '...' } ") return } - + delegate?.userScript(self, didRequestOpenSettings: target) } - + private func handleSetPermission(message: WKScriptMessage) { guard let dict = message.body as? [String: Any], let permission = dict["permission"] as? String, @@ -175,10 +175,10 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("privacyDashboardSetPermission: expected { permission: PermissionType, value: PermissionAuthorizationState }") return } - + delegate?.userScript(self, didSetPermission: permission, to: state) } - + private func handleSetPermissionPaused(message: WKScriptMessage) { guard let dict = message.body as? [String: Any], let permission = dict["permission"] as? String, @@ -187,10 +187,10 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("handleSetPermissionPaused: expected { permission: PermissionType, paused: Bool }") return } - + delegate?.userScript(self, setPermission: permission, paused: paused) } - + // MARK: - Calls to script's JS API func setTrackerInfo(_ tabUrl: URL, trackerInfo: TrackerInfo, webView: WKWebView) { @@ -198,15 +198,15 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("Can't encode trackerInfoViewModel into JSON") return } - + guard let safeTabUrl = try? JSONEncoder().encode(tabUrl).utf8String() else { assertionFailure("Can't encode tabUrl into JSON") return } - + evaluate(js: "window.onChangeRequestData(\(safeTabUrl), \(trackerBlockingDataJson))", in: webView) } - + func setProtectionStatus(_ protectionStatus: ProtectionStatus, webView: WKWebView) { guard let protectionStatusJson = try? JSONEncoder().encode(protectionStatus).utf8String() else { assertionFailure("Can't encode mockProtectionStatus into JSON") @@ -215,42 +215,42 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { evaluate(js: "window.onChangeProtectionStatus(\(protectionStatusJson))", in: webView) } - + func setUpgradedHttps(_ upgradedHttps: Bool, webView: WKWebView) { evaluate(js: "window.onChangeUpgradedHttps(\(upgradedHttps))", in: webView) } - + func setParentEntity(_ parentEntity: Entity?, webView: WKWebView) { if parentEntity == nil { return } - + guard let parentEntityJson = try? JSONEncoder().encode(parentEntity).utf8String() else { assertionFailure("Can't encode parentEntity into JSON") return } - + evaluate(js: "window.onChangeParentEntity(\(parentEntityJson))", in: webView) } - + func setTheme(_ theme: PrivacyDashboardTheme?, webView: WKWebView) { if theme == nil { return } - + guard let themeJson = try? JSONEncoder().encode(theme).utf8String() else { assertionFailure("Can't encode themeName into JSON") return } - + evaluate(js: "window.onChangeTheme(\(themeJson))", in: webView) } - + func setServerTrust(_ serverTrustViewModel: ServerTrustViewModel, webView: WKWebView) { guard let certificateDataJson = try? JSONEncoder().encode(serverTrustViewModel).utf8String() else { assertionFailure("Can't encode serverTrustViewModel into JSON") return } - + evaluate(js: "window.onChangeCertificateData(\(certificateDataJson))", in: webView) } - + func setIsPendingUpdates(_ isPendingUpdates: Bool, webView: WKWebView) { evaluate(js: "window.onIsPendingUpdates(\(isPendingUpdates))", in: webView) } @@ -283,17 +283,17 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { self.evaluate(js: "window.onChangeAllowedPermissions(\(allowedPermissionsJson))", in: webView) } - + private func evaluate(js: String, in webView: WKWebView) { webView.evaluateJavaScript(js) } - + } extension Data { - + func utf8String() -> String? { return String(data: self, encoding: .utf8) } - + } diff --git a/Sources/PrivacyDashboard/ViewModel/ServerTrustViewModel.swift b/Sources/PrivacyDashboard/ViewModel/ServerTrustViewModel.swift index eb94d1db3..0862cb89d 100644 --- a/Sources/PrivacyDashboard/ViewModel/ServerTrustViewModel.swift +++ b/Sources/PrivacyDashboard/ViewModel/ServerTrustViewModel.swift @@ -19,28 +19,28 @@ import Foundation public struct ServerTrustViewModel: Encodable { - + struct SecCertificateViewModel: Encodable { - + let summary: String? let commonName: String? let emails: [String]? let publicKey: SecKeyViewModel? - + public init(secCertificate: SecCertificate) { summary = SecCertificateCopySubjectSummary(secCertificate) as String? ?? "" - + var commonName: CFString? SecCertificateCopyCommonName(secCertificate, &commonName) self.commonName = commonName as String? ?? "" - + var emails: CFArray? if errSecSuccess == SecCertificateCopyEmailAddresses(secCertificate, &emails) { self.emails = emails as? [String] } else { self.emails = nil } - + var secTrust: SecTrust? if errSecSuccess == SecTrustCreateWithCertificates(secCertificate, SecPolicyCreateBasicX509(), &secTrust), let certTrust = secTrust { if #available(iOS 14.0, macOS 11.0, *) { @@ -53,11 +53,11 @@ public struct ServerTrustViewModel: Encodable { publicKey = nil } } - + } - + struct SecKeyViewModel: Encodable { - + static func typeToString(_ type: String) -> String? { switch type as CFString { case kSecAttrKeyTypeRSA: return "RSA" @@ -66,14 +66,14 @@ public struct ServerTrustViewModel: Encodable { default: return nil } } - + let keyId: Data? let externalRepresentation: Data? - + let bitSize: Int? let blockSize: Int? let effectiveSize: Int? - + let canDecrypt: Bool let canDerive: Bool let canEncrypt: Bool @@ -81,20 +81,20 @@ public struct ServerTrustViewModel: Encodable { let canUnwrap: Bool let canVerify: Bool let canWrap: Bool - + let isPermanent: Bool? let type: String? - + init?(secKey: SecKey?) { guard let secKey = secKey else { return nil } - + blockSize = SecKeyGetBlockSize(secKey) externalRepresentation = SecKeyCopyExternalRepresentation(secKey, nil) as Data? - + let attrs: NSDictionary? = SecKeyCopyAttributes(secKey) - + bitSize = attrs?[kSecAttrKeySizeInBits] as? Int effectiveSize = attrs?[kSecAttrEffectiveKeySize] as? Int canDecrypt = attrs?[kSecAttrCanDecrypt] as? Bool ?? false @@ -106,36 +106,36 @@ public struct ServerTrustViewModel: Encodable { canWrap = attrs?[kSecAttrCanWrap] as? Bool ?? false isPermanent = attrs?[kSecAttrIsPermanent] as? Bool ?? false keyId = attrs?[kSecAttrApplicationLabel] as? Data - + if let type = attrs?[kSecAttrType] as? String { self.type = Self.typeToString(type) } else { self.type = nil } - + } - + } - + let secCertificateViewModels: [SecCertificateViewModel] - + public init?(serverTrust: SecTrust?) { guard let serverTrust = serverTrust else { return nil } - + let secTrust = serverTrust let count = SecTrustGetCertificateCount(secTrust) guard count != 0 else { return nil } - + var secCertificateViewModels = [SecCertificateViewModel]() for i in 0 ..< count { guard let certificate = SecTrustGetCertificateAtIndex(secTrust, i) else { return nil } let certificateViewModel = SecCertificateViewModel(secCertificate: certificate) secCertificateViewModels.append(certificateViewModel) } - + self.secCertificateViewModels = secCertificateViewModels } - + } From 52dc7942f5404a336df02f12fa7149e804b2698a Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Thu, 23 Nov 2023 14:58:33 +0000 Subject: [PATCH 29/31] Revert "Breakage report improvements (#566)" (#572) This reverts commit bdac80f60c3ac54b6ac0dc54c60a8942f208a05e. --- Package.swift | 2 +- .../Model/CookieConsentInfo.swift | 2 +- .../Model/ProtectionStatus.swift | 2 +- .../PrivacyDashboard/Model/TrackerInfo.swift | 10 +- .../PrivacyDashboardController.swift | 124 +++++++----------- Sources/PrivacyDashboard/PrivacyInfo.swift | 2 +- .../PrivacyDashboardUserScript.swift | 76 +++++------ .../ViewModel/ServerTrustViewModel.swift | 52 ++++---- 8 files changed, 121 insertions(+), 149 deletions(-) diff --git a/Package.swift b/Package.swift index b90368633..8f561e30c 100644 --- a/Package.swift +++ b/Package.swift @@ -37,8 +37,8 @@ let package = Package( .package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "1.2.1"), .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.2.0"), .package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "2.1.0"), - .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "3.1.0" ), .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "4.52.0"), + .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "2.0.0"), .package(url: "https://github.com/httpswift/swifter.git", exact: "1.5.0"), .package(url: "https://github.com/duckduckgo/bloom_cpp.git", exact: "3.0.0"), .package(url: "https://github.com/duckduckgo/wireguard-apple", exact: "1.1.1") diff --git a/Sources/PrivacyDashboard/Model/CookieConsentInfo.swift b/Sources/PrivacyDashboard/Model/CookieConsentInfo.swift index 475d24931..c86872932 100644 --- a/Sources/PrivacyDashboard/Model/CookieConsentInfo.swift +++ b/Sources/PrivacyDashboard/Model/CookieConsentInfo.swift @@ -24,7 +24,7 @@ public struct CookieConsentInfo: Encodable { let optoutFailed: Bool? let selftestFailed: Bool? let configurable = true - + public init(consentManaged: Bool, cosmetic: Bool?, optoutFailed: Bool?, selftestFailed: Bool?) { self.consentManaged = consentManaged self.cosmetic = cosmetic diff --git a/Sources/PrivacyDashboard/Model/ProtectionStatus.swift b/Sources/PrivacyDashboard/Model/ProtectionStatus.swift index 2fc310faa..22020c81b 100644 --- a/Sources/PrivacyDashboard/Model/ProtectionStatus.swift +++ b/Sources/PrivacyDashboard/Model/ProtectionStatus.swift @@ -20,7 +20,7 @@ import Foundation public struct ProtectionStatus: Encodable { - + let unprotectedTemporary: Bool let enabledFeatures: [String] let allowlisted: Bool diff --git a/Sources/PrivacyDashboard/Model/TrackerInfo.swift b/Sources/PrivacyDashboard/Model/TrackerInfo.swift index 8b5b051b7..9782b349a 100644 --- a/Sources/PrivacyDashboard/Model/TrackerInfo.swift +++ b/Sources/PrivacyDashboard/Model/TrackerInfo.swift @@ -26,11 +26,11 @@ public struct TrackerInfo: Encodable { case requests case installedSurrogates } - + public private (set) var trackers = Set() private(set) var thirdPartyRequests = Set() public private(set) var installedSurrogates = Set() - + public init() { } // MARK: - Collecting detected elements @@ -43,12 +43,12 @@ public struct TrackerInfo: Encodable { public mutating func add(detectedThirdPartyRequest request: DetectedRequest) { thirdPartyRequests.insert(request) } - + public mutating func addInstalledSurrogateHost(_ host: String, for tracker: DetectedRequest, onPageWithURL url: URL) { guard tracker.pageUrl == url.absoluteString else { return } installedSurrogates.insert(host) } - + // MARK: - Helper accessors public var trackersBlocked: [DetectedRequest] { @@ -67,5 +67,5 @@ public struct TrackerInfo: Encodable { try container.encode(allRequests, forKey: .requests) try container.encode(installedSurrogates, forKey: .installedSurrogates) } - + } diff --git a/Sources/PrivacyDashboard/PrivacyDashboardController.swift b/Sources/PrivacyDashboard/PrivacyDashboardController.swift index b3df002c8..085302444 100644 --- a/Sources/PrivacyDashboard/PrivacyDashboardController.swift +++ b/Sources/PrivacyDashboard/PrivacyDashboardController.swift @@ -26,77 +26,58 @@ public enum PrivacyDashboardOpenSettingsTarget: String { case cookiePopupManagement = "cpm" } -/// Navigation delegate for the pages provided by the PrivacyDashboardController -public protocol PrivacyDashboardNavigationDelegate: AnyObject { -#if os(iOS) - func privacyDashboardControllerDidTapClose(_ privacyDashboardController: PrivacyDashboardController) -#endif - - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didSetHeight height: Int) -} - -/// `Report broken site` web page delegate -public protocol PrivacyDashboardReportBrokenSiteDelegate: AnyObject { - - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - didRequestSubmitBrokenSiteReportWithCategory category: String, description: String) - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - reportBrokenSiteDidChangeProtectionSwitch protectionState: ProtectionState) -} - -/// `Privacy Dasboard` web page delegate public protocol PrivacyDashboardControllerDelegate: AnyObject { - - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didChangeProtectionSwitch protectionState: ProtectionState) - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - didRequestOpenUrlInNewTab url: URL) - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didRequestOpenUrlInNewTab url: URL) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didRequestOpenSettings target: PrivacyDashboardOpenSettingsTarget) + +#if os(iOS) + func privacyDashboardControllerDidTapClose(_ privacyDashboardController: PrivacyDashboardController) func privacyDashboardControllerDidRequestShowReportBrokenSite(_ privacyDashboardController: PrivacyDashboardController) - +#endif + #if os(macOS) - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - didSetPermission permissionName: String, to state: PermissionAuthorizationState) - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - setPermission permissionName: String, paused: Bool) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didSetHeight height: Int) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didRequestSubmitBrokenSiteReportWithCategory category: String, description: String) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didSetPermission permissionName: String, + to state: PermissionAuthorizationState) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + setPermission permissionName: String, + paused: Bool) #endif + } -/// This controller provides two type of user experiences -/// 1- `Privacy Dashboard` with the possibility to navigate to the `Report broken site` page -/// 2- Direct access to the `Report broken site` page -/// Which flow is used is decided at `setup(...)` time, where if `reportBrokenSiteOnly` is true then the `Report broken site` page is opened directly. -@MainActor public final class PrivacyDashboardController: NSObject { +@MainActor +public final class PrivacyDashboardController: NSObject { - // Delegates - public weak var privacyDashboardDelegate: PrivacyDashboardControllerDelegate? - public weak var privacyDashboardNavigationDelegate: PrivacyDashboardNavigationDelegate? - public weak var privacyDashboardReportBrokenSiteDelegate: PrivacyDashboardReportBrokenSiteDelegate? + public weak var delegate: PrivacyDashboardControllerDelegate? @Published public var theme: PrivacyDashboardTheme? public var preferredLocale: String? @Published public var allowedPermissions: [AllowedPermission] = [] - public private(set) weak var privacyInfo: PrivacyInfo? + public private(set) weak var privacyInfo: PrivacyInfo? private weak var webView: WKWebView? + private let privacyDashboardScript = PrivacyDashboardUserScript() private var cancellables = Set() - + public init(privacyInfo: PrivacyInfo?) { self.privacyInfo = privacyInfo } - /// Configure the webview for showing `Privacy Dasboard` or `Report broken site` - /// - Parameters: - /// - webView: The webview to use - /// - reportBrokenSiteOnly: true = `Report broken site`, false = `Privacy Dasboard` - public func setup(for webView: WKWebView, reportBrokenSiteOnly: Bool) { + public func setup(for webView: WKWebView) { self.webView = webView + webView.navigationDelegate = self setupPrivacyDashboardUserScript() - loadPrivacyDashboardHTML(reportBrokenSiteOnly: reportBrokenSiteOnly) + loadPrivacyDashboardHTML() } public func updatePrivacyInfo(_ privacyInfo: PrivacyInfo?) { @@ -131,25 +112,20 @@ public protocol PrivacyDashboardControllerDelegate: AnyObject { privacyDashboardScript.delegate = self webView.configuration.userContentController.addUserScript(privacyDashboardScript.makeWKUserScriptSync()) - + privacyDashboardScript.messageNames.forEach { messageName in webView.configuration.userContentController.add(privacyDashboardScript, name: messageName) } } - private func loadPrivacyDashboardHTML(reportBrokenSiteOnly: Bool) { - guard var url = Bundle.privacyDashboardURL else { return } - - if reportBrokenSiteOnly { - url = url.appendingParameter(name: "screen", value: ProtectionState.EventOriginScreen.breakageForm.rawValue) - } - + private func loadPrivacyDashboardHTML() { + guard let url = Bundle.privacyDashboardURL else { return } webView?.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent().deletingLastPathComponent()) } } extension PrivacyDashboardController: WKNavigationDelegate { - + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { subscribeToDataModelChanges() @@ -179,7 +155,7 @@ extension PrivacyDashboardController: WKNavigationDelegate { }) .store(in: &cancellables) } - + private func subscribeToTrackerInfo() { privacyInfo?.$trackerInfo .receive(on: DispatchQueue.main) @@ -258,57 +234,53 @@ extension PrivacyDashboardController: WKNavigationDelegate { } extension PrivacyDashboardController: PrivacyDashboardUserScriptDelegate { - + func userScript(_ userScript: PrivacyDashboardUserScript, didRequestOpenSettings target: String) { let settingsTarget = PrivacyDashboardOpenSettingsTarget(rawValue: target) ?? .general - privacyDashboardDelegate?.privacyDashboardController(self, didRequestOpenSettings: settingsTarget) + delegate?.privacyDashboardController(self, didRequestOpenSettings: settingsTarget) } - + func userScript(_ userScript: PrivacyDashboardUserScript, didChangeProtectionState protectionState: ProtectionState) { - - switch protectionState.eventOrigin.screen { - case .primaryScreen: - privacyDashboardDelegate?.privacyDashboardController(self, didChangeProtectionSwitch: protectionState) - case .breakageForm: - privacyDashboardReportBrokenSiteDelegate?.privacyDashboardController(self, reportBrokenSiteDidChangeProtectionSwitch: protectionState) - } - + delegate?.privacyDashboardController(self, didChangeProtectionSwitch: protectionState) } func userScript(_ userScript: PrivacyDashboardUserScript, didRequestOpenUrlInNewTab url: URL) { - privacyDashboardDelegate?.privacyDashboardController(self, didRequestOpenUrlInNewTab: url) + delegate?.privacyDashboardController(self, didRequestOpenUrlInNewTab: url) } - + func userScriptDidRequestClosing(_ userScript: PrivacyDashboardUserScript) { #if os(iOS) - privacyDashboardNavigationDelegate?.privacyDashboardControllerDidTapClose(self) + delegate?.privacyDashboardControllerDidTapClose(self) #endif } func userScriptDidRequestShowReportBrokenSite(_ userScript: PrivacyDashboardUserScript) { #if os(iOS) - privacyDashboardDelegate?.privacyDashboardControllerDidRequestShowReportBrokenSite(self) + delegate?.privacyDashboardControllerDidRequestShowReportBrokenSite(self) #endif } func userScript(_ userScript: PrivacyDashboardUserScript, setHeight height: Int) { - privacyDashboardNavigationDelegate?.privacyDashboardController(self, didSetHeight: height) +#if os(macOS) + delegate?.privacyDashboardController(self, didSetHeight: height) +#endif } func userScript(_ userScript: PrivacyDashboardUserScript, didRequestSubmitBrokenSiteReportWithCategory category: String, description: String) { - privacyDashboardReportBrokenSiteDelegate?.privacyDashboardController(self, didRequestSubmitBrokenSiteReportWithCategory: category, - description: description) +#if os(macOS) + delegate?.privacyDashboardController(self, didRequestSubmitBrokenSiteReportWithCategory: category, description: description) +#endif } func userScript(_ userScript: PrivacyDashboardUserScript, didSetPermission permission: String, to state: PermissionAuthorizationState) { #if os(macOS) - privacyDashboardDelegate?.privacyDashboardController(self, didSetPermission: permission, to: state) + delegate?.privacyDashboardController(self, didSetPermission: permission, to: state) #endif } func userScript(_ userScript: PrivacyDashboardUserScript, setPermission permission: String, paused: Bool) { #if os(macOS) - privacyDashboardDelegate?.privacyDashboardController(self, setPermission: permission, paused: paused) + delegate?.privacyDashboardController(self, setPermission: permission, paused: paused) #endif } } diff --git a/Sources/PrivacyDashboard/PrivacyInfo.swift b/Sources/PrivacyDashboard/PrivacyInfo.swift index fd488e382..41d937793 100644 --- a/Sources/PrivacyDashboard/PrivacyInfo.swift +++ b/Sources/PrivacyDashboard/PrivacyInfo.swift @@ -35,7 +35,7 @@ public final class PrivacyInfo { self.url = url self.parentEntity = parentEntity self.protectionStatus = protectionStatus - + trackerInfo = TrackerInfo() } diff --git a/Sources/PrivacyDashboard/UserScript/PrivacyDashboardUserScript.swift b/Sources/PrivacyDashboard/UserScript/PrivacyDashboardUserScript.swift index 1df481f4d..77b9c5c5e 100644 --- a/Sources/PrivacyDashboard/UserScript/PrivacyDashboardUserScript.swift +++ b/Sources/PrivacyDashboard/UserScript/PrivacyDashboardUserScript.swift @@ -42,11 +42,11 @@ public enum PrivacyDashboardTheme: String, Encodable { public struct ProtectionState: Decodable { public let isProtected: Bool public let eventOrigin: EventOrigin - + public struct EventOrigin: Decodable { public let screen: EventOriginScreen } - + public enum EventOriginScreen: String, Decodable { case primaryScreen case breakageForm @@ -54,7 +54,7 @@ public struct ProtectionState: Decodable { } final class PrivacyDashboardUserScript: NSObject, StaticUserScript { - + enum MessageNames: String, CaseIterable { case privacyDashboardSetProtection case privacyDashboardSetSize @@ -66,21 +66,21 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { case privacyDashboardSetPermission case privacyDashboardSetPermissionPaused } - + static var injectionTime: WKUserScriptInjectionTime { .atDocumentStart } static var forMainFrameOnly: Bool { false } static var source: String = "" static var script: WKUserScript = PrivacyDashboardUserScript.makeWKUserScript() var messageNames: [String] { MessageNames.allCases.map(\.rawValue) } - + weak var delegate: PrivacyDashboardUserScriptDelegate? - + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let messageType = MessageNames(rawValue: message.name) else { assertionFailure("PrivacyDashboardUserScript: unexpected message name \(message.name)") return } - + switch messageType { case .privacyDashboardSetProtection: handleSetProtection(message: message) @@ -104,14 +104,14 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { } // MARK: - JS message handlers - + private func handleSetProtection(message: WKScriptMessage) { - + guard let protectionState: ProtectionState = DecodableHelper.decode(from: message.messageBody) else { assertionFailure("privacyDashboardSetProtection: expected ProtectionState") return } - + delegate?.userScript(self, didChangeProtectionState: protectionState) } @@ -121,10 +121,10 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("privacyDashboardSetHeight: expected height to be an Int") return } - + delegate?.userScript(self, setHeight: height) } - + private func handleClose() { delegate?.userScriptDidRequestClosing(self) } @@ -140,7 +140,7 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("privacyDashboardSetHeight: expected { category: String, description: String }") return } - + delegate?.userScript(self, didRequestSubmitBrokenSiteReportWithCategory: category, description: description) } @@ -152,10 +152,10 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("handleOpenUrlInNewTab: expected { url: '...' } ") return } - + delegate?.userScript(self, didRequestOpenUrlInNewTab: url) } - + private func handleOpenSettings(message: WKScriptMessage) { guard let dict = message.body as? [String: Any], let target = dict["target"] as? String @@ -163,10 +163,10 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("handleOpenSettings: expected { target: '...' } ") return } - + delegate?.userScript(self, didRequestOpenSettings: target) } - + private func handleSetPermission(message: WKScriptMessage) { guard let dict = message.body as? [String: Any], let permission = dict["permission"] as? String, @@ -175,10 +175,10 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("privacyDashboardSetPermission: expected { permission: PermissionType, value: PermissionAuthorizationState }") return } - + delegate?.userScript(self, didSetPermission: permission, to: state) } - + private func handleSetPermissionPaused(message: WKScriptMessage) { guard let dict = message.body as? [String: Any], let permission = dict["permission"] as? String, @@ -187,10 +187,10 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("handleSetPermissionPaused: expected { permission: PermissionType, paused: Bool }") return } - + delegate?.userScript(self, setPermission: permission, paused: paused) } - + // MARK: - Calls to script's JS API func setTrackerInfo(_ tabUrl: URL, trackerInfo: TrackerInfo, webView: WKWebView) { @@ -198,15 +198,15 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("Can't encode trackerInfoViewModel into JSON") return } - + guard let safeTabUrl = try? JSONEncoder().encode(tabUrl).utf8String() else { assertionFailure("Can't encode tabUrl into JSON") return } - + evaluate(js: "window.onChangeRequestData(\(safeTabUrl), \(trackerBlockingDataJson))", in: webView) } - + func setProtectionStatus(_ protectionStatus: ProtectionStatus, webView: WKWebView) { guard let protectionStatusJson = try? JSONEncoder().encode(protectionStatus).utf8String() else { assertionFailure("Can't encode mockProtectionStatus into JSON") @@ -215,42 +215,42 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { evaluate(js: "window.onChangeProtectionStatus(\(protectionStatusJson))", in: webView) } - + func setUpgradedHttps(_ upgradedHttps: Bool, webView: WKWebView) { evaluate(js: "window.onChangeUpgradedHttps(\(upgradedHttps))", in: webView) } - + func setParentEntity(_ parentEntity: Entity?, webView: WKWebView) { if parentEntity == nil { return } - + guard let parentEntityJson = try? JSONEncoder().encode(parentEntity).utf8String() else { assertionFailure("Can't encode parentEntity into JSON") return } - + evaluate(js: "window.onChangeParentEntity(\(parentEntityJson))", in: webView) } - + func setTheme(_ theme: PrivacyDashboardTheme?, webView: WKWebView) { if theme == nil { return } - + guard let themeJson = try? JSONEncoder().encode(theme).utf8String() else { assertionFailure("Can't encode themeName into JSON") return } - + evaluate(js: "window.onChangeTheme(\(themeJson))", in: webView) } - + func setServerTrust(_ serverTrustViewModel: ServerTrustViewModel, webView: WKWebView) { guard let certificateDataJson = try? JSONEncoder().encode(serverTrustViewModel).utf8String() else { assertionFailure("Can't encode serverTrustViewModel into JSON") return } - + evaluate(js: "window.onChangeCertificateData(\(certificateDataJson))", in: webView) } - + func setIsPendingUpdates(_ isPendingUpdates: Bool, webView: WKWebView) { evaluate(js: "window.onIsPendingUpdates(\(isPendingUpdates))", in: webView) } @@ -283,17 +283,17 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { self.evaluate(js: "window.onChangeAllowedPermissions(\(allowedPermissionsJson))", in: webView) } - + private func evaluate(js: String, in webView: WKWebView) { webView.evaluateJavaScript(js) } - + } extension Data { - + func utf8String() -> String? { return String(data: self, encoding: .utf8) } - + } diff --git a/Sources/PrivacyDashboard/ViewModel/ServerTrustViewModel.swift b/Sources/PrivacyDashboard/ViewModel/ServerTrustViewModel.swift index 0862cb89d..eb94d1db3 100644 --- a/Sources/PrivacyDashboard/ViewModel/ServerTrustViewModel.swift +++ b/Sources/PrivacyDashboard/ViewModel/ServerTrustViewModel.swift @@ -19,28 +19,28 @@ import Foundation public struct ServerTrustViewModel: Encodable { - + struct SecCertificateViewModel: Encodable { - + let summary: String? let commonName: String? let emails: [String]? let publicKey: SecKeyViewModel? - + public init(secCertificate: SecCertificate) { summary = SecCertificateCopySubjectSummary(secCertificate) as String? ?? "" - + var commonName: CFString? SecCertificateCopyCommonName(secCertificate, &commonName) self.commonName = commonName as String? ?? "" - + var emails: CFArray? if errSecSuccess == SecCertificateCopyEmailAddresses(secCertificate, &emails) { self.emails = emails as? [String] } else { self.emails = nil } - + var secTrust: SecTrust? if errSecSuccess == SecTrustCreateWithCertificates(secCertificate, SecPolicyCreateBasicX509(), &secTrust), let certTrust = secTrust { if #available(iOS 14.0, macOS 11.0, *) { @@ -53,11 +53,11 @@ public struct ServerTrustViewModel: Encodable { publicKey = nil } } - + } - + struct SecKeyViewModel: Encodable { - + static func typeToString(_ type: String) -> String? { switch type as CFString { case kSecAttrKeyTypeRSA: return "RSA" @@ -66,14 +66,14 @@ public struct ServerTrustViewModel: Encodable { default: return nil } } - + let keyId: Data? let externalRepresentation: Data? - + let bitSize: Int? let blockSize: Int? let effectiveSize: Int? - + let canDecrypt: Bool let canDerive: Bool let canEncrypt: Bool @@ -81,20 +81,20 @@ public struct ServerTrustViewModel: Encodable { let canUnwrap: Bool let canVerify: Bool let canWrap: Bool - + let isPermanent: Bool? let type: String? - + init?(secKey: SecKey?) { guard let secKey = secKey else { return nil } - + blockSize = SecKeyGetBlockSize(secKey) externalRepresentation = SecKeyCopyExternalRepresentation(secKey, nil) as Data? - + let attrs: NSDictionary? = SecKeyCopyAttributes(secKey) - + bitSize = attrs?[kSecAttrKeySizeInBits] as? Int effectiveSize = attrs?[kSecAttrEffectiveKeySize] as? Int canDecrypt = attrs?[kSecAttrCanDecrypt] as? Bool ?? false @@ -106,36 +106,36 @@ public struct ServerTrustViewModel: Encodable { canWrap = attrs?[kSecAttrCanWrap] as? Bool ?? false isPermanent = attrs?[kSecAttrIsPermanent] as? Bool ?? false keyId = attrs?[kSecAttrApplicationLabel] as? Data - + if let type = attrs?[kSecAttrType] as? String { self.type = Self.typeToString(type) } else { self.type = nil } - + } - + } - + let secCertificateViewModels: [SecCertificateViewModel] - + public init?(serverTrust: SecTrust?) { guard let serverTrust = serverTrust else { return nil } - + let secTrust = serverTrust let count = SecTrustGetCertificateCount(secTrust) guard count != 0 else { return nil } - + var secCertificateViewModels = [SecCertificateViewModel]() for i in 0 ..< count { guard let certificate = SecTrustGetCertificateAtIndex(secTrust, i) else { return nil } let certificateViewModel = SecCertificateViewModel(secCertificate: certificate) secCertificateViewModels.append(certificateViewModel) } - + self.secCertificateViewModels = secCertificateViewModels } - + } From 5b84e6d0cd9b28b1a324538c98156576015981a1 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Thu, 23 Nov 2023 16:14:53 +0100 Subject: [PATCH 30/31] Merge 83.0.0-3 hotfix (#573) Task/Issue URL: https://app.asana.com/0/414235014887631/1206016824928730/f --- Sources/Bookmarks/BookmarkUtils.swift | 32 ------ ...BookmarkFormFactorFavoritesMigration.swift | 104 ++++++++++++++++++ Tests/BookmarksTests/BookmarkUtilsTests.swift | 17 ++- 3 files changed, 120 insertions(+), 33 deletions(-) create mode 100644 Sources/Bookmarks/Migrations/BookmarkFormFactorFavoritesMigration.swift diff --git a/Sources/Bookmarks/BookmarkUtils.swift b/Sources/Bookmarks/BookmarkUtils.swift index 236b0f2c5..cad0920b7 100644 --- a/Sources/Bookmarks/BookmarkUtils.swift +++ b/Sources/Bookmarks/BookmarkUtils.swift @@ -82,38 +82,6 @@ public struct BookmarkUtils { } } - public static func migrateToFormFactorSpecificFavorites(byCopyingExistingTo folderID: FavoritesFolderID, in context: NSManagedObjectContext) { - assert(folderID != .unified, "You must specify either desktop or mobile folder") - - guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.unified.rawValue, in: context) else { - return - } - - if BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.desktop.rawValue, in: context) == nil { - let desktopFavoritesFolder = insertRootFolder(uuid: FavoritesFolderID.desktop.rawValue, into: context) - - if folderID == .desktop { - favoritesFolder.favoritesArray.forEach { bookmark in - bookmark.addToFavorites(favoritesRoot: desktopFavoritesFolder) - } - } else { - desktopFavoritesFolder.shouldManageModifiedAt = false - } - } - - if BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.mobile.rawValue, in: context) == nil { - let mobileFavoritesFolder = insertRootFolder(uuid: FavoritesFolderID.mobile.rawValue, into: context) - - if folderID == .mobile { - favoritesFolder.favoritesArray.forEach { bookmark in - bookmark.addToFavorites(favoritesRoot: mobileFavoritesFolder) - } - } else { - mobileFavoritesFolder.shouldManageModifiedAt = false - } - } - } - public static func copyFavorites( from sourceFolderID: FavoritesFolderID, to targetFolderID: FavoritesFolderID, diff --git a/Sources/Bookmarks/Migrations/BookmarkFormFactorFavoritesMigration.swift b/Sources/Bookmarks/Migrations/BookmarkFormFactorFavoritesMigration.swift new file mode 100644 index 000000000..49fce7802 --- /dev/null +++ b/Sources/Bookmarks/Migrations/BookmarkFormFactorFavoritesMigration.swift @@ -0,0 +1,104 @@ +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import CoreData +import Persistence +import Common + +public class BookmarkFormFactorFavoritesMigration { + + public enum MigrationErrors { + case couldNotLoadDatabase + } + + public static func getFavoritesOrderFromPreV4Model(dbContainerLocation: URL, + dbFileURL: URL, + errorEvents: EventMapping? = nil) -> [String]? { + var oldFavoritesOrder: [String]? + + let metadata = try? NSPersistentStoreCoordinator.metadataForPersistentStore(ofType: NSSQLiteStoreType, + at: dbFileURL) + if let metadata, let version = metadata["NSStoreModelVersionHashesVersion"] as? Int, version < 4 { + // Before migrating to latest scheme version, read order of favorites from DB + + let v3BookmarksModel = NSManagedObjectModel.mergedModel(from: [Bookmarks.bundle], forStoreMetadata: metadata)! + + let oldDB = CoreDataDatabase(name: "Bookmarks", + containerLocation: dbContainerLocation, + model: v3BookmarksModel) + oldDB.loadStore { context, error in + guard let context = context else { + errorEvents?.fire(.couldNotLoadDatabase, error: error) + return + } + + let favs = BookmarkUtils.fetchLegacyFavoritesFolder(context) + oldFavoritesOrder = favs?.favoritesArray.compactMap { $0.uuid } + } + } + return oldFavoritesOrder + } + + public static func migrateToFormFactorSpecificFavorites(byCopyingExistingTo folderID: FavoritesFolderID, + preservingOrderOf orderedUUIDs: [String]?, + in context: NSManagedObjectContext) { + assert(folderID != .unified, "You must specify either desktop or mobile folder") + + guard let favoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.unified.rawValue, in: context) else { + return + } + + if let orderedUUIDs { + // Fix order of favorites + let favorites = favoritesFolder.favoritesArray + + for fav in favorites { + fav.removeFromFavorites(favoritesRoot: favoritesFolder) + } + + for uuid in orderedUUIDs { + if let fav = favorites.first(where: { $0.uuid == uuid}) { + fav.addToFavorites(favoritesRoot: favoritesFolder) + } + } + } + + if BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.desktop.rawValue, in: context) == nil { + let desktopFavoritesFolder = BookmarkUtils.insertRootFolder(uuid: FavoritesFolderID.desktop.rawValue, into: context) + + if folderID == .desktop { + favoritesFolder.favoritesArray.forEach { bookmark in + bookmark.addToFavorites(favoritesRoot: desktopFavoritesFolder) + } + } else { + desktopFavoritesFolder.shouldManageModifiedAt = false + } + } + + if BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.mobile.rawValue, in: context) == nil { + let mobileFavoritesFolder = BookmarkUtils.insertRootFolder(uuid: FavoritesFolderID.mobile.rawValue, into: context) + + if folderID == .mobile { + favoritesFolder.favoritesArray.forEach { bookmark in + bookmark.addToFavorites(favoritesRoot: mobileFavoritesFolder) + } + } else { + mobileFavoritesFolder.shouldManageModifiedAt = false + } + } + } +} diff --git a/Tests/BookmarksTests/BookmarkUtilsTests.swift b/Tests/BookmarksTests/BookmarkUtilsTests.swift index 75266145b..8588e4c44 100644 --- a/Tests/BookmarksTests/BookmarkUtilsTests.swift +++ b/Tests/BookmarksTests/BookmarkUtilsTests.swift @@ -61,6 +61,9 @@ final class BookmarkUtilsTests: XCTestCase { let bookmarkTree = BookmarkTree { Bookmark(id: "1") Bookmark(id: "2", favoritedOn: [.unified]) + Folder(id: "10") { + Bookmark(id: "12", favoritedOn: [.unified]) + } Bookmark(id: "3", favoritedOn: [.unified]) Bookmark(id: "4", favoritedOn: [.unified]) } @@ -69,15 +72,27 @@ final class BookmarkUtilsTests: XCTestCase { bookmarkTree.createEntities(in: context) try! context.save() + let favoritesArray = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.unified.rawValue, in: context)?.favoritesArray.compactMap(\.uuid) - BookmarkUtils.migrateToFormFactorSpecificFavorites(byCopyingExistingTo: .mobile, in: context) + BookmarkFormFactorFavoritesMigration + .migrateToFormFactorSpecificFavorites( + byCopyingExistingTo: .mobile, + preservingOrderOf: nil, + in: context + ) try! context.save() + let mobileFavoritesArray = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.mobile.rawValue, in: context)?.favoritesArray.compactMap(\.uuid) + XCTAssertEqual(favoritesArray, mobileFavoritesArray) + let rootFolder = BookmarkUtils.fetchRootFolder(context)! assertEquivalent(withTimestamps: false, rootFolder, BookmarkTree { Bookmark(id: "1") Bookmark(id: "2", favoritedOn: [.mobile, .unified]) + Folder(id: "10") { + Bookmark(id: "12", favoritedOn: [.mobile, .unified]) + } Bookmark(id: "3", favoritedOn: [.mobile, .unified]) Bookmark(id: "4", favoritedOn: [.mobile, .unified]) }) From 88935a6656c7f0bd9dec75a761463347232f68a1 Mon Sep 17 00:00:00 2001 From: Christopher Brind Date: Fri, 24 Nov 2023 15:51:53 +0000 Subject: [PATCH 31/31] cache the calculated temporary unprotected domains property (#574) Task/Issue URL: https://app.asana.com/0/414235014887631/1206018890798709/f iOS PR: macOS PR: What kind of version bump will this require?: Major/Minor/Patch Optional: Tech Design URL: CC: Description: Cache the calculated temporary unprotected domains property in ContentBlockerRulesUserScript. --- .../UserScripts/ContentBlockerRulesUserScript.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/BrowserServicesKit/ContentBlocking/UserScripts/ContentBlockerRulesUserScript.swift b/Sources/BrowserServicesKit/ContentBlocking/UserScripts/ContentBlockerRulesUserScript.swift index bd8e7efcf..b7d4703d5 100644 --- a/Sources/BrowserServicesKit/ContentBlocking/UserScripts/ContentBlockerRulesUserScript.swift +++ b/Sources/BrowserServicesKit/ContentBlocking/UserScripts/ContentBlockerRulesUserScript.swift @@ -105,10 +105,17 @@ open class ContentBlockerRulesUserScript: NSObject, UserScript { public weak var delegate: ContentBlockerRulesUserScriptDelegate? + private var _temporaryUnprotectedDomainsCache = [String: [String]]() + var temporaryUnprotectedDomains: [String] { + if let domains = _temporaryUnprotectedDomainsCache[configuration.privacyConfiguration.identifier] { + return domains + } + let privacyConfiguration = configuration.privacyConfiguration var temporaryUnprotectedDomains = privacyConfiguration.tempUnprotectedDomains.filter { !$0.trimmingWhitespace().isEmpty } temporaryUnprotectedDomains.append(contentsOf: privacyConfiguration.exceptionsList(forFeature: .contentBlocking)) + _temporaryUnprotectedDomainsCache = [configuration.privacyConfiguration.identifier: temporaryUnprotectedDomains] return temporaryUnprotectedDomains }