Skip to content
Open
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
24 changes: 12 additions & 12 deletions NetBird.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -337,7 +337,7 @@
50E608232A79966600BAF09B /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
50E608252A79968500BAF09B /* AdvancedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedView.swift; sourceTree = "<group>"; };
53CB9305A9DC6CAD1895495A /* SharedUserDefaultsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedUserDefaultsTests.swift; sourceTree = "<group>"; };
5554ACC12F7E59700047BDAC /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
55B5E81A2F39158200852AA7 /* InternetStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetStatusView.swift; sourceTree = "<group>"; };
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; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -913,15 +913,15 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5554ACC42F7E59700047BDAC /* GoogleService-Info.plist in Resources */,
5573F6EE2F9F523D00E63A73 /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
441C5AFB2EDF0DD20055EEFC /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5554ACC32F7E59700047BDAC /* GoogleService-Info.plist in Resources */,
5573F6EF2F9F523D00E63A73 /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand All @@ -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 */,
Expand All @@ -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 */,
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
43 changes: 42 additions & 1 deletion NetBird/Source/App/NetBirdApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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 {
Expand Down
82 changes: 63 additions & 19 deletions NetBird/Source/App/ViewModels/MainViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ class ViewModel: ObservableObject {
private var cancellables = Set<AnyCancellable>()
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
7 changes: 7 additions & 0 deletions NetbirdKit/ConnectionListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
3 changes: 3 additions & 0 deletions NetbirdKit/GlobalConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
4 changes: 4 additions & 0 deletions NetbirdNetworkExtension/NetBirdAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading