diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 6204a3116e..fb0d5386e6 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2311,6 +2311,10 @@ 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 */; }; + 7BBE650D2BC67BA0008F4EE9 /* NetworkProtectionIPCTunnelControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBE650C2BC67BA0008F4EE9 /* NetworkProtectionIPCTunnelControllerTests.swift */; }; + 7BBE650E2BC67BA0008F4EE9 /* NetworkProtectionIPCTunnelControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBE650C2BC67BA0008F4EE9 /* NetworkProtectionIPCTunnelControllerTests.swift */; }; + 7BBE65102BC67EED008F4EE9 /* NetworkProtectionTestingSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBE650F2BC67EED008F4EE9 /* NetworkProtectionTestingSupport.swift */; }; + 7BBE65112BC67EED008F4EE9 /* NetworkProtectionTestingSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBE650F2BC67EED008F4EE9 /* NetworkProtectionTestingSupport.swift */; }; 7BD01C192AD8319C0088B32E /* IPCServiceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD01C182AD8319C0088B32E /* IPCServiceManager.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 */; }; @@ -4135,6 +4139,8 @@ 7BB108582A43375D000AB95F /* PFMoveApplication.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PFMoveApplication.m; sourceTree = ""; }; 7BBA7CE52BAB03C1007579A3 /* DefaultSubscriptionFeatureAvailability+DefaultInitializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DefaultSubscriptionFeatureAvailability+DefaultInitializer.swift"; sourceTree = ""; }; 7BBD45B02A691AB500C83CA9 /* NetworkProtectionDebugUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionDebugUtilities.swift; sourceTree = ""; }; + 7BBE650C2BC67BA0008F4EE9 /* NetworkProtectionIPCTunnelControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionIPCTunnelControllerTests.swift; sourceTree = ""; }; + 7BBE650F2BC67EED008F4EE9 /* NetworkProtectionTestingSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionTestingSupport.swift; sourceTree = ""; }; 7BD01C182AD8319C0088B32E /* IPCServiceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPCServiceManager.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 = ""; }; @@ -6470,10 +6476,12 @@ 4BCF15E32ABB987F0083F6DF /* NetworkProtection */ = { isa = PBXGroup; children = ( + 7BBE65122BC67EF6008F4EE9 /* Support */, 4BCF15E62ABB98A20083F6DF /* Resources */, 4BCF15E42ABB98990083F6DF /* NetworkProtectionRemoteMessageTests.swift */, 4BD57C032AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift */, 7B09CBA72BA4BE7000CF245B /* NetworkProtectionPixelEventTests.swift */, + 7BBE650C2BC67BA0008F4EE9 /* NetworkProtectionIPCTunnelControllerTests.swift */, ); path = NetworkProtection; sourceTree = ""; @@ -6677,6 +6685,14 @@ path = LetsMove1.25; sourceTree = ""; }; + 7BBE65122BC67EF6008F4EE9 /* Support */ = { + isa = PBXGroup; + children = ( + 7BBE650F2BC67EED008F4EE9 /* NetworkProtectionTestingSupport.swift */, + ); + path = Support; + sourceTree = ""; + }; 7BDA36E72B7E037200AD5388 /* VPNProxyExtension */ = { isa = PBXGroup; children = ( @@ -11218,8 +11234,10 @@ B630E80129C887ED00363609 /* NSErrorAdditionalInfo.swift in Sources */, 3706FE31293F661700E42796 /* TabCollectionViewModelDelegateMock.swift in Sources */, 3706FE32293F661700E42796 /* BookmarksHTMLReaderTests.swift in Sources */, + 7BBE650E2BC67BA0008F4EE9 /* NetworkProtectionIPCTunnelControllerTests.swift in Sources */, 3706FE33293F661700E42796 /* FireTests.swift in Sources */, B60C6F8229B1B4AD007BFAA8 /* TestRunHelper.swift in Sources */, + 7BBE65112BC67EED008F4EE9 /* NetworkProtectionTestingSupport.swift in Sources */, 567DA94029E8045D008AC5EE /* MockEmailStorage.swift in Sources */, 317295D32AF058D3002C3206 /* MockWaitlistTermsAndConditionsActionHandler.swift in Sources */, 3706FE34293F661700E42796 /* PermissionStoreTests.swift in Sources */, @@ -13353,6 +13371,7 @@ 1D3B1AC22936B816006F4388 /* BWMessageIdGeneratorTests.swift in Sources */, B6C2C9F62760B659005B7F0A /* TestDataModel.xcdatamodeld in Sources */, 1DA6D1022A1FFA3700540406 /* HTTPCookieTests.swift in Sources */, + 7BBE65102BC67EED008F4EE9 /* NetworkProtectionTestingSupport.swift in Sources */, 1D9FDEC02B9B5FEA0040B78C /* AccessibilityPreferencesTests.swift in Sources */, B68172AE269EB43F006D1092 /* GeolocationServiceTests.swift in Sources */, B6AE74342609AFCE005B9B1A /* ProgressEstimationTests.swift in Sources */, @@ -13433,6 +13452,7 @@ 37CD54B927F1F8AC00F1F7B9 /* AppearancePreferencesTests.swift in Sources */, EEF53E182950CED5002D78F4 /* JSAlertViewModelTests.swift in Sources */, 376C4DB928A1A48A00CC0F5B /* FirePopoverViewModelTests.swift in Sources */, + 7BBE650D2BC67BA0008F4EE9 /* NetworkProtectionIPCTunnelControllerTests.swift in Sources */, AAEC74B62642CC6A00C2EFBC /* HistoryStoringMock.swift in Sources */, AA652CB125DD825B009059CC /* LocalBookmarkStoreTests.swift in Sources */, B630794226731F5400DCEE41 /* WKDownloadMock.swift in Sources */, diff --git a/DuckDuckGo/LoginItems/LoginItemsManager.swift b/DuckDuckGo/LoginItems/LoginItemsManager.swift index e0a44a4fb4..0cce2dc5c6 100644 --- a/DuckDuckGo/LoginItems/LoginItemsManager.swift +++ b/DuckDuckGo/LoginItems/LoginItemsManager.swift @@ -20,9 +20,13 @@ import Common import Foundation import LoginItems +protocol LoginItemsManaging { + func throwingEnableLoginItems(_ items: Set, log: OSLog) throws +} + /// Class to manage the login items for the VPN and DBP /// -final class LoginItemsManager { +final class LoginItemsManager: LoginItemsManaging { private enum Action: String { case enable case disable @@ -42,6 +46,20 @@ final class LoginItemsManager { } } + /// Throwing version of enableLoginItems + /// + func throwingEnableLoginItems(_ items: Set, log: OSLog) throws { + for item in items { + do { + try item.enable() + os_log("🟢 Enabled successfully %{public}@", log: log, String(describing: item)) + } catch let error as NSError { + handleError(for: item, action: .enable, error: error) + throw error + } + } + } + func restartLoginItems(_ items: Set, log: OSLog) { for item in items { do { diff --git a/DuckDuckGo/NavigationBar/View/NetPPopoverManagerMock.swift b/DuckDuckGo/NavigationBar/View/NetPPopoverManagerMock.swift index 0af23bdcd5..211dcc460d 100644 --- a/DuckDuckGo/NavigationBar/View/NetPPopoverManagerMock.swift +++ b/DuckDuckGo/NavigationBar/View/NetPPopoverManagerMock.swift @@ -18,6 +18,7 @@ #if DEBUG +import AppKit import Combine import Foundation import NetworkProtection @@ -63,9 +64,13 @@ final class IPCClientMock: NetworkProtectionIPCClient { } var ipcControllerErrorMessageObserver: any NetworkProtection.ControllerErrorMesssageObserver = ControllerErrorMesssageObserverMock() - func start() {} + func start(completion: @escaping (Error?) -> Void) { + completion(nil) + } - func stop() {} + func stop(completion: @escaping (Error?) -> Void) { + completion(nil) + } } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift index 7bb6fa1363..5d635f7ded 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift @@ -33,8 +33,8 @@ protocol NetworkProtectionIPCClient { var ipcServerInfoObserver: ConnectionServerInfoObserver { get } var ipcConnectionErrorObserver: ConnectionErrorObserver { get } - func start() - func stop() + func start(completion: @escaping (Error?) -> Void) + func stop(completion: @escaping (Error?) -> Void) } extension TunnelControllerIPCClient: NetworkProtectionIPCClient { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionIPCTunnelController.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionIPCTunnelController.swift index 5e2027a3dc..88a5fbfc94 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionIPCTunnelController.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionIPCTunnelController.swift @@ -20,47 +20,113 @@ import Common import Foundation import NetworkProtection import NetworkProtectionIPC +import PixelKit -final class NetworkProtectionIPCTunnelController: TunnelController { +/// VPN tunnel controller through IPC. +/// +final class NetworkProtectionIPCTunnelController { + + enum RequestError: CustomNSError { + case notAuthorizedToEnableLoginItem + case internalLoginItemError(_ error: Error) + + var errorCode: Int { + switch self { + case .notAuthorizedToEnableLoginItem: return 0 + case .internalLoginItemError: return 1 + } + } + + var errorUserInfo: [String: Any] { + switch self { + case .notAuthorizedToEnableLoginItem: + return [:] + case .internalLoginItemError(let error): + return [NSUnderlyingErrorKey: error as NSError] + } + } + } private let featureVisibility: NetworkProtectionFeatureVisibility - private let loginItemsManager: LoginItemsManager + private let loginItemsManager: LoginItemsManaging private let ipcClient: NetworkProtectionIPCClient + private let pixelKit: PixelFiring? init(featureVisibility: NetworkProtectionFeatureVisibility = DefaultNetworkProtectionVisibility(), - loginItemsManager: LoginItemsManager = LoginItemsManager(), - ipcClient: NetworkProtectionIPCClient) { + loginItemsManager: LoginItemsManaging = LoginItemsManager(), + ipcClient: NetworkProtectionIPCClient, + pixelKit: PixelFiring? = PixelKit.shared) { self.featureVisibility = featureVisibility self.loginItemsManager = loginItemsManager self.ipcClient = ipcClient + self.pixelKit = pixelKit } + // MARK: - Login Items Manager + + private func enableLoginItems() async throws { + guard try await featureVisibility.canStartVPN() else { + throw RequestError.notAuthorizedToEnableLoginItem + } + + do { + try loginItemsManager.throwingEnableLoginItems(LoginItemsManager.networkProtectionLoginItems, log: .networkProtection) + } catch { + throw RequestError.internalLoginItemError(error) + } + } +} + +// MARK: - TunnelController Conformance + +extension NetworkProtectionIPCTunnelController: TunnelController { + @MainActor func start() async { + pixelKit?.fire(StartAttempt.begin) + + func handleFailure(_ error: Error) { + log(error) + pixelKit?.fire(StartAttempt.failure(error), frequency: .dailyAndContinuous) + } + do { - guard try await enableLoginItems() else { - os_log("🔴 IPC Controller refusing to start the VPN menu app. Not authorized.", log: .networkProtection) - return - } + try await enableLoginItems() - ipcClient.start() + ipcClient.start { [pixelKit] error in + if let error { + handleFailure(error) + } else { + pixelKit?.fire(StartAttempt.success, frequency: .dailyAndContinuous) + } + } } catch { - os_log("🔴 IPC Controller found en error when starting the VPN: \(error)", log: .networkProtection) + handleFailure(error) } } @MainActor func stop() async { + pixelKit?.fire(StopAttempt.begin) + + func handleFailure(_ error: Error) { + log(error) + pixelKit?.fire(StopAttempt.failure(error), frequency: .dailyAndContinuous) + } + do { - guard try await enableLoginItems() else { - os_log("🔴 IPC Controller refusing to start the VPN. Not authorized.", log: .networkProtection) - return - } + try await enableLoginItems() - ipcClient.stop() + ipcClient.stop { [pixelKit] error in + if let error { + handleFailure(error) + } else { + pixelKit?.fire(StopAttempt.success, frequency: .dailyAndContinuous) + } + } } catch { - os_log("🔴 IPC Controller found en error when starting the VPN: \(error)", log: .networkProtection) + handleFailure(error) } } @@ -78,15 +144,90 @@ final class NetworkProtectionIPCTunnelController: TunnelController { } } - // MARK: - Login Items Manager + private func log(_ error: Error) { + switch error { + case RequestError.notAuthorizedToEnableLoginItem: + os_log("🔴 IPC Controller not authorized to enable the login item", log: .networkProtection) + case RequestError.internalLoginItemError(let error): + os_log("🔴 IPC Controller found an error while enabling the login item: \(error)", log: .networkProtection) + default: + os_log("🔴 IPC Controller found an unknown error: \(error)", log: .networkProtection) + } + } +} - private func enableLoginItems() async throws -> Bool { - guard try await featureVisibility.canStartVPN() else { - // We shouldn't enable the menu app is the VPN feature is disabled. - return false +// MARK: - Start Attempts + +extension NetworkProtectionIPCTunnelController { + + enum StartAttempt: PixelKitEventV2 { + case begin + case success + case failure(_ error: Error) + + var name: String { + switch self { + case .begin: + return "netp_browser_start_attempt" + + case .success: + return "netp_browser_start_success" + + case .failure: + return "netp_browser_start_failure" + } } - loginItemsManager.enableLoginItems(LoginItemsManager.networkProtectionLoginItems, log: .networkProtection) - return true + var parameters: [String: String]? { + return nil + } + + var error: Error? { + switch self { + case .begin, + .success: + return nil + case .failure(let error): + return error + } + } + } +} + +// MARK: - Stop Attempts + +extension NetworkProtectionIPCTunnelController { + + enum StopAttempt: PixelKitEventV2 { + case begin + case success + case failure(_ error: Error) + + var name: String { + switch self { + case .begin: + return "netp_browser_stop_attempt" + + case .success: + return "netp_browser_stop_success" + + case .failure: + return "netp_browser_stop_failure" + } + } + + var parameters: [String: String]? { + return nil + } + + var error: Error? { + switch self { + case .begin, + .success: + return nil + case .failure(let error): + return error + } + } } } diff --git a/DuckDuckGo/Waitlist/NetworkProtectionFeatureDisabler.swift b/DuckDuckGo/Waitlist/NetworkProtectionFeatureDisabler.swift index c04ed9c691..e6d085bc91 100644 --- a/DuckDuckGo/Waitlist/NetworkProtectionFeatureDisabler.swift +++ b/DuckDuckGo/Waitlist/NetworkProtectionFeatureDisabler.swift @@ -128,7 +128,9 @@ final class NetworkProtectionFeatureDisabler: NetworkProtectionFeatureDisabling } func stop() { - ipcClient.stop() + ipcClient.stop { _ in + // Intentional no-op + } } private func enableLoginItems() { diff --git a/DuckDuckGoVPN/TunnelControllerIPCService.swift b/DuckDuckGoVPN/TunnelControllerIPCService.swift index c8a9ce456d..e8df373d16 100644 --- a/DuckDuckGoVPN/TunnelControllerIPCService.swift +++ b/DuckDuckGoVPN/TunnelControllerIPCService.swift @@ -95,16 +95,26 @@ extension TunnelControllerIPCService: IPCServerInterface { server.statusChanged(statusReporter.statusObserver.recentValue) } - func start() { + func start(completion: @escaping (Error?) -> Void) { Task { await tunnelController.start() } + + // For IPC requests, completion means the IPC request was processed, and NOT + // that the requested operation was executed fully. Failure to complete the + // operation will be handled entirely within the tunnel controller. + completion(nil) } - func stop() { + func stop(completion: @escaping (Error?) -> Void) { Task { await tunnelController.stop() } + + // For IPC requests, completion means the IPC request was processed, and NOT + // that the requested operation was executed fully. Failure to complete the + // operation will be handled entirely within the tunnel controller. + completion(nil) } func resetAll(uninstallSystemExtension: Bool) async { diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift index 769939f65e..f5158c239b 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift @@ -144,22 +144,16 @@ extension TunnelControllerIPCClient: IPCServerInterface { }) } - public func start() { + public func start(completion: @escaping (Error?) -> Void) { xpc.execute(call: { server in - server.start() - }, xpcReplyErrorHandler: { _ in - // Intentional no-op as there's no completion block - // If you add a completion block, please remember to call it here too! - }) + server.start(completion: completion) + }, xpcReplyErrorHandler: completion) } - public func stop() { + public func stop(completion: @escaping (Error?) -> Void) { xpc.execute(call: { server in - server.stop() - }, xpcReplyErrorHandler: { _ in - // Intentional no-op as there's no completion block - // If you add a completion block, please remember to call it here too! - }) + server.stop(completion: completion) + }, xpcReplyErrorHandler: completion) } public func debugCommand(_ command: DebugCommand) async throws { diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift index 3fe62c74f3..f2a7adc439 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift @@ -31,11 +31,19 @@ public protocol IPCServerInterface: AnyObject { /// Start the VPN tunnel. /// - func start() + /// - Parameters: + /// - completion: the completion closure. This will be called as soon as the IPC request has been processed, and won't + /// signal successful completion of the request. + /// + func start(completion: @escaping (Error?) -> Void) /// Stop the VPN tunnel. /// - func stop() + /// - Parameters: + /// - completion: the completion closure. This will be called as soon as the IPC request has been processed, and won't + /// signal successful completion of the request. + /// + func stop(completion: @escaping (Error?) -> Void) /// Debug commands /// @@ -57,11 +65,11 @@ protocol XPCServerInterface { /// Start the VPN tunnel. /// - func start() + func start(completion: @escaping (Error?) -> Void) /// Stop the VPN tunnel. /// - func stop() + func stop(completion: @escaping (Error?) -> Void) /// Debug commands /// @@ -144,12 +152,12 @@ extension TunnelControllerIPCServer: XPCServerInterface { serverDelegate?.register() } - func start() { - serverDelegate?.start() + func start(completion: @escaping (Error?) -> Void) { + serverDelegate?.start(completion: completion) } - func stop() { - serverDelegate?.stop() + func stop(completion: @escaping (Error?) -> Void) { + serverDelegate?.stop(completion: completion) } func debugCommand(_ payload: Data, completion: @escaping (Error?) -> Void) { diff --git a/LocalPackages/PixelKit/Sources/PixelKit/PixelKit.swift b/LocalPackages/PixelKit/Sources/PixelKit/PixelKit.swift index f55ec17ba4..f4e6e89731 100644 --- a/LocalPackages/PixelKit/Sources/PixelKit/PixelKit.swift +++ b/LocalPackages/PixelKit/Sources/PixelKit/PixelKit.swift @@ -277,12 +277,14 @@ public final class PixelKit { let newError: Error? - if let event = event as? PixelKitEventV2 { + if let event = event as? PixelKitEventV2, + let error = event.error { + // For v2 events we only consider the error specified in the event // and purposedly ignore the parameter in this call. // This is to encourage moving the error over to the protocol error // instead of still relying on the parameter of this call. - newError = event.error + newError = error } else { newError = error } diff --git a/LocalPackages/PixelKit/Sources/PixelKit/PixelKitEventV2.swift b/LocalPackages/PixelKit/Sources/PixelKit/PixelKitEventV2.swift index 19525d0d0b..02a220eae8 100644 --- a/LocalPackages/PixelKit/Sources/PixelKit/PixelKitEventV2.swift +++ b/LocalPackages/PixelKit/Sources/PixelKit/PixelKitEventV2.swift @@ -30,3 +30,29 @@ import Foundation public protocol PixelKitEventV2: PixelKitEvent { var error: Error? { get } } + +/// Protocol to support mocking pixel firing. +/// +/// We're adding support for `PixelKitEventV2` events strategically because adding support for earlier pixels +/// would be more complicated and time consuming. The idea of V2 events is that fire calls should not include a lot +/// of parameters. Parameters should be provided by the `PixelKitEventV2` protocol (extending it if necessary) +/// and the call to `fire` should process those properties to serialize in the requests. +/// +public protocol PixelFiring { + func fire(_ event: PixelKitEventV2) + + func fire(_ event: PixelKitEventV2, + frequency: PixelKit.Frequency) +} + +extension PixelKit: PixelFiring { + public func fire(_ event: PixelKitEventV2) { + fire(event, frequency: .standard) + } + + public func fire(_ event: PixelKitEventV2, + frequency: PixelKit.Frequency) { + + fire(event, frequency: frequency, onComplete: { _, _ in }) + } +} diff --git a/LocalPackages/PixelKit/Sources/PixelKitTestingUtilities/PixelKitMock.swift b/LocalPackages/PixelKit/Sources/PixelKitTestingUtilities/PixelKitMock.swift new file mode 100644 index 0000000000..dd9bf5390f --- /dev/null +++ b/LocalPackages/PixelKit/Sources/PixelKitTestingUtilities/PixelKitMock.swift @@ -0,0 +1,66 @@ +// +// PixelKitMock.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import PixelKit +import XCTest + +public final class PixelKitMock: PixelFiring { + + /// An array of fire calls, in order, that this mock expects + /// + private let expectedFireCalls: [ExpectedFireCall] + + /// The actual fire calls + /// + private var actualFireCalls = [ExpectedFireCall]() + + public init(expecting expectedFireCalls: [ExpectedFireCall]) { + self.expectedFireCalls = expectedFireCalls + } + + public func fire(_ event: PixelKitEventV2) { + fire(event, frequency: .standard) + } + + public func fire(_ event: PixelKitEventV2, frequency: PixelKit.Frequency) { + let fireCall = ExpectedFireCall(pixel: event, frequency: frequency) + actualFireCalls.append(fireCall) + } + + public func verifyExpectations(file: StaticString, line: UInt) { + XCTAssertEqual(expectedFireCalls, actualFireCalls, file: file, line: line) + } +} + +public struct ExpectedFireCall: Equatable { + let pixel: PixelKitEventV2 + let frequency: PixelKit.Frequency + + public init(pixel: PixelKitEventV2, frequency: PixelKit.Frequency) { + self.pixel = pixel + self.frequency = frequency + } + + public static func == (lhs: ExpectedFireCall, rhs: ExpectedFireCall) -> Bool { + lhs.pixel.name == rhs.pixel.name + && lhs.pixel.parameters == rhs.pixel.parameters + && (lhs.pixel.error as? NSError) == (rhs.pixel.error as? NSError) + && lhs.frequency == rhs.frequency + } +} diff --git a/UnitTests/NetworkProtection/NetworkProtectionIPCTunnelControllerTests.swift b/UnitTests/NetworkProtection/NetworkProtectionIPCTunnelControllerTests.swift new file mode 100644 index 0000000000..0f7837391a --- /dev/null +++ b/UnitTests/NetworkProtection/NetworkProtectionIPCTunnelControllerTests.swift @@ -0,0 +1,138 @@ +// +// NetworkProtectionIPCTunnelControllerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import NetworkProtection +import PixelKit +import PixelKitTestingUtilities +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class NetworkProtectionIPCTunnelControllerTests: XCTestCase { + + // MARK: - Tunnel Start Tests + + func testStartTunnelSuccess() async { + let pixelKit = PixelKitMock(expecting: [ + .init(pixel: NetworkProtectionIPCTunnelController.StartAttempt.begin, frequency: .standard), + .init(pixel: NetworkProtectionIPCTunnelController.StartAttempt.success, frequency: .dailyAndContinuous) + ]) + let controller = NetworkProtectionIPCTunnelController( + featureVisibility: MockFeatureVisibility(), + loginItemsManager: MockLoginItemsManager(mockResult: .success), + ipcClient: MockIPCClient(), + pixelKit: pixelKit) + + await controller.start() + + pixelKit.verifyExpectations(file: #file, line: #line) + } + + func testStartTunnelLoginItemFailure() async { + let error = NSError(domain: "test", code: 1) + let expectedError = NetworkProtectionIPCTunnelController.RequestError.internalLoginItemError(error) + + let pixelKit = PixelKitMock(expecting: [ + .init(pixel: NetworkProtectionIPCTunnelController.StartAttempt.begin, frequency: .standard), + .init(pixel: NetworkProtectionIPCTunnelController.StartAttempt.failure(expectedError), frequency: .dailyAndContinuous) + ]) + + let controller = NetworkProtectionIPCTunnelController( + featureVisibility: MockFeatureVisibility(), + loginItemsManager: MockLoginItemsManager(mockResult: .failure(error)), + ipcClient: MockIPCClient(), + pixelKit: pixelKit) + + await controller.start() + + pixelKit.verifyExpectations(file: #file, line: #line) + } + + func testStartTunnelIPCFailure() async { + let error = NSError(domain: "test", code: 1) + let pixelKit = PixelKitMock(expecting: [ + .init(pixel: NetworkProtectionIPCTunnelController.StartAttempt.begin, frequency: .standard), + .init(pixel: NetworkProtectionIPCTunnelController.StartAttempt.failure(error), frequency: .dailyAndContinuous) + ]) + let controller = NetworkProtectionIPCTunnelController( + featureVisibility: MockFeatureVisibility(), + loginItemsManager: MockLoginItemsManager(mockResult: .success), + ipcClient: MockIPCClient(error: error), + pixelKit: pixelKit) + + await controller.start() + + pixelKit.verifyExpectations(file: #file, line: #line) + } + + // MARK: - Tunnel Stop Tests + + func testStopTunnelSuccess() async { + let pixelKit = PixelKitMock(expecting: [ + .init(pixel: NetworkProtectionIPCTunnelController.StopAttempt.begin, frequency: .standard), + .init(pixel: NetworkProtectionIPCTunnelController.StopAttempt.success, frequency: .dailyAndContinuous) + ]) + let controller = NetworkProtectionIPCTunnelController( + featureVisibility: MockFeatureVisibility(), + loginItemsManager: MockLoginItemsManager(mockResult: .success), + ipcClient: MockIPCClient(), + pixelKit: pixelKit) + + await controller.stop() + + pixelKit.verifyExpectations(file: #file, line: #line) + } + + func testStopTunnelLoginItemFailure() async { + let error = NSError(domain: "test", code: 1) + let expectedError = NetworkProtectionIPCTunnelController.RequestError.internalLoginItemError(error) + + let pixelKit = PixelKitMock(expecting: [ + .init(pixel: NetworkProtectionIPCTunnelController.StopAttempt.begin, frequency: .standard), + .init(pixel: NetworkProtectionIPCTunnelController.StopAttempt.failure(expectedError), frequency: .dailyAndContinuous) + ]) + + let controller = NetworkProtectionIPCTunnelController( + featureVisibility: MockFeatureVisibility(), + loginItemsManager: MockLoginItemsManager(mockResult: .failure(error)), + ipcClient: MockIPCClient(), + pixelKit: pixelKit) + + await controller.stop() + + pixelKit.verifyExpectations(file: #file, line: #line) + } + + func testStopTunnelIPCFailure() async { + let error = NSError(domain: "test", code: 1) + let pixelKit = PixelKitMock(expecting: [ + .init(pixel: NetworkProtectionIPCTunnelController.StopAttempt.begin, frequency: .standard), + .init(pixel: NetworkProtectionIPCTunnelController.StopAttempt.failure(error), frequency: .dailyAndContinuous) + ]) + let controller = NetworkProtectionIPCTunnelController( + featureVisibility: MockFeatureVisibility(), + loginItemsManager: MockLoginItemsManager(mockResult: .success), + ipcClient: MockIPCClient(error: error), + pixelKit: pixelKit) + + await controller.stop() + + pixelKit.verifyExpectations(file: #file, line: #line) + } +} diff --git a/UnitTests/NetworkProtection/Support/NetworkProtectionTestingSupport.swift b/UnitTests/NetworkProtection/Support/NetworkProtectionTestingSupport.swift new file mode 100644 index 0000000000..e298ad553d --- /dev/null +++ b/UnitTests/NetworkProtection/Support/NetworkProtectionTestingSupport.swift @@ -0,0 +1,146 @@ +// +// NetworkProtectionTestingSupport.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Common +import Foundation +import LoginItems +import NetworkProtection +import NetworkProtectionUI +@testable import DuckDuckGo_Privacy_Browser + +struct MockFeatureVisibility: NetworkProtectionFeatureVisibility { + let isEligibleForThankYouMessage: Bool + let isInstalled: Bool + let canStartVPNValue: Bool + let isVPNVisibleValue: Bool + let isNetworkProtectionBetaVisibleValue: Bool + let shouldUninstallAutomaticallyValue: Bool + let disableIfUserHasNoAccessValue: Bool + + func canStartVPN() async throws -> Bool { + canStartVPNValue + } + + func isVPNVisible() -> Bool { + isVPNVisibleValue + } + + func isNetworkProtectionBetaVisible() -> Bool { + isNetworkProtectionBetaVisibleValue + } + + func shouldUninstallAutomatically() -> Bool { + shouldUninstallAutomaticallyValue + } + + func disableForAllUsers() async { + // Intentional no-op + } + + func disableForWaitlistUsers() { + // Intentional no-op + } + + func disableIfUserHasNoAccess() async -> Bool { + disableIfUserHasNoAccessValue + } + + let onboardStatusPublisher: AnyPublisher + + init(isEligibleForThankYouMessage: Bool = false, + isInstalled: Bool = false, + canStartVPNValue: Bool = true, + isVPNVisibleValue: Bool = true, + isNetworkProtectionBetaVisibleValue: Bool = false, + shouldUninstallAutomaticallyValue: Bool = false, + disableIfUserHasNoAccessValue: Bool = false, + onboardStatusPublisher: AnyPublisher = Just(.default).eraseToAnyPublisher()) { + + self.isEligibleForThankYouMessage = isEligibleForThankYouMessage + self.isInstalled = isInstalled + self.canStartVPNValue = canStartVPNValue + self.isVPNVisibleValue = isVPNVisibleValue + self.isNetworkProtectionBetaVisibleValue = isNetworkProtectionBetaVisibleValue + self.shouldUninstallAutomaticallyValue = shouldUninstallAutomaticallyValue + self.disableIfUserHasNoAccessValue = disableIfUserHasNoAccessValue + self.onboardStatusPublisher = onboardStatusPublisher + } +} + +struct MockConnectionStatusObserver: ConnectionStatusObserver { + var publisher: AnyPublisher = Just(.disconnected).eraseToAnyPublisher() + + var recentValue: NetworkProtection.ConnectionStatus = .disconnected +} + +struct MockServerInfoObserver: ConnectionServerInfoObserver { + var publisher: AnyPublisher = Just(.unknown).eraseToAnyPublisher() + + var recentValue: NetworkProtection.NetworkProtectionStatusServerInfo = .unknown +} + +struct MockConnectionErrorObserver: ConnectionErrorObserver { + var publisher: AnyPublisher = Just(nil).eraseToAnyPublisher() + + var recentValue: String? +} + +struct MockIPCClient: NetworkProtectionIPCClient { + + private let error: Error? + + var ipcStatusObserver: NetworkProtection.ConnectionStatusObserver = MockConnectionStatusObserver() + var ipcServerInfoObserver: NetworkProtection.ConnectionServerInfoObserver = MockServerInfoObserver() + var ipcConnectionErrorObserver: NetworkProtection.ConnectionErrorObserver = MockConnectionErrorObserver() + + init(error: Error? = nil) { + self.error = error + } + + func start(completion: @escaping (Error?) -> Void) { + completion(error) + } + + func stop(completion: @escaping (Error?) -> Void) { + completion(error) + } +} + +struct MockLoginItemsManager: LoginItemsManaging { + + enum MockResult { + case success + case failure(_ error: Error) + } + + private let mockResult: MockResult + + init(mockResult: MockResult) { + self.mockResult = mockResult + } + + func throwingEnableLoginItems(_ items: Set, log: Common.OSLog) throws { + switch mockResult { + case .success: + return + case .failure(let error): + throw error + } + } +}