summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorEmīls <emils@mullvad.net>2026-01-22 11:25:39 +0100
committerEmīls <emils@mullvad.net>2026-01-22 11:25:39 +0100
commit18e37645f64baccc37ef8004d6ba522e357115dc (patch)
tree5adba054b762fe83b28df33d8f2e8085b47b7d1e
parentcaf2d53e3b7ac583a5e81eb93c6b7a6bf7907544 (diff)
parent3844dda0f567cf82e4260f2c5e18106c7a94cee7 (diff)
downloadmullvadvpn-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
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj6
-rw-r--r--ios/MullvadVPN/Notifications/Notification Providers/TunnelStatusNotificationProvider.swift5
-rw-r--r--ios/MullvadVPN/TunnelManager/MapConnectionStatusOperation.swift183
-rw-r--r--ios/MullvadVPN/TunnelManager/Tunnel.swift12
-rw-r--r--ios/MullvadVPN/TunnelManager/TunnelManager.swift271
-rw-r--r--ios/MullvadVPN/TunnelManager/TunnelState.swift2
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift2
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/PacketTunnelCore/PacketTunnelActorReducerTests.swift6
-rw-r--r--ios/PacketTunnel/PacketTunnelProvider/PacketTunnelPathObserver.swift21
-rw-r--r--ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift65
-rw-r--r--ios/PacketTunnelCore/Actor/PacketTunnelActor.swift35
-rw-r--r--ios/PacketTunnelCore/Actor/PacketTunnelActorReducer.swift14
-rw-r--r--ios/PacketTunnelCore/Actor/State+Extensions.swift2
-rw-r--r--ios/PacketTunnelCore/Actor/State.swift8
-rw-r--r--ios/PacketTunnelCore/IPC/AppMessageHandler.swift2
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())