From a1e2af09f196f9889e016520a001214b685ce2ef Mon Sep 17 00:00:00 2001 From: Diego Romar Date: Fri, 12 Dec 2025 18:03:54 -0300 Subject: [PATCH 01/34] Add completion handler usage when restarting client --- NetbirdKit/ConnectionListener.swift | 1 + NetbirdNetworkExtension/NetBirdAdapter.swift | 18 +++++++++++++++++- .../PacketTunnelProvider.swift | 14 ++++++++++---- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/NetbirdKit/ConnectionListener.swift b/NetbirdKit/ConnectionListener.swift index fc53470..6007179 100644 --- a/NetbirdKit/ConnectionListener.swift +++ b/NetbirdKit/ConnectionListener.swift @@ -36,6 +36,7 @@ class ConnectionListener: NSObject, NetBirdSDKConnectionListenerProtocol { func onDisconnected() { adapter.clientState = .disconnected + adapter.notifyStopCompleted() } func onDisconnecting() { diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index 72ec2f4..617c0c9 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -58,6 +58,8 @@ public class NetBirdAdapter { return nil } + private var stopCompletionHandler: (() -> Void)? + // MARK: - Initialization /// Designated initializer. @@ -123,7 +125,21 @@ public class NetBirdAdapter { return self.client.loginForMobile() } - public func stop() { + public func stop(completionHandler: (() -> Void)? = nil) { + self.stopCompletionHandler = completionHandler self.client.stop() + + // Fallback timeout (15 seconds) in case onDisconnected doesn't fire + if completionHandler != nil { + DispatchQueue.global().asyncAfter(deadline: .now() + 15) { [weak self] in + self?.notifyStopCompleted() + } + } + } + + func notifyStopCompleted() { + guard let handler = self.stopCompletionHandler else { return } + self.stopCompletionHandler = nil + handler() } } diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 822f944..3e37038 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -147,10 +147,16 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func restartClient() { - adapter.stop() - adapter.start { error in - if let error = error { - print("Error restarting client: \(error.localizedDescription)") + adapter.stop { [weak self] in + self?.adapter.start { error in + if let error = error { + Analytics.logEvent("packet_tunnel_provider", parameters: [ + "level": "ERROR", + "method": "restartClient", + "error" : error.localizedDescription + ]) + print("Error restarting client: \(error.localizedDescription)") + } } } } From 08c206efe2bfce350eb563ef114a680487207ef9 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sat, 13 Dec 2025 22:38:57 +0100 Subject: [PATCH 02/34] Fix UI stuck in connecting state during network type switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Message: When switching between wifi and cellular, the Go engine fires rapid state changes (connecting → disconnecting → connected) that confused the UI animation state machine, causing it to get stuck in "Connecting..." state. Changes: - Add isRestarting flag to suppress intermediate state updates during restart - Add recovery path in playDisconnectingLoop when engine reconnects - Add status polling guard to prevent concurrent fetchData calls - Add AppLogger for unified Swift logging to shared container - Update share logs to export both engine and app log files --- NetBird.xcodeproj/project.pbxproj | 28 ++-- NetBird/Source/App/Views/AdvancedView.swift | 64 ++++--- .../Views/Components/CustomLottieView.swift | 11 ++ NetbirdKit/AppLogger.swift | 157 ++++++++++++++++++ NetbirdKit/ConnectionListener.swift | 28 +++- NetbirdKit/NetworkExtensionAdapter.swift | 38 +++-- NetbirdNetworkExtension/NetBirdAdapter.swift | 7 +- .../PacketTunnelProvider.swift | 16 +- build-go-lib.sh | 2 +- 9 files changed, 286 insertions(+), 65 deletions(-) create mode 100644 NetbirdKit/AppLogger.swift diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 226252c..cdda7c2 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -91,13 +91,15 @@ 50E608202A7979D600BAF09B /* SideDrawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E6081F2A7979D600BAF09B /* SideDrawer.swift */; }; 50E608242A79966600BAF09B /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608232A79966600BAF09B /* AboutView.swift */; }; 50E608262A79968500BAF09B /* AdvancedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608252A79968500BAF09B /* AdvancedView.swift */; }; + 978FC4702EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; + 978FC4712EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; + F1258DE22ED4EE5000C0D205 /* ServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE12ED4EE4900C0D205 /* ServerViewModel.swift */; }; + F1258DEA2ED7B7D600C0D205 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE92ED7B7D200C0D205 /* Extensions.swift */; }; + F1B292052EDE5610001D91B8 /* JustifiedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292042EDE5608001D91B8 /* JustifiedText.swift */; }; F1B292072EE0AC2A001D91B8 /* EnvVarPackager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */; }; F1B292082EE0AC2A001D91B8 /* EnvVarPackager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */; }; F1B2920A2EE0BC46001D91B8 /* GlobalConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */; }; F1B2920B2EE0BC46001D91B8 /* GlobalConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */; }; - F1258DE22ED4EE5000C0D205 /* ServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE12ED4EE4900C0D205 /* ServerViewModel.swift */; }; - F1258DEA2ED7B7D600C0D205 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE92ED7B7D200C0D205 /* Extensions.swift */; }; - F1B292052EDE5610001D91B8 /* JustifiedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292042EDE5608001D91B8 /* JustifiedText.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -184,11 +186,12 @@ 50E6081F2A7979D600BAF09B /* SideDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideDrawer.swift; sourceTree = ""; }; 50E608232A79966600BAF09B /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 50E608252A79968500BAF09B /* AdvancedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedView.swift; sourceTree = ""; }; - F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvVarPackager.swift; sourceTree = ""; }; - F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalConstants.swift; sourceTree = ""; }; + 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogger.swift; sourceTree = ""; }; F1258DE12ED4EE4900C0D205 /* ServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerViewModel.swift; sourceTree = ""; }; F1258DE92ED7B7D200C0D205 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; F1B292042EDE5608001D91B8 /* JustifiedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JustifiedText.swift; sourceTree = ""; }; + F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvVarPackager.swift; sourceTree = ""; }; + F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalConstants.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -312,6 +315,7 @@ 50C727EA2A82479B006E898D /* NetbirdKit */ = { isa = PBXGroup; children = ( + 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */, F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */, F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */, 50245A292A7BDB590034792B /* Preferences.swift */, @@ -578,6 +582,7 @@ buildActionMask = 2147483647; files = ( 50CD81632AD0595E00CF830B /* DNSManager.swift in Sources */, + 978FC4702EEDF167002D0EB8 /* AppLogger.swift in Sources */, 50C5D3102BDD96CF003159BE /* RoutesSelectionDetails.swift in Sources */, 50C727ED2A824C10006E898D /* NetBirdAdapter.swift in Sources */, 50245A572A80431C0034792B /* PacketTunnelProvider.swift in Sources */, @@ -611,6 +616,7 @@ 502455BF2A79B4500034792B /* SolidButton.swift in Sources */, 50BB17412C30239400518BCA /* RouteCard.swift in Sources */, 505344B92C3EFE4C00223065 /* TransparentGradientButton.swift in Sources */, + 978FC4712EEDF167002D0EB8 /* AppLogger.swift in Sources */, 50E608202A7979D600BAF09B /* SideDrawer.swift in Sources */, 50216D932ACB2488009574C9 /* NetworkExtensionAdapter.swift in Sources */, 509CCD6C2BE90D0E00B7C2D8 /* PeerTabView.swift in Sources */, @@ -837,7 +843,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = NetBird/NetBird.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = TA739QLA7A; ENABLE_PREVIEWS = YES; @@ -866,12 +872,12 @@ "$(PROJECT_DIR)/WireGuardKitGo/.tmp/wireguard-go-bridge/goroot/src/go/internal/gccgoimporter/testdata", "$(PROJECT_DIR)/WireGuardKitGo/.tmp/wireguard-go-bridge", ); - MARKETING_VERSION = 0.0.13; + MARKETING_VERSION = 0.0.14; PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "NetBird-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -889,7 +895,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = NetBird/NetBird.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = TA739QLA7A; ENABLE_PREVIEWS = YES; @@ -918,12 +924,12 @@ "$(PROJECT_DIR)/WireGuardKitGo/.tmp/wireguard-go-bridge/goroot/src/go/internal/gccgoimporter/testdata", "$(PROJECT_DIR)/WireGuardKitGo/.tmp/wireguard-go-bridge", ); - MARKETING_VERSION = 0.0.13; + MARKETING_VERSION = 0.0.14; PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "NetBird-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/NetBird/Source/App/Views/AdvancedView.swift b/NetBird/Source/App/Views/AdvancedView.swift index b407b52..f19e370 100644 --- a/NetBird/Source/App/Views/AdvancedView.swift +++ b/NetBird/Source/App/Views/AdvancedView.swift @@ -187,43 +187,53 @@ struct AdvancedView: View { } func shareButtonTapped() { - let fileManager = FileManager.default - guard let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.io.netbird.app") else { - print("Failed to retrieve the group URL") + guard let documentsDir = getDocumentsDirectory() else { + AppLogger.shared.log("Failed to get documents directory") return } - let logURL = groupURL.appendingPathComponent("logfile.log") + var filesToShare: [URL] = [] - do { - let logData = try String(contentsOf: logURL, encoding: .utf8) - let fileName = "netbird-log.txt" - guard let filePath = getDocumentsDirectory()?.appendingPathComponent(fileName) else { - print("Failed to get file path") - return + // Export Go SDK logs + if let goLogURL = AppLogger.getGoLogFileURL() { + do { + let goLogData = try String(contentsOf: goLogURL, encoding: .utf8) + let goLogPath = documentsDir.appendingPathComponent("netbird-engine.log") + try goLogData.write(to: goLogPath, atomically: true, encoding: .utf8) + filesToShare.append(goLogPath) + } catch { + AppLogger.shared.log("Failed to read Go log data: \(error)") } - + } + + // Export Swift logs + if let swiftLogURL = AppLogger.getLogFileURL() { do { - try logData.write(to: filePath, atomically: true, encoding: .utf8) - - let activityViewController = UIActivityViewController(activityItems: [filePath], applicationActivities: nil) - - activityViewController.excludedActivityTypes = [ - .assignToContact, - .saveToCameraRoll - ] - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootViewController = windowScene.windows.first?.rootViewController { - rootViewController.present(activityViewController, animated: true, completion: nil) - } + let swiftLogData = try String(contentsOf: swiftLogURL, encoding: .utf8) + let swiftLogPath = documentsDir.appendingPathComponent("netbird-app.log") + try swiftLogData.write(to: swiftLogPath, atomically: true, encoding: .utf8) + filesToShare.append(swiftLogPath) } catch { - print("Failed to write to file: \(error.localizedDescription)") + AppLogger.shared.log("Failed to read Swift log data: \(error)") } - } catch { - print("Failed to read log data: \(error)") + } + + guard !filesToShare.isEmpty else { + AppLogger.shared.log("No log files to share") return } + + let activityViewController = UIActivityViewController(activityItems: filesToShare, applicationActivities: nil) + + activityViewController.excludedActivityTypes = [ + .assignToContact, + .saveToCameraRoll + ] + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + rootViewController.present(activityViewController, animated: true, completion: nil) + } } func getDocumentsDirectory() -> URL? { diff --git a/NetBird/Source/App/Views/Components/CustomLottieView.swift b/NetBird/Source/App/Views/Components/CustomLottieView.swift index cdfa60d..12f696c 100644 --- a/NetBird/Source/App/Views/Components/CustomLottieView.swift +++ b/NetBird/Source/App/Views/Components/CustomLottieView.swift @@ -163,6 +163,17 @@ struct CustomLottieView: UIViewRepresentable { guard let self = self else { return } if self.extensionStatus == .disconnected { self.playFadeOut(uiView: uiView, startFrame: self.disconnectingFadeOut.startFrame, endFrame: self.disconnectingFadeOut.endFrame, viewModel: viewModel, extensionStateText: "Disconnected") + } else if self.engineStatus == .connected && self.extensionStatus == .connected { + // Engine recovered to connected during internal restart (e.g., network switch) + // Extension never disconnected, so skip fade out and go directly to connected state + self.isPlaying = false + DispatchQueue.main.async { + viewModel.extensionStateText = "Connected" + viewModel.connectPressed = false + viewModel.disconnectPressed = false + viewModel.routeViewModel.getRoutes() + } + uiView.currentFrame = self.connectedFrame } else { playDisconnectingLoop(uiView: uiView, viewModel: viewModel) } diff --git a/NetbirdKit/AppLogger.swift b/NetbirdKit/AppLogger.swift new file mode 100644 index 0000000..03ca711 --- /dev/null +++ b/NetbirdKit/AppLogger.swift @@ -0,0 +1,157 @@ +// +// AppLogger.swift +// NetBird +// + +import Foundation + +/// Unified logger that writes to the shared app group container. +/// Logs from both main app and network extension are written to the same file. +public class AppLogger { + public static let shared = AppLogger() + + private let logFileName = "swift-log.log" + private let maxLogSize: UInt64 = 5 * 1024 * 1024 // 5 MB + private let queue = DispatchQueue(label: "io.netbird.logger", qos: .utility) + private var fileHandle: FileHandle? + private var logFileURL: URL? + private var isReady = false + + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + return formatter + }() + + private init() { + // Setup file logging asynchronously to avoid blocking main thread + queue.async { [weak self] in + self?.setupLogFile() + } + } + + private func setupLogFile() { + let fileManager = FileManager.default + var containerURL: URL? + + // Try app group container first + if let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: GlobalConstants.userPreferencesSuiteName) { + containerURL = groupURL + } else if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + // Fallback to documents directory (works on Mac Catalyst) + containerURL = documentsURL + } + + guard let baseURL = containerURL else { + print("AppLogger: No writable container found") + return + } + + // Ensure directory exists + if !fileManager.fileExists(atPath: baseURL.path) { + do { + try fileManager.createDirectory(at: baseURL, withIntermediateDirectories: true) + } catch { + print("AppLogger: Failed to create directory: \(error)") + return + } + } + + logFileURL = baseURL.appendingPathComponent(logFileName) + guard let url = logFileURL else { return } + + if !fileManager.fileExists(atPath: url.path) { + let created = fileManager.createFile(atPath: url.path, contents: nil) + if !created { + print("AppLogger: Failed to create log file at \(url.path)") + return + } + } + + do { + fileHandle = try FileHandle(forWritingTo: url) + fileHandle?.seekToEndOfFile() + isReady = true + } catch { + print("AppLogger: Failed to open log file: \(error)") + } + } + + public func log(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + let fileName = (file as NSString).lastPathComponent + let timestamp = dateFormatter.string(from: Date()) + let logMessage = "[\(timestamp)] [\(fileName):\(line)] \(message)\n" + + print(logMessage, terminator: "") + + queue.async { [weak self] in + self?.writeToFile(logMessage) + } + } + + private func writeToFile(_ message: String) { + guard isReady, let data = message.data(using: .utf8) else { return } + + rotateLogIfNeeded() + + fileHandle?.write(data) + try? fileHandle?.synchronize() + } + + private func rotateLogIfNeeded() { + guard let url = logFileURL else { return } + + do { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + if let fileSize = attributes[.size] as? UInt64, fileSize > maxLogSize { + fileHandle?.closeFile() + try FileManager.default.removeItem(at: url) + FileManager.default.createFile(atPath: url.path, contents: nil) + fileHandle = try FileHandle(forWritingTo: url) + } + } catch { + print("AppLogger: Failed to rotate log: \(error)") + } + } + + public func clearLogs() { + queue.async { [weak self] in + guard let url = self?.logFileURL else { return } + do { + self?.fileHandle?.closeFile() + try FileManager.default.removeItem(at: url) + FileManager.default.createFile(atPath: url.path, contents: nil) + self?.fileHandle = try FileHandle(forWritingTo: url) + } catch { + print("AppLogger: Failed to clear logs: \(error)") + } + } + } + + public static func getLogFileURL() -> URL? { + guard let url = shared.logFileURL, + FileManager.default.fileExists(atPath: url.path) else { + return nil + } + return url + } + + public static func getGoLogFileURL() -> URL? { + let fileManager = FileManager.default + // Try app group first + if let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: GlobalConstants.userPreferencesSuiteName) { + let url = groupURL.appendingPathComponent("logfile.log") + if fileManager.fileExists(atPath: url.path) { + return url + } + } + // Fallback to documents + if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + let url = documentsURL.appendingPathComponent("logfile.log") + if fileManager.fileExists(atPath: url.path) { + return url + } + } + return nil + } +} diff --git a/NetbirdKit/ConnectionListener.swift b/NetbirdKit/ConnectionListener.swift index 6007179..440ddfe 100644 --- a/NetbirdKit/ConnectionListener.swift +++ b/NetbirdKit/ConnectionListener.swift @@ -23,24 +23,40 @@ class ConnectionListener: NSObject, NetBirdSDKConnectionListenerProtocol { } func onConnected() { + let wasRestarting = adapter.isRestarting + adapter.isRestarting = false adapter.clientState = .connected - + AppLogger.shared.log("onConnected: state=connected, wasRestarting=\(wasRestarting)") + DispatchQueue.main.async { self.completionHandler(nil) } } - + func onConnecting() { - adapter.clientState = .connecting + if adapter.isRestarting { + AppLogger.shared.log("onConnecting: suppressed (isRestarting=true)") + } else { + adapter.clientState = .connecting + AppLogger.shared.log("onConnecting: state=connecting") + } } - + func onDisconnected() { + let wasRestarting = adapter.isRestarting + adapter.isRestarting = false adapter.clientState = .disconnected + AppLogger.shared.log("onDisconnected: state=disconnected, wasRestarting=\(wasRestarting)") adapter.notifyStopCompleted() } - + func onDisconnecting() { - adapter.clientState = .disconnecting + if adapter.isRestarting { + AppLogger.shared.log("onDisconnecting: suppressed (isRestarting=true)") + } else { + adapter.clientState = .disconnecting + AppLogger.shared.log("onDisconnecting: state=disconnecting") + } } func onPeersListChanged(_ p0: Int) { diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index d52c44f..0bb6304 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -10,19 +10,21 @@ import NetworkExtension import SwiftUI public class NetworkExtensionAdapter: ObservableObject { - + var session : NETunnelProviderSession? var vpnManager: NETunnelProviderManager? - + var extensionID = "io.netbird.app.NetbirdNetworkExtension" var extensionName = "NetBird Network Extension" - - let decoder = PropertyListDecoder() - + + let decoder = PropertyListDecoder() + @Published var timer : Timer - + @Published var showBrowser = false @Published var loginURL : String? + + private var isFetchingStatus = false init() { self.timer = Timer() @@ -235,23 +237,31 @@ public class NetworkExtensionAdapter: ObservableObject { } func fetchData(completion: @escaping (StatusDetails) -> Void) { + guard !isFetchingStatus else { + return + } + guard let session = self.session else { let defaultStatus = StatusDetails(ip: "", fqdn: "", managementStatus: .disconnected, peerInfo: []) completion(defaultStatus) return } - + + isFetchingStatus = true let messageString = "Status" if let messageData = messageString.data(using: .utf8) { do { - try session.sendProviderMessage(messageData) { response in + try session.sendProviderMessage(messageData) { [weak self] response in + defer { self?.isFetchingStatus = false } if let response = response { do { - let decodedStatus = try self.decoder.decode(StatusDetails.self, from: response) - completion(decodedStatus) + let decodedStatus = try self?.decoder.decode(StatusDetails.self, from: response) + if let status = decodedStatus { + completion(status) + } return } catch { - print("Failed to decode status details.") + AppLogger.shared.log("Failed to decode status details.") } } else { let defaultStatus = StatusDetails(ip: "", fqdn: "", managementStatus: .disconnected, peerInfo: []) @@ -260,10 +270,12 @@ public class NetworkExtensionAdapter: ObservableObject { } } } catch { - print("Failed to send Provider message") + isFetchingStatus = false + AppLogger.shared.log("Failed to send Provider message") } } else { - print("Error converting message to Data") + isFetchingStatus = false + AppLogger.shared.log("Error converting message to Data") } } diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index 617c0c9..c5b3b71 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -22,8 +22,13 @@ public class NetBirdAdapter { private let dnsManager: DNSManager public var isExecutingLogin = false - + var clientState : ClientState = .disconnected + + /// Flag indicating the client is restarting (e.g., due to network type change). + /// When true, intermediate state changes (connecting/disconnecting) are suppressed + /// to prevent UI animation state machine from getting confused. + var isRestarting = false /// Tunnel device file descriptor. public var tunnelFileDescriptor: Int32? { diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 3e37038..ec27c79 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -116,7 +116,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { func handleNetworkChange(path: Network.NWPath) { guard path.status == .satisfied else { - print("No network connection.") + AppLogger.shared.log("No network connection") return } @@ -131,31 +131,35 @@ class PacketTunnelProvider: NEPacketTunnelProvider { }() guard let networkType = newNetworkType else { - print("Connected to an unsupported network type.") + AppLogger.shared.log("Connected to an unsupported network type") return } if currentNetworkType != networkType { - print("Network type changed to \(networkType).") + AppLogger.shared.log("Network type changed: \(String(describing: currentNetworkType)) -> \(networkType)") if currentNetworkType != nil { restartClient() } currentNetworkType = networkType - } else { - print("Network type remains the same: \(networkType).") } } func restartClient() { + AppLogger.shared.log("restartClient: starting restart sequence") + adapter.isRestarting = true adapter.stop { [weak self] in + AppLogger.shared.log("restartClient: stop completed, starting client") self?.adapter.start { error in if let error = error { + self?.adapter.isRestarting = false + AppLogger.shared.log("restartClient: start failed - \(error.localizedDescription)") Analytics.logEvent("packet_tunnel_provider", parameters: [ "level": "ERROR", "method": "restartClient", "error" : error.localizedDescription ]) - print("Error restarting client: \(error.localizedDescription)") + } else { + AppLogger.shared.log("restartClient: start completed successfully") } } } diff --git a/build-go-lib.sh b/build-go-lib.sh index 21efe53..32aaa32 100755 --- a/build-go-lib.sh +++ b/build-go-lib.sh @@ -16,6 +16,6 @@ fi cd $netbirdPath gomobile init -CGO_ENABLED=0 gomobile bind -target=ios -bundleid=io.netbird.framework -ldflags="-X github.com/netbirdio/netbird/version.version=$version" -o $rn_app_path/NetBirdSDK.xcframework $netbirdPath/client/ios/NetBirdSDK +CGO_ENABLED=0 gomobile bind -target=ios -bundleid=io.netbird.framework -ldflags="-X github.com/netbirdio/netbird/version.version=$version" -o $rn_app_path/NetBird/NetBirdSDK.xcframework $netbirdPath/client/ios/NetBirdSDK cd - From a3f6cb438fd2f61c7c4f224fe7e2ae98105cc021 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sat, 13 Dec 2025 22:48:36 +0100 Subject: [PATCH 03/34] add build and test workflows and handle missing firebase files --- .github/workflows/build.yml | 83 +++++++++++++++++++ .github/workflows/test.yml | 79 ++++++++++++++++++ NetBird/Source/App/NetBirdApp.swift | 6 +- .../PacketTunnelProvider.swift | 14 +--- 4 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c220d0b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,83 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build iOS App + runs-on: macos-14 + + steps: + - name: Checkout ios-client + uses: actions/checkout@v4 + + - name: Checkout netbird + uses: actions/checkout@v4 + with: + repository: netbirdio/netbird + path: netbird + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + cache-dependency-path: netbird/go.sum + + - name: Install gomobile + run: | + go install golang.org/x/mobile/cmd/gomobile@latest + gomobile init + + - name: Build NetBirdSDK xcframework + run: | + cd netbird + gomobile bind \ + -target=ios \ + -bundleid=io.netbird.framework \ + -o ../NetBirdSDK.xcframework \ + ./client/ios/NetBirdSDK + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_16.1.app + + - name: Install xcpretty + run: gem install xcpretty + + - name: Resolve Swift packages + run: | + xcodebuild -resolvePackageDependencies \ + -project NetBird.xcodeproj \ + -scheme NetBird + + - name: Build iOS App + run: | + set -o pipefail + xcodebuild build \ + -project NetBird.xcodeproj \ + -scheme NetBird \ + -destination 'generic/platform=iOS' \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + | xcpretty --color + + - name: Build Network Extension + run: | + set -o pipefail + xcodebuild build \ + -project NetBird.xcodeproj \ + -scheme NetbirdNetworkExtension \ + -destination 'generic/platform=iOS' \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + | xcpretty --color diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c764644 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,79 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Build for Simulator + runs-on: macos-14 + + steps: + - name: Checkout ios-client + uses: actions/checkout@v4 + + - name: Checkout netbird + uses: actions/checkout@v4 + with: + repository: netbirdio/netbird + path: netbird + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + cache-dependency-path: netbird/go.sum + + - name: Install gomobile + run: | + go install golang.org/x/mobile/cmd/gomobile@latest + gomobile init + + - name: Build NetBirdSDK xcframework + run: | + cd netbird + gomobile bind \ + -target=ios \ + -bundleid=io.netbird.framework \ + -o ../NetBirdSDK.xcframework \ + ./client/ios/NetBirdSDK + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_16.1.app + + - name: Install xcpretty + run: gem install xcpretty + + - name: Resolve Swift packages + run: | + xcodebuild -resolvePackageDependencies \ + -project NetBird.xcodeproj \ + -scheme NetBird + + - name: Build for Simulator + run: | + set -o pipefail + xcodebuild build \ + -project NetBird.xcodeproj \ + -scheme NetBird \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + | xcpretty --color + + # Note: The app requires a physical device to run. + # Unit tests can be added here when available: + # - name: Run Tests + # run: | + # set -o pipefail + # xcodebuild test \ + # -project NetBird.xcodeproj \ + # -scheme NetBird \ + # -destination 'platform=iOS Simulator,name=iPhone 16' \ + # | xcpretty --color diff --git a/NetBird/Source/App/NetBirdApp.swift b/NetBird/Source/App/NetBirdApp.swift index f7a6932..262ad2d 100644 --- a/NetBird/Source/App/NetBirdApp.swift +++ b/NetBird/Source/App/NetBirdApp.swift @@ -12,8 +12,10 @@ import FirebasePerformance class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - let options = FirebaseOptions(contentsOfFile: Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist")!) - FirebaseApp.configure(options: options!) + if let path = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist"), + let options = FirebaseOptions(contentsOfFile: path) { + FirebaseApp.configure(options: options) + } return true } } diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index ec27c79..4ec51f7 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -29,19 +29,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider { var currentNetworkType: NWInterface.InterfaceType? override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { - guard let googleServicePlistPath = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist"), - let firebaseOptions = FirebaseOptions(contentsOfFile: googleServicePlistPath) else { - let error = NSError( - domain: "io.netbird.NetbirdNetworkExtension", - code: 1002, - userInfo: [NSLocalizedDescriptionKey: "Could not load Firebase configuration."] - ) - completionHandler(error) - return + if let googleServicePlistPath = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist"), + let firebaseOptions = FirebaseOptions(contentsOfFile: googleServicePlistPath) { + FirebaseApp.configure(options: firebaseOptions) } - FirebaseApp.configure(options: firebaseOptions) - if let options = options, let logLevel = options["logLevel"] as? String { initializeLogging(loglevel: logLevel) } From a52f02e95f560cb182d11d9ccadfe40a755081fa Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sat, 13 Dec 2025 23:00:56 +0100 Subject: [PATCH 04/34] update jobs and docs with build script --- .github/workflows/build.yml | 14 +++----------- .github/workflows/test.yml | 14 +++----------- README.md | 30 +++++++++++++++++++++++------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c220d0b..b1889f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,22 +27,14 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: '1.24' cache-dependency-path: netbird/go.sum - name: Install gomobile - run: | - go install golang.org/x/mobile/cmd/gomobile@latest - gomobile init + run: go install golang.org/x/mobile/cmd/gomobile@latest - name: Build NetBirdSDK xcframework - run: | - cd netbird - gomobile bind \ - -target=ios \ - -bundleid=io.netbird.framework \ - -o ../NetBirdSDK.xcframework \ - ./client/ios/NetBirdSDK + run: ./build-go-lib.sh ./netbird - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode_16.1.app diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c764644..5053ea6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,22 +27,14 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: '1.24' cache-dependency-path: netbird/go.sum - name: Install gomobile - run: | - go install golang.org/x/mobile/cmd/gomobile@latest - gomobile init + run: go install golang.org/x/mobile/cmd/gomobile@latest - name: Build NetBirdSDK xcframework - run: | - cd netbird - gomobile bind \ - -target=ios \ - -bundleid=io.netbird.framework \ - -o ../NetBirdSDK.xcframework \ - ./client/ios/NetBirdSDK + run: ./build-go-lib.sh ./netbird - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode_16.1.app diff --git a/README.md b/README.md index 3e8ce80..c7b0a57 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,13 @@ - + + + + + + +

@@ -55,27 +61,37 @@ The code is divided into 4 parts: ## Requirements - iOS 14.0+ -- Xcode 12.0+ +- Xcode 16.0+ +- Go 1.23+ - gomobile ## Run locally To build the app, this repository and the main netbird repository are needed. -``` +```bash git clone https://github.com/netbirdio/netbird.git git clone https://github.com/netbirdio/ios-client.git +cd ios-client ``` -Building the xcframework from the main netbird repo. This needs to be stored in the root directory of the app +Install gomobile if you haven't already: +```bash +go install golang.org/x/mobile/cmd/gomobile@latest ``` -cd netbird -gomobile bind -target=ios -bundleid=io.netbird.framework -o ../ios-client/NetBirdSDK.xcframework ./client/ios/NetBirdSDK + +Build the xcframework from the main netbird repo using the build script: +```bash +./build-go-lib.sh ../netbird ``` Open the Xcode project, and we are ready to go. -> **Note:** The app can not be run in the iOS simulator. To test the app, a physical device needs to be connected to Xcode via cable and set as the run destination. +> **Note:** The app cannot be run in the iOS simulator. To test the app, a physical device needs to be connected to Xcode via cable and set as the run destination. + +### Firebase Configuration (Optional) + +The app supports Firebase for analytics and crash reporting. To enable it, add your `GoogleService-Info.plist` file to the project root. The app will work without Firebase configuration. ## Other project repositories From cf6f3a220d87b1cd9275e9629f21c3efc365b6e0 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sat, 13 Dec 2025 23:10:39 +0100 Subject: [PATCH 05/34] fix paths --- .github/workflows/build.yml | 4 ++-- .github/workflows/test.yml | 17 +++-------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b1889f3..6619aff 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,10 +31,10 @@ jobs: cache-dependency-path: netbird/go.sum - name: Install gomobile - run: go install golang.org/x/mobile/cmd/gomobile@latest + run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab - name: Build NetBirdSDK xcframework - run: ./build-go-lib.sh ./netbird + run: ./build-go-lib.sh $PWD/netbird - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode_16.1.app diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5053ea6..936a9e9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,10 +31,10 @@ jobs: cache-dependency-path: netbird/go.sum - name: Install gomobile - run: go install golang.org/x/mobile/cmd/gomobile@latest + run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab - name: Build NetBirdSDK xcframework - run: ./build-go-lib.sh ./netbird + run: ./build-go-lib.sh $PWD/netbird - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode_16.1.app @@ -57,15 +57,4 @@ jobs: -destination 'platform=iOS Simulator,name=iPhone 16' \ -configuration Debug \ CODE_SIGNING_ALLOWED=NO \ - | xcpretty --color - - # Note: The app requires a physical device to run. - # Unit tests can be added here when available: - # - name: Run Tests - # run: | - # set -o pipefail - # xcodebuild test \ - # -project NetBird.xcodeproj \ - # -scheme NetBird \ - # -destination 'platform=iOS Simulator,name=iPhone 16' \ - # | xcpretty --color + | xcpretty --color \ No newline at end of file From ae948b93e173d032ed9ad9f36bc38a9ef82d6806 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sat, 13 Dec 2025 23:24:07 +0100 Subject: [PATCH 06/34] fix PeerCard.swift ref --- NetBird.xcodeproj/project.pbxproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index cdda7c2..4b0b342 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -82,7 +82,6 @@ 50CD81A72AD5504B00CF830B /* StatusDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CD81A62AD5504B00CF830B /* StatusDetails.swift */; }; 50CD81A82AD5504B00CF830B /* StatusDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CD81A62AD5504B00CF830B /* StatusDetails.swift */; }; 50CD81B02AD5B94D00CF830B /* PeerCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CD81AF2AD5B94D00CF830B /* PeerCard.swift */; }; - 50CD81B12AD5B94D00CF830B /* PeerCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CD81AF2AD5B94D00CF830B /* PeerCard.swift */; }; 50CD84362AD82F9400CF830B /* ServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CD84352AD82F9400CF830B /* ServerView.swift */; }; 50D402942BD9143900D4AC5B /* NetBirdSDK.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; }; 50D402952BD9143900D4AC5B /* NetBirdSDK.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; }; @@ -594,7 +593,6 @@ 50213A262A8D0A870031D993 /* NetworkChangeListener.swift in Sources */, 50CD81502AD0355000CF830B /* PacketTunnelProviderSettingsManager.swift in Sources */, 50003BCD2AFD3B2B00E5EB6B /* ClientState.swift in Sources */, - 50CD81B12AD5B94D00CF830B /* PeerCard.swift in Sources */, 50C78AD12A82BBFD006E898D /* Device.swift in Sources */, 505118CF2AD96ECA003027D3 /* x25519.c in Sources */, F1B292082EE0AC2A001D91B8 /* EnvVarPackager.swift in Sources */, From da6bbe9252148a03526f0fc3236df4a41ebaa389 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 14:13:13 +0100 Subject: [PATCH 07/34] fix comments --- NetBird/Source/App/Views/AdvancedView.swift | 26 ------------------- NetbirdKit/NetworkExtensionAdapter.swift | 21 +++++++-------- NetbirdNetworkExtension/NetBirdAdapter.swift | 8 +++++- .../PacketTunnelProvider.swift | 2 ++ README.md | 8 +++--- 5 files changed, 22 insertions(+), 43 deletions(-) diff --git a/NetBird/Source/App/Views/AdvancedView.swift b/NetBird/Source/App/Views/AdvancedView.swift index f19e370..e73f3bc 100644 --- a/NetBird/Source/App/Views/AdvancedView.swift +++ b/NetBird/Source/App/Views/AdvancedView.swift @@ -240,33 +240,7 @@ struct AdvancedView: View { let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) return paths.first } - - func saveLogFile(at url: URL?) { - guard let url = url else { return } - - let fileManager = FileManager.default - guard let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.io.netbird.app") else { - print("Failed to retrieve the group URL") - return - } - let logURL = groupURL.appendingPathComponent("logfile.log") - - do { - let logData = try String(contentsOf: logURL, encoding: .utf8) - let fileURL = url.appendingPathComponent("netbird.log") - do { - try logData.write(to: fileURL, atomically: true, encoding: .utf8) - print("Log file saved successfully.") - } catch { - print("Failed to save log file: \(error)") - } - } catch { - print("Failed to read log data: \(error)") - return - } - } - func checkForValidPresharedKey(text: String) { if isValidBase64EncodedString(text) { viewModel.showInvalidPresharedKeyAlert = false diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index 0bb6304..4d4fc0a 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -253,21 +253,18 @@ public class NetworkExtensionAdapter: ObservableObject { do { try session.sendProviderMessage(messageData) { [weak self] response in defer { self?.isFetchingStatus = false } - if let response = response { - do { - let decodedStatus = try self?.decoder.decode(StatusDetails.self, from: response) - if let status = decodedStatus { - completion(status) - } - return - } catch { - AppLogger.shared.log("Failed to decode status details.") - } - } else { - let defaultStatus = StatusDetails(ip: "", fqdn: "", managementStatus: .disconnected, peerInfo: []) + let defaultStatus = StatusDetails(ip: "", fqdn: "", managementStatus: .disconnected, peerInfo: []) + guard let response = response else { completion(defaultStatus) return } + do { + let decodedStatus = try self?.decoder.decode(StatusDetails.self, from: response) + completion(decodedStatus ?? defaultStatus) + } catch { + AppLogger.shared.log("Failed to decode status details: \(error)") + completion(defaultStatus) + } } } catch { isFetchingStatus = false diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index c5b3b71..13d8472 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -131,9 +131,15 @@ public class NetBirdAdapter { } public func stop(completionHandler: (() -> Void)? = nil) { + // Call any pending handler before setting a new one + if let existingHandler = self.stopCompletionHandler { + self.stopCompletionHandler = nil + existingHandler() + } + self.stopCompletionHandler = completionHandler self.client.stop() - + // Fallback timeout (15 seconds) in case onDisconnected doesn't fire if completionHandler != nil { DispatchQueue.global().asyncAfter(deadline: .now() + 15) { [weak self] in diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 4ec51f7..c37692a 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -151,6 +151,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { "error" : error.localizedDescription ]) } else { + // Note: isRestarting is already cleared by onConnected() callback + self?.adapter.isRestarting = false AppLogger.shared.log("restartClient: start completed successfully") } } diff --git a/README.md b/README.md index c7b0a57..6d5faa0 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,16 @@

- + License: GPL-3.0 - + Slack - + Build Status - + Test Status

From f22e1db5b3e84fd10031dc9a2976637b129a0d4c Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 14:19:44 +0100 Subject: [PATCH 08/34] remove network extension refs --- NetBird.xcodeproj/project.pbxproj | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 4b0b342..a9a3a55 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -17,21 +17,13 @@ 50003BCE2AFD405600E5EB6B /* ConnectionListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50003BC82AFD2F0C00E5EB6B /* ConnectionListener.swift */; }; 50051DE02AE69A8100AFBDC4 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 50051DDF2AE69A8100AFBDC4 /* FirebaseCrashlytics */; }; 501B0DCD2AE04DDE004BE7A7 /* button-disconnecting.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DC52AE04DDE004BE7A7 /* button-disconnecting.json */; }; - 501B0DCE2AE04DDE004BE7A7 /* button-disconnecting.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DC52AE04DDE004BE7A7 /* button-disconnecting.json */; }; 501B0DCF2AE04DDE004BE7A7 /* button-connecting-loop.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DC62AE04DDE004BE7A7 /* button-connecting-loop.json */; }; - 501B0DD02AE04DDE004BE7A7 /* button-connecting-loop.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DC62AE04DDE004BE7A7 /* button-connecting-loop.json */; }; 501B0DD12AE04DDE004BE7A7 /* logo_NetBird.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DC72AE04DDE004BE7A7 /* logo_NetBird.json */; }; - 501B0DD22AE04DDE004BE7A7 /* logo_NetBird.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DC72AE04DDE004BE7A7 /* logo_NetBird.json */; }; 501B0DD32AE04DDE004BE7A7 /* loading.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DC82AE04DDE004BE7A7 /* loading.json */; }; - 501B0DD42AE04DDE004BE7A7 /* loading.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DC82AE04DDE004BE7A7 /* loading.json */; }; 501B0DD52AE04DDE004BE7A7 /* button-start-connecting.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DC92AE04DDE004BE7A7 /* button-start-connecting.json */; }; - 501B0DD62AE04DDE004BE7A7 /* button-start-connecting.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DC92AE04DDE004BE7A7 /* button-start-connecting.json */; }; 501B0DD72AE04DDE004BE7A7 /* button-full2.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DCA2AE04DDE004BE7A7 /* button-full2.json */; }; - 501B0DD82AE04DDE004BE7A7 /* button-full2.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DCA2AE04DDE004BE7A7 /* button-full2.json */; }; 501B0DD92AE04DDE004BE7A7 /* button-full.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DCB2AE04DDE004BE7A7 /* button-full.json */; }; - 501B0DDA2AE04DDE004BE7A7 /* button-full.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DCB2AE04DDE004BE7A7 /* button-full.json */; }; 501B0DDB2AE04DDE004BE7A7 /* button-connected.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DCC2AE04DDE004BE7A7 /* button-connected.json */; }; - 501B0DDC2AE04DDE004BE7A7 /* button-connected.json in Resources */ = {isa = PBXBuildFile; fileRef = 501B0DCC2AE04DDE004BE7A7 /* button-connected.json */; }; 50213A262A8D0A870031D993 /* NetworkChangeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50245A2D2A7BDC470034792B /* NetworkChangeListener.swift */; }; 50213A2D2A8D0AA30031D993 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50245A292A7BDB590034792B /* Preferences.swift */; }; 50216D892ACB18EE009574C9 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50245A292A7BDB590034792B /* Preferences.swift */; }; @@ -496,15 +488,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 501B0DCE2AE04DDE004BE7A7 /* button-disconnecting.json in Resources */, - 501B0DD22AE04DDE004BE7A7 /* logo_NetBird.json in Resources */, - 501B0DDC2AE04DDE004BE7A7 /* button-connected.json in Resources */, - 501B0DD62AE04DDE004BE7A7 /* button-start-connecting.json in Resources */, - 501B0DDA2AE04DDE004BE7A7 /* button-full.json in Resources */, - 501B0DD02AE04DDE004BE7A7 /* button-connecting-loop.json in Resources */, 506331F92AF1676B00BC8F0E /* GoogleService-Info.plist in Resources */, - 501B0DD82AE04DDE004BE7A7 /* button-full2.json in Resources */, - 501B0DD42AE04DDE004BE7A7 /* loading.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 4251fc75bdedee52345e5b18c47e3535cf398967 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 15:11:46 +0100 Subject: [PATCH 09/34] Removed PBXBuildFile references --- NetBird.xcodeproj/project.pbxproj | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index a9a3a55..77a553b 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -43,8 +43,6 @@ 505119112AE03F68003027D3 /* FirebaseAnalyticsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 505119102AE03F68003027D3 /* FirebaseAnalyticsSwift */; }; 505119132AE03F68003027D3 /* FirebaseAppCheck in Frameworks */ = {isa = PBXBuildFile; productRef = 505119122AE03F68003027D3 /* FirebaseAppCheck */; }; 505344B92C3EFE4C00223065 /* TransparentGradientButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505344B82C3EFE4C00223065 /* TransparentGradientButton.swift */; }; - 506331F82AF1676B00BC8F0E /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 506331F72AF1676B00BC8F0E /* GoogleService-Info.plist */; }; - 506331F92AF1676B00BC8F0E /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 506331F72AF1676B00BC8F0E /* GoogleService-Info.plist */; }; 506331FB2AF52AB900BC8F0E /* CustomLottieView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506331FA2AF52AB900BC8F0E /* CustomLottieView.swift */; }; 506331FE2AF53CFF00BC8F0E /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 506331FD2AF53CFF00BC8F0E /* Lottie */; }; 506332002AF9197700BC8F0E /* button-full2-dark.json in Resources */ = {isa = PBXBuildFile; fileRef = 506331FF2AF9197700BC8F0E /* button-full2-dark.json */; }; @@ -488,7 +486,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 506331F92AF1676B00BC8F0E /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -504,7 +501,6 @@ 501B0DCD2AE04DDE004BE7A7 /* button-disconnecting.json in Resources */, 501B0DD12AE04DDE004BE7A7 /* logo_NetBird.json in Resources */, 501B0DDB2AE04DDE004BE7A7 /* button-connected.json in Resources */, - 506331F82AF1676B00BC8F0E /* GoogleService-Info.plist in Resources */, 501B0DCF2AE04DDE004BE7A7 /* button-connecting-loop.json in Resources */, 501B0DD72AE04DDE004BE7A7 /* button-full2.json in Resources */, ); @@ -524,7 +520,6 @@ "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}", "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist", - "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", ); outputFileListPaths = ( @@ -533,7 +528,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n"; + shellScript = "# Skip Crashlytics upload if GoogleService-Info.plist is not present\nGOOGLE_PLIST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleService-Info.plist\"\nif [ ! -f \"$GOOGLE_PLIST\" ]; then\n echo \"GoogleService-Info.plist not found. Skipping Crashlytics upload.\"\n exit 0\nfi\n\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n"; }; 508BD8522AF158F80055E415 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; @@ -546,7 +541,6 @@ "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}", "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist", - "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", ); outputFileListPaths = ( @@ -555,7 +549,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n"; + shellScript = "# Skip Crashlytics upload if GoogleService-Info.plist is not present\nGOOGLE_PLIST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleService-Info.plist\"\nif [ ! -f \"$GOOGLE_PLIST\" ]; then\n echo \"GoogleService-Info.plist not found. Skipping Crashlytics upload.\"\n exit 0\nfi\n\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n"; }; /* End PBXShellScriptBuildPhase section */ From 28ddad69f49a3c35aaac285fbed9faccc5793c47 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 16:27:29 +0100 Subject: [PATCH 10/34] use newer xcode --- .github/workflows/build.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6619aff..e170e6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ concurrency: jobs: build: name: Build iOS App - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout ios-client @@ -37,7 +37,7 @@ jobs: run: ./build-go-lib.sh $PWD/netbird - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_16.1.app + run: sudo xcode-select -s /Applications/Xcode_16.2.app - name: Install xcpretty run: gem install xcpretty diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 936a9e9..97ea322 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ concurrency: jobs: test: name: Build for Simulator - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout ios-client @@ -37,7 +37,7 @@ jobs: run: ./build-go-lib.sh $PWD/netbird - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_16.1.app + run: sudo xcode-select -s /Applications/Xcode_16.2.app - name: Install xcpretty run: gem install xcpretty From 08093a784adc22cf9e122cb3b02ca3d1a0858300 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 16:27:42 +0100 Subject: [PATCH 11/34] handle airplane mode --- NetbirdKit/EnvVarPackager.swift | 6 +- .../PacketTunnelProvider.swift | 68 ++++++++++++++++++- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/NetbirdKit/EnvVarPackager.swift b/NetbirdKit/EnvVarPackager.swift index 68f4de7..6635f58 100644 --- a/NetbirdKit/EnvVarPackager.swift +++ b/NetbirdKit/EnvVarPackager.swift @@ -10,12 +10,12 @@ class EnvVarPackager { guard let envList = NetBirdSDKEnvList() else { return nil } - + defaults.register(defaults: [GlobalConstants.keyForceRelayConnection: true]) let forceRelayConnection = defaults.bool(forKey: GlobalConstants.keyForceRelayConnection) - + envList.put(NetBirdSDKGetEnvKeyNBForceRelay(), value: String(forceRelayConnection)) - + return envList } } diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index c37692a..8f23634 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -12,6 +12,7 @@ import Firebase import FirebaseCrashlytics import FirebaseCore import FirebasePerformance +import UserNotifications class PacketTunnelProvider: NEPacketTunnelProvider { @@ -28,6 +29,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let monitorQueue = DispatchQueue(label: "NetworkMonitor") var currentNetworkType: NWInterface.InterfaceType? + /// Tracks if engine was stopped due to network unavailability (e.g., airplane mode) + var wasStoppedDueToNoNetwork = false + override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { if let googleServicePlistPath = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist"), let firebaseOptions = FirebaseOptions(contentsOfFile: googleServicePlistPath) { @@ -39,6 +43,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } currentNetworkType = nil + wasStoppedDueToNoNetwork = false startMonitoringNetworkChanges() if adapter.needsLogin() { @@ -57,6 +62,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + wasStoppedDueToNoNetwork = false adapter.stop() guard let pathMonitor = self.pathMonitor else { print("pathMonitor is nil; nothing to cancel.") @@ -107,11 +113,48 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func handleNetworkChange(path: Network.NWPath) { - guard path.status == .satisfied else { - AppLogger.shared.log("No network connection") + if path.status != .satisfied { + AppLogger.shared.log("No network connection detected") + + // Stop engine if running and not already stopped for this reason + if !wasStoppedDueToNoNetwork && adapter.clientState != .disconnected { + AppLogger.shared.log("Stopping engine due to no network (airplane mode?)") + wasStoppedDueToNoNetwork = true + currentNetworkType = nil + adapter.isRestarting = true + adapter.stop { [weak self] in + self?.adapter.isRestarting = false + AppLogger.shared.log("Engine stopped due to no network") + } + } return } + // Network is back - check if we need to restart + if wasStoppedDueToNoNetwork { + AppLogger.shared.log("Network restored after unavailability") + wasStoppedDueToNoNetwork = false + + if adapter.needsLogin() { + AppLogger.shared.log("Login required after network restore - sending notification") + sendLoginRequiredNotification() + // Leave app in stopped state - user needs to open app to login + } else { + AppLogger.shared.log("Restarting engine after network restore") + adapter.isRestarting = true + adapter.start { [weak self] error in + self?.adapter.isRestarting = false + if let error = error { + AppLogger.shared.log("Restart after network restore failed: \(error.localizedDescription)") + } else { + AppLogger.shared.log("Engine restarted after network restore") + } + } + } + return + } + + // Handle wifi <-> cellular transitions let newNetworkType: NWInterface.InterfaceType? = { if path.usesInterfaceType(.wifi) { return .wifi @@ -159,6 +202,27 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } } + func sendLoginRequiredNotification() { + 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 + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + AppLogger.shared.log("Failed to send login notification: \(error.localizedDescription)") + } else { + AppLogger.shared.log("Login required notification sent") + } + } + } + func login(completionHandler: (Data?) -> Void) { let urlString = adapter.login() let data = urlString.data(using: .utf8) From 62659acb13981952d12cc1bd3a90d0a75a737b73 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 16:51:10 +0100 Subject: [PATCH 12/34] remove xcode selection --- .github/workflows/build.yml | 3 --- .github/workflows/test.yml | 3 --- 2 files changed, 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e170e6a..e572225 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,9 +36,6 @@ jobs: - name: Build NetBirdSDK xcframework run: ./build-go-lib.sh $PWD/netbird - - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_16.2.app - - name: Install xcpretty run: gem install xcpretty diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 97ea322..fc86228 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,9 +36,6 @@ jobs: - name: Build NetBirdSDK xcframework run: ./build-go-lib.sh $PWD/netbird - - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_16.2.app - - name: Install xcpretty run: gem install xcpretty From aada818cfe7a7c31887af45624b6ee898a81b24a Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 17:03:50 +0100 Subject: [PATCH 13/34] address comments --- .../PacketTunnelProvider.swift | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 8f23634..1f21083 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -27,13 +27,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider { var pathMonitor: NWPathMonitor? let monitorQueue = DispatchQueue(label: "NetworkMonitor") - var currentNetworkType: NWInterface.InterfaceType? - /// Tracks if engine was stopped due to network unavailability (e.g., airplane mode) - var wasStoppedDueToNoNetwork = false + /// Network state variables - accessed only on monitorQueue for thread safety + private var currentNetworkType: NWInterface.InterfaceType? + private var wasStoppedDueToNoNetwork = false override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { - if let googleServicePlistPath = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist"), + if FirebaseApp.app() == nil, + let googleServicePlistPath = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist"), let firebaseOptions = FirebaseOptions(contentsOfFile: googleServicePlistPath) { FirebaseApp.configure(options: firebaseOptions) } @@ -42,8 +43,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { initializeLogging(loglevel: logLevel) } - currentNetworkType = nil - wasStoppedDueToNoNetwork = false + monitorQueue.async { [weak self] in + self?.currentNetworkType = nil + self?.wasStoppedDueToNoNetwork = false + } startMonitoringNetworkChanges() if adapter.needsLogin() { @@ -62,7 +65,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { - wasStoppedDueToNoNetwork = false + monitorQueue.async { [weak self] in + self?.wasStoppedDueToNoNetwork = false + } adapter.stop() guard let pathMonitor = self.pathMonitor else { print("pathMonitor is nil; nothing to cancel.") From 303ea9d3daea718256656a3c4df93ea2eb16275a Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 17:15:57 +0100 Subject: [PATCH 14/34] use macos-14 --- .github/workflows/build.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e572225..edb97db 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ concurrency: jobs: build: name: Build iOS App - runs-on: macos-15 + runs-on: macos-14 steps: - name: Checkout ios-client diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc86228..2ac9b15 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ concurrency: jobs: test: name: Build for Simulator - runs-on: macos-15 + runs-on: macos-14 steps: - name: Checkout ios-client From 87433714da0feb8c89f8e564732a0ac100b4b0f8 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 17:38:22 +0100 Subject: [PATCH 15/34] use a subdirectory --- .github/workflows/build.yml | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index edb97db..d9560be 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,6 +17,8 @@ jobs: steps: - name: Checkout ios-client uses: actions/checkout@v4 + with: + path: ios-client - name: Checkout netbird uses: actions/checkout@v4 @@ -33,19 +35,45 @@ jobs: - name: Install gomobile run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab + - name: Debug - List files before xcframework build + working-directory: ios-client + run: | + echo "=== Before xcframework build ===" + ls -la NetBird/Source/App/Views/ || echo "Views dir missing BEFORE" + - name: Build NetBirdSDK xcframework - run: ./build-go-lib.sh $PWD/netbird + working-directory: ios-client + run: ./build-go-lib.sh ../netbird + + - name: Debug - List files after xcframework build + working-directory: ios-client + run: | + echo "=== After xcframework build ===" + ls -la NetBird/ || echo "NetBird dir missing" + ls -la NetBird/Source/App/Views/ || echo "Views dir missing AFTER" - name: Install xcpretty + working-directory: ios-client run: gem install xcpretty + - name: Debug - List Source files + working-directory: ios-client + run: | + echo "=== Checking NetBird/Source/App structure ===" + ls -la NetBird/Source/App/ || echo "App dir not found" + ls -la NetBird/Source/App/Views/ || echo "Views dir not found" + ls -la NetBird/Source/App/Views/Components/ || echo "Components dir not found" + ls -la NetBird/Source/App/ViewModels/ || echo "ViewModels dir not found" + - name: Resolve Swift packages + working-directory: ios-client run: | xcodebuild -resolvePackageDependencies \ -project NetBird.xcodeproj \ -scheme NetBird - name: Build iOS App + working-directory: ios-client run: | set -o pipefail xcodebuild build \ @@ -59,6 +87,7 @@ jobs: | xcpretty --color - name: Build Network Extension + working-directory: ios-client run: | set -o pipefail xcodebuild build \ From 4a54da78c78c34c01f23a315e9e0fed9b4f863ab Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 17:52:56 +0100 Subject: [PATCH 16/34] use a subdirectory in test --- .github/workflows/test.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ac9b15..dca58cc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,8 @@ jobs: steps: - name: Checkout ios-client uses: actions/checkout@v4 + with: + path: ios-client - name: Checkout netbird uses: actions/checkout@v4 @@ -34,18 +36,22 @@ jobs: run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab - name: Build NetBirdSDK xcframework - run: ./build-go-lib.sh $PWD/netbird + working-directory: ios-client + run: ./build-go-lib.sh ../netbird - name: Install xcpretty + working-directory: ios-client run: gem install xcpretty - name: Resolve Swift packages + working-directory: ios-client run: | xcodebuild -resolvePackageDependencies \ -project NetBird.xcodeproj \ -scheme NetBird - name: Build for Simulator + working-directory: ios-client run: | set -o pipefail xcodebuild build \ From a30ea8ec586c90457267bfa3052d548073de01f7 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 17:53:54 +0100 Subject: [PATCH 17/34] improve reliability when handling auth --- NetBird/Source/App/NetBirdApp.swift | 1 + .../Source/App/ViewModels/MainViewModel.swift | 46 ++++++++++++++++ NetbirdKit/GlobalConstants.swift | 1 + .../PacketTunnelProvider.swift | 55 +++++++++++++------ 4 files changed, 86 insertions(+), 17 deletions(-) diff --git a/NetBird/Source/App/NetBirdApp.swift b/NetBird/Source/App/NetBirdApp.swift index 262ad2d..c165ac9 100644 --- a/NetBird/Source/App/NetBirdApp.swift +++ b/NetBird/Source/App/NetBirdApp.swift @@ -35,6 +35,7 @@ struct NetBirdApp: App { .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) {_ in print("App is active!") viewModel.checkExtensionState() + viewModel.checkLoginRequiredFlag() viewModel.startPollingDetails() } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) {_ in diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 1f8f542..90f9ef4 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -9,6 +9,7 @@ import UIKit import NetworkExtension import os import Combine +import UserNotifications @MainActor class ViewModel: ObservableObject { @@ -314,4 +315,49 @@ class ViewModel: ObservableObject { print("Failed to read the log file: \(error.localizedDescription)") } } + + /// 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. + func checkLoginRequiredFlag() { + let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) + guard userDefaults?.bool(forKey: GlobalConstants.keyLoginRequired) == true else { + return + } + + // Clear the flag immediately + userDefaults?.set(false, forKey: GlobalConstants.keyLoginRequired) + userDefaults?.synchronize() + + AppLogger.shared.log("Login required flag detected from extension") + + // Show authentication required UI + self.showAuthenticationRequired = true + + // Schedule local notification if authorized + UNUserNotificationCenter.current().getNotificationSettings { settings in + guard settings.authorizationStatus == .authorized else { + AppLogger.shared.log("Notifications not authorized, skipping notification") + 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 + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + AppLogger.shared.log("Failed to schedule login notification: \(error.localizedDescription)") + } else { + AppLogger.shared.log("Login required notification scheduled from main app") + } + } + } + } } diff --git a/NetbirdKit/GlobalConstants.swift b/NetbirdKit/GlobalConstants.swift index 5fdb445..a76a311 100644 --- a/NetbirdKit/GlobalConstants.swift +++ b/NetbirdKit/GlobalConstants.swift @@ -7,5 +7,6 @@ struct GlobalConstants { static let keyForceRelayConnection = "isConnectionForceRelayed" + static let keyLoginRequired = "netbird.loginRequired" static let userPreferencesSuiteName = "group.io.netbird.app" } diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 1f21083..7227774 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -141,8 +141,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { wasStoppedDueToNoNetwork = false if adapter.needsLogin() { - AppLogger.shared.log("Login required after network restore - sending notification") - sendLoginRequiredNotification() + AppLogger.shared.log("Login required after network restore - signaling main app") + signalLoginRequired() // Leave app in stopped state - user needs to open app to login } else { AppLogger.shared.log("Restarting engine after network restore") @@ -207,23 +207,44 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } } - func sendLoginRequiredNotification() { - let content = UNMutableNotificationContent() - content.title = "NetBird" - content.body = "Login required. Please open the app to reconnect." - content.sound = .default + /// Signals login required by persisting a flag to the shared app-group container. + /// The main app reads this flag when it becomes active and handles notification scheduling. + /// Direct notification from extension is best-effort only since NEPacketTunnelProvider + /// notification scheduling is unreliable. + func signalLoginRequired() { + let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) + userDefaults?.set(true, forKey: GlobalConstants.keyLoginRequired) + userDefaults?.synchronize() + AppLogger.shared.log("Login required flag set in shared container") + + // Best-effort notification attempt from extension (may not work reliably) + sendLoginNotificationBestEffort() + } - let request = UNNotificationRequest( - identifier: "netbird.login.required", - content: content, - trigger: nil - ) + private func sendLoginNotificationBestEffort() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + guard settings.authorizationStatus == .authorized else { + AppLogger.shared.log("Notifications not authorized, skipping extension notification attempt") + return + } - UNUserNotificationCenter.current().add(request) { error in - if let error = error { - AppLogger.shared.log("Failed to send login notification: \(error.localizedDescription)") - } else { - AppLogger.shared.log("Login required notification sent") + 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 + ) + + 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") + } } } } From 0a16812b37c7284dd2cc489be8973cd380f2a7fd Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 17:55:45 +0100 Subject: [PATCH 18/34] Removed Firebase initialization in networkextension --- .../PacketTunnelProvider.swift | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 7227774..9d44d2f 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -8,10 +8,6 @@ import NetworkExtension import Network import os -import Firebase -import FirebaseCrashlytics -import FirebaseCore -import FirebasePerformance import UserNotifications @@ -33,12 +29,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { private var wasStoppedDueToNoNetwork = false override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { - if FirebaseApp.app() == nil, - let googleServicePlistPath = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist"), - let firebaseOptions = FirebaseOptions(contentsOfFile: googleServicePlistPath) { - FirebaseApp.configure(options: firebaseOptions) - } - if let options = options, let logLevel = options["logLevel"] as? String { initializeLogging(loglevel: logLevel) } @@ -193,11 +183,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { if let error = error { self?.adapter.isRestarting = false AppLogger.shared.log("restartClient: start failed - \(error.localizedDescription)") - Analytics.logEvent("packet_tunnel_provider", parameters: [ - "level": "ERROR", - "method": "restartClient", - "error" : error.localizedDescription - ]) } else { // Note: isRestarting is already cleared by onConnected() callback self?.adapter.isRestarting = false From ed7ec21b44f340dff02a91a7e96a703f5a94d0a0 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 17:56:29 +0100 Subject: [PATCH 19/34] remove mac designed for ipad support from gui --- NetBird.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 77a553b..57dd98a 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -853,7 +853,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "NetBird-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -905,7 +905,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "NetBird-Bridging-Header.h"; SWIFT_VERSION = 5.0; From bd521b07e5a8f10a013b32d96007d38c51e6d2d9 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 18:08:36 +0100 Subject: [PATCH 20/34] fix readme version --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6d5faa0..146df06 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ The code is divided into 4 parts: ## Requirements - iOS 14.0+ -- Xcode 16.0+ -- Go 1.23+ +- Xcode 16.1+ +- Go 1.24+ - gomobile ## Run locally From 41778496401cfa8630b1ede8d72c5da935734710 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 18:08:51 +0100 Subject: [PATCH 21/34] add popoverPresentationController when running on ipad --- NetBird/Source/App/Views/AdvancedView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/NetBird/Source/App/Views/AdvancedView.swift b/NetBird/Source/App/Views/AdvancedView.swift index e73f3bc..2766951 100644 --- a/NetBird/Source/App/Views/AdvancedView.swift +++ b/NetBird/Source/App/Views/AdvancedView.swift @@ -232,6 +232,14 @@ struct AdvancedView: View { if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootViewController = windowScene.windows.first?.rootViewController { + // Configure popover for iPad to prevent crash + if let popover = activityViewController.popoverPresentationController { + popover.sourceView = rootViewController.view + popover.sourceRect = CGRect(x: rootViewController.view.bounds.midX, + y: rootViewController.view.bounds.midY, + width: 0, height: 0) + popover.permittedArrowDirections = [] + } rootViewController.present(activityViewController, animated: true, completion: nil) } } From 6defc9fc4faf2798e9d701ba12e923f03f809dc8 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 18:10:50 +0100 Subject: [PATCH 22/34] Uses temporary directory instead of Documents --- NetBird/Source/App/Views/AdvancedView.swift | 32 ++++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/NetBird/Source/App/Views/AdvancedView.swift b/NetBird/Source/App/Views/AdvancedView.swift index 2766951..4d74b7d 100644 --- a/NetBird/Source/App/Views/AdvancedView.swift +++ b/NetBird/Source/App/Views/AdvancedView.swift @@ -187,8 +187,13 @@ struct AdvancedView: View { } func shareButtonTapped() { - guard let documentsDir = getDocumentsDirectory() else { - AppLogger.shared.log("Failed to get documents directory") + let fileManager = FileManager.default + let tempDir = fileManager.temporaryDirectory.appendingPathComponent("netbird-logs-\(UUID().uuidString)") + + do { + try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true) + } catch { + AppLogger.shared.log("Failed to create temp directory: \(error)") return } @@ -198,11 +203,11 @@ struct AdvancedView: View { if let goLogURL = AppLogger.getGoLogFileURL() { do { let goLogData = try String(contentsOf: goLogURL, encoding: .utf8) - let goLogPath = documentsDir.appendingPathComponent("netbird-engine.log") + let goLogPath = tempDir.appendingPathComponent("netbird-engine.log") try goLogData.write(to: goLogPath, atomically: true, encoding: .utf8) filesToShare.append(goLogPath) } catch { - AppLogger.shared.log("Failed to read Go log data: \(error)") + AppLogger.shared.log("Failed to export Go log: \(error)") } } @@ -210,16 +215,17 @@ struct AdvancedView: View { if let swiftLogURL = AppLogger.getLogFileURL() { do { let swiftLogData = try String(contentsOf: swiftLogURL, encoding: .utf8) - let swiftLogPath = documentsDir.appendingPathComponent("netbird-app.log") + let swiftLogPath = tempDir.appendingPathComponent("netbird-app.log") try swiftLogData.write(to: swiftLogPath, atomically: true, encoding: .utf8) filesToShare.append(swiftLogPath) } catch { - AppLogger.shared.log("Failed to read Swift log data: \(error)") + AppLogger.shared.log("Failed to export Swift log: \(error)") } } guard !filesToShare.isEmpty else { AppLogger.shared.log("No log files to share") + try? FileManager.default.removeItem(at: tempDir) return } @@ -230,6 +236,15 @@ struct AdvancedView: View { .saveToCameraRoll ] + // Clean up temp files after share completes (success or cancel) + activityViewController.completionWithItemsHandler = { _, _, _, _ in + do { + try FileManager.default.removeItem(at: tempDir) + } catch { + AppLogger.shared.log("Failed to cleanup temp log files: \(error)") + } + } + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootViewController = windowScene.windows.first?.rootViewController { // Configure popover for iPad to prevent crash @@ -243,11 +258,6 @@ struct AdvancedView: View { rootViewController.present(activityViewController, animated: true, completion: nil) } } - - func getDocumentsDirectory() -> URL? { - let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - return paths.first - } func checkForValidPresharedKey(text: String) { if isValidBase64EncodedString(text) { From bf1225c645a5eba687a07b9164fa131c7e057aeb Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 18:12:17 +0100 Subject: [PATCH 23/34] Removed the misleading comment about onConnected() --- NetbirdNetworkExtension/PacketTunnelProvider.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 9d44d2f..6275725 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -180,12 +180,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { adapter.stop { [weak self] in AppLogger.shared.log("restartClient: stop completed, starting client") self?.adapter.start { error in + self?.adapter.isRestarting = false if let error = error { - self?.adapter.isRestarting = false AppLogger.shared.log("restartClient: start failed - \(error.localizedDescription)") } else { - // Note: isRestarting is already cleared by onConnected() callback - self?.adapter.isRestarting = false AppLogger.shared.log("restartClient: start completed successfully") } } From 0aee71bfa837bb076f98bf1905d88cd949ea73f1 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 18:59:10 +0100 Subject: [PATCH 24/34] Added fallback to Documents directory when App Group is unavailable --- NetbirdKit/Preferences.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/NetbirdKit/Preferences.swift b/NetbirdKit/Preferences.swift index 74e959e..0e40c5e 100644 --- a/NetbirdKit/Preferences.swift +++ b/NetbirdKit/Preferences.swift @@ -15,16 +15,22 @@ class Preferences { static func configFile() -> String { let fileManager = FileManager.default - let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.io.netbird.app") - let logURL = groupURL?.appendingPathComponent("netbird.cfg") - return logURL!.relativePath + if let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.io.netbird.app") { + return groupURL.appendingPathComponent("netbird.cfg").relativePath + } + // Fallback for testing or when app group is not available + let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first! + return (documentsPath as NSString).appendingPathComponent("netbird.cfg") } static func stateFile() -> String { let fileManager = FileManager.default - let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.io.netbird.app") - let logURL = groupURL?.appendingPathComponent("state.json") - return logURL!.relativePath + if let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.io.netbird.app") { + return groupURL.appendingPathComponent("state.json").relativePath + } + // Fallback for testing or when app group is not available + let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first! + return (documentsPath as NSString).appendingPathComponent("state.json") } } From d69a8cf95860ca33051ad149b18396f86aaa120b Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 18:59:31 +0100 Subject: [PATCH 25/34] Added tests --- .github/workflows/test.yml | 8 +- NetBird.xcodeproj/project.pbxproj | 144 ++++++++++++++++++ .../xcshareddata/xcschemes/NetBird.xcscheme | 13 ++ NetBirdTests/AppLoggerTests.swift | 40 +++++ NetBirdTests/GlobalConstantsTests.swift | 22 +++ NetBirdTests/SharedUserDefaultsTests.swift | 54 +++++++ 6 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 NetBirdTests/AppLoggerTests.swift create mode 100644 NetBirdTests/GlobalConstantsTests.swift create mode 100644 NetBirdTests/SharedUserDefaultsTests.swift diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dca58cc..64e7007 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ concurrency: jobs: test: - name: Build for Simulator + name: Build and Test runs-on: macos-14 steps: @@ -50,14 +50,14 @@ jobs: -project NetBird.xcodeproj \ -scheme NetBird - - name: Build for Simulator + - name: Run Tests working-directory: ios-client run: | set -o pipefail - xcodebuild build \ + xcodebuild test \ -project NetBird.xcodeproj \ -scheme NetBird \ -destination 'platform=iOS Simulator,name=iPhone 16' \ -configuration Debug \ CODE_SIGNING_ALLOWED=NO \ - | xcpretty --color \ No newline at end of file + | xcpretty --color --test \ No newline at end of file diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 57dd98a..b597100 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 1C4E6A81CD33FF6D2DEFF8D5 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 91FA1F06D3375864C74EAB3B /* Foundation.framework */; }; + 1C9E4E97130030CE0D6C8F59 /* SharedUserDefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CB9305A9DC6CAD1895495A /* SharedUserDefaultsTests.swift */; }; 50003BBC2AFBCA6B00E5EB6B /* FirebasePerformance in Frameworks */ = {isa = PBXBuildFile; productRef = 50003BBB2AFBCA6B00E5EB6B /* FirebasePerformance */; }; 50003BBE2AFBCA7900E5EB6B /* FirebasePerformance in Frameworks */ = {isa = PBXBuildFile; productRef = 50003BBD2AFBCA7900E5EB6B /* FirebasePerformance */; }; 50003BC42AFBD7D500E5EB6B /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50245A562A80431C0034792B /* PacketTunnelProvider.swift */; }; @@ -80,8 +82,10 @@ 50E608202A7979D600BAF09B /* SideDrawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E6081F2A7979D600BAF09B /* SideDrawer.swift */; }; 50E608242A79966600BAF09B /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608232A79966600BAF09B /* AboutView.swift */; }; 50E608262A79968500BAF09B /* AdvancedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608252A79968500BAF09B /* AdvancedView.swift */; }; + 94F739DA3E076313908BA6DF /* GlobalConstantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E054D83063E440DAD0C52FA /* GlobalConstantsTests.swift */; }; 978FC4702EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; 978FC4712EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; + 9CC0E000AE3F165CA72FD465 /* AppLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AA7193B3AE82DF185EDEB1B /* AppLoggerTests.swift */; }; F1258DE22ED4EE5000C0D205 /* ServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE12ED4EE4900C0D205 /* ServerViewModel.swift */; }; F1258DEA2ED7B7D600C0D205 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE92ED7B7D200C0D205 /* Extensions.swift */; }; F1B292052EDE5610001D91B8 /* JustifiedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292042EDE5608001D91B8 /* JustifiedText.swift */; }; @@ -92,6 +96,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 075441E07E64305C28EF192A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 50A8910F2A792A15007C48FC /* Project object */; + proxyType = 1; + remoteGlobalIDString = 50A891162A792A15007C48FC; + remoteInfo = NetBird; + }; 50245A5A2A80431C0034792B /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 50A8910F2A792A15007C48FC /* Project object */; @@ -116,6 +127,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 3E054D83063E440DAD0C52FA /* GlobalConstantsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlobalConstantsTests.swift; sourceTree = ""; }; 50003BC82AFD2F0C00E5EB6B /* ConnectionListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionListener.swift; sourceTree = ""; }; 50003BCB2AFD3B0C00E5EB6B /* ClientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientState.swift; sourceTree = ""; }; 501B0DC52AE04DDE004BE7A7 /* button-disconnecting.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "button-disconnecting.json"; sourceTree = ""; }; @@ -151,6 +163,7 @@ 506331FA2AF52AB900BC8F0E /* CustomLottieView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomLottieView.swift; sourceTree = ""; }; 506331FF2AF9197700BC8F0E /* button-full2-dark.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "button-full2-dark.json"; sourceTree = ""; }; 506332012AF9415500BC8F0E /* CustomTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTextField.swift; sourceTree = ""; }; + 50733EE9CE10FEDDA61600B8 /* NetBirdTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NetBirdTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 508BD8442AF04A990055E415 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; 509CCD672BE8FFBF00B7C2D8 /* TabBarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarButton.swift; sourceTree = ""; }; 509CCD692BE908C000B7C2D8 /* RoutesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutesViewModel.swift; sourceTree = ""; }; @@ -175,6 +188,9 @@ 50E6081F2A7979D600BAF09B /* SideDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideDrawer.swift; sourceTree = ""; }; 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 = ""; }; + 8AA7193B3AE82DF185EDEB1B /* AppLoggerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppLoggerTests.swift; sourceTree = ""; }; + 91FA1F06D3375864C74EAB3B /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogger.swift; sourceTree = ""; }; F1258DE12ED4EE4900C0D205 /* ServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerViewModel.swift; sourceTree = ""; }; F1258DE92ED7B7D200C0D205 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; @@ -213,6 +229,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AEE671F5AD9A52DB8CAA111 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1C4E6A81CD33FF6D2DEFF8D5 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -237,6 +261,7 @@ children = ( 50245A192A7BCE830034792B /* libresolv.tbd */, 50245A532A80431B0034792B /* NetworkExtension.framework */, + 82DA5029784B2E0DD517575B /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -278,6 +303,7 @@ 505118C72AD96ECA003027D3 /* WireGuardKitC */, 50A891182A792A15007C48FC /* Products */, 50245A182A7BCE830034792B /* Frameworks */, + 651C942641826A7AA94ED369 /* NetBirdTests */, ); sourceTree = ""; }; @@ -286,6 +312,7 @@ children = ( 50A891172A792A15007C48FC /* NetBird.app */, 50245A522A80431B0034792B /* NetbirdNetworkExtension.appex */, + 50733EE9CE10FEDDA61600B8 /* NetBirdTests.xctest */, ); name = Products; sourceTree = ""; @@ -383,6 +410,25 @@ path = App; sourceTree = ""; }; + 651C942641826A7AA94ED369 /* NetBirdTests */ = { + isa = PBXGroup; + children = ( + 3E054D83063E440DAD0C52FA /* GlobalConstantsTests.swift */, + 8AA7193B3AE82DF185EDEB1B /* AppLoggerTests.swift */, + 53CB9305A9DC6CAD1895495A /* SharedUserDefaultsTests.swift */, + ); + name = NetBirdTests; + path = NetBirdTests; + sourceTree = ""; + }; + 82DA5029784B2E0DD517575B /* iOS */ = { + isa = PBXGroup; + children = ( + 91FA1F06D3375864C74EAB3B /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -438,6 +484,24 @@ productReference = 50A891172A792A15007C48FC /* NetBird.app */; productType = "com.apple.product-type.application"; }; + C4BEBDBD1DC2C4D7764C202C /* NetBirdTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2220A9ADFD7D3298B5431E55 /* Build configuration list for PBXNativeTarget "NetBirdTests" */; + buildPhases = ( + 75C1F1B3030A2B3C26074DAB /* Sources */, + 5AEE671F5AD9A52DB8CAA111 /* Frameworks */, + 867D87D69DACEF5CF82E9A1B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 544A834AF876CA67355D4DFA /* PBXTargetDependency */, + ); + name = NetBirdTests; + productName = NetBirdTests; + productReference = 50733EE9CE10FEDDA61600B8 /* NetBirdTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -477,6 +541,7 @@ targets = ( 50A891162A792A15007C48FC /* NetBird */, 50245A512A80431B0034792B /* NetbirdNetworkExtension */, + C4BEBDBD1DC2C4D7764C202C /* NetBirdTests */, ); }; /* End PBXProject section */ @@ -506,6 +571,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 867D87D69DACEF5CF82E9A1B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -624,6 +696,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 75C1F1B3030A2B3C26074DAB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 94F739DA3E076313908BA6DF /* GlobalConstantsTests.swift in Sources */, + 9CC0E000AE3F165CA72FD465 /* AppLoggerTests.swift in Sources */, + 1C9E4E97130030CE0D6C8F59 /* SharedUserDefaultsTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -632,9 +714,38 @@ target = 50245A512A80431B0034792B /* NetbirdNetworkExtension */; targetProxy = 50245A5A2A80431C0034792B /* PBXContainerItemProxy */; }; + 544A834AF876CA67355D4DFA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = NetBird; + target = 50A891162A792A15007C48FC /* NetBird */; + targetProxy = 075441E07E64305C28EF192A /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 10D34E0ECAF1104BDC8395E0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = TA739QLA7A; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ""; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NetBird.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/NetBird"; + }; + name = Debug; + }; 50245A5E2A80431C0034792B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -913,9 +1024,42 @@ }; name = Release; }; + 9D05AFD5853A133CCADBCC3C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = TA739QLA7A; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ""; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NetBird.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/NetBird"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 2220A9ADFD7D3298B5431E55 /* Build configuration list for PBXNativeTarget "NetBirdTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9D05AFD5853A133CCADBCC3C /* Release */, + 10D34E0ECAF1104BDC8395E0 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 50245A5D2A80431C0034792B /* Build configuration list for PBXNativeTarget "NetbirdNetworkExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/NetBird.xcodeproj/xcshareddata/xcschemes/NetBird.xcscheme b/NetBird.xcodeproj/xcshareddata/xcschemes/NetBird.xcscheme index 54896d3..23c3850 100644 --- a/NetBird.xcodeproj/xcshareddata/xcschemes/NetBird.xcscheme +++ b/NetBird.xcodeproj/xcshareddata/xcschemes/NetBird.xcscheme @@ -28,6 +28,19 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + Date: Sun, 14 Dec 2025 19:14:29 +0100 Subject: [PATCH 26/34] Added workflow permissions --- .github/workflows/build.yml | 3 ++- .github/workflows/test.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d9560be..3f20292 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,7 +8,8 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true - +permissions: + contents: read jobs: build: name: Build iOS App diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 64e7007..f5dbc0a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,8 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true - +permissions: + contents: read jobs: test: name: Build and Test From 5f8326128a09fce259f549392596287caab4dc8a Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 19:27:41 +0100 Subject: [PATCH 27/34] handle potential nil in tests --- NetBirdTests/SharedUserDefaultsTests.swift | 47 +++++++++++++--------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/NetBirdTests/SharedUserDefaultsTests.swift b/NetBirdTests/SharedUserDefaultsTests.swift index 4062337..c3c60bb 100644 --- a/NetBirdTests/SharedUserDefaultsTests.swift +++ b/NetBirdTests/SharedUserDefaultsTests.swift @@ -8,47 +8,54 @@ import XCTest final class SharedUserDefaultsTests: XCTestCase { - var userDefaults: UserDefaults! + var userDefaults: UserDefaults? - override func setUp() { - super.setUp() + override func setUpWithError() throws { + try super.setUpWithError() userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) + guard userDefaults != nil else { + throw XCTSkip("Shared UserDefaults suite not available (app group may not be configured)") + } } override func tearDown() { - // Clean up test keys userDefaults?.removeObject(forKey: GlobalConstants.keyLoginRequired) userDefaults?.removeObject(forKey: GlobalConstants.keyForceRelayConnection) super.tearDown() } - func testUserDefaultsSuiteExists() { - XCTAssertNotNil(userDefaults, "Shared UserDefaults suite should exist") + func testUserDefaultsSuiteExists() throws { + let defaults = try XCTUnwrap(userDefaults, "Shared UserDefaults suite should exist") + XCTAssertNotNil(defaults) } - func testLoginRequiredFlagDefaultsToFalse() { - userDefaults.removeObject(forKey: GlobalConstants.keyLoginRequired) - let value = userDefaults.bool(forKey: GlobalConstants.keyLoginRequired) + func testLoginRequiredFlagDefaultsToFalse() throws { + let defaults = try XCTUnwrap(userDefaults) + defaults.removeObject(forKey: GlobalConstants.keyLoginRequired) + let value = defaults.bool(forKey: GlobalConstants.keyLoginRequired) XCTAssertFalse(value, "Login required flag should default to false") } - func testLoginRequiredFlagCanBeSet() { - userDefaults.set(true, forKey: GlobalConstants.keyLoginRequired) - let value = userDefaults.bool(forKey: GlobalConstants.keyLoginRequired) + func testLoginRequiredFlagCanBeSet() throws { + let defaults = try XCTUnwrap(userDefaults) + defaults.set(true, forKey: GlobalConstants.keyLoginRequired) + let value = defaults.bool(forKey: GlobalConstants.keyLoginRequired) XCTAssertTrue(value, "Login required flag should be true after setting") } - func testLoginRequiredFlagCanBeCleared() { - userDefaults.set(true, forKey: GlobalConstants.keyLoginRequired) - userDefaults.set(false, forKey: GlobalConstants.keyLoginRequired) - let value = userDefaults.bool(forKey: GlobalConstants.keyLoginRequired) + func testLoginRequiredFlagCanBeCleared() throws { + let defaults = try XCTUnwrap(userDefaults) + defaults.set(true, forKey: GlobalConstants.keyLoginRequired) + defaults.set(false, forKey: GlobalConstants.keyLoginRequired) + let value = defaults.bool(forKey: GlobalConstants.keyLoginRequired) XCTAssertFalse(value, "Login required flag should be false after clearing") } - func testForceRelayConnectionDefaultsToTrue() { - userDefaults.removeObject(forKey: GlobalConstants.keyForceRelayConnection) - userDefaults.register(defaults: [GlobalConstants.keyForceRelayConnection: true]) - let value = userDefaults.bool(forKey: GlobalConstants.keyForceRelayConnection) + func testForceRelayConnectionDefaultsToTrue() throws { + let defaults = try XCTUnwrap(userDefaults) + defaults.removeObject(forKey: GlobalConstants.keyForceRelayConnection) + defaults.register(defaults: [GlobalConstants.keyForceRelayConnection: true]) + let value = defaults.bool(forKey: GlobalConstants.keyForceRelayConnection) XCTAssertTrue(value, "Force relay connection should default to true") } } From b369f73ee3ea20f7e0437115faac2dd9d12c0ba7 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 21:10:07 +0100 Subject: [PATCH 28/34] handle airplane mode freezes --- NetBird.xcodeproj/project.pbxproj | 5 +-- .../Source/App/ViewModels/MainViewModel.swift | 38 ++++++++++++++++--- .../Views/Components/CustomLottieView.swift | 26 +++++++++++-- NetBird/Source/App/Views/MainView.swift | 3 +- .../PacketTunnelProvider.swift | 4 +- 5 files changed, 61 insertions(+), 15 deletions(-) diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index b597100..f834cba 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -417,7 +417,6 @@ 8AA7193B3AE82DF185EDEB1B /* AppLoggerTests.swift */, 53CB9305A9DC6CAD1895495A /* SharedUserDefaultsTests.swift */, ); - name = NetBirdTests; path = NetBirdTests; sourceTree = ""; }; @@ -930,7 +929,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = NetBird/NetBird.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = TA739QLA7A; ENABLE_PREVIEWS = YES; @@ -982,7 +981,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = NetBird/NetBird.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = TA739QLA7A; ENABLE_PREVIEWS = YES; diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 90f9ef4..542be13 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -137,16 +137,14 @@ class ViewModel: ObservableObject { self.defaults.set(details.ip, forKey: "ip") self.ip = details.ip } - print("Status: \(details.managementStatus) - Extension: \(self.extensionState) - LoginRequired: \(self.networkExtensionAdapter.isLoginRequired())") + print("Status: \(details.managementStatus) - Extension: \(self.extensionState)") if details.managementStatus != self.managementStatus { self.managementStatus = details.managementStatus } - if details.managementStatus == .disconnected && self.extensionState == .connected && self.networkExtensionAdapter.isLoginRequired() { - self.networkExtensionAdapter.stop() - self.showAuthenticationRequired = true - } + // Login required detection is handled by the network extension via signalLoginRequired() + // The app checks for this flag in checkLoginRequiredFlag() when becoming active } self.statusDetailsValid = true @@ -316,6 +314,36 @@ class ViewModel: ObservableObject { } } + /// 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") + + // Reset connection flags first to update UI immediately + connectPressed = false + disconnectPressed = false + buttonLock = false + + // Reset connection state + extensionState = .disconnected + extensionStateText = "Disconnected" + managementStatus = .disconnected + statusDetailsValid = false + + // Clear peer info + peerViewModel.peerInfo = [] + + // Clear connection details + clearDetails() + + // Stop the network extension in background (non-blocking) + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.networkExtensionAdapter.stop() + } + + // Reload preferences for new server + preferences = Preferences.newPreferences() + } + /// 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. func checkLoginRequiredFlag() { diff --git a/NetBird/Source/App/Views/Components/CustomLottieView.swift b/NetBird/Source/App/Views/Components/CustomLottieView.swift index 12f696c..9fce401 100644 --- a/NetBird/Source/App/Views/Components/CustomLottieView.swift +++ b/NetBird/Source/App/Views/Components/CustomLottieView.swift @@ -28,7 +28,21 @@ struct CustomLottieView: UIViewRepresentable { context.coordinator.engineStatus = engineStatus context.coordinator.connectPressed = connectPressed context.coordinator.disconnectPressed = disconnectPressed - + + // Force reset to disconnected state when all flags indicate disconnected + // This handles cases like server change where we need to immediately reset + let shouldForceReset = extensionStatus == .disconnected + && !connectPressed + && !disconnectPressed + && engineStatus == .disconnected + + if shouldForceReset { + context.coordinator.isPlaying = false + uiView.stop() + uiView.currentFrame = context.coordinator.disconnectedFrame + return + } + if context.coordinator.isPlaying { print("Is still playing") return @@ -52,9 +66,15 @@ struct CustomLottieView: UIViewRepresentable { case .connecting: context.coordinator.playConnectingLoop(uiView: uiView, viewModel: viewModel) case .disconnected: - break + // Engine disconnected but tunnel still up - show disconnected state + DispatchQueue.main.async { + viewModel.extensionStateText = "Disconnected" + } + uiView.currentFrame = context.coordinator.disconnectedFrame case .disconnecting: - break + DispatchQueue.main.async { + context.coordinator.playDisconnectingFadeIn(uiView: uiView, viewModel: viewModel) + } } case .disconnected: if connectPressed { diff --git a/NetBird/Source/App/Views/MainView.swift b/NetBird/Source/App/Views/MainView.swift index b45d86e..5c27b13 100644 --- a/NetBird/Source/App/Views/MainView.swift +++ b/NetBird/Source/App/Views/MainView.swift @@ -430,8 +430,7 @@ struct ChangeServerAlert: View { .foregroundColor(Color("TextAlert")) .multilineTextAlignment(.center) SolidButton(text: "Confirm") { - viewModel.close() - viewModel.clearDetails() + viewModel.handleServerChanged() isPresented.toggle() viewModel.navigateToServerView = true } diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 6275725..c815fbe 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -112,13 +112,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { AppLogger.shared.log("No network connection detected") // Stop engine if running and not already stopped for this reason + // Note: We do NOT set isRestarting here because we're just stopping, not restarting + // This allows the UI to properly show disconnecting/disconnected state if !wasStoppedDueToNoNetwork && adapter.clientState != .disconnected { AppLogger.shared.log("Stopping engine due to no network (airplane mode?)") wasStoppedDueToNoNetwork = true currentNetworkType = nil - adapter.isRestarting = true adapter.stop { [weak self] in - self?.adapter.isRestarting = false AppLogger.shared.log("Engine stopped due to no network") } } From 1028f7c433d59d5cf2dd915da7572d1159e4b530 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 14 Dec 2025 21:25:53 +0100 Subject: [PATCH 29/34] fix comment --- NetBird/Source/App/Views/Components/CustomLottieView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NetBird/Source/App/Views/Components/CustomLottieView.swift b/NetBird/Source/App/Views/Components/CustomLottieView.swift index 9fce401..9ac56c4 100644 --- a/NetBird/Source/App/Views/Components/CustomLottieView.swift +++ b/NetBird/Source/App/Views/Components/CustomLottieView.swift @@ -186,14 +186,14 @@ struct CustomLottieView: UIViewRepresentable { } else if self.engineStatus == .connected && self.extensionStatus == .connected { // Engine recovered to connected during internal restart (e.g., network switch) // Extension never disconnected, so skip fade out and go directly to connected state - self.isPlaying = false DispatchQueue.main.async { + self.isPlaying = false + uiView.currentFrame = self.connectedFrame viewModel.extensionStateText = "Connected" viewModel.connectPressed = false viewModel.disconnectPressed = false viewModel.routeViewModel.getRoutes() } - uiView.currentFrame = self.connectedFrame } else { playDisconnectingLoop(uiView: uiView, viewModel: viewModel) } From 50b01653a6d39266beff58266868804bbad22a29 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Mon, 15 Dec 2025 00:30:17 +0100 Subject: [PATCH 30/34] update airplane mode handler --- .../Source/App/ViewModels/MainViewModel.swift | 21 ++++++-- .../Views/Components/CustomLottieView.swift | 38 +++++++++++-- NetBird/Source/App/Views/MainView.swift | 2 +- NetbirdKit/AppLogger.swift | 14 ++++- NetbirdKit/GlobalConstants.swift | 1 + .../PacketTunnelProvider.swift | 53 +++++++++---------- 6 files changed, 94 insertions(+), 35 deletions(-) diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 542be13..8455c93 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -58,7 +58,8 @@ class ViewModel: ObservableObject { } @Published var forceRelayConnection = true @Published var showForceRelayAlert = false - + @Published var networkUnavailable = false + var preferences = Preferences.newPreferences() var buttonLock = false let defaults = UserDefaults.standard @@ -119,13 +120,15 @@ class ViewModel: ObservableObject { func startPollingDetails() { networkExtensionAdapter.startTimer { details in - + self.checkExtensionState() + self.checkNetworkUnavailableFlag() + if self.extensionState == .disconnected && self.extensionStateText == "Connected" { self.showAuthenticationRequired = true self.extensionStateText = "Disconnected" } - + if details.ip != self.ip || details.fqdn != self.fqdn || details.managementStatus != self.managementStatus { if !details.fqdn.isEmpty && details.fqdn != self.fqdn { @@ -344,6 +347,18 @@ class ViewModel: ObservableObject { preferences = Preferences.newPreferences() } + /// Checks shared app-group container for network unavailable flag set by the network extension. + /// Updates the networkUnavailable property to trigger UI animation changes. + func checkNetworkUnavailableFlag() { + 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 + } + } + /// 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. func checkLoginRequiredFlag() { diff --git a/NetBird/Source/App/Views/Components/CustomLottieView.swift b/NetBird/Source/App/Views/Components/CustomLottieView.swift index 9ac56c4..804c69b 100644 --- a/NetBird/Source/App/Views/Components/CustomLottieView.swift +++ b/NetBird/Source/App/Views/Components/CustomLottieView.swift @@ -9,8 +9,9 @@ struct CustomLottieView: UIViewRepresentable { @Binding var engineStatus: ClientState @Binding var connectPressed: Bool @Binding var disconnectPressed: Bool + @Binding var networkUnavailable: Bool @StateObject var viewModel: ViewModel - + func makeUIView(context: Context) -> LottieAnimationView { let animationView = LottieAnimationView() animationView.animation = LottieAnimation.named(colorScheme == .dark ? "button-full2-dark" : "button-full2") @@ -20,6 +21,19 @@ struct CustomLottieView: UIViewRepresentable { } func updateUIView(_ uiView: LottieAnimationView, context: Context) { + // Check for network unavailable state change (airplane mode) + if context.coordinator.networkUnavailable != networkUnavailable { + context.coordinator.networkUnavailable = networkUnavailable + + if networkUnavailable && !context.coordinator.isPlaying { + // Network just became unavailable - trigger disconnecting animation + DispatchQueue.main.async { + context.coordinator.playDisconnectingFadeIn(uiView: uiView, viewModel: viewModel) + } + return + } + } + // Status change check if context.coordinator.extensionStatus != extensionStatus || context.coordinator.engineStatus != engineStatus || context.coordinator.connectPressed != connectPressed || context.coordinator.disconnectPressed != disconnectPressed { @@ -50,7 +64,7 @@ struct CustomLottieView: UIViewRepresentable { // Act based on the new status switch extensionStatus { case .connected: - print("Management status chnaged to \(engineStatus)") + print("Management status changed to \(engineStatus)") if disconnectPressed { DispatchQueue.main.async { context.coordinator.playDisconnectingFadeIn(uiView: uiView, viewModel: viewModel) @@ -64,6 +78,8 @@ struct CustomLottieView: UIViewRepresentable { } uiView.currentFrame = context.coordinator.connectedFrame case .connecting: + // Play connecting animation - the loop has proper exit conditions + // for both user-initiated and automatic reconnections context.coordinator.playConnectingLoop(uiView: uiView, viewModel: viewModel) case .disconnected: // Engine disconnected but tunnel still up - show disconnected state @@ -110,6 +126,7 @@ struct CustomLottieView: UIViewRepresentable { var engineStatus: ClientState? var connectPressed: Bool? var disconnectPressed: Bool? + var networkUnavailable: Bool = false var colorScheme: ColorScheme? let connectedFrame: CGFloat = 142 @@ -150,6 +167,10 @@ struct CustomLottieView: UIViewRepresentable { } else if (self.engineStatus == .disconnecting || self.extensionStatus == .disconnecting || self.engineStatus == .disconnected || self.extensionStatus == .disconnected) && !(self.connectPressed ?? false) { print("Connected pressed = \(String(describing: self.connectPressed?.description))") self.playDisconnectingLoop(uiView: uiView, viewModel: viewModel) + } else if !(self.connectPressed ?? false) && self.engineStatus == .connecting { + // Automatic reconnection (not user-initiated) stuck in connecting state + // Exit to disconnected state after one loop to avoid infinite animation + self.playDisconnectingLoop(uiView: uiView, viewModel: viewModel) } else { playConnectingLoop(uiView: uiView, viewModel: viewModel) } @@ -183,9 +204,10 @@ struct CustomLottieView: UIViewRepresentable { guard let self = self else { return } if self.extensionStatus == .disconnected { self.playFadeOut(uiView: uiView, startFrame: self.disconnectingFadeOut.startFrame, endFrame: self.disconnectingFadeOut.endFrame, viewModel: viewModel, extensionStateText: "Disconnected") - } else if self.engineStatus == .connected && self.extensionStatus == .connected { + } else if self.engineStatus == .connected && self.extensionStatus == .connected && !self.networkUnavailable { // Engine recovered to connected during internal restart (e.g., network switch) // Extension never disconnected, so skip fade out and go directly to connected state + // Only if network is available (not airplane mode) DispatchQueue.main.async { self.isPlaying = false uiView.currentFrame = self.connectedFrame @@ -194,6 +216,16 @@ struct CustomLottieView: UIViewRepresentable { viewModel.disconnectPressed = false viewModel.routeViewModel.getRoutes() } + } else if self.networkUnavailable || ((self.engineStatus == .disconnected || self.engineStatus == .connecting) && self.extensionStatus == .connected) { + // Network unavailable (airplane mode) or engine disconnected/stuck connecting + // Show disconnected state immediately + DispatchQueue.main.async { + self.isPlaying = false + uiView.currentFrame = self.disconnectedFrame + viewModel.extensionStateText = "Disconnected" + viewModel.connectPressed = false + viewModel.disconnectPressed = false + } } else { playDisconnectingLoop(uiView: uiView, viewModel: viewModel) } diff --git a/NetBird/Source/App/Views/MainView.swift b/NetBird/Source/App/Views/MainView.swift index 5c27b13..d97ea1a 100644 --- a/NetBird/Source/App/Views/MainView.swift +++ b/NetBird/Source/App/Views/MainView.swift @@ -99,7 +99,7 @@ struct MainView: View { } } }) { - CustomLottieView(extensionStatus: $viewModel.extensionState, engineStatus: $viewModel.managementStatus, connectPressed: $viewModel.connectPressed, disconnectPressed: $viewModel.disconnectPressed, viewModel: viewModel) + CustomLottieView(extensionStatus: $viewModel.extensionState, engineStatus: $viewModel.managementStatus, connectPressed: $viewModel.connectPressed, disconnectPressed: $viewModel.disconnectPressed, networkUnavailable: $viewModel.networkUnavailable, viewModel: viewModel) .id(animationKey) .frame(width: UIScreen.main.bounds.width * (isLandscape ? 0.40 : 0.79), height: UIScreen.main.bounds.width * (isLandscape ? 0.40 : 0.79)) .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in diff --git a/NetbirdKit/AppLogger.swift b/NetbirdKit/AppLogger.swift index 03ca711..7a9adca 100644 --- a/NetbirdKit/AppLogger.swift +++ b/NetbirdKit/AppLogger.swift @@ -16,6 +16,7 @@ public class AppLogger { private var fileHandle: FileHandle? private var logFileURL: URL? private var isReady = false + private let setupSemaphore = DispatchSemaphore(value: 0) private let dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -44,6 +45,7 @@ public class AppLogger { guard let baseURL = containerURL else { print("AppLogger: No writable container found") + setupSemaphore.signal() return } @@ -53,17 +55,22 @@ public class AppLogger { try fileManager.createDirectory(at: baseURL, withIntermediateDirectories: true) } catch { print("AppLogger: Failed to create directory: \(error)") + setupSemaphore.signal() return } } logFileURL = baseURL.appendingPathComponent(logFileName) - guard let url = logFileURL else { return } + guard let url = logFileURL else { + setupSemaphore.signal() + return + } if !fileManager.fileExists(atPath: url.path) { let created = fileManager.createFile(atPath: url.path, contents: nil) if !created { print("AppLogger: Failed to create log file at \(url.path)") + setupSemaphore.signal() return } } @@ -75,6 +82,7 @@ public class AppLogger { } catch { print("AppLogger: Failed to open log file: \(error)") } + setupSemaphore.signal() } public func log(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { @@ -129,6 +137,10 @@ public class AppLogger { } public static func getLogFileURL() -> URL? { + // Wait for setup to complete (with timeout to avoid blocking forever) + _ = shared.setupSemaphore.wait(timeout: .now() + 2.0) + shared.setupSemaphore.signal() // Re-signal for future calls + guard let url = shared.logFileURL, FileManager.default.fileExists(atPath: url.path) else { return nil diff --git a/NetbirdKit/GlobalConstants.swift b/NetbirdKit/GlobalConstants.swift index a76a311..17eeac0 100644 --- a/NetbirdKit/GlobalConstants.swift +++ b/NetbirdKit/GlobalConstants.swift @@ -8,5 +8,6 @@ struct GlobalConstants { static let keyForceRelayConnection = "isConnectionForceRelayed" static let keyLoginRequired = "netbird.loginRequired" + static let keyNetworkUnavailable = "netbird.networkUnavailable" static let userPreferencesSuiteName = "group.io.netbird.app" } diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index c815fbe..abf7929 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -27,6 +27,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { /// Network state variables - accessed only on monitorQueue for thread safety private var currentNetworkType: NWInterface.InterfaceType? private var wasStoppedDueToNoNetwork = false + private var isRestartInProgress = false override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { if let options = options, let logLevel = options["logLevel"] as? String { @@ -36,6 +37,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { monitorQueue.async { [weak self] in self?.currentNetworkType = nil self?.wasStoppedDueToNoNetwork = false + self?.isRestartInProgress = false } startMonitoringNetworkChanges() @@ -57,6 +59,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { monitorQueue.async { [weak self] in self?.wasStoppedDueToNoNetwork = false + self?.isRestartInProgress = false } adapter.stop() guard let pathMonitor = self.pathMonitor else { @@ -111,41 +114,24 @@ class PacketTunnelProvider: NEPacketTunnelProvider { if path.status != .satisfied { AppLogger.shared.log("No network connection detected") - // Stop engine if running and not already stopped for this reason - // Note: We do NOT set isRestarting here because we're just stopping, not restarting - // This allows the UI to properly show disconnecting/disconnected state - if !wasStoppedDueToNoNetwork && adapter.clientState != .disconnected { - AppLogger.shared.log("Stopping engine due to no network (airplane mode?)") + // Signal UI to show disconnecting animation via shared flag + // We don't call adapter.stop() to avoid race conditions with Go SDK callbacks + // The Go SDK will handle network loss internally and reconnect when available + if !wasStoppedDueToNoNetwork { + AppLogger.shared.log("Network unavailable - signaling UI for disconnecting animation, clientState=\(adapter.clientState)") wasStoppedDueToNoNetwork = true currentNetworkType = nil - adapter.stop { [weak self] in - AppLogger.shared.log("Engine stopped due to no network") - } + setNetworkUnavailableFlag(true) } return } - // Network is back - check if we need to restart + // Network is available again if wasStoppedDueToNoNetwork { - AppLogger.shared.log("Network restored after unavailability") + AppLogger.shared.log("Network restored after unavailability - signaling UI") wasStoppedDueToNoNetwork = false - - if adapter.needsLogin() { - AppLogger.shared.log("Login required after network restore - signaling main app") - signalLoginRequired() - // Leave app in stopped state - user needs to open app to login - } else { - AppLogger.shared.log("Restarting engine after network restore") - adapter.isRestarting = true - adapter.start { [weak self] error in - self?.adapter.isRestarting = false - if let error = error { - AppLogger.shared.log("Restart after network restore failed: \(error.localizedDescription)") - } else { - AppLogger.shared.log("Engine restarted after network restore") - } - } - } + setNetworkUnavailableFlag(false) + // Don't need to restart - Go SDK handles reconnection automatically return } @@ -175,12 +161,18 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func restartClient() { + if isRestartInProgress { + AppLogger.shared.log("restartClient: skipping - restart already in progress") + return + } AppLogger.shared.log("restartClient: starting restart sequence") + isRestartInProgress = true adapter.isRestarting = true adapter.stop { [weak self] in AppLogger.shared.log("restartClient: stop completed, starting client") self?.adapter.start { error in self?.adapter.isRestarting = false + self?.isRestartInProgress = false if let error = error { AppLogger.shared.log("restartClient: start failed - \(error.localizedDescription)") } else { @@ -232,6 +224,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } } + func setNetworkUnavailableFlag(_ unavailable: Bool) { + let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) + userDefaults?.set(unavailable, forKey: GlobalConstants.keyNetworkUnavailable) + userDefaults?.synchronize() + AppLogger.shared.log("Network unavailable flag set to \(unavailable)") + } + func login(completionHandler: (Data?) -> Void) { let urlString = adapter.login() let data = urlString.data(using: .utf8) From e935811997827d406f49283f0bcd3e580b92b3af Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Mon, 15 Dec 2025 00:30:46 +0100 Subject: [PATCH 31/34] build 9 --- NetBird.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index f834cba..e042250 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -929,7 +929,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = NetBird/NetBird.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = TA739QLA7A; ENABLE_PREVIEWS = YES; @@ -981,7 +981,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = NetBird/NetBird.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = TA739QLA7A; ENABLE_PREVIEWS = YES; From a07b82df9fc39c7edd3100015b3f7aab3eb77fd7 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Mon, 15 Dec 2025 00:36:46 +0100 Subject: [PATCH 32/34] remove print and reduce max log app size to 100kb --- NetBird/Source/App/Views/Components/CustomLottieView.swift | 3 --- NetbirdKit/AppLogger.swift | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/NetBird/Source/App/Views/Components/CustomLottieView.swift b/NetBird/Source/App/Views/Components/CustomLottieView.swift index 804c69b..45403bb 100644 --- a/NetBird/Source/App/Views/Components/CustomLottieView.swift +++ b/NetBird/Source/App/Views/Components/CustomLottieView.swift @@ -58,13 +58,11 @@ struct CustomLottieView: UIViewRepresentable { } if context.coordinator.isPlaying { - print("Is still playing") return } // Act based on the new status switch extensionStatus { case .connected: - print("Management status changed to \(engineStatus)") if disconnectPressed { DispatchQueue.main.async { context.coordinator.playDisconnectingFadeIn(uiView: uiView, viewModel: viewModel) @@ -165,7 +163,6 @@ struct CustomLottieView: UIViewRepresentable { if self.engineStatus == .connected { self.playFadeOut(uiView: uiView, startFrame: self.connectingFadeOut.startFrame, endFrame: self.connectingFadeOut.endFrame, viewModel: viewModel, extensionStateText: "Connected") } else if (self.engineStatus == .disconnecting || self.extensionStatus == .disconnecting || self.engineStatus == .disconnected || self.extensionStatus == .disconnected) && !(self.connectPressed ?? false) { - print("Connected pressed = \(String(describing: self.connectPressed?.description))") self.playDisconnectingLoop(uiView: uiView, viewModel: viewModel) } else if !(self.connectPressed ?? false) && self.engineStatus == .connecting { // Automatic reconnection (not user-initiated) stuck in connecting state diff --git a/NetbirdKit/AppLogger.swift b/NetbirdKit/AppLogger.swift index 7a9adca..0618c38 100644 --- a/NetbirdKit/AppLogger.swift +++ b/NetbirdKit/AppLogger.swift @@ -11,7 +11,7 @@ public class AppLogger { public static let shared = AppLogger() private let logFileName = "swift-log.log" - private let maxLogSize: UInt64 = 5 * 1024 * 1024 // 5 MB + private let maxLogSize: UInt64 = 100 * 1024 // 100 KB private let queue = DispatchQueue(label: "io.netbird.logger", qos: .utility) private var fileHandle: FileHandle? private var logFileURL: URL? From dc70b66ab0589c46727d43700d0efe1bfbd02c1b Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Mon, 15 Dec 2025 01:00:37 +0100 Subject: [PATCH 33/34] fix comments --- NetbirdKit/AppLogger.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/NetbirdKit/AppLogger.swift b/NetbirdKit/AppLogger.swift index 0618c38..9e10851 100644 --- a/NetbirdKit/AppLogger.swift +++ b/NetbirdKit/AppLogger.swift @@ -18,9 +18,9 @@ public class AppLogger { private var isReady = false private let setupSemaphore = DispatchSemaphore(value: 0) - private let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + private let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds] return formatter }() @@ -87,7 +87,7 @@ public class AppLogger { public func log(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { let fileName = (file as NSString).lastPathComponent - let timestamp = dateFormatter.string(from: Date()) + let timestamp = iso8601Formatter.string(from: Date()) let logMessage = "[\(timestamp)] [\(fileName):\(line)] \(message)\n" print(logMessage, terminator: "") From e24dce45cb352ea085cb8044a3ab20c770f692ce Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Mon, 15 Dec 2025 01:01:14 +0100 Subject: [PATCH 34/34] build 10 --- NetBird.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index e042250..be68a9a 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -929,7 +929,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = NetBird/NetBird.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = TA739QLA7A; ENABLE_PREVIEWS = YES; @@ -981,7 +981,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = NetBird/NetBird.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = TA739QLA7A; ENABLE_PREVIEWS = YES;