From 58cd7a2e1ba6ef1b7bbb5feb4a6bd3c11a0ae9b0 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:02:34 +0200 Subject: [PATCH 1/3] fix on demand --- .../Source/App/ViewModels/MainViewModel.swift | 17 +++++ NetBird/Source/App/Views/MainView.swift | 5 +- NetbirdKit/NetworkExtensionAdapter.swift | 70 ++++++++++++------- .../PacketTunnelProvider.swift | 36 +++++++--- 4 files changed, 92 insertions(+), 36 deletions(-) diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 8c491ca..b3ba0d1 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -208,6 +208,13 @@ class ViewModel: ObservableObject { self.logger.info("connect: Task started, calling networkExtensionAdapter.start()") await self.networkExtensionAdapter.start() self.logger.info("connect: networkExtensionAdapter.start() completed") + // If start() returned but VPN never launched (e.g. IPC failed to get login URL) + // and the browser login sheet is not showing, the tunnel won't start on its own. + // Reset the stuck "Connecting..." state so the user can try again. + if self.extensionState == .disconnected && !self.networkExtensionAdapter.showBrowser { + self.connectPressed = false + self.updateVPNDisplayState() + } } } @@ -400,6 +407,7 @@ class ViewModel: ObservableObject { self.checkExtensionState() self.checkNetworkUnavailableFlag() + self.checkLoginRequiredFlag() if self.extensionState == .disconnected && self.vpnDisplayState == .connected { self.showAuthenticationRequired = true @@ -751,6 +759,15 @@ class ViewModel: ObservableObject { // Show authentication required UI self.showAuthenticationRequired = true + // Clear any stuck "Connecting..." state + self.connectPressed = false + self.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 checkExtensionState). + self.networkExtensionAdapter.setOnDemandEnabled(false) + // Schedule local notification if authorized UNUserNotificationCenter.current().getNotificationSettings { settings in guard settings.authorizationStatus == .authorized else { 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..99475a2 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -160,6 +160,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 +349,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 +532,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 +549,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) } } 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() From fdefc99be564ff860057c3bb3b8f1235491c68ba Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:12:20 +0200 Subject: [PATCH 2/3] fix connecting status --- .../Source/App/ViewModels/MainViewModel.swift | 31 +++++++++++++------ NetbirdKit/NetworkExtensionAdapter.swift | 14 +++++++-- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index b3ba0d1..785f852 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -208,13 +208,6 @@ class ViewModel: ObservableObject { self.logger.info("connect: Task started, calling networkExtensionAdapter.start()") await self.networkExtensionAdapter.start() self.logger.info("connect: networkExtensionAdapter.start() completed") - // If start() returned but VPN never launched (e.g. IPC failed to get login URL) - // and the browser login sheet is not showing, the tunnel won't start on its own. - // Reset the stuck "Connecting..." state so the user can try again. - if self.extensionState == .disconnected && !self.networkExtensionAdapter.showBrowser { - self.connectPressed = false - self.updateVPNDisplayState() - } } } @@ -365,11 +358,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) @@ -455,10 +458,18 @@ 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 let statuses : [NEVPNStatus] = [.connected, .disconnected, .connecting, .disconnecting] DispatchQueue.main.async { + self.isCheckingExtensionState = false if statuses.contains(status) && self.extensionState != status { print("Changing extension status to \(status.rawValue)") self.extensionState = status diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index 99475a2..e34afa7 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 } From f2878a0c98efd2123fde57f7a5fd047f39e1b492 Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:58:50 +0200 Subject: [PATCH 3/3] Fix first connection state --- NetbirdKit/NetworkExtensionAdapter.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index e34afa7..8a34451 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -905,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) } } }