diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 4c86d42ff3..8498cca34f 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1576,11 +1576,27 @@ 7B4D8A222BDA857300852966 /* VPNOperationErrorRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4D8A202BDA857300852966 /* VPNOperationErrorRecorder.swift */; }; 7B4D8A232BDA857300852966 /* VPNOperationErrorRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4D8A202BDA857300852966 /* VPNOperationErrorRecorder.swift */; }; 7B4D8A242BDA857300852966 /* VPNOperationErrorRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4D8A202BDA857300852966 /* VPNOperationErrorRecorder.swift */; }; + 7B5A23682C468233007213AC /* ExcludedDomainsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5A23672C468233007213AC /* ExcludedDomainsViewController.swift */; }; + 7B5A23692C468233007213AC /* ExcludedDomainsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5A23672C468233007213AC /* ExcludedDomainsViewController.swift */; }; + 7B5A236F2C46A116007213AC /* ExcludedDomainsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5A236E2C46A116007213AC /* ExcludedDomainsModel.swift */; }; + 7B5A23702C46A116007213AC /* ExcludedDomainsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5A236E2C46A116007213AC /* ExcludedDomainsModel.swift */; }; + 7B5A23752C46A4A8007213AC /* ExcludedDomains.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7B5A23742C46A4A8007213AC /* ExcludedDomains.storyboard */; }; + 7B5A23762C46A4A8007213AC /* ExcludedDomains.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7B5A23742C46A4A8007213AC /* ExcludedDomains.storyboard */; }; + 7B60AFFA2C511B65008E32A3 /* VPNUIActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B60AFF92C511B65008E32A3 /* VPNUIActionHandler.swift */; }; + 7B60AFFB2C511C68008E32A3 /* VPNUIActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B60AFF92C511B65008E32A3 /* VPNUIActionHandler.swift */; }; + 7B60AFFE2C514269008E32A3 /* VPNURLEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B60AFFC2C514260008E32A3 /* VPNURLEventHandler.swift */; }; + 7B60AFFF2C51426A008E32A3 /* VPNURLEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B60AFFC2C514260008E32A3 /* VPNURLEventHandler.swift */; }; + 7B60B0022C5145EC008E32A3 /* VPNUIActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B60B0002C514541008E32A3 /* VPNUIActionHandler.swift */; }; + 7B60B0032C5145ED008E32A3 /* VPNUIActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B60B0002C514541008E32A3 /* VPNUIActionHandler.swift */; }; 7B624F172BA25C1F00A6C544 /* NetworkProtectionUI in Frameworks */ = {isa = PBXBuildFile; productRef = 7B624F162BA25C1F00A6C544 /* NetworkProtectionUI */; }; 7B6545ED2C0778BB00115BEA /* VPNControllerUDSClient+ConvenienceInitializers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6545EC2C0778BB00115BEA /* VPNControllerUDSClient+ConvenienceInitializers.swift */; }; 7B6545EE2C0779D500115BEA /* VPNControllerUDSClient+ConvenienceInitializers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6545EC2C0778BB00115BEA /* VPNControllerUDSClient+ConvenienceInitializers.swift */; }; 7B7DFB202B7E736B009EA1A3 /* MacPacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF12E6D2A2111880023E6BF /* MacPacketTunnelProvider.swift */; }; 7B7DFB222B7E7473009EA1A3 /* Networking in Frameworks */ = {isa = PBXBuildFile; productRef = 7B7DFB212B7E7473009EA1A3 /* Networking */; }; + 7B7F5D212C526CE600826256 /* AddExcludedDomainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7F5D202C526CE600826256 /* AddExcludedDomainView.swift */; }; + 7B7F5D222C526CE600826256 /* AddExcludedDomainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7F5D202C526CE600826256 /* AddExcludedDomainView.swift */; }; + 7B7F5D242C52725A00826256 /* AddExcludedDomainButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7F5D232C52725A00826256 /* AddExcludedDomainButtonsView.swift */; }; + 7B7F5D252C52725A00826256 /* AddExcludedDomainButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7F5D232C52725A00826256 /* AddExcludedDomainButtonsView.swift */; }; 7B7FCD0F2BA33B2700C04FBE /* UserDefaults+vpnLegacyUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7FCD0E2BA33B2700C04FBE /* UserDefaults+vpnLegacyUser.swift */; }; 7B7FCD102BA33B2700C04FBE /* UserDefaults+vpnLegacyUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7FCD0E2BA33B2700C04FBE /* UserDefaults+vpnLegacyUser.swift */; }; 7B8594192B5B26230007EB3E /* UDSHelper in Frameworks */ = {isa = PBXBuildFile; productRef = 7B8594182B5B26230007EB3E /* UDSHelper */; }; @@ -1622,6 +1638,10 @@ 7BAF9E4C2A8A3CCA002D3B6E /* UserDefaults+NetworkProtectionShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B934C402A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift */; }; 7BAF9E4D2A8A3CCB002D3B6E /* UserDefaults+NetworkProtectionShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B934C402A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift */; }; 7BB108592A43375D000AB95F /* PFMoveApplication.m in Sources */ = {isa = PBXBuildFile; fileRef = 7BB108582A43375D000AB95F /* PFMoveApplication.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; + 7BB4BC632C5BC13D00E06FC8 /* SiteTroubleshootingInfoPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BB4BC622C5BC13D00E06FC8 /* SiteTroubleshootingInfoPublisher.swift */; }; + 7BB4BC642C5BC13D00E06FC8 /* SiteTroubleshootingInfoPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BB4BC622C5BC13D00E06FC8 /* SiteTroubleshootingInfoPublisher.swift */; }; + 7BB4BC6A2C5CD96200E06FC8 /* ActiveDomainPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BB4BC692C5CD96200E06FC8 /* ActiveDomainPublisher.swift */; }; + 7BB4BC6B2C5CD96200E06FC8 /* ActiveDomainPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BB4BC692C5CD96200E06FC8 /* ActiveDomainPublisher.swift */; }; 7BBD45B12A691AB500C83CA9 /* NetworkProtectionDebugUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBD45B02A691AB500C83CA9 /* NetworkProtectionDebugUtilities.swift */; }; 7BBD45B22A691AB500C83CA9 /* NetworkProtectionDebugUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBD45B02A691AB500C83CA9 /* NetworkProtectionDebugUtilities.swift */; }; 7BBE2B7B2B61663C00697445 /* NetworkProtectionProxy in Frameworks */ = {isa = PBXBuildFile; productRef = 7BBE2B7A2B61663C00697445 /* NetworkProtectionProxy */; }; @@ -3512,10 +3532,18 @@ 7B4D8A202BDA857300852966 /* VPNOperationErrorRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNOperationErrorRecorder.swift; sourceTree = ""; }; 7B5291882A1697680022E406 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 7B5291892A169BC90022E406 /* DeveloperID.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DeveloperID.xcconfig; sourceTree = ""; }; + 7B5A23672C468233007213AC /* ExcludedDomainsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExcludedDomainsViewController.swift; sourceTree = ""; }; + 7B5A236E2C46A116007213AC /* ExcludedDomainsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExcludedDomainsModel.swift; sourceTree = ""; }; + 7B5A23742C46A4A8007213AC /* ExcludedDomains.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = ExcludedDomains.storyboard; sourceTree = ""; }; + 7B60AFF92C511B65008E32A3 /* VPNUIActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNUIActionHandler.swift; sourceTree = ""; }; + 7B60AFFC2C514260008E32A3 /* VPNURLEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNURLEventHandler.swift; sourceTree = ""; }; + 7B60B0002C514541008E32A3 /* VPNUIActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNUIActionHandler.swift; sourceTree = ""; }; 7B6545EC2C0778BB00115BEA /* VPNControllerUDSClient+ConvenienceInitializers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VPNControllerUDSClient+ConvenienceInitializers.swift"; sourceTree = ""; }; 7B6EC5E42AE2D8AF004FE6DF /* DuckDuckGoDBPAgentAppStore.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = DuckDuckGoDBPAgentAppStore.xcconfig; sourceTree = ""; }; 7B6EC5E52AE2D8AF004FE6DF /* DuckDuckGoDBPAgent.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = DuckDuckGoDBPAgent.xcconfig; sourceTree = ""; }; 7B76E6852AD5D77600186A84 /* XPCHelper */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = XPCHelper; sourceTree = ""; }; + 7B7F5D202C526CE600826256 /* AddExcludedDomainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExcludedDomainView.swift; sourceTree = ""; }; + 7B7F5D232C52725A00826256 /* AddExcludedDomainButtonsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExcludedDomainButtonsView.swift; sourceTree = ""; }; 7B7FCD0E2BA33B2700C04FBE /* UserDefaults+vpnLegacyUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+vpnLegacyUser.swift"; sourceTree = ""; }; 7B8594172B5B25FB0007EB3E /* UDSHelper */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = UDSHelper; sourceTree = ""; }; 7B8DB3192B504D7500EC16DA /* VPNAppEventsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNAppEventsHandler.swift; sourceTree = ""; }; @@ -3537,6 +3565,8 @@ 7BA7CC4D2AD11F6F0042E5CE /* NetworkProtectionIPCTunnelController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkProtectionIPCTunnelController.swift; sourceTree = ""; }; 7BB108572A43375D000AB95F /* PFMoveApplication.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PFMoveApplication.h; sourceTree = ""; }; 7BB108582A43375D000AB95F /* PFMoveApplication.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PFMoveApplication.m; sourceTree = ""; }; + 7BB4BC622C5BC13D00E06FC8 /* SiteTroubleshootingInfoPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteTroubleshootingInfoPublisher.swift; sourceTree = ""; }; + 7BB4BC692C5CD96200E06FC8 /* ActiveDomainPublisher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveDomainPublisher.swift; sourceTree = ""; }; 7BBD45B02A691AB500C83CA9 /* NetworkProtectionDebugUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionDebugUtilities.swift; sourceTree = ""; }; 7BCB90C12C18626E008E3543 /* VPNControllerXPCClient+ConvenienceInitializers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VPNControllerXPCClient+ConvenienceInitializers.swift"; sourceTree = ""; }; 7BD1688D2AD4A4C400D24876 /* NetworkExtensionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkExtensionController.swift; sourceTree = ""; }; @@ -5324,6 +5354,7 @@ 4B4D60572A0B29FA00BCD287 /* AppAndExtensionTargets */, 4B4D60602A0B29FA00BCD287 /* AppTargets */, 4B4D60742A0B29FA00BCD287 /* NetworkExtensionTargets */, + 7B5A236D2C46A0DA007213AC /* ExcludedDomains */, ); path = NetworkProtection; sourceTree = ""; @@ -5367,6 +5398,8 @@ 4B4D60632A0B29FA00BCD287 /* BothAppTargets */ = { isa = PBXGroup; children = ( + 7BB4BC692C5CD96200E06FC8 /* ActiveDomainPublisher.swift */, + 7BB4BC622C5BC13D00E06FC8 /* SiteTroubleshootingInfoPublisher.swift */, 4B4D60722A0B29FA00BCD287 /* EventMapping+NetworkProtectionError.swift */, BDE981DB2BBD110800645880 /* Assets */, 7BD3AF5C2A8E7AF1006F9F56 /* KeychainType+ClientDefault.swift */, @@ -5388,6 +5421,8 @@ 7B934C3D2A866CFF00FC8F9C /* NetworkProtectionOnboardingMenu.swift */, B6F1B02D2BCE6B47005E863C /* TunnelControllerProvider.swift */, 4BE3A6C02C16BEB1003FC378 /* VPNRedditSessionWorkaround.swift */, + 7B60B0002C514541008E32A3 /* VPNUIActionHandler.swift */, + 7B60AFFC2C514260008E32A3 /* VPNURLEventHandler.swift */, ); path = BothAppTargets; sourceTree = ""; @@ -6108,6 +6143,18 @@ path = UITests; sourceTree = ""; }; + 7B5A236D2C46A0DA007213AC /* ExcludedDomains */ = { + isa = PBXGroup; + children = ( + 7B5A23742C46A4A8007213AC /* ExcludedDomains.storyboard */, + 7B5A23672C468233007213AC /* ExcludedDomainsViewController.swift */, + 7B5A236E2C46A116007213AC /* ExcludedDomainsModel.swift */, + 7B7F5D202C526CE600826256 /* AddExcludedDomainView.swift */, + 7B7F5D232C52725A00826256 /* AddExcludedDomainButtonsView.swift */, + ); + path = ExcludedDomains; + sourceTree = ""; + }; 7B6EC5E32AE2D88C004FE6DF /* DBP */ = { isa = PBXGroup; children = ( @@ -6129,6 +6176,7 @@ isa = PBXGroup; children = ( 7BA7CC132AD11DC80042E5CE /* AppLauncher+DefaultInitializer.swift */, + 7B60AFF92C511B65008E32A3 /* VPNUIActionHandler.swift */, 7BA7CC0E2AD11DC80042E5CE /* DuckDuckGoVPNAppDelegate.swift */, 7BD1688D2AD4A4C400D24876 /* NetworkExtensionController.swift */, EEDE50102BA360C80017F3C4 /* NetworkProtection+VPNAgentConvenienceInitializers.swift */, @@ -9240,6 +9288,7 @@ 3706FCD0293F65D500E42796 /* BookmarksBarCollectionViewItem.xib in Resources */, 3706FCD2293F65D500E42796 /* shield.json in Resources */, 3706FCD4293F65D500E42796 /* TabBarViewItem.xib in Resources */, + 7B5A23762C46A4A8007213AC /* ExcludedDomains.storyboard in Resources */, 3706FCD6293F65D500E42796 /* httpsMobileV2FalsePositives.json in Resources */, 3706FCD8293F65D500E42796 /* BookmarksBar.storyboard in Resources */, 3706FCD9293F65D500E42796 /* trackers-1.json in Resources */, @@ -9432,6 +9481,7 @@ 4BE5336B286912D40019DBFD /* BookmarksBarCollectionViewItem.xib in Resources */, AA34396C2754D4E300B241FA /* shield.json in Resources */, AA7412B324D0B3AC00D22FE0 /* TabBarViewItem.xib in Resources */, + 7B5A23752C46A4A8007213AC /* ExcludedDomains.storyboard in Resources */, 4B677435255DBEB800025BD8 /* httpsMobileV2FalsePositives.json in Resources */, 4BD18F05283F151F00058124 /* BookmarksBar.storyboard in Resources */, AA3439792754D55100B241FA /* trackers-1.json in Resources */, @@ -9875,6 +9925,7 @@ 3706FAAD293F65D500E42796 /* BadgeNotificationAnimationModel.swift in Sources */, 3706FAAE293F65D500E42796 /* HyperLink.swift in Sources */, 3706FAAF293F65D500E42796 /* PasteboardWriting.swift in Sources */, + 7B5A23692C468233007213AC /* ExcludedDomainsViewController.swift in Sources */, B6E3E5512BBFCDEE00A41922 /* OpenDownloadsCellView.swift in Sources */, 3706FAB0293F65D500E42796 /* BookmarkOutlineCellView.swift in Sources */, 3706FAB1293F65D500E42796 /* UnprotectedDomains.xcdatamodeld in Sources */, @@ -10208,6 +10259,7 @@ 3706FB93293F65D500E42796 /* PasteboardFolder.swift in Sources */, 3706FB94293F65D500E42796 /* CookieManagedNotificationView.swift in Sources */, EEC4A65F2B277EE100F7C0AA /* VPNLocationViewModel.swift in Sources */, + 7B60B0032C5145ED008E32A3 /* VPNUIActionHandler.swift in Sources */, 370A34B22AB24E3700C77F7C /* SyncDebugMenu.swift in Sources */, 4B4D60BE2A0C848A00BCD287 /* NetworkProtection+ConvenienceInitializers.swift in Sources */, 3706FB95293F65D500E42796 /* PermissionType.swift in Sources */, @@ -10270,6 +10322,7 @@ 3706FBBC293F65D500E42796 /* NSViewExtension.swift in Sources */, 3706FBBE293F65D500E42796 /* DownloadListViewModel.swift in Sources */, 3706FBBF293F65D500E42796 /* BookmarkManagementDetailViewController.swift in Sources */, + 7BB4BC642C5BC13D00E06FC8 /* SiteTroubleshootingInfoPublisher.swift in Sources */, B6B4D1CB2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */, F188268E2BBF01C400D9AC4F /* PixelDataModel.xcdatamodeld in Sources */, 3706FBC0293F65D500E42796 /* CSVImporter.swift in Sources */, @@ -10355,6 +10408,7 @@ 9F56CFAE2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift in Sources */, 3706FBF3293F65D500E42796 /* PseudoFolder.swift in Sources */, 1D26EBAD2B74BECB0002A93F /* NSImageSendable.swift in Sources */, + 7B7F5D252C52725A00826256 /* AddExcludedDomainButtonsView.swift in Sources */, 1D220BFD2B87AACF00F8BBC6 /* PrivacyProtectionStatus.swift in Sources */, 3706FBF8293F65D500E42796 /* TabBarFooter.swift in Sources */, B626A7612992407D00053070 /* CancellableExtension.swift in Sources */, @@ -10505,6 +10559,7 @@ 3706FC53293F65D500E42796 /* TabBarScrollView.swift in Sources */, B6104E9C2BA9C173008636B2 /* DownloadResumeData.swift in Sources */, 3706FC54293F65D500E42796 /* BookmarkListTreeControllerDataSource.swift in Sources */, + 7B7F5D222C526CE600826256 /* AddExcludedDomainView.swift in Sources */, 3706FC55293F65D500E42796 /* AddressBarViewController.swift in Sources */, 3706FC56293F65D500E42796 /* Permissions.swift in Sources */, 9F872D992B8DA9F800138637 /* Bookmarks+Tab.swift in Sources */, @@ -10537,6 +10592,7 @@ 7B6545EE2C0779D500115BEA /* VPNControllerUDSClient+ConvenienceInitializers.swift in Sources */, B6F9BDE52B45CD1900677B33 /* ModalView.swift in Sources */, 9F872DA42B90920F00138637 /* BookmarkFolderInfo.swift in Sources */, + 7B60AFFE2C514269008E32A3 /* VPNURLEventHandler.swift in Sources */, B677FC502B06376B0099EB04 /* ReportFeedbackView.swift in Sources */, 3706FC68293F65D500E42796 /* ToggleableScrollView.swift in Sources */, 3706FC69293F65D500E42796 /* UserScripts.swift in Sources */, @@ -10574,6 +10630,7 @@ 4BCBE4552BA7E16600FC75A1 /* NetworkProtectionSubscriptionEventHandler.swift in Sources */, 371209312C233D69003ADF3D /* RemoteMessagingStoreErrorHandling.swift in Sources */, 3706FC83293F65D500E42796 /* PopoverMessageViewController.swift in Sources */, + 7B5A23702C46A116007213AC /* ExcludedDomainsModel.swift in Sources */, 4BF97ADA2B43C5DC00EB4240 /* VPNFeedbackCategory.swift in Sources */, 9D9AE86E2AA76D1F0026E7DC /* LoginItem+NetworkProtection.swift in Sources */, 3707C721294B5D2900682A9F /* WKMenuItemIdentifier.swift in Sources */, @@ -10608,6 +10665,7 @@ 3706FC9C293F65D500E42796 /* BookmarkStore.swift in Sources */, 3706FC9D293F65D500E42796 /* PrivacyDashboardViewController.swift in Sources */, B6A22B632B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */, + 7BB4BC6B2C5CD96200E06FC8 /* ActiveDomainPublisher.swift in Sources */, 3706FC9E293F65D500E42796 /* PreferencesAppearanceView.swift in Sources */, 3706FC9F293F65D500E42796 /* NSMenuItemExtension.swift in Sources */, 3706FCA0293F65D500E42796 /* ContiguousBytesExtension.swift in Sources */, @@ -11076,6 +11134,7 @@ 7BA7CC402AD11E3D0042E5CE /* AppLauncher+DefaultInitializer.swift in Sources */, 7B0694982B6E980F00FA4DBA /* VPNProxyLauncher.swift in Sources */, BDA764842BC49E3F00D0400C /* NetworkProtectionVPNCountryLabelsModel.swift in Sources */, + 7B60AFFA2C511B65008E32A3 /* VPNUIActionHandler.swift in Sources */, B65DA5EF2A77CC3A00CBEE8D /* Bundle+NetworkProtectionExtensions.swift in Sources */, BDA7647F2BC4998900D0400C /* DefaultVPNLocationFormatter.swift in Sources */, 4BF0E5072AD2551A00FFEC9E /* NetworkProtectionPixelEvent.swift in Sources */, @@ -11115,6 +11174,7 @@ B65DA5F02A77CC3C00CBEE8D /* Bundle+NetworkProtectionExtensions.swift in Sources */, BDA764852BC49E4000D0400C /* NetworkProtectionVPNCountryLabelsModel.swift in Sources */, 7BAF9E4D2A8A3CCB002D3B6E /* UserDefaults+NetworkProtectionShared.swift in Sources */, + 7B60AFFB2C511C68008E32A3 /* VPNUIActionHandler.swift in Sources */, 7BA7CC392AD11E2D0042E5CE /* DuckDuckGoVPNAppDelegate.swift in Sources */, BDA764802BC4998A00D0400C /* DefaultVPNLocationFormatter.swift in Sources */, 7BA7CC552AD11FFB0042E5CE /* NetworkProtectionOptionKeyExtension.swift in Sources */, @@ -11388,6 +11448,7 @@ F18826912BC0105800D9AC4F /* PixelDataStore.swift in Sources */, B69B503E2726A12500758A2B /* AtbParser.swift in Sources */, 37F19A6528E1B3FB00740DC6 /* PreferencesDuckPlayerView.swift in Sources */, + 7B7F5D212C526CE600826256 /* AddExcludedDomainView.swift in Sources */, 4B92929E26670D2A00AD2C21 /* BookmarkSidebarTreeController.swift in Sources */, EEC4A6712B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift in Sources */, 85589E8727BBB8F20038AD11 /* HomePageFavoritesModel.swift in Sources */, @@ -11434,6 +11495,7 @@ 85774AFF2A713D3B00DE0561 /* BookmarksBarMenuFactory.swift in Sources */, 4B9292A026670D2A00AD2C21 /* SpacerNode.swift in Sources */, 3775913629AB9A1C00E26367 /* SyncManagementDialogViewController.swift in Sources */, + 7B7F5D242C52725A00826256 /* AddExcludedDomainButtonsView.swift in Sources */, B6C0BB6729AEFF8100AE8E3C /* BookmarkExtension.swift in Sources */, 4BE6547F271FCD4D008D1D63 /* PasswordManagementCreditCardModel.swift in Sources */, 31B4AF532901A4F20013585E /* NSEventExtension.swift in Sources */, @@ -11455,12 +11517,14 @@ 1D0DE93E2C3BA9840037ABC2 /* AppRestarter.swift in Sources */, 7BCB90C22C18626E008E3543 /* VPNControllerXPCClient+ConvenienceInitializers.swift in Sources */, 4B9DB0202A983B24000927DB /* ProductWaitlistRequest.swift in Sources */, + 7B60B0022C5145EC008E32A3 /* VPNUIActionHandler.swift in Sources */, 98779A0029999B64005D8EB6 /* Bookmark.xcdatamodeld in Sources */, 85589E9E27BFE4500038AD11 /* DefaultBrowserPromptView.swift in Sources */, 4B4032842AAAC24400CCA602 /* WaitlistActivationDateStore.swift in Sources */, 1D220BFC2B87AACF00F8BBC6 /* PrivacyProtectionStatus.swift in Sources */, AA512D1424D99D9800230283 /* FaviconManager.swift in Sources */, 7BB108592A43375D000AB95F /* PFMoveApplication.m in Sources */, + 7BB4BC632C5BC13D00E06FC8 /* SiteTroubleshootingInfoPublisher.swift in Sources */, 4B0AACAC28BC63ED001038AC /* ChromiumFaviconsReader.swift in Sources */, AABEE6AB24ACA0F90043105B /* SuggestionTableRowView.swift in Sources */, 37CD54CB27F2FDD100F1F7B9 /* DownloadsPreferences.swift in Sources */, @@ -11826,6 +11890,7 @@ C168B9AC2B31DC7E001AFAD9 /* AutofillNeverPromptWebsitesManager.swift in Sources */, 9FA173E72B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift in Sources */, F1C70D7C2BFF510000599292 /* SubscriptionEnvironment+Default.swift in Sources */, + 7B60AFFF2C51426A008E32A3 /* VPNURLEventHandler.swift in Sources */, D64A5FF82AEA5C2B00B6D6E7 /* HomeButtonMenuFactory.swift in Sources */, 37A6A8F62AFCCA59008580A3 /* FaviconsFetcherOnboardingViewController.swift in Sources */, AA3F895324C18AD500628DDE /* SuggestionViewModel.swift in Sources */, @@ -11833,6 +11898,7 @@ 4B723E1326B0007A00E14D75 /* CSVLoginExporter.swift in Sources */, B6BCC51E2AFCD9ED002C5499 /* DataImportSourcePicker.swift in Sources */, 85C48CCC278D808F00D3263E /* NSAttributedStringExtension.swift in Sources */, + 7B5A236F2C46A116007213AC /* ExcludedDomainsModel.swift in Sources */, 1D710F4B2C48F1F200C3975F /* UpdateDialogHelper.swift in Sources */, 4B41EDB42B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */, AA7EB6E527E7D6DC00036718 /* AnimationView.swift in Sources */, @@ -11955,6 +12021,7 @@ 1456D6E124EFCBC300775049 /* TabBarCollectionView.swift in Sources */, 4B4D60BF2A0C848A00BCD287 /* NetworkProtection+ConvenienceInitializers.swift in Sources */, 4B6B64842BA930420009FF9F /* WaitlistThankYouPromptPresenter.swift in Sources */, + 7B5A23682C468233007213AC /* ExcludedDomainsViewController.swift in Sources */, BDA7647C2BC497BE00D0400C /* DefaultVPNLocationFormatter.swift in Sources */, 3158B1592B0BF76400AF130C /* DataBrokerProtectionFeatureDisabler.swift in Sources */, B655124829A79465009BFE1C /* NavigationActionExtension.swift in Sources */, @@ -12058,6 +12125,7 @@ 37534CA8281198CD002621E7 /* AdjacentItemEnumerator.swift in Sources */, 7B7FCD0F2BA33B2700C04FBE /* UserDefaults+vpnLegacyUser.swift in Sources */, 987799F62999996B005D8EB6 /* BookmarkDatabase.swift in Sources */, + 7BB4BC6A2C5CD96200E06FC8 /* ActiveDomainPublisher.swift in Sources */, 4BE53374286E39F10019DBFD /* ChromiumKeychainPrompt.swift in Sources */, B6553692268440D700085A79 /* WKProcessPool+GeolocationProvider.swift in Sources */, B68D21C32ACBC916002DA3C2 /* ContentBlockingMock.swift in Sources */, @@ -13364,7 +13432,7 @@ repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 180.0.0; + version = 180.0.1; }; }; 9FF521422BAA8FF300B9819B /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d08cec1c36..50bef80e9b 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/BrowserServicesKit", "state" : { - "revision" : "92ecebfb4172ab9561959a07d7ef7037aea8c6e1", - "version" : "180.0.0" + "revision" : "a3b3df069bbaa06149e43ca26e5df219ee61aa15", + "version" : "180.0.1" } }, { diff --git a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme index e343e06df9..7e937b1eb2 100644 --- a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme +++ b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser App Store.xcscheme @@ -145,6 +145,12 @@ + + + + + + + + Void private var didFinishLaunching = false @@ -109,7 +112,9 @@ final class URLEventHandler { private static func openURL(_ url: URL) { if url.scheme?.isNetworkProtectionScheme == true { - handleNetworkProtectionURL(url) + Task { @MainActor in + await vpnURLEventHandler.handle(url) + } } #if DBP @@ -141,38 +146,6 @@ final class URLEventHandler { } } - /// Handles NetP URLs - private static func handleNetworkProtectionURL(_ url: URL) { - DispatchQueue.main.async { - switch url { - case VPNAppLaunchCommand.showStatus.launchURL: - Task { - await WindowControllersManager.shared.showNetworkProtectionStatus() - } - case VPNAppLaunchCommand.showSettings.launchURL: - WindowControllersManager.shared.showPreferencesTab(withSelectedPane: .vpn) - case VPNAppLaunchCommand.shareFeedback.launchURL: - WindowControllersManager.shared.showShareFeedbackModal() - case VPNAppLaunchCommand.justOpen.launchURL: - WindowControllersManager.shared.showMainWindow() - case VPNAppLaunchCommand.showVPNLocations.launchURL: - WindowControllersManager.shared.showPreferencesTab(withSelectedPane: .vpn) - WindowControllersManager.shared.showLocationPickerSheet() - case VPNAppLaunchCommand.showPrivacyPro.launchURL: - let url = Application.appDelegate.subscriptionManager.url(for: .purchase) - WindowControllersManager.shared.showTab(with: .subscription(url)) - PixelKit.fire(PrivacyProPixel.privacyProOfferScreenImpression) -#if !APPSTORE && !DEBUG - case VPNAppLaunchCommand.moveAppToApplications.launchURL: - // this should be run after NSApplication.shared is set - PFMoveToApplicationsFolderIfNecessary(false) -#endif - default: - return - } - } - } - #if DBP /// Handles DBP URLs /// diff --git a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift index 99d6f49afa..4d8c4d348b 100644 --- a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift +++ b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift @@ -124,6 +124,8 @@ extension UserText { // MARK: - Setting Titles // "vpn.location.title" - Location section title in VPN settings static let vpnLocationTitle = "Location" + // "vpn.excluded.sites.title" - Excluded Sites title in VPN settings + static let vpnExcludedSitesTitle = "Excluded Sites" // "vpn.general.title" - General section title in VPN settings static let vpnGeneralTitle = "General" // "vpn.shortcuts.settings.title" - Shortcuts section title in VPN settings @@ -161,6 +163,16 @@ extension UserText { return String(format: message, count) } + // MARK: - Excluded Domains + // "vpn.setting.excluded.domains.description" - Excluded Sites description + static let vpnExcludedDomainsDescription = "Websites you selected to be excluded even when the VPN is connected." + // "vpn.setting.excluded.domains.manage.button.title" - Excluded Sites management button title + static let vpnExcludedDomainsManageButtonTitle = "Manage Excluded Sites…" + // "vpn.excluded.domains.add.domain" - Add Domain button for the excluded sites view + static let vpnExcludedDomainsAddDomain = "Add Website" + // "vpn.excluded.domains.title" - Title for the excluded sites view + static let vpnExcludedDomainsTitle = "Excluded Websites" + // MARK: - DNS // "vpn.dns.server.title" - Title of the DNS Server section static let vpnDnsServerTitle = "DNS Server" diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index b21ce803f6..cfe2bc8a52 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -57121,6 +57121,9 @@ } } } + }, + "URL" : { + }, "version" : { "comment" : "Displays the version and build numbers", diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift index 10e3549432..b92afe8eac 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift @@ -29,6 +29,7 @@ protocol PopoverPresenter { func show(_ popover: NSPopover, positionedBelow view: NSView) } +@MainActor protocol NetPPopoverManager: AnyObject { var isShown: Bool { get } @@ -133,8 +134,10 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { } func toggleNetworkProtectionPopover(from button: MouseOverButton, withDelegate delegate: NSPopoverDelegate) { - if let popover = networkProtectionPopoverManager.toggle(positionedBelow: button, withDelegate: delegate) { - bindIsMouseDownState(of: button, to: popover) + Task { @MainActor in + if let popover = networkProtectionPopoverManager.toggle(positionedBelow: button, withDelegate: delegate) { + bindIsMouseDownState(of: button, to: popover) + } } } @@ -199,8 +202,10 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { downloadsPopover?.close() } - if networkProtectionPopoverManager.isShown { - networkProtectionPopoverManager.close() + Task { @MainActor in + if networkProtectionPopoverManager.isShown { + networkProtectionPopoverManager.close() + } } if bookmarkPopover?.isShown ?? false { @@ -432,8 +437,11 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { // MARK: - VPN func showNetworkProtectionPopover(positionedBelow button: MouseOverButton, withDelegate delegate: NSPopoverDelegate) { - let popover = networkProtectionPopoverManager.show(positionedBelow: button, withDelegate: delegate) - bindIsMouseDownState(of: button, to: popover) + + Task { @MainActor in + let popover = networkProtectionPopoverManager.show(positionedBelow: button, withDelegate: delegate) + bindIsMouseDownState(of: button, to: popover) + } } } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/ActiveDomainPublisher.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/ActiveDomainPublisher.swift new file mode 100644 index 0000000000..9caf65a9ec --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/ActiveDomainPublisher.swift @@ -0,0 +1,100 @@ +// +// ActiveDomainPublisher.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 Combine +import Foundation + +/// A convenience class for publishing the active domain +/// +/// The active domain is the domain loaded in the last active tab within the last active window. +/// +final class ActiveDomainPublisher { + + private let windowControllersManager: WindowControllersManager + private var activeWindowControllerCancellable: AnyCancellable? + private var activeTabViewModelCancellable: AnyCancellable? + private var activeTabContentCancellable: AnyCancellable? + + @MainActor + @Published + private var activeWindowController: MainWindowController? { + didSet { + subscribeToActiveTabViewModel() + } + } + + @MainActor + @Published + private var activeTab: Tab? { + didSet { + subscribeToActiveTabContentChanges() + } + } + + init(windowControllersManager: WindowControllersManager) { + self.windowControllersManager = windowControllersManager + + Task { @MainActor in + subscribeToKeyWindowControllerChanges() + } + } + + @Published + private(set) var activeDomain: String? + + @MainActor + private func subscribeToKeyWindowControllerChanges() { + activeWindowControllerCancellable = windowControllersManager + .didChangeKeyWindowController + .prepend(windowControllersManager.lastKeyMainWindowController) + .assign(to: \.activeWindowController, onWeaklyHeld: self) + } + + @MainActor + private func subscribeToActiveTabViewModel() { + activeTabViewModelCancellable = activeWindowController?.mainViewController.tabCollectionViewModel.$selectedTabViewModel + .map(\.?.tab) + .assign(to: \.activeTab, onWeaklyHeld: self) + } + + @MainActor + private func subscribeToActiveTabContentChanges() { + activeTabContentCancellable = activeTab?.$content + .map(domain(from:)) + .removeDuplicates() + .assign(to: \.activeDomain, onWeaklyHeld: self) + } + + private func domain(from tabContent: Tab.TabContent) -> String? { + if case .url(let url, _, _) = tabContent { + + return url.host + } else { + return nil + } + } +} + +extension ActiveDomainPublisher: Publisher { + typealias Output = String? + typealias Failure = Never + + func receive(subscriber: S) where S: Subscriber, Never == S.Failure, String? == S.Input { + $activeDomain.subscribe(subscriber) + } +} diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift index 3bc4345a91..9afbacf519 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift @@ -82,6 +82,14 @@ final class NetworkProtectionDebugMenu: NSMenu { NSMenuItem.separator() + NSMenuItem(title: "Adapter") { + NSMenuItem(title: "Restart Adapter", action: #selector(NetworkProtectionDebugMenu.restartAdapter(_:))) + .targetting(self) + + NSMenuItem(title: "Re-create Adapter", action: #selector(NetworkProtectionDebugMenu.restartAdapter(_:))) + .targetting(self) + } + NSMenuItem(title: "Tunnel Settings") { shouldIncludeAllNetworksMenuItem .targetting(self) @@ -218,6 +226,18 @@ final class NetworkProtectionDebugMenu: NSMenu { } } + /// Removes the system extension and agents for DuckDuckGo VPN. + /// + @objc func restartAdapter(_ sender: Any?) { + Task { @MainActor in + do { + try await debugUtilities.restartAdapter() + } catch { + await NSAlert(error: error).runModal() + } + } + } + /// Sends a test user notification. /// @objc func sendTestNotification(_ sender: Any?) { @@ -449,8 +469,8 @@ final class NetworkProtectionDebugMenu: NSMenu { private let ddgBrowserAppIdentifier = Bundle.main.bundleIdentifier! private func updateExclusionsMenu() { - excludeDBPTrafficFromVPN.state = transparentProxySettings.isExcluding(dbpBackgroundAppIdentifier) ? .on : .off - excludeDDGBrowserTrafficFromVPN.state = transparentProxySettings.isExcluding(ddgBrowserAppIdentifier) ? .on : .off + excludeDBPTrafficFromVPN.state = transparentProxySettings.isExcluding(appIdentifier: dbpBackgroundAppIdentifier) ? .on : .off + excludeDDGBrowserTrafficFromVPN.state = transparentProxySettings.isExcluding(appIdentifier: ddgBrowserAppIdentifier) ? .on : .off } @objc private func toggleExcludeDBPBackgroundAgent() { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift index 8f3a085734..d8d744f684 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift @@ -54,6 +54,10 @@ final class NetworkProtectionDebugUtilities { // MARK: - Debug commands for the extension + func restartAdapter() async throws { + try await ipcClient.command(.restartAdapter) + } + func resetAllState(keepAuthToken: Bool) async throws { try await vpnUninstaller.uninstall(removeSystemExtension: true) diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift index 6b55361611..6573e3a550 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift @@ -19,6 +19,7 @@ import AppLauncher import AppKit import Combine +import Common import Foundation import LoginItems import NetworkProtection @@ -26,6 +27,8 @@ import NetworkProtectionIPC import NetworkProtectionUI import Subscription import VPNAppLauncher +import SwiftUI +import NetworkProtectionProxy protocol NetworkProtectionIPCClient { var ipcStatusObserver: ConnectionStatusObserver { get } @@ -44,15 +47,36 @@ extension VPNControllerXPCClient: NetworkProtectionIPCClient { public var ipcDataVolumeObserver: any NetworkProtection.DataVolumeObserver { dataVolumeObserver } } +@MainActor final class NetworkProtectionNavBarPopoverManager: NetPPopoverManager { private var networkProtectionPopover: NetworkProtectionPopover? let ipcClient: NetworkProtectionIPCClient let vpnUninstaller: VPNUninstalling + @Published + private var siteInfo: SiteTroubleshootingInfo? + private let siteTroubleshootingInfoPublisher: SiteTroubleshootingInfoPublisher + private var cancellables = Set() + init(ipcClient: VPNControllerXPCClient, vpnUninstaller: VPNUninstalling) { + self.ipcClient = ipcClient self.vpnUninstaller = vpnUninstaller + + let activeDomainPublisher = ActiveDomainPublisher(windowControllersManager: .shared) + + siteTroubleshootingInfoPublisher = SiteTroubleshootingInfoPublisher( + activeDomainPublisher: activeDomainPublisher.eraseToAnyPublisher(), + proxySettings: TransparentProxySettings(defaults: .netP)) + + subscribeToCurrentSitePublisher() + } + + private func subscribeToCurrentSitePublisher() { + siteTroubleshootingInfoPublisher + .assign(to: \.siteInfo, onWeaklyHeld: self) + .store(in: &cancellables) } var isShown: Bool { @@ -60,8 +84,7 @@ final class NetworkProtectionNavBarPopoverManager: NetPPopoverManager { } func show(positionedBelow view: NSView, withDelegate delegate: NSPopoverDelegate) -> NSPopover { - let popover = { - + let popover: NSPopover = { let controller = NetworkProtectionIPCTunnelController(ipcClient: ipcClient) let statusReporter = DefaultNetworkProtectionStatusReporter( @@ -77,12 +100,23 @@ final class NetworkProtectionNavBarPopoverManager: NetPPopoverManager { let onboardingStatusPublisher = UserDefaults.netP.networkProtectionOnboardingStatusPublisher _ = VPNSettings(defaults: .netP) let appLauncher = AppLauncher(appBundleURL: Bundle.main.bundleURL) - - let popover = NetworkProtectionPopover(controller: controller, - onboardingStatusPublisher: onboardingStatusPublisher, - statusReporter: statusReporter, - uiActionHandler: appLauncher, - menuItems: { + let vpnURLEventHandler = VPNURLEventHandler() + let proxySettings = TransparentProxySettings(defaults: .netP) + let uiActionHandler = VPNUIActionHandler(vpnURLEventHandler: vpnURLEventHandler, proxySettings: proxySettings) + + let siteTroubleshootingFeatureFlagPublisher = NSApp.delegateTyped.internalUserDecider.isInternalUserPublisher.eraseToAnyPublisher() + + let siteTroubleshootingViewModel = SiteTroubleshootingView.Model( + featureFlagPublisher: siteTroubleshootingFeatureFlagPublisher, + connectionStatusPublisher: statusReporter.statusObserver.publisher, + siteTroubleshootingInfoPublisher: $siteInfo.eraseToAnyPublisher(), + uiActionHandler: uiActionHandler) + + let statusViewModel = NetworkProtectionStatusView.Model(controller: controller, + onboardingStatusPublisher: onboardingStatusPublisher, + statusReporter: statusReporter, + uiActionHandler: uiActionHandler, + menuItems: { if UserDefaults.netP.networkProtectionOnboardingStatus == .completed { return [ NetworkProtectionStatusView.Model.MenuItem( @@ -113,13 +147,19 @@ final class NetworkProtectionNavBarPopoverManager: NetPPopoverManager { ] } }, - agentLoginItem: LoginItem.vpnMenu, - isMenuBarStatusView: false, - userDefaults: .netP, - locationFormatter: DefaultVPNLocationFormatter(), - uninstallHandler: { [weak self] in + agentLoginItem: LoginItem.vpnMenu, + isMenuBarStatusView: false, + userDefaults: .netP, + locationFormatter: DefaultVPNLocationFormatter(), + uninstallHandler: { [weak self] in _ = try? await self?.vpnUninstaller.uninstall(removeSystemExtension: true) }) + + let popover = NetworkProtectionPopover( + statusViewModel: statusViewModel, + statusReporter: statusReporter, + siteTroubleshootingViewModel: siteTroubleshootingViewModel, + debugInformationViewModel: DebugInformationViewModel(showDebugInformation: false)) popover.delegate = delegate networkProtectionPopover = popover diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift index 899897dfce..78d6cda52f 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift @@ -111,13 +111,6 @@ final class NetworkProtectionTunnelController: TunnelController, TunnelSessionPr // MARK: - User Defaults - /* Temporarily disabled - https://app.asana.com/0/0/1205766100762904/f - /// Test setting to exclude duckduckgo route from VPN - @MainActor - @UserDefaultsWrapper(key: .networkProtectionExcludedRoutes, defaultValue: [:]) - private(set) var excludedRoutesPreferences: [String: Bool] - */ - @UserDefaultsWrapper(key: .networkProtectionOnboardingStatusRawValue, defaultValue: OnboardingStatus.default.rawValue, defaults: .netP) private(set) var onboardingStatusRawValue: OnboardingStatus.RawValue diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/SiteTroubleshootingInfoPublisher.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/SiteTroubleshootingInfoPublisher.swift new file mode 100644 index 0000000000..5a3d568414 --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/SiteTroubleshootingInfoPublisher.swift @@ -0,0 +1,111 @@ +// +// SiteTroubleshootingInfoPublisher.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 Combine +import Foundation +import NetworkProtectionProxy +import NetworkProtectionUI + +@MainActor +final class SiteTroubleshootingInfoPublisher { + + private var activeDomain: String? { + didSet { + refreshSiteTroubleshootingInfo() + } + } + + private let subject: CurrentValueSubject + + private let activeDomainPublisher: AnyPublisher + private let proxySettings: TransparentProxySettings + private var cancellables = Set() + + init(activeDomainPublisher: AnyPublisher, + proxySettings: TransparentProxySettings) { + + subject = CurrentValueSubject(nil) + self.activeDomainPublisher = activeDomainPublisher + self.proxySettings = proxySettings + + subscribeToActiveDomainChanges() + subscribeToExclusionChanges() + } + + private func subscribeToActiveDomainChanges() { + activeDomainPublisher + .assign(to: \.activeDomain, onWeaklyHeld: self) + .store(in: &cancellables) + } + + private func subscribeToExclusionChanges() { + proxySettings.changePublisher.sink { [weak self] change in + guard let self else { return } + + switch change { + case .excludedDomains: + refreshSiteTroubleshootingInfo() + default: + break + } + }.store(in: &cancellables) + } + + // MARK: - Refreshing + + func refreshSiteTroubleshootingInfo() { + if activeSiteTroubleshootingInfo != subject.value { + subject.send(activeSiteTroubleshootingInfo) + } + } + + // MARK: - Active Site Troubleshooting Info + + var activeSiteTroubleshootingInfo: SiteTroubleshootingInfo? { + guard let activeDomain else { + return nil + } + + return site(forDomain: activeDomain.droppingWwwPrefix()) + } + + private func site(forDomain domain: String) -> SiteTroubleshootingInfo? { + let icon: NSImage? + let currentSite: NetworkProtectionUI.SiteTroubleshootingInfo? + + icon = FaviconManager.shared.getCachedFavicon(for: domain, sizeCategory: .small)?.image + let proxySettings = TransparentProxySettings(defaults: .netP) + currentSite = NetworkProtectionUI.SiteTroubleshootingInfo( + icon: icon, + domain: domain, + excluded: proxySettings.isExcluding(domain: domain)) + + return currentSite + } +} + +extension SiteTroubleshootingInfoPublisher: Publisher { + typealias Output = SiteTroubleshootingInfo? + typealias Failure = Never + + nonisolated + func receive(subscriber: S) where S: Subscriber, Never == S.Failure, NetworkProtectionUI.SiteTroubleshootingInfo? == S.Input { + + subject.receive(subscriber: subscriber) + } +} diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNUIActionHandler.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNUIActionHandler.swift new file mode 100644 index 0000000000..53db14808b --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNUIActionHandler.swift @@ -0,0 +1,65 @@ +// +// VPNUIActionHandler.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 AppLauncher +import Foundation +import NetworkProtectionIPC +import NetworkProtectionProxy +import NetworkProtectionUI +import VPNAppLauncher + +/// Main App's VPN UI action handler +/// +final class VPNUIActionHandler: VPNUIActionHandling { + + private let vpnIPCClient: VPNControllerXPCClient + private let proxySettings: TransparentProxySettings + private let vpnURLEventHandler: VPNURLEventHandler + + init(vpnIPCClient: VPNControllerXPCClient = .shared, + vpnURLEventHandler: VPNURLEventHandler, + proxySettings: TransparentProxySettings) { + + self.vpnIPCClient = vpnIPCClient + self.vpnURLEventHandler = vpnURLEventHandler + self.proxySettings = proxySettings + } + + public func moveAppToApplications() async { +#if !APPSTORE && !DEBUG + await vpnURLEventHandler.moveAppToApplicationsFolder() +#endif + } + + func setExclusion(_ exclude: Bool, forDomain domain: String) async { + proxySettings.setExclusion(exclude, forDomain: domain) + try? await vpnIPCClient.command(.restartAdapter) + } + + public func shareFeedback() async { + await vpnURLEventHandler.showShareFeedback() + } + + public func showVPNLocations() async { + await vpnURLEventHandler.showLocations() + } + + public func showPrivacyPro() async { + await vpnURLEventHandler.showPrivacyPro() + } +} diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNURLEventHandler.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNURLEventHandler.swift new file mode 100644 index 0000000000..54368bc09e --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNURLEventHandler.swift @@ -0,0 +1,91 @@ +// +// VPNURLEventHandler.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 PixelKit +import VPNAppLauncher + +@MainActor +final class VPNURLEventHandler { + + private let windowControllerManager: WindowControllersManager + + init(windowControllerManager: WindowControllersManager? = nil) { + self.windowControllerManager = windowControllerManager ?? .shared + } + + /// Handles VPN event URLs + /// + func handle(_ url: URL) async { + switch url { + case VPNAppLaunchCommand.showStatus.launchURL: + await showStatus() + case VPNAppLaunchCommand.showSettings.launchURL: + showPreferences() + case VPNAppLaunchCommand.shareFeedback.launchURL: + showShareFeedback() + case VPNAppLaunchCommand.justOpen.launchURL: + showMainWindow() + case VPNAppLaunchCommand.showVPNLocations.launchURL: + showLocations() + case VPNAppLaunchCommand.showPrivacyPro.launchURL: + showPrivacyPro() +#if !APPSTORE && !DEBUG + case VPNAppLaunchCommand.moveAppToApplications.launchURL: + moveAppToApplicationsFolder() +#endif + default: + return + } + } + + func showStatus() async { + await windowControllerManager.showNetworkProtectionStatus() + } + + func showPreferences() { + windowControllerManager.showPreferencesTab(withSelectedPane: .vpn) + } + + func showShareFeedback() { + windowControllerManager.showShareFeedbackModal() + } + + func showMainWindow() { + windowControllerManager.showMainWindow() + } + + func showLocations() { + windowControllerManager.showPreferencesTab(withSelectedPane: .vpn) + windowControllerManager.showLocationPickerSheet() + } + + func showPrivacyPro() { + let url = Application.appDelegate.subscriptionManager.url(for: .purchase) + windowControllerManager.showTab(with: .subscription(url)) + + PixelKit.fire(PrivacyProPixel.privacyProOfferScreenImpression) + } + +#if !APPSTORE && !DEBUG + func moveAppToApplicationsFolder() { + // this should be run after NSApplication.shared is set + PFMoveToApplicationsFolderIfNecessary(false) + } +#endif +} diff --git a/DuckDuckGo/NetworkProtection/ExcludedDomains/AddExcludedDomainButtonsView.swift b/DuckDuckGo/NetworkProtection/ExcludedDomains/AddExcludedDomainButtonsView.swift new file mode 100644 index 0000000000..c4db9dafc0 --- /dev/null +++ b/DuckDuckGo/NetworkProtection/ExcludedDomains/AddExcludedDomainButtonsView.swift @@ -0,0 +1,187 @@ +// +// AddExcludedDomainButtonsView.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 SwiftUI + +struct AddExcludedDomainButtonsView: View { + private let viewState: ViewState + private let otherButtonAction: Action + private let defaultButtonAction: Action + @Environment(\.dismiss) private var dismiss + + init( + viewState: ViewState, + otherButtonAction: Action, + defaultButtonAction: Action + ) { + self.viewState = viewState + self.otherButtonAction = otherButtonAction + self.defaultButtonAction = defaultButtonAction + } + + var body: some View { + HStack { + if viewState == .compressed { + Spacer() + } + + actionButton(action: otherButtonAction, viewState: viewState).accessibilityIdentifier("AddExcludedDomainButtonsView.otherButton") + + actionButton(action: defaultButtonAction, viewState: viewState).accessibilityIdentifier("AddExcludedDomainButtonsView.defaultButton") + } + } + + @MainActor + private func actionButton(action: Action, viewState: ViewState) -> some View { + Button { + action.action(dismiss.callAsFunction) + } label: { + Text(action.title) + .frame(height: viewState.height) + .frame(maxWidth: viewState.maxWidth) + } + .keyboardShortcut(action.keyboardShortCut) + .disabled(action.isDisabled) + .ifLet(action.accessibilityIdentifier) { view, value in + view.accessibilityIdentifier(value) + } + } +} + +// MARK: - BookmarkDialogButtonsView + Types + +extension AddExcludedDomainButtonsView { + + enum ViewState: Equatable { + case compressed + case expanded + } + + struct Action { + let title: String + let keyboardShortCut: KeyboardShortcut? + let accessibilityIdentifier: String? + let isDisabled: Bool + let action: @MainActor (_ dismiss: () -> Void) -> Void + + init( + title: String, + accessibilityIdentifier: String? = nil, + keyboardShortCut: KeyboardShortcut? = nil, + isDisabled: Bool = false, + action: @MainActor @escaping (_ dismiss: () -> Void) -> Void + ) { + self.title = title + self.keyboardShortCut = keyboardShortCut + self.accessibilityIdentifier = accessibilityIdentifier + self.isDisabled = isDisabled + self.action = action + } + } +} + +// MARK: - BookmarkDialogButtonsView.ViewState + +private extension AddExcludedDomainButtonsView.ViewState { + + var maxWidth: CGFloat? { + switch self { + case .compressed: + return nil + case .expanded: + return .infinity + } + } + + var height: CGFloat? { + switch self { + case .compressed: + return nil + case .expanded: + return 28.0 + } + } + +} + +// MARK: - Preview + +#Preview("Compressed - Disable Default Button") { + AddExcludedDomainButtonsView( + viewState: .compressed, + otherButtonAction: .init( + title: "Left", + action: { _ in } + ), + defaultButtonAction: .init( + title: "Right", + isDisabled: true, + action: {_ in } + ) + ) + .frame(width: 320, height: 50) +} + +#Preview("Compressed - Enabled Default Button") { + AddExcludedDomainButtonsView( + viewState: .compressed, + otherButtonAction: .init( + title: "Left", + action: { _ in } + ), + defaultButtonAction: .init( + title: "Right", + isDisabled: false, + action: {_ in } + ) + ) + .frame(width: 320, height: 50) +} + +#Preview("Expanded - Disable Default Button") { + AddExcludedDomainButtonsView( + viewState: .expanded, + otherButtonAction: .init( + title: "Left", + action: { _ in } + ), + defaultButtonAction: .init( + title: "Right", + isDisabled: true, + action: {_ in } + ) + ) + .frame(width: 320, height: 50) +} + +#Preview("Expanded - Enable Default Button") { + AddExcludedDomainButtonsView( + viewState: .expanded, + otherButtonAction: .init( + title: "Left", + action: { _ in } + ), + defaultButtonAction: .init( + title: "Right", + isDisabled: false, + action: {_ in } + ) + ) + .frame(width: 320, height: 50) +} diff --git a/DuckDuckGo/NetworkProtection/ExcludedDomains/AddExcludedDomainView.swift b/DuckDuckGo/NetworkProtection/ExcludedDomains/AddExcludedDomainView.swift new file mode 100644 index 0000000000..d79d26d0d1 --- /dev/null +++ b/DuckDuckGo/NetworkProtection/ExcludedDomains/AddExcludedDomainView.swift @@ -0,0 +1,112 @@ +// +// AddExcludedDomainView.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 SwiftUI +import SwiftUIExtensions + +struct AddExcludedDomainView: ModalView { + enum ButtonsState { + case compressed + case expanded + } + + let title: String + let buttonsState: ButtonsState + + @State + private var domain = "" + + let cancelActionTitle: String + let cancelAction: @MainActor (_ dismiss: () -> Void) -> Void + + let defaultActionTitle: String + @State + private var isDefaultActionDisabled = true + let defaultAction: @MainActor (_ domain: String, _ dismiss: () -> Void) -> Void + + var body: some View { + TieredDialogView( + verticalSpacing: 16.0, + horizontalPadding: 20.0, + top: { + Text(title) + .foregroundColor(.primary) + .fontWeight(.semibold) + .padding(.top, 20) + }, + center: { + form + }, + bottom: { + AddExcludedDomainButtonsView( + viewState: .init(buttonsState), + otherButtonAction: .init( + title: cancelActionTitle, + keyboardShortCut: .cancelAction, + isDisabled: false, + action: cancelAction + ), defaultButtonAction: .init( + title: defaultActionTitle, + keyboardShortCut: .defaultAction, + isDisabled: isDefaultActionDisabled, + action: { dismiss in + defaultAction(domain, dismiss) + } + ) + ).padding(.bottom, 16.0) + } + ).font(.system(size: 13)) + .frame(width: 420) + } + + var form: some View { + TwoColumnsListView( + horizontalSpacing: 16.0, + verticalSpacing: 20.0, + rowHeight: 22.0, + leftColumn: { + Text("URL") + .foregroundColor(.primary) + .fontWeight(.medium) + }, + rightColumn: { + TextField("", text: $domain) + .focusedOnAppear() + .onChange(of: domain) { _ in + isDefaultActionDisabled = !domain.isValidHostname + } + .accessibilityIdentifier("bookmark.add.name.textfield") + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(.system(size: 14)) + } + ) + } +} + +private extension AddExcludedDomainButtonsView.ViewState { + + init(_ state: AddExcludedDomainView.ButtonsState) { + switch state { + case .compressed: + self = .compressed + case .expanded: + self = .expanded + } + } +} diff --git a/DuckDuckGo/NetworkProtection/ExcludedDomains/ExcludedDomains.storyboard b/DuckDuckGo/NetworkProtection/ExcludedDomains/ExcludedDomains.storyboard new file mode 100644 index 0000000000..30ffd0b64f --- /dev/null +++ b/DuckDuckGo/NetworkProtection/ExcludedDomains/ExcludedDomains.storyboard @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DuckDuckGo/NetworkProtection/ExcludedDomains/ExcludedDomainsModel.swift b/DuckDuckGo/NetworkProtection/ExcludedDomains/ExcludedDomainsModel.swift new file mode 100644 index 0000000000..b3cbdbd75e --- /dev/null +++ b/DuckDuckGo/NetworkProtection/ExcludedDomains/ExcludedDomainsModel.swift @@ -0,0 +1,54 @@ +// +// ExcludedDomainsModel.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 NetworkProtectionProxy + +protocol ExcludedDomainsViewModel { + var domains: [String] { get } + + func add(domain: String) + func remove(domain: String) +} + +final class DefaultExcludedDomainsViewModel { + let proxySettings = TransparentProxySettings(defaults: .netP) + + init() { + } +} + +extension DefaultExcludedDomainsViewModel: ExcludedDomainsViewModel { + var domains: [String] { + proxySettings.excludedDomains + } + + func add(domain: String) { + guard !proxySettings.excludedDomains.contains(domain) else { + return + } + + proxySettings.excludedDomains.append(domain) + } + + func remove(domain: String) { + proxySettings.excludedDomains.removeAll { cursor in + domain == cursor + } + } +} diff --git a/DuckDuckGo/NetworkProtection/ExcludedDomains/ExcludedDomainsViewController.swift b/DuckDuckGo/NetworkProtection/ExcludedDomains/ExcludedDomainsViewController.swift new file mode 100644 index 0000000000..6192405f21 --- /dev/null +++ b/DuckDuckGo/NetworkProtection/ExcludedDomains/ExcludedDomainsViewController.swift @@ -0,0 +1,178 @@ +// +// ExcludedDomainsViewController.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 + +final class ExcludedDomainsViewController: NSViewController { + typealias Model = ExcludedDomainsViewModel + + enum Constants { + static let storyboardName = "ExcludedDomains" + static let identifier = "ExcludedDomainsViewController" + static let cellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "ExcludedDomainCell") + } + + static func create(model: Model = DefaultExcludedDomainsViewModel()) -> ExcludedDomainsViewController { + let storyboard = loadStoryboard() + + return storyboard.instantiateController(identifier: Constants.identifier) { coder in + ExcludedDomainsViewController(model: model, coder: coder) + } + } + + static func loadStoryboard() -> NSStoryboard { + NSStoryboard(name: Constants.storyboardName, bundle: nil) + } + + @IBOutlet var tableView: NSTableView! + @IBOutlet var addDomainButton: NSButton! + @IBOutlet var removeDomainButton: NSButton! + @IBOutlet var doneButton: NSButton! + @IBOutlet var excludedDomainsLabel: NSTextField! + + private let faviconManagement: FaviconManagement = FaviconManager.shared + + private var allDomains = [String]() + private var filteredDomains: [String]? + + private var visibleDomains: [String] { + return filteredDomains ?? allDomains + } + + private let model: Model + + init?(model: Model, coder: NSCoder) { + self.model = model + + super.init(coder: coder) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + applyModalWindowStyleIfNeeded() + reloadData() + setUpStrings() + } + + private func setUpStrings() { + addDomainButton.title = UserText.vpnExcludedDomainsAddDomain + removeDomainButton.title = UserText.remove + doneButton.title = UserText.done + excludedDomainsLabel.stringValue = UserText.vpnExcludedDomainsTitle + } + + private func updateRemoveButtonState() { + removeDomainButton.isEnabled = tableView.selectedRow > -1 + } + + fileprivate func reloadData() { + allDomains = model.domains.sorted { (lhs, rhs) -> Bool in + return lhs < rhs + } + + tableView.reloadData() + updateRemoveButtonState() + } + + @IBAction func doneButtonClicked(_ sender: NSButton) { + dismiss() + } + + @IBAction func addDomain(_ sender: NSButton) { + AddExcludedDomainView(title: "Add Website Exclusion", buttonsState: .compressed, cancelActionTitle: "Cancel", cancelAction: { dismiss in + + dismiss() + }, defaultActionTitle: "Add Website") { [weak self] domain, dismiss in + guard let self else { return } + + addDomain(domain) + dismiss() + }.show(in: view.window) + } + + private func addDomain(_ domain: String) { + model.add(domain: domain) + reloadData() + + if let newRowIndex = allDomains.firstIndex(of: domain) { + tableView.scrollRowToVisible(newRowIndex) + } + } + + @IBAction func removeSelectedDomain(_ sender: NSButton) { + guard tableView.selectedRow > -1 else { + updateRemoveButtonState() + return + } + + let selectedDomain = visibleDomains[tableView.selectedRow] + model.remove(domain: selectedDomain) + reloadData() + } +} + +extension ExcludedDomainsViewController: NSTableViewDataSource, NSTableViewDelegate { + + func numberOfRows(in tableView: NSTableView) -> Int { + return visibleDomains.count + } + + func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? { + return visibleDomains[row] + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard let cell = tableView.makeView(withIdentifier: Constants.cellIdentifier, owner: nil) as? NSTableCellView else { + + return nil + } + + let domain = visibleDomains[row] + + cell.textField?.stringValue = domain + cell.imageView?.image = faviconManagement.getCachedFavicon(for: domain, sizeCategory: .small)?.image + cell.imageView?.applyFaviconStyle() + + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { + updateRemoveButtonState() + } +} + +extension ExcludedDomainsViewController: NSTextFieldDelegate { + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSSearchField else { return } + + if field.stringValue.isEmpty { + filteredDomains = nil + } else { + filteredDomains = allDomains.filter { $0.contains(field.stringValue) } + } + + reloadData() + } + +} diff --git a/DuckDuckGo/Preferences/Model/VPNPreferencesModel.swift b/DuckDuckGo/Preferences/Model/VPNPreferencesModel.swift index 0e856edf95..bf2e66e664 100644 --- a/DuckDuckGo/Preferences/Model/VPNPreferencesModel.swift +++ b/DuckDuckGo/Preferences/Model/VPNPreferencesModel.swift @@ -20,6 +20,7 @@ import AppKit import Combine import Foundation import NetworkProtection +import NetworkProtectionIPC import NetworkProtectionUI import BrowserServicesKit @@ -38,6 +39,9 @@ final class VPNPreferencesModel: ObservableObject { @Published var excludeLocalNetworks: Bool { didSet { settings.excludeLocalNetworks = excludeLocalNetworks + Task { + try await vpnXPCClient.command(.restartAdapter) + } } } @@ -59,6 +63,13 @@ final class VPNPreferencesModel: ObservableObject { } } + /// Whether the excluded sites section in preferences is shown. + /// + /// Only necessary because this is feature flagged to internal users. + /// + @Published + var showExcludedSites: Bool + @Published var notifyStatusChanges: Bool { didSet { settings.notifyStatusChanges = notifyStatusChanges @@ -74,19 +85,25 @@ final class VPNPreferencesModel: ObservableObject { } @Published public var dnsSettings: NetworkProtectionDNSSettings = .default - @Published public var isCustomDNSSelected = false @Published public var customDNSServers: String? + private let vpnXPCClient: VPNControllerXPCClient private let settings: VPNSettings private let pinningManager: PinningManager + private let internalUserDecider: InternalUserDecider private var cancellables = Set() - init(settings: VPNSettings = .init(defaults: .netP), + init(vpnXPCClient: VPNControllerXPCClient = .shared, + settings: VPNSettings = .init(defaults: .netP), pinningManager: PinningManager = LocalPinningManager.shared, - defaults: UserDefaults = .netP) { + defaults: UserDefaults = .netP, + internalUserDecider: InternalUserDecider = NSApp.delegateTyped.internalUserDecider) { + + self.vpnXPCClient = vpnXPCClient self.settings = settings self.pinningManager = pinningManager + self.internalUserDecider = internalUserDecider connectOnLogin = settings.connectOnLogin excludeLocalNetworks = settings.excludeLocalNetworks @@ -94,6 +111,7 @@ final class VPNPreferencesModel: ObservableObject { showInMenuBar = settings.showInMenuBar showInBrowserToolbar = pinningManager.isPinned(.networkProtection) showUninstallVPN = defaults.networkProtectionOnboardingStatus != .default + showExcludedSites = internalUserDecider.isInternalUser onboardingStatus = defaults.networkProtectionOnboardingStatus locationItem = VPNLocationPreferenceItemModel(selectedLocation: settings.selectedLocation) @@ -102,6 +120,7 @@ final class VPNPreferencesModel: ObservableObject { subscribeToShowInBrowserToolbarSettingsChanges() subscribeToLocationSettingChanges() subscribeToDNSSettingsChanges() + subscribeToInternalUserChanges() } func subscribeToOnboardingStatusChanges(defaults: UserDefaults) { @@ -150,6 +169,12 @@ final class VPNPreferencesModel: ObservableObject { customDNSServers = settings.dnsSettings.dnsServersText } + private func subscribeToInternalUserChanges() { + internalUserDecider.isInternalUserPublisher + .assign(to: \.showExcludedSites, onWeaklyHeld: self) + .store(in: &cancellables) + } + func resetDNSSettings() { settings.dnsSettings = .default } @@ -182,6 +207,22 @@ final class VPNPreferencesModel: ObservableObject { return alert } + + // MARK: - Excluded Sites + + @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) + } } extension NetworkProtectionDNSSettings { diff --git a/DuckDuckGo/Preferences/View/PreferencesVPNView.swift b/DuckDuckGo/Preferences/View/PreferencesVPNView.swift index 10ef5fd090..7d0c793bc2 100644 --- a/DuckDuckGo/Preferences/View/PreferencesVPNView.swift +++ b/DuckDuckGo/Preferences/View/PreferencesVPNView.swift @@ -55,7 +55,24 @@ extension Preferences { } .padding(.bottom, 12) - // SECTION: Manage VPN + // SECTION: Excluded Sites + + if model.showExcludedSites { + PreferencePaneSection(UserText.vpnExcludedSitesTitle, spacing: 4) { + Text(UserText.vpnExcludedDomainsDescription) + .foregroundColor(.secondary) + .padding(.bottom, 18) + + PreferencePaneSubSection { + Button(UserText.vpnExcludedDomainsManageButtonTitle) { + model.manageExcludedSites() + } + } + } + .padding(.bottom, 12) + } + + // SECTION: General PreferencePaneSection(UserText.vpnGeneralTitle) { diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index d9c696c820..779e35dc47 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -1193,7 +1193,7 @@ extension BrowserTabViewController { private func subscribeToTabSelectedInCurrentKeyWindow() { let lastKeyWindowOtherThanOurs = WindowControllersManager.shared.didChangeKeyWindowController - .map { WindowControllersManager.shared.lastKeyMainWindowController } + .map { $0 } .prepend(WindowControllersManager.shared.lastKeyMainWindowController) .compactMap { $0 } .filter { [weak self] in $0.window !== self?.view.window } diff --git a/DuckDuckGo/Windows/View/WindowControllersManager.swift b/DuckDuckGo/Windows/View/WindowControllersManager.swift index f597e7432e..e21c96bc99 100644 --- a/DuckDuckGo/Windows/View/WindowControllersManager.swift +++ b/DuckDuckGo/Windows/View/WindowControllersManager.swift @@ -38,6 +38,10 @@ final class WindowControllersManager: WindowControllersManagerProtocol { static let shared = WindowControllersManager(pinnedTabsManager: Application.appDelegate.pinnedTabsManager) + var activeViewController: MainViewController? { + lastKeyMainWindowController?.mainViewController + } + init(pinnedTabsManager: PinnedTabsManager) { self.pinnedTabsManager = pinnedTabsManager } @@ -52,7 +56,7 @@ final class WindowControllersManager: WindowControllersManagerProtocol { weak var lastKeyMainWindowController: MainWindowController? { didSet { if lastKeyMainWindowController != oldValue { - didChangeKeyWindowController.send(()) + didChangeKeyWindowController.send(lastKeyMainWindowController) } } } @@ -69,7 +73,7 @@ final class WindowControllersManager: WindowControllersManagerProtocol { return mainWindowController?.mainViewController.tabCollectionViewModel.selectedTab } - let didChangeKeyWindowController = PassthroughSubject() + let didChangeKeyWindowController = PassthroughSubject() let didRegisterWindowController = PassthroughSubject<(MainWindowController), Never>() let didUnregisterWindowController = PassthroughSubject<(MainWindowController), Never>() diff --git a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift index 6a3e6c6f7f..3b5d39600d 100644 --- a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift +++ b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift @@ -75,7 +75,7 @@ final class DuckDuckGoVPNApplication: NSApplication { self.delegate = _delegate #if DEBUG - if let token = accountManager.accessToken { + if accountManager.accessToken != nil { os_log(.error, log: .networkProtection, "🟢 VPN Agent found token") } else { os_log(.error, log: .networkProtection, "🔴 VPN Agent found no token") @@ -311,6 +311,9 @@ final class DuckDuckGoVPNAppDelegate: NSObject, NSApplicationDelegate { }.eraseToAnyPublisher() let model = StatusBarMenuModel(vpnSettings: .init(defaults: .netP)) + let uiActionHandler = VPNUIActionHandler( + appLauncher: appLauncher, + proxySettings: proxySettings) return StatusBarMenu( model: model, @@ -318,7 +321,7 @@ final class DuckDuckGoVPNAppDelegate: NSObject, NSApplicationDelegate { statusReporter: statusReporter, controller: tunnelController, iconProvider: iconProvider, - uiActionHandler: appLauncher, + uiActionHandler: uiActionHandler, menuItems: { [ StatusBarMenu.MenuItem(name: UserText.networkProtectionStatusMenuVPNSettings, action: { [weak self] in diff --git a/DuckDuckGoVPN/TunnelControllerIPCService.swift b/DuckDuckGoVPN/TunnelControllerIPCService.swift index 5052f99ca2..c95c4fc0b1 100644 --- a/DuckDuckGoVPN/TunnelControllerIPCService.swift +++ b/DuckDuckGoVPN/TunnelControllerIPCService.swift @@ -243,6 +243,9 @@ extension TunnelControllerIPCService: XPCServerInterface { break case .removeVPNConfiguration: try await uninstall(.configuration) + case .restartAdapter: + // Intentional no-op: handled by the extension + break case .uninstallVPN: try await uninstall(.all) case .disableConnectOnDemandAndShutDown: diff --git a/DuckDuckGoVPN/VPNUIActionHandler.swift b/DuckDuckGoVPN/VPNUIActionHandler.swift new file mode 100644 index 0000000000..dae739499c --- /dev/null +++ b/DuckDuckGoVPN/VPNUIActionHandler.swift @@ -0,0 +1,58 @@ +// +// VPNUIActionHandler.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 AppLauncher +import Foundation +import NetworkProtectionProxy +import NetworkProtectionUI +import VPNAppLauncher + +/// VPN Agent's UI action handler +/// +final class VPNUIActionHandler: VPNUIActionHandling { + + private let appLauncher: AppLauncher + private let proxySettings: TransparentProxySettings + + init(appLauncher: AppLauncher, proxySettings: TransparentProxySettings) { + self.appLauncher = appLauncher + self.proxySettings = proxySettings + } + + public func moveAppToApplications() async { +#if !APPSTORE && !DEBUG + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.moveAppToApplications) +#endif + } + + func setExclusion(_ exclude: Bool, forDomain domain: String) async { + proxySettings.setExclusion(exclude, forDomain: domain) + } + + public func shareFeedback() async { + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.shareFeedback) + } + + public func showVPNLocations() async { + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showVPNLocations) + } + + public func showPrivacyPro() async { + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showPrivacyPro) + } +} diff --git a/LocalPackages/DataBrokerProtection/Package.swift b/LocalPackages/DataBrokerProtection/Package.swift index 0da0593983..abefa9639e 100644 --- a/LocalPackages/DataBrokerProtection/Package.swift +++ b/LocalPackages/DataBrokerProtection/Package.swift @@ -29,7 +29,7 @@ let package = Package( targets: ["DataBrokerProtection"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "180.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "180.0.1"), .package(path: "../SwiftUIExtensions"), .package(path: "../XPCHelper"), ], diff --git a/LocalPackages/NetworkProtectionMac/Package.swift b/LocalPackages/NetworkProtectionMac/Package.swift index 841f8ef5eb..9bea4fce24 100644 --- a/LocalPackages/NetworkProtectionMac/Package.swift +++ b/LocalPackages/NetworkProtectionMac/Package.swift @@ -32,7 +32,7 @@ let package = Package( .library(name: "VPNAppLauncher", targets: ["VPNAppLauncher"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "180.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "180.0.1"), .package(url: "https://github.com/airbnb/lottie-spm", exact: "4.4.3"), .package(path: "../AppLauncher"), .package(path: "../UDSHelper"), diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/FlowManagers/TCPFlowManager.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/FlowManagers/TCPFlowManager.swift index 882eb19734..edf987d201 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/FlowManagers/TCPFlowManager.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/FlowManagers/TCPFlowManager.swift @@ -30,20 +30,35 @@ struct TCPFlowActor { } @TCPFlowActor -enum RemoteConnectionError: Error { +enum RemoteConnectionError: CustomNSError { case complete case cancelled case couldNotEstablishConnection(_ error: Error) case unhandledError(_ error: Error) + + nonisolated + var errorUserInfo: [String: Any] { + switch self { + case .complete, + .cancelled: + return [:] + case .couldNotEstablishConnection(let error), + .unhandledError(let error): + return [NSUnderlyingErrorKey: error as NSError] + + } + } } final class TCPFlowManager { private let flow: NEAppProxyTCPFlow private var connectionTask: Task? private var connection: NWConnection? + private let logger: Logger - init(flow: NEAppProxyTCPFlow) { + init(flow: NEAppProxyTCPFlow, logger: Logger) { self.flow = flow + self.logger = logger } deinit { @@ -67,10 +82,13 @@ final class TCPFlowManager { do { try await startDataCopyLoop(for: remoteConnection) + logger.log("🔴 Stopping proxy connection to \(remoteEndpoint, privacy: .public)") remoteConnection.cancel() flow.closeReadWithError(nil) flow.closeWriteWithError(nil) } catch { + logger.log("🔴 Stopping proxy connection to \(remoteEndpoint, privacy: .public) with error \(String(reflecting: error), privacy: .public)") + remoteConnection.cancel() flow.closeReadWithError(error) flow.closeWriteWithError(error) diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/IPC/TransparentProxyAppMessageHandler.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/IPC/TransparentProxyAppMessageHandler.swift index 12339a673d..988ffd1f77 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/IPC/TransparentProxyAppMessageHandler.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/IPC/TransparentProxyAppMessageHandler.swift @@ -18,14 +18,18 @@ import Foundation import OSLog // swiftlint:disable:this enforce_os_log_wrapper +import NetworkExtension /// Handles app messages /// final class TransparentProxyAppMessageHandler { private let settings: TransparentProxySettings + private let logger: Logger - init(settings: TransparentProxySettings) { + init(settings: TransparentProxySettings, logger: Logger) { + + self.logger = logger self.settings = settings } @@ -48,11 +52,13 @@ final class TransparentProxyAppMessageHandler { await withCheckedContinuation { continuation in var request: TransparentProxyRequest + logger.log("Handling app message: \(String(describing: message), privacy: .public)") + switch message { case .changeSetting(let change): - request = .changeSetting(change, responseHandler: { + request = .changeSetting(change) { continuation.resume(returning: nil) - }) + } } handle(request) diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/Settings/TransparentProxySettings.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/Settings/TransparentProxySettings.swift index db010ec2b5..ba8d98c243 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/Settings/TransparentProxySettings.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/Settings/TransparentProxySettings.swift @@ -79,16 +79,16 @@ public final class TransparentProxySettings { // MARK: - App routing rules logic - public func isBlocking(_ appIdentifier: String) -> Bool { + public func isBlocking(appIdentifier: String) -> Bool { appRoutingRules[appIdentifier] == .block } - public func isExcluding(_ appIdentifier: String) -> Bool { + public func isExcluding(appIdentifier: String) -> Bool { appRoutingRules[appIdentifier] == .exclude } public func toggleBlocking(for appIdentifier: String) { - if isBlocking(appIdentifier) { + if isBlocking(appIdentifier: appIdentifier) { appRoutingRules.removeValue(forKey: appIdentifier) } else { appRoutingRules[appIdentifier] = .block @@ -96,13 +96,43 @@ public final class TransparentProxySettings { } public func toggleExclusion(for appIdentifier: String) { - if isExcluding(appIdentifier) { + if isExcluding(appIdentifier: appIdentifier) { appRoutingRules.removeValue(forKey: appIdentifier) } else { appRoutingRules[appIdentifier] = .exclude } } + // MARK: - Domain Exclusions + + public func isExcluding(domain: String) -> Bool { + excludedDomains.contains(domain) + } + + public func setExclusion(_ exclude: Bool, forDomain domain: String) { + if exclude { + guard !isExcluding(domain: domain) else { + return + } + + excludedDomains.append(domain) + } else { + guard isExcluding(domain: domain) else { + return + } + + excludedDomains.removeAll { $0 == domain } + } + } + + public func toggleExclusion(domain: String) { + if isExcluding(domain: domain) { + excludedDomains.removeAll { $0 == domain } + } else { + excludedDomains.append(domain) + } + } + // MARK: - Snapshot support public func snapshot() -> TransparentProxySettingsSnapshot { diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/TransparentProxyProvider.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/TransparentProxyProvider.swift index 33b75fd73b..c71dbc9ee1 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/TransparentProxyProvider.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionProxy/TransparentProxyProvider.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Combine import Foundation import NetworkExtension import NetworkProtection @@ -35,10 +36,10 @@ open class TransparentProxyProvider: NETransparentProxyProvider { static let dnsPort = 53 @TCPFlowActor - var tcpFlowManagers = Set() + private var tcpFlowManagers = Set() @UDPFlowActor - var udpFlowManagers = Set() + private var udpFlowManagers = Set() private let monitor = nw_path_monitor_create() var directInterface: nw_interface_t? @@ -46,6 +47,8 @@ open class TransparentProxyProvider: NETransparentProxyProvider { private let bMonitor = NWPathMonitor() var interface: NWInterface? + private var cancellables = Set() + public let configuration: Configuration public let settings: TransparentProxySettings @@ -54,8 +57,7 @@ open class TransparentProxyProvider: NETransparentProxyProvider { public var eventHandler: EventCallback? private let logger: Logger - - private lazy var appMessageHandler = TransparentProxyAppMessageHandler(settings: settings) + private let appMessageHandler: TransparentProxyAppMessageHandler // MARK: - Init @@ -63,10 +65,15 @@ open class TransparentProxyProvider: NETransparentProxyProvider { configuration: Configuration, logger: Logger) { + appMessageHandler = TransparentProxyAppMessageHandler(settings: settings, logger: logger) self.configuration = configuration self.logger = logger self.settings = settings + super.init() + + subscribeToSettings() + logger.debug("[+] \(String(describing: Self.self), privacy: .public)") } @@ -74,6 +81,21 @@ open class TransparentProxyProvider: NETransparentProxyProvider { logger.debug("[-] \(String(describing: Self.self), privacy: .public)") } + private func subscribeToSettings() { + settings.changePublisher.sink { change in + switch change { + case .appRoutingRules: + Task { + try await self.updateNetworkSettings() + } + case .excludedDomains: + Task { + try await self.updateNetworkSettings() + } + } + }.store(in: &cancellables) + } + private func loadProviderConfiguration() throws { guard configuration.loadSettingsFromProviderConfiguration else { return @@ -105,7 +127,7 @@ open class TransparentProxyProvider: NETransparentProxyProvider { return } - logger.log("Successfully Updated network settings: \(String(describing: error), privacy: .public))") + logger.log("Successfully Updated network settings: \(networkSettings.description, privacy: .public)") continuation.resume() } } @@ -116,35 +138,39 @@ open class TransparentProxyProvider: NETransparentProxyProvider { let networkSettings = NETransparentProxyNetworkSettings(tunnelRemoteAddress: "127.0.0.1") networkSettings.includedNetworkRules = [ - NENetworkRule(remoteNetwork: NWHostEndpoint(hostname: "127.0.0.1", port: ""), remotePrefix: 0, localNetwork: nil, localPrefix: 0, protocol: .any, direction: .outbound) + NENetworkRule(remoteNetwork: nil, remotePrefix: 0, localNetwork: nil, localPrefix: 0, protocol: .TCP, direction: .outbound), + NENetworkRule(remoteNetwork: nil, remotePrefix: 0, localNetwork: nil, localPrefix: 0, protocol: .UDP, direction: .outbound) ] + if isExcludedDomain("duckduckgo.com") { + networkSettings.includedNetworkRules?.append( + NENetworkRule(destinationHost: NWHostEndpoint(hostname: "duckduckgo.com", port: "443"), protocol: .any)) + } + return networkSettings } - override public func startProxy(options: [String: Any]?, - completionHandler: @escaping (Error?) -> Void) { + @MainActor + override open func startProxy(options: [String: Any]? = nil) async throws { eventHandler?(.startInitiated) - logger.log( - """ - Starting proxy\n - > configuration: \(String(describing: self.configuration), privacy: .public)\n - > settings: \(String(describing: self.settings), privacy: .public)\n - > options: \(String(describing: options), privacy: .public) - """) - do { - try loadProviderConfiguration() - } catch { - logger.error("Failed to load provider configuration, bailing out") - eventHandler?(.startFailure(error)) - completionHandler(error) - return - } + logger.log( + """ + Starting proxy\n + > configuration: \(String(describing: self.configuration), privacy: .public)\n + > settings: \(String(describing: self.settings), privacy: .public)\n + > options: \(String(describing: options), privacy: .public) + """) + + do { + try loadProviderConfiguration() + } catch { + logger.error("Failed to load provider configuration, bailing out") + throw error + } - Task { @MainActor in do { startMonitoringNetworkInterfaces() @@ -152,61 +178,82 @@ open class TransparentProxyProvider: NETransparentProxyProvider { logger.log("Proxy started successfully") isRunning = true eventHandler?(.startSuccess) - completionHandler(nil) } catch { let error = StartError.failedToUpdateNetworkSettings(underlyingError: error) logger.error("Proxy failed to start \(String(reflecting: error), privacy: .public)") - eventHandler?(.startFailure(error)) - completionHandler(error) + throw error } + } catch { + eventHandler?(.startFailure(error)) + throw error } } - override public func stopProxy(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + @MainActor + open override func stopProxy(with reason: NEProviderStopReason) async { + logger.log("Stopping proxy with reason: \(String(reflecting: reason), privacy: .public)") - Task { @MainActor in - stopMonitoringNetworkInterfaces() - isRunning = false - completionHandler() - } + stopMonitoringNetworkInterfaces() + isRunning = false } + @MainActor override public func sleep(completionHandler: @escaping () -> Void) { - Task { @MainActor in - stopMonitoringNetworkInterfaces() - logger.log("The proxy is now sleeping") - completionHandler() - } + stopMonitoringNetworkInterfaces() + logger.log("The proxy is now sleeping") + completionHandler() } + @MainActor override public func wake() { - Task { @MainActor in - logger.log("The proxy is now awake") - startMonitoringNetworkInterfaces() - } + logger.log("The proxy is now awake") + startMonitoringNetworkInterfaces() + } + + private func logFlowMessage(_ flow: NEAppProxyFlow, level: OSLogType, message: String) { + logger.log( + level: level, + """ + \(message, privacy: .public) + - remote: \(String(reflecting: flow.remoteHostname), privacy: .public) + - flowID: \(String(reflecting: flow.metaData.filterFlowIdentifier?.uuidString), privacy: .public) + - appID: \(String(reflecting: flow.metaData.sourceAppSigningIdentifier), privacy: .public) + """ + ) + } + + private func logNewTCPFlow(_ flow: NEAppProxyFlow) { + logFlowMessage( + flow, + level: .default, + message: "[TCP] New flow: \(String(reflecting: flow))") + } + + private func logFlowHandlingFailure(_ flow: NEAppProxyFlow, message: String) { + logFlowMessage( + flow, + level: .error, + message: "[TCP] Failure handling flow: \(message)") } override public func handleNewFlow(_ flow: NEAppProxyFlow) -> Bool { + logNewTCPFlow(flow) + guard let flow = flow as? NEAppProxyTCPFlow else { - logger.info("Expected a TCP flow, but got something else. We're ignoring it.") + logFlowHandlingFailure(flow, message: "Expected a TCP flow, but got something else. We're ignoring the flow.") return false } - guard let remoteEndpoint = flow.remoteEndpoint as? NWHostEndpoint, - !isDnsServer(remoteEndpoint) else { + guard let remoteEndpoint = flow.remoteEndpoint as? NWHostEndpoint else { + logFlowHandlingFailure(flow, message: "No remote endpoint. We're ignoring the flow.") return false } - let printableRemote = flow.remoteHostname ?? (flow.remoteEndpoint as? NWHostEndpoint)?.hostname ?? "unknown" - - logger.debug( - """ - [TCP] New flow: \(String(describing: flow), privacy: .public) - - remote: \(printableRemote, privacy: .public) - - flowID: \(String(describing: flow.metaData.filterFlowIdentifier?.uuidString), privacy: .public) - - appID: \(String(describing: flow.metaData.sourceAppSigningIdentifier), privacy: .public) - """) + guard !isDnsServer(remoteEndpoint) else { + logFlowHandlingFailure(flow, message: "DNS resolver endpoint. We're ignoring the flow.") + return false + } guard let interface else { logger.error("[TCP: \(String(describing: flow), privacy: .public)] Expected an interface to exclude traffic through") @@ -235,11 +282,13 @@ open class TransparentProxyProvider: NETransparentProxyProvider { flow.networkInterface = directInterface Task { @TCPFlowActor in - let flowManager = TCPFlowManager(flow: flow) + let flowManager = TCPFlowManager(flow: flow, logger: logger) tcpFlowManagers.insert(flowManager) try? await flowManager.start(interface: interface) tcpFlowManagers.remove(flowManager) + + logFlowMessage(flow, level: .default, message: "[TCP] Flow completed") } return true diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Animation/AnimationConstants.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Animation/AnimationConstants.swift new file mode 100644 index 0000000000..552e5dc433 --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Animation/AnimationConstants.swift @@ -0,0 +1,23 @@ +// +// AnimationConstants.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 + +struct AnimationConstants { + static let highlightAnimationStepSpeed = 0.05 +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Menu/StatusBarMenu.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Menu/StatusBarMenu.swift index 9d541e976c..53e2a24599 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Menu/StatusBarMenu.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Menu/StatusBarMenu.swift @@ -37,7 +37,7 @@ public final class StatusBarMenu: NSObject { private let controller: TunnelController private let statusReporter: NetworkProtectionStatusReporter private let onboardingStatusPublisher: OnboardingStatusPublisher - private let uiActionHandler: VPNUIActionHandler + private let uiActionHandler: VPNUIActionHandling private let menuItems: () -> [MenuItem] private let agentLoginItem: LoginItem? private let isMenuBarStatusView: Bool @@ -64,7 +64,7 @@ public final class StatusBarMenu: NSObject { statusReporter: NetworkProtectionStatusReporter, controller: TunnelController, iconProvider: IconProvider, - uiActionHandler: VPNUIActionHandler, + uiActionHandler: VPNUIActionHandling, menuItems: @escaping () -> [MenuItem], agentLoginItem: LoginItem?, isMenuBarStatusView: Bool, @@ -108,7 +108,9 @@ public final class StatusBarMenu: NSObject { return } - togglePopover(isOptionKeyPressed: isOptionKeyPressed) + Task { @MainActor in + togglePopover(isOptionKeyPressed: isOptionKeyPressed) + } } private func subscribeToIconUpdates() { @@ -122,6 +124,7 @@ public final class StatusBarMenu: NSObject { // MARK: - Popover + @MainActor private func togglePopover(isOptionKeyPressed: Bool) { if let popover, popover.isShown { popover.close() @@ -131,19 +134,33 @@ public final class StatusBarMenu: NSObject { return } - popover = NetworkProtectionPopover(controller: controller, - onboardingStatusPublisher: onboardingStatusPublisher, - statusReporter: statusReporter, - uiActionHandler: uiActionHandler, - menuItems: menuItems, - agentLoginItem: agentLoginItem, - isMenuBarStatusView: isMenuBarStatusView, - userDefaults: userDefaults, - locationFormatter: locationFormatter, - uninstallHandler: uninstallHandler) + let siteTroubleshootingViewModel = SiteTroubleshootingView.Model( + featureFlagPublisher: Just(false).eraseToAnyPublisher(), + connectionStatusPublisher: Just(NetworkProtection.ConnectionStatus.disconnected).eraseToAnyPublisher(), + siteTroubleshootingInfoPublisher: Just(SiteTroubleshootingInfo?(nil)).eraseToAnyPublisher(), + uiActionHandler: uiActionHandler) + + let debugInformationViewModel = DebugInformationViewModel(showDebugInformation: isOptionKeyPressed) + + let statusViewModel = NetworkProtectionStatusView.Model( + controller: controller, + onboardingStatusPublisher: onboardingStatusPublisher, + statusReporter: statusReporter, + uiActionHandler: uiActionHandler, + menuItems: menuItems, + agentLoginItem: agentLoginItem, + isMenuBarStatusView: isMenuBarStatusView, + userDefaults: userDefaults, + locationFormatter: locationFormatter, + uninstallHandler: uninstallHandler) + + popover = NetworkProtectionPopover( + statusViewModel: statusViewModel, + statusReporter: statusReporter, + siteTroubleshootingViewModel: siteTroubleshootingViewModel, + debugInformationViewModel: debugInformationViewModel) popover?.behavior = .transient - popover?.setShowsDebugInformation(isOptionKeyPressed) popover?.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) } } diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/NetworkProtectionAsset.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/NetworkProtectionAsset.swift index a8288b7dbd..e09c9c93a6 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/NetworkProtectionAsset.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/NetworkProtectionAsset.swift @@ -51,4 +51,7 @@ public enum NetworkProtectionAsset: String, CaseIterable { // Images: case allowSysexScreenshot = "allow-sysex-screenshot" case allowSysexScreenshotBigSur = "allow-sysex-screenshot-bigsur" + + // Accordion View + case accordionViewCheckmark = "Check-16D" } diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/NetworkProtectionPopover.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/NetworkProtectionPopover.swift index 0d1837d6e8..a6ba7befa5 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/NetworkProtectionPopover.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/NetworkProtectionPopover.swift @@ -45,34 +45,21 @@ public final class NetworkProtectionPopover: NSPopover { public typealias MenuItem = NetworkProtectionStatusView.Model.MenuItem - private let debugInformationPublisher = CurrentValueSubject(false) private let statusReporter: NetworkProtectionStatusReporter - private let model: NetworkProtectionStatusView.Model + private let debugInformationViewModel: DebugInformationViewModel + private let siteTroubleshootingViewModel: SiteTroubleshootingView.Model + private let statusViewModel: NetworkProtectionStatusView.Model private var appLifecycleCancellables = Set() - public required init(controller: TunnelController, - onboardingStatusPublisher: OnboardingStatusPublisher, + public required init(statusViewModel: NetworkProtectionStatusView.Model, statusReporter: NetworkProtectionStatusReporter, - uiActionHandler: VPNUIActionHandler, - menuItems: @escaping () -> [MenuItem], - agentLoginItem: LoginItem?, - isMenuBarStatusView: Bool, - userDefaults: UserDefaults, - locationFormatter: VPNLocationFormatting, - uninstallHandler: @escaping () async -> Void) { + siteTroubleshootingViewModel: SiteTroubleshootingView.Model, + debugInformationViewModel: DebugInformationViewModel) { self.statusReporter = statusReporter - self.model = NetworkProtectionStatusView.Model(controller: controller, - onboardingStatusPublisher: onboardingStatusPublisher, - statusReporter: statusReporter, - debugInformationPublisher: debugInformationPublisher.eraseToAnyPublisher(), - uiActionHandler: uiActionHandler, - menuItems: menuItems, - agentLoginItem: agentLoginItem, - isMenuBarStatusView: isMenuBarStatusView, - userDefaults: userDefaults, - locationFormatter: locationFormatter, - uninstallHandler: uninstallHandler) + self.debugInformationViewModel = debugInformationViewModel + self.siteTroubleshootingViewModel = siteTroubleshootingViewModel + self.statusViewModel = statusViewModel super.init() @@ -88,7 +75,11 @@ public final class NetworkProtectionPopover: NSPopover { } private func setupContentController() { - let view = NetworkProtectionStatusView(model: self.model).environment(\.dismiss, { [weak self] in + let view = NetworkProtectionStatusView() + .environmentObject(debugInformationViewModel) + .environmentObject(siteTroubleshootingViewModel) + .environmentObject(statusViewModel) + .environment(\.dismiss, { [weak self] in self?.close() }).fixedSize() @@ -106,7 +97,7 @@ public final class NetworkProtectionPopover: NSPopover { NotificationCenter .default .publisher(for: NSApplication.didBecomeActiveNotification) - .sink { [weak self] _ in self?.model.refreshLoginItemStatus() } + .sink { [weak self] _ in self?.statusViewModel.refreshLoginItemStatus() } .store(in: &appLifecycleCancellables) NotificationCenter @@ -117,20 +108,14 @@ public final class NetworkProtectionPopover: NSPopover { } private func closePopoverIfOnboarded() { - if self.model.onboardingStatus == .completed { + if self.statusViewModel.onboardingStatus == .completed { self.close() } } override public func show(relativeTo positioningRect: NSRect, of positioningView: NSView, preferredEdge: NSRectEdge) { statusReporter.forceRefresh() - model.refreshLoginItemStatus() + statusViewModel.refreshLoginItemStatus() super.show(relativeTo: positioningRect, of: positioningView, preferredEdge: preferredEdge) } - - // MARK: - Debug Information - - func setShowsDebugInformation(_ showsDebugInformation: Bool) { - debugInformationPublisher.send(showsDebugInformation) - } } diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/Check-16D.imageset/Check-16D.pdf b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/Check-16D.imageset/Check-16D.pdf new file mode 100644 index 0000000000..9a1b194b77 Binary files /dev/null and b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/Check-16D.imageset/Check-16D.pdf differ diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/Check-16D.imageset/Contents.json b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/Check-16D.imageset/Contents.json new file mode 100644 index 0000000000..20caa8ff8a --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/Check-16D.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Check-16D.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/AccordionView.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/AccordionView.swift new file mode 100644 index 0000000000..eb1cd4ce8e --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/AccordionView.swift @@ -0,0 +1,119 @@ +// +// AccordionView.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 SwiftUI + +struct SideMenuStyle: MenuStyle { + func makeBody(configuration: Configuration) -> some View { + Menu(configuration) + .menuStyle(DefaultMenuStyle()) + .frame(maxWidth: .infinity, alignment: .leading) // Align the menu to the leading edge + } +} + +struct AccordionView: View { + @Environment(\.colorScheme) private var colorScheme + private var label: (Bool) -> Label + private let submenu: () -> Submenu + + private var highlightAnimationStepSpeed = AnimationConstants.highlightAnimationStepSpeed + + @State private var isHovered = false + @State private var highlightOverride: Bool? + @State private var showSubmenu = false + + private var isHighlighted: Bool { + highlightOverride ?? isHovered + } + + init(@ViewBuilder label: @escaping (Bool) -> Label, + @ViewBuilder submenu: @escaping () -> Submenu) { + + self.label = label + self.submenu = submenu + } + + var body: some View { + content + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .cornerRadius(4) + } + + private func buttonBackground(highlighted: Bool) -> some View { + if highlighted { + return AnyView( + VisualEffectView(material: .selection, blendingMode: .withinWindow, state: .active, isEmphasized: true)) + } else { + return AnyView(Color.clear) + } + } + + private var content: some View { + VStack { + Button(action: { + buttonTapped() + }) { + HStack { + label(isHovered) + Spacer() + + if showSubmenu { + Image(systemName: "chevron.down") // Chevron pointing right + .foregroundColor(.gray) + } else { + Image(systemName: "chevron.right") // Chevron pointing right + .foregroundColor(.gray) + } + }.padding([.top, .bottom], 3) + .padding([.leading, .trailing], 9) + }.buttonStyle(PlainButtonStyle()) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + buttonBackground(highlighted: isHighlighted) + ) + .onTapGesture { + buttonTapped() + } + .onHover { hovering in + isHovered = hovering + } + + if showSubmenu { + VStack { + submenu() + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + private func buttonTapped() { + highlightOverride = false + + DispatchQueue.main.asyncAfter(deadline: .now() + highlightAnimationStepSpeed) { + highlightOverride = true + + DispatchQueue.main.asyncAfter(deadline: .now() + highlightAnimationStepSpeed) { + highlightOverride = nil + showSubmenu.toggle() + } + } + } +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MenuItemButton.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MenuItemButton.swift index 59e814b53c..e8270c4a9f 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MenuItemButton.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MenuItemButton.swift @@ -27,7 +27,7 @@ struct MenuItemButton: View { private let textColor: Color private let action: () async -> Void - private let highlightAnimationStepSpeed = 0.05 + private let highlightAnimationStepSpeed = AnimationConstants.highlightAnimationStepSpeed @State private var isHovered = false @State private var animatingTap = false diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MenuItemCustomButton.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MenuItemCustomButton.swift index cdd8bd79c8..dbef97ab23 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MenuItemCustomButton.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/SwiftUI/MenuItemCustomButton.swift @@ -24,7 +24,7 @@ struct MenuItemCustomButton: View { private var label: (Bool) -> Label private let action: () async -> Void - private let highlightAnimationStepSpeed = 0.05 + private let highlightAnimationStepSpeed = AnimationConstants.highlightAnimationStepSpeed @State private var isHovered = false @State private var animatingTap = false diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/VPNUIActionHandler/VPNUIActionHandler.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/VPNUIActionHandling/VPNUIActionHandling.swift similarity index 84% rename from LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/VPNUIActionHandler/VPNUIActionHandler.swift rename to LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/VPNUIActionHandling/VPNUIActionHandling.swift index 76ff99d563..b57f8bcc55 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/VPNUIActionHandler/VPNUIActionHandler.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/VPNUIActionHandling/VPNUIActionHandling.swift @@ -1,5 +1,5 @@ // -// VPNUIActionHandler.swift +// VPNUIActionHandling.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -18,8 +18,9 @@ import Foundation -public protocol VPNUIActionHandler { +public protocol VPNUIActionHandling { func moveAppToApplications() async + func setExclusion(_ exclude: Bool, forDomain domain: String) async func shareFeedback() async func showPrivacyPro() async func showVPNLocations() async diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationView.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationView.swift index 34ea0bb2b1..82c30728d4 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationView.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationView.swift @@ -31,25 +31,21 @@ public struct DebugInformationView: View { /// The view model that this instance will use. /// - @ObservedObject var model: DebugInformationViewModel - - // MARK: - Initializers - - public init(model: DebugInformationViewModel) { - self.model = model - } + @EnvironmentObject var model: DebugInformationViewModel // MARK: - View Contents public var body: some View { - Group { - VStack(alignment: .leading, spacing: 0) { - informationRow(title: "Bundle Path", details: model.bundlePath) - informationRow(title: "Version", details: model.version) + if model.showDebugInformation { + Group { + VStack(alignment: .leading, spacing: 0) { + informationRow(title: "Bundle Path", details: model.bundlePath) + informationRow(title: "Version", details: model.version) + } + + Divider() + .padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9)) } - - Divider() - .padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9)) } } diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationViewModel.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationViewModel.swift index f8433a1c28..d8186a65c7 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationViewModel.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/DebugInformationView/DebugInformationViewModel.swift @@ -21,15 +21,19 @@ import Foundation import NetworkProtection import SwiftUI -@MainActor public final class DebugInformationViewModel: ObservableObject { - var bundlePath: String - var version: String + let showDebugInformation: Bool + let bundlePath: String + let version: String + private var cancellables = Set() // MARK: - Initialization & Deinitialization - public init(bundle: Bundle = .main) { + public init(showDebugInformation: Bool, + bundle: Bundle = .main) { + + self.showDebugInformation = showDebugInformation bundlePath = bundle.bundlePath // swiftlint:disable:next force_cast diff --git a/LocalPackages/NetworkProtectionMac/Sources/VPNAppLauncher/AppLauncher+VPNUIActionHandler.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/SiteTroubleshootingView/SiteTroubleshootingInfo.swift similarity index 50% rename from LocalPackages/NetworkProtectionMac/Sources/VPNAppLauncher/AppLauncher+VPNUIActionHandler.swift rename to LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/SiteTroubleshootingView/SiteTroubleshootingInfo.swift index ed491f743b..704cb2731e 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/VPNAppLauncher/AppLauncher+VPNUIActionHandler.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/SiteTroubleshootingView/SiteTroubleshootingInfo.swift @@ -1,5 +1,5 @@ // -// AppLauncher+VPNUIActionHandler.swift +// SiteTroubleshootingInfo.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -16,25 +16,21 @@ // limitations under the License. // -import AppLauncher +import AppKit import Foundation -import NetworkProtectionUI -extension AppLauncher: VPNUIActionHandler { +public struct SiteTroubleshootingInfo { + public let icon: NSImage + public let domain: String + public let excluded: Bool - public func moveAppToApplications() async { - try? await launchApp(withCommand: VPNAppLaunchCommand.moveAppToApplications) + public init(icon: NSImage?, domain: String, excluded: Bool) { + self.icon = icon ?? NSImage(systemSymbolName: "globe", accessibilityDescription: nil)! + self.domain = domain + self.excluded = excluded } +} - public func shareFeedback() async { - try? await launchApp(withCommand: VPNAppLaunchCommand.shareFeedback) - } +extension SiteTroubleshootingInfo: Equatable { - public func showVPNLocations() async { - try? await launchApp(withCommand: VPNAppLaunchCommand.showVPNLocations) - } - - public func showPrivacyPro() async { - try? await launchApp(withCommand: VPNAppLaunchCommand.showPrivacyPro) - } } diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/SiteTroubleshootingView/SiteTroubleshootingView.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/SiteTroubleshootingView/SiteTroubleshootingView.swift new file mode 100644 index 0000000000..ed7580369b --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/SiteTroubleshootingView/SiteTroubleshootingView.swift @@ -0,0 +1,97 @@ +// +// SiteTroubleshootingView.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 Combine +import Foundation +import SwiftUI + +fileprivate extension View { + func applyCurrentSiteAttributes() -> some View { + font(.system(size: 13, weight: .regular, design: .default)) + } +} + +public struct SiteTroubleshootingView: View { + + @EnvironmentObject var model: Model + + // MARK: - View Contents + + public var body: some View { + if model.isFeatureEnabled, + let siteInfo = model.siteInfo { + siteTroubleshootingView(siteInfo) + } else { + EmptyView() + } + } + + private func siteTroubleshootingView(_ siteInfo: SiteTroubleshootingInfo) -> some View { + Group { + AccordionView { _ in + Image(nsImage: siteInfo.icon) + .resizable() + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3.0)) + Text("\(siteInfo.domain) issues?") + .applyCurrentSiteAttributes() + } submenu: { + VStack { + MenuItemCustomButton { + model.setExclusion(true, forDomain: siteInfo.domain) + } label: { _ in + HStack { + if siteInfo.excluded { + Image(.accordionViewCheckmark) + .resizable() + .font(.system(size: 13)) + .frame(width: 16, height: 16) + .applyCurrentSiteAttributes() + } else { + Rectangle() + .fill(Color.clear) + .frame(width: 16, height: 16) + } + Text("Exclude from VPN") + } + } + + MenuItemCustomButton { + model.setExclusion(false, forDomain: siteInfo.domain) + } label: { _ in + if !siteInfo.excluded { + Image(.accordionViewCheckmark) + .resizable() + .font(.system(size: 13)) + .frame(width: 16, height: 16) + } else { + Rectangle() + .fill(Color.clear) + .frame(width: 16, height: 16) + } + + Text("Route through VPN") + } + } + } + + Divider() + .padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9)) + } + } +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/SiteTroubleshootingView/SiteTroubleshootingViewModel.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/SiteTroubleshootingView/SiteTroubleshootingViewModel.swift new file mode 100644 index 0000000000..b026feed85 --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/SiteTroubleshootingView/SiteTroubleshootingViewModel.swift @@ -0,0 +1,88 @@ +// +// SiteTroubleshootingViewModel.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 Combine +import Foundation +import NetworkProtection + +extension SiteTroubleshootingView { + + public final class Model: ObservableObject { + + @Published + private(set) var isFeatureEnabled = false + + @Published + private(set) var connectionStatus: ConnectionStatus = .disconnected + + @Published + private var internalSiteInfo: SiteTroubleshootingInfo? + + var siteInfo: SiteTroubleshootingInfo? { + guard case .connected = connectionStatus else { + return nil + } + + return internalSiteInfo + } + + private let uiActionHandler: VPNUIActionHandling + private var cancellables = Set() + + public init(featureFlagPublisher: AnyPublisher, + connectionStatusPublisher: AnyPublisher, + siteTroubleshootingInfoPublisher: AnyPublisher, + uiActionHandler: VPNUIActionHandling) { + + self.uiActionHandler = uiActionHandler + + subscribeToConnectionStatusChanges(connectionStatusPublisher) + subscribeToSiteTroubleshootingInfoChanges(siteTroubleshootingInfoPublisher) + subscribeToFeatureFlagChanges(featureFlagPublisher) + } + + private func subscribeToConnectionStatusChanges(_ publisher: AnyPublisher) { + + publisher + .receive(on: DispatchQueue.main) + .assign(to: \.connectionStatus, onWeaklyHeld: self) + .store(in: &cancellables) + } + + private func subscribeToSiteTroubleshootingInfoChanges(_ publisher: AnyPublisher) { + + publisher + .receive(on: DispatchQueue.main) + .assign(to: \.internalSiteInfo, onWeaklyHeld: self) + .store(in: &cancellables) + } + + private func subscribeToFeatureFlagChanges(_ publisher: AnyPublisher) { + publisher + .receive(on: DispatchQueue.main) + .assign(to: \.isFeatureEnabled, onWeaklyHeld: self) + .store(in: &cancellables) + } + + func setExclusion(_ exclude: Bool, forDomain domain: String) { + Task { @MainActor in + await uiActionHandler.setExclusion(exclude, forDomain: domain) + } + } + } +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift index 39a63eec04..22562a36dd 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift @@ -38,13 +38,7 @@ public struct NetworkProtectionStatusView: View { /// The view model that this instance will use. /// - @ObservedObject var model: Model - - // MARK: - Initializers - - public init(model: Model) { - self.model = model - } + @EnvironmentObject var model: Model // MARK: - View Contents @@ -74,10 +68,8 @@ public struct NetworkProtectionStatusView: View { TunnelControllerView(model: model.tunnelControllerViewModel) .disabled(model.tunnelControllerViewDisabled) - if model.showDebugInformation { - DebugInformationView(model: DebugInformationViewModel()) - .transition(.slide) - } + DebugInformationView() + .transition(.slide) bottomMenuView() } diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift index 8c126a8142..835078e15b 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift @@ -73,15 +73,6 @@ extension NetworkProtectionStatusView { /// private let statusReporter: NetworkProtectionStatusReporter - /// The debug information publisher - /// - private let debugInformationPublisher: AnyPublisher - - /// Whether we're showing debug information - /// - @Published - var showDebugInformation: Bool - public let agentLoginItem: LoginItem? private let isMenuBarStatusView: Bool @@ -95,7 +86,7 @@ extension NetworkProtectionStatusView { /// private let runLoopMode: RunLoop.Mode? - private let uiActionHandler: VPNUIActionHandler + private let uiActionHandler: VPNUIActionHandling private let uninstallHandler: () async -> Void @@ -114,8 +105,7 @@ extension NetworkProtectionStatusView { public init(controller: TunnelController, onboardingStatusPublisher: OnboardingStatusPublisher, statusReporter: NetworkProtectionStatusReporter, - debugInformationPublisher: AnyPublisher, - uiActionHandler: VPNUIActionHandler, + uiActionHandler: VPNUIActionHandling, menuItems: @escaping () -> [MenuItem], agentLoginItem: LoginItem?, isMenuBarStatusView: Bool, @@ -127,7 +117,6 @@ extension NetworkProtectionStatusView { self.tunnelController = controller self.onboardingStatusPublisher = onboardingStatusPublisher self.statusReporter = statusReporter - self.debugInformationPublisher = debugInformationPublisher self.menuItems = menuItems self.agentLoginItem = agentLoginItem self.isMenuBarStatusView = isMenuBarStatusView @@ -147,7 +136,6 @@ extension NetworkProtectionStatusView { lastTunnelErrorMessage = statusReporter.connectionErrorObserver.recentValue lastControllerErrorMessage = statusReporter.controllerErrorMessageObserver.recentValue knownFailure = statusReporter.knownFailureObserver.recentValue - showDebugInformation = false // Particularly useful when unit testing with an initial status of our choosing. subscribeToStatusChanges() @@ -155,7 +143,6 @@ extension NetworkProtectionStatusView { subscribeToTunnelErrorMessages() subscribeToControllerErrorMessages() subscribeToKnownFailures() - subscribeToDebugInformationChanges() refreshLoginItemStatus() onboardingStatusPublisher @@ -255,14 +242,6 @@ extension NetworkProtectionStatusView { .store(in: &cancellables) } - private func subscribeToDebugInformationChanges() { - debugInformationPublisher - .removeDuplicates() - .receive(on: DispatchQueue.main) - .assign(to: \.showDebugInformation, onWeaklyHeld: self) - .store(in: &cancellables) - } - // MARK: - Connection Status: Errors @Published diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerView.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerView.swift index 3ef0d01d82..96b5cab49e 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerView.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerView.swift @@ -32,6 +32,10 @@ fileprivate extension Font { .system(size: 13, weight: .regular, design: .default) } + static var currentSite: Font { + .system(size: 13, weight: .regular, design: .default) + } + static var location: Font { .system(size: 13, weight: .regular, design: .default) } @@ -102,6 +106,10 @@ fileprivate extension View { .foregroundColor(Color(.defaultText)) } + func applyCurrentSiteAttributes() -> some View { + font(.NetworkProtection.currentSite) + } + func applyLocationAttributes() -> some View { font(.NetworkProtection.location) } @@ -172,6 +180,8 @@ public struct TunnelControllerView: View { Divider() .padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9)) + SiteTroubleshootingView() + locationView() if model.showServerDetails { diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerViewModel.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerViewModel.swift index 08cc7614e7..19d4dd5ee7 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerViewModel.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerViewModel.swift @@ -57,7 +57,6 @@ public final class TunnelControllerViewModel: ObservableObject { private let statusReporter: NetworkProtectionStatusReporter private let vpnSettings: VPNSettings - private let locationFormatter: VPNLocationFormatting private static let byteCountFormatter: ByteCountFormatter = { @@ -67,7 +66,12 @@ public final class TunnelControllerViewModel: ObservableObject { return formatter }() - private let uiActionHandler: VPNUIActionHandler + private let uiActionHandler: VPNUIActionHandling + + // MARK: - Environment + + @EnvironmentObject + private var siteTroubleshootingViewModel: SiteTroubleshootingView.Model // MARK: - Misc @@ -91,7 +95,7 @@ public final class TunnelControllerViewModel: ObservableObject { runLoopMode: RunLoop.Mode? = nil, vpnSettings: VPNSettings, locationFormatter: VPNLocationFormatting, - uiActionHandler: VPNUIActionHandler) { + uiActionHandler: VPNUIActionHandling) { self.tunnelController = controller self.onboardingStatusPublisher = onboardingStatusPublisher @@ -531,11 +535,13 @@ public final class TunnelControllerViewModel: ObservableObject { } } +#if !APPSTORE && !DEBUG func moveToApplications() { Task { @MainActor in await uiActionHandler.moveAppToApplications() } } +#endif } extension DataVolume { diff --git a/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/MockVPNUIActionHandler/MockVPNUIActionHandler.swift b/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/MockVPNUIActionHandler/MockVPNUIActionHandler.swift index 5e0240ca3d..c8d60cf9ed 100644 --- a/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/MockVPNUIActionHandler/MockVPNUIActionHandler.swift +++ b/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/MockVPNUIActionHandler/MockVPNUIActionHandler.swift @@ -19,7 +19,10 @@ import Foundation import NetworkProtectionUI -public final class MockVPNUIActionHandler: VPNUIActionHandler { +public final class MockVPNUIActionHandler: VPNUIActionHandling { + public func setExclusion(_ exclude: Bool, forDomain domain: String) async { + // placeholder + } public func moveAppToApplications() async { // placeholder diff --git a/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/NetworkProtectionAssetTests.swift b/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/NetworkProtectionAssetTests.swift index b49d24d5b4..345050aba4 100644 --- a/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/NetworkProtectionAssetTests.swift +++ b/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/NetworkProtectionAssetTests.swift @@ -49,7 +49,8 @@ final class NetworkProtectionAssetTests: XCTestCase { .statusbarBrandedVPNOffIcon: "statusbar-branded-vpn-off", .statusbarBrandedVPNIssueIcon: "statusbar-branded-vpn-issue", .allowSysexScreenshot: "allow-sysex-screenshot", - .allowSysexScreenshotBigSur: "allow-sysex-screenshot-bigsur" + .allowSysexScreenshotBigSur: "allow-sysex-screenshot-bigsur", + .accordionViewCheckmark: "Check-16D" ] XCTAssertEqual(assetsAndExpectedRawValues.count, NetworkProtectionAsset.allCases.count) diff --git a/LocalPackages/SubscriptionUI/Package.swift b/LocalPackages/SubscriptionUI/Package.swift index 6e9ed1cd74..b75fff5061 100644 --- a/LocalPackages/SubscriptionUI/Package.swift +++ b/LocalPackages/SubscriptionUI/Package.swift @@ -12,7 +12,7 @@ let package = Package( targets: ["SubscriptionUI"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "180.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "180.0.1"), .package(path: "../SwiftUIExtensions") ], targets: [ diff --git a/UnitTests/NavigationBar/View/NavigationBarPopoversTests.swift b/UnitTests/NavigationBar/View/NavigationBarPopoversTests.swift index d2982ee38c..b1b050b5e8 100644 --- a/UnitTests/NavigationBar/View/NavigationBarPopoversTests.swift +++ b/UnitTests/NavigationBar/View/NavigationBarPopoversTests.swift @@ -27,6 +27,7 @@ final class NavigationBarPopoversTests: XCTestCase { private var sut: NavigationBarPopovers! private var autofillPopoverPresenter: MockAutofillPopoverPresenter! + @MainActor override func setUpWithError() throws { autofillPopoverPresenter = MockAutofillPopoverPresenter() sut = NavigationBarPopovers(networkProtectionPopoverManager: NetPPopoverManagerMock(), autofillPopoverPresenter: autofillPopoverPresenter)