diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 7b92881927..5131c32908 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10182,7 +10182,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 173.0.0; + version = 174.0.0; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5720e2d319..cfa33a3392 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "16686ec1d3a8641b47c55d5271e29c7dbe4c9e73", - "version" : "173.0.0" + "revision" : "6db80afec11da4f0a36a81dc6030f7e83a524c87", + "version" : "174.0.0" } }, { @@ -138,7 +138,7 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", "version" : "1.4.0" diff --git a/DuckDuckGo/AppDependencyProvider.swift b/DuckDuckGo/AppDependencyProvider.swift index 228093422b..fea06bf74e 100644 --- a/DuckDuckGo/AppDependencyProvider.swift +++ b/DuckDuckGo/AppDependencyProvider.swift @@ -47,6 +47,7 @@ protocol DependencyProvider { var networkProtectionKeychainTokenStore: NetworkProtectionKeychainTokenStore { get } var networkProtectionTunnelController: NetworkProtectionTunnelController { get } var connectionObserver: ConnectionStatusObserver { get } + var serverInfoObserver: ConnectionServerInfoObserver { get } var vpnSettings: VPNSettings { get } } @@ -88,6 +89,7 @@ class AppDependencyProvider: DependencyProvider { let subscriptionAppGroup = Bundle.main.appGroup(bundle: .subs) let connectionObserver: ConnectionStatusObserver = ConnectionStatusObserverThroughSession() + let serverInfoObserver: ConnectionServerInfoObserver = ConnectionServerInfoObserverThroughSession() let vpnSettings = VPNSettings(defaults: .networkProtectionGroupDefaults) init() { diff --git a/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift b/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift index cfa8429d58..0840e6fe1b 100644 --- a/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift +++ b/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift @@ -28,7 +28,7 @@ import Subscription private class DefaultTunnelSessionProvider: TunnelSessionProvider { func activeSession() async -> NETunnelProviderSession? { - try? await ConnectionSessionUtilities.activeSession() + return await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() } } diff --git a/DuckDuckGo/NetworkProtectionDebugUtilities.swift b/DuckDuckGo/NetworkProtectionDebugUtilities.swift index 22a16a2b83..0186856979 100644 --- a/DuckDuckGo/NetworkProtectionDebugUtilities.swift +++ b/DuckDuckGo/NetworkProtectionDebugUtilities.swift @@ -31,7 +31,7 @@ final class NetworkProtectionDebugUtilities { // MARK: - Registation Key func expireRegistrationKeyNow() async { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() else { return } @@ -41,7 +41,7 @@ final class NetworkProtectionDebugUtilities { // MARK: - Notifications func sendTestNotificationRequest() async throws { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() else { return } @@ -51,7 +51,7 @@ final class NetworkProtectionDebugUtilities { // MARK: - Disable VPN func disableConnectOnDemandAndShutDown() async { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() else { return } @@ -61,7 +61,7 @@ final class NetworkProtectionDebugUtilities { // MARK: - Failure Simulation func triggerSimulation(_ option: NetworkProtectionSimulationOption) async { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() else { return } diff --git a/DuckDuckGo/NetworkProtectionRootView.swift b/DuckDuckGo/NetworkProtectionRootView.swift index bdad605a52..1d23f301a9 100644 --- a/DuckDuckGo/NetworkProtectionRootView.swift +++ b/DuckDuckGo/NetworkProtectionRootView.swift @@ -33,6 +33,7 @@ struct NetworkProtectionRootView: View { statusViewModel = NetworkProtectionStatusViewModel(tunnelController: AppDependencyProvider.shared.networkProtectionTunnelController, settings: AppDependencyProvider.shared.vpnSettings, statusObserver: AppDependencyProvider.shared.connectionObserver, + serverInfoObserver: AppDependencyProvider.shared.serverInfoObserver, locationListRepository: locationListRepository) } var body: some View { diff --git a/DuckDuckGo/NetworkProtectionStatusView.swift b/DuckDuckGo/NetworkProtectionStatusView.swift index 67abeb033a..b98cc348da 100644 --- a/DuckDuckGo/NetworkProtectionStatusView.swift +++ b/DuckDuckGo/NetworkProtectionStatusView.swift @@ -202,11 +202,9 @@ struct NetworkProtectionStatusView: View { @ViewBuilder private func about() -> some View { Section { - if statusModel.shouldShowFAQ { - NavigationLink(UserText.netPVPNSettingsFAQ, destination: LazyView(NetworkProtectionFAQView())) - .daxBodyRegular() - .foregroundColor(.init(designSystemColor: .textPrimary)) - } + NavigationLink(UserText.netPVPNSettingsFAQ, destination: LazyView(NetworkProtectionFAQView())) + .daxBodyRegular() + .foregroundColor(.init(designSystemColor: .textPrimary)) NavigationLink(UserText.netPVPNSettingsShareFeedback, destination: VPNFeedbackFormCategoryView()) .daxBodyRegular() diff --git a/DuckDuckGo/NetworkProtectionStatusViewModel.swift b/DuckDuckGo/NetworkProtectionStatusViewModel.swift index 2509a21e73..8419921d0d 100644 --- a/DuckDuckGo/NetworkProtectionStatusViewModel.swift +++ b/DuckDuckGo/NetworkProtectionStatusViewModel.swift @@ -87,7 +87,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { return formatter }() - private let tunnelController: TunnelController + private let tunnelController: (TunnelController & TunnelSessionProvider) private let statusObserver: ConnectionStatusObserver private let serverInfoObserver: ConnectionServerInfoObserver private let errorObserver: ConnectionErrorObserver @@ -134,16 +134,12 @@ final class NetworkProtectionStatusViewModel: ObservableObject { @Published public var downloadTotal: String? private var throughputUpdateTimer: Timer? - var shouldShowFAQ: Bool { - AppDependencyProvider.shared.subscriptionFeatureAvailability.isFeatureAvailable - } - @Published public var animationsOn: Bool = false - public init(tunnelController: TunnelController, + public init(tunnelController: (TunnelController & TunnelSessionProvider), settings: VPNSettings, statusObserver: ConnectionStatusObserver, - serverInfoObserver: ConnectionServerInfoObserver = ConnectionServerInfoObserverThroughSession(), + serverInfoObserver: ConnectionServerInfoObserver, errorObserver: ConnectionErrorObserver = ConnectionErrorObserverThroughSession(), locationListRepository: NetworkProtectionLocationListRepository) { self.tunnelController = tunnelController @@ -159,6 +155,8 @@ final class NetworkProtectionStatusViewModel: ObservableObject { self.dnsSettings = settings.dnsSettings + updateViewModel(withStatus: statusObserver.recentValue) + setUpIsConnectedStatePublishers() setUpToggledStatePublisher() setUpStatusMessagePublishers() @@ -176,30 +174,10 @@ final class NetworkProtectionStatusViewModel: ObservableObject { } private func setUpIsConnectedStatePublishers() { - let isConnectedPublisher = statusObserver.publisher - .map { $0.isConnected } - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() - isConnectedPublisher - .map(Self.titleText(connected:)) - .assign(to: \.headerTitle, onWeaklyHeld: self) - .store(in: &cancellables) - isConnectedPublisher - .map(Self.statusImageID(connected:)) - .assign(to: \.statusImageID, onWeaklyHeld: self) - .store(in: &cancellables) - isConnectedPublisher - .sink { [weak self] isConnected in - if !isConnected { - self?.uploadTotal = nil - self?.downloadTotal = nil - self?.throughputUpdateTimer?.invalidate() - self?.throughputUpdateTimer = nil - } else { - self?.setUpThroughputRefreshTimer() - } - } - .store(in: &cancellables) + statusObserver.publisher.sink { [weak self] status in + self?.updateViewModel(withStatus: status) + } + .store(in: &cancellables) } private func setUpToggledStatePublisher() { @@ -292,6 +270,31 @@ final class NetworkProtectionStatusViewModel: ObservableObject { .store(in: &cancellables) } + private func updateViewModel(withStatus connectionStatus: ConnectionStatus) { + self.headerTitle = Self.titleText(connected: connectionStatus.isConnected) + self.statusImageID = Self.statusImageID(connected: connectionStatus.isConnected) + + if !connectionStatus.isConnected { + self.uploadTotal = nil + self.downloadTotal = nil + self.throughputUpdateTimer?.invalidate() + self.throughputUpdateTimer = nil + } else { + self.setUpThroughputRefreshTimer() + } + + switch connectionStatus { + case .connected: + self.isNetPEnabled = true + case .connecting: + self.isNetPEnabled = true + self.resetConnectionInformation() + default: + self.isNetPEnabled = false + self.resetConnectionInformation() + } + } + private func setUpErrorPublishers() { guard AppDependencyProvider.shared.internalUserDecider.isInternalUser else { return @@ -346,7 +349,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { } private func refreshDataVolumeTotals() async { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await tunnelController.activeSession() else { return } diff --git a/DuckDuckGo/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtectionTunnelController.swift index 02f5953de7..30ef1e6f80 100644 --- a/DuckDuckGo/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtectionTunnelController.swift @@ -33,9 +33,10 @@ enum VPNConfigurationRemovalReason: String { case debugMenu } -final class NetworkProtectionTunnelController: TunnelController { +final class NetworkProtectionTunnelController: TunnelController, TunnelSessionProvider { static var shouldSimulateFailure: Bool = false + private var internalManager: NETunnelProviderManager? private let debugFeatures = NetworkProtectionDebugFeatures() private let tokenStore: NetworkProtectionKeychainTokenStore private let errorStore = NetworkProtectionTunnelErrorStore() @@ -43,6 +44,44 @@ final class NetworkProtectionTunnelController: TunnelController { private var previousStatus: NEVPNStatus = .invalid private var cancellables = Set() + // MARK: - Manager, Session, & Connection + + /// The tunnel manager: will try to load if it its not loaded yet, but if one can't be loaded from preferences, + /// a new one will not be created. This is useful for querying the connection state and information without triggering + /// a VPN-access popup to the user. + /// + @MainActor var tunnelManager: NETunnelProviderManager? { + get async { + if let internalManager { + return internalManager + } + + let loadedManager = try? await NETunnelProviderManager.loadAllFromPreferences().first + internalManager = loadedManager + return loadedManager + } + } + + public var connection: NEVPNConnection? { + get async { + await tunnelManager?.connection + } + } + + public func activeSession() async -> NETunnelProviderSession? { + await session + } + + public var session: NETunnelProviderSession? { + get async { + guard let manager = await tunnelManager, let session = manager.connection as? NETunnelProviderSession else { + return nil + } + + return session + } + } + // MARK: - Starting & Stopping the VPN enum StartError: LocalizedError, CustomNSError { @@ -83,6 +122,7 @@ final class NetworkProtectionTunnelController: TunnelController { init(accountManager: AccountManager, tokenStore: NetworkProtectionKeychainTokenStore) { self.tokenStore = tokenStore subscribeToStatusChanges() + subscribeToConfigurationChanges() } /// Starts the VPN connection used for Network Protection @@ -106,7 +146,7 @@ final class NetworkProtectionTunnelController: TunnelController { } func stop() async { - guard let tunnelManager = await loadTunnelManager() else { + guard let tunnelManager = await self.tunnelManager else { return } @@ -139,8 +179,7 @@ final class NetworkProtectionTunnelController: TunnelController { var isInstalled: Bool { get async { - let tunnelManager = await loadTunnelManager() - return tunnelManager != nil + return await self.tunnelManager != nil } } @@ -150,7 +189,7 @@ final class NetworkProtectionTunnelController: TunnelController { /// var isConnected: Bool { get async { - guard let tunnelManager = await loadTunnelManager() else { + guard let tunnelManager = await self.tunnelManager else { return false } @@ -174,7 +213,7 @@ final class NetworkProtectionTunnelController: TunnelController { switch tunnelManager.connection.status { case .invalid: - reloadTunnelManager() + clearInternalManager() try await startWithError() case .connected: // Intentional no-op @@ -184,10 +223,8 @@ final class NetworkProtectionTunnelController: TunnelController { } } - /// Reloads the tunnel manager from preferences. - /// - private func reloadTunnelManager() { - internalTunnelManager = nil + private func clearInternalManager() { + internalManager = nil } private func start(_ tunnelManager: NETunnelProviderManager) throws { @@ -224,35 +261,11 @@ final class NetworkProtectionTunnelController: TunnelController { } } - /// The actual storage for our tunnel manager. - /// - private var internalTunnelManager: NETunnelProviderManager? - - /// The tunnel manager: will try to load if it its not loaded yet, but if one can't be loaded from preferences, - /// a new one will not be created. This is useful for querying the connection state and information without triggering - /// a VPN-access popup to the user. - /// - private var tunnelManager: NETunnelProviderManager? { - get async { - guard let tunnelManager = internalTunnelManager else { - let tunnelManager = await loadTunnelManager() - internalTunnelManager = tunnelManager - return tunnelManager - } - - return tunnelManager - } - } - - private func loadTunnelManager() async -> NETunnelProviderManager? { - try? await NETunnelProviderManager.loadAllFromPreferences().first - } - private func loadOrMakeTunnelManager() async throws -> NETunnelProviderManager { guard let tunnelManager = await tunnelManager else { let tunnelManager = NETunnelProviderManager() try await setupAndSave(tunnelManager) - internalTunnelManager = tunnelManager + internalManager = tunnelManager return tunnelManager } @@ -262,12 +275,7 @@ final class NetworkProtectionTunnelController: TunnelController { private func setupAndSave(_ tunnelManager: NETunnelProviderManager) async throws { setup(tunnelManager) - try await saveToPreferences(tunnelManager) - try await loadFromPreferences(tunnelManager) - try await saveToPreferences(tunnelManager) - } - private func saveToPreferences(_ tunnelManager: NETunnelProviderManager) async throws { do { try await tunnelManager.saveToPreferences() } catch { @@ -281,9 +289,7 @@ final class NetworkProtectionTunnelController: TunnelController { } throw StartError.saveToPreferencesFailed(error) } - } - private func loadFromPreferences(_ tunnelManager: NETunnelProviderManager) async throws { do { try await tunnelManager.loadFromPreferences() } catch { @@ -311,6 +317,31 @@ final class NetworkProtectionTunnelController: TunnelController { tunnelManager.onDemandRules = [NEOnDemandRuleConnect()] } + // MARK: - Observing Configuration Changes + + private func subscribeToConfigurationChanges() { + notificationCenter.publisher(for: .NEVPNConfigurationChange) + .receive(on: DispatchQueue.main) + .sink { _ in + Task { @MainActor in + guard let manager = self.internalManager else { + return + } + + do { + try await manager.loadFromPreferences() + + if manager.connection.status == .invalid { + self.clearInternalManager() + } + } catch { + self.clearInternalManager() + } + } + } + .store(in: &cancellables) + } + // MARK: - Observing Status Changes private func subscribeToStatusChanges() { diff --git a/DuckDuckGoTests/MockDependencyProvider.swift b/DuckDuckGoTests/MockDependencyProvider.swift index 7a0a327087..214f675820 100644 --- a/DuckDuckGoTests/MockDependencyProvider.swift +++ b/DuckDuckGoTests/MockDependencyProvider.swift @@ -46,6 +46,7 @@ class MockDependencyProvider: DependencyProvider { var networkProtectionKeychainTokenStore: NetworkProtectionKeychainTokenStore var networkProtectionTunnelController: NetworkProtectionTunnelController var connectionObserver: NetworkProtection.ConnectionStatusObserver + var serverInfoObserver: NetworkProtection.ConnectionServerInfoObserver var vpnSettings: NetworkProtection.VPNSettings init() { @@ -88,6 +89,7 @@ class MockDependencyProvider: DependencyProvider { accountManager: accountManager) connectionObserver = ConnectionStatusObserverThroughSession() + serverInfoObserver = ConnectionServerInfoObserverThroughSession() vpnSettings = VPNSettings(defaults: .networkProtectionGroupDefaults) } }