diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index c7732a2..cf98ae7 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -133,10 +133,10 @@ 50E608132A7958B100BAF09B /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608122A7958B100BAF09B /* MainViewModel.swift */; }; 50E608242A79966600BAF09B /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608232A79966600BAF09B /* AboutView.swift */; }; 50E608262A79968500BAF09B /* AdvancedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608252A79968500BAF09B /* AdvancedView.swift */; }; - 5554ACC22F7E59700047BDAC /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5554ACC12F7E59700047BDAC /* GoogleService-Info.plist */; }; - 5554ACC32F7E59700047BDAC /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5554ACC12F7E59700047BDAC /* GoogleService-Info.plist */; }; - 5554ACC42F7E59700047BDAC /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5554ACC12F7E59700047BDAC /* GoogleService-Info.plist */; }; - 5554ACC52F7E59700047BDAC /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5554ACC12F7E59700047BDAC /* GoogleService-Info.plist */; }; + 5573F6EE2F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; + 5573F6EF2F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; + 5573F6F02F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; + 5573F6F12F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; 55B5E81B2F39158200852AA7 /* InternetStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B5E81A2F39158200852AA7 /* InternetStatusView.swift */; }; 55D865852F70982000A2EFF8 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 55D865842F70982000A2EFF8 /* WidgetKit.framework */; }; 55D865872F70982000A2EFF8 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 55D865862F70982000A2EFF8 /* SwiftUI.framework */; }; @@ -337,7 +337,7 @@ 50E608232A79966600BAF09B /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 50E608252A79968500BAF09B /* AdvancedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedView.swift; sourceTree = ""; }; 53CB9305A9DC6CAD1895495A /* SharedUserDefaultsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedUserDefaultsTests.swift; sourceTree = ""; }; - 5554ACC12F7E59700047BDAC /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 55B5E81A2F39158200852AA7 /* InternetStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetStatusView.swift; sourceTree = ""; }; 55D865832F70982000A2EFF8 /* NetBirdWidgetExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NetBirdWidgetExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 55D865842F70982000A2EFF8 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -549,7 +549,7 @@ 50A8910E2A792A15007C48FC = { isa = PBXGroup; children = ( - 5554ACC12F7E59700047BDAC /* GoogleService-Info.plist */, + 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */, 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */, 50245A0A2A7AA9390034792B /* NetBird-Bridging-Header.h */, 50A891192A792A15007C48FC /* NetBird */, @@ -913,7 +913,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5554ACC42F7E59700047BDAC /* GoogleService-Info.plist in Resources */, + 5573F6EE2F9F523D00E63A73 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -921,7 +921,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5554ACC32F7E59700047BDAC /* GoogleService-Info.plist in Resources */, + 5573F6EF2F9F523D00E63A73 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -931,7 +931,7 @@ files = ( 501B0DCE2AE04DDE004BE7A7 /* button-disconnecting.json in Resources */, 501B0DD22AE04DDE004BE7A7 /* logo_NetBird.json in Resources */, - 5554ACC22F7E59700047BDAC /* GoogleService-Info.plist in Resources */, + 5573F6F12F9F523D00E63A73 /* GoogleService-Info.plist in Resources */, 501B0DDC2AE04DDE004BE7A7 /* button-connected.json in Resources */, 501B0DD62AE04DDE004BE7A7 /* button-start-connecting.json in Resources */, 501B0DDA2AE04DDE004BE7A7 /* button-full.json in Resources */, @@ -953,7 +953,7 @@ 501B0DCD2AE04DDE004BE7A7 /* button-disconnecting.json in Resources */, 501B0DD12AE04DDE004BE7A7 /* logo_NetBird.json in Resources */, 501B0DDB2AE04DDE004BE7A7 /* button-connected.json in Resources */, - 5554ACC52F7E59700047BDAC /* GoogleService-Info.plist in Resources */, + 5573F6F02F9F523D00E63A73 /* GoogleService-Info.plist in Resources */, 501B0DCF2AE04DDE004BE7A7 /* button-connecting-loop.json in Resources */, 501B0DD72AE04DDE004BE7A7 /* button-full2.json in Resources */, ); @@ -1685,7 +1685,7 @@ CODE_SIGN_ENTITLEMENTS = NetBirdWidgetExtension/NetBirdWidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 5; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = TA739QLA7A; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1724,7 +1724,7 @@ CODE_SIGN_ENTITLEMENTS = NetBirdWidgetExtension/NetBirdWidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = TA739QLA7A; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/NetBird/Source/App/NetBirdApp.swift b/NetBird/Source/App/NetBirdApp.swift index 54cf426..0ad80a3 100644 --- a/NetBird/Source/App/NetBirdApp.swift +++ b/NetBird/Source/App/NetBirdApp.swift @@ -11,13 +11,19 @@ import SwiftUI import FirebaseCore import Combine +import UserNotifications +import NetBirdSDK #if os(iOS) import FirebasePerformance #endif #if os(iOS) -class AppDelegate: NSObject, UIApplicationDelegate { +extension Notification.Name { + static let netbirdLoginNotificationTapped = Notification.Name("io.netbird.loginNotificationTapped") +} + +class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil @@ -26,8 +32,40 @@ class AppDelegate: NSObject, UIApplicationDelegate { let options = FirebaseOptions(contentsOfFile: path) { FirebaseApp.configure(options: options) } + + let center = UNUserNotificationCenter.current() + center.delegate = self + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + if let error = error { + AppLogger.shared.log("Notification authorization error: \(error.localizedDescription)") + } else { + AppLogger.shared.log("Notification authorization granted: \(granted)") + } + } + return true } + + // Show notification banner even when app is in foreground + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } + + // Handle tap on notification — post event so the app navigates to auth flow + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if response.notification.request.identifier == GlobalConstants.notificationLoginRequired { + NotificationCenter.default.post(name: .netbirdLoginNotificationTapped, object: nil) + } + completionHandler() + } } #endif @@ -76,6 +114,9 @@ struct NetBirdApp: App { .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in stopActivation(viewModel: viewModel) } + .onReceive(NotificationCenter.default.publisher(for: .netbirdLoginNotificationTapped)) { _ in + viewModel.showAuthenticationRequired = true + } #endif #if os(tvOS) .onAppear { diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 0ddf9ce..e48867a 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -159,6 +159,9 @@ class ViewModel: ObservableObject { private var cancellables = Set() private let networkMonitor = NWPathMonitor() private let monitorQueue = DispatchQueue(label: "io.netbird.networkMonitor") + #if os(iOS) + private var vpnStatusObserver: NSObjectProtocol? + #endif @Published var peerViewModel: PeerViewModel @Published var routeViewModel: RoutesViewModel @@ -198,6 +201,19 @@ class ViewModel: ObservableObject { } networkMonitor.start(queue: monitorQueue) + #if os(iOS) + // Observe VPN status changes even in background to deliver reliable local notifications. + // UNUserNotificationCenter in NEPacketTunnelProvider is unreliable — sending from + // the main app process is the only way notifications are guaranteed to be delivered. + vpnStatusObserver = NotificationCenter.default.addObserver( + forName: .NEVPNStatusDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleVPNStatusChangeForNotification() + } + #endif + $setupKey .removeDuplicates() .debounce(for: .seconds(0.5), scheduler: RunLoop.main) @@ -847,51 +863,79 @@ class ViewModel: ObservableObject { // tvOS: Network status is determined by extension state, not a shared flag } - /// Checks shared app-group container for login required flag set by the network extension. - /// If set, schedules a local notification (if authorized) and shows the authentication UI. - /// iOS only — tvOS uses IPC via `checkLoginError` in TVAuthView. - func checkLoginRequiredFlag() { - #if os(iOS) + /// Fires on every NEVPNStatusDidChange — runs in main app process, even when backgrounded. + /// Sends the notification from here because UNUserNotificationCenter in NEPacketTunnelProvider + /// does not reliably deliver notifications (known iOS limitation). + #if os(iOS) + private func handleVPNStatusChangeForNotification() { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) guard userDefaults?.bool(forKey: GlobalConstants.keyLoginRequired) == true else { return } + // Only notify when genuinely backgrounded; .inactive is a transitional state (e.g. app + // opening after a notification tap) — scheduling there fires extra banners via willPresent. + guard UIApplication.shared.applicationState == .background else { return } + // Clear the flag immediately so repeated NEVPNStatusDidChange events (VPN passes through + // several states during disconnect) don't each schedule their own notification. userDefaults?.set(false, forKey: GlobalConstants.keyLoginRequired) userDefaults?.synchronize() - 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) + AppLogger.shared.log("VPN status changed with loginRequired flag — scheduling notification from main app") scheduleLoginRequiredNotification() - #endif } - #if os(iOS) private func scheduleLoginRequiredNotification() { let center = UNUserNotificationCenter.current() center.getNotificationSettings { settings in - guard settings.authorizationStatus == .authorized else { return } + guard settings.authorizationStatus == .authorized else { + AppLogger.shared.log("Notifications not authorized, skipping notification") + return + } + + // Cancel the delayed best-effort notification scheduled by the extension + // so that only this one (from the main app process) is delivered. + center.removePendingNotificationRequests(withIdentifiers: [GlobalConstants.notificationLoginRequired]) + center.removeDeliveredNotifications(withIdentifiers: [GlobalConstants.notificationLoginRequired]) let content = UNMutableNotificationContent() - content.title = "NetBird" - content.body = "Login required. Please open the app to reconnect." + content.title = NSLocalizedString("notification_login_required_title", value: "VPN Disconnected", comment: "") + content.body = NSLocalizedString("notification_login_required_body", value: "Re-authentication required. Tap to log in and restore your VPN connection.", comment: "") content.sound = .default let request = UNNotificationRequest( - identifier: "netbird.login.required", + identifier: GlobalConstants.notificationLoginRequired, content: content, trigger: nil ) center.add(request) { error in if let error { AppLogger.shared.log("Failed to schedule login notification: \(error.localizedDescription)") + } else { + AppLogger.shared.log("Login notification scheduled from main app process") } } } } #endif + + /// Checks shared app-group container for login required flag set by the network extension. + /// Shows the authentication UI. Notification was already delivered via NEVPNStatusDidChange observer. + /// iOS only — tvOS uses IPC via `checkLoginError` in TVAuthView. + func checkLoginRequiredFlag() { + #if os(iOS) + let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) + guard userDefaults?.bool(forKey: GlobalConstants.keyLoginRequired) == true else { return } + + userDefaults?.set(false, forKey: GlobalConstants.keyLoginRequired) + userDefaults?.synchronize() + + 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) + #endif + } } diff --git a/NetbirdKit/ConnectionListener.swift b/NetbirdKit/ConnectionListener.swift index 1fa0f87..439bb6c 100644 --- a/NetbirdKit/ConnectionListener.swift +++ b/NetbirdKit/ConnectionListener.swift @@ -83,6 +83,13 @@ class ConnectionListener: NSObject, NetBirdSDKConnectionListenerProtocol { } else { adapter.clientState = .disconnected AppLogger.shared.log("onDisconnected: state=disconnected, wasRestarting=\(wasRestarting)") + + // If session expired (not a network drop), signal login required so the user + // gets a notification. needsLogin() checks the management server error code. + if !wasRestarting && adapter.needsLogin() { + AppLogger.shared.log("onDisconnected: login required detected — signalling") + adapter.onLoginRequired?() + } } adapter.notifyStopCompleted() } diff --git a/NetbirdKit/GlobalConstants.swift b/NetbirdKit/GlobalConstants.swift index 9834ed4..f46afa2 100644 --- a/NetbirdKit/GlobalConstants.swift +++ b/NetbirdKit/GlobalConstants.swift @@ -29,4 +29,7 @@ struct GlobalConstants { static let configFileName = "netbird.cfg" static let stateFileName = "state.json" static let serverURLFileName = "netbird_server_url" + + // Local notification identifiers + static let notificationLoginRequired = "netbird.login.required" } diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index b22b807..107937f 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -112,6 +112,10 @@ public class NetBirdAdapter { set { networkUnavailableLock.lock(); defer { networkUnavailableLock.unlock() }; _isNetworkUnavailable = newValue } } + /// Called when the SDK disconnects due to an expired or invalid auth token. + /// Set by PacketTunnelProvider to trigger flag + notification from the extension. + var onLoginRequired: (() -> Void)? + private let stopLock = NSLock() /// Tunnel device file descriptor. diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 380ee20..fa5d52a 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -83,6 +83,12 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } + // Wire up login-required callback so ConnectionListener can signal it during + // an active session (e.g. token expires while VPN is running without On-Demand). + adapter.onLoginRequired = { [weak self] in + self?.signalLoginRequired() + } + adapter.start(completionHandler: completionHandler) } @@ -348,29 +354,30 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } private func sendLoginNotificationBestEffort() { - UNUserNotificationCenter.current().getNotificationSettings { settings in - guard settings.authorizationStatus == .authorized else { - AppLogger.shared.log("Notifications not authorized, skipping extension notification attempt") - return - } - - let content = UNMutableNotificationContent() - content.title = "NetBird" - content.body = "Login required. Please open the app to reconnect." - content.sound = .default - - let request = UNNotificationRequest( - identifier: "netbird.login.required", - content: content, - trigger: nil - ) + // Skip authorization check — in a Network Extension context, + // UNUserNotificationCenter reports the extension bundle's status + // (always .notDetermined), not the containing app's granted permission. + // Attempt delivery unconditionally and let the system reject if needed. + let content = UNMutableNotificationContent() + content.title = NSLocalizedString("notification_login_required_title", value: "VPN Disconnected", comment: "") + content.body = NSLocalizedString("notification_login_required_body", value: "Re-authentication required. Tap to log in and restore your VPN connection.", comment: "") + content.sound = .default + + // Delayed so the main app process (if backgrounded) can cancel this pending request + // and deliver its own — prevents the duplicate that occurs when both paths fire. + // If the app is force-quit, the delay expires and this notification fires instead. + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false) + let request = UNNotificationRequest( + identifier: GlobalConstants.notificationLoginRequired, + content: content, + trigger: trigger + ) - UNUserNotificationCenter.current().add(request) { error in - if let error = error { - AppLogger.shared.log("Extension notification attempt failed (expected): \(error.localizedDescription)") - } else { - AppLogger.shared.log("Extension notification attempt succeeded") - } + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + AppLogger.shared.log("Extension notification attempt failed: \(error.localizedDescription)") + } else { + AppLogger.shared.log("Extension notification scheduled with 3s delay") } } } diff --git a/netbird-core b/netbird-core index 3dd34c9..c1d1229 160000 --- a/netbird-core +++ b/netbird-core @@ -1 +1 @@ -Subproject commit 3dd34c920ec30f3797aad0ec87fb3d92a328dda1 +Subproject commit c1d1229ae0cb402fd4e16ee61f9f7c4652099b08