diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index ec90cbc288..c3b0b496e8 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2749,6 +2749,10 @@ BDCB66D92C7CE1A700E8ABC9 /* VPNFeedbackFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCB66D72C7CE1A600E8ABC9 /* VPNFeedbackFormViewModelTests.swift */; }; BDE981D92BBD10D600645880 /* vpn-dark-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC82BBC821100EF3735 /* vpn-dark-mode.json */; }; BDE981DA2BBD10D600645880 /* vpn-light-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC72BBC821100EF3735 /* vpn-light-mode.json */; }; + C10529432C9CC18B0041E502 /* AutofillCredentialsDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10529422C9CC18B0041E502 /* AutofillCredentialsDebugView.swift */; }; + C10529442C9CC18B0041E502 /* AutofillCredentialsDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10529422C9CC18B0041E502 /* AutofillCredentialsDebugView.swift */; }; + C10529462C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10529452C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift */; }; + C10529472C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10529452C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift */; }; C1372EF42BBC5BAD003F8793 /* SecureTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1372EF32BBC5BAD003F8793 /* SecureTextField.swift */; }; C1372EF52BBC5BAD003F8793 /* SecureTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1372EF32BBC5BAD003F8793 /* SecureTextField.swift */; }; C13909EF2B85FD4E001626ED /* AutofillActionExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13909EE2B85FD4E001626ED /* AutofillActionExecutor.swift */; }; @@ -4555,6 +4559,8 @@ BDBA859B2C5D25A300BC54F5 /* VPNFeedbackFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewModel.swift; sourceTree = ""; }; BDBA859E2C5D25B700BC54F5 /* VPNMetadataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNMetadataCollector.swift; sourceTree = ""; }; BDCB66D72C7CE1A600E8ABC9 /* VPNFeedbackFormViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewModelTests.swift; sourceTree = ""; }; + C10529422C9CC18B0041E502 /* AutofillCredentialsDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillCredentialsDebugView.swift; sourceTree = ""; }; + C10529452C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillCredentialsDebugViewModel.swift; sourceTree = ""; }; C1372EF32BBC5BAD003F8793 /* SecureTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureTextField.swift; sourceTree = ""; }; C13909EE2B85FD4E001626ED /* AutofillActionExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillActionExecutor.swift; sourceTree = ""; }; C13909F32B85FD79001626ED /* AutofillDeleteAllPasswordsExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillDeleteAllPasswordsExecutorTests.swift; sourceTree = ""; }; @@ -6607,6 +6613,7 @@ 7B1E819A27C8874900FF0E60 /* Autofill */ = { isa = PBXGroup; children = ( + C10529482C9F45720041E502 /* Debug */, 7B1E819B27C8874900FF0E60 /* ContentOverlayPopover.swift */, 7B1E819C27C8874900FF0E60 /* ContentOverlay.storyboard */, 7B1E819D27C8874900FF0E60 /* ContentOverlayViewController.swift */, @@ -8986,6 +8993,15 @@ path = Assets; sourceTree = ""; }; + C10529482C9F45720041E502 /* Debug */ = { + isa = PBXGroup; + children = ( + C10529422C9CC18B0041E502 /* AutofillCredentialsDebugView.swift */, + C10529452C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift */, + ); + path = Debug; + sourceTree = ""; + }; C13909F22B85FD60001626ED /* Autofill */ = { isa = PBXGroup; children = ( @@ -10725,6 +10741,7 @@ 3706FAF7293F65D500E42796 /* FireproofDomainsViewController.swift in Sources */, F18826852BBEE31700D9AC4F /* PixelKit+Assertion.swift in Sources */, 4BF0E5062AD2551A00FFEC9E /* NetworkProtectionPixelEvent.swift in Sources */, + C10529442C9CC18B0041E502 /* AutofillCredentialsDebugView.swift in Sources */, 3706FAF8293F65D500E42796 /* URLEventHandler.swift in Sources */, 9FBD84742BB3E15D00220859 /* InstallationAttributionPixelHandler.swift in Sources */, BBFB72802C48047C0088884C /* SortBookmarksViewModel.swift in Sources */, @@ -11140,6 +11157,7 @@ 567A23D52C81E2180010F66C /* ContextualDaxDialogsFactory.swift in Sources */, 3706FBF3293F65D500E42796 /* PseudoFolder.swift in Sources */, 1D26EBAD2B74BECB0002A93F /* NSImageSendable.swift in Sources */, + C10529472C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift in Sources */, 7B7F5D252C52725A00826256 /* AddExcludedDomainButtonsView.swift in Sources */, 1D220BFD2B87AACF00F8BBC6 /* PrivacyProtectionStatus.swift in Sources */, 3706FBF8293F65D500E42796 /* TabBarFooter.swift in Sources */, @@ -12461,6 +12479,7 @@ B647EFBB2922584B00BA628D /* AdClickAttributionTabExtension.swift in Sources */, B677FC542B064A9C0099EB04 /* DataImportViewModel.swift in Sources */, 4B980E212817604000282EE1 /* NSNotificationName+Debug.swift in Sources */, + C10529432C9CC18B0041E502 /* AutofillCredentialsDebugView.swift in Sources */, B690152C2ACBF4DA00AD0BAB /* MenuPreview.swift in Sources */, 31F7F2A6288AD2CA001C0D64 /* NavigationBarBadgeAnimationView.swift in Sources */, AAC5E4F125D6BF10007F5990 /* AddressBarButton.swift in Sources */, @@ -12582,6 +12601,7 @@ B63D466925BEB6C200874977 /* WKWebView+SessionState.swift in Sources */, B6F1B0222BCE5658005E863C /* BrokenSiteInfoTabExtension.swift in Sources */, 3199AF7F2C80734A003AEBDC /* TabModalManageable.swift in Sources */, + C10529462C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift in Sources */, 4B4D60C02A0C848D00BCD287 /* NetworkProtectionControllerErrorStore.swift in Sources */, 4B723E1226B0006E00E14D75 /* DataImport.swift in Sources */, 7BE146072A6A83C700C313B8 /* NetworkProtectionDebugMenu.swift in Sources */, diff --git a/DuckDuckGo/Autofill/Debug/AutofillCredentialsDebugView.swift b/DuckDuckGo/Autofill/Debug/AutofillCredentialsDebugView.swift new file mode 100644 index 0000000000..d2332de520 --- /dev/null +++ b/DuckDuckGo/Autofill/Debug/AutofillCredentialsDebugView.swift @@ -0,0 +1,64 @@ +// +// AutofillCredentialsDebugView.swift +// +// 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 BrowserServicesKit +import Common +import SwiftUI + +@available(macOS 13.5, *) +struct AutofillCredentialsDebugView: ModalView { + + @ObservedObject private var model: AutofillCredentialsDebugViewModel + @State private var sortOrder = [KeyPathComparator(\AutofillCredentialsDebugViewModel.DisplayCredentials.accountId)] + + init(model: AutofillCredentialsDebugViewModel = AutofillCredentialsDebugViewModel()) { + self.model = model + } + + var body: some View { + Table(model.credentials, sortOrder: $sortOrder) { + TableColumn(Text(verbatim: "Id"), value: \.accountId) { selectableText($0.accountId) } + TableColumn(Text(verbatim: "Website URL"), value: \.websiteUrl) { selectableText($0.websiteUrl) } + TableColumn(Text(verbatim: "Domain"), value: \.domain) { selectableText($0.domain) } + TableColumn(Text(verbatim: "Username"), value: \.username) { selectableText($0.username) } + TableColumn(Text(verbatim: "Password"), value: \.displayPassword) { selectableText($0.displayPassword) } + TableColumn(Text(verbatim: "Notes"), value: \.notes) { selectableText($0.notes) } + TableColumn(Text(verbatim: "Created"), value: \.created) { selectableText($0.created) } + TableColumn(Text(verbatim: "Last Updated"), value: \.lastUpdated) { selectableText($0.lastUpdated) } + TableColumn(Text(verbatim: "Last Used"), value: \.lastUsed) { selectableText($0.lastUsed) } + TableColumn(Text(verbatim: "Signature"), value: \.signature) { selectableText($0.signature) } + } + .onChange(of: sortOrder) { _ in + applySort() + } + } + + private func selectableText(_ content: String) -> some View { + Text(content) + .textSelection(.enabled) + } + + private func applySort() { + model.credentials.sort(using: sortOrder) + } +} + +@available(macOS 13.5, *) +#Preview { + AutofillCredentialsDebugView(model: AutofillCredentialsDebugViewModel()) +} diff --git a/DuckDuckGo/Autofill/Debug/AutofillCredentialsDebugViewModel.swift b/DuckDuckGo/Autofill/Debug/AutofillCredentialsDebugViewModel.swift new file mode 100644 index 0000000000..69be0ed15f --- /dev/null +++ b/DuckDuckGo/Autofill/Debug/AutofillCredentialsDebugViewModel.swift @@ -0,0 +1,150 @@ +// +// AutofillCredentialsDebugViewModel.swift +// +// 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 Common +import os.log + +final class AutofillCredentialsDebugViewModel: ObservableObject { + + struct DisplayCredentials: Identifiable { + + let tld: TLD + let autofillDomainNameUrlMatcher: AutofillDomainNameUrlMatcher + var credential: SecureVaultModels.WebsiteCredentials + + var id = UUID() + + var accountId: String { + credential.account.id ?? "" + } + + var accountTitle: String { + credential.account.title ?? "" + } + + var displayTitle: String { + credential.account.name(tld: tld, autofillDomainNameUrlMatcher: autofillDomainNameUrlMatcher) + } + + var websiteUrl: String { + credential.account.domain ?? "" + } + + var domain: String { + guard let url = credential.account.domain, + let urlComponents = autofillDomainNameUrlMatcher.normalizeSchemeForAutofill(url), + let domain = urlComponents.eTLDplus1(tld: tld) ?? urlComponents.host else { + return "" + } + return domain + } + + var username: String { + credential.account.username ?? "" + } + + var displayPassword: String { + return credential.password.flatMap { String(data: $0, encoding: .utf8) } ?? "FAILED TO DECODE PW" + } + + var notes: String { + credential.account.notes ?? "" + } + + var created: String { + "\(credential.account.created)" + } + + var lastUpdated: String { + "\(credential.account.lastUpdated)" + } + + var lastUsed: String { + credential.account.lastUsed != nil ? "\(credential.account.lastUsed!)" : "" + } + + var signature: String { + credential.account.signature ?? "" + } + } + + private let tld: TLD = ContentBlocking.shared.tld + private let autofillDomainNameUrlMatcher: AutofillDomainNameUrlMatcher = AutofillDomainNameUrlMatcher() + private var userAuthenticator: UserAuthenticating + + @Published var credentials: [DisplayCredentials] = [] + + init(userAuthenticator: UserAuthenticating = DeviceAuthenticator.shared) { + self.userAuthenticator = userAuthenticator + beginAuthentication() + } + + private func beginAuthentication() { + userAuthenticator.authenticateUser(reason: .viewAllCredentials) { [weak self] authenticationResult in + guard let self = self else { + return + } + + if authenticationResult.authenticated { + self.credentials = self.loadCredentials() + } + } + } + + private func loadCredentials() -> [DisplayCredentials] { + do { + let secureVault = try AutofillSecureVaultFactory.makeVault(reporter: SecureVaultReporter.shared) + let accounts = try secureVault.accounts() + var credentials: [DisplayCredentials] = [] + var accountsFailedToLoad: [String?] = [] + + for account in accounts { + guard let accountId = account.id, + let accountIdInt = Int64(accountId), + let credential = try secureVault.websiteCredentialsFor(accountId: accountIdInt) else { + accountsFailedToLoad.append(account.id) + continue + } + + let displayCredential = DisplayCredentials(tld: tld, autofillDomainNameUrlMatcher: autofillDomainNameUrlMatcher, credential: credential) + credentials.append(displayCredential) + } + + if !accountsFailedToLoad.isEmpty { + os_log("Failed to load credentials for accounts: %@", accountsFailedToLoad) + showErrorAlertFor(accountsFailedToLoad) + } + + return credentials + } catch { + os_log("Failed to fetch accounts") + return [] + } + } + + private func showErrorAlertFor(_ accountIds: [String?]) { + let alert = NSAlert() + alert.messageText = "Failed to load credentials for accounts:" + alert.informativeText = accountIds.compactMap { $0 }.joined(separator: ", ") + alert.alertStyle = .warning + alert.addButton(withTitle: UserText.ok) + alert.runModal() + } +} diff --git a/DuckDuckGo/DeviceAuthentication/DeviceAuthenticator.swift b/DuckDuckGo/DeviceAuthentication/DeviceAuthenticator.swift index aad803bf55..0f2ba0ea50 100644 --- a/DuckDuckGo/DeviceAuthentication/DeviceAuthenticator.swift +++ b/DuckDuckGo/DeviceAuthentication/DeviceAuthenticator.swift @@ -42,6 +42,7 @@ final class DeviceAuthenticator: UserAuthenticating { case exportLogins case syncSettings case deleteAllPasswords + case viewAllCredentials var localizedDescription: String { switch self { @@ -51,6 +52,7 @@ final class DeviceAuthenticator: UserAuthenticating { case .exportLogins: return UserText.pmAutoLockPromptExportLogins case .syncSettings: return UserText.syncAutoLockPrompt case .deleteAllPasswords: return UserText.deleteAllPasswordsPermissionText + case .viewAllCredentials: return UserText.pmAutoLockPromptUnlockLogins } } } @@ -163,7 +165,9 @@ final class DeviceAuthenticator: UserAuthenticating { let needsAuthenticationForCreditCardsAutofill = reason == .autofillCreditCards && isCreditCardTimeIntervalExpired() let needsAuthenticationForSyncSettings = reason == .syncSettings && isSyncSettingsTimeIntervalExpired() let needsAuthenticationForDeleteAllPasswords = reason == .deleteAllPasswords - guard needsAuthenticationForCreditCardsAutofill || needsAuthenticationForSyncSettings || needsAuthenticationForDeleteAllPasswords || requiresAuthentication else { + let needsAuthenticationForViewAllCredentials = reason == .viewAllCredentials + guard needsAuthenticationForCreditCardsAutofill || needsAuthenticationForSyncSettings || needsAuthenticationForDeleteAllPasswords || needsAuthenticationForViewAllCredentials || + requiresAuthentication else { result(.success) return } diff --git a/DuckDuckGo/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index de6b7c1fb1..b540f08adf 100644 --- a/DuckDuckGo/Menus/MainMenu.swift +++ b/DuckDuckGo/Menus/MainMenu.swift @@ -640,6 +640,12 @@ final class MainMenu: NSMenu { .submenu(NetworkProtectionDebugMenu()) } + if #available(macOS 13.5, *) { + NSMenuItem(title: "Autofill") { + NSMenuItem(title: "View all Credentials", action: #selector(MainViewController.showAllCredentials)).withAccessibilityIdentifier("MainMenu.showAllCredentials") + } + } + NSMenuItem(title: "Simulate crash") { NSMenuItem(title: "fatalError", action: #selector(MainViewController.triggerFatalError)) NSMenuItem(title: "NSException", action: #selector(MainViewController.crashOnException)) diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 1697d2b063..4b8efc9ad1 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -26,6 +26,7 @@ import PixelKit import Subscription import WebKit import os.log +import SwiftUI // Actions are sent to objects of responder chain @@ -964,6 +965,24 @@ extension MainViewController { setConfigurationUrl(nil) } + @available(macOS 13.5, *) + @objc func showAllCredentials(_ sender: Any?) { + let hostingView = NSHostingView(rootView: AutofillCredentialsDebugView()) + hostingView.translatesAutoresizingMaskIntoConstraints = false + hostingView.frame.size = hostingView.intrinsicContentSize + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1400, height: 700), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, defer: false) + + window.center() + window.title = "Credentials" + window.contentView = hostingView + window.isReleasedWhenClosed = false + window.makeKeyAndOrderFront(nil) + } + // MARK: - Developer Tools @objc func toggleDeveloperTools(_ sender: Any?) {