// // MainViewModel.swift // NetBirdiOS // // Created by Pascal Fischer on 01.08.23. // // This ViewModel is shared between iOS and tvOS. // Platform-specific code is wrapped with #if os() directives. // import SwiftUI import NetworkExtension import Network import os import Combine import NetBirdSDK import UserNotifications #if os(iOS) import WidgetKit #endif /// Used by updateManagementURL to check if SSO is supported class SSOCheckListener: NSObject, NetBirdSDKSSOListenerProtocol { var onResult: ((Bool?, Error?) -> Void)? func onError(_ p0: Error?) { onResult?(nil, p0) } func onSuccess(_ p0: Bool) { onResult?(p0, nil) } } // Error Listener for setup key login /// Used by setSetupKey to handle async login result class SetupKeyErrListener: NSObject, NetBirdSDKErrListenerProtocol { var onResult: ((Error?) -> Void)? func onError(_ p0: Error?) { onResult?(p0) } func onSuccess() { onResult?(nil) } } enum VPNDisplayState { case connected case connecting case disconnecting case disconnected } /// For both iOS and tvOS (tvOS 17+ required for VPN support). @MainActor class ViewModel: ObservableObject { private let logger = Logger(subsystem: "io.netbird.app", category: "ViewModel") // VPN Adapter (shared) @Published var networkExtensionAdapter: NetworkExtensionAdapter // UI State (shared) @Published var showSetupKeyPopup = false @Published var showChangeServerAlert = false @Published var showInvalidServerAlert = false @Published var showInvalidSetupKeyHint = false @Published var showInvalidSetupKeyAlert = false @Published var showLogLevelChangedAlert = false @Published var showBetaProgramAlert = false @Published var showInvalidPresharedKeyAlert = false @Published var showServerChangedInfo = false @Published var showPreSharedKeyChangedInfo = false @Published var showFqdnCopiedAlert = false @Published var showIpCopiedAlert = false @Published var showAuthenticationRequired = false @Published var navigateToServerView = false @Published var navigateToProfilesView = false #if os(iOS) @Published var activeProfileName: String = ProfileManager.shared.getActiveProfileName() #endif @Published var extensionState: NEVPNStatus = .disconnected @Published var managementStatus: ClientState = .disconnected @Published var statusDetailsValid = false @Published var extensionStateText = "Disconnected" @Published var vpnDisplayState: VPNDisplayState = .disconnected var connectPressed = false var disconnectPressed = false @Published var rosenpassEnabled = false @Published var rosenpassPermissive = false @Published var presharedKey = "" @Published var server: String = "" @Published var setupKey: String = "" @Published var presharedKeySecure = true @Published var fqdn = "" @Published var ip = "" // Debug @Published var traceLogsEnabled: Bool { didSet { self.showLogLevelChangedAlert = true DispatchQueue.main.asyncAfter(deadline: .now() + 3) { self.showLogLevelChangedAlert = false } let logLevel = traceLogsEnabled ? "TRACE" : "INFO" UserDefaults.standard.set(logLevel, forKey: "logLevel") UserDefaults.standard.synchronize() } } @Published var forceRelayConnection = true @Published var showForceRelayAlert = false @Published var connectOnDemand = false @Published var showOnDemandAlert = false @Published var showOnDemandConflictAlert = false @Published var showOnDemandDisconnectAlert = false @Published var onDemandWiFiPolicy: WiFiOnDemandPolicy = .always @Published var onDemandCellularPolicy: CellularOnDemandPolicy = .always @Published var onDemandWiFiNetworks: [String] = [] @Published var knownSSIDs: [String] = [] @Published var showRosenpassChangedAlert = false @Published var networkUnavailable = false @Published var isInternetConnected = true /// Platform-agnostic configuration provider. /// Abstracts iOS SDK preferences vs tvOS UserDefaults + IPC. private lazy var configProvider: ConfigurationProvider = ConfigurationProviderFactory.create() var buttonLock = false let defaults = UserDefaults.standard // MARK: - Per-profile connection info #if os(iOS) private let profileConnectionCache = ProfileConnectionCache() #endif /// While true the polling timer must not overwrite ip/fqdn/peers. /// Set when switching profiles; cleared once the extension fully /// disconnects and then reconnects to the new profile. private var profileSwitchPending = false private var previousExtensionState: NEVPNStatus = .disconnected /// Loads cached ip/fqdn for the given profile into the published properties. /// Shows empty strings if no data has been saved for that profile yet. func loadConnectionInfoForProfile(_ profileName: String) { #if os(iOS) let entry = profileConnectionCache.entry(for: profileName) ip = entry?.ip ?? "" fqdn = entry?.fqdn ?? "" #endif } private var cancellables = Set() private let networkMonitor = NWPathMonitor() private let monitorQueue = DispatchQueue(label: "io.netbird.networkMonitor") @Published var peerViewModel: PeerViewModel @Published var routeViewModel: RoutesViewModel init() { let networkExtensionAdapter = NetworkExtensionAdapter() self.networkExtensionAdapter = networkExtensionAdapter let logLevel = UserDefaults.standard.string(forKey: "logLevel") ?? "INFO" self.traceLogsEnabled = logLevel == "TRACE" self.peerViewModel = PeerViewModel() self.routeViewModel = RoutesViewModel(networkExtensionAdapter: networkExtensionAdapter) // Load cached connection info for the active profile #if os(iOS) let activeProfile = ProfileManager.shared.getActiveProfileName() let cache = ProfileConnectionCache() let cached = cache.entry(for: activeProfile) self.ip = cached?.ip ?? "" self.fqdn = cached?.fqdn ?? "" #endif // Don't load rosenpass settings during init - they trigger expensive SDK initialization. // These will be loaded lazily when the settings view is accessed. // self.rosenpassEnabled = self.getRosenpassEnabled() // self.rosenpassPermissive = self.getRosenpassPermissive() // forceRelayConnection uses UserDefaults (not SDK), so it's safe to load during init self.forceRelayConnection = self.getForcedRelayConnectionEnabled() self.connectOnDemand = self.getConnectOnDemandEnabled() self.loadOnDemandSettings() networkMonitor.pathUpdateHandler = { [weak self] path in DispatchQueue.main.async { self?.isInternetConnected = path.status == .satisfied self?.updateVPNDisplayState() } } networkMonitor.start(queue: monitorQueue) $setupKey .removeDuplicates() .debounce(for: .seconds(0.5), scheduler: RunLoop.main) .map { setupKey in !self.isValidSetupKey(setupKey) } .assign(to: &$showInvalidSetupKeyHint) } func connect() { logger.info("connect: ENTRY POINT - function called") #if os(iOS) // Check if On Demand rules would block the connection on the current interface if connectOnDemand && !onDemandRulesAllowConnect() { logger.info("connect: On Demand rules conflict with current network, showing alert") showOnDemandConflictAlert = true return } #endif performConnect() } /// Performs the actual VPN connection (called directly or after user dismisses On Demand conflict). func performConnect() { self.connectPressed = true self.buttonLock = true // Reset networkUnavailable flag when user initiates connection self.networkUnavailable = false #if os(iOS) let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) userDefaults?.set(false, forKey: GlobalConstants.keyNetworkUnavailable) userDefaults?.synchronize() #endif updateVPNDisplayState() logger.info("connect: connectPressed=true, buttonLock=true, starting adapter...") // Reset buttonLock after 3 seconds DispatchQueue.main.asyncAfter(deadline: .now() + 3) { self.buttonLock = false } // Start the VPN connection Task { 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() } } } /// Disables On Demand and connects (user chose to override conflicting rules). func connectWithOnDemandDisabled() { setConnectOnDemand(isEnabled: false) performConnect() } #if os(iOS) /// Checks whether On Demand rules would allow a connection on the current network interface. /// Uses NWPathMonitor snapshot and current SSID to evaluate against saved policies. private func onDemandRulesAllowConnect() -> Bool { let path = networkMonitor.currentPath // Determine which interface is active let isOnWiFi = path.usesInterfaceType(.wifi) let isOnCellular = path.usesInterfaceType(.cellular) if isOnWiFi { switch onDemandWiFiPolicy { case .never: return false case .onlyOn: guard let currentSSID = getCurrentSSID(), !currentSSID.isEmpty else { return false } return onDemandWiFiNetworks.contains(currentSSID) case .exceptOn: guard let currentSSID = getCurrentSSID(), !currentSSID.isEmpty else { return true } return !onDemandWiFiNetworks.contains(currentSSID) case .always, .doNothing: return true } } if isOnCellular { switch onDemandCellularPolicy { case .never: return false case .always, .doNothing: return true } } return true } /// Checks whether On Demand has an active connect rule that will reconnect the tunnel /// after a manual disconnect. Unlike onDemandRulesAllowConnect(), this excludes .doNothing /// and only evaluates the currently active interface. private func onDemandWillReconnect() -> Bool { let path = networkMonitor.currentPath let isOnWiFi = path.usesInterfaceType(.wifi) let isOnCellular = path.usesInterfaceType(.cellular) if isOnWiFi { switch onDemandWiFiPolicy { case .always: return true case .onlyOn: guard let currentSSID = getCurrentSSID(), !currentSSID.isEmpty else { return false } return onDemandWiFiNetworks.contains(currentSSID) case .exceptOn: guard let currentSSID = getCurrentSSID(), !currentSSID.isEmpty else { return false } return !onDemandWiFiNetworks.contains(currentSSID) case .never, .doNothing: return false } } else if isOnCellular { switch onDemandCellularPolicy { case .always: return true case .never, .doNothing: return false } } return false } private func getCurrentSSID() -> String? { // Synchronous check not possible with NEHotspotNetwork.fetchCurrent() // Use cached value from last fetch if available return _cachedSSID } private var _cachedSSID: String? func refreshCurrentSSID() { NEHotspotNetwork.fetchCurrent { [weak self] network in DispatchQueue.main.async { self?._cachedSSID = network?.ssid } } } #endif func close() -> Void { #if os(iOS) // Warn user that On Demand will reconnect if rules match if connectOnDemand && onDemandRulesAllowConnect() { showOnDemandDisconnectAlert = true return } #endif performClose() } /// Performs the actual VPN disconnect. func performClose() { self.disconnectPressed = true DispatchQueue.main.async { print("Stopping extension") self.buttonLock = true DispatchQueue.main.asyncAfter(deadline: .now() + 3) { self.buttonLock = false } self.networkExtensionAdapter.stop() self.updateVPNDisplayState() } } /// Disables On Demand and disconnects (user chose to prevent auto-reconnect). func closeWithOnDemandDisabled() { setConnectOnDemand(isEnabled: false) performClose() } func updateVPNDisplayState() { let newState: VPNDisplayState // Extension state is the source of truth. // Flags only provide immediate UI feedback for the brief gap // between button press and extension state change. switch extensionState { case .connected: // Extension confirmed connected — clear both flags connectPressed = false disconnectPressed = false newState = .connected case .connecting: // 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: // 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) if connectPressed { newState = .connecting } else { disconnectPressed = false newState = .disconnected } default: connectPressed = false disconnectPressed = false newState = .disconnected } vpnDisplayState = newState switch newState { case .connected: extensionStateText = isInternetConnected ? "Connected" : "Offline" case .connecting: extensionStateText = "Connecting..." case .disconnecting: extensionStateText = "Disconnecting..." case .disconnected: extensionStateText = "Disconnected" } #if os(iOS) updateWidgetState() #endif } #if os(iOS) /// Writes current VPN state to shared UserDefaults so the widget can read it. private func updateWidgetState() { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) let statusString: String switch vpnDisplayState { case .connected: statusString = "connected" case .connecting: statusString = "connecting" case .disconnecting: statusString = "disconnecting" case .disconnected: statusString = "disconnected" } userDefaults?.set(statusString, forKey: GlobalConstants.keyWidgetVPNStatus) userDefaults?.set(ip, forKey: GlobalConstants.keyWidgetIP) userDefaults?.set(fqdn, forKey: GlobalConstants.keyWidgetFQDN) WidgetCenter.shared.reloadAllTimelines() } #endif func startPollingDetails() { #if os(iOS) refreshCurrentSSID() #endif networkExtensionAdapter.startTimer { details in self.checkExtensionState() self.checkNetworkUnavailableFlag() self.checkLoginRequiredFlag() let currentState = self.extensionState // Detect reconnection after a profile switch: // the guard lifts only once the extension has gone through a // non-connected state and then comes back as .connected. if self.profileSwitchPending { if self.previousExtensionState != .connected && currentState == .connected { self.profileSwitchPending = false } self.previousExtensionState = currentState } if currentState == .disconnected && self.vpnDisplayState == .connected { self.showAuthenticationRequired = true self.updateVPNDisplayState() } // Only update ip/fqdn/peers when the extension is connected // AND we are not mid-profile-switch (guard ensures we don't // overwrite the new profile's cached data with the old tunnel's values). if !self.profileSwitchPending && currentState == .connected { let newFqdn = details.fqdn.isEmpty ? self.fqdn : details.fqdn let newIp = details.ip.isEmpty ? self.ip : details.ip let changed = newFqdn != self.fqdn || newIp != self.ip if changed { self.fqdn = newFqdn self.ip = newIp #if os(iOS) let profile = ProfileManager.shared.getActiveProfileName() self.profileConnectionCache.save(ip: newIp, fqdn: newFqdn, for: profile) #endif } let sortedPeerInfo = details.peerInfo.sorted { $0.ip < $1.ip } if sortedPeerInfo.count != self.peerViewModel.peerInfo.count || !sortedPeerInfo.elementsEqual(self.peerViewModel.peerInfo, by: { a, b in a.ip == b.ip && a.connStatus == b.connStatus && a.relayed == b.relayed && a.direct == b.direct && a.connStatusUpdate == b.connStatusUpdate && a.routes.count == b.routes.count }) { print("Setting new peer info: \(sortedPeerInfo.count) Peers") self.peerViewModel.peerInfo = sortedPeerInfo } } if details.managementStatus != self.managementStatus { print("Status: \(details.managementStatus) - Extension: \(currentState)") self.managementStatus = details.managementStatus self.updateVPNDisplayState() } self.statusDetailsValid = true } } func stopPollingDetails() { 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) } } } private func applyExtensionStatus(_ status: NEVPNStatus) { let knownStatuses: Set = [.connected, .disconnected, .connecting, .disconnecting] guard knownStatuses.contains(status), extensionState != status else { return } extensionState = status updateVPNDisplayState() if status == .connected { routeViewModel.getRoutes() if connectOnDemand { networkExtensionAdapter.setOnDemandEnabled(true) } } } func clearDetails() { self.ip = "" self.fqdn = "" defaults.removeObject(forKey: "ip") defaults.removeObject(forKey: "fqdn") // Disable and persist On Demand off to keep UI/storage/manager in sync setConnectOnDemand(isEnabled: false) // Clear config JSON (contains server credentials and all settings) Preferences.removeConfigFromUserDefaults() // Reset @Published properties to reflect cleared state in UI self.rosenpassEnabled = false self.rosenpassPermissive = false self.presharedKey = "" self.presharedKeySecure = false #if os(tvOS) // Also clear extension-local config to prevent stale credentials networkExtensionAdapter.clearExtensionConfig() #endif } // MARK: - Configuration Methods (via ConfigurationProvider) func updatePreSharedKey() { configProvider.preSharedKey = presharedKey if configProvider.commit() { self.close() self.presharedKeySecure = true self.showPreSharedKeyChangedInfo = true } else { print("Failed to update preshared key") } } func removePreSharedKey() { presharedKey = "" configProvider.preSharedKey = "" if configProvider.commit() { self.close() self.presharedKeySecure = false } else { print("Failed to remove preshared key") } } func loadPreSharedKey() { self.presharedKey = configProvider.preSharedKey self.presharedKeySecure = configProvider.hasPreSharedKey } func setRosenpassEnabled(enabled: Bool) { // Update @Published property for immediate UI feedback self.rosenpassEnabled = enabled // Persist to storage (on tvOS this writes directly to config JSON) configProvider.rosenpassEnabled = enabled if !configProvider.commit() { print("Failed to update rosenpass settings") } #if os(tvOS) // Show reconnect alert if currently connected if extensionState == .connected { showRosenpassChangedAlert = true } #endif } func getRosenpassEnabled() -> Bool { return configProvider.rosenpassEnabled } func getRosenpassPermissive() -> Bool { return configProvider.rosenpassPermissive } /// Loads Rosenpass settings from the configuration provider into the @Published properties. /// Call this when opening settings views to sync UI with stored values. /// On iOS, this triggers SDK initialization, so it's deferred until needed. /// On tvOS, this reads from UserDefaults which is fast. func loadRosenpassSettings() { self.rosenpassEnabled = configProvider.rosenpassEnabled self.rosenpassPermissive = configProvider.rosenpassPermissive } func setRosenpassPermissive(permissive: Bool) { // Update @Published property for immediate UI feedback self.rosenpassPermissive = permissive // Persist to storage (on tvOS this writes directly to config JSON) configProvider.rosenpassPermissive = permissive if !configProvider.commit() { print("Failed to update rosenpass permissive settings") } } /// Reloads configuration from persistent storage. /// Call this after server changes or when returning to settings view. func reloadConfiguration() { configProvider.reload() // Sync @Published properties with reloaded config values loadRosenpassSettings() } /// Switches connection display data to the given profile's cached values. /// Call this when switching profiles so the new profile's last known info is shown immediately. func switchConnectionInfo(to profileName: String) { // Load cached data for the target profile so the UI shows it right away. loadConnectionInfoForProfile(profileName) peerViewModel.peerInfo = [] managementStatus = .disconnected updateVPNDisplayState() // Block polling from overwriting the new profile's data until the extension // has fully disconnected and reconnected to the new profile. // Seed previousExtensionState with the CURRENT extension state so the guard // only fires on a genuine non-connected → connected transition. // (Setting it to .disconnected would falsely trigger on the very next tick // while the old tunnel is still connected.) profileSwitchPending = true previousExtensionState = extensionState } func setForcedRelayConnection(isEnabled: Bool) { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) userDefaults?.set(isEnabled, forKey: GlobalConstants.keyForceRelayConnection) self.forceRelayConnection = isEnabled self.showForceRelayAlert = true } func getForcedRelayConnectionEnabled() -> Bool { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) #if os(iOS) userDefaults?.register(defaults: [GlobalConstants.keyForceRelayConnection: true]) return userDefaults?.bool(forKey: GlobalConstants.keyForceRelayConnection) ?? true #else // forced relay battery optimization not need on Apple Tv userDefaults?.register(defaults: [GlobalConstants.keyForceRelayConnection: false]) return userDefaults?.bool(forKey: GlobalConstants.keyForceRelayConnection) ?? false #endif } func setConnectOnDemand(isEnabled: Bool) { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) userDefaults?.set(isEnabled, forKey: GlobalConstants.keyConnectOnDemand) self.connectOnDemand = isEnabled networkExtensionAdapter.setOnDemandEnabled(isEnabled) if isEnabled { self.showOnDemandAlert = true } } func getConnectOnDemandEnabled() -> Bool { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) return userDefaults?.bool(forKey: GlobalConstants.keyConnectOnDemand) ?? false } func loadOnDemandSettings() { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) let wifiRaw = userDefaults?.string(forKey: GlobalConstants.keyOnDemandWiFiPolicy) ?? WiFiOnDemandPolicy.always.rawValue let cellularRaw = userDefaults?.string(forKey: GlobalConstants.keyOnDemandCellularPolicy) ?? CellularOnDemandPolicy.always.rawValue self.onDemandWiFiPolicy = WiFiOnDemandPolicy(rawValue: wifiRaw) ?? .always self.onDemandCellularPolicy = CellularOnDemandPolicy(rawValue: cellularRaw) ?? .always self.onDemandWiFiNetworks = userDefaults?.stringArray(forKey: GlobalConstants.keyOnDemandWiFiNetworks) ?? [] self.knownSSIDs = userDefaults?.stringArray(forKey: GlobalConstants.keyKnownSSIDs) ?? [] } func saveOnDemandSettings() { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) userDefaults?.set(onDemandWiFiPolicy.rawValue, forKey: GlobalConstants.keyOnDemandWiFiPolicy) userDefaults?.set(onDemandCellularPolicy.rawValue, forKey: GlobalConstants.keyOnDemandCellularPolicy) userDefaults?.set(onDemandWiFiNetworks, forKey: GlobalConstants.keyOnDemandWiFiNetworks) if connectOnDemand { networkExtensionAdapter.applyOnDemandRules( wifiPolicy: onDemandWiFiPolicy, cellularPolicy: onDemandCellularPolicy, wifiNetworks: onDemandWiFiNetworks ) } } func addOnDemandWiFiNetwork(_ ssid: String) { let trimmed = ssid.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !onDemandWiFiNetworks.contains(trimmed) else { return } onDemandWiFiNetworks.append(trimmed) saveOnDemandSettings() } func removeOnDemandWiFiNetwork(at offsets: IndexSet) { onDemandWiFiNetworks.remove(atOffsets: offsets) saveOnDemandSettings() } func recordKnownSSID(_ ssid: String) { let trimmed = ssid.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !knownSSIDs.contains(trimmed) else { return } knownSSIDs.append(trimmed) let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) userDefaults?.set(knownSSIDs, forKey: GlobalConstants.keyKnownSSIDs) } func removeKnownSSID(_ ssid: String) { knownSSIDs.removeAll { $0 == ssid } let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) userDefaults?.set(knownSSIDs, forKey: GlobalConstants.keyKnownSSIDs) } func getDefaultStatus() -> StatusDetails { return StatusDetails(ip: "", fqdn: "", managementStatus: .disconnected, peerInfo: []) } func isValidSetupKey(_ string: String) -> Bool { if string.isEmpty { return true } let pattern = "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$" let isMatch = string.range(of: pattern, options: .regularExpression, range: nil, locale: nil) != nil return isMatch } func printLogContents(from logURL: URL) { do { let logContents = try String(contentsOf: logURL, encoding: .utf8) print(logContents) } catch { print("Failed to read the log file: \(error.localizedDescription)") } } /// Handles server change completion by stopping the engine and resetting all connection state. func handleServerChanged() { AppLogger.shared.log("Server changed - stopping engine and resetting state") // Stop polling to prevent transitional states from updating UI stopPollingDetails() // Reset connection flags first to update UI immediately connectPressed = false disconnectPressed = false buttonLock = false // Reset connection state extensionState = .disconnected managementStatus = .disconnected updateVPNDisplayState() // Clear peer info peerViewModel.peerInfo = [] // Clear connection details clearDetails() // Stop the network extension in background (non-blocking) Task { @MainActor in self.networkExtensionAdapter.stop() } // Reload configuration for new server reloadConfiguration() } /// Checks shared app-group container for network unavailable flag set by the network extension. /// Updates the networkUnavailable property to trigger UI animation changes. /// iOS only - tvOS has a platform limitation where `UserDefaults(suiteName:)` does not /// reliably synchronize between the main app and network extension processes, even with /// a correctly configured App Group. On tvOS, we use IPC messaging instead. func checkNetworkUnavailableFlag() { #if os(iOS) let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) let isUnavailable = userDefaults?.bool(forKey: GlobalConstants.keyNetworkUnavailable) ?? false if isUnavailable != networkUnavailable { AppLogger.shared.log("Network unavailable flag changed: \(isUnavailable)") networkUnavailable = isUnavailable updateVPNDisplayState() } #endif // 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) 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) scheduleLoginRequiredNotification() #endif } #if os(iOS) private func scheduleLoginRequiredNotification() { let center = UNUserNotificationCenter.current() center.getNotificationSettings { settings in guard settings.authorizationStatus == .authorized else { 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 ) center.add(request) { error in if let error { AppLogger.shared.log("Failed to schedule login notification: \(error.localizedDescription)") } } } } #endif }