diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..3f20292
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,102 @@
+name: Build
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+permissions:
+ contents: read
+jobs:
+ build:
+ name: Build iOS App
+ runs-on: macos-14
+
+ steps:
+ - name: Checkout ios-client
+ uses: actions/checkout@v4
+ with:
+ path: ios-client
+
+ - 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.24'
+ cache-dependency-path: netbird/go.sum
+
+ - 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
+ 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 \
+ -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
+ working-directory: ios-client
+ 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..f5dbc0a
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,64 @@
+name: Test
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+permissions:
+ contents: read
+jobs:
+ test:
+ name: Build and Test
+ runs-on: macos-14
+
+ steps:
+ - name: Checkout ios-client
+ uses: actions/checkout@v4
+ with:
+ path: ios-client
+
+ - 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.24'
+ cache-dependency-path: netbird/go.sum
+
+ - name: Install gomobile
+ run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab
+
+ - name: Build NetBirdSDK xcframework
+ 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: Run Tests
+ working-directory: ios-client
+ run: |
+ set -o pipefail
+ xcodebuild test \
+ -project NetBird.xcodeproj \
+ -scheme NetBird \
+ -destination 'platform=iOS Simulator,name=iPhone 16' \
+ -configuration Debug \
+ CODE_SIGNING_ALLOWED=NO \
+ | xcpretty --color --test
\ No newline at end of file
diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj
index 226252c..be68a9a 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 */; };
@@ -17,21 +19,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 */; };
@@ -51,8 +45,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 */; };
@@ -82,7 +74,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 */; };
@@ -91,16 +82,27 @@
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 */; };
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 */
+ 075441E07E64305C28EF192A /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 50A8910F2A792A15007C48FC /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 50A891162A792A15007C48FC;
+ remoteInfo = NetBird;
+ };
50245A5A2A80431C0034792B /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 50A8910F2A792A15007C48FC /* Project object */;
@@ -125,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 = ""; };
@@ -160,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 = ""; };
@@ -184,11 +188,15 @@
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 = ""; };
+ 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 = ""; };
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 */
@@ -221,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 */
@@ -245,6 +261,7 @@
children = (
50245A192A7BCE830034792B /* libresolv.tbd */,
50245A532A80431B0034792B /* NetworkExtension.framework */,
+ 82DA5029784B2E0DD517575B /* iOS */,
);
name = Frameworks;
sourceTree = "";
@@ -286,6 +303,7 @@
505118C72AD96ECA003027D3 /* WireGuardKitC */,
50A891182A792A15007C48FC /* Products */,
50245A182A7BCE830034792B /* Frameworks */,
+ 651C942641826A7AA94ED369 /* NetBirdTests */,
);
sourceTree = "";
};
@@ -294,6 +312,7 @@
children = (
50A891172A792A15007C48FC /* NetBird.app */,
50245A522A80431B0034792B /* NetbirdNetworkExtension.appex */,
+ 50733EE9CE10FEDDA61600B8 /* NetBirdTests.xctest */,
);
name = Products;
sourceTree = "";
@@ -312,6 +331,7 @@
50C727EA2A82479B006E898D /* NetbirdKit */ = {
isa = PBXGroup;
children = (
+ 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */,
F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */,
F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */,
50245A292A7BDB590034792B /* Preferences.swift */,
@@ -390,6 +410,24 @@
path = App;
sourceTree = "";
};
+ 651C942641826A7AA94ED369 /* NetBirdTests */ = {
+ isa = PBXGroup;
+ children = (
+ 3E054D83063E440DAD0C52FA /* GlobalConstantsTests.swift */,
+ 8AA7193B3AE82DF185EDEB1B /* AppLoggerTests.swift */,
+ 53CB9305A9DC6CAD1895495A /* SharedUserDefaultsTests.swift */,
+ );
+ path = NetBirdTests;
+ sourceTree = "";
+ };
+ 82DA5029784B2E0DD517575B /* iOS */ = {
+ isa = PBXGroup;
+ children = (
+ 91FA1F06D3375864C74EAB3B /* Foundation.framework */,
+ );
+ name = iOS;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -445,6 +483,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 */
@@ -484,6 +540,7 @@
targets = (
50A891162A792A15007C48FC /* NetBird */,
50245A512A80431B0034792B /* NetbirdNetworkExtension */,
+ C4BEBDBD1DC2C4D7764C202C /* NetBirdTests */,
);
};
/* End PBXProject section */
@@ -493,15 +550,6 @@
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;
};
@@ -517,12 +565,18 @@
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 */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 867D87D69DACEF5CF82E9A1B /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -537,7 +591,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 = (
@@ -546,7 +599,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;
@@ -559,7 +612,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 = (
@@ -568,7 +620,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 */
@@ -578,6 +630,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 */,
@@ -589,7 +642,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 */,
@@ -611,6 +663,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 */,
@@ -642,6 +695,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 */
@@ -650,9 +713,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 = {
@@ -837,7 +929,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = NetBird/NetBird.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 5;
+ CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = TA739QLA7A;
ENABLE_PREVIEWS = YES;
@@ -866,7 +958,7 @@
"$(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";
@@ -889,7 +981,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = NetBird/NetBird.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 5;
+ CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = TA739QLA7A;
ENABLE_PREVIEWS = YES;
@@ -918,7 +1010,7 @@
"$(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";
@@ -931,9 +1023,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">
+
+
+
+
+
+
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
}
}
@@ -33,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..8455c93 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 {
@@ -57,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
@@ -118,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 {
@@ -136,16 +140,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
@@ -314,4 +316,91 @@ class ViewModel: ObservableObject {
print("Failed to read the log file: \(error.localizedDescription)")
}
}
+
+ /// Handles server change completion by stopping the engine and resetting all connection state.
+ func handleServerChanged() {
+ AppLogger.shared.log("Server changed - stopping engine and resetting state")
+
+ // 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 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() {
+ 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/NetBird/Source/App/Views/AdvancedView.swift b/NetBird/Source/App/Views/AdvancedView.swift
index b407b52..4d74b7d 100644
--- a/NetBird/Source/App/Views/AdvancedView.swift
+++ b/NetBird/Source/App/Views/AdvancedView.swift
@@ -188,75 +188,77 @@ 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")
+ 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
}
- 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 = tempDir.appendingPathComponent("netbird-engine.log")
+ try goLogData.write(to: goLogPath, atomically: true, encoding: .utf8)
+ filesToShare.append(goLogPath)
+ } catch {
+ AppLogger.shared.log("Failed to export Go log: \(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 = tempDir.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 export Swift log: \(error)")
}
- } catch {
- print("Failed to read log data: \(error)")
+ }
+
+ guard !filesToShare.isEmpty else {
+ AppLogger.shared.log("No log files to share")
+ try? FileManager.default.removeItem(at: tempDir)
return
}
- }
-
- func getDocumentsDirectory() -> URL? {
- 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 activityViewController = UIActivityViewController(activityItems: filesToShare, applicationActivities: nil)
- let logURL = groupURL.appendingPathComponent("logfile.log")
+ activityViewController.excludedActivityTypes = [
+ .assignToContact,
+ .saveToCameraRoll
+ ]
+ // Clean up temp files after share completes (success or cancel)
+ activityViewController.completionWithItemsHandler = { _, _, _, _ in
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)")
- }
+ try FileManager.default.removeItem(at: tempDir)
} catch {
- print("Failed to read log data: \(error)")
- return
+ 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
+ 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)
+ }
}
-
+
func checkForValidPresharedKey(text: String) {
if isValidBase64EncodedString(text) {
viewModel.showInvalidPresharedKeyAlert = false
diff --git a/NetBird/Source/App/Views/Components/CustomLottieView.swift b/NetBird/Source/App/Views/Components/CustomLottieView.swift
index cdfa60d..45403bb 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 {
@@ -28,15 +42,27 @@ 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
}
// Act based on the new status
switch extensionStatus {
case .connected:
- print("Management status chnaged to \(engineStatus)")
if disconnectPressed {
DispatchQueue.main.async {
context.coordinator.playDisconnectingFadeIn(uiView: uiView, viewModel: viewModel)
@@ -50,11 +76,19 @@ 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:
- 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 {
@@ -90,6 +124,7 @@ struct CustomLottieView: UIViewRepresentable {
var engineStatus: ClientState?
var connectPressed: Bool?
var disconnectPressed: Bool?
+ var networkUnavailable: Bool = false
var colorScheme: ColorScheme?
let connectedFrame: CGFloat = 142
@@ -128,7 +163,10 @@ 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
+ // Exit to disconnected state after one loop to avoid infinite animation
self.playDisconnectingLoop(uiView: uiView, viewModel: viewModel)
} else {
playConnectingLoop(uiView: uiView, viewModel: viewModel)
@@ -163,6 +201,28 @@ 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 && !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
+ viewModel.extensionStateText = "Connected"
+ viewModel.connectPressed = false
+ 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 b45d86e..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
@@ -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/NetBirdTests/AppLoggerTests.swift b/NetBirdTests/AppLoggerTests.swift
new file mode 100644
index 0000000..c311c45
--- /dev/null
+++ b/NetBirdTests/AppLoggerTests.swift
@@ -0,0 +1,40 @@
+//
+// AppLoggerTests.swift
+// NetBirdTests
+//
+
+import XCTest
+@testable import NetBird
+
+final class AppLoggerTests: XCTestCase {
+
+ func testSharedInstanceExists() {
+ let logger = AppLogger.shared
+ XCTAssertNotNil(logger)
+ }
+
+ func testSharedInstanceIsSingleton() {
+ let logger1 = AppLogger.shared
+ let logger2 = AppLogger.shared
+ XCTAssertTrue(logger1 === logger2)
+ }
+
+ func testLogDoesNotCrash() {
+ // Verify logging doesn't throw or crash
+ AppLogger.shared.log("Test message")
+ AppLogger.shared.log("Test message with special chars: !@#$%^&*()")
+ AppLogger.shared.log("")
+ }
+
+ func testGetLogFileURLReturnsURL() {
+ // May return nil if log file hasn't been created yet
+ // Just verify the method doesn't crash
+ _ = AppLogger.getLogFileURL()
+ }
+
+ func testGetGoLogFileURLReturnsURL() {
+ // May return nil if Go log file doesn't exist
+ // Just verify the method doesn't crash
+ _ = AppLogger.getGoLogFileURL()
+ }
+}
diff --git a/NetBirdTests/GlobalConstantsTests.swift b/NetBirdTests/GlobalConstantsTests.swift
new file mode 100644
index 0000000..3902cc1
--- /dev/null
+++ b/NetBirdTests/GlobalConstantsTests.swift
@@ -0,0 +1,22 @@
+//
+// GlobalConstantsTests.swift
+// NetBirdTests
+//
+
+import XCTest
+@testable import NetBird
+
+final class GlobalConstantsTests: XCTestCase {
+
+ func testForceRelayConnectionKey() {
+ XCTAssertEqual(GlobalConstants.keyForceRelayConnection, "isConnectionForceRelayed")
+ }
+
+ func testLoginRequiredKey() {
+ XCTAssertEqual(GlobalConstants.keyLoginRequired, "netbird.loginRequired")
+ }
+
+ func testUserPreferencesSuiteName() {
+ XCTAssertEqual(GlobalConstants.userPreferencesSuiteName, "group.io.netbird.app")
+ }
+}
diff --git a/NetBirdTests/SharedUserDefaultsTests.swift b/NetBirdTests/SharedUserDefaultsTests.swift
new file mode 100644
index 0000000..c3c60bb
--- /dev/null
+++ b/NetBirdTests/SharedUserDefaultsTests.swift
@@ -0,0 +1,61 @@
+//
+// SharedUserDefaultsTests.swift
+// NetBirdTests
+//
+
+import XCTest
+@testable import NetBird
+
+final class SharedUserDefaultsTests: XCTestCase {
+
+ var userDefaults: UserDefaults?
+
+ 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() {
+ userDefaults?.removeObject(forKey: GlobalConstants.keyLoginRequired)
+ userDefaults?.removeObject(forKey: GlobalConstants.keyForceRelayConnection)
+ super.tearDown()
+ }
+
+ func testUserDefaultsSuiteExists() throws {
+ let defaults = try XCTUnwrap(userDefaults, "Shared UserDefaults suite should exist")
+ XCTAssertNotNil(defaults)
+ }
+
+ 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() 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() 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() 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")
+ }
+}
diff --git a/NetbirdKit/AppLogger.swift b/NetbirdKit/AppLogger.swift
new file mode 100644
index 0000000..9e10851
--- /dev/null
+++ b/NetbirdKit/AppLogger.swift
@@ -0,0 +1,169 @@
+//
+// 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 = 100 * 1024 // 100 KB
+ private let queue = DispatchQueue(label: "io.netbird.logger", qos: .utility)
+ private var fileHandle: FileHandle?
+ private var logFileURL: URL?
+ private var isReady = false
+ private let setupSemaphore = DispatchSemaphore(value: 0)
+
+ private let iso8601Formatter: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds]
+ 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")
+ setupSemaphore.signal()
+ 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)")
+ setupSemaphore.signal()
+ return
+ }
+ }
+
+ logFileURL = baseURL.appendingPathComponent(logFileName)
+ 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
+ }
+ }
+
+ do {
+ fileHandle = try FileHandle(forWritingTo: url)
+ fileHandle?.seekToEndOfFile()
+ isReady = true
+ } 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) {
+ let fileName = (file as NSString).lastPathComponent
+ let timestamp = iso8601Formatter.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? {
+ // 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
+ }
+ 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 fc53470..440ddfe 100644
--- a/NetbirdKit/ConnectionListener.swift
+++ b/NetbirdKit/ConnectionListener.swift
@@ -23,23 +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/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/NetbirdKit/GlobalConstants.swift b/NetbirdKit/GlobalConstants.swift
index 5fdb445..17eeac0 100644
--- a/NetbirdKit/GlobalConstants.swift
+++ b/NetbirdKit/GlobalConstants.swift
@@ -7,5 +7,7 @@
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/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift
index d52c44f..4d4fc0a 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,35 +237,42 @@ 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
- if let response = response {
- do {
- let decodedStatus = try self.decoder.decode(StatusDetails.self, from: response)
- completion(decodedStatus)
- return
- } catch {
- print("Failed to decode status details.")
- }
- } else {
- let defaultStatus = StatusDetails(ip: "", fqdn: "", managementStatus: .disconnected, peerInfo: [])
+ try session.sendProviderMessage(messageData) { [weak self] response in
+ defer { self?.isFetchingStatus = false }
+ 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 {
- 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/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")
}
}
diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift
index 72ec2f4..13d8472 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? {
@@ -58,6 +63,8 @@ public class NetBirdAdapter {
return nil
}
+ private var stopCompletionHandler: (() -> Void)?
+
// MARK: - Initialization
/// Designated initializer.
@@ -123,7 +130,27 @@ public class NetBirdAdapter {
return self.client.loginForMobile()
}
- public func stop() {
+ 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
+ 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..abf7929 100644
--- a/NetbirdNetworkExtension/PacketTunnelProvider.swift
+++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift
@@ -8,10 +8,7 @@
import NetworkExtension
import Network
import os
-import Firebase
-import FirebaseCrashlytics
-import FirebaseCore
-import FirebasePerformance
+import UserNotifications
class PacketTunnelProvider: NEPacketTunnelProvider {
@@ -26,27 +23,22 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
var pathMonitor: NWPathMonitor?
let monitorQueue = DispatchQueue(label: "NetworkMonitor")
- 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
- }
-
- FirebaseApp.configure(options: firebaseOptions)
+ /// 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 {
initializeLogging(loglevel: logLevel)
}
- currentNetworkType = nil
+ monitorQueue.async { [weak self] in
+ self?.currentNetworkType = nil
+ self?.wasStoppedDueToNoNetwork = false
+ self?.isRestartInProgress = false
+ }
startMonitoringNetworkChanges()
if adapter.needsLogin() {
@@ -65,6 +57,10 @@ 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 {
print("pathMonitor is nil; nothing to cancel.")
@@ -115,11 +111,31 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
}
func handleNetworkChange(path: Network.NWPath) {
- guard path.status == .satisfied else {
- print("No network connection.")
+ if path.status != .satisfied {
+ AppLogger.shared.log("No network connection detected")
+
+ // 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
+ setNetworkUnavailableFlag(true)
+ }
return
}
+ // Network is available again
+ if wasStoppedDueToNoNetwork {
+ AppLogger.shared.log("Network restored after unavailability - signaling UI")
+ wasStoppedDueToNoNetwork = false
+ setNetworkUnavailableFlag(false)
+ // Don't need to restart - Go SDK handles reconnection automatically
+ return
+ }
+
+ // Handle wifi <-> cellular transitions
let newNetworkType: NWInterface.InterfaceType? = {
if path.usesInterfaceType(.wifi) {
return .wifi
@@ -131,30 +147,90 @@ 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() {
- adapter.stop()
- adapter.start { error in
- if let error = error {
- print("Error restarting client: \(error.localizedDescription)")
+ 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 {
+ AppLogger.shared.log("restartClient: start completed successfully")
+ }
+ }
+ }
+ }
+
+ /// 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()
+ }
+
+ private func sendLoginNotificationBestEffort() {
+ UNUserNotificationCenter.current().getNotificationSettings { settings in
+ guard settings.authorizationStatus == .authorized else {
+ AppLogger.shared.log("Notifications not authorized, skipping extension notification attempt")
+ return
+ }
+
+ let content = UNMutableNotificationContent()
+ content.title = "NetBird"
+ content.body = "Login required. Please open the app to reconnect."
+ content.sound = .default
+
+ let request = UNNotificationRequest(
+ identifier: "netbird.login.required",
+ content: content,
+ trigger: nil
+ )
+
+ 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")
+ }
}
}
}
+ 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)
diff --git a/README.md b/README.md
index 3e8ce80..146df06 100644
--- a/README.md
+++ b/README.md
@@ -5,11 +5,17 @@
-
+
-
-
+
+
+
+
+
+
+
+
@@ -55,27 +61,37 @@ The code is divided into 4 parts:
## Requirements
- iOS 14.0+
-- Xcode 12.0+
+- Xcode 16.1+
+- Go 1.24+
- 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
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 -