diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index eb7930a..b626767 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -361,11 +361,21 @@ class ViewModel: ObservableObject { disconnectPressed = false newState = .connected case .connecting: - connectPressed = false + // Do NOT clear connectPressed here — iOS can emit .disconnecting right after + // .connecting during tunnel startup (cleanup of old instance). Keeping + // connectPressed=true lets the .disconnecting handler suppress that noise. + // connectPressed is cleared only on .connected or .disconnected. newState = .connecting case .disconnecting: - disconnectPressed = false - newState = .disconnecting + // Ignore transient .disconnecting emitted by iOS VPN framework during tunnel startup. + // When startVPNTunnel() is called, iOS briefly reports .disconnecting while cleaning + // up the previous tunnel instance — even though the user pressed Connect, not Disconnect. + if connectPressed { + newState = .connecting + } else { + disconnectPressed = false + newState = .disconnecting + } case .disconnected: // Extension confirmed disconnected — clear both flags, // unless a flag was JUST set (immediate feedback) @@ -424,6 +434,7 @@ class ViewModel: ObservableObject { networkExtensionAdapter.startTimer { details in self.checkExtensionState() self.checkNetworkUnavailableFlag() + self.checkLoginRequiredFlag() self.updateDetailsIfChanged(details) self.updatePeersIfChanged(details) self.statusDetailsValid = true @@ -477,9 +488,17 @@ class ViewModel: ObservableObject { networkExtensionAdapter.stopTimer() } + // Prevents overlapping getExtensionStatus calls from delivering out-of-order results. + // loadAllFromPreferences() is slow and variable; without this guard a stale .disconnecting + // completion can arrive after a newer .disconnected one, causing a spurious Disconnecting flash. + private var isCheckingExtensionState = false + func checkExtensionState() { + guard !isCheckingExtensionState else { return } + isCheckingExtensionState = true networkExtensionAdapter.getExtensionStatus { status in DispatchQueue.main.async { + self.isCheckingExtensionState = false self.applyExtensionStatus(status) } } @@ -776,6 +795,12 @@ class ViewModel: ObservableObject { AppLogger.shared.log("Login required flag detected from extension") showAuthenticationRequired = true + connectPressed = false + updateVPNDisplayState() + // Temporarily disable On Demand to stop iOS from looping reconnect attempts + // while the user is not authenticated. It will be re-enabled automatically + // after a successful connection (see applyExtensionStatus). + networkExtensionAdapter.setOnDemandEnabled(false) scheduleLoginRequiredNotification() #endif } diff --git a/NetBird/Source/App/Views/MainView.swift b/NetBird/Source/App/Views/MainView.swift index 609f96b..b1bbc7e 100644 --- a/NetBird/Source/App/Views/MainView.swift +++ b/NetBird/Source/App/Views/MainView.swift @@ -104,7 +104,10 @@ struct iOSMainView: View { return Alert( title: Text("Authentication required"), message: Text("The server requires a new authentication."), - dismissButton: .default(Text("OK")) + primaryButton: .default(Text("Login")) { + viewModel.connect() + }, + secondaryButton: .cancel(Text("Later")) ) case .onDemandConflict: return Alert( diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index d937141..8a34451 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -91,14 +91,22 @@ public class NetworkExtensionAdapter: ObservableObject { let managers = try await NETunnelProviderManager.loadAllFromPreferences() if let manager = managers.first(where: { $0.localizedDescription == self.extensionName }) { self.vpnManager = manager + // Only write preferences when strictly necessary. + // Calling saveToPreferences() on an already-configured manager triggers + // NEVPNStatusDidChange notifications — including a transient .disconnecting — + // that the polling timer picks up, producing the wrong UI sequence: + // Connecting → Disconnecting → Connected. + if !manager.isEnabled { + manager.isEnabled = true + try await manager.saveToPreferences() + try await manager.loadFromPreferences() + } } else { let newManager = createNewManager() try await newManager.saveToPreferences() + try await newManager.loadFromPreferences() self.vpnManager = newManager } - self.vpnManager?.isEnabled = true - try await self.vpnManager?.saveToPreferences() - try await self.vpnManager?.loadFromPreferences() self.session = self.vpnManager?.connection as? NETunnelProviderSession } @@ -160,6 +168,18 @@ public class NetworkExtensionAdapter: ObservableObject { // Note: For tvOS, config initialization happens in the extension's startTunnel // before the needsLogin check. The extension has permission to write to App Group. await performLogin() + + #if os(iOS) + // If performLogin() didn't open the browser, the IPC failed because the extension + // is not running. Start the VPN connection anyway so the extension process starts, + // detects login required, signals the main app via UserDefaults, and stays alive + // long enough for the user to press Connect from the auth alert (which will retry + // IPC to a still-alive extension and succeed). + if !self.showBrowser { + logger.info("loginIfRequired: IPC failed (extension not running), starting VPN to launch extension") + startVPNConnection() + } + #endif } else { logger.info("loginIfRequired: login NOT required, calling startVPNConnection()") startVPNConnection() @@ -337,13 +357,18 @@ public class NetworkExtensionAdapter: ObservableObject { } private func performLogin() async { - let loginURLString = await withCheckedContinuation { continuation in + let loginURLString: String? = await withCheckedContinuation { continuation in self.login { urlString in continuation.resume(returning: urlString) } } - self.loginURL = loginURLString + guard let url = loginURLString, !url.isEmpty else { + logger.error("performLogin: no login URL received from extension, aborting") + return + } + + self.loginURL = url self.showBrowser = true } @@ -515,9 +540,10 @@ public class NetworkExtensionAdapter: ObservableObject { return vpnManager?.isOnDemandEnabled ?? false } - func login(completion: @escaping (String) -> Void) { - if self.session == nil { + func login(completion: @escaping (String?) -> Void) { + guard let session = self.session else { logger.error("login: No session available for login") + completion(nil) return } @@ -531,36 +557,36 @@ public class NetworkExtensionAdapter: ObservableObject { if let messageData = messageString.data(using: .utf8) { // Send the message to the network extension - try self.session!.sendProviderMessage(messageData) { response in - if let response = response { - #if os(tvOS) - // For tvOS, decode DeviceAuthResponse struct - do { - let authResponse = try self.decoder.decode(DeviceAuthResponse.self, from: response) - DispatchQueue.main.async { - self.userCode = authResponse.userCode - } - completion(authResponse.url) - } catch { - print("login: Failed to decode DeviceAuthResponse - \(error)") - // Fallback to plain string for backwards compatibility - if let string = String(data: response, encoding: .utf8) { - completion(string) - } - } - #else - if let string = String(data: response, encoding: .utf8) { - completion(string) - } - #endif + try session.sendProviderMessage(messageData) { response in + guard let response = response else { + self.logger.error("login: No response from extension") + completion(nil) return } + #if os(tvOS) + // For tvOS, decode DeviceAuthResponse struct + do { + let authResponse = try self.decoder.decode(DeviceAuthResponse.self, from: response) + DispatchQueue.main.async { + self.userCode = authResponse.userCode + } + completion(authResponse.url) + } catch { + print("login: Failed to decode DeviceAuthResponse - \(error)") + // Fallback to plain string for backwards compatibility + completion(String(data: response, encoding: .utf8)) + } + #else + completion(String(data: response, encoding: .utf8)) + #endif } } else { print("Error converting message to Data") + completion(nil) } } catch { print("error when performing network extension action") + completion(nil) } } @@ -879,9 +905,15 @@ public class NetworkExtensionAdapter: ObservableObject { let managers = try await NETunnelProviderManager.loadAllFromPreferences() if let manager = managers.first(where: { $0.localizedDescription == self.extensionName }) { completion(manager.connection.status) + } else { + // No VPN manager exists yet (e.g. first connect before the iOS permission + // dialog completes). Must still call completion so that isCheckingExtensionState + // is reset to false; otherwise checkExtensionState() is permanently blocked. + completion(.disconnected) } } catch { print("Error loading from preferences: \(error)") + completion(.disconnected) } } } diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index dff0d3b..7cbf4ad 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -55,14 +55,16 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } if adapter.needsLogin() { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - let error = NSError( - domain: "io.netbird.NetbirdNetworkExtension", - code: 1001, - userInfo: [NSLocalizedDescriptionKey: "Login required."] - ) - completionHandler(error) - } + signalLoginRequired() + // Return the error immediately so iOS tears down the tunnel interface at once. + // A deferred completionHandler keeps the tunnel interface alive (black-hole state) + // and intercepts all network traffic — including ASWebAuthenticationSession requests + // to the OAuth server — causing "Page not found" during re-auth with On Demand enabled. + completionHandler(NSError( + domain: "io.netbird.NetbirdNetworkExtension", + code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Login required."] + )) return } @@ -243,7 +245,23 @@ class PacketTunnelProvider: NEPacketTunnelProvider { monitorQueue.asyncAfter(deadline: .now() + 30, execute: timeoutWorkItem) adapter.stop { [weak self] in - AppLogger.shared.log("restartClient: stop completed, starting client") + AppLogger.shared.log("restartClient: stop completed, checking login status") + + // Tokens may have expired during a network change (common with self-hosted servers + // that have shorter token lifetimes). Check before restarting; if login is required + // signal the main app so it can show the re-auth UI instead of silently failing. + if self?.adapter?.needsLogin() == true { + AppLogger.shared.log("restartClient: login required — signaling main app, skipping restart") + self?.signalLoginRequired() + self?.monitorQueue.async { + self?.adapter?.isRestarting = false + self?.isRestartInProgress = false + } + timeoutWorkItem.cancel() + return + } + + AppLogger.shared.log("restartClient: starting client") self?.adapter?.start { error in // Cancel timeout whether start succeeds or not timeoutWorkItem.cancel()