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 @@
-
+
-
+
-
+
-
+
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;