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

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

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