diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5a39eb4c63..3b5b68023d 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2882,6 +2882,8 @@ EEC8EB402982CD550065AA39 /* JSAlertViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF53E172950CED5002D78F4 /* JSAlertViewModelTests.swift */; }; EECE10E529DD77E60044D027 /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = EECE10E429DD77E60044D027 /* FeatureFlag.swift */; }; EECE10E629DD77E60044D027 /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = EECE10E429DD77E60044D027 /* FeatureFlag.swift */; }; + EED4D3D82C874AE200C79EEA /* PopoverInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED4D3D72C874AE200C79EEA /* PopoverInfoViewController.swift */; }; + EED4D3D92C874AE200C79EEA /* PopoverInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED4D3D72C874AE200C79EEA /* PopoverInfoViewController.swift */; }; EED4D3DF2C8A298D00C79EEA /* AutofillPixelEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED4D3DE2C8A298D00C79EEA /* AutofillPixelEvent.swift */; }; EED4D3E02C8A298D00C79EEA /* AutofillPixelEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED4D3DE2C8A298D00C79EEA /* AutofillPixelEvent.swift */; }; EED735362BB46B6000F173D6 /* AutocompleteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED735352BB46B6000F173D6 /* AutocompleteTests.swift */; }; @@ -4616,6 +4618,7 @@ EEC4A6702B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNLocationPreferenceItem.swift; sourceTree = ""; }; EEC7BE2D2BC6C09400F86835 /* AddressBarKeyboardShortcutsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddressBarKeyboardShortcutsTests.swift; sourceTree = ""; }; EECE10E429DD77E60044D027 /* FeatureFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlag.swift; sourceTree = ""; }; + EED4D3D72C874AE200C79EEA /* PopoverInfoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoverInfoViewController.swift; sourceTree = ""; }; EED4D3DE2C8A298D00C79EEA /* AutofillPixelEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillPixelEvent.swift; sourceTree = ""; }; EED735352BB46B6000F173D6 /* AutocompleteTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutocompleteTests.swift; sourceTree = ""; }; EED9A6732C37FE6800E0FAB9 /* login_deduplication_test_data.csv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = login_deduplication_test_data.csv; sourceTree = ""; }; @@ -7415,6 +7418,7 @@ B65536902684409300085A79 /* Geolocation */, AAE75275263B036300B973F8 /* History */, AAE71DB225F66A0900D74437 /* HomePage */, + EED4D3D62C87480B00C79EEA /* InfoViews */, 56CEE9092B7A66C500CF10AA /* Info.plist */, 56CEE90D2B7A6DE100CF10AA /* InfoPlist.xcstrings */, EEAEA3F4294D05CF00D04DF3 /* JSAlert */, @@ -9168,6 +9172,14 @@ path = AppAndExtensionAndAgentTargets; sourceTree = ""; }; + EED4D3D62C87480B00C79EEA /* InfoViews */ = { + isa = PBXGroup; + children = ( + EED4D3D72C874AE200C79EEA /* PopoverInfoViewController.swift */, + ); + path = InfoViews; + sourceTree = ""; + }; EEE0E1CB2C32F53C0058E148 /* DataImport */ = { isa = PBXGroup; children = ( @@ -10656,6 +10668,7 @@ FD22255E2C64B68500199373 /* AutoconsentExperiment.swift in Sources */, 3706FAD7293F65D500E42796 /* Feedback.swift in Sources */, 1D0DE9422C3BB9CC0037ABC2 /* ReleaseNotesParser.swift in Sources */, + EED4D3D92C874AE200C79EEA /* PopoverInfoViewController.swift in Sources */, 3707C722294B5D2900682A9F /* WKWebViewExtension.swift in Sources */, 3706FAD9293F65D500E42796 /* FirefoxFaviconsReader.swift in Sources */, 3706FADB293F65D500E42796 /* ContentBlockingRulesUpdateObserver.swift in Sources */, @@ -12564,6 +12577,7 @@ 856CADF0271710F400E79BB0 /* HoverUserScript.swift in Sources */, B6DE57F62B05EA9000CD54B9 /* SheetHostingWindow.swift in Sources */, AA6EF9B525081B4C004754E6 /* MainMenuActions.swift in Sources */, + EED4D3D82C874AE200C79EEA /* PopoverInfoViewController.swift in Sources */, 56A0541F2C1CA1F5007D8FAB /* OnboardingTabExtension.swift in Sources */, B63D466925BEB6C200874977 /* WKWebView+SessionState.swift in Sources */, B6F1B0222BCE5658005E863C /* BrokenSiteInfoTabExtension.swift in Sources */, diff --git a/DuckDuckGo/Assets.xcassets/Colors/InfoHoverButton.colorset/Contents.json b/DuckDuckGo/Assets.xcassets/Colors/InfoHoverButton.colorset/Contents.json new file mode 100644 index 0000000000..524f806670 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Colors/InfoHoverButton.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.090", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.090", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Colors/InfoHoverButtonHovered.colorset/Contents.json b/DuckDuckGo/Assets.xcassets/Colors/InfoHoverButtonHovered.colorset/Contents.json new file mode 100644 index 0000000000..6c1feac8d2 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Colors/InfoHoverButtonHovered.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.180", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.180", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Color-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Color-16.imageset/Contents.json new file mode 100644 index 0000000000..d159b38fd4 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Color-16.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Lock-Color-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Color-16.imageset/Lock-Color-16.pdf b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Color-16.imageset/Lock-Color-16.pdf new file mode 100644 index 0000000000..067bb5fa62 Binary files /dev/null and b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Color-16.imageset/Lock-Color-16.pdf differ diff --git a/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Solid-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Solid-16.imageset/Contents.json new file mode 100644 index 0000000000..ce371320cc --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Solid-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Lock-Solid-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Solid-16.imageset/Lock-Solid-16.pdf b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Solid-16.imageset/Lock-Solid-16.pdf new file mode 100644 index 0000000000..b1a5584f9c Binary files /dev/null and b/DuckDuckGo/Assets.xcassets/Images/Autofill/Lock-Solid-16.imageset/Lock-Solid-16.pdf differ diff --git a/DuckDuckGo/Common/Extensions/URLExtension.swift b/DuckDuckGo/Common/Extensions/URLExtension.swift index 037601249a..703b6afefc 100644 --- a/DuckDuckGo/Common/Extensions/URLExtension.swift +++ b/DuckDuckGo/Common/Extensions/URLExtension.swift @@ -382,6 +382,10 @@ extension URL { return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/search-privacy/")! } + static var passwordManagerLearnMore: URL { + return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/")! + } + static var searchSettings: URL { return URL(string: "https://duckduckgo.com/settings/")! } diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 206e516753..43f61cc86a 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -727,6 +727,8 @@ struct UserText { } static let importLoginsPasswords = NSLocalizedString("import.logins.passwords", value: "Passwords", comment: "Title text for the Passwords import option") + static let importLoginsPasswordsExplainer = NSLocalizedString("import.logins.passwords.explainer", value: "Passwords are encrypted. Viewing them or filling out forms requires Touch ID or a password. Nobody but you can see your passwords, not even us. Find Passwords in DuckDuckGo Settings > Passwords & Autofill.", comment: "Explanatory text for the Passwords import option to alleviate security concerns and explain usage.") + static let importLoginsPasswordsExplainerAutolockOff = NSLocalizedString("import.logins.passwords.explainer.autolock.off", value: "Passwords are encrypted. We recommend setting up Auto-lock to keep your passwords even more secure. Set it up in DuckDuckGo Settings > Passwords & Autofill.", comment: "Explanatory text for the Passwords import option to alleviate security concerns and explain usage when autolock is disabled") static let importBookmarksButtonTitle = NSLocalizedString("bookmarks.import.button.title", value: "Import", comment: "Button text to open bookmark import dialog") static let initiateImport = NSLocalizedString("import.data.initiate", value: "Import", comment: "Button text for importing data") diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index 5aa2833b48..c11c0868b0 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -133,11 +133,14 @@ struct DataImportViewModel { #endif + let isPasswordManagerAutolockEnabled: Bool + init(importSource: Source? = nil, screen: Screen? = nil, availableImportSources: [DataImport.Source] = Source.allCases.filter { $0.canImportData }, preferredImportSources: [Source] = [.chrome, .firefox, .safari], summary: [DataTypeImportResult] = [], + isPasswordManagerAutolockEnabled: Bool = AutofillPreferences().isAutoLockEnabled, loadProfiles: @escaping (ThirdPartyBrowser) -> BrowserProfileList = { $0.browserProfiles() }, dataImporterFactory: @escaping DataImporterFactory = dataImporter, requestPrimaryPasswordCallback: @escaping @MainActor (Source) -> String? = Self.requestPrimaryPasswordCallback, @@ -161,6 +164,7 @@ struct DataImportViewModel { self.selectedDataTypes = importSource.supportedDataTypes self.summary = summary + self.isPasswordManagerAutolockEnabled = isPasswordManagerAutolockEnabled self.requestPrimaryPasswordCallback = requestPrimaryPasswordCallback self.openPanelCallback = openPanelCallback @@ -683,7 +687,7 @@ extension DataImportViewModel { } mutating func update(with importSource: Source) { - self = .init(importSource: importSource, loadProfiles: loadProfiles, dataImporterFactory: dataImporterFactory, requestPrimaryPasswordCallback: requestPrimaryPasswordCallback, reportSenderFactory: reportSenderFactory, onFinished: onFinished, onCancelled: onCancelled) + self = .init(importSource: importSource, isPasswordManagerAutolockEnabled: isPasswordManagerAutolockEnabled, loadProfiles: loadProfiles, dataImporterFactory: dataImporterFactory, requestPrimaryPasswordCallback: requestPrimaryPasswordCallback, reportSenderFactory: reportSenderFactory, onFinished: onFinished, onCancelled: onCancelled) } @MainActor diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index ab880b0415..dee9ecc021 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -119,7 +119,7 @@ struct DataImportView: ModalView { DataImportTypePicker(viewModel: $model) .disabled(model.isImportSourcePickerDisabled) - importPasswordSubtitle() + passwordsExplainerView().padding(.top, 20) case .moreInfo: // you will be asked for your keychain password blah blah... @@ -159,7 +159,7 @@ struct DataImportView: ModalView { } if dataType == .passwords { - importPasswordSubtitle() + passwordsExplainerView().padding(.top, 20) } case .summary(let dataTypes, let isFileImport): @@ -208,11 +208,18 @@ struct DataImportView: ModalView { } } - private func importPasswordSubtitle() -> some View { - Text(UserText.importDataSubtitle) - .font(.subheadline) - .foregroundColor(Color(.greyText)) - .padding(.top, 16) + private func passwordsExplainerView() -> some View { + HStack(alignment: .top, spacing: 8) { + Image(.lockColor16) + Text(model.isPasswordManagerAutolockEnabled ? UserText.importLoginsPasswordsExplainer : UserText.importLoginsPasswordsExplainerAutolockOff) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .topLeading) + .padding(14) + .background(Color.blackWhite1) + .roundedBorder() } private func handleImportProgress(_ progress: TaskProgress) async { diff --git a/DuckDuckGo/InfoViews/PopoverInfoViewController.swift b/DuckDuckGo/InfoViews/PopoverInfoViewController.swift new file mode 100644 index 0000000000..a8c37f6f73 --- /dev/null +++ b/DuckDuckGo/InfoViews/PopoverInfoViewController.swift @@ -0,0 +1,171 @@ +// +// PopoverInfoViewController.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 AppKit +import SwiftUI +import SwiftUIExtensions + +final class PopoverInfoViewController: NSHostingController { + + enum Constants { + static let autoDismissDuration: TimeInterval = 0.5 + } + + let onDismiss: (() -> Void)? + let autoDismissDuration: TimeInterval + private var timer: Timer? + private var trackingArea: NSTrackingArea? + + init(message: String, + autoDismissDuration: TimeInterval = Constants.autoDismissDuration, + onDismiss: (() -> Void)? = nil) { + self.onDismiss = onDismiss + self.autoDismissDuration = autoDismissDuration + super.init(rootView: InfoView(info: message)) + let popoverBackground = PopoverInfoContentView() + view.addSubview(popoverBackground, positioned: .below, relativeTo: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + cancelAutoDismiss() + onDismiss?() + } + + override func viewDidAppear() { + super.viewDidAppear() + scheduleAutoDismiss() + createTrackingArea() + } + + func show(onParent parent: NSViewController, rect: NSRect, of view: NSView) { + // Set the content size to match the SwiftUI view's intrinsic size + self.preferredContentSize = self.view.fittingSize + + parent.present(self, + asPopoverRelativeTo: rect, + of: view, + preferredEdge: .maxY, + behavior: .applicationDefined) + } + + func show(onParent parent: NSViewController, relativeTo view: NSView) { + // Set the content size to match the SwiftUI view's intrinsic size + self.preferredContentSize = self.view.fittingSize + // For shorter strings, the positioning can be off unless the width is set a second time + self.preferredContentSize.width = self.view.fittingSize.width + + parent.present(self, + asPopoverRelativeTo: self.view.bounds, + of: view, + preferredEdge: .maxY, + behavior: .applicationDefined) + let presentingViewTrackingArea = NSTrackingArea(rect: self.view.convert(self.view.frame, from: view), + options: [.mouseEnteredAndExited, .activeInKeyWindow], + owner: self) + view.addTrackingArea(presentingViewTrackingArea) + } + + // MARK: - Auto Dismissal + func cancelAutoDismiss() { + timer?.invalidate() + timer = nil + } + + func scheduleAutoDismiss() { + cancelAutoDismiss() + timer = Timer.scheduledTimer(withTimeInterval: autoDismissDuration, repeats: false) { [weak self] _ in + guard let self = self else { return } + self.presentingViewController?.dismiss(self) + } + } + + // MARK: - Mouse Tracking + private func createTrackingArea() { + trackingArea = NSTrackingArea(rect: view.bounds, + options: [.mouseEnteredAndExited, .activeInKeyWindow], + owner: self, + userInfo: nil) + view.addTrackingArea(trackingArea!) + } + + override func mouseEntered(with event: NSEvent) { + cancelAutoDismiss() + } + + override func mouseExited(with event: NSEvent) { + scheduleAutoDismiss() + } + + override func mouseDown(with event: NSEvent) { + dismissPopover() + } + + private func dismissPopover() { + presentingViewController?.dismiss(self) + } +} + +struct InfoView: View { + let info: String + + var body: some View { + Text(.init(info)) + .onURLTap { url in + if let pane = PreferencePaneIdentifier(url: url) { + WindowControllersManager.shared.showPreferencesTab(withSelectedPane: pane) + } else { + WindowControllersManager.shared.showTab(with: .url(url, source: .link)) + } + } + .padding(16) + .frame(width: 250, alignment: .leading) + .frame(minHeight: 22) + .lineLimit(nil) + } +} + +private final class PopoverInfoContentView: NSView { + var backgroundView: PopoverInfoBackgroundView? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let frameView = self.window?.contentView?.superview { + if backgroundView == nil { + backgroundView = PopoverInfoBackgroundView(frame: frameView.bounds) + backgroundView!.autoresizingMask = NSView.AutoresizingMask([.width, .height]) + frameView.addSubview(backgroundView!, positioned: NSWindow.OrderingMode.below, relativeTo: frameView) + } + } + } +} + +private final class PopoverInfoBackgroundView: NSView { + var backgroundColor: NSColor = NSColor.controlColor { + didSet { + draw(bounds) + } + } + override func draw(_ dirtyRect: NSRect) { + backgroundColor.set() + self.bounds.fill() + } +} diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 1a2b36e9a4..6c784cd314 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -29340,6 +29340,30 @@ } } }, + "import.logins.passwords.explainer" : { + "comment" : "Explanatory text for the Passwords import option to alleviate security concerns and explain usage.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords are encrypted. Viewing them or filling out forms requires Touch ID or a password. Nobody but you can see your passwords, not even us. Find Passwords in DuckDuckGo Settings > Passwords & Autofill." + } + } + } + }, + "import.logins.passwords.explainer.autolock.off" : { + "comment" : "Explanatory text for the Passwords import option to alleviate security concerns and explain usage when autolock is disabled", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords are encrypted. We recommend setting up Auto-lock to keep your passwords even more secure. Set it up in DuckDuckGo Settings > Passwords & Autofill." + } + } + } + }, "import.logins.select-csv-file" : { "comment" : "Button text for selecting a CSV file", "extractionState" : "extracted_with_value", @@ -44489,7 +44513,7 @@ }, "pm.empty.default.description" : { "comment" : "Label for default empty state description", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -44547,6 +44571,18 @@ } } }, + "pm.empty.default.description.extended.v2" : { + "comment" : "Label for default empty state description\n Label for default empty state description when the autolock feature is off", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords are encrypted. Nobody but you can see them, not even us." + } + } + } + }, "pm.empty.default.title" : { "comment" : "Label for default empty state title", "extractionState" : "extracted_with_value", @@ -44667,6 +44703,18 @@ } } }, + "pm.empty.learn.more.link" : { + "comment" : "Text for link to learn more about DuckDuckGo password manager", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Learn more" + } + } + } + }, "pm.empty.logins.title" : { "comment" : "Label for logins empty state title", "extractionState" : "extracted_with_value", @@ -47367,6 +47415,18 @@ } } }, + "pm.save-credentials.security.info" : { + "comment" : "Info message for the save credentials dialog\n Info message for the save credentials dialog when the autolock feature is off", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passwords are encrypted. Nobody but you can see them, not even us. [Learn More]( (URL.passwordManagerLearnMore))" + } + } + } + }, "pm.signin.to.manage" : { "comment" : "Message displayed to the user when they are logged out of Email protection.", "extractionState" : "extracted_with_value", diff --git a/DuckDuckGo/Preferences/Model/AutofillPreferences.swift b/DuckDuckGo/Preferences/Model/AutofillPreferences.swift index 08c5b5d8ad..f9ef1b3a15 100644 --- a/DuckDuckGo/Preferences/Model/AutofillPreferences.swift +++ b/DuckDuckGo/Preferences/Model/AutofillPreferences.swift @@ -163,6 +163,12 @@ final class AutofillPreferences: AutofillPreferencesPersistor { private let injectedDependencyStore: StatisticsStore? private lazy var defaultDependencyStore: StatisticsStore = { +#if DEBUG + // To prevent an assertion failure deep within dependencies in Database.makeDatabase + if [.unitTests, .xcPreviews].contains(NSApp.runType) { + return StubStatisticsStore() + } +#endif return LocalStatisticsStore() }() diff --git a/DuckDuckGo/SecureVault/Extensions/UserText+PasswordManager.swift b/DuckDuckGo/SecureVault/Extensions/UserText+PasswordManager.swift index 1324cf72bb..a61f15e218 100644 --- a/DuckDuckGo/SecureVault/Extensions/UserText+PasswordManager.swift +++ b/DuckDuckGo/SecureVault/Extensions/UserText+PasswordManager.swift @@ -22,12 +22,18 @@ extension UserText { static let pmSaveCredentialsEditableTitle = NSLocalizedString("pm.save-credentials.editable.title", value: "Save password in DuckDuckGo?", comment: "Title for the editable Save Credentials popover") static let pmSaveCredentialsNonEditableTitle = NSLocalizedString("pm.save-credentials.non-editable.title", value: "New password saved", comment: "Title for the non-editable Save Credentials popover") + static let pmSaveCredentialsSecurityInfo = NSLocalizedString("pm.save-credentials.security.info", value: "Passwords are encrypted. Nobody but you can see them, not even us. [Learn More]( \(URL.passwordManagerLearnMore))", comment: "Info message for the save credentials dialog") + static let pmSaveCredentialsSecurityInfoAutolockOff = NSLocalizedString("pm.save-credentials.security.info", value: "Passwords are encrypted. We recommend setting up Auto-lock to keep your passwords even more secure. [Go to Settings](\(URL.settingsPane(.autofill)))", comment: "Info message for the save credentials dialog when the autolock feature is off") static let pmUpdateCredentialsTitle = NSLocalizedString("pm.update-credentials.title", value: "Update password?", comment: "Title for the Update Credentials popover") static let pmEmptyStateDefaultTitle = NSLocalizedString("pm.empty.default.title", value: "No passwords or credit cards saved yet", comment: "Label for default empty state title") - static let pmEmptyStateDefaultDescription = NSLocalizedString("pm.empty.default.description", - value: "If your passwords are saved in another browser, you can import them into DuckDuckGo.", + static let pmEmptyStateDefaultDescription = NSLocalizedString("pm.empty.default.description.extended.v2", + value: "Passwords are encrypted. Nobody but you can see them, not even us.", comment: "Label for default empty state description") + static let pmEmptyStateDefaultDescriptionAutolockOff = NSLocalizedString("pm.empty.default.description.extended.v2", + value: "Passwords are encrypted.", + comment: "Label for default empty state description when the autolock feature is off") + static let pmEmptyStateLearnMoreLink = NSLocalizedString("pm.empty.learn.more.link", value: "Learn more", comment: "Text for link to learn more about DuckDuckGo password manager") static let pmEmptyStateDefaultButtonTitle = NSLocalizedString("pm.empty.default.button.title", value: "Import Passwords", comment: "Import passwords button title for default empty state") static let pmEmptyStateLoginsTitle = NSLocalizedString("pm.empty.logins.title", value: "No passwords saved yet", comment: "Label for logins empty state title") diff --git a/DuckDuckGo/SecureVault/Model/PasswordManagementItemListModel.swift b/DuckDuckGo/SecureVault/Model/PasswordManagementItemListModel.swift index 2c14c32cd3..21eef35196 100644 --- a/DuckDuckGo/SecureVault/Model/PasswordManagementItemListModel.swift +++ b/DuckDuckGo/SecureVault/Model/PasswordManagementItemListModel.swift @@ -290,6 +290,7 @@ final class PasswordManagementItemListModel: ObservableObject { } } } + @Published var syncPromoSelected: Bool = false { didSet { if syncPromoSelected { @@ -297,12 +298,26 @@ final class PasswordManagementItemListModel: ObservableObject { } } } + + var emptyStateMessageDescription: String { + autofillPreferences.isAutoLockEnabled ? UserText.pmEmptyStateDefaultDescription : UserText.pmEmptyStateDefaultDescriptionAutolockOff + } + + var emptyStateMessageLinkText: String { + UserText.learnMore + } + + var emptyStateMessageLinkURL: URL { + URL.passwordManagerLearnMore + } + @Published private(set) var emptyState: EmptyState = .none @Published var canChangeCategory: Bool = true private var onItemSelected: (_ old: SecureVaultItem?, _ new: SecureVaultItem?) -> Void private var onAddItemSelected: (_ category: SecureVaultSorting.Category) -> Void private let tld: TLD + private let autofillPreferences: AutofillPreferencesPersistor private let urlMatcher: AutofillDomainNameUrlMatcher private static let randomColorsCount = 15 @@ -310,6 +325,7 @@ final class PasswordManagementItemListModel: ObservableObject { syncPromoManager: SyncPromoManaging, urlMatcher: AutofillDomainNameUrlMatcher = AutofillDomainNameUrlMatcher(), tld: TLD = ContentBlocking.shared.tld, + autofillPreferences: AutofillPreferencesPersistor = AutofillPreferences(), onItemSelected: @escaping (_ old: SecureVaultItem?, _ new: SecureVaultItem?) -> Void, onAddItemSelected: @escaping (_ category: SecureVaultSorting.Category) -> Void) { self.onItemSelected = onItemSelected @@ -318,6 +334,7 @@ final class PasswordManagementItemListModel: ObservableObject { self.syncPromoManager = syncPromoManager self.urlMatcher = urlMatcher self.tld = tld + self.autofillPreferences = autofillPreferences } func update(items: [SecureVaultItem]) { diff --git a/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift b/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift index 6d9ddcdd7d..3d322ed17d 100644 --- a/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift +++ b/DuckDuckGo/SecureVault/View/PasswordManagementViewController.swift @@ -63,7 +63,8 @@ final class PasswordManagementViewController: NSViewController { @IBOutlet var emptyState: NSView! @IBOutlet var emptyStateImageView: NSImageView! @IBOutlet var emptyStateTitle: NSTextField! - @IBOutlet var emptyStateMessage: NSTextField! + @IBOutlet var emptyStateMessage: NSTextView! + @IBOutlet var emptyStateMessageHeight: NSLayoutConstraint! @IBOutlet var emptyStateButton: NSButton! @IBOutlet weak var exportLoginItem: NSMenuItem! @IBOutlet var lockScreen: NSView! @@ -171,7 +172,10 @@ final class PasswordManagementViewController: NSViewController { reloadDataAfterSyncCancellable = bindSyncDidFinish() emptyStateTitle.attributedStringValue = NSAttributedString.make(emptyStateTitle.stringValue, lineHeight: 1.14, kern: -0.23) - emptyStateMessage.attributedStringValue = NSAttributedString.make(emptyStateMessage.stringValue, lineHeight: 1.05, kern: -0.08) + + emptyStateMessage.isSelectable = true + emptyStateMessage.delegate = self + setUpEmptyStateMessageAttributedText() addVaultItemButton.toolTip = UserText.addItemTooltip moreButton.toolTip = UserText.moreOptionsTooltip @@ -197,6 +201,49 @@ final class PasswordManagementViewController: NSViewController { .store(in: &cancellables) } + private func setUpEmptyStateMessageAttributedText() { + guard let listModel else { return } + emptyStateMessage.delegate = self + + let linkAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.linkBlue, + .cursor: NSCursor.pointingHand + ] + + emptyStateMessage.linkTextAttributes = linkAttributes + + let attachment = NSTextAttachment() + attachment.image = NSImage(resource: .lockSolid16).tinted(with: NSColor.blackWhite80) + attachment.bounds = CGRect(x: 0, y: -1, width: 12, height: 12) + let attributedTextImage = NSMutableAttributedString(attachment: attachment) + + let string = NSMutableAttributedString(attributedString: attributedTextImage) + + let messageString = NSMutableAttributedString(string: " " + listModel.emptyStateMessageDescription + " ") + string.append(messageString) + + let linkString = NSMutableAttributedString(string: listModel.emptyStateMessageLinkText, attributes: [ + .link: listModel.emptyStateMessageLinkURL + ]) + string.append(linkString) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + string.addAttributes([ + .cursor: NSCursor.arrow, + .paragraphStyle: paragraphStyle, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + .foregroundColor: NSColor.blackWhite80 + ], range: NSRange(location: 0, length: string.length)) + + let maxSize = NSSize(width: 280, height: 20000) + let bounds = string.boundingRect(with: maxSize, options: .usesLineFragmentOrigin) + + emptyStateMessageHeight.constant = bounds.height + + emptyStateMessage.textStorage?.setAttributedString(string) + } + private func setupStrings() { importPasswordMenuItem.title = UserText.importPasswords exportLoginItem.title = UserText.exportLogins @@ -205,7 +252,7 @@ final class PasswordManagementViewController: NSViewController { unlockYourAutofillLabel.title = UserText.passwordManagerUnlockAutofill autofillTitleLabel.stringValue = UserText.passwordManagementTitle emptyStateTitle.stringValue = UserText.pmEmptyStateDefaultTitle - emptyStateMessage.stringValue = UserText.pmEmptyStateDefaultDescription + setUpEmptyStateMessageAttributedText() emptyStateButton.title = UserText.pmEmptyStateDefaultButtonTitle } @@ -1016,7 +1063,7 @@ final class PasswordManagementViewController: NSViewController { private func showEmptyState(category: SecureVaultSorting.Category) { switch category { - case .allItems: showEmptyState(image: .passwordsAdd128, title: UserText.pmEmptyStateDefaultTitle, message: UserText.pmEmptyStateDefaultDescription, hideMessage: false, hideButton: false) + case .allItems: showEmptyState(image: .passwordsAdd128, title: UserText.pmEmptyStateDefaultTitle, hideMessage: false, hideButton: false) case .logins: showEmptyState(image: .passwordsAdd128, title: UserText.pmEmptyStateLoginsTitle, hideMessage: false, hideButton: false) case .identities: showEmptyState(image: .identityAdd128, title: UserText.pmEmptyStateIdentitiesTitle) case .cards: showEmptyState(image: .creditCardsAdd128, title: UserText.pmEmptyStateCardsTitle) @@ -1027,12 +1074,12 @@ final class PasswordManagementViewController: NSViewController { emptyState.isHidden = true } - private func showEmptyState(image: NSImage, title: String, message: String? = nil, hideMessage: Bool = true, hideButton: Bool = true) { + private func showEmptyState(image: NSImage, title: String, hideMessage: Bool = true, hideButton: Bool = true) { emptyState.isHidden = false emptyStateImageView.image = image emptyStateTitle.attributedStringValue = NSAttributedString.make(title, lineHeight: 1.14, kern: -0.23) - if let message { - emptyStateMessage.attributedStringValue = NSAttributedString.make(message, lineHeight: 1.05, kern: -0.08) + if !hideMessage { + setUpEmptyStateMessageAttributedText() } emptyStateMessage.isHidden = hideMessage emptyStateButton.isHidden = hideButton @@ -1069,14 +1116,17 @@ extension PasswordManagementViewController: NSTextFieldDelegate { func controlTextDidChange(_ obj: Notification) { updateFilter() } - } extension PasswordManagementViewController: NSTextViewDelegate { func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { - if let link = link as? URL, let pane = PreferencePaneIdentifier(url: link) { - WindowControllersManager.shared.showPreferencesTab(withSelectedPane: pane) + if let link = link as? URL { + if let pane = PreferencePaneIdentifier(url: link) { + WindowControllersManager.shared.showPreferencesTab(withSelectedPane: pane) + } else { + WindowControllersManager.shared.showTab(with: .url(link, source: .link)) + } self.dismiss() } diff --git a/DuckDuckGo/SecureVault/View/PasswordManager.storyboard b/DuckDuckGo/SecureVault/View/PasswordManager.storyboard index e8f73afdc7..f256d250f0 100644 --- a/DuckDuckGo/SecureVault/View/PasswordManager.storyboard +++ b/DuckDuckGo/SecureVault/View/PasswordManager.storyboard @@ -46,11 +46,11 @@ - - + + - + @@ -58,9 +58,9 @@ - + - + @@ -68,19 +68,47 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + - + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -792,11 +856,13 @@ DQ + + @@ -824,6 +890,7 @@ DQ + @@ -833,6 +900,7 @@ DQ + @@ -1097,6 +1165,7 @@ DQ + diff --git a/DuckDuckGo/SecureVault/View/SaveCredentialsViewController.swift b/DuckDuckGo/SecureVault/View/SaveCredentialsViewController.swift index ae751fb401..3fa5fe71b0 100644 --- a/DuckDuckGo/SecureVault/View/SaveCredentialsViewController.swift +++ b/DuckDuckGo/SecureVault/View/SaveCredentialsViewController.swift @@ -30,6 +30,38 @@ protocol SaveCredentialsDelegate: AnyObject { } +extension SaveCredentialsViewController: MouseOverViewDelegate { + func mouseOverView(_ mouseOverView: MouseOverView, isMouseOver: Bool) { + if isMouseOver { + lockImageBackgroundView.fillColor = NSColor.infoHoverButtonHovered + presentSecurityInfoPopover() + } else { + dismissSecurityInfoPopover() + } + } + + private func presentSecurityInfoPopover() { + // Only show the popover if we aren't already presenting one: + guard infoViewController == nil else { + infoViewController?.cancelAutoDismiss() + return + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let message = autofillPreferences.isAutoLockEnabled ? UserText.pmSaveCredentialsSecurityInfo : UserText.pmSaveCredentialsSecurityInfoAutolockOff + let infoViewController = PopoverInfoViewController(message: message) { [weak self] in + self?.lockImageBackgroundView.fillColor = NSColor.infoHoverButton + } + infoViewController.show(onParent: self, relativeTo: self.tooltipView) + } + } + + private func dismissSecurityInfoPopover() { + infoViewController?.scheduleAutoDismiss() + } +} + final class SaveCredentialsViewController: NSViewController { static func create() -> SaveCredentialsViewController { @@ -64,6 +96,14 @@ final class SaveCredentialsViewController: NSViewController { @IBOutlet weak var passwordManagerNotNowButton: NSButton! @IBOutlet var fireproofCheck: NSButton! @IBOutlet weak var fireproofCheckDescription: NSTextFieldCell! + @IBOutlet weak var tooltipView: MouseOverView! + @IBOutlet weak var lockImageBackgroundView: NSBox! + + private var infoViewController: PopoverInfoViewController? { + presentedViewControllers?.first { + ($0 as? PopoverInfoViewController) != nil + } as? PopoverInfoViewController + } private enum Action { case displayed @@ -79,6 +119,8 @@ final class SaveCredentialsViewController: NSViewController { private var passwordManagerCoordinator = PasswordManagerCoordinator.shared + private var autofillPreferences: AutofillPreferencesPersistor = AutofillPreferences() + private var passwordManagerStateCancellable: AnyCancellable? private var saveButtonAction: (() -> Void)? @@ -97,6 +139,7 @@ final class SaveCredentialsViewController: NSViewController { saveButton.becomeFirstResponder() updateSaveSegmentedControl() setUpStrings() + setUpSecurityInfoViews() } override func viewWillAppear() { @@ -134,6 +177,13 @@ final class SaveCredentialsViewController: NSViewController { passwordManagerNotNowButton.title = UserText.notNow } + private func setUpSecurityInfoViews() { + tooltipView.delegate = self + lockImageBackgroundView.cornerRadius = lockImageBackgroundView.bounds.height / 2 + lockImageBackgroundView.fillColor = NSColor.infoHoverButton + lockImageBackgroundView.boxType = .custom + } + /// Note that if the credentials.account.id is not nil, then we consider this an update rather than a save. func update(credentials: SecureVaultModels.WebsiteCredentials, automaticallySaved: Bool) { self.credentials = credentials diff --git a/DuckDuckGo/Statistics/ATB/LocalStatisticsStore.swift b/DuckDuckGo/Statistics/ATB/LocalStatisticsStore.swift index 4885a848c6..0cda196c2c 100644 --- a/DuckDuckGo/Statistics/ATB/LocalStatisticsStore.swift +++ b/DuckDuckGo/Statistics/ATB/LocalStatisticsStore.swift @@ -231,3 +231,23 @@ final class LocalStatisticsStore: StatisticsStore { } } + +#if DEBUG + +// For use in tests to avoid indirect access of Database.makeDatabase + +final class StubStatisticsStore: StatisticsStore { + var installDate: Date? + var atb: String? + var searchRetentionAtb: String? + var appRetentionAtb: String? + var variant: String? + var lastAppRetentionRequestDate: Date? + + var waitlistUnlocked: Bool = false + + var autoLockEnabled: Bool = false + var autoLockThreshold: String? +} + +#endif