Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions NetBird/Source/App/ViewModels/MainViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
}
Expand Down
5 changes: 4 additions & 1 deletion NetBird/Source/App/Views/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
90 changes: 61 additions & 29 deletions NetbirdKit/NetworkExtensionAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}
}
Expand Down
36 changes: 27 additions & 9 deletions NetbirdNetworkExtension/PacketTunnelProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down
Loading