diff options
| author | Emīls <emils@mullvad.net> | 2026-01-22 11:25:39 +0100 |
|---|---|---|
| committer | Emīls <emils@mullvad.net> | 2026-01-22 11:25:39 +0100 |
| commit | 18e37645f64baccc37ef8004d6ba522e357115dc (patch) | |
| tree | 5adba054b762fe83b28df33d8f2e8085b47b7d1e | |
| parent | caf2d53e3b7ac583a5e81eb93c6b7a6bf7907544 (diff) | |
| parent | 3844dda0f567cf82e4260f2c5e18106c7a94cee7 (diff) | |
| download | mullvadvpn-ios-bug-bash-01-22.tar.xz mullvadvpn-ios-bug-bash-01-22.zip | |
Merge branch 'ios-fix-offline-state' into ios-bug-bash-01-22ios-bug-bash-01-22
15 files changed, 322 insertions, 312 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 9ae2b8d6bf..27783fe8e3 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -405,7 +405,6 @@ 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */; }; 58F2E144276A13F300A79513 /* StartTunnelOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F2E143276A13F300A79513 /* StartTunnelOperation.swift */; }; 58F2E146276A2C9900A79513 /* StopTunnelOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F2E145276A2C9900A79513 /* StopTunnelOperation.swift */; }; - 58F2E148276A307400A79513 /* MapConnectionStatusOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F2E147276A307400A79513 /* MapConnectionStatusOperation.swift */; }; 58F2E14C276A61C000A79513 /* RotateKeyOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F2E14B276A61C000A79513 /* RotateKeyOperation.swift */; }; 58F3C0A4249CB069003E76BE /* HeaderBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F3C0A3249CB069003E76BE /* HeaderBarView.swift */; }; 58F3F36A2AA08E3C00D3B0A4 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F3F3692AA08E3C00D3B0A4 /* PacketTunnelProvider.swift */; }; @@ -815,7 +814,6 @@ A9A5FA102ACB05160083449F /* PacketTunnelAPITransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 063687B928EB234F00BE7161 /* PacketTunnelAPITransport.swift */; }; A9A5FA112ACB05160083449F /* APITransportMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0697D6E628F01513007A9E99 /* APITransportMonitor.swift */; }; A9A5FA132ACB05160083449F /* LoadTunnelConfigurationOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588527B1276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift */; }; - A9A5FA142ACB05160083449F /* MapConnectionStatusOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F2E147276A307400A79513 /* MapConnectionStatusOperation.swift */; }; A9A5FA152ACB05160083449F /* RedeemVoucherOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07BF2612A26279100042943 /* RedeemVoucherOperation.swift */; }; A9A5FA162ACB05160083449F /* RotateKeyOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F2E14B276A61C000A79513 /* RotateKeyOperation.swift */; }; A9A5FA172ACB05160083449F /* SendTunnelProviderMessageOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586E54FA27A2DF6D0029B88B /* SendTunnelProviderMessageOperation.swift */; }; @@ -1978,7 +1976,6 @@ 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerActivityIndicatorView.swift; sourceTree = "<group>"; }; 58F2E143276A13F300A79513 /* StartTunnelOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartTunnelOperation.swift; sourceTree = "<group>"; }; 58F2E145276A2C9900A79513 /* StopTunnelOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopTunnelOperation.swift; sourceTree = "<group>"; }; - 58F2E147276A307400A79513 /* MapConnectionStatusOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapConnectionStatusOperation.swift; sourceTree = "<group>"; }; 58F2E14B276A61C000A79513 /* RotateKeyOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RotateKeyOperation.swift; sourceTree = "<group>"; }; 58F3C0A3249CB069003E76BE /* HeaderBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderBarView.swift; sourceTree = "<group>"; }; 58F3F3652AA086A400D3B0A4 /* AutoCancellingTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCancellingTask.swift; sourceTree = "<group>"; }; @@ -3086,7 +3083,6 @@ isa = PBXGroup; children = ( 588527B1276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift */, - 58F2E147276A307400A79513 /* MapConnectionStatusOperation.swift */, F07BF2612A26279100042943 /* RedeemVoucherOperation.swift */, 58F2E14B276A61C000A79513 /* RotateKeyOperation.swift */, 586E54FA27A2DF6D0029B88B /* SendTunnelProviderMessageOperation.swift */, @@ -6043,7 +6039,6 @@ A9A5FA132ACB05160083449F /* LoadTunnelConfigurationOperation.swift in Sources */, 7AD63A3F2CDA53F600445268 /* ObfuscationMethodSelectorTests.swift in Sources */, 44DD7D292B7113CA0005F67F /* MockTunnel.swift in Sources */, - A9A5FA142ACB05160083449F /* MapConnectionStatusOperation.swift in Sources */, F998EFF82D359C4600D88D01 /* SKProduct+Formatting.swift in Sources */, A9A5FA152ACB05160083449F /* RedeemVoucherOperation.swift in Sources */, A9A5FA162ACB05160083449F /* RotateKeyOperation.swift in Sources */, @@ -6321,7 +6316,6 @@ 58C3F4F92964B08300D72515 /* MapViewController.swift in Sources */, 584D26C6270C8741004EA533 /* SettingsDNSTextCell.swift in Sources */, F09C97212D311D8800ADE747 /* ChangeLogView.swift in Sources */, - 58F2E148276A307400A79513 /* MapConnectionStatusOperation.swift in Sources */, 58BA693123EADA6A009DC256 /* SimulatorTunnelProvider.swift in Sources */, 7A9CCCB32A96302800DD6A34 /* WelcomeCoordinator.swift in Sources */, 587B753B2666467500DEF7E9 /* NotificationBannerView.swift in Sources */, diff --git a/ios/MullvadVPN/Notifications/Notification Providers/TunnelStatusNotificationProvider.swift b/ios/MullvadVPN/Notifications/Notification Providers/TunnelStatusNotificationProvider.swift index 12d626762e..cfc15b0aaa 100644 --- a/ios/MullvadVPN/Notifications/Notification Providers/TunnelStatusNotificationProvider.swift +++ b/ios/MullvadVPN/Notifications/Notification Providers/TunnelStatusNotificationProvider.swift @@ -140,6 +140,11 @@ final class TunnelStatusNotificationProvider: NotificationProvider, InAppNotific } private func notificationDescription(for packetTunnelError: BlockedStateReason) -> InAppNotificationDescriptor { + // Show the same notification for offline error as for the noNetwork state + if packetTunnelError == .offline { + return connectivityNotificationDescription() + } + let tapAction: InAppNotificationAction? = switch packetTunnelError { case .noRelaysSatisfyingPortConstraints: diff --git a/ios/MullvadVPN/TunnelManager/MapConnectionStatusOperation.swift b/ios/MullvadVPN/TunnelManager/MapConnectionStatusOperation.swift deleted file mode 100644 index 2f9ec9414d..0000000000 --- a/ios/MullvadVPN/TunnelManager/MapConnectionStatusOperation.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// MapConnectionStatusOperation.swift -// MullvadVPN -// -// Created by pronebird on 15/12/2021. -// Copyright © 2025 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import MullvadLogging -import MullvadREST -import MullvadTypes -import NetworkExtension -import Operations -import PacketTunnelCore - -class MapConnectionStatusOperation: AsyncOperation, @unchecked Sendable { - private let interactor: TunnelInteractor - private let connectionStatus: NEVPNStatus - private var request: Cancellable? - private var pathStatus: Network.NWPath.Status? - - private let logger = Logger(label: "TunnelManager.MapConnectionStatusOperation") - - required init( - queue: DispatchQueue, - interactor: TunnelInteractor, - connectionStatus: NEVPNStatus, - networkStatus: Network.NWPath.Status? - ) { - self.interactor = interactor - self.connectionStatus = connectionStatus - pathStatus = networkStatus - - super.init(dispatchQueue: queue) - } - - override func main() { - guard let tunnel = interactor.tunnel else { - setTunnelDisconnectedStatus() - - finish() - return - } - - let tunnelState = interactor.tunnelStatus.state - - switch connectionStatus { - case .connecting, .reasserting, .connected: - fetchTunnelStatus(tunnel: tunnel) { observedState in - switch observedState { - case let .connected(connectionState): - return connectionState.isNetworkReachable - ? .connected( - connectionState.selectedRelays, - isPostQuantum: connectionState.isPostQuantum, - isDaita: connectionState.isDaitaEnabled - ) - : .waitingForConnectivity(.noConnection) - case let .connecting(connectionState): - return connectionState.isNetworkReachable - ? .connecting( - connectionState.selectedRelays, - isPostQuantum: connectionState.isPostQuantum, - isDaita: connectionState.isDaitaEnabled - ) - : .waitingForConnectivity(.noConnection) - case let .negotiatingEphemeralPeer(connectionState, privateKey): - return connectionState.isNetworkReachable - ? .negotiatingEphemeralPeer( - connectionState.selectedRelays, - privateKey, - isPostQuantum: connectionState.isPostQuantum, - isDaita: connectionState.isDaitaEnabled - ) - : .waitingForConnectivity(.noConnection) - case let .reconnecting(connectionState): - return connectionState.isNetworkReachable - ? .reconnecting( - connectionState.selectedRelays, - isPostQuantum: connectionState.isPostQuantum, - isDaita: connectionState.isDaitaEnabled - ) - : .waitingForConnectivity(.noConnection) - case let .error(blockedState): - return .error(blockedState.reason) - case .initial, .disconnecting, .disconnected: - return .none - } - } - return - - case .disconnected: - handleDisconnectedState(tunnelState) - - case .disconnecting: - handleDisconnectingState(tunnelState) - - case .invalid: - setTunnelDisconnectedStatus() - - @unknown default: - logger.debug("Unknown NEVPNStatus: \(connectionStatus.rawValue)") - } - - finish() - } - - override func operationDidCancel() { - request?.cancel() - } - - private func handleDisconnectingState(_ tunnelState: TunnelState) { - switch tunnelState { - case .disconnecting: - break - default: - interactor.updateTunnelStatus { tunnelStatus in - // Avoid displaying waiting for connectivity banners if the tunnel in a blocked state when disconnecting - if tunnelStatus.observedState.blockedState != nil { - tunnelStatus.state = .disconnecting(.nothing) - } else { - let isNetworkReachable = tunnelStatus.observedState.connectionState?.isNetworkReachable ?? false - tunnelStatus.state = - isNetworkReachable - ? .disconnecting(.nothing) - : .waitingForConnectivity(.noNetwork) - } - } - } - } - - private func handleDisconnectedState(_ tunnelState: TunnelState) { - switch tunnelState { - case .pendingReconnect: - logger.debug("Ignore disconnected state when pending reconnect.") - - case .disconnecting(.reconnect): - logger.debug("Restart the tunnel on disconnect.") - interactor.updateTunnelStatus { tunnelStatus in - tunnelStatus = TunnelStatus() - tunnelStatus.state = .pendingReconnect - } - interactor.startTunnel() - - default: - setTunnelDisconnectedStatus() - } - } - - private func setTunnelDisconnectedStatus() { - interactor.updateTunnelStatus { tunnelStatus in - tunnelStatus = TunnelStatus() - tunnelStatus.state = - pathStatus == .unsatisfied - ? .waitingForConnectivity(.noNetwork) - : .disconnected - } - } - - private func fetchTunnelStatus( - tunnel: any TunnelProtocol, - mapToState: @escaping @Sendable (ObservedState) -> TunnelState? - ) { - request = tunnel.getTunnelStatus { [weak self] result in - guard let self else { return } - - dispatchQueue.async { - if case let .success(observedState) = result, !self.isCancelled { - self.interactor.updateTunnelStatus { tunnelStatus in - tunnelStatus.observedState = observedState - - if let newState = mapToState(observedState) { - tunnelStatus.state = newState - } - } - } - - self.finish() - } - } - } -} diff --git a/ios/MullvadVPN/TunnelManager/Tunnel.swift b/ios/MullvadVPN/TunnelManager/Tunnel.swift index 2acc884bca..5fc512c2a5 100644 --- a/ios/MullvadVPN/TunnelManager/Tunnel.swift +++ b/ios/MullvadVPN/TunnelManager/Tunnel.swift @@ -121,10 +121,15 @@ final class Tunnel: TunnelProtocol, Equatable, @unchecked Sendable { self.tunnelProvider = tunnelProvider self.backgroundTaskProvider = backgroundTaskProvider + // Observe ALL NEVPNStatusDidChange notifications rather than filtering by specific + // connection object. This is necessary because `loadFromPreferences` may internally + // replace the connection object, causing notifications to be sent to a different + // object than the one we originally registered for. We filter in the handler instead + // by comparing against the current `tunnelProvider.connection`. NotificationCenter.default.addObserver( self, selector: #selector(handleVPNStatusChangeNotification(_:)), name: .NEVPNStatusDidChange, - object: tunnelProvider.connection + object: nil ) handleVPNStatus(tunnelProvider.connection.status) @@ -188,6 +193,11 @@ final class Tunnel: TunnelProtocol, Equatable, @unchecked Sendable { @objc private func handleVPNStatusChangeNotification(_ notification: Notification) { guard let connection = notification.object as? VPNConnectionProtocol else { return } + // Filter to only handle notifications for our connection. + // We compare against the current `tunnelProvider.connection` (not a captured reference) + // because `loadFromPreferences` may replace the connection object internally. + guard connection === tunnelProvider.connection else { return } + let newStatus = connection.status handleVPNStatus(newStatus) diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift index 682607a68f..2e8dbb7028 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift @@ -20,11 +20,11 @@ import WireGuardKitTypes /// Interval used for periodic polling of tunnel relay status when tunnel is establishing /// connection. -private let establishingTunnelStatusPollInterval: Duration = .seconds(1) +private let establishingTunnelStatusPollInterval: Duration = .milliseconds(500) /// Interval used for periodic polling of tunnel connectivity status once the tunnel connection /// is established. -private let establishedTunnelStatusPollInterval: Duration = .seconds(5) +private let establishedTunnelStatusPollInterval: Duration = .milliseconds(500) /// A class that provides a convenient interface for VPN tunnels configuration, manipulation and /// monitoring. @@ -33,7 +33,6 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable { case manageTunnel case deviceStateUpdate case settingsUpdate - case tunnelStateUpdate var category: String { "TunnelManager.\(rawValue)" @@ -55,7 +54,6 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable { private let internalQueue = DispatchQueue(label: "TunnelManager.internalQueue") private var statusObserver: TunnelStatusBlockObserver? - private weak var lastMapConnectionStatusOperation: Operation? private let observerList = ObserverList<TunnelObserver>() private var networkMonitor: NWPathMonitor? private let relaySelector: RelaySelectorProtocol @@ -78,6 +76,7 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable { private var _tunnel: (any TunnelProtocol)? private var _tunnelStatus = TunnelStatus() + private var _lastNEVPNStatus: NEVPNStatus = .invalid /// Last processed device check. private var lastPacketTunnelKeyRotation: Date? @@ -730,30 +729,11 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable { lastPacketTunnelKeyRotation = newPacketTunnelKeyRotation refreshDeviceState() } - switch _tunnelStatus.state { - case .connecting, .reconnecting, .negotiatingEphemeralPeer: - // Start polling tunnel status to keep the relay information up to date - // while the tunnel process is trying to connect. - startPollingTunnelStatus(interval: establishingTunnelStatusPollInterval) - - case .connected, .waitingForConnectivity(.noConnection): - // Start polling tunnel status to keep connectivity status up to date. - startPollingTunnelStatus(interval: establishedTunnelStatusPollInterval) - - case .pendingReconnect, .disconnecting, .disconnected, .waitingForConnectivity(.noNetwork): - // Stop polling tunnel status once connection moved to final state. - cancelPollingTunnelStatus() - - case let .error(blockedStateReason): - switch blockedStateReason { - case .deviceRevoked, .invalidAccount: - handleBlockedState(reason: blockedStateReason) - default: - break - } - - // Stop polling tunnel status once blocked state has been determined. - cancelPollingTunnelStatus() + // Handle unrecoverable blocked states + if case let .error(blockedStateReason) = _tunnelStatus.state, + !blockedStateReason.recoverableError() + { + handleBlockedState(reason: blockedStateReason) } DispatchQueue.main.async { @@ -837,7 +817,12 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable { } private func didUpdateNetworkPath(_ path: Network.NWPath) { - updateTunnelStatus(tunnel?.status ?? .disconnected) + // Only act on network path updates when VPN is disconnected. + // When VPN is up, the packet tunnel handles network changes internally. + let status = tunnel?.status ?? .disconnected + guard [.disconnected, .invalid].contains(status) else { return } + + setDisconnectedState(networkPathStatus: path.status) } fileprivate func prepareForVPNConfigurationDeletion() { @@ -846,10 +831,6 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable { // Unregister from receiving VPN connection status changes unsubscribeVPNStatusObserver() - - // Cancel last VPN status mapping operation - lastMapConnectionStatusOperation?.cancel() - lastMapConnectionStatusOperation = nil } private func didReconnectTunnel(error: Error?) { @@ -884,14 +865,26 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable { .addBlockObserver(queue: internalQueue) { [weak self] tunnel, status in guard let self else { return } + // Save the NEVPNStatus so we can reject stale IPC updates + self._lastNEVPNStatus = status self.logger.debug("VPN connection status changed to \(status).") + + // Control polling based on NEVPNStatus directly (the source of truth), + // not the derived tunnelStatus.state which can be stale. + self.updatePollingFromVPNStatus(status) + + // Update tunnel status for all state changes to ensure UI reflects + // disconnecting and disconnected states immediately. self.updateTunnelStatus(status) } + + // Save and start polling for the current status since the observer + // only fires on status changes, not for the initial state. + _lastNEVPNStatus = tunnel.status + updatePollingFromVPNStatus(tunnel.status) } private func startNetworkMonitor() { - cancelNetworkMonitor() - networkMonitor = NWPathMonitor() networkMonitor?.pathUpdateHandler = { [weak self] path in self?.scheduleNetworkPathUpdate(path) @@ -918,14 +911,6 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable { ) } - private func cancelNetworkMonitor() { - pendingNetworkPathUpdate?.cancel() - pendingNetworkPathUpdate = nil - networkMonitor?.pathUpdateHandler = nil - networkMonitor?.cancel() - networkMonitor = nil - } - private func unsubscribeVPNStatusObserver() { nslock.lock() defer { nslock.unlock() } @@ -938,8 +923,17 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable { nslock.lock() defer { nslock.unlock() } - if let connectionStatus = _tunnel?.status { + guard let connectionStatus = _tunnel?.status else { return } + + switch connectionStatus { + case .connecting, .reasserting, .connected: + // Active states: fetch via IPC + fetchAndUpdateTunnelStatus() + case .disconnected, .disconnecting, .invalid: + // Down states: update directly updateTunnelStatus(connectionStatus) + @unknown default: + break } } @@ -972,28 +966,164 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable { } /// Update `TunnelStatus` from `NEVPNStatus`. - /// Collects the `PacketTunnelStatus` from the tunnel via IPC if needed before assigning - /// the `tunnelStatus`. + /// For active states, fetches detailed status via IPC. + /// For down states, updates state directly without IPC. private func updateTunnelStatus(_ connectionStatus: NEVPNStatus) { nslock.lock() defer { nslock.unlock() } - let operation = MapConnectionStatusOperation( - queue: internalQueue, - interactor: TunnelInteractorProxy(self), - connectionStatus: connectionStatus, - networkStatus: networkMonitor?.currentPath.status - ) + switch connectionStatus { + case .connecting, .reasserting, .connected: + // Active states: fetch details via IPC + fetchAndUpdateTunnelStatus() - operation.addCondition( - MutuallyExclusive(category: OperationCategory.tunnelStateUpdate.category) - ) + case .disconnecting: + handleDisconnectingStateDirectly() - // Cancel last VPN status mapping operation - lastMapConnectionStatusOperation?.cancel() - lastMapConnectionStatusOperation = operation + case .disconnected: + handleDisconnectedStateDirectly() - operationQueue.addOperation(operation) + case .invalid: + setDisconnectedState(networkPathStatus: networkMonitor?.currentPath.status) + + @unknown default: + logger.debug("Unknown NEVPNStatus: \(connectionStatus.rawValue)") + } + } + + // MARK: - Direct state updates (no IPC needed) + + /// Directly set disconnected state without going through operation queue. + /// Safe because disconnected is an idempotent final state. + private func setDisconnectedState(networkPathStatus: Network.NWPath.Status?) { + _ = setTunnelStatus { tunnelStatus in + tunnelStatus = TunnelStatus() + tunnelStatus.state = + networkPathStatus == .unsatisfied + ? .waitingForConnectivity(.noNetwork) + : .disconnected + } + } + + /// Handle disconnecting state directly without IPC. + private func handleDisconnectingStateDirectly() { + let currentState = tunnelStatus.state + + switch currentState { + case .disconnecting: + // Already disconnecting, no change needed + break + default: + _ = setTunnelStatus { tunnelStatus in + if tunnelStatus.observedState.blockedState != nil { + tunnelStatus.state = .disconnecting(.nothing) + } else { + let isNetworkReachable = tunnelStatus.observedState.connectionState?.isNetworkReachable ?? false + tunnelStatus.state = + isNetworkReachable + ? .disconnecting(.nothing) + : .waitingForConnectivity(.noNetwork) + } + } + } + } + + /// Handle disconnected state directly without IPC. + private func handleDisconnectedStateDirectly() { + let currentState = tunnelStatus.state + + switch currentState { + case .pendingReconnect: + logger.debug("Ignore disconnected state when pending reconnect.") + + case .disconnecting(.reconnect): + logger.debug("Restart the tunnel on disconnect.") + _ = setTunnelStatus { tunnelStatus in + tunnelStatus = TunnelStatus() + tunnelStatus.state = .pendingReconnect + } + startTunnel() + + default: + setDisconnectedState(networkPathStatus: networkMonitor?.currentPath.status) + } + } + + // MARK: - IPC-based status fetch (for active states) + + /// Fetch tunnel status via IPC and update state. + /// Only called for active states (.connecting, .reasserting, .connected). + private func fetchAndUpdateTunnelStatus() { + guard let tunnel = _tunnel else { return } + + _ = tunnel.getTunnelStatus { [weak self] result in + guard let self else { return } + + self.internalQueue.async { + // Reject stale IPC updates if the tunnel is now dead + guard self.isTunnelAlive else { + self.logger.debug("Ignoring stale IPC response, tunnel is dead.") + return + } + + if case let .success(observedState) = result { + _ = self.setTunnelStatus { tunnelStatus in + tunnelStatus.observedState = observedState + + if let newState = self.mapObservedStateToTunnelState(observedState) { + tunnelStatus.state = newState + } + } + } + } + } + } + + /// Returns true if the tunnel is in an active state based on NEVPNStatus. + private var isTunnelAlive: Bool { + switch _lastNEVPNStatus { + case .connecting, .reasserting, .connected: + return true + case .disconnecting, .disconnected, .invalid: + return false + @unknown default: + return false + } + } + + /// Map ObservedState from packet tunnel to TunnelState for UI. + private func mapObservedStateToTunnelState(_ observedState: ObservedState) -> TunnelState? { + switch observedState { + case let .connected(connectionState): + return .connected( + connectionState.selectedRelays, + isPostQuantum: connectionState.isPostQuantum, + isDaita: connectionState.isDaitaEnabled + ) + case let .connecting(connectionState): + return .connecting( + connectionState.selectedRelays, + isPostQuantum: connectionState.isPostQuantum, + isDaita: connectionState.isDaitaEnabled + ) + case let .negotiatingEphemeralPeer(connectionState, privateKey): + return .negotiatingEphemeralPeer( + connectionState.selectedRelays, + privateKey, + isPostQuantum: connectionState.isPostQuantum, + isDaita: connectionState.isDaitaEnabled + ) + case let .reconnecting(connectionState): + return .reconnecting( + connectionState.selectedRelays, + isPostQuantum: connectionState.isPostQuantum, + isDaita: connectionState.isDaitaEnabled + ) + case let .error(blockedState): + return .error(blockedState.reason) + case .initial, .disconnecting, .disconnected: + return nil + } } private func scheduleSettingsUpdate( @@ -1082,10 +1212,9 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable { // MARK: - Tunnel status polling private func startPollingTunnelStatus(interval: Duration) { - /* - Ignore idempotency, otherwise the timer will not be using the correct time interval - when switching between states, until the tunnel disconnects. - */ + guard !isPolling else { + return + } isPolling = true tunnelStatusPollTimer?.cancel() @@ -1111,6 +1240,22 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable { isPolling = false } + /// Update polling state based on NEVPNStatus (the source of truth). + /// Poll continuously while tunnel is not disconnected, disconnecting, or invalid. + private func updatePollingFromVPNStatus(_ status: NEVPNStatus) { + switch status { + case .disconnecting, .disconnected, .invalid: + cancelPollingTunnelStatus() + case .connecting, .reasserting: + startPollingTunnelStatus(interval: establishingTunnelStatusPollInterval) + case .connected: + startPollingTunnelStatus(interval: establishedTunnelStatusPollInterval) + @unknown default: + // For any unknown future states, assume it's an active state and poll + startPollingTunnelStatus(interval: establishedTunnelStatusPollInterval) + } + } + fileprivate func removeLastUsedAccount() { do { try SettingsManager.setLastUsedAccount(nil) diff --git a/ios/MullvadVPN/TunnelManager/TunnelState.swift b/ios/MullvadVPN/TunnelManager/TunnelState.swift index 43d302a9b2..afe643fbec 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelState.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelState.swift @@ -139,7 +139,7 @@ enum TunnelState: Equatable, CustomStringConvertible, Sendable { var isSecured: Bool { switch self { case .reconnecting, .connecting, .connected, .waitingForConnectivity(.noConnection), .error(.accountExpired), - .error(.deviceRevoked), .negotiatingEphemeralPeer: + .error(.deviceRevoked), .error(.offline), .negotiatingEphemeralPeer: true case .pendingReconnect, .disconnecting, .disconnected, .waitingForConnectivity(.noNetwork), .error: false diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift index 2d8546ac45..fee7dba1bb 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift @@ -82,7 +82,7 @@ extension ConnectionViewViewModel { var textColorForSecureLabel: UIColor { switch tunnelStatus.state { case .connecting, .reconnecting, .waitingForConnectivity(.noConnection), .negotiatingEphemeralPeer, - .pendingReconnect, .disconnecting: + .pendingReconnect, .disconnecting, .error(.offline): .white case .connected: .successColor diff --git a/ios/MullvadVPNTests/MullvadVPN/PacketTunnelCore/PacketTunnelActorReducerTests.swift b/ios/MullvadVPNTests/MullvadVPN/PacketTunnelCore/PacketTunnelActorReducerTests.swift index 2d0ac62a96..98ae4f18f3 100644 --- a/ios/MullvadVPNTests/MullvadVPN/PacketTunnelCore/PacketTunnelActorReducerTests.swift +++ b/ios/MullvadVPNTests/MullvadVPN/PacketTunnelCore/PacketTunnelActorReducerTests.swift @@ -52,8 +52,7 @@ final class PacketTunnelActorReducerTests: XCTestCase { XCTAssertEqual( effects, [ - .startTunnelMonitor, - .startConnection(.random), + .startConnection(.random) ]) } @@ -69,8 +68,7 @@ final class PacketTunnelActorReducerTests: XCTestCase { XCTAssertEqual( effects, [ - .startTunnelMonitor, - .startConnection(.preSelected(selectedRelays)), + .startConnection(.preSelected(selectedRelays)) ]) } diff --git a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelPathObserver.swift b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelPathObserver.swift index 5ca3895bf0..4ee0bccb45 100644 --- a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelPathObserver.swift +++ b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelPathObserver.swift @@ -20,6 +20,8 @@ final class PacketTunnelPathObserver: DefaultPathObserverProtocol, Sendable { private let stateLock = NSLock() nonisolated(unsafe) private var started = false + nonisolated(unsafe) private var pendingPathUpdate: DispatchWorkItem? + private static let pathUpdateDebounceDelay: DispatchTimeInterval = .seconds(2) public var currentPathStatus: Network.NWPath.Status { stateLock.withLock { @@ -35,8 +37,21 @@ final class PacketTunnelPathObserver: DefaultPathObserverProtocol, Sendable { stateLock.withLock { guard started == false else { return } defer { started = true } - pathMonitor.pathUpdateHandler = { updatedPath in - body(updatedPath.status) + pathMonitor.pathUpdateHandler = { [weak self] updatedPath in + guard let self else { return } + self.stateLock.withLock { + self.pendingPathUpdate?.cancel() + + let workItem = DispatchWorkItem { + body(updatedPath.status) + } + self.pendingPathUpdate = workItem + + self.eventQueue.asyncAfter( + deadline: .now() + Self.pathUpdateDebounceDelay, + execute: workItem + ) + } } pathMonitor.start(queue: eventQueue) @@ -47,6 +62,8 @@ final class PacketTunnelPathObserver: DefaultPathObserverProtocol, Sendable { stateLock.withLock { guard started == true else { return } defer { started = false } + pendingPathUpdate?.cancel() + pendingPathUpdate = nil pathMonitor.pathUpdateHandler = nil pathMonitor.cancel() } diff --git a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift index 95d1ce3f3b..6df6796364 100644 --- a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift +++ b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift @@ -166,35 +166,22 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { // This check is allowed to push new key to server if there are some issues with it. startDeviceCheck(rotateKeyOnMismatch: true) - actor.start(options: startOptions) - - Task { - for await state in await actor.observedStates { - switch state { - case .connected, .disconnected, .error: - completionHandler(nil) - return - case let .connecting(connectionState): - // Give the tunnel a few tries to connect, otherwise return immediately. This will enable VPN in - // device settings, but the app will still report the true state via ObservedState over IPC. - // In essence, this prevents the 60s tunnel timeout to trigger. - if connectionState.connectionAttemptCount > 1 { - completionHandler(nil) - return - } - case .negotiatingEphemeralPeer: - // When negotiating ephemeral peers, allow the connection to go through immediately. - // Otherwise, the in-tunnel TCP connection will never become ready as the OS doesn't let - // any traffic through until this function returns, which would prevent negotiating ephemeral peers - // from an unconnected state. - completionHandler(nil) - return - default: - completionHandler(nil) - return + setTunnelNetworkSettings( + initialTunnelNetworkSettings(), + completionHandler: { error in + if let error { + self.providerLogger + .error( + "Failed to configure tunnel with initial config: \(error)" + ) + } else { + self.providerLogger.debug("Starting actor after initial configuration is applied") + self.actor.start(options: startOptions) } - } - } + self.internalQueue.async { + completionHandler(error) + } + }) } override func stopTunnel(with reason: NEProviderStopReason) async { @@ -298,6 +285,28 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { requestDataSource: accessMethodRepository.requestAccessMethodPublisher ) } + + private func initialTunnelNetworkSettings() -> NETunnelNetworkSettings { + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") + + // IPv4 settings + let ipv4Settings = NEIPv4Settings( + addresses: ["10.64.0.1"], + subnetMasks: ["255.255.255.255"] + ) + ipv4Settings.includedRoutes = [NEIPv4Route.default()] + settings.ipv4Settings = ipv4Settings + + // IPv6 settings + let ipv6Settings = NEIPv6Settings( + addresses: ["fc00::1"], + networkPrefixLengths: [128] + ) + ipv6Settings.includedRoutes = [NEIPv6Route.default()] + settings.ipv6Settings = ipv6Settings + + return settings + } } extension PacketTunnelProvider { diff --git a/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift b/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift index a0915536b7..1f9ce35aba 100644 --- a/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift +++ b/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift @@ -71,6 +71,7 @@ public actor PacketTunnelActor { self.settingsReader = settingsReader self.protocolObfuscator = protocolObfuscator + Task { await setTunnelMonitorEventHandler() } consumeEvents(channel: eventChannel) } @@ -112,8 +113,6 @@ public actor PacketTunnelActor { func executeEffect(_ effect: Effect) async { switch effect { - case .startTunnelMonitor: - setTunnelMonitorEventHandler() case .stopTunnelMonitor: tunnelMonitor.stop() case let .updateTunnelMonitorPath(networkPath): @@ -183,24 +182,30 @@ public actor PacketTunnelActor { } private func handleDefaultPathChange(_ networkPath: Network.NWPath.Status) async { + guard self.state != .initial else { + return + } tunnelMonitor.handleNetworkPathUpdate(networkPath) let newReachability = networkPath.networkReachability - let reachabilityChanged = - state.mutateAssociatedData { - let reachabilityChanged = $0.networkReachability != newReachability - $0.networkReachability = newReachability - return reachabilityChanged - } ?? false if case .reachable = newReachability, case let .error( errorState ) = state, errorState.reason - .recoverableError(), reachabilityChanged + .recoverableError() { - await handleRestartConnection(nextRelays: .random, reason: .userInitiated) + await handleRestartConnection( + nextRelays: .random, + reason: .restoredConnectivity + ) + return + } + + // If network is unreachable, enter error state. + if case .unreachable = newReachability { + await setErrorStateInternal(with: .offline) } } } @@ -220,9 +225,6 @@ extension PacketTunnelActor { logger.debug("\(options.logFormat())") - // Assign a closure receiving tunnel monitor events. - setTunnelMonitorEventHandler() - do { try await tryStart(nextRelays: options.selectedRelays.map { .preSelected($0) } ?? .random) } catch { @@ -269,6 +271,13 @@ extension PacketTunnelActor { nextRelays: NextRelays, reason: ActorReconnectReason = .userInitiated ) async throws { + if case let .error(blockedState) = self.state, + blockedState.reason == .offline && reason != .restoredConnectivity + { + logger.debug("Ignore reconnection due to being offline") + return + } + let settings: Settings = try settingsReader.read() try await self.applyNetworkSettingsIfNeeded(settings: settings) diff --git a/ios/PacketTunnelCore/Actor/PacketTunnelActorReducer.swift b/ios/PacketTunnelCore/Actor/PacketTunnelActorReducer.swift index 1a5e5b0e3a..e38e4279c0 100644 --- a/ios/PacketTunnelCore/Actor/PacketTunnelActorReducer.swift +++ b/ios/PacketTunnelCore/Actor/PacketTunnelActorReducer.swift @@ -14,7 +14,6 @@ import WireGuardKitTypes extension PacketTunnelActor { /// A structure encoding an effect; each event will yield zero or more of those, which can then be sequentially executed. enum Effect: Equatable, Sendable { - case startTunnelMonitor case stopTunnelMonitor case updateTunnelMonitorPath(Network.NWPath.Status) case startConnection(NextRelays) @@ -34,7 +33,6 @@ extension PacketTunnelActor { // We cannot synthesise Equatable on Effect because NetworkPath is a protocol which cannot be easily made Equatable, so we need to do this for now. static func == (lhs: PacketTunnelActor.Effect, rhs: PacketTunnelActor.Effect) -> Bool { return switch (lhs, rhs) { - case (.startTunnelMonitor, .startTunnelMonitor): true case (.stopTunnelMonitor, .stopTunnelMonitor): true case let (.updateTunnelMonitorPath(lp), .updateTunnelMonitorPath(rp)): lp == rp case let (.startConnection(nr0), .startConnection(nr1)): nr0 == nr1 @@ -57,8 +55,7 @@ extension PacketTunnelActor { case let .start(options): guard case .initial = state else { return [] } return [ - .startTunnelMonitor, - .startConnection(options.selectedRelays.map { .preSelected($0) } ?? .random), + .startConnection(options.selectedRelays.map { .preSelected($0) } ?? .random) ] case .stop: return subreducerForStop(&state) @@ -83,9 +80,12 @@ extension PacketTunnelActor { case let .networkReachability(defaultPath): let newReachability = defaultPath.networkReachability - state.mutateAssociatedData { $0.networkReachability = newReachability } - return [.updateTunnelMonitorPath(defaultPath)] - + let reachabilityChanged = state.associatedData?.networkReachability != newReachability + if reachabilityChanged { + state.mutateAssociatedData { $0.networkReachability = newReachability } + return [.updateTunnelMonitorPath(defaultPath)] + } + return [] case let .ephemeralPeerNegotiationStateChanged(configuration, reconfigurationSemaphore): return [.reconfigureForEphemeralPeer(configuration, reconfigurationSemaphore)] diff --git a/ios/PacketTunnelCore/Actor/State+Extensions.swift b/ios/PacketTunnelCore/Actor/State+Extensions.swift index e1b0c715e3..ba09434860 100644 --- a/ios/PacketTunnelCore/Actor/State+Extensions.swift +++ b/ios/PacketTunnelCore/Actor/State+Extensions.swift @@ -206,7 +206,7 @@ extension BlockedStateReason { .multihopEntryEqualsExit, .noRelaysSatisfyingObfuscationSettings, .noRelaysSatisfyingDaitaConstraints, .readSettings, .invalidAccount, .accountExpired, .deviceRevoked, .unknown, .deviceLoggedOut, .outdatedSchema, .invalidRelayPublicKey, - .noRelaysSatisfyingPortConstraints, .tunnelAdapter: + .noRelaysSatisfyingPortConstraints, .tunnelAdapter, .offline: return false } } diff --git a/ios/PacketTunnelCore/Actor/State.swift b/ios/PacketTunnelCore/Actor/State.swift index e91f121f0a..71076c0525 100644 --- a/ios/PacketTunnelCore/Actor/State.swift +++ b/ios/PacketTunnelCore/Actor/State.swift @@ -238,6 +238,9 @@ public enum BlockedStateReason: String, Codable, Equatable, Sendable { /// Invalid public key. case invalidRelayPublicKey + /// Device is offline + case offline + /// Unidentified reason. case unknown @@ -247,7 +250,7 @@ public enum BlockedStateReason: String, Codable, Equatable, Sendable { case .deviceLocked, .multihopEntryEqualsExit, .outdatedSchema, .noRelaysSatisfyingConstraints, .noRelaysSatisfyingPortConstraints, .noRelaysSatisfyingDaitaConstraints, .noRelaysSatisfyingFilterConstraints, .noRelaysSatisfyingObfuscationSettings, .readSettings, - .invalidRelayPublicKey: + .invalidRelayPublicKey, .offline: return true case .deviceRevoked, .deviceLoggedOut, .tunnelAdapter, .accountExpired, .invalidAccount, .unknown: return false @@ -282,4 +285,7 @@ public enum ActorReconnectReason: Equatable, Sendable { /// Initiated by tunnel monitor due to loss of connectivity, or if ephemeral peer negotiation times out. /// Actor will increment the connection attempt counter before picking next relay. case connectionLoss + + /// Restored connectivity + case restoredConnectivity } diff --git a/ios/PacketTunnelCore/IPC/AppMessageHandler.swift b/ios/PacketTunnelCore/IPC/AppMessageHandler.swift index e0ef568a79..abe56a42d6 100644 --- a/ios/PacketTunnelCore/IPC/AppMessageHandler.swift +++ b/ios/PacketTunnelCore/IPC/AppMessageHandler.swift @@ -49,7 +49,7 @@ public struct AppMessageHandler { return nil case .getTunnelStatus: - return await encodeReply(packetTunnelActor.observedState) + return encodeReply(await packetTunnelActor.observedState) case .privateKeyRotation: packetTunnelActor.notifyKeyRotation(date: Date()) |
