diff --git a/.maestro/data_clearing_tests/01_fire_proofing.yml b/.maestro/data_clearing_tests/01_fire_proofing.yml new file mode 100644 index 0000000000..e717392439 --- /dev/null +++ b/.maestro/data_clearing_tests/01_fire_proofing.yml @@ -0,0 +1,75 @@ +appId: com.duckduckgo.mobile.ios +tags: + - dataclearing + +--- + +# Set up +- clearState +- launchApp +- runFlow: + when: + visible: + text: "Let’s Do It!" + index: 0 + file: ../shared/onboarding.yaml + +# Load Site +- assertVisible: + id: "searchEntry" +- tapOn: + id: "searchEntry" +- inputText: "https://setcookie.net" +- pressKey: Enter + +# Set a cookie +- assertVisible: "Cookie Test" +- tapOn: "Cookie name" +- inputText: "TestName" +- tapOn: "Cookie value" +- inputText: "TestValue" +- scrollUntilVisible: + centerElement: true + element: + text: "Submit" +- tapOn: "Submit" + +# Fireproof the site +- tapOn: "Browsing Menu" +- tapOn: "Fireproof This Site" +- tapOn: "Fireproof" +- assertVisible: "setcookie.net is now Fireproof" + +# Fire Button - twice, just to be sure +- tapOn: "Close Tabs and Clear Data" +- tapOn: + id: "alert.forget-data.confirm" +- assertVisible: + id: "searchEntry" +- tapOn: "Close Tabs and Clear Data" +- tapOn: + id: "alert.forget-data.confirm" + +# Validate Cookie was retained +- tapOn: + id: "searchEntry" +- inputText: "https://setcookie.net" +- pressKey: Enter +- assertVisible: "TestName = TestValue" + +# Remove fireproofing +- tapOn: "Browsing Menu" +- tapOn: "Remove Fireproofing" + +# Fire Button +- tapOn: "Close Tabs and Clear Data" +- tapOn: + id: "alert.forget-data.confirm" + +# Validate Cookie was removed +- tapOn: + id: "searchEntry" +- inputText: "https://setcookie.net" +- pressKey: Enter +- assertVisible: "Cookie Test" +- assertVisible: "Received no cookies." diff --git a/.maestro/data_clearing_tests/02_duckduckgo_settings.yml b/.maestro/data_clearing_tests/02_duckduckgo_settings.yml new file mode 100644 index 0000000000..9de815dc35 --- /dev/null +++ b/.maestro/data_clearing_tests/02_duckduckgo_settings.yml @@ -0,0 +1,44 @@ +appId: com.duckduckgo.mobile.ios +tags: + - privacy + +--- + +# Set up +- clearState +- launchApp +- runFlow: + when: + visible: + text: "Let’s Do It!" + index: 0 + file: ../shared/onboarding.yaml + +# Load Site +- assertVisible: + id: "searchEntry" +- tapOn: + id: "searchEntry" +- inputText: "privacy blogs" +- pressKey: Enter + +# Change settings +- tapOn: "Safe search: moderate ▼" +- tapOn: "Off" + +# Fire Button - twice, just to be sure +- tapOn: "Close Tabs and Clear Data" +- tapOn: + id: "alert.forget-data.confirm" +- assertVisible: + id: "searchEntry" +- tapOn: "Close Tabs and Clear Data" +- tapOn: + id: "alert.forget-data.confirm" + +# Validate Cookie was retained +- tapOn: + id: "searchEntry" +- inputText: "creepy trackers" +- pressKey: Enter +- assertVisible: "Safe search: off ▼" diff --git a/Core/CookieStorage.swift b/Core/CookieStorage.swift index 0a83d6cba1..0349d9841a 100644 --- a/Core/CookieStorage.swift +++ b/Core/CookieStorage.swift @@ -20,6 +20,12 @@ import Common import Foundation +/// Class for persisting cookies for fire proofed sites to work around a WKWebView / DataStore bug which does not let data get persisted until the webview has loaded. +/// +/// Privacy information: +/// * The Fire Button does not delete the user's DuckDuckGo search settings, which are saved as cookies. Removing these cookies would reset them and have undesired consequences, i.e. changing the theme, default language, etc. +/// * The Fire Button also does not delete temporary cookies associated with 'surveys.duckduckgo.com'. When we launch surveys to help us understand issues that impact users over time, we use this cookie to temporarily store anonymous survey answers, before deleting the cookie. Cookie storage duration is communicated to users before they opt to submit survey answers. +/// * These cookies are not stored in a personally identifiable way. For example, the large size setting is stored as 's=l.' More info in https://duckduckgo.com/privacy public class CookieStorage { struct Keys { @@ -31,7 +37,7 @@ public class CookieStorage { var isConsumed: Bool { get { - userDefaults.bool(forKey: Keys.consumed, defaultValue: false) + return userDefaults.bool(forKey: Keys.consumed, defaultValue: false) } set { userDefaults.set(newValue, forKey: Keys.consumed) @@ -77,15 +83,20 @@ public class CookieStorage { self.userDefaults = userDefaults } - enum CookieDomainsOnUpdate { + /// Used when debugging (e.g. on the simulator). + enum CookieDomainsOnUpdateDiagnostic { case empty case match case missing case different + case notConsumed } + /// Update ALL cookies. The absence of cookie domains here indicateds they have been removed by the website, so be sure to call this with all cookies that might need to be persisted even if those websites have not been visited yet. @discardableResult - func updateCookies(_ cookies: [HTTPCookie], keepingPreservedLogins preservedLogins: PreserveLogins) -> CookieDomainsOnUpdate { + func updateCookies(_ cookies: [HTTPCookie], keepingPreservedLogins preservedLogins: PreserveLogins) -> CookieDomainsOnUpdateDiagnostic { + guard isConsumed else { return .notConsumed } + isConsumed = false let persisted = self.cookies @@ -109,7 +120,9 @@ public class CookieStorage { persistedDomains: persistedCookiesByDomain.keys.sorted() ) - updatedCookiesByDomain.keys.forEach { + let cookieDomains = Set(updatedCookiesByDomain.keys.map { $0 } + persistedCookiesByDomain.keys.map { $0 }) + + cookieDomains.forEach { persistedCookiesByDomain[$0] = updatedCookiesByDomain[$0] } @@ -128,7 +141,7 @@ public class CookieStorage { return diagnosticResult } - private func evaluateDomains(updatedDomains: [String], persistedDomains: [String]) -> CookieDomainsOnUpdate { + private func evaluateDomains(updatedDomains: [String], persistedDomains: [String]) -> CookieDomainsOnUpdateDiagnostic { if persistedDomains.isEmpty { return .empty } else if updatedDomains.count < persistedDomains.count { diff --git a/Core/DataStoreWarmup.swift b/Core/DataStoreWarmup.swift new file mode 100644 index 0000000000..79087b9fc2 --- /dev/null +++ b/Core/DataStoreWarmup.swift @@ -0,0 +1,72 @@ +// +// DataStoreWarmup.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import WebKit + +/// WKWebsiteDataStore is basically non-functional until a web view has been instanciated and a page is successfully loaded. +public class DataStoreWarmup { + + public init() { } + + @MainActor + public func ensureReady() async { + await BlockingNavigationDelegate().loadInBackgroundWebView(url: URL(string: "about:blank")!) + } + +} + +private class BlockingNavigationDelegate: NSObject, WKNavigationDelegate { + + let finished = PassthroughSubject() + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + return .allow + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + finished.send() + } + + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + Pixel.fire(pixel: .webKitDidTerminateDuringWarmup) + // We won't get a `didFinish` if the webview crashes + finished.send() + } + + var cancellable: AnyCancellable? + func waitForLoad() async { + await withCheckedContinuation { continuation in + cancellable = finished.sink { _ in + continuation.resume() + } + } + } + + @MainActor + func loadInBackgroundWebView(url: URL) async { + let config = WKWebViewConfiguration.persistent() + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = self + let request = URLRequest(url: url) + webView.load(request) + await waitForLoad() + } + +} diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 49166d4662..7261d5bafa 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -394,7 +394,8 @@ extension Pixel { case webKitDidTerminate case webKitTerminationDidReloadCurrentTab - + case webKitDidTerminateDuringWarmup + case backgroundTaskSubmissionFailed case blankOverlayNotDismissed @@ -891,6 +892,7 @@ extension Pixel.Event { case .ampBlockingRulesCompilationFailed: return "m_debug_amp_rules_compilation_failed" case .webKitDidTerminate: return "m_d_wkt" + case .webKitDidTerminateDuringWarmup: return "m_d_webkit-terminated-during-warmup" case .webKitTerminationDidReloadCurrentTab: return "m_d_wktct" case .backgroundTaskSubmissionFailed: return "m_bt_rf" @@ -926,7 +928,7 @@ extension Pixel.Event { case .debugCannotClearObservationsDatabase: return "m_d_cannot_clear_observations_database" case .debugWebsiteDataStoresNotClearedMultiple: return "m_d_wkwebsitedatastoresnotcleared_multiple" case .debugWebsiteDataStoresNotClearedOne: return "m_d_wkwebsitedatastoresnotcleared_one" - case .debugCookieCleanupError: return "m_cookie_cleanup_error" + case .debugCookieCleanupError: return "m_d_cookie-cleanup-error" // MARK: Ad Attribution diff --git a/Core/PreserveLogins.swift b/Core/PreserveLogins.swift index ef1ab7b9b1..2c747a4abd 100644 --- a/Core/PreserveLogins.swift +++ b/Core/PreserveLogins.swift @@ -46,7 +46,6 @@ public class PreserveLogins { return allowedDomains.contains(where: { $0 == cookieDomain || ".\($0)" == cookieDomain || (cookieDomain.hasPrefix(".") && $0.hasSuffix(cookieDomain)) }) - } public func remove(domain: String) { diff --git a/Core/WebCacheManager.swift b/Core/WebCacheManager.swift index d9b91bb2f9..2ea7a18984 100644 --- a/Core/WebCacheManager.swift +++ b/Core/WebCacheManager.swift @@ -17,35 +17,13 @@ // limitations under the License. // -// swiftlint:disable file_length - import Common import WebKit import GRDB -public protocol WebCacheManagerCookieStore { - - func getAllCookies(_ completionHandler: @escaping ([HTTPCookie]) -> Void) - - func setCookie(_ cookie: HTTPCookie, completionHandler: (() -> Void)?) - - func delete(_ cookie: HTTPCookie, completionHandler: (() -> Void)?) - -} - -public protocol WebCacheManagerDataStore { - - var cookieStore: WebCacheManagerCookieStore? { get } - - func legacyClearingRemovingAllDataExceptCookies(completion: @escaping () -> Void) - - func preservedCookies(_ preservedLogins: PreserveLogins) async -> [HTTPCookie] - -} - -extension WebCacheManagerDataStore { +extension WKWebsiteDataStore { - public static func current(dataStoreIdManager: DataStoreIdManager = .shared) -> WebCacheManagerDataStore { + public static func current(dataStoreIdManager: DataStoreIdManager = .shared) -> WKWebsiteDataStore { if #available(iOS 17, *), let id = dataStoreIdManager.id { return WKWebsiteDataStore(forIdentifier: id) } else { @@ -55,96 +33,78 @@ extension WebCacheManagerDataStore { } +extension HTTPCookie { + + func matchesDomain(_ domain: String) -> Bool { + return self.domain == domain || (self.domain.hasPrefix(".") && domain.hasSuffix(self.domain)) + } + +} + +@MainActor public class WebCacheManager { public static var shared = WebCacheManager() - + private init() { } - + /// We save cookies from the current container rather than copying them to a new container because /// the container only persists cookies to disk when the web view is used. If the user presses the fire button /// twice then the fire proofed cookies will be lost and the user will be logged out any sites they're logged in to. public func consumeCookies(cookieStorage: CookieStorage = CookieStorage(), - httpCookieStore: WebCacheManagerCookieStore, - completion: @escaping () -> Void) { + httpCookieStore: WKHTTPCookieStore) async { + guard !cookieStorage.isConsumed else { return } let cookies = cookieStorage.cookies - - guard !cookies.isEmpty, !cookieStorage.isConsumed else { - completion() - return - } - - let group = DispatchGroup() - var consumedCookiesCount = 0 - for cookie in cookies { - group.enter() consumedCookiesCount += 1 - httpCookieStore.setCookie(cookie) { - group.leave() - } - } - - DispatchQueue.global(qos: .userInitiated).async { - group.wait() - cookieStorage.isConsumed = true - - DispatchQueue.main.async { - completion() - - if cookieStorage.cookies.count > 0 { - os_log("Error removing cookies: %d cookies left in CookieStorage", - log: .generalLog, type: .debug, cookieStorage.cookies.count) - - Pixel.fire(pixel: .debugCookieCleanupError, withAdditionalParameters: [ - PixelParameters.count: "\(cookieStorage.cookies.count)" - ]) - } - } + await httpCookieStore.setCookie(cookie) } + cookieStorage.isConsumed = true } - + public func removeCookies(forDomains domains: [String], - dataStore: WebCacheManagerDataStore, - completion: @escaping () -> Void) { - - guard let cookieStore = dataStore.cookieStore else { - completion() - return - } - - cookieStore.getAllCookies { cookies in - let group = DispatchGroup() - cookies.forEach { cookie in - if domains.contains(where: { self.isCookie(cookie, matchingDomain: $0) }) { - group.enter() - cookieStore.delete(cookie) { - group.leave() - } - } + dataStore: WKWebsiteDataStore) async { + + let timeoutTask = Task.detached { + try? await Task.sleep(interval: 5.0) + if !Task.isCancelled { + Pixel.fire(pixel: .cookieDeletionTimedOut, withAdditionalParameters: [ + PixelParameters.removeCookiesTimedOut: "1" + ]) } + } + + let cookieStore = dataStore.httpCookieStore + let cookies = await cookieStore.allCookies() + for cookie in cookies where domains.contains(where: { cookie.matchesDomain($0) }) { + await cookieStore.deleteCookie(cookie) + } + timeoutTask.cancel() + } + + public func clear(cookieStorage: CookieStorage = CookieStorage(), + logins: PreserveLogins = PreserveLogins.shared, + dataStoreIdManager: DataStoreIdManager = .shared) async { - DispatchQueue.global(qos: .userInitiated).async { - let result = group.wait(timeout: .now() + 5) - - if result == .timedOut { - Pixel.fire(pixel: .cookieDeletionTimedOut, withAdditionalParameters: [ - PixelParameters.removeCookiesTimedOut: "1" - ]) - } - - DispatchQueue.main.async { - completion() - } - } + var cookiesToUpdate = [HTTPCookie]() + if #available(iOS 17, *), dataStoreIdManager.hasId { + cookiesToUpdate += await containerBasedClearing(storeIdManager: dataStoreIdManager) ?? [] } + + // Perform legacy clearing to migrate to new container + cookiesToUpdate += await legacyDataClearing() ?? [] + cookieStorage.updateCookies(cookiesToUpdate, keepingPreservedLogins: logins) } + +} +extension WebCacheManager { + @available(iOS 17, *) - func checkForLeftBehindDataStores() async { + private func checkForLeftBehindDataStores() async { let ids = await WKWebsiteDataStore.allDataStoreIdentifiers if ids.count > 1 { Pixel.fire(pixel: .debugWebsiteDataStoresNotClearedMultiple) @@ -154,180 +114,36 @@ public class WebCacheManager { } @available(iOS 17, *) - func containerBasedClearing(cookieStorage: CookieStorage = CookieStorage(), - logins: PreserveLogins, - storeIdManager: DataStoreIdManager, - completion: @escaping () -> Void) { + private func containerBasedClearing(storeIdManager: DataStoreIdManager) async -> [HTTPCookie]? { + guard let containerId = storeIdManager.id else { return [] } + var dataStore: WKWebsiteDataStore? = WKWebsiteDataStore(forIdentifier: containerId) + let cookies = await dataStore?.httpCookieStore.allCookies() + dataStore = nil - guard let containerId = storeIdManager.id else { - completion() - return - } - - Task { @MainActor in - var dataStore: WKWebsiteDataStore? = WKWebsiteDataStore(forIdentifier: containerId) - let cookies = await dataStore?.preservedCookies(logins) - dataStore = nil - - let uuids = await WKWebsiteDataStore.allDataStoreIdentifiers - for uuid in uuids { - try? await WKWebsiteDataStore.remove(forIdentifier: uuid) - } - - await checkForLeftBehindDataStores() - - storeIdManager.allocateNewContainerId() - - cookieStorage.updateCookies(cookies ?? [], keepingPreservedLogins: logins) - - completion() + let uuids = await WKWebsiteDataStore.allDataStoreIdentifiers + for uuid in uuids { + try? await WKWebsiteDataStore.remove(forIdentifier: uuid) } - } - - public func clear(cookieStorage: CookieStorage = CookieStorage(), - logins: PreserveLogins = PreserveLogins.shared, - tabCountInfo: TabCountInfo? = nil, - dataStoreIdManager: DataStoreIdManager = .shared, - completion: @escaping () -> Void) { - - if #available(iOS 17, *), dataStoreIdManager.hasId { - containerBasedClearing(logins: logins, storeIdManager: dataStoreIdManager) { - // Perform legacy clearing anyway, just to be sure - self.legacyDataClearing(logins: logins) { _ in completion() } - } - } else { - legacyDataClearing(logins: logins) { cookies in - if #available(iOS 17, *) { - // From this point onwards... use containers - dataStoreIdManager.allocateNewContainerId() - Task { @MainActor in - cookieStorage.updateCookies(cookies, keepingPreservedLogins: logins) - completion() - } - } else { - completion() - } - } - } - + await checkForLeftBehindDataStores() + + storeIdManager.allocateNewContainerId() + return cookies } - // swiftlint:disable function_body_length - private func legacyDataClearing(logins: PreserveLogins, - tabCountInfo: TabCountInfo? = nil, - completion: @escaping ([HTTPCookie]) -> Void) { - - func keep(_ cookie: HTTPCookie) -> Bool { - return logins.isAllowed(cookieDomain: cookie.domain) || - URL.isDuckDuckGo(domain: cookie.domain) - } - - let dataStore = WKWebsiteDataStore.default() - dataStore.legacyClearingRemovingAllDataExceptCookies { - guard let cookieStore = dataStore.cookieStore else { - completion([]) - return - } - - let cookieClearingSummary = WebStoreCookieClearingSummary() - - cookieStore.getAllCookies { cookies in - let group = DispatchGroup() - let cookiesToRemove = cookies.filter { - !keep($0) - } - - let cookiesToKeep = cookies.filter { - keep($0) - } - - let protectedCookiesCount = cookies.count - cookiesToRemove.count - - cookieClearingSummary.storeInitialCount = cookies.count - cookieClearingSummary.storeProtectedCount = protectedCookiesCount - - for cookie in cookiesToRemove { - group.enter() - cookieStore.delete(cookie) { - group.leave() - } - } - - DispatchQueue.global(qos: .userInitiated).async { - let result = group.wait(timeout: .now() + 5) - - if result == .timedOut { - cookieClearingSummary.didStoreDeletionTimeOut = true - Pixel.fire(pixel: .cookieDeletionTimedOut, withAdditionalParameters: [ - PixelParameters.clearWebDataTimedOut: "1" - ]) - } - - // Remove legacy HTTPCookieStorage cookies - let storageCookies = HTTPCookieStorage.shared.cookies ?? [] - let storageCookiesToRemove = storageCookies.filter { - !logins.isAllowed(cookieDomain: $0.domain) && !URL.isDuckDuckGo(domain: $0.domain) - } - - let protectedStorageCookiesCount = storageCookies.count - storageCookiesToRemove.count - - cookieClearingSummary.storageInitialCount = storageCookies.count - cookieClearingSummary.storageProtectedCount = protectedStorageCookiesCount - - for storageCookie in storageCookiesToRemove { - HTTPCookieStorage.shared.deleteCookie(storageCookie) - } - - self.removeObservationsData() - - self.validateLegacyClearing(for: cookieStore, summary: cookieClearingSummary, tabCountInfo: tabCountInfo) - - DispatchQueue.main.async { - completion(cookiesToKeep) - } - } - } - } - - } - // swiftlint:enable function_body_length - - private func validateLegacyClearing(for cookieStore: WebCacheManagerCookieStore, summary: WebStoreCookieClearingSummary, tabCountInfo: TabCountInfo?) { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - cookieStore.getAllCookies { cookiesAfterCleaning in - let storageCookiesAfterCleaning = HTTPCookieStorage.shared.cookies ?? [] - - summary.storeAfterDeletionCount = cookiesAfterCleaning.count - summary.storageAfterDeletionCount = storageCookiesAfterCleaning.count - - let cookieStoreDiff = cookiesAfterCleaning.count - summary.storeProtectedCount - let cookieStorageDiff = storageCookiesAfterCleaning.count - summary.storageProtectedCount - - summary.storeAfterDeletionDiffCount = cookieStoreDiff - summary.storageAfterDeletionDiffCount = cookieStorageDiff - - if cookieStoreDiff + cookieStorageDiff > 0 { - os_log("Error removing cookies: %d cookies left in WKHTTPCookieStore, %d cookies left in HTTPCookieStorage", - log: .generalLog, type: .debug, cookieStoreDiff, cookieStorageDiff) - - var parameters = summary.makeDictionaryRepresentation() - - if let tabCountInfo = tabCountInfo { - parameters.merge(tabCountInfo.makeDictionaryRepresentation(), uniquingKeysWith: { _, new in new }) - } - - Pixel.fire(pixel: .cookieDeletionLeftovers, - withAdditionalParameters: parameters) - } + private func legacyDataClearing() async -> [HTTPCookie]? { + let timeoutTask = Task.detached { + if !Task.isCancelled { + Pixel.fire(pixel: .cookieDeletionTimedOut, withAdditionalParameters: [ + PixelParameters.clearWebDataTimedOut: "1" + ]) } } - } - - /// The Fire Button does not delete the user's DuckDuckGo search settings, which are saved as cookies. Removing these cookies would reset them and have undesired consequences, i.e. changing the theme, default language, etc. - /// The Fire Button also does not delete temporary cookies associated with 'surveys.duckduckgo.com'. When we launch surveys to help us understand issues that impact users over time, we use this cookie to temporarily store anonymous survey answers, before deleting the cookie. Cookie storage duration is communicated to users before they opt to submit survey answers. - /// These cookies are not stored in a personally identifiable way. For example, the large size setting is stored as 's=l.' More info in https://duckduckgo.com/privacy - public func isCookie(_ cookie: HTTPCookie, matchingDomain domain: String) -> Bool { - return cookie.domain == domain || (cookie.domain.hasPrefix(".") && domain.hasSuffix(cookie.domain)) + let dataStore = WKWebsiteDataStore.default() + let cookies = await dataStore.httpCookieStore.allCookies() + await dataStore.removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), modifiedSince: .distantPast) + self.removeObservationsData() + timeoutTask.cancel() + return cookies } private func removeObservationsData() { @@ -356,7 +172,6 @@ public class WebCacheManager { return pool } - private func removeObservationsData(from pool: DatabasePool) { do { try pool.write { database in @@ -374,88 +189,3 @@ public class WebCacheManager { } } - -extension WKHTTPCookieStore: WebCacheManagerCookieStore { - -} - -extension WKWebsiteDataStore: WebCacheManagerDataStore { - - @MainActor - public func preservedCookies(_ preservedLogins: PreserveLogins) async -> [HTTPCookie] { - let allCookies = await self.httpCookieStore.allCookies() - return allCookies.filter { - URL.isDuckDuckGo(domain: $0.domain) || preservedLogins.isAllowed(cookieDomain: $0.domain) - } - } - - public var cookieStore: WebCacheManagerCookieStore? { - return self.httpCookieStore - } - - public func legacyClearingRemovingAllDataExceptCookies(completion: @escaping () -> Void) { - var types = WKWebsiteDataStore.allWebsiteDataTypes() - - // Force the HSTS, Media and Alt services cache to clear when using the Fire button. - // https://github.com/WebKit/WebKit/blob/0f73b4d4350c707763146ff0501ab62425c902d6/Source/WebKit/UIProcess/API/Cocoa/WKWebsiteDataRecord.mm#L47 - types.insert("_WKWebsiteDataTypeHSTSCache") - types.insert("_WKWebsiteDataTypeMediaKeys") - types.insert("_WKWebsiteDataTypeAlternativeServices") - types.insert("_WKWebsiteDataTypeSearchFieldRecentSearches") - types.insert("_WKWebsiteDataTypeResourceLoadStatistics") - types.insert("_WKWebsiteDataTypeCredentials") - types.insert("_WKWebsiteDataTypeAdClickAttributions") - types.insert("_WKWebsiteDataTypePrivateClickMeasurements") - - types.remove(WKWebsiteDataTypeCookies) - - removeData(ofTypes: types, - modifiedSince: Date.distantPast, - completionHandler: completion) - } - -} - -final class WebStoreCookieClearingSummary { - var storeInitialCount: Int = 0 - var storeProtectedCount: Int = 0 - var didStoreDeletionTimeOut: Bool = false - var storageInitialCount: Int = 0 - var storageProtectedCount: Int = 0 - - var storeAfterDeletionCount: Int = 0 - var storageAfterDeletionCount: Int = 0 - var storeAfterDeletionDiffCount: Int = 0 - var storageAfterDeletionDiffCount: Int = 0 - - func makeDictionaryRepresentation() -> [String: String] { - [PixelParameters.storeInitialCount: "\(storeInitialCount)", - PixelParameters.storeProtectedCount: "\(storeProtectedCount)", - PixelParameters.didStoreDeletionTimeOut: didStoreDeletionTimeOut ? "true" : "false", - PixelParameters.storageInitialCount: "\(storageInitialCount)", - PixelParameters.storageProtectedCount: "\(storageProtectedCount)", - PixelParameters.storeAfterDeletionCount: "\(storeAfterDeletionCount)", - PixelParameters.storageAfterDeletionCount: "\(storageAfterDeletionCount)", - PixelParameters.storeAfterDeletionDiffCount: "\(storeAfterDeletionDiffCount)", - PixelParameters.storageAfterDeletionDiffCount: "\(storageAfterDeletionDiffCount)"] - } -} - -public final class TabCountInfo { - var tabsModelCount: Int = 0 - var tabControllerCacheCount: Int = 0 - - public init() { } - - public init(tabsModelCount: Int, tabControllerCacheCount: Int) { - self.tabsModelCount = tabsModelCount - self.tabControllerCacheCount = tabControllerCacheCount - } - - func makeDictionaryRepresentation() -> [String: String] { - [PixelParameters.tabsModelCount: "\(tabsModelCount)", - PixelParameters.tabControllerCacheCount: "\(tabControllerCacheCount)"] - } -} - -// swiftlint:enable file_length diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index e5c8134a66..7f559488b0 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -440,6 +440,7 @@ 8590CB632684F10F0089F6BF /* ContentBlockerProtectionStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590CB622684F10F0089F6BF /* ContentBlockerProtectionStoreTests.swift */; }; 8590CB67268A2E520089F6BF /* RootDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590CB66268A2E520089F6BF /* RootDebugViewController.swift */; }; 8590CB69268A4E190089F6BF /* DebugEtagStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590CB68268A4E190089F6BF /* DebugEtagStorage.swift */; }; + 8596C30D2B7EB1800058EF90 /* DataStoreWarmup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8596C30C2B7EB1800058EF90 /* DataStoreWarmup.swift */; }; 8598F67B2405EB8D00FBC70C /* KeyboardSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8598F6792405EB8600FBC70C /* KeyboardSettingsTests.swift */; }; 8599690F29D2F1C100DBF9FA /* DDGSync in Frameworks */ = {isa = PBXBuildFile; productRef = 8599690E29D2F1C100DBF9FA /* DDGSync */; }; 85A1B3B220C6CD9900C18F15 /* CookieStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A1B3B120C6CD9900C18F15 /* CookieStorage.swift */; }; @@ -1527,6 +1528,7 @@ 8590CB622684F10F0089F6BF /* ContentBlockerProtectionStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentBlockerProtectionStoreTests.swift; sourceTree = ""; }; 8590CB66268A2E520089F6BF /* RootDebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootDebugViewController.swift; sourceTree = ""; }; 8590CB68268A4E190089F6BF /* DebugEtagStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugEtagStorage.swift; sourceTree = ""; }; + 8596C30C2B7EB1800058EF90 /* DataStoreWarmup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStoreWarmup.swift; sourceTree = ""; }; 8598F6792405EB8600FBC70C /* KeyboardSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardSettingsTests.swift; sourceTree = ""; }; 85A1B3B120C6CD9900C18F15 /* CookieStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieStorage.swift; sourceTree = ""; }; 85A313962028E78A00327D00 /* release_notes.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = release_notes.txt; path = fastlane/metadata/default/release_notes.txt; sourceTree = ""; }; @@ -5078,6 +5080,7 @@ F1A886771F29394E0096251E /* WebCacheManager.swift */, 83004E7F2193BB8200DA013C /* WKNavigationExtension.swift */, 830381BF1F850AAF00863075 /* WKWebViewConfigurationExtension.swift */, + 8596C30C2B7EB1800058EF90 /* DataStoreWarmup.swift */, ); name = Web; sourceTree = ""; @@ -7097,6 +7100,7 @@ 9833913727AC400800DAF119 /* AppTrackerDataSetProvider.swift in Sources */, 83004E802193BB8200DA013C /* WKNavigationExtension.swift in Sources */, 853A717620F62FE800FE60BC /* Pixel.swift in Sources */, + 8596C30D2B7EB1800058EF90 /* DataStoreWarmup.swift in Sources */, 4B470EDB299C4FB20086EBDC /* AppTrackingProtectionListViewModel.swift in Sources */, F41C2DA526C1975E00F9A760 /* BookmarksCoreDataStorage.swift in Sources */, 9876B75E2232B36900D81D9F /* TabInstrumentation.swift in Sources */, diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index c9bfe95a32..8e81eb22af 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -287,7 +287,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } autoClear = AutoClear(worker: main) - autoClear?.applicationDidLaunch() AppDependencyProvider.shared.voiceSearchHelper.migrateSettingsFlagIfNecessary() @@ -524,7 +523,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Task { @MainActor in await beginAuthentication() - autoClear?.applicationWillMoveToForeground() + await autoClear?.applicationWillMoveToForeground() showKeyboardIfSettingOn = true syncService.scheduler.resumeSyncQueue() } @@ -581,11 +580,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { mainViewController?.clearNavigationStack() } - autoClear?.applicationWillMoveToForeground() - showKeyboardIfSettingOn = false + Task { @MainActor in + await autoClear?.applicationWillMoveToForeground() + showKeyboardIfSettingOn = false - if !handleAppDeepLink(app, mainViewController, url) { - mainViewController?.loadUrlInNewTab(url, reuseExisting: true, inheritedAttribution: nil) + if !handleAppDeepLink(app, mainViewController, url) { + mainViewController?.loadUrlInNewTab(url, reuseExisting: true, inheritedAttribution: nil) + } } return true @@ -695,19 +696,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func handleShortCutItem(_ shortcutItem: UIApplicationShortcutItem) { os_log("Handling shortcut item: %s", log: .generalLog, type: .debug, shortcutItem.type) - autoClear?.applicationWillMoveToForeground() + Task { @MainActor in + + await autoClear?.applicationWillMoveToForeground() - if shortcutItem.type == ShortcutKey.clipboard, let query = UIPasteboard.general.string { - mainViewController?.clearNavigationStack() - mainViewController?.loadQueryInNewTab(query) - return - } + if shortcutItem.type == ShortcutKey.clipboard, let query = UIPasteboard.general.string { + mainViewController?.clearNavigationStack() + mainViewController?.loadQueryInNewTab(query) + return + } #if NETWORK_PROTECTION - if shortcutItem.type == ShortcutKey.openVPNSettings { - presentNetworkProtectionStatusSettingsModal() - } + if shortcutItem.type == ShortcutKey.openVPNSettings { + presentNetworkProtectionStatusSettingsModal() + } #endif + + } } private func removeEmailWaitlistState() { diff --git a/DuckDuckGo/AutoClear.swift b/DuckDuckGo/AutoClear.swift index 79359dbc4f..eca57dce95 100644 --- a/DuckDuckGo/AutoClear.swift +++ b/DuckDuckGo/AutoClear.swift @@ -23,8 +23,9 @@ import UIKit protocol AutoClearWorker { func clearNavigationStack() - func forgetData() + func forgetData() async func forgetTabs() + func clearDataFinished(_: AutoClear) } class AutoClear { @@ -42,25 +43,19 @@ class AutoClear { self.worker = worker } - private func clearData() { + @MainActor + private func clearData() async { guard let settings = AutoClearSettingsModel(settings: appSettings) else { return } - if settings.action.contains(.clearData) { - worker.forgetData() - } - if settings.action.contains(.clearTabs) { worker.forgetTabs() } - } - - func applicationDidLaunch() { - guard let settings = AutoClearSettingsModel(settings: appSettings) else { return } - - // Note: for startup, we clear only Data, as TabsModel is cleared on load + if settings.action.contains(.clearData) { - worker.forgetData() + await worker.forgetData() } + + worker.clearDataFinished(self) } /// Note: function is parametrised because of tests. @@ -85,13 +80,14 @@ class AutoClear { } } - func applicationWillMoveToForeground() { + @MainActor + func applicationWillMoveToForeground() async { guard isClearingEnabled, let timestamp = timestamp, shouldClearData(elapsedTime: Date().timeIntervalSince1970 - timestamp) else { return } worker.clearNavigationStack() - clearData() + await clearData() self.timestamp = nil } } diff --git a/DuckDuckGo/CookieDebugViewController.swift b/DuckDuckGo/CookieDebugViewController.swift index 0ddb0960d4..ed2b84fb16 100644 --- a/DuckDuckGo/CookieDebugViewController.swift +++ b/DuckDuckGo/CookieDebugViewController.swift @@ -46,9 +46,13 @@ class CookieDebugViewController: UITableViewController { } private func fetchCookies() { - WKWebsiteDataStore.default().cookieStore?.getAllCookies(displayCookies) + Task { @MainActor in + let dataStore = WKWebsiteDataStore.current() + displayCookies(cookies: await dataStore.httpCookieStore.allCookies()) + } } + @MainActor private func displayCookies(cookies: [HTTPCookie]) { self.loaded = true diff --git a/DuckDuckGo/FireButtonAnimator.swift b/DuckDuckGo/FireButtonAnimator.swift index e153dcc30c..ef8c2af543 100644 --- a/DuckDuckGo/FireButtonAnimator.swift +++ b/DuckDuckGo/FireButtonAnimator.swift @@ -110,20 +110,24 @@ class FireButtonAnimator { object: nil) } - func animate(onAnimationStart: @escaping () -> Void, onTransitionCompleted: @escaping () -> Void, completion: @escaping () -> Void) { - + func animate(onAnimationStart: @escaping () async -> Void, onTransitionCompleted: @escaping () async -> Void, completion: @escaping () async -> Void) { + guard let window = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first, let snapshot = window.snapshotView(afterScreenUpdates: false) else { - onAnimationStart() - onTransitionCompleted() - completion() + Task { @MainActor in + await onAnimationStart() + await onTransitionCompleted() + await completion() + } return } guard let composition = preLoadedComposition else { - onAnimationStart() - onTransitionCompleted() - completion() + Task { @MainActor in + await onAnimationStart() + await onTransitionCompleted() + await completion() + } return } @@ -141,16 +145,22 @@ class FireButtonAnimator { let delay = duration * currentAnimation.transition DispatchQueue.main.asyncAfter(deadline: .now() + delay) { snapshot.removeFromSuperview() - onTransitionCompleted() + Task { @MainActor in + await onTransitionCompleted() + } } animationView.play(fromProgress: 0, toProgress: 1) { [weak animationView] _ in animationView?.removeFromSuperview() - completion() + Task { @MainActor in + await completion() + } } DispatchQueue.main.async { - onAnimationStart() + Task { @MainActor in + await onAnimationStart() + } } } diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 8799d48c8b..c4b19b187d 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -154,6 +154,7 @@ class MainViewController: UIViewController { var postClear: (() -> Void)? var clearInProgress = false + var dataStoreWarmup: DataStoreWarmup? = DataStoreWarmup() required init?(coder: NSCoder) { fatalError("Use init?(code:") @@ -205,11 +206,9 @@ class MainViewController: UIViewController { bindSyncService() } #endif - - fileprivate var tabCountInfo: TabCountInfo? - + func loadFindInPage() { - + let view = FindInPageView.loadFromXib() self.view.addSubview(view) @@ -682,11 +681,18 @@ class MainViewController: UIViewController { let isPadDevice = UIDevice.current.userInterfaceIdiom == .pad let tabsModel: TabsModel - let shouldClearTabsModelOnStartup = AutoClearSettingsModel(settings: appSettings) != nil - if shouldClearTabsModelOnStartup { + if let settings = AutoClearSettingsModel(settings: appSettings) { + // This needs to be refactored so that tabs model is injected and cleared before view did load, + // but for now, ensure this happens in the right order by clearing data here too, if needed. tabsModel = TabsModel(desktop: isPadDevice) tabsModel.save() previewsSource.removeAllPreviews() + + if settings.action.contains(.clearData) { + Task { @MainActor in + await forgetData() + } + } } else { if let storedModel = TabsModel.get() { // Save new model in case of migration @@ -2158,6 +2164,7 @@ extension MainViewController: AutoClearWorker { } func forgetTabs() { + omniBar.resignFirstResponder() findInPageView?.done() tabManager.removeAll() } @@ -2169,33 +2176,42 @@ extension MainViewController: AutoClearWorker { swipeTabsCoordinator?.refresh(tabsModel: tabManager.model) Favicons.shared.clearCache(.tabs) } - - func forgetData() { + + @MainActor + func clearDataFinished(_: AutoClear) { + refreshUIAfterClear() + } + + func forgetData() async { guard !clearInProgress else { assertionFailure("Shouldn't get called multiple times") return } clearInProgress = true + + // This needs to happen only once per app launch + if let dataStoreWarmup { + await dataStoreWarmup.ensureReady() + self.dataStoreWarmup = nil + } + URLSession.shared.configuration.urlCache?.removeAllCachedResponses() let pixel = TimedPixel(.forgetAllDataCleared) - WebCacheManager.shared.clear(tabCountInfo: tabCountInfo) { - pixel.fire(withAdditionalParameters: [PixelParameters.tabCount: "\(self.tabManager.count)"]) + await WebCacheManager.shared.clear() + pixel.fire(withAdditionalParameters: [PixelParameters.tabCount: "\(self.tabManager.count)"]) - AutoconsentManagement.shared.clearCache() - DaxDialogs.shared.clearHeldURLData() + AutoconsentManagement.shared.clearCache() + DaxDialogs.shared.clearHeldURLData() - if self.syncService.authState == .inactive { - self.bookmarksDatabaseCleaner?.cleanUpDatabaseNow() - } - - self.refreshUIAfterClear() - self.clearInProgress = false - - self.postClear?() - self.postClear = nil + if self.syncService.authState == .inactive { + self.bookmarksDatabaseCleaner?.cleanUpDatabaseNow() } + self.clearInProgress = false + + self.postClear?() + self.postClear = nil } func stopAllOngoingDownloads() { @@ -2206,22 +2222,22 @@ extension MainViewController: AutoClearWorker { let spid = Instruments.shared.startTimedEvent(.clearingData) Pixel.fire(pixel: .forgetAllExecuted) - self.tabCountInfo = tabManager.makeTabCountInfo() - tabManager.prepareAllTabsExceptCurrentForDataClearing() fireButtonAnimator.animate { self.tabManager.prepareCurrentTabForDataClearing() self.stopAllOngoingDownloads() self.forgetTabs() - self.forgetData() + await self.forgetData() + Instruments.shared.endTimedEvent(for: spid) DaxDialogs.shared.resumeRegularFlow() } onTransitionCompleted: { ActionMessageView.present(message: UserText.actionForgetAllDone, presentationLocation: .withBottomBar(andAddressBarBottom: self.appSettings.currentAddressBarPosition.isBottom)) transitionCompletion?() + self.refreshUIAfterClear() } completion: { - Instruments.shared.endTimedEvent(for: spid) + // Ideally this should happen once data clearing has finished AND the animation is finished if showNextDaxDialog { self.homeController?.showNextDaxDialog() } else if KeyboardSettings().onNewTab { diff --git a/DuckDuckGo/PreserveLoginsSettingsViewController.swift b/DuckDuckGo/PreserveLoginsSettingsViewController.swift index d243ed1315..027099470a 100644 --- a/DuckDuckGo/PreserveLoginsSettingsViewController.swift +++ b/DuckDuckGo/PreserveLoginsSettingsViewController.swift @@ -142,11 +142,10 @@ class PreserveLoginsSettingsViewController: UITableViewController { override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { guard editingStyle == .delete else { return } - + let domain = model.remove(at: indexPath.row) PreserveLogins.shared.remove(domain: domain) Favicons.shared.removeFireproofFavicon(forDomain: domain) - WebCacheManager.shared.removeCookies(forDomains: [domain], dataStore: WKWebsiteDataStore.current()) { } if self.model.isEmpty { self.endEditing() @@ -154,6 +153,10 @@ class PreserveLoginsSettingsViewController: UITableViewController { } else { tableView.deleteRows(at: [indexPath], with: .automatic) } + + Task { @MainActor in + await WebCacheManager.shared.removeCookies(forDomains: [domain], dataStore: WKWebsiteDataStore.current()) + } } override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { @@ -209,10 +212,12 @@ class PreserveLoginsSettingsViewController: UITableViewController { PreserveLoginsAlert.showClearAllAlert(usingController: self, cancelled: { [weak self] in self?.refreshModel() }, confirmed: { [weak self] in - WebCacheManager.shared.removeCookies(forDomains: self?.model ?? [], dataStore: WKWebsiteDataStore.current()) { } - PreserveLogins.shared.clearAll() - self?.refreshModel() - self?.endEditing() + Task { @MainActor in + await WebCacheManager.shared.removeCookies(forDomains: self?.model ?? [], dataStore: WKWebsiteDataStore.current()) + PreserveLogins.shared.clearAll() + self?.refreshModel() + self?.endEditing() + } }) } diff --git a/DuckDuckGo/RootDebugViewController.swift b/DuckDuckGo/RootDebugViewController.swift index 485c269970..80fbf7f6f8 100644 --- a/DuckDuckGo/RootDebugViewController.swift +++ b/DuckDuckGo/RootDebugViewController.swift @@ -255,7 +255,7 @@ class DiagnosticReportDataSource: UIActivityItemProvider { let group = DispatchGroup() group.enter() DispatchQueue.main.async { - WKWebsiteDataStore.default().cookieStore?.getAllCookies { httpCookies in + WKWebsiteDataStore.current().httpCookieStore.getAllCookies { httpCookies in cookies = httpCookies group.leave() } diff --git a/DuckDuckGo/TabManager.swift b/DuckDuckGo/TabManager.swift index 0ca7f51639..39e2629895 100644 --- a/DuckDuckGo/TabManager.swift +++ b/DuckDuckGo/TabManager.swift @@ -225,7 +225,6 @@ class TabManager { model.clearAll() for controller in tabControllerCache { removeFromCache(controller) - // controller.prepareForDataClearing() } save() } @@ -326,11 +325,3 @@ extension TabManager { } } } - -extension TabManager { - - func makeTabCountInfo() -> TabCountInfo { - TabCountInfo(tabsModelCount: model.count, - tabControllerCacheCount: tabControllerCache.count) - } -} diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 21e2e3925b..b0c7df98ea 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -489,16 +489,11 @@ class TabViewController: UIViewController { } } - webView.configuration.websiteDataStore.fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { _ in - guard let cookieStore = self.webView.configuration.websiteDataStore.cookieStore else { - doLoad() - return - } - - WebCacheManager.shared.consumeCookies(httpCookieStore: cookieStore) { [weak self] in - guard let strongSelf = self else { return } - doLoad() - } + Task { @MainActor in + await webView.configuration.websiteDataStore.dataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) + let cookieStore = webView.configuration.websiteDataStore.httpCookieStore + await WebCacheManager.shared.consumeCookies(httpCookieStore: cookieStore) + doLoad() } } diff --git a/DuckDuckGoTests/AutoClearTests.swift b/DuckDuckGoTests/AutoClearTests.swift index 71a2a4dd1c..2edb1e52ee 100644 --- a/DuckDuckGoTests/AutoClearTests.swift +++ b/DuckDuckGoTests/AutoClearTests.swift @@ -28,7 +28,8 @@ class AutoClearTests: XCTestCase { var clearNavigationStackInvocationCount = 0 var forgetDataInvocationCount = 0 var forgetTabsInvocationCount = 0 - + var clearDataFinishedInvocationCount = 0 + func clearNavigationStack() { clearNavigationStackInvocationCount += 1 } @@ -40,6 +41,10 @@ class AutoClearTests: XCTestCase { func forgetTabs() { forgetTabsInvocationCount += 1 } + + func clearDataFinished(_: AutoClear) { + clearDataFinishedInvocationCount += 1 + } } private var worker: MockWorker! @@ -51,59 +56,23 @@ class AutoClearTests: XCTestCase { worker = MockWorker() logic = AutoClear(worker: worker) } - - func testWhenModeIsSetToCleanDataThenDataIsCleared() { - let appSettings = AppUserDefaults() - appSettings.autoClearAction = .clearData - appSettings.autoClearTiming = .termination - - logic.applicationDidLaunch() - - XCTAssertEqual(worker.forgetDataInvocationCount, 1) - XCTAssertEqual(worker.forgetTabsInvocationCount, 0) - } - - func testWhenModeIsSetToCleanTabsAndDataThenDataIsCleared() { - let appSettings = AppUserDefaults() - appSettings.autoClearAction = [.clearData, .clearTabs] - appSettings.autoClearTiming = .termination - - logic.applicationDidLaunch() - - XCTAssertEqual(worker.forgetDataInvocationCount, 1) - - // Tabs are cleared when loading TabsModel for the first time - XCTAssertEqual(worker.forgetTabsInvocationCount, 0) - } - - func testWhenModeIsNotSetThenNothingIsCleared() { - let appSettings = AppUserDefaults() - appSettings.autoClearAction = [] - appSettings.autoClearTiming = .termination - - logic.applicationDidLaunch() - - XCTAssertEqual(worker.forgetDataInvocationCount, 0) - XCTAssertEqual(worker.forgetTabsInvocationCount, 0) - } - - func testWhenTimingIsSetToTerminationThenOnlyRestartClearsData() { + + // Note: applicationDidLaunch based clearing has moved to "configureTabManager" function of + // MainViewController to ensure that tabs are removed before the data is cleared. + + func testWhenTimingIsSetToTerminationThenOnlyRestartClearsData() async { let appSettings = AppUserDefaults() appSettings.autoClearAction = .clearData appSettings.autoClearTiming = .termination - logic.applicationWillMoveToForeground() + await logic.applicationWillMoveToForeground() logic.applicationDidEnterBackground() XCTAssertEqual(worker.clearNavigationStackInvocationCount, 0) XCTAssertEqual(worker.forgetDataInvocationCount, 0) - - logic.applicationDidLaunch() - - XCTAssertEqual(worker.forgetDataInvocationCount, 1) } - func testWhenDesiredTimingIsSetThenDataIsClearedOnceTimeHasElapsed() { + func testWhenDesiredTimingIsSetThenDataIsClearedOnceTimeHasElapsed() async { let appSettings = AppUserDefaults() appSettings.autoClearAction = .clearData @@ -117,13 +86,13 @@ class AutoClearTests: XCTestCase { appSettings.autoClearTiming = timing logic.applicationDidEnterBackground(Date().timeIntervalSince1970 - delay + 1) - logic.applicationWillMoveToForeground() + await logic.applicationWillMoveToForeground() XCTAssertEqual(worker.clearNavigationStackInvocationCount, iterationCount) XCTAssertEqual(worker.forgetDataInvocationCount, iterationCount) logic.applicationDidEnterBackground(Date().timeIntervalSince1970 - delay - 1) - logic.applicationWillMoveToForeground() + await logic.applicationWillMoveToForeground() iterationCount += 1 XCTAssertEqual(worker.clearNavigationStackInvocationCount, iterationCount) diff --git a/DuckDuckGoTests/CookieStorageTests.swift b/DuckDuckGoTests/CookieStorageTests.swift index 4a78e3e006..07927f155c 100644 --- a/DuckDuckGoTests/CookieStorageTests.swift +++ b/DuckDuckGoTests/CookieStorageTests.swift @@ -35,8 +35,25 @@ public class CookieStorageTests: XCTestCase { let defaults = UserDefaults(suiteName: Self.userDefaultsSuiteName)! defaults.removePersistentDomain(forName: Self.userDefaultsSuiteName) storage = CookieStorage(userDefaults: defaults) + storage.isConsumed = true logins.clearAll() } + + func testWhenDomainRemovesAllCookesThenTheyAreClearedFromPersisted() { + logins.addToAllowed(domain: "example.com") + + XCTAssertEqual(storage.updateCookies([ + make("example.com", name: "x", value: "1"), + ], keepingPreservedLogins: logins), .empty) + + XCTAssertEqual(1, storage.cookies.count) + + storage.isConsumed = true + storage.updateCookies([], keepingPreservedLogins: logins) + + XCTAssertEqual(0, storage.cookies.count) + + } func testWhenUpdatedThenDuckDuckGoCookiesAreNotRemoved() { storage.updateCookies([ @@ -45,14 +62,19 @@ public class CookieStorageTests: XCTestCase { XCTAssertEqual(1, storage.cookies.count) + storage.isConsumed = true storage.updateCookies([ + make("duckduckgo.com", name: "x", value: "1"), make("test.com", name: "x", value: "1"), ], keepingPreservedLogins: logins) XCTAssertEqual(2, storage.cookies.count) + storage.isConsumed = true storage.updateCookies([ make("usedev1.duckduckgo.com", name: "x", value: "1"), + make("duckduckgo.com", name: "x", value: "1"), + make("test.com", name: "x", value: "1"), ], keepingPreservedLogins: logins) XCTAssertEqual(3, storage.cookies.count) @@ -62,9 +84,6 @@ public class CookieStorageTests: XCTestCase { func testWhenUpdatedThenCookiesWithFutureExpirationAreNotRemoved() { storage.updateCookies([ make("test.com", name: "x", value: "1", expires: .distantFuture), - ], keepingPreservedLogins: logins) - - storage.updateCookies([ make("example.com", name: "x", value: "1"), ], keepingPreservedLogins: logins) @@ -80,6 +99,7 @@ public class CookieStorageTests: XCTestCase { ] XCTAssertEqual(1, storage.cookies.count) + storage.isConsumed = true storage.updateCookies([ make("example.com", name: "x", value: "1"), ], keepingPreservedLogins: logins) @@ -108,6 +128,7 @@ public class CookieStorageTests: XCTestCase { logins.remove(domain: "test.com") + storage.isConsumed = true storage.updateCookies([ make("example.com", name: "x", value: "1"), ], keepingPreservedLogins: logins) @@ -117,9 +138,9 @@ public class CookieStorageTests: XCTestCase { XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "example.com" })) } - func testWhenStorageInitialiedThenItIsEmptyAndConsumedIsFalse() { + func testWhenStorageInitialiedThenItIsEmptyAndIsReadyToBeUpdated() { XCTAssertEqual(0, storage.cookies.count) - XCTAssertEqual(false, storage.isConsumed) + XCTAssertTrue(storage.isConsumed) } func testWhenStorageIsUpdatedThenConsumedIsResetToFalse() { @@ -149,26 +170,12 @@ public class CookieStorageTests: XCTestCase { XCTAssertEqual(1, storage.cookies.count) } - func testWhenStorageIsUpdatedThenExistingCookiesAreUnaffected() { - storage.updateCookies([ - make("test.com", name: "x", value: "1"), - make("example.com", name: "x", value: "1"), - ], keepingPreservedLogins: logins) - - storage.updateCookies([ - make("example.com", name: "x", value: "2"), - ], keepingPreservedLogins: logins) - - XCTAssertEqual(2, storage.cookies.count) - XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "test.com" && $0.name == "x" && $0.value == "1" })) - XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "example.com" && $0.name == "x" && $0.value == "2" })) - } - func testWhenStorageHasMatchingDOmainThenUpdatingReplacesCookies() { storage.updateCookies([ make("test.com", name: "x", value: "1") ], keepingPreservedLogins: logins) + storage.isConsumed = true storage.updateCookies([ make("test.com", name: "x", value: "2"), make("test.com", name: "y", value: "3"), @@ -180,6 +187,19 @@ public class CookieStorageTests: XCTestCase { XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "test.com" && $0.name == "y" && $0.value == "3" })) } + func testWhenStorageUpdatedAndNotConsumedThenNothingHappens() { + storage.updateCookies([ + make("test.com", name: "x", value: "1") + ], keepingPreservedLogins: logins) + + storage.updateCookies([ + make("example.com", name: "y", value: "3"), + ], keepingPreservedLogins: logins) + + XCTAssertEqual(1, storage.cookies.count) + XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "test.com" && $0.name == "x" && $0.value == "1" })) + } + func make(_ domain: String, name: String, value: String, expires: Date? = nil) -> HTTPCookie { logins.addToAllowed(domain: domain) return HTTPCookie(properties: [ diff --git a/DuckDuckGoTests/DownloadManagerTests.swift b/DuckDuckGoTests/DownloadManagerTests.swift index b4d428d268..86367d7144 100644 --- a/DuckDuckGoTests/DownloadManagerTests.swift +++ b/DuckDuckGoTests/DownloadManagerTests.swift @@ -33,7 +33,21 @@ class DownloadManagerTests: XCTestCase { downloadManagerTestsHelper.deleteAllFiles() } - func testNotificationTemporaryPKPassDownload() { + func testWhenIPadThenPKPassThenDownloadIsNotTemporary() { + guard UIDevice.current.userInterfaceIdiom == .pad else { return } + + let notificationCenter = NotificationCenter() + let downloadManager = DownloadManager(notificationCenter) + + let sessionSetup = MockSessionSetup(mimeType: "application/vnd.apple.pkpass", downloadManager: downloadManager) + + let download = downloadManager.makeDownload(navigationResponse: sessionSetup.response, downloadSession: sessionSetup.session)! + XCTAssertFalse(download.temporary, "Download should be not temporary") + } + + func testNotificationTemporaryPKPassDownloadOnPhone() { + guard UIDevice.current.userInterfaceIdiom == .phone else { return } + let notificationCenter = NotificationCenter() let downloadManager = DownloadManager(notificationCenter) diff --git a/DuckDuckGoTests/FireButtonReferenceTests.swift b/DuckDuckGoTests/FireButtonReferenceTests.swift index 6e1ea46555..5b45c0a642 100644 --- a/DuckDuckGoTests/FireButtonReferenceTests.swift +++ b/DuckDuckGoTests/FireButtonReferenceTests.swift @@ -44,7 +44,10 @@ final class FireButtonReferenceTests: XCTestCase { return url.host! } - func testClearData() async throws { + @MainActor + func testClearDataUsingLegacyContainer() async throws { + // Using WKWebsiteDataStore(forIdentifier:) doesn't persist cookies in a testable way, so use the legacy container here. + let preservedLogins = PreserveLogins.shared preservedLogins.clearAll() @@ -57,23 +60,22 @@ final class FireButtonReferenceTests: XCTestCase { let referenceTests = testData.fireButtonFireproofing.tests.filter { $0.exceptPlatforms.contains("ios-browser") == false } - + let cookieStorage = CookieStorage() + let idManager = DataStoreIdManager() + XCTAssertFalse(idManager.hasId) + for test in referenceTests { let cookie = try XCTUnwrap(cookie(for: test)) + + let cookieStore = WKWebsiteDataStore.default().httpCookieStore + await cookieStore.setCookie(cookie) - // Set directly to avoid logic to remove non-preserved cookies - cookieStorage.cookies = [ - cookie - ] + // Pretend the webview was loaded and the cookies were previously consumed + cookieStorage.isConsumed = true - idManager.allocateNewContainerId() - await withCheckedContinuation { continuation in - WebCacheManager.shared.clear(cookieStorage: cookieStorage, logins: preservedLogins, dataStoreIdManager: idManager) { - continuation.resume() - } - } + await WebCacheManager.shared.clear(cookieStorage: cookieStorage, logins: preservedLogins, dataStoreIdManager: idManager) let testCookie = cookieStorage.cookies.filter { $0.name == test.cookieName }.first @@ -104,13 +106,18 @@ final class FireButtonReferenceTests: XCTestCase { } let cookieStorage = CookieStorage() + cookieStorage.isConsumed = true for test in referenceTests { let cookie = try XCTUnwrap(cookie(for: test)) + + // Pretend the webview was loaded and the cookies were previously consumed + cookieStorage.isConsumed = true + // This simulates loading the cookies from the current web view data stores and updating the storage cookieStorage.updateCookies([ cookie ], keepingPreservedLogins: preservedLogins) - + let testCookie = cookieStorage.cookies.filter { $0.name == test.cookieName }.first if test.expectCookieRemoved { @@ -121,6 +128,7 @@ final class FireButtonReferenceTests: XCTestCase { // Reset cache cookieStorage.cookies = [] + cookieStorage.isConsumed = true } } diff --git a/DuckDuckGoTests/WebCacheManagerTests.swift b/DuckDuckGoTests/WebCacheManagerTests.swift index b0c3dc2b2c..ab435d637e 100644 --- a/DuckDuckGoTests/WebCacheManagerTests.swift +++ b/DuckDuckGoTests/WebCacheManagerTests.swift @@ -27,6 +27,8 @@ class WebCacheManagerTests: XCTestCase { override func setUp() { super.setUp() + CookieStorage().cookies = [] + CookieStorage().isConsumed = true UserDefaults.standard.removeObject(forKey: UserDefaultsWrapper.Key.webContainerId.rawValue) if #available(iOS 17, *) { WKWebsiteDataStore.fetchAllDataStoreIdentifiers { uuids in @@ -59,16 +61,15 @@ class WebCacheManagerTests: XCTestCase { let loadedCount = await defaultStore.httpCookieStore.allCookies().count XCTAssertEqual(5, loadedCount) - await withCheckedContinuation { continuation in - WebCacheManager.shared.clear(logins: logins, dataStoreIdManager: dataStoreIdManager) { - continuation.resume() - } - } + let cookieStore = CookieStorage() + await WebCacheManager.shared.clear(cookieStorage: cookieStore, logins: logins, dataStoreIdManager: dataStoreIdManager) let cookies = await defaultStore.httpCookieStore.allCookies() - XCTAssertEqual(cookies.count, 2) - XCTAssertTrue(cookies.contains(where: { $0.domain == ".twitter.com" })) - XCTAssertTrue(cookies.contains(where: { $0.domain == "mobile.twitter.com" })) + XCTAssertEqual(cookies.count, 0) + + XCTAssertEqual(2, cookieStore.cookies.count) + XCTAssertTrue(cookieStore.cookies.contains(where: { $0.domain == ".twitter.com" })) + XCTAssertTrue(cookieStore.cookies.contains(where: { $0.domain == "mobile.twitter.com" })) } @MainActor @@ -83,18 +84,13 @@ class WebCacheManagerTests: XCTestCase { await defaultStore.httpCookieStore.setCookie(.make(domain: "www.example.com")) await defaultStore.httpCookieStore.setCookie(.make(domain: ".example.com")) - await withCheckedContinuation { continuation in - WebCacheManager.shared.removeCookies(forDomains: ["www.example.com"], dataStore: WKWebsiteDataStore.current()) { - continuation.resume() - } - } + await WebCacheManager.shared.removeCookies(forDomains: ["www.example.com"], dataStore: WKWebsiteDataStore.current()) let cookies = await defaultStore.httpCookieStore.allCookies() XCTAssertEqual(cookies.count, 0) } @MainActor func testWhenClearedThenCookiesWithParentDomainsAreRetained() async { - let logins = MockPreservedLogins(domains: [ "www.example.com" ]) @@ -109,40 +105,47 @@ class WebCacheManagerTests: XCTestCase { await defaultStore.httpCookieStore.setCookie(.make(domain: "example.com")) await defaultStore.httpCookieStore.setCookie(.make(domain: ".example.com")) - await withCheckedContinuation { continuation in - WebCacheManager.shared.clear(logins: logins, dataStoreIdManager: dataStoreIdManager) { - continuation.resume() - } - } - + let cookieStorage = CookieStorage() + + await WebCacheManager.shared.clear(cookieStorage: cookieStorage, + logins: logins, + dataStoreIdManager: dataStoreIdManager) let cookies = await defaultStore.httpCookieStore.allCookies() - XCTAssertEqual(cookies.count, 1) - XCTAssertEqual(cookies[0].domain, ".example.com") + XCTAssertEqual(cookies.count, 0) + XCTAssertEqual(cookieStorage.cookies.count, 1) + XCTAssertEqual(cookieStorage.cookies[0].domain, ".example.com") } - func testWhenClearedThenDDGCookiesAreRetained() { + @MainActor + @available(iOS 17, *) + func testWhenClearedWithDataStoreContainerThenDDGCookiesAreRetained() async throws { + throw XCTSkip("WKWebsiteDataStore(forIdentifier:) does not persist cookies properly until attached to a running webview") + + // This test should look like `testWhenClearedWithLegacyContainerThenDDGCookiesAreRetained` but + // with a container ID set on the `dataStoreIdManager`. + } + + @MainActor + func testWhenClearedWithLegacyContainerThenDDGCookiesAreRetained() async { let logins = MockPreservedLogins(domains: [ "www.example.com" ]) + + XCTAssertFalse(dataStoreIdManager.hasId) + + let cookieStore = WKWebsiteDataStore.default().httpCookieStore + await cookieStore.setCookie(.make(name: "name", value: "value", domain: "duckduckgo.com")) + await cookieStore.setCookie(.make(name: "name", value: "value", domain: "subdomain.duckduckgo.com")) - let dataStore = MockDataStore() - let cookieStore = MockHTTPCookieStore(cookies: [ - .make(domain: "duckduckgo.com"), - .make(domain: "subdomain.duckduckgo.com") - ]) - - dataStore.cookieStore = cookieStore + let storage = CookieStorage() + storage.isConsumed = true - let expect = expectation(description: #function) - WebCacheManager.shared.clear(logins: logins, dataStoreIdManager: dataStoreIdManager) { - expect.fulfill() - } - wait(for: [expect], timeout: 5.0) + await WebCacheManager.shared.clear(cookieStorage: storage, logins: logins, dataStoreIdManager: dataStoreIdManager) - XCTAssertEqual(cookieStore.cookies.count, 2) - XCTAssertTrue(cookieStore.cookies.contains(where: { $0.domain == "duckduckgo.com" })) - XCTAssertTrue(cookieStore.cookies.contains(where: { $0.domain == "subdomain.duckduckgo.com" })) + XCTAssertEqual(storage.cookies.count, 2) + XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "duckduckgo.com" })) + XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "subdomain.duckduckgo.com" })) } @MainActor @@ -163,17 +166,18 @@ class WebCacheManagerTests: XCTestCase { let loadedCount = await defaultStore.httpCookieStore.allCookies().count XCTAssertEqual(2, loadedCount) - await withCheckedContinuation { continuation in - WebCacheManager.shared.clear(logins: logins, dataStoreIdManager: dataStoreIdManager) { - continuation.resume() - } - } + let cookieStore = CookieStorage() + + await WebCacheManager.shared.clear(cookieStorage: cookieStore, logins: logins, dataStoreIdManager: dataStoreIdManager) let cookies = await defaultStore.httpCookieStore.allCookies() - XCTAssertEqual(cookies.count, 1) - XCTAssertEqual(cookies[0].domain, "www.example.com") + XCTAssertEqual(cookies.count, 0) + + XCTAssertEqual(1, cookieStore.cookies.count) + XCTAssertEqual(cookieStore.cookies[0].domain, "www.example.com") } + @MainActor func testWhenAccessingObservationsDbThenValidDatabasePoolIsReturned() { let pool = WebCacheManager.shared.getValidDatabasePool() XCTAssertNotNil(pool, "DatabasePool should not be nil") @@ -181,23 +185,6 @@ class WebCacheManagerTests: XCTestCase { // MARK: Mocks - class MockDataStore: WebCacheManagerDataStore { - - func preservedCookies(_ preservedLogins: Core.PreserveLogins) async -> [HTTPCookie] { - [] - } - - var removeAllDataCalledCount = 0 - - var cookieStore: WebCacheManagerCookieStore? - - func legacyClearingRemovingAllDataExceptCookies(completion: @escaping () -> Void) { - removeAllDataCalledCount += 1 - completion() - } - - } - class MockPreservedLogins: PreserveLogins { let domains: [String] @@ -212,28 +199,4 @@ class WebCacheManagerTests: XCTestCase { } - class MockHTTPCookieStore: WebCacheManagerCookieStore { - - var cookies: [HTTPCookie] - - init(cookies: [HTTPCookie] = []) { - self.cookies = cookies - } - - func getAllCookies(_ completionHandler: @escaping ([HTTPCookie]) -> Void) { - completionHandler(cookies) - } - - func setCookie(_ cookie: HTTPCookie, completionHandler: (() -> Void)?) { - cookies.append(cookie) - completionHandler?() - } - - func delete(_ cookie: HTTPCookie, completionHandler: (() -> Void)?) { - cookies.removeAll { $0 == cookie } - completionHandler?() - } - - } - }