From 081886eebc27f78a16b4ae389eacd07e6e277114 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Mon, 6 Jan 2025 06:41:39 -0800 Subject: [PATCH 1/6] Fix nightly alpha build uploading (#3770) Task/Issue URL: https://app.asana.com/0/414235014887631/1209086253243176/f Tech Design URL: CC: Description: This PR updates the nightly build export options to support building the alpha. --- alphaExportOptions.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/alphaExportOptions.plist b/alphaExportOptions.plist index 9f949f40ac..f1ef150833 100644 --- a/alphaExportOptions.plist +++ b/alphaExportOptions.plist @@ -18,6 +18,8 @@ match AppStore com.duckduckgo.mobile.ios.alpha.Widgets com.duckduckgo.mobile.ios.alpha.NetworkExtension match AppStore com.duckduckgo.mobile.ios.alpha.NetworkExtension + com.duckduckgo.mobile.ios.alpha.CredentialExtension + match AppStore com.duckduckgo.mobile.ios.alpha.CredentialExtension From 37800ccf3f7cbb3b905648c842e7ffcf41f05287 Mon Sep 17 00:00:00 2001 From: amddg44 Date: Mon, 6 Jan 2025 18:24:33 +0100 Subject: [PATCH 2/6] Recovery for users impacted by enabling the credential extension before app migration on app version 7.149.0 (#3754) Task/Issue URL: https://app.asana.com/0/414235014887631/1209005320461774/f Tech Design URL: CC: **Description**: Recovers the passwords vault of any users impacted by edge case scenario of enabling the credential provider extension before the app has perform the migration --- .../Shared/SecureVaultReporter.swift | 3 +- Core/AutofillVaultKeychainMigrator.swift | 199 ++++++++++++++++ Core/Pixel.swift | 1 + Core/PixelEvent.swift | 10 +- Core/UserDefaultsPropertyWrapper.swift | 1 + DuckDuckGo.xcodeproj/project.pbxproj | 24 +- DuckDuckGo/AutofillUsageMonitor.swift | 4 + .../AutofillVaultKeychainMigratorTests.swift | 215 ++++++++++++++++++ 8 files changed, 446 insertions(+), 11 deletions(-) create mode 100644 Core/AutofillVaultKeychainMigrator.swift create mode 100644 DuckDuckGoTests/AutofillVaultKeychainMigratorTests.swift diff --git a/AutofillCredentialProvider/CredentialProvider/Shared/SecureVaultReporter.swift b/AutofillCredentialProvider/CredentialProvider/Shared/SecureVaultReporter.swift index 2a001a714f..0c584f8741 100644 --- a/AutofillCredentialProvider/CredentialProvider/Shared/SecureVaultReporter.swift +++ b/AutofillCredentialProvider/CredentialProvider/Shared/SecureVaultReporter.swift @@ -29,7 +29,8 @@ final class SecureVaultReporter: SecureVaultReporting { guard !ProcessInfo().arguments.contains("testing") else { return } #endif let pixelParams = [PixelParameters.isBackgrounded: "false", - PixelParameters.appVersion: AppVersion.shared.versionAndBuildNumber] + PixelParameters.appVersion: AppVersion.shared.versionAndBuildNumber, + PixelParameters.isExtension: "true"] switch error { case .initFailed(let error): diff --git a/Core/AutofillVaultKeychainMigrator.swift b/Core/AutofillVaultKeychainMigrator.swift new file mode 100644 index 0000000000..6ea03c7c05 --- /dev/null +++ b/Core/AutofillVaultKeychainMigrator.swift @@ -0,0 +1,199 @@ +// +// AutofillVaultKeychainMigrator.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 Foundation +import BrowserServicesKit +import os.log +import Persistence +import GRDB +import SecureStorage + +public protocol AutofillKeychainService { + func hasKeychainItemsMatching(serviceName: String) -> Bool + func deleteKeychainItems(matching serviceName: String) +} + +public struct DefaultAutofillKeychainService: AutofillKeychainService { + + public init() {} + + public func hasKeychainItemsMatching(serviceName: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess, let items = result as? [[String: Any]] { + for item in items { + if let service = item[kSecAttrService as String] as? String, + service.lowercased() == serviceName.lowercased() { + Logger.autofill.debug("Found keychain items matching service name: \(serviceName)") + return true + } + } + } + + return false + } + + public func deleteKeychainItems(matching serviceName: String) { + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName + ] + + let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) + if deleteStatus == errSecSuccess { + Logger.autofill.debug("Deleted keychain item: \(serviceName)") + } else { + Logger.autofill.debug("Failed to delete keychain item: \(serviceName), error: \(deleteStatus)") + } + } +} + +public class AutofillVaultKeychainMigrator { + + internal let keychainService: AutofillKeychainService + + private let store: KeyValueStoring + + @UserDefaultsWrapper(key: .autofillVaultMigrated, defaultValue: false) + var wasVaultMigrated: Bool + + private var vaultMigrated: Bool { + get { + return (store.object(forKey: UserDefaultsWrapper.Key.autofillVaultMigrated.rawValue) as? Bool) ?? false + } + set { + store.set(newValue, forKey: UserDefaultsWrapper.Key.autofillVaultMigrated.rawValue) + } + } + + public init(keychainService: AutofillKeychainService = DefaultAutofillKeychainService(), store: KeyValueStoring = UserDefaults.app) { + self.keychainService = keychainService + self.store = store + } + + public func resetVaultMigrationIfRequired(fileManager: FileManager = FileManager.default) { + guard !vaultMigrated else { + return + } + + let originalVaultLocation = DefaultAutofillDatabaseProvider.defaultDatabaseURL() + let sharedVaultLocation = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL() + let hasV4Items = keychainService.hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault v4") + + // only care about users who have have both the original and migrated vaults, as well as v4 keychain items + guard fileManager.fileExists(atPath: originalVaultLocation.path), + fileManager.fileExists(atPath: sharedVaultLocation.path), + hasV4Items else { + vaultMigrated = true + return + } + + let hasV3Items = keychainService.hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault v3") + let hasV2Items = keychainService.hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault v2") + let hasV1Items = keychainService.hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault") + + // Only continue if there are original keychain items to migrate from + guard hasV1Items || hasV2Items || hasV3Items else { + vaultMigrated = true + return + } + + let backupFilePath = sharedVaultLocation.appendingPathExtension("bak") + do { + // only complete the migration if the shared database is empty + let databaseIsEmpty = try databaseIsEmpty(at: sharedVaultLocation) + if !databaseIsEmpty { + Pixel.fire(pixel: .secureVaultV4MigrationSkipped) + } else { + // Creating a backup of the migrated file + try fileManager.moveItem(at: sharedVaultLocation, to: backupFilePath) + keychainService.deleteKeychainItems(matching: "DuckDuckGo Secure Vault v4") + Pixel.fire(pixel: .secureVaultV4Migration) + } + wasVaultMigrated = true + } catch { + Logger.autofill.error("Failed to create backup of migrated file: \(error.localizedDescription)") + return + } + } + + internal func databaseIsEmpty(at url: URL) throws -> Bool { + let keyStoreProvider: SecureStorageKeyStoreProvider = AutofillSecureVaultFactory.makeKeyStoreProvider(nil) + guard let existingL1Key = try keyStoreProvider.l1Key() else { + return false + } + + var config = Configuration() + config.prepareDatabase { + try $0.usePassphrase(existingL1Key) + } + + let dbQueue = try DatabaseQueue(path: url.path, configuration: config) + + try dbQueue.write { db in + try db.usePassphrase(existingL1Key) + } + + let isEmpty = try dbQueue.read { db in + // Find all user-created tables (excluding system tables) + let tableNames = try Row.fetchAll( + db, + sql: """ + SELECT name + FROM sqlite_master + WHERE type='table' + AND name NOT LIKE 'sqlite_%' + """ + ).map { row -> String in + row["name"] + } + + // No user tables at all -> definitely empty + if tableNames.isEmpty { + return true + } + + // Check each table for rows + for table in tableNames { + if table == "grdb_migrations" { + continue + } + + let rowCount = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(table)") ?? 0 + if rowCount > 0 { + // Found data in at least one table -> not empty + return false + } + } + + // There's at least one user table, but all are empty + return true + } + + return isEmpty + } + +} diff --git a/Core/Pixel.swift b/Core/Pixel.swift index ded3c22a2c..0434329098 100644 --- a/Core/Pixel.swift +++ b/Core/Pixel.swift @@ -148,6 +148,7 @@ public struct PixelParameters { // Autofill public static let countBucket = "count_bucket" public static let backfilled = "backfilled" + public static let isExtension = "is_extension" // Privacy Dashboard public static let daysSinceInstall = "daysSinceInstall" diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 1b1568d708..cc50325dc0 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -359,7 +359,10 @@ extension Pixel { // Replacing secureVaultIsEnabledCheckedWhenEnabledAndBackgrounded with data protection check case secureVaultIsEnabledCheckedWhenEnabledAndDataProtected - + + case secureVaultV4Migration + case secureVaultV4MigrationSkipped + // MARK: Ad Click Attribution pixels case adClickAttributionDetected @@ -1344,7 +1347,10 @@ extension Pixel.Event { case .secureVaultFailedToOpenDatabaseError: return "m_secure-vault_error_failed-to-open-database" case .secureVaultIsEnabledCheckedWhenEnabledAndDataProtected: return "m_secure-vault_is-enabled-checked_when-enabled-and-data-protected" - + + case .secureVaultV4Migration: return "m_secure-vault_v4-migration" + case .secureVaultV4MigrationSkipped: return "m_secure-vault_v4-migration-skipped" + // MARK: Ad Click Attribution pixels case .adClickAttributionDetected: return "m_ad_click_detected" diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index 4b0b9682ab..da55022245 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -96,6 +96,7 @@ public struct UserDefaultsWrapper { case autofillOnboardedUser = "com.duckduckgo.app.autofill.OnboardedUser" case autofillSurveysCompleted = "com.duckduckgo.app.autofill.SurveysCompleted" case autofillExtensionEnabled = "com.duckduckgo.app.autofill.ExtensionEnabled" + case autofillVaultMigrated = "com.duckduckgo.app.autofill.VaultMigrated" case syncPromoBookmarksDismissed = "com.duckduckgo.app.sync.PromoBookmarksDismissed" case syncPromoPasswordsDismissed = "com.duckduckgo.app.sync.PromoPasswordsDismissed" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index f37fc692c7..eda1499a91 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -941,6 +941,7 @@ C1BF0BA529B63D7200482B73 /* AutofillLoginPromptHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BF0BA429B63D7200482B73 /* AutofillLoginPromptHelper.swift */; }; C1BF0BA929B63E2200482B73 /* AutofillLoginPromptViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BF0BA729B63E1A00482B73 /* AutofillLoginPromptViewModelTests.swift */; }; C1BF26152C74D10F00F6405E /* SyncPromoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BF26142C74D10F00F6405E /* SyncPromoManager.swift */; }; + C1BF4BAA2D26E0B300A83C77 /* AutofillVaultKeychainMigratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BF4BA92D26E0B300A83C77 /* AutofillVaultKeychainMigratorTests.swift */; }; C1C1FF452D085A280017ACCE /* CredentialProviderListDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C1FF412D085A280017ACCE /* CredentialProviderListDetailsView.swift */; }; C1C1FF462D085A280017ACCE /* CredentialProviderListDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C1FF422D085A280017ACCE /* CredentialProviderListDetailsViewController.swift */; }; C1C1FF472D085A280017ACCE /* CredentialProviderListDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C1FF432D085A280017ACCE /* CredentialProviderListDetailsViewModel.swift */; }; @@ -975,6 +976,7 @@ C1CDA31E2AFBF811006D1476 /* AutofillNeverPromptWebsitesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CDA31D2AFBF811006D1476 /* AutofillNeverPromptWebsitesManagerTests.swift */; }; C1D21E2D293A5965006E5A05 /* AutofillLoginSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D21E2C293A5965006E5A05 /* AutofillLoginSession.swift */; }; C1D21E2F293A599C006E5A05 /* AutofillLoginSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D21E2E293A599C006E5A05 /* AutofillLoginSessionTests.swift */; }; + C1E12B7A2D14B86A0079AD8F /* AutofillVaultKeychainMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E12B792D14B86A0079AD8F /* AutofillVaultKeychainMigrator.swift */; }; C1E42C7B2C5CD8AE00509204 /* AutofillCredentialsDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E42C7A2C5CD8AD00509204 /* AutofillCredentialsDebugViewController.swift */; }; C1E4E9A62D0861AD00AA39AF /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1E4E9A42D0861AD00AA39AF /* InfoPlist.strings */; }; C1E4E9A92D0861AD00AA39AF /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1E4E9A72D0861AD00AA39AF /* Localizable.strings */; }; @@ -2844,6 +2846,7 @@ C1BF0BA429B63D7200482B73 /* AutofillLoginPromptHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillLoginPromptHelper.swift; sourceTree = ""; }; C1BF0BA729B63E1A00482B73 /* AutofillLoginPromptViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillLoginPromptViewModelTests.swift; sourceTree = ""; }; C1BF26142C74D10F00F6405E /* SyncPromoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPromoManager.swift; sourceTree = ""; }; + C1BF4BA92D26E0B300A83C77 /* AutofillVaultKeychainMigratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillVaultKeychainMigratorTests.swift; sourceTree = ""; }; C1C1FF412D085A280017ACCE /* CredentialProviderListDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialProviderListDetailsView.swift; sourceTree = ""; }; C1C1FF422D085A280017ACCE /* CredentialProviderListDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialProviderListDetailsViewController.swift; sourceTree = ""; }; C1C1FF432D085A280017ACCE /* CredentialProviderListDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialProviderListDetailsViewModel.swift; sourceTree = ""; }; @@ -2880,6 +2883,7 @@ C1D21E2E293A599C006E5A05 /* AutofillLoginSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginSessionTests.swift; sourceTree = ""; }; C1DCF3502D0862330055F8B0 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C1DCF3512D0862330055F8B0 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + C1E12B792D14B86A0079AD8F /* AutofillVaultKeychainMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillVaultKeychainMigrator.swift; sourceTree = ""; }; C1E42C7A2C5CD8AD00509204 /* AutofillCredentialsDebugViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillCredentialsDebugViewController.swift; sourceTree = ""; }; C1E490582D08646400F86C5A /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/InfoPlist.strings; sourceTree = ""; }; C1E490592D08646400F86C5A /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = ""; }; @@ -3714,13 +3718,6 @@ name = Downloads; sourceTree = ""; }; - 310EEA2C2CFFCD9B0043CA1A /* New Group */ = { - isa = PBXGroup; - children = ( - ); - path = "New Group"; - sourceTree = ""; - }; 310EEA2D2CFFCDB60043CA1A /* AIChat */ = { isa = PBXGroup; children = ( @@ -5493,6 +5490,14 @@ name = AutofillLoginUI; sourceTree = ""; }; + C1BF4BA82D26E08F00A83C77 /* Autofill */ = { + isa = PBXGroup; + children = ( + C1BF4BA92D26E0B300A83C77 /* AutofillVaultKeychainMigratorTests.swift */, + ); + name = Autofill; + sourceTree = ""; + }; C1C1FF442D085A280017ACCE /* CredentialProviderListDetails */ = { isa = PBXGroup; children = ( @@ -5578,6 +5583,7 @@ C19D90D02CFE3A7F00D17DF3 /* AutofillLoginListSectionType.swift */, C1CAAA992CFCAD3E00C37EE6 /* AutofillLoginItem.swift */, C1CAAA9B2CFCB39800C37EE6 /* AutofillLoginListSorting.swift */, + C1E12B792D14B86A0079AD8F /* AutofillVaultKeychainMigrator.swift */, ); name = Autofill; sourceTree = ""; @@ -6764,7 +6770,7 @@ F1E092B31E92A6B900732CCC /* Core */ = { isa = PBXGroup; children = ( - 310EEA2C2CFFCD9B0043CA1A /* New Group */, + C1BF4BA82D26E08F00A83C77 /* Autofill */, 316790E32C9350980090B0A2 /* MarketplaceAdPostback */, 858479CA2B8795BF00D156C1 /* History */, EA7EFE662677F5BD0075464E /* PrivacyReferenceTests */, @@ -8446,6 +8452,7 @@ 9FE05CF12C36468A00D9046B /* OnboardingPixelReporterTests.swift in Sources */, 9FDEC7B42C8FD62F00C7A692 /* OnboardingAddressBarPositionPickerViewModelTests.swift in Sources */, 85BA58581F34F72F00C6E8CA /* AppUserDefaultsTests.swift in Sources */, + C1BF4BAA2D26E0B300A83C77 /* AutofillVaultKeychainMigratorTests.swift in Sources */, F1134EBC1F40D45700B73467 /* MockStatisticsStore.swift in Sources */, 9FCFCD802C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift in Sources */, 983C52E72C2C0ACB007B5747 /* BookmarkStateRepairTests.swift in Sources */, @@ -8888,6 +8895,7 @@ B652DF0D287C2A6300C12A9C /* PrivacyFeatures.swift in Sources */, F10E522D1E946F8800CE1253 /* NSAttributedStringExtension.swift in Sources */, 9F678B892C9BAA4800CA0E19 /* Debouncer.swift in Sources */, + C1E12B7A2D14B86A0079AD8F /* AutofillVaultKeychainMigrator.swift in Sources */, 85AB84C32CF624D8007E679F /* HTTPCookieExtension.swift in Sources */, 9887DC252354D2AA005C85F5 /* Database.swift in Sources */, F143C3171E4A99D200CFDE3A /* AppURLs.swift in Sources */, diff --git a/DuckDuckGo/AutofillUsageMonitor.swift b/DuckDuckGo/AutofillUsageMonitor.swift index 6123c43180..cd4c04d71c 100644 --- a/DuckDuckGo/AutofillUsageMonitor.swift +++ b/DuckDuckGo/AutofillUsageMonitor.swift @@ -35,6 +35,10 @@ final class AutofillUsageMonitor { init() { NotificationCenter.default.addObserver(self, selector: #selector(didReceiveSaveEvent), name: .autofillSaveEvent, object: nil) + if autofillExtensionEnabled != nil { + AutofillVaultKeychainMigrator().resetVaultMigrationIfRequired() + } + ASCredentialIdentityStore.shared.getState({ [weak self] state in if state.isEnabled { if self?.autofillExtensionEnabled == nil { diff --git a/DuckDuckGoTests/AutofillVaultKeychainMigratorTests.swift b/DuckDuckGoTests/AutofillVaultKeychainMigratorTests.swift new file mode 100644 index 0000000000..7d5083ad09 --- /dev/null +++ b/DuckDuckGoTests/AutofillVaultKeychainMigratorTests.swift @@ -0,0 +1,215 @@ +// +// AutofillVaultKeychainMigratorTests.swift +// DuckDuckGo +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Core +import BrowserServicesKit +import TestUtils + +final class AutofillVaultKeychainMigratorTests: XCTestCase { + + private var mockKeychain: MockKeychainService! + private var mockFileManager: MockFileManager! + private var mockStore: MockKeyValueStore! + private var migrator: TestableAutofillVaultKeychainMigrator! + private let autofillVaultMigratedKey = "com.duckduckgo.app.autofill.VaultMigrated" + + override func setUpWithError() throws { + super.setUp() + mockKeychain = MockKeychainService() + mockFileManager = MockFileManager() + mockStore = MockKeyValueStore() + + // Create a testable migrator that overrides databaseIsEmpty + migrator = TestableAutofillVaultKeychainMigrator( + keychainService: mockKeychain, + store: mockStore + ) + } + + override func tearDownWithError() throws { + migrator = nil + mockStore.clearAll() + mockStore = nil + mockFileManager = nil + mockKeychain = nil + + try super.tearDownWithError() + } + + func testSkipsIfAlreadyMigrated() { + // Mark vault as already migrated + mockStore.set(true, forKey: autofillVaultMigratedKey) + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + // Because vault was already migrated, we skip everything + XCTAssertFalse(mockFileManager.didMoveItem, "Should not move files if already migrated") + XCTAssertTrue(mockKeychain.deletedServices.isEmpty, "Should not delete keychain items if already migrated") + } + + func testSetsVaultMigratedToTrueIfOriginalMissing() { + // Arrange: + // Suppose only shared vault exists + let sharedPath = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL().path + mockFileManager.existingPaths = [sharedPath] // Missing the original vault + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + let migratedValue = mockStore.object(forKey: autofillVaultMigratedKey) as? Bool + XCTAssertTrue(migratedValue == true, "Migrator should set wasVaultMigrated = true if original is missing") + } + + func testSetsVaultMigratedToTrueIfSharedMissing() { + // Suppose only original vault exists + let originalPath = DefaultAutofillDatabaseProvider.defaultDatabaseURL().path + mockFileManager.existingPaths = [originalPath] // Missing the shared vault + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + let migratedValue = mockStore.object(forKey: autofillVaultMigratedKey) as? Bool + XCTAssertTrue(migratedValue == true, "Migrator should set wasVaultMigrated = true if shared is missing") + } + + func testSetsVaultMigratedToTrueIfNoV4Items() { + // Both vaults exist but no V4 items in keychain + let originalPath = DefaultAutofillDatabaseProvider.defaultDatabaseURL().path + let sharedPath = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL().path + mockFileManager.existingPaths = [originalPath, sharedPath] + + // Simulate no v4 items + mockKeychain.servicesWithItems = ["DuckDuckGo Secure Vault v3"] // just v3, no v4 + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + let migratedValue = mockStore.object(forKey: autofillVaultMigratedKey) as? Bool + XCTAssertTrue(migratedValue == true, "Migrator should set wasVaultMigrated = true if v4 items not found") + } + + func testSkipsIfNoOriginalKeychainItems() { + // We have v4 items, but no v1/v2/v3 items + let originalPath = DefaultAutofillDatabaseProvider.defaultDatabaseURL().path + let sharedPath = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL().path + mockFileManager.existingPaths = [originalPath, sharedPath] + + // v4 exists, but no older items + mockKeychain.servicesWithItems = ["DuckDuckGo Secure Vault v4"] + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + // No file move, no keychain deletion, wasVaultMigrated should remain false if we read it + XCTAssertFalse(mockFileManager.didMoveItem, "Should not move item if no original keychain items") + XCTAssertTrue(mockKeychain.deletedServices.isEmpty, "Should not delete any keychain service if no older items exist") + + let migratedValue = mockStore.object(forKey: autofillVaultMigratedKey) as? Bool + XCTAssertTrue(migratedValue == true, "Migrator should set wasVaultMigrated = true if no v1/v2/v3 items items found") + } + + func testSkipsIfDatabaseIsNotEmpty() { + // We have original and shared vaults, plus v4 items, plus older items + let originalPath = DefaultAutofillDatabaseProvider.defaultDatabaseURL().path + let sharedPath = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL().path + mockFileManager.existingPaths = [originalPath, sharedPath] + + // Keychain has both v4 and older items + mockKeychain.servicesWithItems = [ + "DuckDuckGo Secure Vault v4", + "DuckDuckGo Secure Vault v3" + ] + + // Simulate the "database is NOT empty" to cause skipping + migrator.databaseIsEmptyReturnValue = false + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + // The code checks the DB, sees it’s not empty, logs and sets wasVaultMigrated = true, + // but does NOT move or delete the v4 items. + XCTAssertFalse(mockFileManager.didMoveItem, "Should not move item if DB is not empty") + XCTAssertTrue(mockKeychain.deletedServices.isEmpty, "Should not delete if DB is not empty") + } + + func testDeletesV4AndMovesFileIfDatabaseIsEmpty() { + // Arrange: Both vaults exist, v4 + older items exist + let originalPath = DefaultAutofillDatabaseProvider.defaultDatabaseURL().path + let sharedPath = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL().path + mockFileManager.existingPaths = [originalPath, sharedPath] + + // Keychain has v4 and older items + mockKeychain.servicesWithItems = [ + "DuckDuckGo Secure Vault v4", + "DuckDuckGo Secure Vault v2" + ] + + // Simulate the DB is empty + migrator.databaseIsEmptyReturnValue = true + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + XCTAssertTrue(mockFileManager.didMoveItem, "Should move the shared vault file to .bak if DB is empty") + XCTAssertEqual(mockKeychain.deletedServices, ["DuckDuckGo Secure Vault v4"], "Should delete v4 items") + } + +} + +private class MockKeychainService: AutofillKeychainService { + + var servicesWithItems: Set = [] + var deletedServices: [String] = [] + + func hasKeychainItemsMatching(serviceName: String) -> Bool { + return servicesWithItems.contains(serviceName) + } + + func deleteKeychainItems(matching serviceName: String) { + deletedServices.append(serviceName) + // Simulate that they no longer exist + servicesWithItems.remove(serviceName) + } +} + +private class MockFileManager: FileManager { + + var existingPaths = Set() + + var didMoveItem = false + var movedFromPath: String? + var movedToPath: String? + + override func fileExists(atPath path: String) -> Bool { + return existingPaths.contains(path) + } + + override func moveItem(at srcURL: URL, to dstURL: URL) throws { + didMoveItem = true + movedFromPath = srcURL.path + movedToPath = dstURL.path + // In a real test double, you might simulate an error if you want to test error paths + } +} + +private class TestableAutofillVaultKeychainMigrator: AutofillVaultKeychainMigrator { + + var databaseIsEmptyReturnValue: Bool = true + var databaseIsEmptyCalledCount = 0 + + override func databaseIsEmpty(at url: URL) throws -> Bool { + databaseIsEmptyCalledCount += 1 + return databaseIsEmptyReturnValue + } +} From bb26dd2253eacbc825d63c39ec801b11edb375d0 Mon Sep 17 00:00:00 2001 From: amddg44 Date: Mon, 6 Jan 2025 21:09:47 +0100 Subject: [PATCH 3/6] Release 7.152.0-0 (#3773) --- Configuration/Version.xcconfig | 2 +- .../AppPrivacyConfigurationDataProvider.swift | 4 +- Core/ios-config.json | 52 ++++++++++++++----- DuckDuckGo/Settings.bundle/Root.plist | 2 +- 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/Configuration/Version.xcconfig b/Configuration/Version.xcconfig index dd85c332f7..475cbcbcf3 100644 --- a/Configuration/Version.xcconfig +++ b/Configuration/Version.xcconfig @@ -1 +1 @@ -MARKETING_VERSION = 7.151.0 +MARKETING_VERSION = 7.152.0 diff --git a/Core/AppPrivacyConfigurationDataProvider.swift b/Core/AppPrivacyConfigurationDataProvider.swift index 09747e4c6b..166e8a7166 100644 --- a/Core/AppPrivacyConfigurationDataProvider.swift +++ b/Core/AppPrivacyConfigurationDataProvider.swift @@ -23,8 +23,8 @@ import BrowserServicesKit final public class AppPrivacyConfigurationDataProvider: EmbeddedDataProvider { public struct Constants { - public static let embeddedDataETag = "\"c065d77250bce32110b6f66be0ddf36d\"" - public static let embeddedDataSHA = "eb30059bd7dd769eb9b5546ea345305254cc4bd93ee285b9199e0e556174c2e8" + public static let embeddedDataETag = "\"dc4fc236d233883d9391c439e05ab610\"" + public static let embeddedDataSHA = "8af2ec9ba8b7d6393c87b389d8e3ea6f770ffa3a9dee97f9e06e5e4c8fcaae23" } public var embeddedDataEtag: String { diff --git a/Core/ios-config.json b/Core/ios-config.json index 370e342505..266cb4ff59 100644 --- a/Core/ios-config.json +++ b/Core/ios-config.json @@ -1,6 +1,6 @@ { "readme": "https://github.com/duckduckgo/privacy-configuration", - "version": 1734712666372, + "version": 1736158448802, "features": { "adAttributionReporting": { "state": "enabled", @@ -5150,6 +5150,22 @@ { "selector": "[class*='sdaContainer']", "type": "hide" + }, + { + "selector": "#sda-Horizon-viewer", + "type": "hide-empty" + }, + { + "selector": "[id*='mrt-node-Lead']", + "type": "hide-empty" + }, + { + "selector": "[id*='mrt-node-Secondary']", + "type": "hide-empty" + }, + { + "selector": "[id*='rrvkqH1']", + "type": "hide-empty" } ] }, @@ -5319,7 +5335,7 @@ ] }, "state": "enabled", - "hash": "ecf5936832b4856f89f523db4d656e67" + "hash": "33a9e4e7a2287b523be00cd63884e166" }, "exceptionHandler": { "exceptions": [ @@ -5538,9 +5554,6 @@ { "domain": "secureserver.net" }, - { - "domain": "hyatt.com" - }, { "domain": "proton.me" }, @@ -5579,7 +5592,7 @@ } ], "state": "enabled", - "hash": "b1f406c6ff87e27244d26289c0e383f9" + "hash": "de6c3bbf0cfed28146d019f073800240" }, "fingerprintingScreenSize": { "settings": { @@ -5610,9 +5623,6 @@ { "domain": "godaddy.com" }, - { - "domain": "hyatt.com" - }, { "domain": "litebluesso.usps.gov" }, @@ -5636,7 +5646,7 @@ } ], "state": "enabled", - "hash": "046340bb9287a20efed6189525ec5fed" + "hash": "25e6055479ec4c8a62c2e530ae6ba1ff" }, "fingerprintingTemporaryStorage": { "exceptions": [ @@ -5728,6 +5738,9 @@ { "domain": "visible.com" }, + { + "domain": "flyfrontier.com" + }, { "domain": "marvel.com" }, @@ -5753,7 +5766,7 @@ "privacy-test-pages.site" ] }, - "hash": "7bac9629b3b6d7cbe2e473c1273d31ee" + "hash": "fdb3180769d77fd3cedfc8601e184dcd" }, "harmfulApis": { "settings": { @@ -6183,7 +6196,7 @@ "minSupportedVersion": "7.148.0" }, "privacyProFreeTrialJan25": { - "state": "internal", + "state": "disabled", "targets": [ { "localeCountry": "US" @@ -6201,7 +6214,7 @@ ] } }, - "hash": "d7172cafbbb94f5407ef9ff4806fd8ba" + "hash": "fd266e728de7cb4cc5510403ecd88bc7" }, "privacyProtectionsPopup": { "state": "disabled", @@ -7894,6 +7907,7 @@ "experian.com", "ljsilvers.com", "piedmontng.com", + "tesla.com", "thesimsresource.com", "tradersync.com", "vanguardplan.com", @@ -8581,6 +8595,16 @@ } ] }, + "nitropay.com": { + "rules": [ + { + "rule": "nitropay.com/ads", + "domains": [ + "maxroll.gg" + ] + } + ] + }, "nosto.com": { "rules": [ { @@ -9853,7 +9877,7 @@ "domain": "instructure.com" } ], - "hash": "fe0934622a089920211c50aaf246954c" + "hash": "6fdf3194e5e7f214b9905a3dd566f7d7" }, "trackingCookies1p": { "settings": { diff --git a/DuckDuckGo/Settings.bundle/Root.plist b/DuckDuckGo/Settings.bundle/Root.plist index 03e9cf888c..52c1f8fd21 100644 --- a/DuckDuckGo/Settings.bundle/Root.plist +++ b/DuckDuckGo/Settings.bundle/Root.plist @@ -6,7 +6,7 @@ DefaultValue - 7.151.0 + 7.152.0 Key version Title From 0cdc818c171df40c930fe60260a0fb6ac714264a Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Tue, 7 Jan 2025 16:05:37 +0000 Subject: [PATCH 4/6] dashboard: removing flicker (#3774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1201141132935289/1209097550105017/f Tech Design URL: CC: **Description**: - just updating BSK for a macOS fix https://github.com/duckduckgo/macos-browser/pull/3705 **Steps to test this PR**: 1. 2. **Definition of Done (Internal Only)**: * [ ] Does this PR satisfy our [Definition of Done](https://app.asana.com/0/1202500774821704/1207634633537039/f)? **Copy Testing**: * [ ] Use of correct apostrophes in new copy, ie `’` rather than `'` **Orientation Testing**: * [ ] Portrait * [ ] Landscape **Device Testing**: * [ ] iPhone SE (1st Gen) * [ ] iPhone 8 * [ ] iPhone X * [ ] iPhone 14 Pro * [ ] iPad **OS Testing**: * [ ] iOS 15 * [ ] iOS 16 * [ ] iOS 17 **Theme Testing**: * [ ] Light theme * [ ] Dark theme --- ###### Internal references: [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index eda1499a91..21c6526abe 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -11865,7 +11865,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 223.0.0; + version = 223.0.1; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a91a1f376a..f95bfa62f6 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "e8f94cf597f4a447f86f39f461b736ac9ea280ce", - "version" : "223.0.0" + "revision" : "eea5c7cb7578e771727aa2fd9120950a166e02b2", + "version" : "223.0.1" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "93ea6c3e771bc0b743b38cefbff548c10add9898", - "version" : "6.42.0" + "revision" : "bc808eb735d9eb72d5c54cf2452b104b6a370e25", + "version" : "6.43.0" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/privacy-dashboard", "state" : { - "revision" : "022c845b06ace6a4aa712a4fa3e79da32193d5c6", - "version" : "7.4.0" + "revision" : "2e2baf7d31c7d8e158a58bc1cb79498c1c727fd2", + "version" : "7.5.0" } }, { From a4142e3773aeae3620f2971ad7086926605ccf29 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Tue, 7 Jan 2025 19:20:46 +0100 Subject: [PATCH 5/6] Update C-S-S to 7.1.0 (#3776) Task/Issue URL: https://app.asana.com/0/0/1209096872344395/f Description: This change updates C-S-S to the version containing latest fixes to HTML New Tab Page. The production code isn't affected. This is a major bump of the C-S-S but it's transparent to native apps (extension-specific change). --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 21c6526abe..dfa700dc62 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -11865,7 +11865,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 223.0.1; + version = 224.0.0; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f95bfa62f6..556334abe5 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "eea5c7cb7578e771727aa2fd9120950a166e02b2", - "version" : "223.0.1" + "revision" : "bb43f0c2a489d7eed4b33baf2201f9b2b6512588", + "version" : "224.0.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "bc808eb735d9eb72d5c54cf2452b104b6a370e25", - "version" : "6.43.0" + "revision" : "a539758027d9fd37d9d26213399ac156ca9fb81c", + "version" : "7.1.0" } }, { From 5591a5a22bb803c1a51776a95c750977e5e4be59 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Tue, 7 Jan 2025 19:16:52 -0300 Subject: [PATCH 6/6] Release iOS: VPN widget for control center & Siri shortcut (#3772) Task/Issue URL: https://app.asana.com/0/1206580121312550/1209007855977973/f ## Description Addresses final ship-review feedback for our new control center widget and releases it for all users. --- .../NetworkProtectionVPNSettingsView.swift | 2 -- DuckDuckGo/VPNAutoShortcuts.swift | 2 -- Widgets/UserText.swift | 34 +++++++++++++++++++ Widgets/VPNControlWidget.swift | 30 +++++++++++++--- Widgets/VPNWidget.swift | 18 ++++++++++ Widgets/Widgets.swift | 2 -- Widgets/en.lproj/Localizable.strings | 18 ++++++++++ 7 files changed, 95 insertions(+), 11 deletions(-) diff --git a/DuckDuckGo/NetworkProtectionVPNSettingsView.swift b/DuckDuckGo/NetworkProtectionVPNSettingsView.swift index 9b431d22a1..7e32e8693f 100644 --- a/DuckDuckGo/NetworkProtectionVPNSettingsView.swift +++ b/DuckDuckGo/NetworkProtectionVPNSettingsView.swift @@ -162,7 +162,6 @@ struct NetworkProtectionVPNSettingsView: View { }.daxBodyRegular() } - #if ALPHA || DEBUG if #available(iOS 18.0, *) { NavigationLink { ControlCenterWidgetEducationView(navBarTitle: "Add DuckDuckGo VPN Shortcut to Your Control Center", @@ -187,7 +186,6 @@ struct NetworkProtectionVPNSettingsView: View { .frame(width: 24, height: 24) }.daxBodyRegular() } - #endif } header: { Text(UserText.netPVPNShortcutsSectionHeader) } diff --git a/DuckDuckGo/VPNAutoShortcuts.swift b/DuckDuckGo/VPNAutoShortcuts.swift index 4944fcb418..3f55c4f94e 100644 --- a/DuckDuckGo/VPNAutoShortcuts.swift +++ b/DuckDuckGo/VPNAutoShortcuts.swift @@ -20,7 +20,6 @@ import AppIntents import Foundation -#if ALPHA || DEBUG @available(iOS 17.0, *) struct VPNAutoShortcutsiOS17: AppShortcutsProvider { @@ -60,4 +59,3 @@ struct VPNAutoShortcutsiOS17: AppShortcutsProvider { systemImageName: "globe") } } -#endif diff --git a/Widgets/UserText.swift b/Widgets/UserText.swift index 18eeb077ca..bbdf6d4e01 100644 --- a/Widgets/UserText.swift +++ b/Widgets/UserText.swift @@ -114,6 +114,40 @@ struct UserText { return localized.format(arguments: endDate) } + // MARK: - Control Center Widget + + static let vpnControlWidgetOn = NSLocalizedString( + "vpn.control.widget.on", + value: "VPN is ON", + comment: "Title for the control widget when enabled") + + static let vpnControlWidgetOff = NSLocalizedString( + "vpn.control.widget.off", + value: "VPN is OFF", + comment: "Title for the control widget when disabled") + + static let vpnControlWidgetLocationUnknown = NSLocalizedString( + "vpn.control.widget.location-unknown", + value: "Unknown Location", + comment: "Description for the control widget when the location is unknown") + + static let vpnControlWidgetConnecting = NSLocalizedString( + "vpn.control.widget.connecting", + value: "Connecting...", + comment: "Description for the control widget when connecting") + + static let vpnControlWidgetDisconnecting = NSLocalizedString( + "vpn.control.widget.disconnecting", + value: "Disconnecting...", + comment: "Description for the control widget when disconnecting") + + static let vpnControlWidgetNotConnected = NSLocalizedString( + "vpn.control.widget.not-connected", + value: "Not Connected", + comment: "Description for the control widget when not connected") + + // MARK: - Misc... + static let lockScreenSearchTitle = NSLocalizedString( "lock.screen.widget.search.title", value: "Private Search", diff --git a/Widgets/VPNControlWidget.swift b/Widgets/VPNControlWidget.swift index 4b1048147b..1dfbe0f41f 100644 --- a/Widgets/VPNControlWidget.swift +++ b/Widgets/VPNControlWidget.swift @@ -17,15 +17,16 @@ // limitations under the License. // +import Core import Foundation import SwiftUI import WidgetKit -#if ALPHA || DEBUG @available(iOSApplicationExtension 18.0, *) public struct VPNControlWidget: ControlWidget { static let displayName = LocalizedStringResource(stringLiteral: "DuckDuckGo\nVPN") static let description = LocalizedStringResource(stringLiteral: "View and manage your VPN connection. Requires a Privacy Pro subscription.") + static let unknownLocation = UserText.vpnControlWidgetLocationUnknown public init() {} @@ -33,16 +34,35 @@ public struct VPNControlWidget: ControlWidget { StaticControlConfiguration(kind: .vpn, provider: VPNControlStatusValueProvider()) { status in - ControlWidgetToggle("DuckDuckGo\nVPN", isOn: status.isConnected, action: ControlWidgetToggleVPNIntent()) { isOn in + ControlWidgetToggle(title(status: status), isOn: status.isConnected, action: ControlWidgetToggleVPNIntent()) { isOn in if isOn { - Label("Connected", image: "ControlCenter-VPN-on") + Label(location(status: status), image: "ControlCenter-VPN-on") } else { - Label("Not Connected", image: "ControlCenter-VPN-off") + Label(UserText.vpnControlWidgetNotConnected, image: "ControlCenter-VPN-off") } } .tint(.green) }.displayName(Self.displayName) .description(Self.description) } + + private func title(status: VPNStatus) -> String { + if status.isConnected { + return UserText.vpnControlWidgetOn + } else { + return UserText.vpnControlWidgetOff + } + } + + private func location(status: VPNStatus) -> String { + if status.isConnecting { + return UserText.vpnControlWidgetConnecting + } else if status.isDisconnecting { + return UserText.vpnControlWidgetDisconnecting + } else if status.isConnected { + return UserDefaults.networkProtectionGroupDefaults.string(forKey: NetworkProtectionUserDefaultKeys.lastSelectedServerCity) ?? Self.unknownLocation + } else { + return Self.unknownLocation + } + } } -#endif diff --git a/Widgets/VPNWidget.swift b/Widgets/VPNWidget.swift index db9107220e..9fbee88914 100644 --- a/Widgets/VPNWidget.swift +++ b/Widgets/VPNWidget.swift @@ -31,6 +31,24 @@ enum VPNStatus { case error case notConfigured + var isConnecting: Bool { + switch self { + case .status(let status): + return status == .connecting + default: + return false + } + } + + var isDisconnecting: Bool { + switch self { + case .status(let status): + return status == .disconnecting + default: + return false + } + } + var isConnected: Bool { switch self { case .status(let status): diff --git a/Widgets/Widgets.swift b/Widgets/Widgets.swift index 6966e69c21..e12e774acb 100644 --- a/Widgets/Widgets.swift +++ b/Widgets/Widgets.swift @@ -259,11 +259,9 @@ struct VPNBundle: WidgetBundle { VPNSnoozeLiveActivity() } - #if ALPHA || DEBUG if #available(iOS 18, *) { VPNControlWidget() } - #endif } } diff --git a/Widgets/en.lproj/Localizable.strings b/Widgets/en.lproj/Localizable.strings index 9fd16ec9ad..53b0449de3 100644 --- a/Widgets/en.lproj/Localizable.strings +++ b/Widgets/en.lproj/Localizable.strings @@ -37,6 +37,24 @@ /* Title shown to the user when adding the Voice Search lock screen widget */ "lock.screen.widget.voice.title" = "Voice Search"; +/* Description for the control widget when connecting */ +"vpn.control.widget.connecting" = "Connecting..."; + +/* Description for the control widget when disconnecting */ +"vpn.control.widget.disconnecting" = "Disconnecting..."; + +/* Description for the control widget when the location is unknown */ +"vpn.control.widget.location-unknown" = "Unknown Location"; + +/* Description for the control widget when not connected */ +"vpn.control.widget.not-connected" = "Not Connected"; + +/* Title for the control widget when disabled */ +"vpn.control.widget.off" = "VPN is OFF"; + +/* Title for the control widget when enabled */ +"vpn.control.widget.on" = "VPN is ON"; + /* Description of search passwords widget in widget gallery */ "widget.gallery.passwords.description" = "Quickly search your saved DuckDuckGo passwords.";