diff --git a/DuckDuckGo-macOS.xcodeproj/project.pbxproj b/DuckDuckGo-macOS.xcodeproj/project.pbxproj index 9aba612c48..207ce93a07 100644 --- a/DuckDuckGo-macOS.xcodeproj/project.pbxproj +++ b/DuckDuckGo-macOS.xcodeproj/project.pbxproj @@ -1907,6 +1907,8 @@ 7B934C412A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B934C402A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift */; }; 7B93A68B2D4A5AF200E9FFC1 /* ExcludedAppsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93A68A2D4A5AEC00E9FFC1 /* ExcludedAppsModel.swift */; }; 7B93A68C2D4A5AF200E9FFC1 /* ExcludedAppsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93A68A2D4A5AEC00E9FFC1 /* ExcludedAppsModel.swift */; }; + 7B969B3D2D52A81D004AE4E8 /* VPNUIPresenting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B969B3C2D52A818004AE4E8 /* VPNUIPresenting.swift */; }; + 7B969B3E2D52A81D004AE4E8 /* VPNUIPresenting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B969B3C2D52A818004AE4E8 /* VPNUIPresenting.swift */; }; 7B97CD592B7E0B57004FEF43 /* NetworkProtectionProxy in Frameworks */ = {isa = PBXBuildFile; productRef = 7B97CD582B7E0B57004FEF43 /* NetworkProtectionProxy */; }; 7B97CD5B2B7E0B85004FEF43 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 7B97CD5A2B7E0B85004FEF43 /* Common */; }; 7B97CD5C2B7E0BBB004FEF43 /* UserDefaultsWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C6A29525CC1FFD00EEB5F1 /* UserDefaultsWrapper.swift */; }; @@ -4290,6 +4292,7 @@ 7B934C3D2A866CFF00FC8F9C /* NetworkProtectionOnboardingMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkProtectionOnboardingMenu.swift; sourceTree = ""; }; 7B934C402A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UserDefaults+NetworkProtectionShared.swift"; sourceTree = ""; }; 7B93A68A2D4A5AEC00E9FFC1 /* ExcludedAppsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExcludedAppsModel.swift; sourceTree = ""; }; + 7B969B3C2D52A818004AE4E8 /* VPNUIPresenting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNUIPresenting.swift; sourceTree = ""; }; 7BA7CC0B2AD11D1E0042E5CE /* DuckDuckGoVPNAppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DuckDuckGoVPNAppStore.xcconfig; sourceTree = ""; }; 7BA7CC0C2AD11D1E0042E5CE /* DuckDuckGoVPN.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DuckDuckGoVPN.xcconfig; sourceTree = ""; }; 7BA7CC0E2AD11DC80042E5CE /* DuckDuckGoVPNAppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckDuckGoVPNAppDelegate.swift; sourceTree = ""; }; @@ -6633,6 +6636,7 @@ B6F1B02D2BCE6B47005E863C /* TunnelControllerProvider.swift */, 4BE3A6C02C16BEB1003FC378 /* VPNRedditSessionWorkaround.swift */, 7B60B0002C514541008E32A3 /* VPNUIActionHandler.swift */, + 7B969B3C2D52A818004AE4E8 /* VPNUIPresenting.swift */, 7B60AFFC2C514260008E32A3 /* VPNURLEventHandler.swift */, ); path = BothAppTargets; @@ -12329,6 +12333,7 @@ 317307262CD248DB00C492AB /* AutofillToolbarOnboardingPopover.swift in Sources */, 9F6434622BEC82B700D2D8A0 /* AttributionPixelHandler.swift in Sources */, 843AD3DC2CD389CC00163067 /* XMLNodeExtension.swift in Sources */, + 7B969B3E2D52A81D004AE4E8 /* VPNUIPresenting.swift in Sources */, 3706FC32293F65D500E42796 /* MoreOptionsMenu.swift in Sources */, 3706FC34293F65D500E42796 /* PermissionAuthorizationViewController.swift in Sources */, 3706FC35293F65D500E42796 /* BookmarkNode.swift in Sources */, @@ -13744,6 +13749,7 @@ 37DF370A2CF38CD7005ED34B /* PrivacyStatsDatabase.swift in Sources */, 4B4D60C22A0C849000BCD287 /* EventMapping+NetworkProtectionError.swift in Sources */, 4B9292D02667123700AD2C21 /* BookmarkManagementSplitViewController.swift in Sources */, + 7B969B3D2D52A81D004AE4E8 /* VPNUIPresenting.swift in Sources */, 31F2D1FF2AF026D800BF0144 /* WaitlistTermsAndConditionsActionHandler.swift in Sources */, B6B140882ABDBCC1004F8E85 /* HoverTrackingArea.swift in Sources */, 84F1C8CF2C7705B500716446 /* BookmarksBarMenuPopover.swift in Sources */, diff --git a/DuckDuckGo/Assets.xcassets/Images/Help-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Help-16.imageset/Contents.json new file mode 100644 index 0000000000..d4a43f732c --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Help-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Help-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Help-16.imageset/Help-16.pdf b/DuckDuckGo/Assets.xcassets/Images/Help-16.imageset/Help-16.pdf new file mode 100644 index 0000000000..0fdcbfabd1 Binary files /dev/null and b/DuckDuckGo/Assets.xcassets/Images/Help-16.imageset/Help-16.pdf differ diff --git a/DuckDuckGo/Assets.xcassets/Images/Support-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Support-16.imageset/Contents.json new file mode 100644 index 0000000000..e348d8ee4f --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Support-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Support-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Support-16.imageset/Support-16.pdf b/DuckDuckGo/Assets.xcassets/Images/Support-16.imageset/Support-16.pdf new file mode 100644 index 0000000000..ed941cca05 Binary files /dev/null and b/DuckDuckGo/Assets.xcassets/Images/Support-16.imageset/Support-16.pdf differ diff --git a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift index bdfa7f5298..8df2f7a3cd 100644 --- a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift +++ b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift @@ -40,13 +40,48 @@ extension UserText { static let networkProtectionInviteSuccessMessage = NSLocalizedString("network.protection.invite.success.title", value: "DuckDuckGo's VPN secures all of your device's Internet traffic anytime, anywhere.", comment: "Message for the VPN invite success view") - // MARK: - Navigation Bar Status View + // MARK: - VPN Status View submenu (legacy) static let networkProtectionNavBarStatusViewSendFeedback = NSLocalizedString("network.protection.navbar.status.view.send.feedback", value: "Send Feedback…", comment: "Menu item for 'Send Feedback' in the VPN status view that's shown in the navigation bar") static let networkProtectionNavBarStatusViewVPNSettings = NSLocalizedString("network.protection.navbar.status.view.vpn.settings", value: "VPN Settings…", comment: "The status menu 'VPN Settings' menu item") static let networkProtectionNavBarStatusViewFAQ = NSLocalizedString("network.protection.navbar.status.view.faq", value: "FAQs and Support…", comment: "The status menu 'FAQ' menu item") + + // MARK: - VPN Status View submenu + + static let vpnStatusViewVPNSettingsMenuItemTitle = NSLocalizedString( + "vpn.status-view.vpn-settings.menu-item.title", + value: "VPN Settings", + comment: "The VPN status view's 'VPN Settings' menu item for our main app. The number shown is how many Apps are excluded.") + + static func vpnStatusViewExcludedAppsMenuItemTitle(_ count: Int) -> String { + let message = NSLocalizedString( + "vpn.status-view.excluded-apps.menu-item.title", + value: "Excluded Apps (%d)", + comment: "The VPN status view's 'Excluded Apps' menu item for our main app. The number shown is how many Apps are excluded.") + + return String(format: message, count) + } + + static func vpnStatusViewExcludedDomainsMenuItemTitle(_ count: Int) -> String { + let message = NSLocalizedString( + "vpn.status-view.excluded-domains.menu-item.title", + value: "Excluded Websites (%d)", + comment: "The VPN status view's 'Excluded Websites' menu item for our main app. The number shown is how many websites are excluded.") + + return String(format: message, count) + } + + static let vpnStatusViewSendFeedbackMenuItemTitle = NSLocalizedString( + "vpn.status-view.send-feedback.menu-item.title", + value: "Send Feedback", + comment: "The VPN status view's 'Send Feedback' menu item for our main app") + + static let vpnStatusViewFAQMenuItemTitle = NSLocalizedString( + "vpn.status-view.faq.menu-item.title", + value: "FAQs and Support", + comment: "The VPN status view's 'FAQ' menu item for our main app") } extension UserText { diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 6de97c1737..5c98bd4375 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -72417,6 +72417,306 @@ } } }, + "vpn.status-view.excluded-apps.menu-item.title" : { + "comment" : "The VPN status view's 'Excluded Apps' menu item for our main app. The number shown is how many Apps are excluded.", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeschlossene Apps (%d)" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Excluded Apps (%d)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicaciones excluidas (%d)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applications exclues (%d)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "App escluse (%d)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uitgesloten apps (%d)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wykluczone aplikacje (%d)" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicações excluídas (%d)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Исключенные приложения (%d)" + } + } + } + }, + "vpn.status-view.excluded-domains.menu-item.title" : { + "comment" : "The VPN status view's 'Excluded Websites' menu item for our main app. The number shown is how many websites are excluded.", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeschlossene Websites (%d)" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Excluded Websites (%d)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sitios web excluidos (%d)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sites Web exclus (%d)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siti esclusi (%d)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uitgesloten websites (%d)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wykluczone witryny internetowe (%d)" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Websites excluídos (%d)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Исключенные сайты (%d)" + } + } + } + }, + "vpn.status-view.faq.menu-item.title" : { + "comment" : "The VPN status view's 'FAQ' menu item for our main app", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "F&A und Support" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "FAQs and Support" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preguntas frecuentes y asistencia" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "FAQ et assistance" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Domande frequenti e assistenza" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veelgestelde vragen en ondersteuning" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Często zadawane pytania i pomoc techniczna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perguntas Frequentes e Apoio Técnico" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ЧаВо и поддержка" + } + } + } + }, + "vpn.status-view.send-feedback.menu-item.title" : { + "comment" : "The VPN status view's 'Send Feedback' menu item for our main app", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rückmeldung senden" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Send Feedback" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar comentarios" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envoyer vos remarques" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invia feedback" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verstuur feedback" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyślij opinię" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar comentário" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить отзыв" + } + } + } + }, + "vpn.status-view.vpn-settings.menu-item.title" : { + "comment" : "The VPN status view's 'VPN Settings' menu item for our main app. The number shown is how many Apps are excluded.", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "VPN-Einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN Settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de VPN" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paramètres VPN" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni VPN" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "VPN-instellingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustawienia VPN" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Definições da VPN" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки VPN" + } + } + } + }, "vpn.uninstall.alert.informative.text" : { "comment" : "Informative text for the alert that comes up when the user decides to uninstall our VPN", "extractionState" : "extracted_with_value", diff --git a/DuckDuckGo/MainWindow/MainViewController.swift b/DuckDuckGo/MainWindow/MainViewController.swift index 64057aba22..0ff9c259d5 100644 --- a/DuckDuckGo/MainWindow/MainViewController.swift +++ b/DuckDuckGo/MainWindow/MainViewController.swift @@ -78,7 +78,7 @@ final class MainViewController: NSViewController { tabBarViewController = TabBarViewController.create(tabCollectionViewModel: tabCollectionViewModel, activeRemoteMessageModel: NSApp.delegateTyped.activeRemoteMessageModel) bookmarksBarVisibilityManager = BookmarksBarVisibilityManager(selectedTabPublisher: tabCollectionViewModel.$selectedTabViewModel.eraseToAnyPublisher()) - let networkProtectionPopoverManager: NetPPopoverManager = { + let networkProtectionPopoverManager: NetPPopoverManager = { @MainActor in #if DEBUG guard case .normal = NSApp.runType else { return NetPPopoverManagerMock() @@ -93,7 +93,8 @@ final class MainViewController: NSViewController { return NetworkProtectionNavBarPopoverManager( ipcClient: vpnXPCClient, - vpnUninstaller: vpnUninstaller) + vpnUninstaller: vpnUninstaller, + vpnUIPresenting: WindowControllersManager.shared) }() let networkProtectionStatusReporter: NetworkProtectionStatusReporter = { var connectivityIssuesObserver: ConnectivityIssueObserver! diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift index 1165d2afce..b7f94740ef 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift @@ -56,23 +56,30 @@ final class NetworkProtectionNavBarPopoverManager: NetPPopoverManager { private var networkProtectionPopover: NetworkProtectionPopover? let ipcClient: NetworkProtectionIPCClient let vpnUninstaller: VPNUninstalling + private let vpnUIPresenting: VPNUIPresenting + private let proxySettings: TransparentProxySettings @Published private var siteInfo: ActiveSiteInfo? private let activeSitePublisher: ActiveSiteInfoPublisher + private let featureFlagger = NSApp.delegateTyped.featureFlagger private var cancellables = Set() init(ipcClient: VPNControllerXPCClient, - vpnUninstaller: VPNUninstalling) { + vpnUninstaller: VPNUninstalling, + vpnUIPresenting: VPNUIPresenting, + proxySettings: TransparentProxySettings = .init(defaults: .netP)) { self.ipcClient = ipcClient self.vpnUninstaller = vpnUninstaller + self.vpnUIPresenting = vpnUIPresenting + self.proxySettings = proxySettings let activeDomainPublisher = ActiveDomainPublisher(windowControllersManager: .shared) activeSitePublisher = ActiveSiteInfoPublisher( activeDomainPublisher: activeDomainPublisher.eraseToAnyPublisher(), - proxySettings: TransparentProxySettings(defaults: .netP)) + proxySettings: proxySettings) subscribeToCurrentSitePublisher() } @@ -87,6 +94,78 @@ final class NetworkProtectionNavBarPopoverManager: NetPPopoverManager { networkProtectionPopover?.isShown ?? false } + @MainActor + func manageExcludedApps() { + vpnUIPresenting.showVPNAppExclusions() + } + + @MainActor + func manageExcludedSites() { + vpnUIPresenting.showVPNDomainExclusions() + } + + private func statusViewSubmenu() -> [StatusBarMenu.MenuItem] { + let appLauncher = AppLauncher(appBundleURL: Bundle.main.bundleURL) + let excludedAppsTitle = UserText.vpnStatusViewExcludedAppsMenuItemTitle(proxySettings.excludedApps.count) + let excludedWebsitesTitle = UserText.vpnStatusViewExcludedDomainsMenuItemTitle(proxySettings.excludedDomains.count) + + var menuItems = [StatusBarMenu.MenuItem]() + + if UserDefaults.netP.networkProtectionOnboardingStatus == .completed { + menuItems.append( + .text(icon: Image(.settings16), title: UserText.vpnStatusViewVPNSettingsMenuItemTitle, action: { + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showSettings) + })) + } + + menuItems.append(contentsOf: [ + .text(icon: Image(.window16), title: excludedAppsTitle, action: { [weak self] in + self?.manageExcludedApps() + }), + .text(icon: Image(.globe16), title: excludedWebsitesTitle, action: { [weak self] in + self?.manageExcludedSites() + }), + .divider(), + .text(icon: Image(.help16), title: UserText.vpnStatusViewFAQMenuItemTitle, action: { + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showFAQ) + }), + .text(icon: Image(.support16), title: UserText.vpnStatusViewSendFeedbackMenuItemTitle, action: { + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.shareFeedback) + }) + ]) + + return menuItems + } + + /// Only used if the .networkProtectionAppExclusions feature flag is disabled + /// + private func legacyStatusViewSubmenu() -> [StatusBarMenu.MenuItem] { + let appLauncher = AppLauncher(appBundleURL: Bundle.main.bundleURL) + + if UserDefaults.netP.networkProtectionOnboardingStatus == .completed { + return [ + .text(title: UserText.networkProtectionNavBarStatusViewVPNSettings, action: { + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showSettings) + }), + .text(title: UserText.networkProtectionNavBarStatusViewFAQ, action: { + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showFAQ) + }), + .text(title: UserText.networkProtectionNavBarStatusViewSendFeedback, action: { + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.shareFeedback) + }) + ] + } else { + return [ + .text(title: UserText.networkProtectionNavBarStatusViewFAQ, action: { + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showFAQ) + }), + .text(title: UserText.networkProtectionNavBarStatusViewSendFeedback, action: { + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.shareFeedback) + }) + ] + } + } + func show(positionedBelow view: NSView, withDelegate delegate: NSPopoverDelegate) -> NSPopover { /// Since the favicon doesn't have a publisher we force refreshing here @@ -107,9 +186,7 @@ final class NetworkProtectionNavBarPopoverManager: NetPPopoverManager { ) let onboardingStatusPublisher = UserDefaults.netP.networkProtectionOnboardingStatusPublisher - let appLauncher = AppLauncher(appBundleURL: Bundle.main.bundleURL) let vpnURLEventHandler = VPNURLEventHandler() - let proxySettings = TransparentProxySettings(defaults: .netP) let uiActionHandler = VPNUIActionHandler(vpnURLEventHandler: vpnURLEventHandler, proxySettings: proxySettings) let connectionStatusPublisher = CurrentValuePublisher( @@ -129,36 +206,15 @@ final class NetworkProtectionNavBarPopoverManager: NetPPopoverManager { onboardingStatusPublisher: onboardingStatusPublisher, statusReporter: statusReporter, uiActionHandler: uiActionHandler, - menuItems: { - if UserDefaults.netP.networkProtectionOnboardingStatus == .completed { - return [ - NetworkProtectionStatusView.Model.MenuItem( - name: UserText.networkProtectionNavBarStatusViewVPNSettings, action: { - try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showSettings) - }), - NetworkProtectionStatusView.Model.MenuItem( - name: UserText.networkProtectionNavBarStatusViewFAQ, action: { - try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showFAQ) - }), - NetworkProtectionStatusView.Model.MenuItem( - name: UserText.networkProtectionNavBarStatusViewSendFeedback, - action: { - try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.shareFeedback) - }) - ] - } else { - return [ - NetworkProtectionStatusView.Model.MenuItem( - name: UserText.networkProtectionNavBarStatusViewFAQ, action: { - try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showFAQ) - }), - NetworkProtectionStatusView.Model.MenuItem( - name: UserText.networkProtectionNavBarStatusViewSendFeedback, - action: { - try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.shareFeedback) - }) - ] + menuItems: { [weak self] in + + guard let self else { return [] } + + guard featureFlagger.isFeatureOn(.networkProtectionAppExclusions) else { + return legacyStatusViewSubmenu() } + + return statusViewSubmenu() }, agentLoginItem: LoginItem.vpnMenu, isMenuBarStatusView: false, @@ -168,7 +224,6 @@ final class NetworkProtectionNavBarPopoverManager: NetPPopoverManager { _ = try? await self?.vpnUninstaller.uninstall(removeSystemExtension: true) }) - let featureFlagger = NSApp.delegateTyped.featureFlagger let tipsFeatureFlagInitialValue = featureFlagger.isFeatureOn(.networkProtectionUserTips) let tipsFeatureFlagPublisher: CurrentValuePublisher diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNUIPresenting.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNUIPresenting.swift new file mode 100644 index 0000000000..16c3ea2cfa --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNUIPresenting.swift @@ -0,0 +1,60 @@ +// +// VPNUIPresenting.swift +// +// 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. +// + +protocol VPNUIPresenting { + @MainActor + func showVPNAppExclusions() + + @MainActor + func showVPNDomainExclusions() +} + +extension WindowControllersManager: VPNUIPresenting { + + @MainActor + func showVPNAppExclusions() { + showPreferencesTab(withSelectedPane: .vpn) + + let windowController = ExcludedAppsViewController.create().wrappedInWindowController() + + guard let window = windowController.window, + let parentWindowController = WindowControllersManager.shared.lastKeyMainWindowController + else { + assertionFailure("Failed to present ExcludedAppsViewController") + return + } + + parentWindowController.window?.beginSheet(window) + } + + @MainActor + func showVPNDomainExclusions() { + showPreferencesTab(withSelectedPane: .vpn) + + let windowController = ExcludedDomainsViewController.create().wrappedInWindowController() + + guard let window = windowController.window, + let parentWindowController = WindowControllersManager.shared.lastKeyMainWindowController + else { + assertionFailure("Failed to present ExcludedDomainsViewController") + return + } + + parentWindowController.window?.beginSheet(window) + } +} diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNURLEventHandler.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNURLEventHandler.swift index 70fff17588..064b20bd8f 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNURLEventHandler.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNURLEventHandler.swift @@ -33,6 +33,10 @@ final class VPNURLEventHandler { /// func handle(_ url: URL) async { switch url { + case VPNAppLaunchCommand.manageExcludedApps.launchURL: + windowControllerManager.showVPNAppExclusions() + case VPNAppLaunchCommand.manageExcludedDomains.launchURL: + windowControllerManager.showVPNDomainExclusions() case VPNAppLaunchCommand.showStatus.launchURL: await showStatus() case VPNAppLaunchCommand.showSettings.launchURL: diff --git a/DuckDuckGo/Preferences/Model/VPNPreferencesModel.swift b/DuckDuckGo/Preferences/Model/VPNPreferencesModel.swift index 9e74dea8b3..a51fab0d4b 100644 --- a/DuckDuckGo/Preferences/Model/VPNPreferencesModel.swift +++ b/DuckDuckGo/Preferences/Model/VPNPreferencesModel.swift @@ -275,30 +275,12 @@ final class VPNPreferencesModel: ObservableObject { @MainActor func manageExcludedApps() { - let windowController = ExcludedAppsViewController.create().wrappedInWindowController() - - guard let window = windowController.window, - let parentWindowController = WindowControllersManager.shared.lastKeyMainWindowController - else { - assertionFailure("DataClearingPreferences: Failed to present ExcludedAppsViewController") - return - } - - parentWindowController.window?.beginSheet(window) + WindowControllersManager.shared.showVPNAppExclusions() } @MainActor func manageExcludedSites() { - let windowController = ExcludedDomainsViewController.create().wrappedInWindowController() - - guard let window = windowController.window, - let parentWindowController = WindowControllersManager.shared.lastKeyMainWindowController - else { - assertionFailure("DataClearingPreferences: Failed to present ExcludedDomainsViewController") - return - } - - parentWindowController.window?.beginSheet(window) + WindowControllersManager.shared.showVPNDomainExclusions() } } diff --git a/DuckDuckGoVPN/Assets.xcassets/Icons/Contents.json b/DuckDuckGoVPN/Assets.xcassets/Icons/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/DuckDuckGoVPN/Assets.xcassets/Icons/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGoVPN/Assets.xcassets/Icons/Globe-16.imageset/Contents.json b/DuckDuckGoVPN/Assets.xcassets/Icons/Globe-16.imageset/Contents.json new file mode 100644 index 0000000000..8fafea4f8d --- /dev/null +++ b/DuckDuckGoVPN/Assets.xcassets/Icons/Globe-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Globe-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGoVPN/Assets.xcassets/Icons/Globe-16.imageset/Globe-16.pdf b/DuckDuckGoVPN/Assets.xcassets/Icons/Globe-16.imageset/Globe-16.pdf new file mode 100644 index 0000000000..e61c83ff09 Binary files /dev/null and b/DuckDuckGoVPN/Assets.xcassets/Icons/Globe-16.imageset/Globe-16.pdf differ diff --git a/DuckDuckGoVPN/Assets.xcassets/Icons/Help-16.imageset/Contents.json b/DuckDuckGoVPN/Assets.xcassets/Icons/Help-16.imageset/Contents.json new file mode 100644 index 0000000000..d4a43f732c --- /dev/null +++ b/DuckDuckGoVPN/Assets.xcassets/Icons/Help-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Help-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGoVPN/Assets.xcassets/Icons/Help-16.imageset/Help-16.pdf b/DuckDuckGoVPN/Assets.xcassets/Icons/Help-16.imageset/Help-16.pdf new file mode 100644 index 0000000000..0fdcbfabd1 Binary files /dev/null and b/DuckDuckGoVPN/Assets.xcassets/Icons/Help-16.imageset/Help-16.pdf differ diff --git a/DuckDuckGoVPN/Assets.xcassets/Icons/Settings-16.imageset/Contents.json b/DuckDuckGoVPN/Assets.xcassets/Icons/Settings-16.imageset/Contents.json new file mode 100644 index 0000000000..d09602778e --- /dev/null +++ b/DuckDuckGoVPN/Assets.xcassets/Icons/Settings-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Settings-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGoVPN/Assets.xcassets/Icons/Settings-16.imageset/Settings-16.pdf b/DuckDuckGoVPN/Assets.xcassets/Icons/Settings-16.imageset/Settings-16.pdf new file mode 100644 index 0000000000..cd3cfeb69a Binary files /dev/null and b/DuckDuckGoVPN/Assets.xcassets/Icons/Settings-16.imageset/Settings-16.pdf differ diff --git a/DuckDuckGoVPN/Assets.xcassets/Icons/Support-16.imageset/Contents.json b/DuckDuckGoVPN/Assets.xcassets/Icons/Support-16.imageset/Contents.json new file mode 100644 index 0000000000..e348d8ee4f --- /dev/null +++ b/DuckDuckGoVPN/Assets.xcassets/Icons/Support-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Support-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGoVPN/Assets.xcassets/Icons/Support-16.imageset/Support-16.pdf b/DuckDuckGoVPN/Assets.xcassets/Icons/Support-16.imageset/Support-16.pdf new file mode 100644 index 0000000000..ed941cca05 Binary files /dev/null and b/DuckDuckGoVPN/Assets.xcassets/Icons/Support-16.imageset/Support-16.pdf differ diff --git a/DuckDuckGoVPN/Assets.xcassets/Icons/Window-16.imageset/Contents.json b/DuckDuckGoVPN/Assets.xcassets/Icons/Window-16.imageset/Contents.json new file mode 100644 index 0000000000..b0d5f1693f --- /dev/null +++ b/DuckDuckGoVPN/Assets.xcassets/Icons/Window-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Window-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGoVPN/Assets.xcassets/Icons/Window-16.imageset/Window-16.pdf b/DuckDuckGoVPN/Assets.xcassets/Icons/Window-16.imageset/Window-16.pdf new file mode 100644 index 0000000000..25f59a0568 Binary files /dev/null and b/DuckDuckGoVPN/Assets.xcassets/Icons/Window-16.imageset/Window-16.pdf differ diff --git a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift index 46028204d7..bd8418a05d 100644 --- a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift +++ b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift @@ -17,22 +17,24 @@ // import AppLauncher +import BrowserServicesKit import Cocoa import Combine -import BrowserServicesKit import Common import Configuration +import FeatureFlags import LoginItems import Networking import NetworkExtension import NetworkProtection import NetworkProtectionProxy import NetworkProtectionUI -import ServiceManagement +import os.log import PixelKit +import ServiceManagement import Subscription +import SwiftUICore import VPNAppLauncher -import os.log @objc(Application) final class DuckDuckGoVPNApplication: NSApplication { @@ -138,7 +140,12 @@ final class DuckDuckGoVPNAppDelegate: NSObject, NSApplicationDelegate { private lazy var featureFlagger = DefaultFeatureFlagger( internalUserDecider: privacyConfigurationManager.internalUserDecider, privacyConfigManager: privacyConfigurationManager, - experimentManager: nil) + localOverrides: FeatureFlagLocalOverrides( + keyValueStore: UserDefaults.appConfiguration, + actionHandler: FeatureFlagOverridesPublishingHandler() + ), + experimentManager: nil, + for: FeatureFlag.self) public init(accountManager: AccountManager, accessTokenStorage: SubscriptionTokenKeychainStorage, @@ -314,6 +321,57 @@ final class DuckDuckGoVPNAppDelegate: NSObject, NSApplicationDelegate { makeStatusBarMenu() }() + private func statusViewSubmenu() -> [StatusBarMenu.MenuItem] { + let appLauncher = AppLauncher(appBundleURL: Bundle.main.bundleURL) + let proxySettings = TransparentProxySettings(defaults: .netP) + let excludedAppsTitle = UserText.vpnStatusViewExcludedAppsMenuItemTitle(proxySettings.excludedApps.count) + let excludedWebsitesTitle = UserText.vpnStatusViewExcludedDomainsMenuItemTitle(proxySettings.excludedDomains.count) + + var menuItems = [StatusBarMenu.MenuItem]() + + if UserDefaults.netP.networkProtectionOnboardingStatus == .completed { + menuItems.append( + .text(icon: Image(.settings16), title: UserText.vpnStatusViewVPNSettingsMenuItemTitle, action: { + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showSettings) + })) + } + + menuItems.append(contentsOf: [ + .text(icon: Image(.window16), title: excludedAppsTitle, action: { [weak self] in + try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.manageExcludedApps) + }), + .text(icon: Image(.globe16), title: excludedWebsitesTitle, action: { [weak self] in + try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.manageExcludedDomains) + }), + .divider(), + .text(icon: Image(.help16), title: UserText.vpnStatusViewFAQMenuItemTitle, action: { [weak self] in + try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showFAQ) + }), + .text(icon: Image(.support16), title: UserText.vpnStatusViewSendFeedbackMenuItemTitle, action: { [weak self] in + try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.shareFeedback) + }) + ]) + + return menuItems + } + + private func legacyStatusViewSubmenu() -> [StatusBarMenu.MenuItem] { + [ + .text(title: UserText.networkProtectionStatusMenuVPNSettings, action: { [weak self] in + try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showSettings) + }), + .text(title: UserText.networkProtectionStatusMenuFAQ, action: { [weak self] in + try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showFAQ) + }), + .text(title: UserText.networkProtectionStatusMenuSendFeedback, action: { [weak self] in + try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.shareFeedback) + }), + .text(title: UserText.networkProtectionStatusMenuOpenDuckDuckGo, action: { [weak self] in + try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.justOpen) + }), + ] + } + @MainActor private func makeStatusBarMenu() -> StatusBarMenu { #if DEBUG @@ -340,21 +398,14 @@ final class DuckDuckGoVPNAppDelegate: NSObject, NSApplicationDelegate { controller: tunnelController, iconProvider: iconProvider, uiActionHandler: uiActionHandler, - menuItems: { - [ - StatusBarMenu.MenuItem(name: UserText.networkProtectionStatusMenuVPNSettings, action: { [weak self] in - try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showSettings) - }), - StatusBarMenu.MenuItem(name: UserText.networkProtectionStatusMenuFAQ, action: { [weak self] in - try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showFAQ) - }), - StatusBarMenu.MenuItem(name: UserText.networkProtectionStatusMenuSendFeedback, action: { [weak self] in - try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.shareFeedback) - }), - StatusBarMenu.MenuItem(name: UserText.networkProtectionStatusMenuOpenDuckDuckGo, action: { [weak self] in - try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.justOpen) - }), - ] + menuItems: { [weak self] in + guard let self else { return [] } + + guard featureFlagger.isFeatureOn(.networkProtectionAppExclusions) else { + return legacyStatusViewSubmenu() + } + + return statusViewSubmenu() }, agentLoginItem: nil, isMenuBarStatusView: true, diff --git a/DuckDuckGoVPN/Localizable.xcstrings b/DuckDuckGoVPN/Localizable.xcstrings index 43636be9c4..c8002e3487 100644 --- a/DuckDuckGoVPN/Localizable.xcstrings +++ b/DuckDuckGoVPN/Localizable.xcstrings @@ -243,7 +243,7 @@ }, "network.protection.status.menu.share.feedback" : { "comment" : "The status menu 'Share VPN Feedback' menu item", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -726,6 +726,306 @@ } } } + }, + "vpn.status-view.excluded-apps.menu-item.title" : { + "comment" : "The VPN status view's 'Excluded Apps' menu item for our status menu app. The number shown is how many Apps are excluded.", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeschlossene Apps (%d)" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Excluded Apps (%d)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicaciones excluidas (%d)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applications exclues (%d)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "App escluse (%d)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uitgesloten apps (%d)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wykluczone aplikacje (%d)" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicações excluídas (%d)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Исключенные приложения (%d)" + } + } + } + }, + "vpn.status-view.excluded-domains.menu-item.title" : { + "comment" : "The VPN status view's 'Excluded Websites' menu item for our status menu app. The number shown is how many websites are excluded.", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeschlossene Websites (%d)" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Excluded Websites (%d)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sitios web excluidos (%d)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sites Web exclus (%d)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siti esclusi (%d)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uitgesloten websites (%d)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wykluczone witryny internetowe (%d)" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Websites excluídos (%d)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Исключенные сайты (%d)" + } + } + } + }, + "vpn.status-view.faq.menu-item.title" : { + "comment" : "The VPN status view's 'FAQ' menu item for our status menu app", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "F&A und Support" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "FAQs and Support" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preguntas frecuentes y asistencia" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "FAQ et assistance" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Domande frequenti e assistenza" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veelgestelde vragen en ondersteuning" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Często zadawane pytania i pomoc techniczna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perguntas Frequentes e Apoio Técnico" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ЧаВо и поддержка" + } + } + } + }, + "vpn.status-view.send-feedback.menu-item.title" : { + "comment" : "The VPN status view's 'Send Feedback' menu item for our status menu app", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rückmeldung senden" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Send Feedback" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar comentarios" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envoyer vos remarques" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invia feedback" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verstuur feedback" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyślij opinię" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar comentário" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить отзыв" + } + } + } + }, + "vpn.status-view.vpn-settings.menu-item.title" : { + "comment" : "The VPN status view's 'VPN Settings' menu item for our status menu app. The number shown is how many Apps are excluded.", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "VPN-Einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "VPN Settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de VPN" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paramètres VPN" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni VPN" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "VPN-instellingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustawienia VPN" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Definições da VPN" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки VPN" + } + } + } } }, "version" : "1.0" diff --git a/DuckDuckGoVPN/UserText.swift b/DuckDuckGoVPN/UserText.swift index 0e7d32e691..17d985634a 100644 --- a/DuckDuckGoVPN/UserText.swift +++ b/DuckDuckGoVPN/UserText.swift @@ -19,11 +19,45 @@ import Foundation final class UserText { - // MARK: - Status Menu + // MARK: - VPN Status View submenu (legacy) static let networkProtectionStatusMenuVPNSettings = NSLocalizedString("network.protection.status.menu.vpn.settings", value: "VPN Settings…", comment: "The status menu 'VPN Settings' menu item") static let networkProtectionStatusMenuFAQ = NSLocalizedString("network.protection.status.menu.faq", value: "FAQs and Support…", comment: "The status menu 'FAQ' menu item") static let networkProtectionStatusMenuOpenDuckDuckGo = NSLocalizedString("network.protection.status.menu.vpn.open-duckduckgo", value: "Open DuckDuckGo…", comment: "The status menu 'Open DuckDuckGo' menu item") - static let networkProtectionStatusMenuShareFeedback = NSLocalizedString("network.protection.status.menu.share.feedback", value: "Share VPN Feedback…", comment: "The status menu 'Share VPN Feedback' menu item") static let networkProtectionStatusMenuSendFeedback = NSLocalizedString("network.protection.status.menu.send.feedback", value: "Send Feedback…", comment: "The status menu 'Send Feedback' menu item") + + // MARK: - VPN Status View submenu + + static let vpnStatusViewVPNSettingsMenuItemTitle = NSLocalizedString( + "vpn.status-view.vpn-settings.menu-item.title", + value: "VPN Settings", + comment: "The VPN status view's 'VPN Settings' menu item for our status menu app. The number shown is how many Apps are excluded.") + + static func vpnStatusViewExcludedAppsMenuItemTitle(_ count: Int) -> String { + let message = NSLocalizedString( + "vpn.status-view.excluded-apps.menu-item.title", + value: "Excluded Apps (%d)", + comment: "The VPN status view's 'Excluded Apps' menu item for our status menu app. The number shown is how many Apps are excluded.") + + return String(format: message, count) + } + + static func vpnStatusViewExcludedDomainsMenuItemTitle(_ count: Int) -> String { + let message = NSLocalizedString( + "vpn.status-view.excluded-domains.menu-item.title", + value: "Excluded Websites (%d)", + comment: "The VPN status view's 'Excluded Websites' menu item for our status menu app. The number shown is how many websites are excluded.") + + return String(format: message, count) + } + + static let vpnStatusViewFAQMenuItemTitle = NSLocalizedString( + "vpn.status-view.faq.menu-item.title", + value: "FAQs and Support", + comment: "The VPN status view's 'FAQ' menu item for our status menu app") + + static let vpnStatusViewSendFeedbackMenuItemTitle = NSLocalizedString( + "vpn.status-view.send-feedback.menu-item.title", + value: "Send Feedback", + comment: "The VPN status view's 'Send Feedback' menu item for our status menu app") } diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MenuItemButton.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MenuItemButton.swift index e8270c4a9f..939871b195 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MenuItemButton.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MenuItemButton.swift @@ -21,7 +21,7 @@ import SwiftUI struct MenuItemButton: View { @Environment(\.colorScheme) private var colorScheme - private let iconName: NetworkProtectionAsset? + private let icon: Image? private let title: String private let detailTitle: String? private let textColor: Color @@ -32,8 +32,8 @@ struct MenuItemButton: View { @State private var isHovered = false @State private var animatingTap = false - init(iconName: NetworkProtectionAsset? = nil, title: String, detailTitle: String? = nil, textColor: Color, action: @escaping () async -> Void) { - self.iconName = iconName + init(icon: Image? = nil, title: String, detailTitle: String? = nil, textColor: Color, action: @escaping () async -> Void) { + self.icon = icon self.title = title self.detailTitle = detailTitle self.textColor = textColor @@ -45,8 +45,8 @@ struct MenuItemButton: View { buttonTapped() }) { HStack { - if let iconName { - Image(iconName) + if let icon { + icon .foregroundColor(isHovered ? .white : textColor) } Text(title) diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift index 22562a36dd..77acac244e 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift @@ -82,11 +82,17 @@ public struct NetworkProtectionStatusView: View { private func bottomMenuView() -> some View { VStack(spacing: 0) { - ForEach(model.menuItems(), id: \.name) { menuItem in - MenuItemButton(title: menuItem.name, textColor: Color(.defaultText)) { - await menuItem.action() - dismiss() - }.applyMenuAttributes() + ForEach(model.menuItems(), id: \.uuid) { item in + switch item { + case .divider: + Divider() + .padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9)) + case .text(_, let icon, let title, let action): + MenuItemButton(icon: icon, title: title, textColor: Color(.defaultText)) { + await action() + dismiss() + }.applyMenuAttributes() + } } } } diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift index 90225f3822..f579612487 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift @@ -17,12 +17,12 @@ // import Combine +import Common import LoginItems import NetworkExtension import NetworkProtection import ServiceManagement import SwiftUI -import Common /// This view can be shown from any location where we want the user to be able to interact with VPN. /// This view shows status information about the VPN, and offers a chance to toggle it ON and OFF. @@ -34,13 +34,17 @@ extension NetworkProtectionStatusView { @MainActor public final class Model: ObservableObject { - public struct MenuItem { - let name: String - let action: () async -> Void + public enum MenuItem { + case divider(uuid: UUID = UUID()) + case text(uuid: UUID = UUID(), icon: Image? = nil, title: String, action: () async -> Void) - public init(name: String, action: @escaping () async -> Void) { - self.name = name - self.action = action + public var uuid: UUID { + switch self { + case .divider(let uuid): + return uuid + case .text(let uuid, _, _, _): + return uuid + } } } diff --git a/LocalPackages/NetworkProtectionMac/Sources/VPNAppLauncher/VPNAppLaunchCommand.swift b/LocalPackages/NetworkProtectionMac/Sources/VPNAppLauncher/VPNAppLaunchCommand.swift index 81e01787e6..937d5a8e0c 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/VPNAppLauncher/VPNAppLaunchCommand.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/VPNAppLauncher/VPNAppLaunchCommand.swift @@ -21,6 +21,8 @@ import Foundation public enum VPNAppLaunchCommand: Codable, AppLaunchCommand { case justOpen + case manageExcludedApps + case manageExcludedDomains case shareFeedback case showFAQ case showStatus @@ -33,6 +35,10 @@ public enum VPNAppLaunchCommand: Codable, AppLaunchCommand { switch self { case .justOpen: return "networkprotection://just-open" + case .manageExcludedApps: + return "networkprotection://excluded-apps" + case .manageExcludedDomains: + return "networkprotection://excluded-domains" case .shareFeedback: return "networkprotection://share-feedback" case .showFAQ: