From 39d9c419b14484374a1c8b5007441a07e32c8d37 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Wed, 12 Jun 2024 16:44:47 +0200 Subject: [PATCH] Implement VPN control through UDS (#2767) Task/Issue URL: https://app.asana.com/0/1203108348835387/1207203883170230/f ## Description Implement VPN control through UDS --- Configuration/AppStore.xcconfig | 18 ++ Configuration/DeveloperID.xcconfig | 18 ++ DuckDuckGo.xcodeproj/project.pbxproj | 37 +++ .../DuckDuckGo Privacy Browser.xcscheme | 24 ++ DuckDuckGo/Application/AppDelegate.swift | 14 +- .../Common/Extensions/BundleExtension.swift | 17 ++ DuckDuckGo/DuckDuckGo.entitlements | 1 + DuckDuckGo/DuckDuckGoAppStore.entitlements | 1 + DuckDuckGo/DuckDuckGoAppStoreCI.entitlements | 1 + DuckDuckGo/DuckDuckGoDebug.entitlements | 1 + DuckDuckGo/Info.plist | 2 + DuckDuckGo/LoginItems/LoginItemsManager.swift | 2 + .../MainWindow/MainViewController.swift | 23 +- .../View/NavigationBarPopovers.swift | 1 - .../VPNIPCResources.swift | 23 ++ .../VPNOperationErrorRecorder.swift | 4 +- ...rkProtection+ConvenienceInitializers.swift | 7 - .../NetworkProtectionDebugUtilities.swift | 6 +- ...etworkProtectionNavBarPopoverManager.swift | 4 +- .../TunnelControllerProvider.swift | 3 +- ...lerUDSClient+ConvenienceInitializers.swift | 33 +++ ...lerXPCClient+ConvenienceInitializers.swift | 29 ++ ...NetworkProtectionIPCTunnelController.swift | 2 + ...rkProtectionUNNotificationsPresenter.swift | 7 +- .../Model/PreferencesSidebarModel.swift | 4 +- .../VPNMetadataCollector.swift | 6 +- DuckDuckGo/Waitlist/IPCServiceLauncher.swift | 94 ++++++ DuckDuckGo/Waitlist/VPNUninstaller.swift | 61 ++-- DuckDuckGoVPN/DuckDuckGoVPN.entitlements | 2 +- DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift | 7 +- .../DuckDuckGoVPNAppStore.entitlements | 1 + DuckDuckGoVPN/DuckDuckGoVPNDebug.entitlements | 1 + DuckDuckGoVPN/Info-AppStore.plist | 2 + DuckDuckGoVPN/Info.plist | 4 + .../TunnelControllerIPCService.swift | 71 ++++- .../Sources/AppLauncher/AppLauncher.swift | 12 +- .../NetworkProtectionMac/Package.swift | 2 + .../VPNControllerIPCClient.swift | 26 ++ .../VPNControllerUDSClient.swift | 38 +++ ...ent.swift => VPNControllerXPCClient.swift} | 54 +++- ...ver.swift => VPNControllerXPCServer.swift} | 23 +- .../VPNIPCClientCommand.swift | 29 ++ .../TunnelControllerView.swift | 2 +- LocalPackages/UDSHelper/.gitignore | 8 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + LocalPackages/UDSHelper/Package.swift | 26 ++ .../Sources/UDSHelper/UDSClient.swift | 236 +++++++++++++++ .../Sources/UDSHelper/UDSMessage.swift | 38 +++ .../Sources/UDSHelper/UDSReceiver.swift | 188 ++++++++++++ .../Sources/UDSHelper/UDSServer.swift | 276 ++++++++++++++++++ .../UDSHelperTests/UDSMessageTests.swift | 46 +++ 51 files changed, 1454 insertions(+), 89 deletions(-) create mode 100644 DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/VPNIPCResources.swift create mode 100644 DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNControllerUDSClient+ConvenienceInitializers.swift create mode 100644 DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNControllerXPCClient+ConvenienceInitializers.swift create mode 100644 DuckDuckGo/Waitlist/IPCServiceLauncher.swift create mode 100644 LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerIPCClient.swift create mode 100644 LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerUDSClient.swift rename LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/{TunnelControllerIPCClient.swift => VPNControllerXPCClient.swift} (84%) rename LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/{TunnelControllerIPCServer.swift => VPNControllerXPCServer.swift} (93%) create mode 100644 LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNIPCClientCommand.swift create mode 100644 LocalPackages/UDSHelper/.gitignore create mode 100644 LocalPackages/UDSHelper/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 LocalPackages/UDSHelper/Package.swift create mode 100644 LocalPackages/UDSHelper/Sources/UDSHelper/UDSClient.swift create mode 100644 LocalPackages/UDSHelper/Sources/UDSHelper/UDSMessage.swift create mode 100644 LocalPackages/UDSHelper/Sources/UDSHelper/UDSReceiver.swift create mode 100644 LocalPackages/UDSHelper/Sources/UDSHelper/UDSServer.swift create mode 100644 LocalPackages/UDSHelper/Tests/UDSHelperTests/UDSMessageTests.swift diff --git a/Configuration/AppStore.xcconfig b/Configuration/AppStore.xcconfig index 30ec838615..4bf15b00f3 100644 --- a/Configuration/AppStore.xcconfig +++ b/Configuration/AppStore.xcconfig @@ -95,3 +95,21 @@ DBP_APP_GROUP[config=CI][sdk=*] = $(DBP_BASE_APP_GROUP).debug DBP_APP_GROUP[config=Review][sdk=*] = $(DBP_BASE_APP_GROUP).review DBP_APP_GROUP[config=Debug][sdk=*] = $(DBP_BASE_APP_GROUP).debug DBP_APP_GROUP[config=Release][sdk=*] = $(DBP_BASE_APP_GROUP) + +// IPC + +// IMPORTANT: The reason this app group was created is because IPC through +// Unix Domain Sockets requires the socket file path to be no longer than +// 108 characters. Sandboxing requirements force us to place said socket +// within an app group container. +// +// Name coding: +// - ipc.a = ipc app store release +// - ipc.a.d = ipc app store debug +// - ipc.a.r = ipc app store review +// +IPC_APP_GROUP_BASE = $(DEVELOPMENT_TEAM).com.ddg.ipc.a +IPC_APP_GROUP[config=CI][sdk=*] = $(IPC_APP_GROUP_BASE).d +IPC_APP_GROUP[config=Review][sdk=*] = $(IPC_APP_GROUP_BASE).r +IPC_APP_GROUP[config=Debug][sdk=*] = $(IPC_APP_GROUP_BASE).d +IPC_APP_GROUP[config=Release][sdk=*] = $(IPC_APP_GROUP_BASE) diff --git a/Configuration/DeveloperID.xcconfig b/Configuration/DeveloperID.xcconfig index 0dc8de0485..ddbe7edc51 100644 --- a/Configuration/DeveloperID.xcconfig +++ b/Configuration/DeveloperID.xcconfig @@ -100,3 +100,21 @@ DBP_APP_GROUP[config=CI][sdk=*] = $(DBP_BASE_APP_GROUP).debug DBP_APP_GROUP[config=Review][sdk=*] = $(DBP_BASE_APP_GROUP).review DBP_APP_GROUP[config=Debug][sdk=*] = $(DBP_BASE_APP_GROUP).debug DBP_APP_GROUP[config=Release][sdk=*] = $(DBP_BASE_APP_GROUP) + +// IPC + +// IMPORTANT: The reason this app group was created is because IPC through +// Unix Domain Sockets requires the socket file path to be no longer than +// 108 characters. Sandboxing requirements force us to place said socket +// within an app group container. +// +// Name coding: +// - ipc.d = ipc developer id release +// - ipc.d.d = ipc developer id debug +// - ipc.d.r = ipc developer id review +// +IPC_APP_GROUP_BASE = $(DEVELOPMENT_TEAM).com.ddg.ipc +IPC_APP_GROUP[config=CI][sdk=*] = $(IPC_APP_GROUP_BASE).d +IPC_APP_GROUP[config=Review][sdk=*] = $(IPC_APP_GROUP_BASE).r +IPC_APP_GROUP[config=Debug][sdk=*] = $(IPC_APP_GROUP_BASE).d +IPC_APP_GROUP[config=Release][sdk=*] = $(IPC_APP_GROUP_BASE) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index ad5446c719..ba2c846099 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1520,10 +1520,13 @@ 7B4D8A232BDA857300852966 /* VPNOperationErrorRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4D8A202BDA857300852966 /* VPNOperationErrorRecorder.swift */; }; 7B4D8A242BDA857300852966 /* VPNOperationErrorRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4D8A202BDA857300852966 /* VPNOperationErrorRecorder.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 */; }; 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 */; }; 7B8DB31A2B504D7500EC16DA /* VPNAppEventsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8DB3192B504D7500EC16DA /* VPNAppEventsHandler.swift */; }; 7B934C412A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B934C402A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift */; }; 7B97CD592B7E0B57004FEF43 /* NetworkProtectionProxy in Frameworks */ = {isa = PBXBuildFile; productRef = 7B97CD582B7E0B57004FEF43 /* NetworkProtectionProxy */; }; @@ -1568,8 +1571,14 @@ 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 */; }; + 7BCB90C22C18626E008E3543 /* VPNControllerXPCClient+ConvenienceInitializers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BCB90C12C18626E008E3543 /* VPNControllerXPCClient+ConvenienceInitializers.swift */; }; + 7BCB90C32C1863BA008E3543 /* VPNControllerXPCClient+ConvenienceInitializers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BCB90C12C18626E008E3543 /* VPNControllerXPCClient+ConvenienceInitializers.swift */; }; 7BD1688E2AD4A4C400D24876 /* NetworkExtensionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD1688D2AD4A4C400D24876 /* NetworkExtensionController.swift */; }; 7BD3AF5D2A8E7AF1006F9F56 /* KeychainType+ClientDefault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD3AF5C2A8E7AF1006F9F56 /* KeychainType+ClientDefault.swift */; }; + 7BD7B0012C19D3830039D20A /* VPNIPCResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD7B0002C19D3830039D20A /* VPNIPCResources.swift */; }; + 7BD7B0022C19D3830039D20A /* VPNIPCResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD7B0002C19D3830039D20A /* VPNIPCResources.swift */; }; + 7BD7B0032C19D3830039D20A /* VPNIPCResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD7B0002C19D3830039D20A /* VPNIPCResources.swift */; }; + 7BD7B0042C19D3830039D20A /* VPNIPCResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD7B0002C19D3830039D20A /* VPNIPCResources.swift */; }; 7BDA36E62B7E037100AD5388 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4B4D603E2A0B290200BCD287 /* NetworkExtension.framework */; }; 7BDA36F52B7E055800AD5388 /* MacTransparentProxyProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B0099802B65C6B300FE7C31 /* MacTransparentProxyProvider.swift */; }; 7BE146072A6A83C700C313B8 /* NetworkProtectionDebugMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BE146062A6A83C700C313B8 /* NetworkProtectionDebugMenu.swift */; }; @@ -1582,6 +1591,8 @@ 7BEEA5122AD1235B00A9E72B /* NetworkProtectionIPC in Frameworks */ = {isa = PBXBuildFile; productRef = 7BEEA5112AD1235B00A9E72B /* NetworkProtectionIPC */; }; 7BEEA5142AD1236300A9E72B /* NetworkProtectionIPC in Frameworks */ = {isa = PBXBuildFile; productRef = 7BEEA5132AD1236300A9E72B /* NetworkProtectionIPC */; }; 7BEEA5162AD1236E00A9E72B /* NetworkProtectionUI in Frameworks */ = {isa = PBXBuildFile; productRef = 7BEEA5152AD1236E00A9E72B /* NetworkProtectionUI */; }; + 7BFF35732C10D75000F89673 /* IPCServiceLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFF35722C10D75000F89673 /* IPCServiceLauncher.swift */; }; + 7BFF35742C10D75000F89673 /* IPCServiceLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFF35722C10D75000F89673 /* IPCServiceLauncher.swift */; }; 7BFF850F2B0C09DA00ECACA2 /* DuckDuckGo Personal Information Removal.app in Embed Login Items */ = {isa = PBXBuildFile; fileRef = 9D9AE8D12AAA39A70026E7DC /* DuckDuckGo Personal Information Removal.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 85012B0229133F9F003D0DCC /* NavigationBarPopovers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85012B0129133F9F003D0DCC /* NavigationBarPopovers.swift */; }; 850E8DFB2A6FEC5E00691187 /* BookmarksBarAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850E8DFA2A6FEC5E00691187 /* BookmarksBarAppearance.swift */; }; @@ -3384,10 +3395,12 @@ 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 = ""; }; + 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 = ""; }; 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 = ""; }; 7B9167A82C09E88800322310 /* AppLauncher */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AppLauncher; sourceTree = ""; }; 7B934C3D2A866CFF00FC8F9C /* NetworkProtectionOnboardingMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkProtectionOnboardingMenu.swift; sourceTree = ""; }; @@ -3409,8 +3422,10 @@ 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 = ""; }; 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 = ""; }; 7BD3AF5C2A8E7AF1006F9F56 /* KeychainType+ClientDefault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeychainType+ClientDefault.swift"; sourceTree = ""; }; + 7BD7B0002C19D3830039D20A /* VPNIPCResources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNIPCResources.swift; sourceTree = ""; }; 7BD8679A2A9E9E000063B9F7 /* NetworkProtectionFeatureVisibility.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkProtectionFeatureVisibility.swift; sourceTree = ""; }; 7BDA36E52B7E037100AD5388 /* VPNProxyExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VPNProxyExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 7BDA36EA2B7E037200AD5388 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -3421,6 +3436,7 @@ 7BEC20402B0F505F00243D3E /* AddBookmarkPopoverView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddBookmarkPopoverView.swift; sourceTree = ""; }; 7BEC20412B0F505F00243D3E /* AddBookmarkFolderPopoverView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddBookmarkFolderPopoverView.swift; sourceTree = ""; }; 7BF1A9D72AE054D300FCA683 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 7BFF35722C10D75000F89673 /* IPCServiceLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPCServiceLauncher.swift; sourceTree = ""; }; 85012B0129133F9F003D0DCC /* NavigationBarPopovers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarPopovers.swift; sourceTree = ""; }; 850E8DFA2A6FEC5E00691187 /* BookmarksBarAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarAppearance.swift; sourceTree = ""; }; 8511E18325F82B34002F516B /* 01_Fire_really_small.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = 01_Fire_really_small.json; sourceTree = ""; }; @@ -4247,6 +4263,7 @@ 7BEEA5122AD1235B00A9E72B /* NetworkProtectionIPC in Frameworks */, 7BA7CC5F2AD1210C0042E5CE /* Networking in Frameworks */, 7BEEA5162AD1236E00A9E72B /* NetworkProtectionUI in Frameworks */, + 7B8594192B5B26230007EB3E /* UDSHelper in Frameworks */, BDADBDC92BD2BC2200421B9B /* Lottie in Frameworks */, 7B23668C2C09FAF1002D393F /* VPNAppLauncher in Frameworks */, EE7295ED2A545C0A008C0991 /* NetworkProtection in Frameworks */, @@ -4835,6 +4852,7 @@ 37BA812B29B3CB8A0053F1A3 /* SyncUI */, 1E862A882A9FC01200F84D4B /* SubscriptionUI */, 7BEC182D2AD5D89C00D30536 /* SystemExtensionManager */, + 7B8594172B5B25FB0007EB3E /* UDSHelper */, 7B76E6852AD5D77600186A84 /* XPCHelper */, ); path = LocalPackages; @@ -5178,6 +5196,8 @@ 7BD3AF5C2A8E7AF1006F9F56 /* KeychainType+ClientDefault.swift */, 9D9AE8682AA76CDC0026E7DC /* LoginItem+NetworkProtection.swift */, B602E81C2A1E25B0006D261F /* NEOnDemandRuleExtension.swift */, + 7B6545EC2C0778BB00115BEA /* VPNControllerUDSClient+ConvenienceInitializers.swift */, + 7BCB90C12C18626E008E3543 /* VPNControllerXPCClient+ConvenienceInitializers.swift */, 4B4D60692A0B29FA00BCD287 /* NetworkProtection+ConvenienceInitializers.swift */, 7B2DDCF72A93A8BB0039D884 /* NetworkProtectionAppEvents.swift */, 4B4D606A2A0B29FA00BCD287 /* NetworkProtectionControllerErrorStore.swift */, @@ -5494,6 +5514,7 @@ children = ( 7BD8679A2A9E9E000063B9F7 /* NetworkProtectionFeatureVisibility.swift */, 4B6785432AA8DE1F008A5004 /* VPNUninstaller.swift */, + 7BFF35722C10D75000F89673 /* IPCServiceLauncher.swift */, 7B7FCD0E2BA33B2700C04FBE /* UserDefaults+vpnLegacyUser.swift */, 31F2D1FE2AF026D800BF0144 /* WaitlistTermsAndConditionsActionHandler.swift */, 31C9ADE42AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift */, @@ -8256,6 +8277,7 @@ 4BF0E5042AD2551A00FFEC9E /* NetworkProtectionPixelEvent.swift */, 7B934C402A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift */, 7B4D8A202BDA857300852966 /* VPNOperationErrorRecorder.swift */, + 7BD7B0002C19D3830039D20A /* VPNIPCResources.swift */, ); path = AppAndExtensionAndAgentTargets; sourceTree = ""; @@ -8494,6 +8516,7 @@ 7BEEA5152AD1236E00A9E72B /* NetworkProtectionUI */, 7BEC182E2AD5D8DC00D30536 /* SystemExtensionManager */, 4B41EDAA2B1544B2001EEDF4 /* LoginItems */, + 7B8594182B5B26230007EB3E /* UDSHelper */, 7B00997C2B6508B700FE7C31 /* NetworkProtectionProxy */, EE2F9C5A2B90F2FF00D45FC9 /* Subscription */, F198C7192BD18A5B000BF24D /* PixelKit */, @@ -9567,6 +9590,7 @@ 560C40002BCD5A1E00F589CE /* PermanentSurveyManager.swift in Sources */, C13909FC2B861039001626ED /* AutofillActionPresenter.swift in Sources */, 4BF97AD72B43C53D00EB4240 /* NetworkProtectionIPCTunnelController.swift in Sources */, + 7BCB90C32C1863BA008E3543 /* VPNControllerXPCClient+ConvenienceInitializers.swift in Sources */, EEC4A66E2B2C894F00F7C0AA /* VPNLocationPreferenceItemModel.swift in Sources */, 3706FA83293F65D500E42796 /* LazyLoadable.swift in Sources */, 3706FA84293F65D500E42796 /* ClickToLoadModel.swift in Sources */, @@ -9580,6 +9604,7 @@ 9FEE986E2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift in Sources */, 3706FA8D293F65D500E42796 /* BookmarkTableCellView.swift in Sources */, 3706FA8E293F65D500E42796 /* BookmarkManagementSidebarViewController.swift in Sources */, + 7BD7B0022C19D3830039D20A /* VPNIPCResources.swift in Sources */, 3706FA8F293F65D500E42796 /* NSStackViewExtension.swift in Sources */, 3706FA90293F65D500E42796 /* OptionalExtension.swift in Sources */, 3706FA91293F65D500E42796 /* PasswordManagementLoginItemView.swift in Sources */, @@ -9603,6 +9628,7 @@ 3706FAA2293F65D500E42796 /* MainWindow.swift in Sources */, 3707C727294B5D2900682A9F /* WKWebView+SessionState.swift in Sources */, 4B44FEF42B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */, + 7BFF35742C10D75000F89673 /* IPCServiceLauncher.swift in Sources */, F1D042A22BFBB4DD00A31506 /* DataBrokerProtectionSettings+Environment.swift in Sources */, 3706FAA3293F65D500E42796 /* CrashReportPromptViewController.swift in Sources */, 3706FAA4293F65D500E42796 /* ContextMenuManager.swift in Sources */, @@ -10256,6 +10282,7 @@ C16127EF2BDFB46400966BB9 /* DataImportShortcutsView.swift in Sources */, 3706FC65293F65D500E42796 /* HomePageViewController.swift in Sources */, 3706FC67293F65D500E42796 /* OperatingSystemVersionExtension.swift in Sources */, + 7B6545EE2C0779D500115BEA /* VPNControllerUDSClient+ConvenienceInitializers.swift in Sources */, B6F9BDE52B45CD1900677B33 /* ModalView.swift in Sources */, 9F872DA42B90920F00138637 /* BookmarkFolderInfo.swift in Sources */, B677FC502B06376B0099EB04 /* ReportFeedbackView.swift in Sources */, @@ -10749,6 +10776,7 @@ 7BAF9E4C2A8A3CCA002D3B6E /* UserDefaults+NetworkProtectionShared.swift in Sources */, 7BA7CC592AD1203B0042E5CE /* UserText+NetworkProtection.swift in Sources */, EEDE50112BA360C80017F3C4 /* NetworkProtection+VPNAgentConvenienceInitializers.swift in Sources */, + 7BD7B0032C19D3830039D20A /* VPNIPCResources.swift in Sources */, F1DA51942BF6081E00CF29FA /* AttributionPixelHandler.swift in Sources */, 7BA7CC562AD11FFB0042E5CE /* NetworkProtectionOptionKeyExtension.swift in Sources */, 7B2DDCFA2A93B25F0039D884 /* KeychainType+ClientDefault.swift in Sources */, @@ -10790,6 +10818,7 @@ 4B2D067C2A13340900DE1F49 /* Logging.swift in Sources */, 7B1459552B7D438F00047F2C /* VPNProxyLauncher.swift in Sources */, EEDE50122BA360C80017F3C4 /* NetworkProtection+VPNAgentConvenienceInitializers.swift in Sources */, + 7BD7B0042C19D3830039D20A /* VPNIPCResources.swift in Sources */, F1DA51952BF6081E00CF29FA /* AttributionPixelHandler.swift in Sources */, B6F92BAD2A6937B5002ABA6B /* OptionalExtension.swift in Sources */, 4BA7C4D92B3F61FB00AFE511 /* BundleExtension.swift in Sources */, @@ -11030,6 +11059,7 @@ 85D33F1225C82EB3002B91A6 /* ConfigurationManager.swift in Sources */, 31F28C4F28C8EEC500119F70 /* YoutubePlayerUserScript.swift in Sources */, 4B41EDAE2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */, + 7BFF35732C10D75000F89673 /* IPCServiceLauncher.swift in Sources */, AA5FA697275F90C400DCE9C9 /* FaviconImageCache.swift in Sources */, 1430DFF524D0580F00B8978C /* TabBarViewController.swift in Sources */, B62B483E2ADE48DE000DECE5 /* ArrayBuilder.swift in Sources */, @@ -11060,6 +11090,7 @@ AABEE6A524AA0A7F0043105B /* SuggestionViewController.swift in Sources */, 1D6216B229069BBF00386B2C /* BWKeyStorage.swift in Sources */, AA7E919F287872EA00AB6B62 /* VisitViewModel.swift in Sources */, + 7BD7B0012C19D3830039D20A /* VPNIPCResources.swift in Sources */, B6676BE12AA986A700525A21 /* AddressBarTextEditor.swift in Sources */, B69B503B2726A12500758A2B /* Atb.swift in Sources */, 37A6A8F12AFCC988008580A3 /* FaviconsFetcherOnboarding.swift in Sources */, @@ -11140,6 +11171,7 @@ B6BF5D8929470BC4006742B1 /* HTTPSUpgradeTabExtension.swift in Sources */, 1D36E65B298ACD2900AA485D /* AppIconChanger.swift in Sources */, 4B4D60E22A0C883A00BCD287 /* AppMain.swift in Sources */, + 7BCB90C22C18626E008E3543 /* VPNControllerXPCClient+ConvenienceInitializers.swift in Sources */, 4B9DB0202A983B24000927DB /* ProductWaitlistRequest.swift in Sources */, 98779A0029999B64005D8EB6 /* Bookmark.xcdatamodeld in Sources */, 85589E9E27BFE4500038AD11 /* DefaultBrowserPromptView.swift in Sources */, @@ -11547,6 +11579,7 @@ 4BBDEE9128FC14760092FAA6 /* BWInstallationService.swift in Sources */, 7B4D8A212BDA857300852966 /* VPNOperationErrorRecorder.swift in Sources */, 859F30642A72A7BB00C20372 /* BookmarksBarPromptPopover.swift in Sources */, + 7B6545ED2C0778BB00115BEA /* VPNControllerUDSClient+ConvenienceInitializers.swift in Sources */, B693955426F04BEC0015B914 /* ColorView.swift in Sources */, AA5C1DD3285A217F0089850C /* RecentlyClosedCacheItem.swift in Sources */, B6BBF17427475B15004F850E /* PopupBlockedPopover.swift in Sources */, @@ -13375,6 +13408,10 @@ package = 9807F643278CA16F00E1547B /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; productName = Networking; }; + 7B8594182B5B26230007EB3E /* UDSHelper */ = { + isa = XCSwiftPackageProductDependency; + productName = UDSHelper; + }; 7B97CD582B7E0B57004FEF43 /* NetworkProtectionProxy */ = { isa = XCSwiftPackageProductDependency; productName = NetworkProtectionProxy; diff --git a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser.xcscheme b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser.xcscheme index 3afb1cb16d..3adcec9f87 100644 --- a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser.xcscheme +++ b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser.xcscheme @@ -62,6 +62,20 @@ ReferencedContainer = "container:LocalPackages/DataBrokerProtection"> + + + + + + + + com.apple.security.application-groups + $(IPC_APP_GROUP) $(DBP_APP_GROUP) $(NETP_APP_GROUP) $(SUBSCRIPTION_APP_GROUP) diff --git a/DuckDuckGo/DuckDuckGoAppStore.entitlements b/DuckDuckGo/DuckDuckGoAppStore.entitlements index 7b07bc92d8..5f57d80c3b 100644 --- a/DuckDuckGo/DuckDuckGoAppStore.entitlements +++ b/DuckDuckGo/DuckDuckGoAppStore.entitlements @@ -11,6 +11,7 @@ com.apple.security.application-groups + $(IPC_APP_GROUP) $(DBP_APP_GROUP) $(NETP_APP_GROUP) $(SUBSCRIPTION_APP_GROUP) diff --git a/DuckDuckGo/DuckDuckGoAppStoreCI.entitlements b/DuckDuckGo/DuckDuckGoAppStoreCI.entitlements index 8779a3f306..8cfb90076d 100644 --- a/DuckDuckGo/DuckDuckGoAppStoreCI.entitlements +++ b/DuckDuckGo/DuckDuckGoAppStoreCI.entitlements @@ -6,6 +6,7 @@ com.apple.security.application-groups + $(IPC_APP_GROUP) $(NETP_APP_GROUP) $(DBP_APP_GROUP) $(SUBSCRIPTION_APP_GROUP) diff --git a/DuckDuckGo/DuckDuckGoDebug.entitlements b/DuckDuckGo/DuckDuckGoDebug.entitlements index e5efc9872c..2e99139e1a 100644 --- a/DuckDuckGo/DuckDuckGoDebug.entitlements +++ b/DuckDuckGo/DuckDuckGoDebug.entitlements @@ -13,6 +13,7 @@ com.apple.security.application-groups + $(IPC_APP_GROUP) $(DBP_APP_GROUP) $(NETP_APP_GROUP) $(SUBSCRIPTION_APP_GROUP) diff --git a/DuckDuckGo/Info.plist b/DuckDuckGo/Info.plist index 095900fb99..f7a87ccdde 100644 --- a/DuckDuckGo/Info.plist +++ b/DuckDuckGo/Info.plist @@ -550,5 +550,7 @@ 10800 ViewBridgeService + IPC_APP_GROUP + $(IPC_APP_GROUP) diff --git a/DuckDuckGo/LoginItems/LoginItemsManager.swift b/DuckDuckGo/LoginItems/LoginItemsManager.swift index 4dd2f98fb5..1260177691 100644 --- a/DuckDuckGo/LoginItems/LoginItemsManager.swift +++ b/DuckDuckGo/LoginItems/LoginItemsManager.swift @@ -26,6 +26,8 @@ protocol LoginItemsManaging { func throwingEnableLoginItems(_ items: Set, log: OSLog) throws func disableLoginItems(_ items: Set) func restartLoginItems(_ items: Set, log: OSLog) + + func isAnyEnabled(_ items: Set) -> Bool } /// Class to manage the login items for the VPN and DBP diff --git a/DuckDuckGo/MainWindow/MainViewController.swift b/DuckDuckGo/MainWindow/MainViewController.swift index 4624b91d14..ded43e5a6f 100644 --- a/DuckDuckGo/MainWindow/MainViewController.swift +++ b/DuckDuckGo/MainWindow/MainViewController.swift @@ -55,7 +55,11 @@ final class MainViewController: NSViewController { fatalError("MainViewController: Bad initializer") } - init(tabCollectionViewModel: TabCollectionViewModel? = nil, bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, autofillPopoverPresenter: AutofillPopoverPresenter) { + init(tabCollectionViewModel: TabCollectionViewModel? = nil, + bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, + autofillPopoverPresenter: AutofillPopoverPresenter, + vpnXPCClient: VPNControllerXPCClient = .shared) { + let tabCollectionViewModel = tabCollectionViewModel ?? TabCollectionViewModel() self.tabCollectionViewModel = tabCollectionViewModel self.isBurner = tabCollectionViewModel.isBurner @@ -70,14 +74,14 @@ final class MainViewController: NSViewController { } #endif - let ipcClient = TunnelControllerIPCClient() - ipcClient.register { error in + vpnXPCClient.register { error in NetworkProtectionKnownFailureStore().lastKnownFailure = KnownFailure(error) } - let vpnUninstaller = VPNUninstaller(ipcClient: ipcClient) + + let vpnUninstaller = VPNUninstaller(ipcClient: vpnXPCClient) return NetworkProtectionNavBarPopoverManager( - ipcClient: ipcClient, + ipcClient: vpnXPCClient, vpnUninstaller: vpnUninstaller) }() let networkProtectionStatusReporter: NetworkProtectionStatusReporter = { @@ -92,14 +96,13 @@ final class MainViewController: NSViewController { connectivityIssuesObserver = connectivityIssuesObserver ?? DisabledConnectivityIssueObserver() controllerErrorMessageObserver = controllerErrorMessageObserver ?? ControllerErrorMesssageObserverThroughDistributedNotifications() - let ipcClient = networkProtectionPopoverManager.ipcClient return DefaultNetworkProtectionStatusReporter( - statusObserver: ipcClient.ipcStatusObserver, - serverInfoObserver: ipcClient.ipcServerInfoObserver, - connectionErrorObserver: ipcClient.ipcConnectionErrorObserver, + statusObserver: vpnXPCClient.ipcStatusObserver, + serverInfoObserver: vpnXPCClient.ipcServerInfoObserver, + connectionErrorObserver: vpnXPCClient.ipcConnectionErrorObserver, connectivityIssuesObserver: connectivityIssuesObserver, controllerErrorMessageObserver: controllerErrorMessageObserver, - dataVolumeObserver: ipcClient.ipcDataVolumeObserver, + dataVolumeObserver: vpnXPCClient.ipcDataVolumeObserver, knownFailureObserver: KnownFailureObserverThroughDistributedNotifications() ) }() diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift index 133c11b4b6..29ef44add5 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift @@ -29,7 +29,6 @@ protocol PopoverPresenter { } protocol NetPPopoverManager: AnyObject { - var ipcClient: NetworkProtectionIPCClient { get } var isShown: Bool { get } func show(positionedBelow view: NSView, withDelegate delegate: NSPopoverDelegate) -> NSPopover diff --git a/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/VPNIPCResources.swift b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/VPNIPCResources.swift new file mode 100644 index 0000000000..396cc45a80 --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/VPNIPCResources.swift @@ -0,0 +1,23 @@ +// +// VPNIPCResources.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 VPNIPCResources { + public static let socketFileURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Bundle.main.appGroup(bundle: .ipc))!.appendingPathComponent("vpn.ipc") +} diff --git a/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/VPNOperationErrorRecorder.swift b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/VPNOperationErrorRecorder.swift index 7fa58ab46d..f96ff470b9 100644 --- a/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/VPNOperationErrorRecorder.swift +++ b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/VPNOperationErrorRecorder.swift @@ -38,10 +38,10 @@ final class ErrorInformation: NSObject, Codable { /// final class VPNOperationErrorHistory { - private let ipcClient: TunnelControllerIPCClient + private let ipcClient: VPNControllerXPCClient private let defaults: UserDefaults - init(ipcClient: TunnelControllerIPCClient, + init(ipcClient: VPNControllerXPCClient, defaults: UserDefaults = .netP) { self.ipcClient = ipcClient diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtection+ConvenienceInitializers.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtection+ConvenienceInitializers.swift index f48a2c97fb..2d57ecb38d 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtection+ConvenienceInitializers.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtection+ConvenienceInitializers.swift @@ -80,10 +80,3 @@ extension NetworkProtectionLocationListCompositeRepository { ) } } - -extension TunnelControllerIPCClient { - - convenience init() { - self.init(machServiceName: Bundle.main.vpnMenuAgentBundleId) - } -} diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift index aa2c99a004..a640eb38a8 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift @@ -29,7 +29,7 @@ import NetworkProtectionIPC /// final class NetworkProtectionDebugUtilities { - private let ipcClient: TunnelControllerIPCClient + private let ipcClient: VPNControllerXPCClient private let vpnUninstaller: VPNUninstaller // MARK: - Login Items Management @@ -46,7 +46,7 @@ final class NetworkProtectionDebugUtilities { self.loginItemsManager = loginItemsManager self.settings = settings - let ipcClient = TunnelControllerIPCClient() + let ipcClient = VPNControllerXPCClient.shared self.ipcClient = ipcClient self.vpnUninstaller = VPNUninstaller(ipcClient: ipcClient) @@ -66,7 +66,7 @@ final class NetworkProtectionDebugUtilities { func removeSystemExtensionAndAgents() async throws { try await vpnUninstaller.removeSystemExtension() - vpnUninstaller.disableLoginItems() + vpnUninstaller.removeAgents() } func sendTestNotificationRequest() async throws { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift index 997b03d7b8..b2a029c814 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift @@ -37,7 +37,7 @@ protocol NetworkProtectionIPCClient { func stop(completion: @escaping (Error?) -> Void) } -extension TunnelControllerIPCClient: NetworkProtectionIPCClient { +extension VPNControllerXPCClient: NetworkProtectionIPCClient { public var ipcStatusObserver: any NetworkProtection.ConnectionStatusObserver { connectionStatusObserver } public var ipcServerInfoObserver: any NetworkProtection.ConnectionServerInfoObserver { serverInfoObserver } public var ipcConnectionErrorObserver: any NetworkProtection.ConnectionErrorObserver { connectionErrorObserver } @@ -49,7 +49,7 @@ final class NetworkProtectionNavBarPopoverManager: NetPPopoverManager { let ipcClient: NetworkProtectionIPCClient let vpnUninstaller: VPNUninstalling - init(ipcClient: TunnelControllerIPCClient, + init(ipcClient: VPNControllerXPCClient, vpnUninstaller: VPNUninstalling) { self.ipcClient = ipcClient self.vpnUninstaller = vpnUninstaller diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/TunnelControllerProvider.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/TunnelControllerProvider.swift index 1b50a1156c..ace1bd3868 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/TunnelControllerProvider.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/TunnelControllerProvider.swift @@ -26,10 +26,11 @@ final class TunnelControllerProvider { let tunnelController: NetworkProtectionIPCTunnelController private init() { - let ipcClient = TunnelControllerIPCClient() + let ipcClient = VPNControllerXPCClient.shared ipcClient.register { error in NetworkProtectionKnownFailureStore().lastKnownFailure = KnownFailure(error) } + tunnelController = NetworkProtectionIPCTunnelController(ipcClient: ipcClient) } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNControllerUDSClient+ConvenienceInitializers.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNControllerUDSClient+ConvenienceInitializers.swift new file mode 100644 index 0000000000..b4ea2f7d65 --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNControllerUDSClient+ConvenienceInitializers.swift @@ -0,0 +1,33 @@ +// +// VPNControllerUDSClient+ConvenienceInitializers.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 NetworkProtectionIPC +import UDSHelper + +extension VPNControllerUDSClient { + convenience init() { + self.init(udsClient: .sharedVPNUDSClient) + } +} + +extension UDSClient { + static let sharedVPNUDSClient: UDSClient = { + return UDSClient(socketFileURL: VPNIPCResources.socketFileURL, log: .networkProtectionIPCLog) + }() +} diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNControllerXPCClient+ConvenienceInitializers.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNControllerXPCClient+ConvenienceInitializers.swift new file mode 100644 index 0000000000..0aa7231ee4 --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNControllerXPCClient+ConvenienceInitializers.swift @@ -0,0 +1,29 @@ +// +// VPNControllerXPCClient+ConvenienceInitializers.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 NetworkProtectionIPC + +extension VPNControllerXPCClient { + + static let shared = VPNControllerXPCClient() + + convenience init() { + self.init(machServiceName: Bundle.main.vpnMenuAgentBundleId) + } +} diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionIPCTunnelController.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionIPCTunnelController.swift index 9a883ab588..13e733d622 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionIPCTunnelController.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionIPCTunnelController.swift @@ -21,6 +21,7 @@ import Foundation import NetworkProtection import NetworkProtectionIPC import PixelKit +import UDSHelper /// VPN tunnel controller through IPC. /// @@ -57,6 +58,7 @@ final class NetworkProtectionIPCTunnelController { init(featureVisibility: NetworkProtectionFeatureVisibility = DefaultNetworkProtectionVisibility(subscriptionManager: Application.appDelegate.subscriptionManager), loginItemsManager: LoginItemsManaging = LoginItemsManager(), ipcClient: NetworkProtectionIPCClient, + fileManager: FileManager = .default, pixelKit: PixelFiring? = PixelKit.shared, errorRecorder: VPNOperationErrorRecorder = VPNOperationErrorRecorder(), knownFailureStore: NetworkProtectionKnownFailureStore = NetworkProtectionKnownFailureStore()) { diff --git a/DuckDuckGo/NetworkProtection/NetworkExtensionTargets/AppExtensionAndNotificationTargets/NetworkProtectionUNNotificationsPresenter.swift b/DuckDuckGo/NetworkProtection/NetworkExtensionTargets/AppExtensionAndNotificationTargets/NetworkProtectionUNNotificationsPresenter.swift index a2c1634901..1757d550a2 100644 --- a/DuckDuckGo/NetworkProtection/NetworkExtensionTargets/AppExtensionAndNotificationTargets/NetworkProtectionUNNotificationsPresenter.swift +++ b/DuckDuckGo/NetworkProtection/NetworkExtensionTargets/AppExtensionAndNotificationTargets/NetworkProtectionUNNotificationsPresenter.swift @@ -182,13 +182,8 @@ extension NetworkProtectionUNNotificationsPresenter: UNUserNotificationCenterDel } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { - switch UNNotificationAction.Identifier(rawValue: response.actionIdentifier) { - case .reconnect: - try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showStatus) - case .none: - try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showStatus) - } + try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showStatus) } } diff --git a/DuckDuckGo/Preferences/Model/PreferencesSidebarModel.swift b/DuckDuckGo/Preferences/Model/PreferencesSidebarModel.swift index 8c8520ea2f..e998a35be8 100644 --- a/DuckDuckGo/Preferences/Model/PreferencesSidebarModel.swift +++ b/DuckDuckGo/Preferences/Model/PreferencesSidebarModel.swift @@ -31,7 +31,7 @@ final class PreferencesSidebarModel: ObservableObject { @Published var selectedTabIndex: Int = 0 @Published private(set) var selectedPane: PreferencePaneIdentifier = .defaultBrowser private let vpnVisibility: NetworkProtectionFeatureVisibility - let vpnTunnelIPCClient: TunnelControllerIPCClient + let vpnTunnelIPCClient: VPNControllerXPCClient var selectedTabContent: AnyPublisher { $selectedTabIndex.map { [tabSwitcherTabs] in tabSwitcherTabs[$0] }.eraseToAnyPublisher() @@ -45,7 +45,7 @@ final class PreferencesSidebarModel: ObservableObject { privacyConfigurationManager: PrivacyConfigurationManaging, syncService: DDGSyncing, vpnVisibility: NetworkProtectionFeatureVisibility = DefaultNetworkProtectionVisibility(subscriptionManager: Application.appDelegate.subscriptionManager), - vpnTunnelIPCClient: TunnelControllerIPCClient = TunnelControllerIPCClient() + vpnTunnelIPCClient: VPNControllerXPCClient = .shared ) { self.loadSections = loadSections self.tabSwitcherTabs = tabSwitcherTabs diff --git a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift index 51f64b8912..e4e435edda 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift @@ -120,15 +120,17 @@ protocol VPNMetadataCollector { final class DefaultVPNMetadataCollector: VPNMetadataCollector { private let statusReporter: NetworkProtectionStatusReporter - private let ipcClient: TunnelControllerIPCClient + private let ipcClient: VPNControllerXPCClient private let defaults: UserDefaults private let accountManager: AccountManaging private let settings: VPNSettings init(defaults: UserDefaults = .netP, accountManager: AccountManaging) { - let ipcClient = TunnelControllerIPCClient() + + let ipcClient = VPNControllerXPCClient.shared ipcClient.register { _ in } + self.accountManager = accountManager self.ipcClient = ipcClient self.defaults = defaults diff --git a/DuckDuckGo/Waitlist/IPCServiceLauncher.swift b/DuckDuckGo/Waitlist/IPCServiceLauncher.swift new file mode 100644 index 0000000000..579793a5d3 --- /dev/null +++ b/DuckDuckGo/Waitlist/IPCServiceLauncher.swift @@ -0,0 +1,94 @@ +// +// IPCServiceLauncher.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 Common +import Foundation +import LoginItems +import NetworkProtectionIPC + +final class IPCServiceLauncher { + + enum DisableError: Error { + case failedToStopService + case serviceNotRunning + } + + enum LaunchMethod { + case direct(bundleID: String, appLauncher: AppLauncher) + case loginItem(loginItem: LoginItem, loginItemsManager: LoginItemsManager) + } + + private let launchMethod: LaunchMethod + private var runningApplication: NSRunningApplication? + + init(launchMethod: LaunchMethod) { + self.launchMethod = launchMethod + } + + /// Enables the IPC service + /// + func enable() async throws { + switch launchMethod { + case .direct(let bundleID, let appLauncher): + runningApplication = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).first + + guard runningApplication == nil else { + return + } + + struct UDSLaunchAppCommand: AppLaunchCommand { + var allowsRunningApplicationSubstitution = true + var launchURL: URL? + var hideApp = true + } + + runningApplication = try await appLauncher.runApp(withCommand: UDSLaunchAppCommand()) + case .loginItem(let loginItem, let loginItemsManager): + try loginItemsManager.throwingEnableLoginItems([loginItem], log: .disabled) + } + } + + /// Disables the IPC service. + /// + /// - Throws: ``DisableError`` + /// + func disable() async throws { + switch launchMethod { + case .direct: + guard let runningApplication else { + throw DisableError.serviceNotRunning + } + + runningApplication.terminate() + + try await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC) + + if !runningApplication.isTerminated { + runningApplication.forceTerminate() + } + + if !runningApplication.isTerminated { + throw DisableError.failedToStopService + } + + case .loginItem(let loginItem, let loginItemsManager): + loginItemsManager.disableLoginItems([loginItem]) + } + } +} diff --git a/DuckDuckGo/Waitlist/VPNUninstaller.swift b/DuckDuckGo/Waitlist/VPNUninstaller.swift index 0de31fcf6a..20d3b621a4 100644 --- a/DuckDuckGo/Waitlist/VPNUninstaller.swift +++ b/DuckDuckGo/Waitlist/VPNUninstaller.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import AppLauncher import BrowserServicesKit import Common import LoginItems @@ -81,16 +82,16 @@ final class VPNUninstaller: VPNUninstalling { var name: String { switch self { case .begin: - return "vpn_browser_uninstall_attempt_ipc" + return "vpn_browser_uninstall_attempt_uds" case .cancelled: - return "vpn_browser_uninstall_cancelled_ipc" + return "vpn_browser_uninstall_cancelled_uds" case .success: - return "vpn_browser_uninstall_success_ipc" + return "vpn_browser_uninstall_success_uds" case .failure: - return "vpn_browser_uninstall_failure_ipc" + return "vpn_browser_uninstall_failure_uds" } } @@ -118,27 +119,36 @@ final class VPNUninstaller: VPNUninstalling { } private let log: OSLog + private let ipcServiceLauncher: IPCServiceLauncher private let loginItemsManager: LoginItemsManaging private let pinningManager: LocalPinningManager private let settings: VPNSettings private let userDefaults: UserDefaults private let vpnMenuLoginItem: LoginItem - private let ipcClient: TunnelControllerIPCClient + private let ipcClient: VPNControllerIPCClient private let pixelKit: PixelFiring? @MainActor private var isDisabling = false - init(loginItemsManager: LoginItemsManaging = LoginItemsManager(), + init(ipcServiceLauncher: IPCServiceLauncher? = nil, + loginItemsManager: LoginItemsManaging = LoginItemsManager(), pinningManager: LocalPinningManager = .shared, userDefaults: UserDefaults = .netP, settings: VPNSettings = .init(defaults: .netP), - ipcClient: TunnelControllerIPCClient = TunnelControllerIPCClient(), + ipcClient: VPNControllerIPCClient = VPNControllerUDSClient(), vpnMenuLoginItem: LoginItem = .vpnMenu, pixelKit: PixelFiring? = PixelKit.shared, log: OSLog = .networkProtection) { + let vpnAgentBundleID = Bundle.main.vpnMenuAgentBundleId + let appLauncher = AppLauncher(appBundleURL: Bundle.main.vpnMenuAgentURL) + let ipcServiceLaunchMethod = IPCServiceLauncher.LaunchMethod.direct( + bundleID: vpnAgentBundleID, + appLauncher: appLauncher) + self.log = log + self.ipcServiceLauncher = ipcServiceLauncher ?? IPCServiceLauncher(launchMethod: ipcServiceLaunchMethod) self.loginItemsManager = loginItemsManager self.pinningManager = pinningManager self.settings = settings @@ -176,15 +186,20 @@ final class VPNUninstaller: VPNUninstalling { } do { - try enableLoginItems() + try await ipcServiceLauncher.enable() } catch { throw UninstallError.runAgentError(error) } // Allow some time for the login items to fully launch + try await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC) do { - try await ipcClient.command(.uninstallVPN) + if removeSystemExtension { + try await ipcClient.uninstall(.all) + } else { + try await ipcClient.uninstall(.configuration) + } } catch { print("Failed to uninstall VPN, with error: \(error.localizedDescription)") @@ -200,7 +215,23 @@ final class VPNUninstaller: VPNUninstalling { // We want to give some time for the login item to reset state before disabling it try? await Task.sleep(interval: 0.5) - disableLoginItems() + + // Workaround: since status updates are provided through XPC we want to make sure the + // VPN is marked as disconnected. We may be able to more properly resolve this by using + // UDS for all VPN status updates. + // + // Ref: https://app.asana.com/0/0/1207499177312396/1207538373572594/f + // + VPNControllerXPCClient.shared.forceStatusToDisconnected() + + // While it may seem like a duplication of code, it's one thing to disable the IPC service + // and it's nother one to "uninstall" our login items. The uninstaller wants both things + // to happen. + // + // As an example of why this is important, we want all agents to be disabled even if the IPC + // service is not based on login items. + try await ipcServiceLauncher.disable() + removeAgents() notifyVPNUninstalled() isDisabling = false @@ -213,18 +244,14 @@ final class VPNUninstaller: VPNUninstalling { } } - private func enableLoginItems() throws { - try loginItemsManager.throwingEnableLoginItems(LoginItemsManager.networkProtectionLoginItems, log: log) - } - - func disableLoginItems() { + func removeAgents() { loginItemsManager.disableLoginItems(LoginItemsManager.networkProtectionLoginItems) } func removeSystemExtension() async throws { #if NETP_SYSTEM_EXTENSION do { - try await ipcClient.command(.removeSystemExtension) + try await ipcClient.uninstall(.all) } catch { throw UninstallError.systemExtensionError(error) } @@ -238,7 +265,7 @@ final class VPNUninstaller: VPNUninstalling { private func removeVPNConfiguration() async throws { // Remove the agent VPN configuration do { - try await ipcClient.command(.removeVPNConfiguration) + try await ipcClient.uninstall(.configuration) } catch { throw UninstallError.vpnConfigurationError(error) } diff --git a/DuckDuckGoVPN/DuckDuckGoVPN.entitlements b/DuckDuckGoVPN/DuckDuckGoVPN.entitlements index 3701cb8c93..cdb83c9740 100644 --- a/DuckDuckGoVPN/DuckDuckGoVPN.entitlements +++ b/DuckDuckGoVPN/DuckDuckGoVPN.entitlements @@ -12,11 +12,11 @@ com.apple.security.application-groups $(NETP_APP_GROUP) + $(IPC_APP_GROUP) $(SUBSCRIPTION_APP_GROUP) keychain-access-groups - $(AppIdentifierPrefix)$(NETP_APP_GROUP) $(NETP_APP_GROUP) $(SUBSCRIPTION_APP_GROUP) diff --git a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift index b80094a934..3f496db8cb 100644 --- a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift +++ b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift @@ -38,7 +38,12 @@ final class DuckDuckGoVPNApplication: NSApplication { private let _delegate: DuckDuckGoVPNAppDelegate override init() { - os_log(.error, log: .networkProtection, "🟢 Status Bar Agent starting: %{public}d", NSRunningApplication.current.processIdentifier) + os_log(.default, + log: .networkProtection, + "🟢 Status Bar Agent starting\nPath: (%{public}@)\nVersion: %{public}@\nPID: %{public}d", + Bundle.main.bundlePath, + "\(Bundle.main.versionNumber!).\(Bundle.main.buildNumber)", + NSRunningApplication.current.processIdentifier) // prevent agent from running twice if let anotherInstance = NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier!).first(where: { $0 != .current }) { diff --git a/DuckDuckGoVPN/DuckDuckGoVPNAppStore.entitlements b/DuckDuckGoVPN/DuckDuckGoVPNAppStore.entitlements index fd8ab61887..3c7103631f 100644 --- a/DuckDuckGoVPN/DuckDuckGoVPNAppStore.entitlements +++ b/DuckDuckGoVPN/DuckDuckGoVPNAppStore.entitlements @@ -20,6 +20,7 @@ com.apple.security.application-groups + $(IPC_APP_GROUP) $(SUBSCRIPTION_APP_GROUP) $(NETP_APP_GROUP) diff --git a/DuckDuckGoVPN/DuckDuckGoVPNDebug.entitlements b/DuckDuckGoVPN/DuckDuckGoVPNDebug.entitlements index 6fa17c3b42..2bb2f334b0 100644 --- a/DuckDuckGoVPN/DuckDuckGoVPNDebug.entitlements +++ b/DuckDuckGoVPN/DuckDuckGoVPNDebug.entitlements @@ -12,6 +12,7 @@ com.apple.security.application-groups $(NETP_APP_GROUP) + $(IPC_APP_GROUP) $(SUBSCRIPTION_APP_GROUP) keychain-access-groups diff --git a/DuckDuckGoVPN/Info-AppStore.plist b/DuckDuckGoVPN/Info-AppStore.plist index 26b434800b..aa02b53027 100644 --- a/DuckDuckGoVPN/Info-AppStore.plist +++ b/DuckDuckGoVPN/Info-AppStore.plist @@ -16,5 +16,7 @@ public.app-category.productivity CFBundleShortVersionString $(MARKETING_VERSION) + IPC_APP_GROUP + $(IPC_APP_GROUP) diff --git a/DuckDuckGoVPN/Info.plist b/DuckDuckGoVPN/Info.plist index 26b434800b..8e16c48168 100644 --- a/DuckDuckGoVPN/Info.plist +++ b/DuckDuckGoVPN/Info.plist @@ -4,8 +4,12 @@ DISTRIBUTED_NOTIFICATIONS_PREFIX $(DISTRIBUTED_NOTIFICATIONS_PREFIX) + IPC_APP_GROUP + $(IPC_APP_GROUP) NETP_APP_GROUP $(NETP_APP_GROUP) + SYSEX_BUNDLE_ID + $(SYSEX_BUNDLE_ID) PROXY_EXTENSION_BUNDLE_ID $(PROXY_EXTENSION_BUNDLE_ID) SUBSCRIPTION_APP_GROUP diff --git a/DuckDuckGoVPN/TunnelControllerIPCService.swift b/DuckDuckGoVPN/TunnelControllerIPCService.swift index c79aedc986..e8eeb5e68a 100644 --- a/DuckDuckGoVPN/TunnelControllerIPCService.swift +++ b/DuckDuckGoVPN/TunnelControllerIPCService.swift @@ -21,6 +21,8 @@ import Foundation import NetworkProtection import NetworkProtectionIPC import NetworkProtectionUI +import PixelKit +import UDSHelper /// Takes care of handling incoming IPC requests from clients that need to be relayed to the tunnel, and handling state /// changes that need to be relayed back to IPC clients. @@ -32,10 +34,12 @@ final class TunnelControllerIPCService { private let tunnelController: NetworkProtectionTunnelController private let networkExtensionController: NetworkExtensionController private let uninstaller: VPNUninstalling - private let server: NetworkProtectionIPC.TunnelControllerIPCServer + private let server: NetworkProtectionIPC.VPNControllerXPCServer private let statusReporter: NetworkProtectionStatusReporter private var cancellables = Set() private let defaults: UserDefaults + private let pixelKit: PixelKit? + private let udsServer: UDSServer enum IPCError: SilentErrorConvertible { case versionMismatched @@ -47,11 +51,35 @@ final class TunnelControllerIPCService { } } + enum UDSError: PixelKitEventV2 { + case udsServerStartFailure(_ error: Error) + + var name: String { + switch self { + case .udsServerStartFailure: + return "vpn_agent_uds_server_start_failure" + } + } + + var error: Error? { + switch self { + case .udsServerStartFailure(let error): + return error + } + } + + var parameters: [String: String]? { + return nil + } + } + init(tunnelController: NetworkProtectionTunnelController, uninstaller: VPNUninstalling, networkExtensionController: NetworkExtensionController, statusReporter: NetworkProtectionStatusReporter, - defaults: UserDefaults = .netP) { + fileManager: FileManager = .default, + defaults: UserDefaults = .netP, + pixelKit: PixelKit? = .shared) { self.tunnelController = tunnelController self.uninstaller = uninstaller @@ -59,6 +87,9 @@ final class TunnelControllerIPCService { server = .init(machServiceName: Bundle.main.bundleIdentifier!) self.statusReporter = statusReporter self.defaults = defaults + self.pixelKit = pixelKit + + udsServer = UDSServer(socketFileURL: VPNIPCResources.socketFileURL, log: .networkProtectionIPCLog) subscribeToErrorChanges() subscribeToStatusUpdates() @@ -71,6 +102,23 @@ final class TunnelControllerIPCService { public func activate() { server.activate() + + do { + try udsServer.start { [weak self] message in + guard let self else { return nil } + + let command = try JSONDecoder().decode(VPNIPCClientCommand.self, from: message) + + switch command { + case .uninstall(let component): + try await uninstall(component) + return nil + } + } + } catch { + pixelKit?.fire(UDSError.udsServerStartFailure(error)) + assertionFailure(error.localizedDescription) + } } private func subscribeToErrorChanges() { @@ -121,7 +169,7 @@ final class TunnelControllerIPCService { // MARK: - Requests from the client -extension TunnelControllerIPCService: IPCServerInterface { +extension TunnelControllerIPCService: XPCServerInterface { func register(completion: @escaping (Error?) -> Void) { register(version: version, bundlePath: bundlePath, completion: completion) @@ -183,7 +231,7 @@ extension TunnelControllerIPCService: IPCServerInterface { switch command { case .removeSystemExtension: - try await uninstaller.removeSystemExtension() + try await uninstall(.systemExtension) case .expireRegistrationKey: // Intentional no-op: handled by the extension break @@ -191,14 +239,25 @@ extension TunnelControllerIPCService: IPCServerInterface { // Intentional no-op: handled by the extension break case .removeVPNConfiguration: - try await uninstaller.removeVPNConfiguration() + try await uninstall(.configuration) case .uninstallVPN: - try await uninstaller.uninstall(includingSystemExtension: true) + try await uninstall(.all) case .disableConnectOnDemandAndShutDown: // Not implemented on macOS yet break } } + + private func uninstall(_ component: VPNUninstallComponent) async throws { + switch component { + case .all: + try await uninstaller.uninstall(includingSystemExtension: true) + case .configuration: + try await uninstaller.removeVPNConfiguration() + case .systemExtension: + try await uninstaller.removeSystemExtension() + } + } } // MARK: - Error Handling diff --git a/LocalPackages/AppLauncher/Sources/AppLauncher/AppLauncher.swift b/LocalPackages/AppLauncher/Sources/AppLauncher/AppLauncher.swift index c09cb03940..8dee99d87d 100644 --- a/LocalPackages/AppLauncher/Sources/AppLauncher/AppLauncher.swift +++ b/LocalPackages/AppLauncher/Sources/AppLauncher/AppLauncher.swift @@ -21,6 +21,7 @@ import Foundation public protocol AppLaunching { func launchApp(withCommand command: AppLaunchCommand) async throws + func runApp(withCommand command: AppLaunchCommand) async throws -> NSRunningApplication } /// Launches the main App @@ -51,6 +52,13 @@ public final class AppLauncher: AppLaunching { } public func launchApp(withCommand command: AppLaunchCommand) async throws { + _ = try await runApp(withCommand: command) + } + + /// The only difference with launchApp is this method returns the `NSRunningApplication` + /// + public func runApp(withCommand command: AppLaunchCommand) async throws -> NSRunningApplication { + let configuration = NSWorkspace.OpenConfiguration() configuration.allowsRunningApplicationSubstitution = command.allowsRunningApplicationSubstitution @@ -68,7 +76,9 @@ public final class AppLauncher: AppLaunching { do { if let launchURL = command.launchURL { - try await NSWorkspace.shared.open([launchURL], withApplicationAt: mainBundleURL, configuration: configuration) + return try await NSWorkspace.shared.open([launchURL], withApplicationAt: mainBundleURL, configuration: configuration) + } else { + return try await NSWorkspace.shared.openApplication(at: mainBundleURL, configuration: configuration) } } catch { throw AppLaunchError.workspaceOpenError(error) diff --git a/LocalPackages/NetworkProtectionMac/Package.swift b/LocalPackages/NetworkProtectionMac/Package.swift index ed49843a93..a87366d64a 100644 --- a/LocalPackages/NetworkProtectionMac/Package.swift +++ b/LocalPackages/NetworkProtectionMac/Package.swift @@ -35,6 +35,7 @@ let package = Package( .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "153.0.0"), .package(url: "https://github.com/airbnb/lottie-spm", exact: "4.4.1"), .package(path: "../AppLauncher"), + .package(path: "../UDSHelper"), .package(path: "../XPCHelper"), .package(path: "../SwiftUIExtensions"), .package(path: "../LoginItems"), @@ -47,6 +48,7 @@ let package = Package( dependencies: [ .product(name: "NetworkProtection", package: "BrowserServicesKit"), .product(name: "XPCHelper", package: "XPCHelper"), + .product(name: "UDSHelper", package: "UDSHelper"), .product(name: "PixelKit", package: "BrowserServicesKit"), ], swiftSettings: [ diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerIPCClient.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerIPCClient.swift new file mode 100644 index 0000000000..657a920666 --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerIPCClient.swift @@ -0,0 +1,26 @@ +// +// VPNControllerIPCClient.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 NetworkProtection + +// Base protocol for any IPC server we implement. +// +public protocol VPNControllerIPCClient { + + func uninstall(_ component: VPNUninstallComponent) async throws +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerUDSClient.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerUDSClient.swift new file mode 100644 index 0000000000..4a04409fff --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerUDSClient.swift @@ -0,0 +1,38 @@ +// +// VPNControllerUDSClient.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 UDSHelper + +public final class VPNControllerUDSClient { + + private let udsClient: UDSClient + private let encoder = JSONEncoder() + + public init(udsClient: UDSClient) { + self.udsClient = udsClient + } +} + +extension VPNControllerUDSClient: VPNControllerIPCClient { + + public func uninstall(_ component: VPNUninstallComponent) async throws { + let payload = try encoder.encode(VPNIPCClientCommand.uninstall(component)) + try await udsClient.send(payload) + } +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerXPCClient.swift similarity index 84% rename from LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift rename to LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerXPCClient.swift index 84a68750d0..214f08f9e5 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerXPCClient.swift @@ -1,5 +1,5 @@ // -// TunnelControllerIPCClient.swift +// VPNControllerXPCClient.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -22,7 +22,7 @@ import XPCHelper /// This protocol describes the client-side IPC interface for controlling the tunnel /// -public protocol IPCClientInterface: AnyObject { +public protocol XPCClientInterface: AnyObject { func errorChanged(_ error: String?) func serverInfoChanged(_ serverInfo: NetworkProtectionStatusServerInfo) func statusChanged(_ status: ConnectionStatus) @@ -32,7 +32,7 @@ public protocol IPCClientInterface: AnyObject { /// This is the XPC interface with parameters that can be packed properly @objc -protocol XPCClientInterface { +protocol XPCClientInterfaceObjC { func errorChanged(error: String?) func serverInfoChanged(payload: Data) func statusChanged(payload: Data) @@ -40,11 +40,11 @@ protocol XPCClientInterface { func knownFailureUpdated(failure: KnownFailure?) } -public final class TunnelControllerIPCClient { +public final class VPNControllerXPCClient { // MARK: - XPC Communication - let xpc: XPCClient + let xpc: XPCClient // MARK: - Observers offered @@ -56,7 +56,7 @@ public final class TunnelControllerIPCClient { /// The delegate. /// - public weak var clientDelegate: IPCClientInterface? { + public weak var clientDelegate: XPCClientInterface? { didSet { xpcDelegate.clientDelegate = self.clientDelegate } @@ -65,8 +65,8 @@ public final class TunnelControllerIPCClient { private let xpcDelegate: TunnelControllerXPCClientDelegate public init(machServiceName: String) { - let clientInterface = NSXPCInterface(with: XPCClientInterface.self) - let serverInterface = NSXPCInterface(with: XPCServerInterface.self) + let clientInterface = NSXPCInterface(with: XPCClientInterfaceObjC.self) + let serverInterface = NSXPCInterface(with: XPCServerInterfaceObjC.self) self.xpcDelegate = TunnelControllerXPCClientDelegate( clientDelegate: self.clientDelegate, serverInfoObserver: self.serverInfoObserver, @@ -97,18 +97,28 @@ public final class TunnelControllerIPCClient { self.register { _ in } } + + /// Forces the XPC client status to be updated to disconnected. + /// + /// This is just used as a temporary mechanism to allow the main app to tell that the VPN has been disconnected + /// when it's uninstalled. You should not call this method directly or rely on this for other logic. This should be + /// replaced by status updates through XPC. + /// + public func forceStatusToDisconnected() { + xpcDelegate.statusChanged(status: .disconnected) + } } -private final class TunnelControllerXPCClientDelegate: XPCClientInterface { +private final class TunnelControllerXPCClientDelegate: XPCClientInterfaceObjC { - weak var clientDelegate: IPCClientInterface? + weak var clientDelegate: XPCClientInterface? let serverInfoObserver: ConnectionServerInfoObserverThroughIPC let connectionErrorObserver: ConnectionErrorObserverThroughIPC let connectionStatusObserver: ConnectionStatusObserverThroughIPC let dataVolumeObserver: DataVolumeObserverThroughIPC let knownFailureObserver: KnownFailureObserverThroughIPC - init(clientDelegate: IPCClientInterface?, + init(clientDelegate: XPCClientInterface?, serverInfoObserver: ConnectionServerInfoObserverThroughIPC, connectionErrorObserver: ConnectionErrorObserverThroughIPC, connectionStatusObserver: ConnectionStatusObserverThroughIPC, @@ -141,6 +151,10 @@ private final class TunnelControllerXPCClientDelegate: XPCClientInterface { return } + statusChanged(status: status) + } + + func statusChanged(status: ConnectionStatus) { connectionStatusObserver.publish(status) clientDelegate?.statusChanged(status) } @@ -162,7 +176,8 @@ private final class TunnelControllerXPCClientDelegate: XPCClientInterface { // MARK: - Outgoing communication to the server -extension TunnelControllerIPCClient: IPCServerInterface { +extension VPNControllerXPCClient: XPCServerInterface { + public func register(completion: @escaping (Error?) -> Void) { register(version: version, bundlePath: bundlePath, completion: self.onComplete(completion)) } @@ -220,3 +235,18 @@ extension TunnelControllerIPCClient: IPCServerInterface { } } } + +extension VPNControllerXPCClient: VPNControllerIPCClient { + + public func uninstall(_ component: VPNUninstallComponent) async throws { + switch component { + case .all: + try await self.command(.uninstallVPN) + case .configuration: + try await self.command(.removeVPNConfiguration) + case .systemExtension: + try await self.command(.removeSystemExtension) + } + + } +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerXPCServer.swift similarity index 93% rename from LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift rename to LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerXPCServer.swift index 576e270415..58d3a5f4fa 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNControllerXPCServer.swift @@ -1,5 +1,5 @@ // -// TunnelControllerIPCServer.swift +// VPNControllerXPCServer.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -22,7 +22,7 @@ import XPCHelper /// This protocol describes the server-side IPC interface for controlling the tunnel /// -public protocol IPCServerInterface: AnyObject { +public protocol XPCServerInterface: AnyObject { var version: String { get } var bundlePath: String { get } @@ -59,7 +59,7 @@ public protocol IPCServerInterface: AnyObject { func command(_ command: VPNCommand) async throws } -public extension IPCServerInterface { +public extension XPCServerInterface { var version: String { DefaultIPCMetadataCollector.version } var bundlePath: String { DefaultIPCMetadataCollector.bundlePath } } @@ -70,7 +70,7 @@ public extension IPCServerInterface { /// calls to the IPC interface when appropriate. /// @objc -protocol XPCServerInterface { +protocol XPCServerInterfaceObjC { /// Registers a connection with the server. /// /// This is the point where the server will start sending status updates to the client. @@ -96,8 +96,8 @@ protocol XPCServerInterface { func command(_ payload: Data, completion: @escaping (Error?) -> Void) } -public final class TunnelControllerIPCServer { - let xpc: XPCServer +public final class VPNControllerXPCServer { + let xpc: XPCServer enum IPCError: Error { case cannotDecodeDebugCommand @@ -105,11 +105,11 @@ public final class TunnelControllerIPCServer { /// The delegate. /// - public weak var serverDelegate: IPCServerInterface? + public weak var serverDelegate: XPCServerInterface? public init(machServiceName: String) { - let clientInterface = NSXPCInterface(with: XPCClientInterface.self) - let serverInterface = NSXPCInterface(with: XPCServerInterface.self) + let clientInterface = NSXPCInterface(with: XPCClientInterfaceObjC.self) + let serverInterface = NSXPCInterface(with: XPCServerInterfaceObjC.self) xpc = XPCServer( machServiceName: machServiceName, @@ -126,7 +126,7 @@ public final class TunnelControllerIPCServer { // MARK: - Outgoing communication to the clients -extension TunnelControllerIPCServer: IPCClientInterface { +extension VPNControllerXPCServer: XPCClientInterface { public func errorChanged(_ error: String?) { xpc.forEachClient { client in @@ -187,7 +187,8 @@ extension TunnelControllerIPCServer: IPCClientInterface { // MARK: - Incoming communication from a client -extension TunnelControllerIPCServer: XPCServerInterface { +extension VPNControllerXPCServer: XPCServerInterfaceObjC { + func register(completion: @escaping (Error?) -> Void) { serverDelegate?.register(completion: completion) } diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNIPCClientCommand.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNIPCClientCommand.swift new file mode 100644 index 0000000000..ec541efbc1 --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/VPNIPCClientCommand.swift @@ -0,0 +1,29 @@ +// +// VPNIPCClientCommand.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 + +public enum VPNUninstallComponent: Codable { + case all + case configuration + case systemExtension +} + +public enum VPNIPCClientCommand: Codable { + case uninstall(_ component: VPNUninstallComponent) +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerView.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerView.swift index c4ede6adeb..84db127c57 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerView.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerView.swift @@ -223,7 +223,7 @@ public struct TunnelControllerView: View { loopStartFrame: 130, loopEndFrame: 370 ), isAnimating: $model.isVPNEnabled) -} + } @ViewBuilder private func statusBadge(isConnected: Bool) -> some View { diff --git a/LocalPackages/UDSHelper/.gitignore b/LocalPackages/UDSHelper/.gitignore new file mode 100644 index 0000000000..0023a53406 --- /dev/null +++ b/LocalPackages/UDSHelper/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/LocalPackages/UDSHelper/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/LocalPackages/UDSHelper/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/LocalPackages/UDSHelper/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/LocalPackages/UDSHelper/Package.swift b/LocalPackages/UDSHelper/Package.swift new file mode 100644 index 0000000000..954c358c88 --- /dev/null +++ b/LocalPackages/UDSHelper/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "UDSHelper", + platforms: [ + .macOS("11.4") + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "UDSHelper", + targets: ["UDSHelper"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "UDSHelper"), + .testTarget( + name: "UDSHelperTests", + dependencies: ["UDSHelper"]), + ] +) diff --git a/LocalPackages/UDSHelper/Sources/UDSHelper/UDSClient.swift b/LocalPackages/UDSHelper/Sources/UDSHelper/UDSClient.swift new file mode 100644 index 0000000000..023f350761 --- /dev/null +++ b/LocalPackages/UDSHelper/Sources/UDSHelper/UDSClient.swift @@ -0,0 +1,236 @@ +// +// UDSClient.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 Network +// swiftlint:disable:next enforce_os_log_wrapper +import os.log + +public actor UDSClient { + + public typealias PayloadHandler = (Data) async throws -> Void + + enum ConnectionError: Error { + case cancelled + case failure(_ error: Error) + } + + private var internalConnection: NWConnection? + private let socketFileURL: URL + private let receiver: UDSReceiver + private let queue = DispatchQueue(label: "com.duckduckgo.UDSConnection.queue.\(UUID().uuidString)") + private let log: OSLog + private let payloadHandler: PayloadHandler? + + // MARK: - Message completion callbacks + + typealias Callback = (Data?) async -> Void + + private var responseCallbacks = [UUID: Callback]() + + // MARK: - Initializers + + /// This should not be called directly because the socketFileURL needs to comply with some requirements in terms of + /// maximum length of the path. Use any public factory method provided below instead. + /// + public init(socketFileURL: URL, + log: OSLog, + payloadHandler: PayloadHandler? = nil) { + + os_log("UDSClient - Initialized with path: %{public}@", log: log, type: .info, socketFileURL.path) + + self.receiver = UDSReceiver(log: log) + self.socketFileURL = socketFileURL + self.log = log + self.payloadHandler = payloadHandler + } + + // MARK: - Connection Management + + private func connection() async throws -> NWConnection { + guard let internalConnection, + internalConnection.state == .ready else { + + return try await connect() + } + + return internalConnection + } + + /// Establishes a new connection + /// + private func connect() async throws -> NWConnection { + let endpoint = NWEndpoint.unix(path: socketFileURL.path) + let parameters = NWParameters.tcp + let connection = NWConnection(to: endpoint, using: parameters) + + connection.stateUpdateHandler = { state in + Task { + await self.statusUpdateHandler(state) + } + } + + startReceivingMessages(on: connection) + + internalConnection = connection + connection.start(queue: queue) + + while connection.state != .ready { + switch connection.state { + case .cancelled: + throw ConnectionError.cancelled + case .failed(let error): + throw ConnectionError.failure(error) + default: + try await Task.sleep(nanoseconds: 200 * MSEC_PER_SEC) + } + } + + return connection + } + + private func statusUpdateHandler(_ state: NWConnection.State) { + switch state { + case .cancelled: + os_log("UDSClient - Connection cancelled", log: self.log, type: .info) + + self.releaseConnection() + case .failed(let error): + os_log("UDSClient - Connection failed with error: %{public}@", log: self.log, type: .error, String(describing: error)) + + self.releaseConnection() + case .ready: + os_log("UDSClient - Connection ready", log: self.log, type: .info) + case .waiting(let error): + os_log("UDSClient - Waiting to connect... %{public}@", log: self.log, type: .info, String(describing: error)) + default: + os_log("UDSClient - Unexpected state", log: self.log, type: .info) + } + } + + private func releaseConnection() { + internalConnection?.stateUpdateHandler = nil + internalConnection = nil + } + + // MARK: - Sending commands + + @discardableResult + public func send(_ payload: Data) async throws -> Data? { + let uuid = UUID() + let message = UDSMessage(uuid: uuid, body: .request(payload)) + + return try await send(message) + } + + private func send(_ message: UDSMessage) async throws -> Data? { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + + Task { + await send(message) { result in + switch result { + case .success(let data): + continuation.resume(returning: data) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + } + + private func send(_ message: UDSMessage, completion: @escaping (Result) async -> Void) async { + + do { + let data = try JSONEncoder().encode(message) + let lengthData = withUnsafeBytes(of: UDSMessageLength(data.count)) { + Data($0) + } + let payload = lengthData + data + let connection = try await connection() + + assert(responseCallbacks[message.uuid] == nil) + responseCallbacks[message.uuid] = { (data: Data?) in + await completion(.success(data)) + } + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: payload, completion: .contentProcessed { error in + if let error { + os_log("UDSClient - Send Error %{public}@", log: self.log, String(describing: error)) + continuation.resume(throwing: error) + return + } + + os_log("UDSClient - Send Success", log: self.log) + continuation.resume() + }) + } + } catch { + responseCallbacks.removeValue(forKey: message.uuid) + await completion(.failure(error)) + } + } + + /// Starts receiveing messages for a specific connection + /// + /// - Parameters: + /// - connection: the connection to receive messages for. + /// + private func startReceivingMessages(on connection: NWConnection) { + + receiver.startReceivingMessages(on: connection) { [weak self] message in + guard let self else { return false } + + switch message.body { + case .request(let payload): + try await payloadHandler?(payload) + case .response(let response): + await handleResponse(uuid: message.uuid, response: response, on: connection) + } + + return true + } onError: { [weak self] _ in + guard let self else { return false } + await self.closeConnection(connection) + return false + } + } + + private func handleResponse(uuid: UUID, response: UDSMessageResponse, on connection: NWConnection) async { + guard let callback = responseCallbacks[uuid] else { + return + } + + responseCallbacks.removeValue(forKey: uuid) + + switch response { + case .success(let data): + await callback(data) + case .failure: + await callback(nil) + } + + return + } + + private func closeConnection(_ connection: NWConnection) { + internalConnection?.cancel() + internalConnection = nil + } +} diff --git a/LocalPackages/UDSHelper/Sources/UDSHelper/UDSMessage.swift b/LocalPackages/UDSHelper/Sources/UDSHelper/UDSMessage.swift new file mode 100644 index 0000000000..2eb4117390 --- /dev/null +++ b/LocalPackages/UDSHelper/Sources/UDSHelper/UDSMessage.swift @@ -0,0 +1,38 @@ +// +// UDSMessage.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 + +public enum UDSMessageResponse: Codable { + case success(_ data: Data?) + case failure +} + +public enum UDSMessageBody: Codable { + case request(_ data: Data) + case response(_ response: UDSMessageResponse) +} + +public struct UDSMessage: Codable { + public let uuid: UUID + public let body: UDSMessageBody + + public func successResponse(withPayload payload: Data?) -> UDSMessage { + UDSMessage(uuid: uuid, body: .response(.success(payload))) + } +} diff --git a/LocalPackages/UDSHelper/Sources/UDSHelper/UDSReceiver.swift b/LocalPackages/UDSHelper/Sources/UDSHelper/UDSReceiver.swift new file mode 100644 index 0000000000..8d82a4ecdb --- /dev/null +++ b/LocalPackages/UDSHelper/Sources/UDSHelper/UDSReceiver.swift @@ -0,0 +1,188 @@ +// +// UDSReceiver.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 Network +// swiftlint:disable:next enforce_os_log_wrapper +import os.log + +typealias UDSMessageLength = UInt16 + +struct UDSReceiver { + + /// The return value allows the callback handler to continue receiving messages (if it returns `true`) + /// or stop receiving messages (when it returns `false`). + /// + typealias MessageHandler = (UDSMessage) async throws -> Bool + + enum ReadError: Error { + case notEnoughData(expected: Int, received: Int) + case connectionError(_ error: Error) + case connectionClosed + } + + private let log: OSLog + + init(log: OSLog) { + self.log = log + } + + /// Starts receiveing messages for a specific connection + /// + /// - Parameters: + /// - connection: the connection to receive messages for. + /// - messageHandler: the callback for important events. + /// + func startReceivingMessages(on connection: NWConnection, messageHandler: @escaping MessageHandler, onError errorHandler: @escaping (Error) async -> Bool) { + Task { + await runReceiveMessageLoop(on: connection, messageHandler: messageHandler, onError: errorHandler) + } + } + + // swiftlint:disable:next cyclomatic_complexity + private func runReceiveMessageLoop(on connection: NWConnection, messageHandler: @escaping MessageHandler, onError errorHandler: @escaping (Error) async -> Bool) async { + + while true { + do { + let length = try await receiveMessageLength(on: connection) + let message = try await receiveEncodedObjectData(ofLength: length, on: connection) + + guard try await messageHandler(message) else { + return + } + } catch { + switch error { + case ReadError.notEnoughData(let expected, let received): + os_log("UDSServer - Connection closing due to error: Not enough data (expected: %{public}@, received: %{public}@", + log: log, + type: .error, + String(describing: expected), + String(describing: received)) + + guard await errorHandler(error) else { + return + } + case ReadError.connectionError(let error): + os_log("UDSServer - Connection closing due to a connection error: %{public}@", + log: log, + type: .error, + String(describing: error)) + + guard await errorHandler(error) else { + return + } + case ReadError.connectionClosed: + os_log("UDSServer - Connection closing: End of file reached", + log: log, + type: .info) + + guard await errorHandler(error) else { + return + } + default: + os_log("UDSServer - Connection closing due to error: %{public}@", + log: log, + type: .error, + String(describing: error)) + + guard await errorHandler(error) else { + return + } + } + } + } + } + + /// Receives the length value for the next message in the data stream. + /// + /// - Parameters: + /// - connection: the connection through which we're receveing messages + /// + /// - Returns: the length of the next message + /// + private func receiveMessageLength(on connection: NWConnection) async throws -> UDSMessageLength { + try await withCheckedThrowingContinuation { continuation in + let messageLengthMemorySize = MemoryLayout.size + + connection.receive(minimumIncompleteLength: messageLengthMemorySize, maximumLength: messageLengthMemorySize) { (data, _, isComplete, error) in + + if let data = data { + guard data.count == messageLengthMemorySize else { + continuation.resume(throwing: ReadError.notEnoughData(expected: messageLengthMemorySize, received: data.count)) + return + } + + let length = data.withUnsafeBytes { $0.load(as: UDSMessageLength.self) } + continuation.resume(returning: length) + } + + if let error { + continuation.resume(throwing: ReadError.connectionError(error)) + return + } + + guard !isComplete else { + continuation.resume(throwing: ReadError.connectionClosed) + return + } + } + } + } + + /// Decodes an incoming message. + /// + /// - Parameters: + /// - length: the length of the data that represents the next message. + /// - connection: the connection through which we're receiving the message. + /// + /// - Returns: a message on success. + /// + private func receiveEncodedObjectData(ofLength length: UDSMessageLength, on connection: NWConnection) async throws -> UDSMessage { + try await withCheckedThrowingContinuation { continuation in + connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { (data, _, isComplete, error) in + if let data = data { + guard data.count == length else { + continuation.resume(throwing: ReadError.notEnoughData(expected: Int(length), received: data.count)) + return + } + + do { + let message = try self.decodeMessage(from: data) + continuation.resume(returning: message) + } catch { + continuation.resume(throwing: error) + } + } + + if let error { + continuation.resume(throwing: ReadError.connectionError(error)) + return + } + + guard !isComplete else { + continuation.resume(throwing: ReadError.connectionClosed) + return + } + } + } + } + + private func decodeMessage(from data: Data) throws -> UDSMessage { + try JSONDecoder().decode(UDSMessage.self, from: data) + } +} diff --git a/LocalPackages/UDSHelper/Sources/UDSHelper/UDSServer.swift b/LocalPackages/UDSHelper/Sources/UDSHelper/UDSServer.swift new file mode 100644 index 0000000000..41812e5819 --- /dev/null +++ b/LocalPackages/UDSHelper/Sources/UDSHelper/UDSServer.swift @@ -0,0 +1,276 @@ +// +// UDSServer.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 Network +// swiftlint:disable:next enforce_os_log_wrapper +import os.log + +/// Convenience Hashable support for `NWConnection`, so we can use `Set` +/// +extension NWConnection: Hashable { + public static func == (lhs: NWConnection, rhs: NWConnection) -> Bool { + return lhs === rhs + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +/// An actor to manage client connections in a thread-safe manner. +/// +private actor ClientConnections { + var connections = Set() + + func forEach(perform closure: (NWConnection) -> Void) { + // Copy the set for looping so that operations that modify the set + // won't cause trouble. + let connections = connections + + for connection in connections { + closure(connection) + } + } + + func insert(_ connection: NWConnection) { + connections.insert(connection) + } + + func remove(_ connection: NWConnection) { + connections.remove(connection) + } + + func removeAll() { + connections = Set() + } +} + +/// Unix Domain Socket server +/// +public final class UDSServer { + private let listenerQueue = DispatchQueue(label: "com.duckduckgo.UDSServer.listenerQueue") + private let connectionQueue = DispatchQueue(label: "com.duckduckgo.UDSServer.connectionQueue") + + private var listener: NWListener? + private var connections = ClientConnections() + + private let receiver: UDSReceiver + + private let fileManager: FileManager + private let socketFileURL: URL + private let log: OSLog + + /// Default initializer + /// + /// - Parameters: + /// - socketFileDirectory: the directory where we want the socket file to be created. If you're planning + /// to share this socket with other apps in the same app group, this path should be in an app group + /// that both apps have access to. + /// - socketFileName: the name of the socket file + /// - log: the log to use + /// + public init(socketFileURL: URL, fileManager: FileManager = .default, log: OSLog) { + self.fileManager = fileManager + self.socketFileURL = socketFileURL + self.log = log + self.receiver = UDSReceiver(log: log) + + do { + try fileManager.removeItem(at: socketFileURL) + } catch { + print(error) + } + + os_log("UDSServer - Initialized with path: %{public}@", log: log, type: .info, socketFileURL.path) + } + + public func start(messageHandler: @escaping (Data) async throws -> Data?) throws { + let listener: NWListener + + do { + let params = NWParameters() + params.defaultProtocolStack.transportProtocol = NWProtocolTCP.Options() + params.requiredLocalEndpoint = NWEndpoint.unix(path: socketFileURL.path) + params.allowLocalEndpointReuse = true + // IMPORTANT: I'm leaving the following line commented because I want to document + // that enabling it seems to break the UDS listener completely. + // params.acceptLocalOnly = true + + listener = try NWListener(using: params) + self.listener = listener + } catch { + os_log("UDSServer - Error creating listener: %{public}@", + log: log, + type: .error, + String(describing: error)) + throw error + } + + listener.newConnectionHandler = { [weak self] connection in + self?.handleNewConnection(connection, messageHandler: messageHandler) + } + + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + + switch state { + case .ready: + os_log("UDSServer - Listener is ready", log: log, type: .info) + case .failed(let error): + os_log("UDSServer - Listener failed with error: %{public}@", log: log, type: .error, String(describing: error)) + stop() + case .cancelled: + os_log("UDSServer - Listener cancelled", log: log, type: .info) + default: + break + } + } + + listener.start(queue: listenerQueue) + } + + func stop() { + guard let listener else { + return + } + + listener.cancel() + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + + Task { + await stopConnections() + } + } + + private func stopConnections() async { + await connections.forEach { connection in + connection.cancel() + } + + await connections.removeAll() + } + + private func handleNewConnection(_ connection: NWConnection, messageHandler: @escaping (Data) async throws -> Data?) { + Task { + os_log("UDSServer - New connection: %{public}@", + log: log, + type: .info, + String(describing: connection.hashValue)) + + connection.stateUpdateHandler = { [weak self] state in + guard let self else { return } + + switch state { + case .ready: + os_log("UDSServer - Client connection is ready", log: log, type: .info) + self.startReceivingMessages(on: connection, messageHandler: messageHandler) + case .failed(let error): + os_log("UDSServer - Client connection failed with error: %{public}@", log: log, type: .error, String(describing: error)) + self.closeConnection(connection) + case .cancelled: + os_log("UDSServer - Client connection cancelled", log: log, type: .info) + default: + break + } + } + + await connections.insert(connection) + connection.start(queue: connectionQueue) + } + } + + private func closeAllConnections() { + Task { + await connections.forEach { connection in + connection.cancel() + } + + await connections.removeAll() + } + } + + private func closeConnection(_ connection: NWConnection) { + Task { + await self.connections.remove(connection) + connection.cancel() + } + } + + // - MARK: Data reception logic + + private enum ReadError: Error { + case notEnoughData(expected: Int, received: Int) + case connectionError(_ error: Error) + case connectionClosed + } + + /// Starts receiveing messages for a specific connection + /// + /// - Parameters: + /// - connection: the connection to receive messages for. + /// + private func startReceivingMessages(on connection: NWConnection, messageHandler: @escaping (Data) async throws -> Data?) { + + receiver.startReceivingMessages(on: connection) { [weak self] message in + guard let self else { return false } + + switch message.body { + case .request(let data): + let responsePayload = try await messageHandler(data) + let responseMessage = message.successResponse(withPayload: responsePayload) + try await self.send(responseMessage, connection: connection) + case .response: + // We still don't fully support server to client messages. This is the location where we'd + // add the handling for that. + // + // This will be useful if we, for example, want to move VPN status observation to UDS. + // + break + } + + return true + } onError: { [weak self] _ in + guard let self else { return false } + self.closeConnection(connection) + return false + } + } + + private func send(_ message: UDSMessage, connection: NWConnection) async throws { + + let data = try JSONEncoder().encode(message) + let lengthData = withUnsafeBytes(of: UDSMessageLength(data.count)) { + Data($0) + } + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: lengthData + data, completion: .contentProcessed { error in + if let error { + os_log("UDSServer - Send Error %{public}@", log: self.log, String(describing: error)) + continuation.resume(throwing: error) + return + } + + os_log("UDSServer - Send Success", log: self.log) + continuation.resume() + }) + } + } +} diff --git a/LocalPackages/UDSHelper/Tests/UDSHelperTests/UDSMessageTests.swift b/LocalPackages/UDSHelper/Tests/UDSHelperTests/UDSMessageTests.swift new file mode 100644 index 0000000000..8f59f38fe5 --- /dev/null +++ b/LocalPackages/UDSHelper/Tests/UDSHelperTests/UDSMessageTests.swift @@ -0,0 +1,46 @@ +// +// UDSMessageTests.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 XCTest +@testable import UDSHelper + +final class UDSMessageTests: XCTestCase { + func testExample() throws { + let uuid = UUID() + let requestData = "request".data(using: .utf8)! + let responseData = "response".data(using: .utf8)! + + let message = UDSMessage(uuid: uuid, body: .request(requestData)) + XCTAssertEqual(message.uuid, uuid) + + let response = message.successResponse(withPayload: responseData) + XCTAssertEqual(response.uuid, uuid) + + switch response.body { + case .request: + XCTFail("Expected a response body") + case .response(let result): + switch result { + case .success(let data): + XCTAssertEqual(data, responseData) + case .failure: + XCTFail("Expected a success response") + } + } + } +}