diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3f20292..c3b0e0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,9 +13,12 @@ permissions: jobs: build: name: Build iOS App - runs-on: macos-14 + runs-on: macos-15 steps: + - name: Select Xcode 26.1.1 + run: sudo xcode-select -s /Applications/Xcode_26.1.1.app/Contents/Developer + - name: Checkout ios-client uses: actions/checkout@v4 with: @@ -33,8 +36,15 @@ jobs: 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: Install gomobile-netbird + run: GOPROXY=direct go install github.com/netbirdio/gomobile-tvos-fork/cmd/gomobile-netbird@latest + + - name: Install gobind-netbird + run: GOPROXY=direct go install github.com/netbirdio/gomobile-tvos-fork/cmd/gobind-netbird@latest + + - name: Add gomobile-tvos-fork to netbird dependencies + working-directory: netbird + run: GOPROXY=direct go get github.com/netbirdio/gomobile-tvos-fork@latest - name: Debug - List files before xcframework build working-directory: ios-client @@ -66,6 +76,46 @@ jobs: ls -la NetBird/Source/App/Views/Components/ || echo "Components dir not found" ls -la NetBird/Source/App/ViewModels/ || echo "ViewModels dir not found" + - name: Create dummy GoogleService-Info.plist + working-directory: ios-client + run: | + cat > GoogleService-Info.plist << 'EOF' + + + + + CLIENT_ID + dummy + REVERSED_CLIENT_ID + dummy + API_KEY + dummy + GCM_SENDER_ID + dummy + PLIST_VERSION + 1 + BUNDLE_ID + io.netbird.app + PROJECT_ID + dummy + STORAGE_BUCKET + dummy + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + dummy + + + EOF + - name: Resolve Swift packages working-directory: ios-client run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f5dbc0a..f4175dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,9 +13,12 @@ permissions: jobs: test: name: Build and Test - runs-on: macos-14 + runs-on: macos-15 steps: + - name: Select Xcode 26.1.1 + run: sudo xcode-select -s /Applications/Xcode_26.1.1.app/Contents/Developer + - name: Checkout ios-client uses: actions/checkout@v4 with: @@ -33,8 +36,15 @@ jobs: 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: Install gomobile-netbird + run: GOPROXY=direct go install github.com/netbirdio/gomobile-tvos-fork/cmd/gomobile-netbird@latest + + - name: Install gobind-netbird + run: GOPROXY=direct go install github.com/netbirdio/gomobile-tvos-fork/cmd/gobind-netbird@latest + + - name: Add gomobile-tvos-fork to netbird dependencies + working-directory: netbird + run: GOPROXY=direct go get github.com/netbirdio/gomobile-tvos-fork@latest - name: Build NetBirdSDK xcframework working-directory: ios-client @@ -44,6 +54,46 @@ jobs: working-directory: ios-client run: gem install xcpretty + - name: Create dummy GoogleService-Info.plist + working-directory: ios-client + run: | + cat > GoogleService-Info.plist << 'EOF' + + + + + CLIENT_ID + dummy + REVERSED_CLIENT_ID + dummy + API_KEY + dummy + GCM_SENDER_ID + dummy + PLIST_VERSION + 1 + BUNDLE_ID + io.netbird.app + PROJECT_ID + dummy + STORAGE_BUCKET + dummy + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + dummy + + + EOF + - name: Resolve Swift packages working-directory: ios-client run: | @@ -60,5 +110,46 @@ jobs: -scheme NetBird \ -destination 'platform=iOS Simulator,name=iPhone 16' \ -configuration Debug \ + -resultBundlePath TestResults.xcresult \ CODE_SIGNING_ALLOWED=NO \ - | xcpretty --color --test \ No newline at end of file + 2>&1 | tee test-output.log + + - name: Extract detailed test results + if: failure() + working-directory: ios-client + run: | + echo "=== Extracting test results ===" + xcrun xcresulttool get --path TestResults.xcresult --format json > test-results.json || echo "Failed to extract JSON" + + echo "=== Test Summary ===" + xcrun xcresulttool get --path TestResults.xcresult || echo "Failed to get summary" + + echo "=== Looking for crash info ===" + xcrun xcresulttool get --path TestResults.xcresult --id root || echo "No root info" + + - name: Upload test logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-logs + path: | + ios-client/test-output.log + ios-client/test-results.json + ios-client/TestResults.xcresult + + - name: Find and upload crash logs + if: failure() + run: | + echo "=== Searching for crash logs ===" + find ~/Library/Logs/DiagnosticReports -name "*.crash" -mmin -10 -exec echo "Found: {}" \; -exec cat {} \; || echo "No crash logs found" + + echo "=== Searching simulator logs ===" + find ~/Library/Logs/CoreSimulator -name "*.log" -mmin -10 | head -20 || echo "No simulator logs" + + - name: Upload crash reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: crash-reports + path: ~/Library/Logs/DiagnosticReports/*.crash + if-no-files-found: ignore \ No newline at end of file diff --git a/NetBird TV/Assets.xcassets/AccentColor.colorset/Contents.json b/NetBird TV/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/NetBird TV/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..2fedeeb --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "app-store-icon.png", + "idiom" : "tv" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/app-store-icon.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/app-store-icon.png new file mode 100644 index 0000000..32da6f2 Binary files /dev/null and b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/app-store-icon.png differ diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json new file mode 100644 index 0000000..95d75a5 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json @@ -0,0 +1,14 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "layers" : [ + { + "filename" : "Middle.imagestacklayer" + }, + { + "filename" : "Back.imagestacklayer" + } + ] +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..2fedeeb --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "app-store-icon.png", + "idiom" : "tv" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/app-store-icon.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/app-store-icon.png new file mode 100644 index 0000000..a06c5f8 Binary files /dev/null and b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/app-store-icon.png differ diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..7799777 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "netbird-tvos-icon.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "netbird-tvos-icon@2x.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/netbird-tvos-icon.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/netbird-tvos-icon.png new file mode 100644 index 0000000..fe51d1f Binary files /dev/null and b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/netbird-tvos-icon.png differ diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/netbird-tvos-icon@2x.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/netbird-tvos-icon@2x.png new file mode 100644 index 0000000..7b362c1 Binary files /dev/null and b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/netbird-tvos-icon@2x.png differ diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json new file mode 100644 index 0000000..de59d88 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json @@ -0,0 +1,17 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "layers" : [ + { + "filename" : "Front.imagestacklayer" + }, + { + "filename" : "Middle.imagestacklayer" + }, + { + "filename" : "Back.imagestacklayer" + } + ] +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..7799777 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "netbird-tvos-icon.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "netbird-tvos-icon@2x.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/netbird-tvos-icon.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/netbird-tvos-icon.png new file mode 100644 index 0000000..d6274fd Binary files /dev/null and b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/netbird-tvos-icon.png differ diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/netbird-tvos-icon@2x.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/netbird-tvos-icon@2x.png new file mode 100644 index 0000000..ee9899a Binary files /dev/null and b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/netbird-tvos-icon@2x.png differ diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..7799777 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "netbird-tvos-icon.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "netbird-tvos-icon@2x.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/netbird-tvos-icon.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/netbird-tvos-icon.png new file mode 100644 index 0000000..fe51d1f Binary files /dev/null and b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/netbird-tvos-icon.png differ diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/netbird-tvos-icon@2x.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/netbird-tvos-icon@2x.png new file mode 100644 index 0000000..7b362c1 Binary files /dev/null and b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/netbird-tvos-icon@2x.png differ diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json new file mode 100644 index 0000000..f47ba43 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json @@ -0,0 +1,32 @@ +{ + "assets" : [ + { + "filename" : "App Icon - App Store.imagestack", + "idiom" : "tv", + "role" : "primary-app-icon", + "size" : "1280x768" + }, + { + "filename" : "App Icon.imagestack", + "idiom" : "tv", + "role" : "primary-app-icon", + "size" : "400x240" + }, + { + "filename" : "Top Shelf Image Wide.imageset", + "idiom" : "tv", + "role" : "top-shelf-image-wide", + "size" : "2320x720" + }, + { + "filename" : "Top Shelf Image.imageset", + "idiom" : "tv", + "role" : "top-shelf-image", + "size" : "1920x720" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json new file mode 100644 index 0000000..4952949 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "top-shelf-wide.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "top-shelf-wide@2x.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide.png new file mode 100644 index 0000000..6165fc3 Binary files /dev/null and b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide.png differ diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide@2x.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide@2x.png new file mode 100644 index 0000000..81d5ab9 Binary files /dev/null and b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide@2x.png differ diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json new file mode 100644 index 0000000..88cef15 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "top-shelf.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "top-shelf@2x.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top-shelf.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top-shelf.png new file mode 100644 index 0000000..04bd644 Binary files /dev/null and b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top-shelf.png differ diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top-shelf@2x.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top-shelf@2x.png new file mode 100644 index 0000000..e670401 Binary files /dev/null and b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top-shelf@2x.png differ diff --git a/NetBird TV/Assets.xcassets/Contents.json b/NetBird TV/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/NetBird TV/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/icon-empty-box.imageset/Contents.json b/NetBird TV/Assets.xcassets/icon-empty-box.imageset/Contents.json new file mode 100644 index 0000000..9ad38af --- /dev/null +++ b/NetBird TV/Assets.xcassets/icon-empty-box.imageset/Contents.json @@ -0,0 +1,17 @@ +{ + "images" : [ + { + "filename" : "icon-empty-box.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/icon-empty-box.imageset/icon-empty-box.png b/NetBird TV/Assets.xcassets/icon-empty-box.imageset/icon-empty-box.png new file mode 100644 index 0000000..f51c1d2 Binary files /dev/null and b/NetBird TV/Assets.xcassets/icon-empty-box.imageset/icon-empty-box.png differ diff --git a/NetBird TV/Assets.xcassets/icon-netbird-button.imageset/Contents.json b/NetBird TV/Assets.xcassets/icon-netbird-button.imageset/Contents.json new file mode 100644 index 0000000..f6774f0 --- /dev/null +++ b/NetBird TV/Assets.xcassets/icon-netbird-button.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon-netbird-button.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon-netbird-button@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon-netbird-button@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/icon-netbird-button.imageset/icon-netbird-button.png b/NetBird TV/Assets.xcassets/icon-netbird-button.imageset/icon-netbird-button.png new file mode 100644 index 0000000..884e378 Binary files /dev/null and b/NetBird TV/Assets.xcassets/icon-netbird-button.imageset/icon-netbird-button.png differ diff --git a/NetBird TV/Assets.xcassets/icon-netbird-button.imageset/icon-netbird-button@2x.png b/NetBird TV/Assets.xcassets/icon-netbird-button.imageset/icon-netbird-button@2x.png new file mode 100644 index 0000000..4bb45e0 Binary files /dev/null and b/NetBird TV/Assets.xcassets/icon-netbird-button.imageset/icon-netbird-button@2x.png differ diff --git a/NetBird TV/Assets.xcassets/icon-netbird-button.imageset/icon-netbird-button@3x.png b/NetBird TV/Assets.xcassets/icon-netbird-button.imageset/icon-netbird-button@3x.png new file mode 100644 index 0000000..021aed1 Binary files /dev/null and b/NetBird TV/Assets.xcassets/icon-netbird-button.imageset/icon-netbird-button@3x.png differ diff --git a/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/Contents.json b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/Contents.json new file mode 100644 index 0000000..31fff9e --- /dev/null +++ b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/Contents.json @@ -0,0 +1,56 @@ +{ + "images" : [ + { + "filename" : "netbird-logo-menu.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "netbird-logo-menu 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "netbird-logo-menu@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "netbird-logo-menu@2x 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "netbird-logo-menu@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "netbird-logo-menu@3x 1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu 1.png b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu 1.png new file mode 100644 index 0000000..3119611 Binary files /dev/null and b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu 1.png differ diff --git a/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu.png b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu.png new file mode 100644 index 0000000..63eed5a Binary files /dev/null and b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu.png differ diff --git a/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@2x 1.png b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@2x 1.png new file mode 100644 index 0000000..020daa6 Binary files /dev/null and b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@2x 1.png differ diff --git a/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@2x.png b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@2x.png new file mode 100644 index 0000000..8035730 Binary files /dev/null and b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@2x.png differ diff --git a/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@3x 1.png b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@3x 1.png new file mode 100644 index 0000000..f98e90e Binary files /dev/null and b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@3x 1.png differ diff --git a/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@3x.png b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@3x.png new file mode 100644 index 0000000..7e139f3 Binary files /dev/null and b/NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@3x.png differ diff --git a/NetBird TV/NetBird TV.entitlements b/NetBird TV/NetBird TV.entitlements new file mode 100644 index 0000000..46f1038 --- /dev/null +++ b/NetBird TV/NetBird TV.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.application-groups + + group.io.netbird.app.tv + + + diff --git a/NetBird TV/NetBird TVDebug.entitlements b/NetBird TV/NetBird TVDebug.entitlements new file mode 100644 index 0000000..46f1038 --- /dev/null +++ b/NetBird TV/NetBird TVDebug.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.application-groups + + group.io.netbird.app.tv + + + diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 40aabcd..e440685 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -3,12 +3,60 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; 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 */; }; + 36F90EF57603411B9916FDD6 /* ServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE12ED4EE4900C0D205 /* ServerViewModel.swift */; }; + 3A9A981B20EF47C1907CC877 /* TVServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8BCB110545747678AA5A9C2 /* TVServerView.swift */; }; + 441C5AFE2EDF0DD20055EEFC /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50245A532A80431B0034792B /* NetworkExtension.framework */; }; + 441C5B062EDF0DD20055EEFC /* NetBirdTVNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 441C5AFD2EDF0DD20055EEFC /* NetBirdTVNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 443782BF2EDF284A00F9FA94 /* Platform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443782BD2EDF284A00F9FA94 /* Platform.swift */; }; + 443782C52EDF288A00F9FA94 /* TVSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443782C32EDF288A00F9FA94 /* TVSettingsView.swift */; }; + 443782C62EDF288A00F9FA94 /* TVPeersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443782C22EDF288A00F9FA94 /* TVPeersView.swift */; }; + 443782C72EDF288A00F9FA94 /* TVNetworksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443782C12EDF288A00F9FA94 /* TVNetworksView.swift */; }; + 443782C82EDF288A00F9FA94 /* TVMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443782C02EDF288A00F9FA94 /* TVMainView.swift */; }; + 443782C92EDF293400F9FA94 /* NetBirdApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A8911A2A792A15007C48FC /* NetBirdApp.swift */; }; + 443782CA2EDF296500F9FA94 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A8911C2A792A15007C48FC /* MainView.swift */; }; + 443782CC2EDF298B00F9FA94 /* PeerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D402962BD9B89300D4AC5B /* PeerViewModel.swift */; }; + 443782CD2EDF298B00F9FA94 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608122A7958B100BAF09B /* MainViewModel.swift */; }; + 443782CE2EDF298B00F9FA94 /* RoutesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509CCD692BE908C000B7C2D8 /* RoutesViewModel.swift */; }; + 443782D02EDF29A800F9FA94 /* Device.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608022A7950CB00BAF09B /* Device.swift */; }; + 443782D12EDF29A800F9FA94 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50245A292A7BDB590034792B /* Preferences.swift */; }; + 443782D42EDF29A800F9FA94 /* RoutesSelectionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C5D30E2BDD96CF003159BE /* RoutesSelectionDetails.swift */; }; + 443782D52EDF29A800F9FA94 /* StatusDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CD81A62AD5504B00CF830B /* StatusDetails.swift */; }; + 443782D62EDF29A800F9FA94 /* ClientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50003BCB2AFD3B0C00E5EB6B /* ClientState.swift */; }; + 443782D72EDF29A800F9FA94 /* NetworkExtensionAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50216D922ACB2488009574C9 /* NetworkExtensionAdapter.swift */; }; + 445B5F732EECAE32008932B8 /* GlobalConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */; }; + 445B5F742EECAE4E008932B8 /* EnvVarPackager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */; }; + 445B5F752EECAF01008932B8 /* GlobalConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */; }; + 445B5F762EECAF02008932B8 /* EnvVarPackager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */; }; + 4483B26D2F19331A00BD9F66 /* TVColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4483B26C2F19331A00BD9F66 /* TVColors.swift */; }; + 44DCF5A62EDF45C00026078E /* NetBirdSDK.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; }; + 44DCF5A72EDF45C00026078E /* NetBirdSDK.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 44DCF5AC2EDF45FC0026078E /* NetBirdSDK.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; }; + 44DCF5AF2EDF46140026078E /* NetBirdSDK.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; }; + 44DCF5B02EDF46140026078E /* NetBirdSDK.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 44DCF5B32EDF48310026078E /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 44DCF5B22EDF48310026078E /* FirebaseAnalytics */; }; + 44DCF5B52EDF48310026078E /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 44DCF5B42EDF48310026078E /* FirebaseCrashlytics */; }; + 44DCF5B72EDF48310026078E /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 44DCF5B62EDF48310026078E /* Lottie */; }; + 44DCF5B92EDF4DB10026078E /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 44DCF5B82EDF4D900026078E /* libresolv.tbd */; }; + 44F3E38B2EE214D300C87FEC /* PacketTunnelProviderSettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CD814E2AD0355000CF830B /* PacketTunnelProviderSettingsManager.swift */; }; + 44F3E38C2EE214E300C87FEC /* NetBirdAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C727EB2A824812006E898D /* NetBirdAdapter.swift */; }; + 44F3E38D2EE2151100C87FEC /* Device.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608022A7950CB00BAF09B /* Device.swift */; }; + 44F3E38E2EE2151100C87FEC /* RoutesSelectionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C5D30E2BDD96CF003159BE /* RoutesSelectionDetails.swift */; }; + 44F3E38F2EE2151100C87FEC /* DNSManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CD81612AD0595E00CF830B /* DNSManager.swift */; }; + 44F3E3902EE2151100C87FEC /* StatusDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CD81A62AD5504B00CF830B /* StatusDetails.swift */; }; + 44F3E3912EE2151100C87FEC /* ClientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50003BCB2AFD3B0C00E5EB6B /* ClientState.swift */; }; + 44F3E3922EE2151100C87FEC /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50245A292A7BDB590034792B /* Preferences.swift */; }; + 44F3E3932EE2151100C87FEC /* NetworkExtensionAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50216D922ACB2488009574C9 /* NetworkExtensionAdapter.swift */; }; + 44F3E3942EE2151100C87FEC /* ConnectionListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50003BC82AFD2F0C00E5EB6B /* ConnectionListener.swift */; }; + 44F3E3952EE2F6F900C87FEC /* NetBirdSDK.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; }; + 44F3E3982EE2F89200C87FEC /* NetworkChangeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50245A2D2A7BDC470034792B /* NetworkChangeListener.swift */; }; + 44F3E3992EE2F90900C87FEC /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 44DCF5B82EDF4D900026078E /* libresolv.tbd */; }; + 44F3E39B2EE2F9FA00C87FEC /* TVAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44F3E39A2EE2F9FA00C87FEC /* TVAuthView.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 */; }; @@ -19,13 +67,21 @@ 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 */; }; @@ -45,6 +101,8 @@ 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 */; }; @@ -74,9 +132,8 @@ 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 */; }; 50D402972BD9B89300D4AC5B /* PeerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D402962BD9B89300D4AC5B /* PeerViewModel.swift */; }; 50E608132A7958B100BAF09B /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608122A7958B100BAF09B /* MainViewModel.swift */; }; 50E608202A7979D600BAF09B /* SideDrawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E6081F2A7979D600BAF09B /* SideDrawer.swift */; }; @@ -85,7 +142,13 @@ 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 */; }; + 978FC4722EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; + 978FC4732EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; + 978FC4742EEDF168002D0EB8 /* Platform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443782BD2EDF284A00F9FA94 /* Platform.swift */; }; 9CC0E000AE3F165CA72FD465 /* AppLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AA7193B3AE82DF185EDEB1B /* AppLoggerTests.swift */; }; + A1B2C3D52EEDF501001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; + A1B2C3D62EEDF502001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; + A1B2C3D72EEDF503001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.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 */; }; @@ -103,6 +166,13 @@ remoteGlobalIDString = 50A891162A792A15007C48FC; remoteInfo = NetBird; }; + 441C5B042EDF0DD20055EEFC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 50A8910F2A792A15007C48FC /* Project object */; + proxyType = 1; + remoteGlobalIDString = 441C5AFC2EDF0DD20055EEFC; + remoteInfo = NetBirdTVNetworkExtension; + }; 50245A5A2A80431C0034792B /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 50A8910F2A792A15007C48FC /* Project object */; @@ -113,6 +183,39 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 441C5B0B2EDF0DD20055EEFC /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 441C5B062EDF0DD20055EEFC /* NetBirdTVNetworkExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + 44DCF5A82EDF45C10026078E /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 44DCF5A72EDF45C00026078E /* NetBirdSDK.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 44DCF5B12EDF46140026078E /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 44DCF5B02EDF46140026078E /* NetBirdSDK.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; 50245A602A80431C0034792B /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -128,6 +231,16 @@ /* Begin PBXFileReference section */ 3E054D83063E440DAD0C52FA /* GlobalConstantsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlobalConstantsTests.swift; sourceTree = ""; }; + 441C5AEE2EDF0DAE0055EEFC /* NetBird TV.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "NetBird TV.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 441C5AFD2EDF0DD20055EEFC /* NetBirdTVNetworkExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NetBirdTVNetworkExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 443782BD2EDF284A00F9FA94 /* Platform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Platform.swift; sourceTree = ""; }; + 443782C02EDF288A00F9FA94 /* TVMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVMainView.swift; sourceTree = ""; }; + 443782C12EDF288A00F9FA94 /* TVNetworksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVNetworksView.swift; sourceTree = ""; }; + 443782C22EDF288A00F9FA94 /* TVPeersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVPeersView.swift; sourceTree = ""; }; + 443782C32EDF288A00F9FA94 /* TVSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVSettingsView.swift; sourceTree = ""; }; + 4483B26C2F19331A00BD9F66 /* TVColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVColors.swift; sourceTree = ""; }; + 44DCF5B82EDF4D900026078E /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; }; + 44F3E39A2EE2F9FA00C87FEC /* TVAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVAuthView.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 = ""; }; @@ -181,7 +294,7 @@ 50CD81A62AD5504B00CF830B /* StatusDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusDetails.swift; sourceTree = ""; }; 50CD81AF2AD5B94D00CF830B /* PeerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerCard.swift; sourceTree = ""; }; 50CD84352AD82F9400CF830B /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; - 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = NetBirdSDK.xcframework; path = NetBird/NetBirdSDK.xcframework; sourceTree = ""; }; + 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = NetBirdSDK.xcframework; sourceTree = ""; }; 50D402962BD9B89300D4AC5B /* PeerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerViewModel.swift; sourceTree = ""; }; 50E608022A7950CB00BAF09B /* Device.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Device.swift; sourceTree = ""; }; 50E608122A7958B100BAF09B /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = ""; }; @@ -192,20 +305,59 @@ 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 = ""; }; + A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationProvider.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 = ""; }; + F8BCB110545747678AA5A9C2 /* TVServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVServerView.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 441C5B072EDF0DD20055EEFC /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 441C5AFC2EDF0DD20055EEFC /* NetBirdTVNetworkExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 441C5AEF2EDF0DAE0055EEFC /* NetBird TV */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "NetBird TV"; sourceTree = ""; }; + 441C5AFF2EDF0DD20055EEFC /* NetBirdTVNetworkExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (441C5B072EDF0DD20055EEFC /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = NetBirdTVNetworkExtension; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ + 441C5AEB2EDF0DAE0055EEFC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 44DCF5B52EDF48310026078E /* FirebaseCrashlytics in Frameworks */, + 44DCF5B32EDF48310026078E /* FirebaseAnalytics in Frameworks */, + 44DCF5B92EDF4DB10026078E /* libresolv.tbd in Frameworks */, + 44DCF5A62EDF45C00026078E /* NetBirdSDK.xcframework in Frameworks */, + 44DCF5B72EDF48310026078E /* Lottie in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 441C5AFA2EDF0DD20055EEFC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 44F3E3952EE2F6F900C87FEC /* NetBirdSDK.xcframework in Frameworks */, + 441C5AFE2EDF0DD20055EEFC /* NetworkExtension.framework in Frameworks */, + 44F3E3992EE2F90900C87FEC /* libresolv.tbd in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 50245A4F2A80431B0034792B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 508BD8492AF140D50055E415 /* FirebaseAnalyticsSwift in Frameworks */, - 50D402952BD9143900D4AC5B /* NetBirdSDK.xcframework in Frameworks */, + 44DCF5AC2EDF45FC0026078E /* NetBirdSDK.xcframework in Frameworks */, 50245A542A80431B0034792B /* NetworkExtension.framework in Frameworks */, 50003BBE2AFBCA7900E5EB6B /* FirebasePerformance in Frameworks */, 508BD84B2AF140D50055E415 /* FirebaseCrashlytics in Frameworks */, @@ -225,7 +377,7 @@ 50051DE02AE69A8100AFBDC4 /* FirebaseCrashlytics in Frameworks */, 50003BBC2AFBCA6B00E5EB6B /* FirebasePerformance in Frameworks */, 5051190F2AE03F68003027D3 /* FirebaseAnalytics in Frameworks */, - 50D402942BD9143900D4AC5B /* NetBirdSDK.xcframework in Frameworks */, + 44DCF5AF2EDF46140026078E /* NetBirdSDK.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -240,6 +392,29 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 443782BE2EDF284A00F9FA94 /* Platform */ = { + isa = PBXGroup; + children = ( + 443782BD2EDF284A00F9FA94 /* Platform.swift */, + ); + path = Platform; + sourceTree = ""; + }; + 443782C42EDF288A00F9FA94 /* TV */ = { + isa = PBXGroup; + children = ( + 4483B26C2F19331A00BD9F66 /* TVColors.swift */, + 44F3E39A2EE2F9FA00C87FEC /* TVAuthView.swift */, + 443782C02EDF288A00F9FA94 /* TVMainView.swift */, + 443782C12EDF288A00F9FA94 /* TVNetworksView.swift */, + 443782C22EDF288A00F9FA94 /* TVPeersView.swift */, + 443782C32EDF288A00F9FA94 /* TVSettingsView.swift */, + F8BCB110545747678AA5A9C2 /* TVServerView.swift */, + ); + name = TV; + path = Views/TV; + sourceTree = ""; + }; 501B0DC42AE04DDE004BE7A7 /* animations */ = { isa = PBXGroup; children = ( @@ -260,8 +435,9 @@ isa = PBXGroup; children = ( 50245A192A7BCE830034792B /* libresolv.tbd */, + 44DCF5B82EDF4D900026078E /* libresolv.tbd */, 50245A532A80431B0034792B /* NetworkExtension.framework */, - 82DA5029784B2E0DD517575B /* iOS */, + 91FA1F06D3375864C74EAB3B /* Foundation.framework */, ); name = Frameworks; sourceTree = ""; @@ -301,6 +477,8 @@ 50C727EA2A82479B006E898D /* NetbirdKit */, 50245A552A80431C0034792B /* NetbirdNetworkExtension */, 505118C72AD96ECA003027D3 /* WireGuardKitC */, + 441C5AEF2EDF0DAE0055EEFC /* NetBird TV */, + 441C5AFF2EDF0DD20055EEFC /* NetBirdTVNetworkExtension */, 50A891182A792A15007C48FC /* Products */, 50245A182A7BCE830034792B /* Frameworks */, 651C942641826A7AA94ED369 /* NetBirdTests */, @@ -312,6 +490,8 @@ children = ( 50A891172A792A15007C48FC /* NetBird.app */, 50245A522A80431B0034792B /* NetbirdNetworkExtension.appex */, + 441C5AEE2EDF0DAE0055EEFC /* NetBird TV.app */, + 441C5AFD2EDF0DD20055EEFC /* NetBirdTVNetworkExtension.appex */, 50733EE9CE10FEDDA61600B8 /* NetBirdTests.xctest */, ); name = Products; @@ -335,6 +515,7 @@ F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */, F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */, 50245A292A7BDB590034792B /* Preferences.swift */, + A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */, 50245A2D2A7BDC470034792B /* NetworkChangeListener.swift */, 50CD81612AD0595E00CF830B /* DNSManager.swift */, 50E608022A7950CB00BAF09B /* Device.swift */, @@ -403,6 +584,8 @@ 50E6080A2A79568800BAF09B /* App */ = { isa = PBXGroup; children = ( + 443782C42EDF288A00F9FA94 /* TV */, + 443782BE2EDF284A00F9FA94 /* Platform */, 50A8911A2A792A15007C48FC /* NetBirdApp.swift */, 50E607FF2A794F8200BAF09B /* Views */, 50E608012A7950C000BAF09B /* ViewModels */, @@ -420,17 +603,59 @@ path = NetBirdTests; sourceTree = ""; }; - 82DA5029784B2E0DD517575B /* iOS */ = { - isa = PBXGroup; - children = ( - 91FA1F06D3375864C74EAB3B /* Foundation.framework */, - ); - name = iOS; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 441C5AED2EDF0DAE0055EEFC /* NetBird TV */ = { + isa = PBXNativeTarget; + buildConfigurationList = 441C5AF82EDF0DB00055EEFC /* Build configuration list for PBXNativeTarget "NetBird TV" */; + buildPhases = ( + 441C5AEA2EDF0DAE0055EEFC /* Sources */, + 441C5AEB2EDF0DAE0055EEFC /* Frameworks */, + 441C5AEC2EDF0DAE0055EEFC /* Resources */, + 441C5B0B2EDF0DD20055EEFC /* Embed Foundation Extensions */, + 44DCF5A82EDF45C10026078E /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 441C5B052EDF0DD20055EEFC /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 441C5AEF2EDF0DAE0055EEFC /* NetBird TV */, + ); + name = "NetBird TV"; + packageProductDependencies = ( + 44DCF5B22EDF48310026078E /* FirebaseAnalytics */, + 44DCF5B42EDF48310026078E /* FirebaseCrashlytics */, + 44DCF5B62EDF48310026078E /* Lottie */, + ); + productName = "NetBird TV"; + productReference = 441C5AEE2EDF0DAE0055EEFC /* NetBird TV.app */; + productType = "com.apple.product-type.application"; + }; + 441C5AFC2EDF0DD20055EEFC /* NetBirdTVNetworkExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 441C5B082EDF0DD20055EEFC /* Build configuration list for PBXNativeTarget "NetBirdTVNetworkExtension" */; + buildPhases = ( + 441C5AF92EDF0DD20055EEFC /* Sources */, + 441C5AFA2EDF0DD20055EEFC /* Frameworks */, + 441C5AFB2EDF0DD20055EEFC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 441C5AFF2EDF0DD20055EEFC /* NetBirdTVNetworkExtension */, + ); + name = NetBirdTVNetworkExtension; + packageProductDependencies = ( + ); + productName = NetBirdTVNetworkExtension; + productReference = 441C5AFD2EDF0DD20055EEFC /* NetBirdTVNetworkExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; 50245A512A80431B0034792B /* NetbirdNetworkExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 50245A5D2A80431C0034792B /* Build configuration list for PBXNativeTarget "NetbirdNetworkExtension" */; @@ -463,6 +688,7 @@ 50A891142A792A15007C48FC /* Frameworks */, 50A891152A792A15007C48FC /* Resources */, 50245A602A80431C0034792B /* Embed Foundation Extensions */, + 44DCF5B12EDF46140026078E /* Embed Frameworks */, 508BD8502AF153350055E415 /* ShellScript */, ); buildRules = ( @@ -508,9 +734,15 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1430; - LastUpgradeCheck = 1430; + LastSwiftUpdateCheck = 2610; + LastUpgradeCheck = 2610; TargetAttributes = { + 441C5AED2EDF0DAE0055EEFC = { + CreatedOnToolsVersion = 26.1; + }; + 441C5AFC2EDF0DD20055EEFC = { + CreatedOnToolsVersion = 26.1; + }; 50245A512A80431B0034792B = { CreatedOnToolsVersion = 14.3.1; LastSwiftMigration = 1430; @@ -540,16 +772,41 @@ targets = ( 50A891162A792A15007C48FC /* NetBird */, 50245A512A80431B0034792B /* NetbirdNetworkExtension */, + 441C5AED2EDF0DAE0055EEFC /* NetBird TV */, + 441C5AFC2EDF0DD20055EEFC /* NetBirdTVNetworkExtension */, C4BEBDBD1DC2C4D7764C202C /* NetBirdTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 441C5AEC2EDF0DAE0055EEFC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 441C5AFB2EDF0DD20055EEFC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 50245A502A80431B0034792B /* Resources */ = { 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; }; @@ -565,6 +822,7 @@ 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 */, ); @@ -591,6 +849,7 @@ "${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 = ( @@ -612,6 +871,7 @@ "${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 = ( @@ -625,12 +885,64 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 441C5AEA2EDF0DAE0055EEFC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 445B5F752EECAF01008932B8 /* GlobalConstants.swift in Sources */, + 445B5F762EECAF02008932B8 /* EnvVarPackager.swift in Sources */, + 443782D02EDF29A800F9FA94 /* Device.swift in Sources */, + 443782D12EDF29A800F9FA94 /* Preferences.swift in Sources */, + A1B2C3D52EEDF501001A2B3C /* ConfigurationProvider.swift in Sources */, + 443782D42EDF29A800F9FA94 /* RoutesSelectionDetails.swift in Sources */, + 443782D52EDF29A800F9FA94 /* StatusDetails.swift in Sources */, + 443782D62EDF29A800F9FA94 /* ClientState.swift in Sources */, + 443782D72EDF29A800F9FA94 /* NetworkExtensionAdapter.swift in Sources */, + 443782BF2EDF284A00F9FA94 /* Platform.swift in Sources */, + 443782CA2EDF296500F9FA94 /* MainView.swift in Sources */, + 443782CC2EDF298B00F9FA94 /* PeerViewModel.swift in Sources */, + 443782CD2EDF298B00F9FA94 /* MainViewModel.swift in Sources */, + 443782CE2EDF298B00F9FA94 /* RoutesViewModel.swift in Sources */, + 443782C52EDF288A00F9FA94 /* TVSettingsView.swift in Sources */, + 3A9A981B20EF47C1907CC877 /* TVServerView.swift in Sources */, + 36F90EF57603411B9916FDD6 /* ServerViewModel.swift in Sources */, + 443782C92EDF293400F9FA94 /* NetBirdApp.swift in Sources */, + 443782C62EDF288A00F9FA94 /* TVPeersView.swift in Sources */, + 443782C72EDF288A00F9FA94 /* TVNetworksView.swift in Sources */, + 44F3E39B2EE2F9FA00C87FEC /* TVAuthView.swift in Sources */, + 4483B26D2F19331A00BD9F66 /* TVColors.swift in Sources */, + 443782C82EDF288A00F9FA94 /* TVMainView.swift in Sources */, + 978FC4732EEDF167002D0EB8 /* AppLogger.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 441C5AF92EDF0DD20055EEFC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 44F3E3982EE2F89200C87FEC /* NetworkChangeListener.swift in Sources */, + 44F3E38B2EE214D300C87FEC /* PacketTunnelProviderSettingsManager.swift in Sources */, + 445B5F742EECAE4E008932B8 /* EnvVarPackager.swift in Sources */, + 44F3E38D2EE2151100C87FEC /* Device.swift in Sources */, + 44F3E38E2EE2151100C87FEC /* RoutesSelectionDetails.swift in Sources */, + 445B5F732EECAE32008932B8 /* GlobalConstants.swift in Sources */, + 44F3E38F2EE2151100C87FEC /* DNSManager.swift in Sources */, + 44F3E3902EE2151100C87FEC /* StatusDetails.swift in Sources */, + 44F3E3912EE2151100C87FEC /* ClientState.swift in Sources */, + 44F3E3922EE2151100C87FEC /* Preferences.swift in Sources */, + A1B2C3D72EEDF503001A2B3C /* ConfigurationProvider.swift in Sources */, + 44F3E3932EE2151100C87FEC /* NetworkExtensionAdapter.swift in Sources */, + 44F3E3942EE2151100C87FEC /* ConnectionListener.swift in Sources */, + 44F3E38C2EE214E300C87FEC /* NetBirdAdapter.swift in Sources */, + 978FC4722EEDF167002D0EB8 /* AppLogger.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 50245A4E2A80431B0034792B /* Sources */ = { isa = PBXSourcesBuildPhase; 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 */, @@ -642,9 +954,11 @@ 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 */, + 978FC4712EEDF167002D0EB8 /* AppLogger.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -663,7 +977,6 @@ 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 */, @@ -686,12 +999,15 @@ 509CCD682BE8FFBF00B7C2D8 /* TabBarButton.swift in Sources */, 502455BD2A79B0480034792B /* CustomBackButton.swift in Sources */, 50216D892ACB18EE009574C9 /* Preferences.swift in Sources */, + A1B2C3D62EEDF502001A2B3C /* ConfigurationProvider.swift in Sources */, 50CD81B02AD5B94D00CF830B /* PeerCard.swift in Sources */, 50003BCE2AFD405600E5EB6B /* ConnectionListener.swift in Sources */, 50003BC72AFBD7F200E5EB6B /* NetBirdAdapter.swift in Sources */, F1B292052EDE5610001D91B8 /* JustifiedText.swift in Sources */, 50E608132A7958B100BAF09B /* MainViewModel.swift in Sources */, F1B2920A2EE0BC46001D91B8 /* GlobalConstants.swift in Sources */, + 978FC4702EEDF167002D0EB8 /* AppLogger.swift in Sources */, + 978FC4742EEDF168002D0EB8 /* Platform.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -708,6 +1024,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 441C5B052EDF0DD20055EEFC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 441C5AFC2EDF0DD20055EEFC /* NetBirdTVNetworkExtension */; + targetProxy = 441C5B042EDF0DD20055EEFC /* PBXContainerItemProxy */; + }; 50245A5B2A80431C0034792B /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 50245A512A80431B0034792B /* NetbirdNetworkExtension */; @@ -745,13 +1066,169 @@ }; name = Debug; }; + 441C5AF62EDF0DB00055EEFC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = "NetBird TV/NetBird TVDebug.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = TA739QLA7A; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = NetBird; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIUserInterfaceStyle = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 0.0.15; + PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app.tv; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = appletvos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 17.0; + }; + name = Debug; + }; + 441C5AF72EDF0DB00055EEFC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = "NetBird TV/NetBird TV.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = TA739QLA7A; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = NetBird; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIUserInterfaceStyle = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 0.0.15; + PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app.tv; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 17.0; + }; + name = Release; + }; + 441C5B092EDF0DD20055EEFC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_ENTITLEMENTS = NetBirdTVNetworkExtension/NetBirdTVNetworkExtensionDebug.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = TA739QLA7A; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NetBirdTVNetworkExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = NetBirdTVNetworkExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app.tv.extension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = appletvos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 17.0; + }; + name = Debug; + }; + 441C5B0A2EDF0DD20055EEFC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_ENTITLEMENTS = NetBirdTVNetworkExtension/NetBirdTVNetworkExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = TA739QLA7A; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NetBirdTVNetworkExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = NetBirdTVNetworkExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app.tv.extension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 17.0; + }; + name = Release; + }; 50245A5E2A80431C0034792B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = NetbirdNetworkExtension/NetbirdNetworkExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = TA739QLA7A; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NetbirdNetworkExtension/Info.plist; @@ -763,7 +1240,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.0.15; PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app.NetbirdNetworkExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -781,7 +1258,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = NetbirdNetworkExtension/NetbirdNetworkExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = TA739QLA7A; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NetbirdNetworkExtension/Info.plist; @@ -793,7 +1270,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.0.15; PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app.NetbirdNetworkExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -859,6 +1336,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; @@ -913,6 +1391,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; @@ -923,13 +1402,13 @@ 50A891262A792A16007C48FC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = NetBird/NetBird.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = TA739QLA7A; ENABLE_PREVIEWS = YES; @@ -952,15 +1431,11 @@ ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/WireGuardKitGo/out", - "$(PROJECT_DIR)/WireGuardKitGo/.tmp/wireguard-go-bridge/goroot/src/cmd/objdump/testdata", - "$(PROJECT_DIR)/WireGuardKitGo/.tmp/wireguard-go-bridge/goroot/src/go/internal/gcimporter/testdata/versions", - "$(PROJECT_DIR)/WireGuardKitGo/.tmp/wireguard-go-bridge/goroot/src/go/internal/gccgoimporter/testdata", - "$(PROJECT_DIR)/WireGuardKitGo/.tmp/wireguard-go-bridge", ); - MARKETING_VERSION = 0.0.14; + MARKETING_VERSION = 0.0.15; PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -975,13 +1450,13 @@ 50A891272A792A16007C48FC /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = NetBird/NetBird.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = TA739QLA7A; ENABLE_PREVIEWS = YES; @@ -1004,15 +1479,11 @@ ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/WireGuardKitGo/out", - "$(PROJECT_DIR)/WireGuardKitGo/.tmp/wireguard-go-bridge/goroot/src/cmd/objdump/testdata", - "$(PROJECT_DIR)/WireGuardKitGo/.tmp/wireguard-go-bridge/goroot/src/go/internal/gcimporter/testdata/versions", - "$(PROJECT_DIR)/WireGuardKitGo/.tmp/wireguard-go-bridge/goroot/src/go/internal/gccgoimporter/testdata", - "$(PROJECT_DIR)/WireGuardKitGo/.tmp/wireguard-go-bridge", ); - MARKETING_VERSION = 0.0.14; + MARKETING_VERSION = 0.0.15; PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -1059,6 +1530,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 441C5AF82EDF0DB00055EEFC /* Build configuration list for PBXNativeTarget "NetBird TV" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 441C5AF62EDF0DB00055EEFC /* Debug */, + 441C5AF72EDF0DB00055EEFC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 441C5B082EDF0DD20055EEFC /* Build configuration list for PBXNativeTarget "NetBirdTVNetworkExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 441C5B092EDF0DD20055EEFC /* Debug */, + 441C5B0A2EDF0DD20055EEFC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 50245A5D2A80431C0034792B /* Build configuration list for PBXNativeTarget "NetbirdNetworkExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1108,6 +1597,21 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 44DCF5B22EDF48310026078E /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = 5051190D2AE03F68003027D3 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + 44DCF5B42EDF48310026078E /* FirebaseCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = 5051190D2AE03F68003027D3 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCrashlytics; + }; + 44DCF5B62EDF48310026078E /* Lottie */ = { + isa = XCSwiftPackageProductDependency; + package = 506331FC2AF52B8100BC8F0E /* XCRemoteSwiftPackageReference "lottie-ios" */; + productName = Lottie; + }; 50003BBB2AFBCA6B00E5EB6B /* FirebasePerformance */ = { isa = XCSwiftPackageProductDependency; package = 5051190D2AE03F68003027D3 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/NetBird.xcodeproj/xcshareddata/xcschemes/NetBird.xcscheme b/NetBird.xcodeproj/xcshareddata/xcschemes/NetBird.xcscheme index 23c3850..4d8c520 100644 --- a/NetBird.xcodeproj/xcshareddata/xcschemes/NetBird.xcscheme +++ b/NetBird.xcodeproj/xcshareddata/xcschemes/NetBird.xcscheme @@ -1,6 +1,6 @@ Bool { - if let path = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist"), - let options = FirebaseOptions(contentsOfFile: path) { - FirebaseApp.configure(options: options) - } - return true - } + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + // Configure Firebase on main thread as required by Firebase + if let path = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist"), + let options = FirebaseOptions(contentsOfFile: path) { + FirebaseApp.configure(options: options) + } + return true + } } - +#endif @main struct NetBirdApp: App { - @StateObject var viewModel = ViewModel() + // Create ViewModel on background thread to avoid blocking app launch with Go runtime init + @StateObject private var viewModelLoader = ViewModelLoader() @Environment(\.scenePhase) var scenePhase - + + #if os(iOS) @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate - + #endif + + init() { + // Configure Firebase on main thread as required by Firebase + #if os(tvOS) + if let path = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist"), + let options = FirebaseOptions(contentsOfFile: path) { + FirebaseApp.configure(options: options) + } + #endif + } + var body: some Scene { WindowGroup { - MainView() - .environmentObject(viewModel) - .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 - print("App is inactive!") - viewModel.stopPollingDetails() - } + if let viewModel = viewModelLoader.viewModel { + MainView() + .environmentObject(viewModel) + #if os(iOS) + .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 + print("App is inactive!") + viewModel.stopPollingDetails() + } + #endif + #if os(tvOS) + // tvOS uses scenePhase changes + .onChange(of: scenePhase) { _, newPhase in + switch newPhase { + case .active: + print("App is active!") + viewModel.checkExtensionState() + viewModel.startPollingDetails() + case .inactive, .background: + print("App is inactive!") + viewModel.stopPollingDetails() + @unknown default: + break + } + } + #endif + } else { + // Show loading screen while ViewModel initializes + Color.black + .ignoresSafeArea() + .overlay( + Image("netbird-logo-menu") + .resizable() + .scaledToFit() + .frame(width: 300) + ) + } + } + } +} + +/// Loads ViewModel asynchronously to avoid blocking app launch. +/// The Go runtime initialization (from NetBirdSDK) can take 10+ seconds on first launch. +@MainActor +class ViewModelLoader: ObservableObject { + @Published var viewModel: ViewModel? + + init() { + // Create ViewModel asynchronously on main thread + // The ViewModel itself must be created on MainActor since it's an ObservableObject + Task { @MainActor in + let vm = ViewModel() + self.viewModel = vm } } } + + diff --git a/NetBird/Source/App/Platform/Platform.swift b/NetBird/Source/App/Platform/Platform.swift new file mode 100644 index 0000000..cbae8ac --- /dev/null +++ b/NetBird/Source/App/Platform/Platform.swift @@ -0,0 +1,187 @@ +// +// Platform.swift +// NetBird +// +// Platform abstraction layer for iOS/tvOS compatibility. +// This file provides unified APIs that work across both platforms, +// hiding the differences behind simple, consistent interfaces. +// + +import SwiftUI +import Combine + +// Screen Size Abstraction +/// Replaces direct UIScreen.main.bounds usage which isn't ideal for tvOS. +struct Screen { + + /// Screen width in points + static var width: CGFloat { + #if os(tvOS) + // Apple TV is always 1920x1080 (or 3840x2160 for 4K, but points are same) + return 1920 + #else + return UIScreen.main.bounds.width + #endif + } + + static var height: CGFloat { + #if os(tvOS) + return 1080 + #else + return UIScreen.main.bounds.height + #endif + } + + /// Full screen bounds as CGRect + static var bounds: CGRect { + CGRect(x: 0, y: 0, width: width, height: height) + } + + /// Safe way to calculate proportional sizes + /// - Parameters: + /// - widthRatio: Fraction of screen width (0.0 to 1.0) + /// - heightRatio: Fraction of screen height (0.0 to 1.0) + /// - Returns: CGSize proportional to screen + static func size(widthRatio: CGFloat = 1.0, heightRatio: CGFloat = 1.0) -> CGSize { + CGSize(width: width * widthRatio, height: height * heightRatio) + } +} + +// Device Type Detection +/// Identifies what type of Apple device we're running on. +/// Useful for conditional UI layouts and feature availability. +struct DeviceType { + static var isTV: Bool { + #if os(tvOS) + return true + #else + return false + #endif + } + + static var isPad: Bool { + #if os(tvOS) + return false + #else + return UIDevice.current.userInterfaceIdiom == .pad + #endif + } + + static var isPhone: Bool { + #if os(tvOS) + return false + #else + return UIDevice.current.userInterfaceIdiom == .phone + #endif + } + + /// Returns appropriate scale factor for the current device type. + /// Useful for sizing UI elements proportionally. + static var scaleFactor: CGFloat { + if isTV { + return 2.0 // TV needs larger UI elements + } else if isPad { + return 1.3 + } else { + return 1.0 + } + } +} + +struct PlatformCapabilities { + static var supportsVPN: Bool { + #if os(tvOS) + if #available(tvOS 17.0, *) { + return true + } + return false + #else + return true // iOS has always supported VPN + #endif + } + + static var supportsSafariView: Bool { + #if os(tvOS) + return false + #else + return true + #endif + } + + static var hasTouchScreen: Bool { + #if os(tvOS) + return false + #else + return true + #endif + } + + static var supportsClipboard: Bool { + #if os(tvOS) + return false + #else + return true + #endif + } + + static var supportsKeyboard: Bool { + true + } +} + +struct Layout { + + /// Standard padding for content edges + static var contentPadding: CGFloat { + DeviceType.isTV ? 80 : 16 + } + + /// Padding between UI elements + static var elementSpacing: CGFloat { + DeviceType.isTV ? 40 : 12 + } + + /// Standard corner radius for cards and buttons + static var cornerRadius: CGFloat { + DeviceType.isTV ? 20 : 10 + } + + /// Minimum touch/focus target size (Apple HIG compliance) + static var minTapTarget: CGFloat { + DeviceType.isTV ? 66 : 44 // Apple's minimum for accessibility + } + + /// Font size multiplier for the platform + static var fontScale: CGFloat { + DeviceType.isTV ? 1.5 : 1.0 + } +} + +// Scaled Font Helper +/// Creates fonts that scale appropriately for each platform. +extension Font { + /// Creates a system font scaled for the current platform + static func scaledSystem(size: CGFloat, weight: Font.Weight = .regular) -> Font { + .system(size: size * Layout.fontScale, weight: weight) + } +} + +// View Modifiers for Platform Adaptation +extension View { + /// Applies platform-appropriate padding + func platformPadding(_ edges: Edge.Set = .all) -> some View { + self.padding(edges, Layout.contentPadding) + } + + /// Makes the view focusable on tvOS (no-op on iOS) + @ViewBuilder + func tvFocusable() -> some View { + #if os(tvOS) + self.focusable() + #else + self + #endif + } +} + + diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 2943609..4c50168 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -4,16 +4,54 @@ // // Created by Pascal Fischer on 01.08.23. // +// This ViewModel is shared between iOS and tvOS. +// Platform-specific code is wrapped with #if os() directives. +// -import UIKit +import SwiftUI import NetworkExtension import os import Combine +import NetBirdSDK import UserNotifications +/// Used by updateManagementURL to check if SSO is supported +class SSOCheckListener: NSObject, NetBirdSDKSSOListenerProtocol { + var onResult: ((Bool?, Error?) -> Void)? + + func onError(_ p0: Error?) { + onResult?(nil, p0) + } + + func onSuccess(_ p0: Bool) { + onResult?(p0, nil) + } +} + +// Error Listener for setup key login +/// Used by setSetupKey to handle async login result +class SetupKeyErrListener: NSObject, NetBirdSDKErrListenerProtocol { + var onResult: ((Error?) -> Void)? + + func onError(_ p0: Error?) { + onResult?(p0) + } + + func onSuccess() { + onResult?(nil) + } +} + +/// For both iOS and tvOS (tvOS 17+ required for VPN support). @MainActor class ViewModel: ObservableObject { + + private let logger = Logger(subsystem: "io.netbird.app", category: "ViewModel") + + // VPN Adapter (shared) @Published var networkExtensionAdapter: NetworkExtensionAdapter + + // UI State (shared) @Published var showSetupKeyPopup = false @Published var showChangeServerAlert = false @Published var showInvalidServerAlert = false @@ -29,22 +67,26 @@ class ViewModel: ObservableObject { @Published var showAuthenticationRequired = false @Published var isSheetExpanded = false @Published var presentSideDrawer = false - @Published var extensionState : NEVPNStatus = .disconnected @Published var navigateToServerView = false + + @Published var extensionState: NEVPNStatus = .disconnected + @Published var managementStatus: ClientState = .disconnected + @Published var statusDetailsValid = false + @Published var extensionStateText = "Disconnected" + @Published var connectPressed = false + @Published var disconnectPressed = false + @Published var rosenpassEnabled = false @Published var rosenpassPermissive = false - @Published var managementURL = "" @Published var presharedKey = "" @Published var server: String = "" @Published var setupKey: String = "" @Published var presharedKeySecure = true + @Published var fqdn = UserDefaults.standard.string(forKey: "fqdn") ?? "" @Published var ip = UserDefaults.standard.string(forKey: "ip") ?? "" - @Published var managementStatus: ClientState = .disconnected - @Published var statusDetailsValid = false - @Published var extensionStateText = "Disconnected" - @Published var connectPressed = false - @Published var disconnectPressed = false + + // Debug @Published var traceLogsEnabled: Bool { didSet { self.showLogLevelChangedAlert = true @@ -58,12 +100,15 @@ class ViewModel: ObservableObject { } @Published var forceRelayConnection = true @Published var showForceRelayAlert = false + @Published var showRosenpassChangedAlert = false @Published var networkUnavailable = false - var preferences = Preferences.newPreferences() + /// Platform-agnostic configuration provider. + /// Abstracts iOS SDK preferences vs tvOS UserDefaults + IPC. + private lazy var configProvider: ConfigurationProvider = ConfigurationProviderFactory.create() + var buttonLock = false let defaults = UserDefaults.standard - let isIpad = UIDevice.current.userInterfaceIdiom == .pad private var cancellables = Set() @@ -77,10 +122,15 @@ class ViewModel: ObservableObject { self.traceLogsEnabled = logLevel == "TRACE" self.peerViewModel = PeerViewModel() self.routeViewModel = RoutesViewModel(networkExtensionAdapter: networkExtensionAdapter) - self.rosenpassEnabled = self.getRosenpassEnabled() - self.rosenpassPermissive = self.getRosenpassPermissive() + + // Don't load rosenpass settings during init - they trigger expensive SDK initialization. + // These will be loaded lazily when the settings view is accessed. + // self.rosenpassEnabled = self.getRosenpassEnabled() + // self.rosenpassPermissive = self.getRosenpassPermissive() + + // forceRelayConnection uses UserDefaults (not SDK), so it's safe to load during init self.forceRelayConnection = self.getForcedRelayConnectionEnabled() - + $setupKey .removeDuplicates() .debounce(for: .seconds(0.5), scheduler: RunLoop.main) @@ -91,18 +141,21 @@ class ViewModel: ObservableObject { } func connect() { + logger.info("connect: ENTRY POINT - function called") self.connectPressed = true - print("Connected pressed set to true") - DispatchQueue.main.async { - print("starting extension") - self.buttonLock = true - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - self.buttonLock = false - } - Task { - await self.networkExtensionAdapter.start() - print("Connected pressed set to false") - } + self.buttonLock = true + logger.info("connect: connectPressed=true, buttonLock=true, starting adapter...") + + // Reset buttonLock after 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.buttonLock = false + } + + // Start the VPN connection + Task { + self.logger.info("connect: Task started, calling networkExtensionAdapter.start()") + await self.networkExtensionAdapter.start() + self.logger.info("connect: networkExtensionAdapter.start() completed") } } @@ -174,115 +227,138 @@ class ViewModel: ObservableObject { let statuses : [NEVPNStatus] = [.connected, .disconnected, .connecting, .disconnecting] DispatchQueue.main.async { if statuses.contains(status) && self.extensionState != status { - print("Changing extension status") + print("Changing extension status to \(status.rawValue)") self.extensionState = status + + // On tvOS, update extensionStateText directly since we don't have CustomLottieView + #if os(tvOS) + switch status { + case .connected: + self.extensionStateText = "Connected" + self.connectPressed = false + // Fetch routes when connected so the Networks counter is accurate on the home screen + self.routeViewModel.getRoutes() + case .disconnected: + self.extensionStateText = "Disconnected" + self.disconnectPressed = false + case .connecting: + self.extensionStateText = "Connecting" + case .disconnecting: + self.extensionStateText = "Disconnecting" + default: + break + } + self.logger.info("checkExtensionState: tvOS - extensionStateText = \(self.extensionStateText)") + #endif } } } } - func updateManagementURL(url: String) -> Bool? { - let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) - let newAuth = NetBirdSDKNewAuth(Preferences.configFile(), trimmedURL, nil) - self.managementURL = trimmedURL - var ssoSupported: ObjCBool = false - do { - try newAuth?.saveConfigIfSSOSupported(&ssoSupported) - if ssoSupported.boolValue { - print("SSO is supported") - return true - } else { - print("SSO is not supported. Fallback to setup key") - return false - } - } catch { - print("Failed to check SSO support") - } - return nil - } - func clearDetails() { self.ip = "" self.fqdn = "" defaults.removeObject(forKey: "ip") defaults.removeObject(forKey: "fqdn") + + // Clear config JSON (contains server credentials and all settings) + Preferences.removeConfigFromUserDefaults() + + // Reset @Published properties to reflect cleared state in UI + self.rosenpassEnabled = false + self.rosenpassPermissive = false + self.presharedKey = "" + self.presharedKeySecure = false + + #if os(tvOS) + // Also clear extension-local config to prevent stale credentials + networkExtensionAdapter.clearExtensionConfig() + #endif } - func setSetupKey(key: String) throws { - let newAuth = NetBirdSDKNewAuth(Preferences.configFile(), self.managementURL, nil) - try newAuth?.login(withSetupKeyAndSaveConfig: key, deviceName: Device.getName()) - self.managementURL = "" - } - + // MARK: - Configuration Methods (via ConfigurationProvider) + func updatePreSharedKey() { - preferences.setPreSharedKey(presharedKey) - do { - try preferences.commit() + configProvider.preSharedKey = presharedKey + if configProvider.commit() { self.close() self.presharedKeySecure = true self.presentSideDrawer = false self.showPreSharedKeyChangedInfo = true - } catch { + } else { print("Failed to update preshared key") } } - + func removePreSharedKey() { presharedKey = "" - preferences.setPreSharedKey(presharedKey) - do { - try preferences.commit() + configProvider.preSharedKey = "" + if configProvider.commit() { self.close() self.presharedKeySecure = false - } catch { + } else { print("Failed to remove preshared key") } } - + func loadPreSharedKey() { - self.presharedKey = preferences.getPreSharedKey(nil) - self.presharedKeySecure = self.presharedKey != "" + self.presharedKey = configProvider.preSharedKey + self.presharedKeySecure = configProvider.hasPreSharedKey } - + func setRosenpassEnabled(enabled: Bool) { - preferences.setRosenpassEnabled(enabled) - do { - try preferences.commit() - } catch { + // Update @Published property for immediate UI feedback + self.rosenpassEnabled = enabled + + // Persist to storage (on tvOS this writes directly to config JSON) + configProvider.rosenpassEnabled = enabled + if !configProvider.commit() { print("Failed to update rosenpass settings") } + + #if os(tvOS) + // Show reconnect alert if currently connected + if extensionState == .connected { + showRosenpassChangedAlert = true + } + #endif } - + func getRosenpassEnabled() -> Bool { - var result = ObjCBool(false) - do { - try preferences.getRosenpassEnabled(&result) - } catch { - print("Failed to read rosenpass settings") - } - - return result.boolValue + return configProvider.rosenpassEnabled } - + func getRosenpassPermissive() -> Bool { - var result = ObjCBool(false) - do { - try preferences.getRosenpassPermissive(&result) - } catch { - print("Failed to read rosenpass permissive settings") - } - - return result.boolValue + return configProvider.rosenpassPermissive } - + + /// Loads Rosenpass settings from the configuration provider into the @Published properties. + /// Call this when opening settings views to sync UI with stored values. + /// On iOS, this triggers SDK initialization, so it's deferred until needed. + /// On tvOS, this reads from UserDefaults which is fast. + func loadRosenpassSettings() { + self.rosenpassEnabled = configProvider.rosenpassEnabled + self.rosenpassPermissive = configProvider.rosenpassPermissive + } + func setRosenpassPermissive(permissive: Bool) { - preferences.setRosenpassPermissive(permissive) - do { - try preferences.commit() - } catch { + // Update @Published property for immediate UI feedback + self.rosenpassPermissive = permissive + + // Persist to storage (on tvOS this writes directly to config JSON) + configProvider.rosenpassPermissive = permissive + if !configProvider.commit() { print("Failed to update rosenpass permissive settings") } } + + /// Reloads configuration from persistent storage. + /// Call this after server changes or when returning to settings view. + func reloadConfiguration() { + configProvider.reload() + // Sync @Published properties with reloaded config values + loadRosenpassSettings() + } func setForcedRelayConnection(isEnabled: Bool) { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) @@ -293,8 +369,14 @@ class ViewModel: ObservableObject { func getForcedRelayConnectionEnabled() -> Bool { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) + #if os(iOS) userDefaults?.register(defaults: [GlobalConstants.keyForceRelayConnection: true]) return userDefaults?.bool(forKey: GlobalConstants.keyForceRelayConnection) ?? true + #else + // forced relay battery optimization not need on Apple Tv + userDefaults?.register(defaults: [GlobalConstants.keyForceRelayConnection: false]) + return userDefaults?.bool(forKey: GlobalConstants.keyForceRelayConnection) ?? false + #endif } func getDefaultStatus() -> StatusDetails { @@ -341,18 +423,21 @@ class ViewModel: ObservableObject { clearDetails() // Stop the network extension in background (non-blocking) - let adapter = self.networkExtensionAdapter - Task.detached { - adapter.stop() + Task { @MainActor in + self.networkExtensionAdapter.stop() } - // Reload preferences for new server - preferences = Preferences.newPreferences() + // Reload configuration for new server + reloadConfiguration() } /// Checks shared app-group container for network unavailable flag set by the network extension. /// Updates the networkUnavailable property to trigger UI animation changes. + /// iOS only - tvOS has a platform limitation where `UserDefaults(suiteName:)` does not + /// reliably synchronize between the main app and network extension processes, even with + /// a correctly configured App Group. On tvOS, we use IPC messaging instead. func checkNetworkUnavailableFlag() { + #if os(iOS) let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) let isUnavailable = userDefaults?.bool(forKey: GlobalConstants.keyNetworkUnavailable) ?? false @@ -360,11 +445,16 @@ class ViewModel: ObservableObject { AppLogger.shared.log("Network unavailable flag changed: \(isUnavailable)") networkUnavailable = isUnavailable } + #endif + // tvOS: Network status is determined by extension state, not a shared flag } /// Checks shared app-group container for login required flag set by the network extension. /// If set, schedules a local notification (if authorized) and shows the authentication UI. + /// iOS only - tvOS cannot share UserDefaults between app and extension, and uses IPC + /// via `checkLoginError` instead. func checkLoginRequiredFlag() { + #if os(iOS) let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) guard userDefaults?.bool(forKey: GlobalConstants.keyLoginRequired) == true else { return @@ -405,5 +495,7 @@ class ViewModel: ObservableObject { } } } + #endif + // tvOS: Login errors are detected via IPC (checkLoginError in TVAuthView) } } diff --git a/NetBird/Source/App/ViewModels/PeerViewModel.swift b/NetBird/Source/App/ViewModels/PeerViewModel.swift index 2053925..9b2ffcf 100644 --- a/NetBird/Source/App/ViewModels/PeerViewModel.swift +++ b/NetBird/Source/App/ViewModels/PeerViewModel.swift @@ -5,6 +5,7 @@ // Created by Pascal Fischer on 25.04.24. // +import Foundation import Combine class PeerViewModel: ObservableObject { @@ -41,9 +42,6 @@ class PeerViewModel: ObservableObject { return displayedPeersBackup } else { displayedPeersBackup = filteredPeers - let conn = filteredPeers.filter{ peer in - peer.connStatus == "Connected" - } return filteredPeers } } diff --git a/NetBird/Source/App/ViewModels/RoutesViewModel.swift b/NetBird/Source/App/ViewModels/RoutesViewModel.swift index 50bce95..0e67385 100644 --- a/NetBird/Source/App/ViewModels/RoutesViewModel.swift +++ b/NetBird/Source/App/ViewModels/RoutesViewModel.swift @@ -5,6 +5,7 @@ // Created by Pascal Fischer on 06.05.24. // +import Foundation import Combine class RoutesViewModel: ObservableObject { diff --git a/NetBird/Source/App/ViewModels/ServerViewModel.swift b/NetBird/Source/App/ViewModels/ServerViewModel.swift index 6b60af3..c3d2e40 100644 --- a/NetBird/Source/App/ViewModels/ServerViewModel.swift +++ b/NetBird/Source/App/ViewModels/ServerViewModel.swift @@ -6,22 +6,70 @@ // import Combine +import NetBirdSDK +import Foundation + +// MARK: - SDK Listener Implementations + +/// Listener for SSO support check +class SSOListenerImpl: NSObject, NetBirdSDKSSOListenerProtocol { + private let onSuccessHandler: (Bool) -> Void + private let onErrorHandler: (Error) -> Void + + init(onSuccess: @escaping (Bool) -> Void, onError: @escaping (Error) -> Void) { + self.onSuccessHandler = onSuccess + self.onErrorHandler = onError + } + + func onSuccess(_ ssoSupported: Bool) { + onSuccessHandler(ssoSupported) + } + + func onError(_ error: (any Error)?) { + if let error = error { + onErrorHandler(error) + } + } +} + +/// Listener for login operations +class ErrListenerImpl: NSObject, NetBirdSDKErrListenerProtocol { + private let onSuccessHandler: () -> Void + private let onErrorHandler: (Error) -> Void + + init(onSuccess: @escaping () -> Void, onError: @escaping (Error) -> Void) { + self.onSuccessHandler = onSuccess + self.onErrorHandler = onError + } + + func onSuccess() { + onSuccessHandler() + } + + func onError(_ error: (any Error)?) { + if let error = error { + onErrorHandler(error) + } + } +} + +// MARK: - ServerViewModel @MainActor class ServerViewModel : ObservableObject { let configurationFilePath: String let deviceName: String - + @Published var isOperationSuccessful: Bool = false @Published var isUiEnabled: Bool = true - + @Published var viewErrors = ServerViewErrors() private var cancellables = Set() - + init(configurationFilePath: String, deviceName: String) { self.configurationFilePath = configurationFilePath self.deviceName = deviceName - + // Forward viewErrors changes to trigger ServerViewModel's objectWillChange // This is to make ServerViewModel react to changes made on ServerViewErrors. viewErrors.objectWillChange @@ -30,21 +78,21 @@ class ServerViewModel : ObservableObject { } .store(in: &cancellables) } - + private func isSetupKeyInvalid(setupKey: String) -> Bool { if setupKey.isEmpty || setupKey.count != 36 { return true } - + let uuid = UUID(uuidString: setupKey) - + if uuid == nil { return true } - + return false } - + private func isUrlInvalid(url: String) -> Bool { if let url = URL(string: url), url.host != nil { return false @@ -52,11 +100,11 @@ class ServerViewModel : ObservableObject { return true } } - + private func handleSdkErrorMessage(errorMessage: String) { let reviewUrl = "Review the URL:\n\(errorMessage)" let reviewSetupKey = "Review the setup key:\n\(errorMessage)" - + if errorMessage.localizedCaseInsensitiveContains("dial context: context deadline exceeded") { viewErrors.urlError = reviewUrl } else if errorMessage.localizedCaseInsensitiveContains("failed while getting management service public key") { @@ -68,27 +116,23 @@ class ServerViewModel : ObservableObject { viewErrors.generalError = errorMessage } } - + private func getAuthenticator(url managementServerUrl: String) async -> NetBirdSDKAuth? { let configPath = self.configurationFilePath - let detachedTask = Task.detached(priority: .background) { + let detachedTask = Task.detached(priority: .background) { () -> (NetBirdSDKAuth?, String?) in var error: NSError? - var errorMessage : String? - var authenticator : NetBirdSDKAuth? - authenticator = NetBirdSDKNewAuth(configPath, managementServerUrl, &error) - + let authenticator = NetBirdSDKNewAuth(configPath, managementServerUrl, &error) + if let error = error { print(error.domain, error.code, error.description) - errorMessage = error.description - authenticator = nil - return (authenticator, errorMessage) + return (authenticator, error.description) } - + return (authenticator, nil) } - + let (authenticator, errorMessage) = await detachedTask.value - + if let errorMessage = errorMessage { handleSdkErrorMessage(errorMessage: errorMessage) return nil @@ -96,129 +140,161 @@ class ServerViewModel : ObservableObject { return authenticator } } - + func changeManagementServerAddress(managementServerUrl: String) async { // disable UI here isUiEnabled = false await Task.yield() - + let isUrlInvalid = isUrlInvalid(url: managementServerUrl) - + if isUrlInvalid { viewErrors.urlError = "Invalid URL format" // error state emitted, enable UI here isUiEnabled = true return } - - let authenticator = await getAuthenticator(url: managementServerUrl) - if authenticator == nil { + + guard let authenticator = await getAuthenticator(url: managementServerUrl) else { isUiEnabled = true return } - - let detachedTask = Task.detached { - var isSsoSupported: Bool = true - var isOperationSuccessful: Bool = false - var errorMessage: String? - - guard let auth = authenticator else { - errorMessage = "Authentication not available" - return (false, true, errorMessage) - } - - do { - var isSsoSupportedPointer: ObjCBool = false - try auth.saveConfigIfSSOSupported(&isSsoSupportedPointer) - - if isSsoSupportedPointer.boolValue { - isOperationSuccessful = true - } else { - isSsoSupported = false + + // Use continuation to bridge async callback to async/await + await withCheckedContinuation { (continuation: CheckedContinuation) in + let listener = SSOListenerImpl( + onSuccess: { [weak self] ssoSupported in + Task { @MainActor in + if ssoSupported { + // On tvOS, try to save config to UserDefaults since file writes may have failed + #if os(tvOS) + self?.saveConfigToUserDefaults(authenticator: authenticator) + #endif + self?.isOperationSuccessful = true + } else { + self?.isUiEnabled = true + self?.viewErrors.ssoNotSupportedError = "SSO isn't available for the provided server, register this device with a setup key" + } + continuation.resume() + } + }, + onError: { [weak self] error in + Task { @MainActor in + let errorMessage = error.localizedDescription + + // On tvOS, file permission errors mean SSO check succeeded but file save failed + // We can still proceed by saving to UserDefaults instead + #if os(tvOS) + if errorMessage.contains("operation not permitted") || errorMessage.contains("permission denied") { + print("tvOS: File write failed, saving config to UserDefaults") + self?.saveConfigToUserDefaults(authenticator: authenticator) + self?.isOperationSuccessful = true + continuation.resume() + return + } + #endif + + self?.isUiEnabled = true + self?.handleSdkErrorMessage(errorMessage: errorMessage) + continuation.resume() + } } - } catch { - errorMessage = error.localizedDescription - } - - return (isOperationSuccessful, isSsoSupported, errorMessage) - } - - let (success, isSsoSupported, errorMessage) = await detachedTask.value - - if success { - self.isOperationSuccessful = true - } else { - isUiEnabled = true - - if !isSsoSupported { - viewErrors.ssoNotSupportedError = "SSO isn't available for the provided server, register this device with a setup key" - } else if let error = errorMessage { - handleSdkErrorMessage(errorMessage: error) + ) + + authenticator.saveConfigIfSSOSupported(listener) + } + } + + #if os(tvOS) + /// On tvOS, save the config JSON to UserDefaults since file writes are blocked + private func saveConfigToUserDefaults(authenticator: NetBirdSDKAuth) { + var error: NSError? + let configJSON = authenticator.getConfigJSON(&error) + + if let error = error { + print("tvOS: Failed to get config JSON: \(error.localizedDescription)") + return + } + + if !configJSON.isEmpty { + if Preferences.saveConfigToUserDefaults(configJSON) { + print("tvOS: Config saved to UserDefaults successfully") + } else { + print("tvOS: Failed to save config to UserDefaults") } } } - + #endif + func loginWithSetupKey(managementServerUrl: String, setupKey: String) async { // disable UI here isUiEnabled = false await Task.yield() - + let isSetupKeyInvalid = isSetupKeyInvalid(setupKey: setupKey) let isUrlInvalid = isUrlInvalid(url: managementServerUrl) - + if isUrlInvalid { viewErrors.urlError = "Invalid URL format" } - + if isSetupKeyInvalid { viewErrors.setupKeyError = "Invalid setup key format" } - + if isSetupKeyInvalid || isUrlInvalid { // error states emitted, enable UI here isUiEnabled = true return } - - let authenticator = await getAuthenticator(url: managementServerUrl) - if authenticator == nil { + + guard let authenticator = await getAuthenticator(url: managementServerUrl) else { isUiEnabled = true return } - + let deviceName = self.deviceName - let detachedTask = Task.detached { - var isOperationSuccessful = false - var errorMessage : String? - - guard let auth = authenticator else { - errorMessage = "Authentication not available" - return (false, errorMessage) - } - do { - try auth.login(withSetupKeyAndSaveConfig: setupKey, deviceName: deviceName) - isOperationSuccessful = true - } catch { - errorMessage = error.localizedDescription - } - - return (isOperationSuccessful, errorMessage) - } - - let (success, errorMessage) = await detachedTask.value - - if success { - self.isOperationSuccessful = true - } else { - isUiEnabled = true - - if let error = errorMessage { - handleSdkErrorMessage(errorMessage: error) - } + // Use continuation to bridge async callback to async/await + await withCheckedContinuation { (continuation: CheckedContinuation) in + let listener = ErrListenerImpl( + onSuccess: { [weak self] in + Task { @MainActor in + // On tvOS, try to save config to UserDefaults since file writes may have failed + #if os(tvOS) + self?.saveConfigToUserDefaults(authenticator: authenticator) + #endif + self?.isOperationSuccessful = true + continuation.resume() + } + }, + onError: { [weak self] error in + Task { @MainActor in + let errorMessage = error.localizedDescription + + // On tvOS, file permission errors mean login succeeded but file save failed + // We can still proceed by saving to UserDefaults instead + #if os(tvOS) + if errorMessage.contains("operation not permitted") || errorMessage.contains("permission denied") { + print("tvOS: File write failed, saving config to UserDefaults") + self?.saveConfigToUserDefaults(authenticator: authenticator) + self?.isOperationSuccessful = true + continuation.resume() + return + } + #endif + + self?.isUiEnabled = true + self?.handleSdkErrorMessage(errorMessage: errorMessage) + continuation.resume() + } + } + ) + + authenticator.login(withSetupKeyAndSaveConfig: listener, setupKey: setupKey, deviceName: deviceName) } } - + func clearErrorsFor(field: Field) { switch field { case .url: diff --git a/NetBird/Source/App/Views/Components/SafariView.swift b/NetBird/Source/App/Views/Components/SafariView.swift index 9a83afa..c881844 100644 --- a/NetBird/Source/App/Views/Components/SafariView.swift +++ b/NetBird/Source/App/Views/Components/SafariView.swift @@ -1,4 +1,14 @@ +// +// SafariView.swift +// NetBird +// +// iOS-only: Wraps SFSafariViewController for in-app web authentication. +// + import SwiftUI + +// Safari is only available on iOS +#if os(iOS) import SafariServices struct SafariView: UIViewControllerRepresentable { @@ -48,3 +58,4 @@ struct SafariView: UIViewControllerRepresentable { } } } +#endif diff --git a/NetBird/Source/App/Views/Components/SideDrawer.swift b/NetBird/Source/App/Views/Components/SideDrawer.swift index f9747ec..fff7f8e 100644 --- a/NetBird/Source/App/Views/Components/SideDrawer.swift +++ b/NetBird/Source/App/Views/Components/SideDrawer.swift @@ -4,9 +4,13 @@ // // Created by Pascal Fischer on 01.08.23. // +// iOS only: Side drawer menu for navigation. +// tvOS uses TVSettingsView (tab-based) instead. +// import SwiftUI +#if os(iOS) struct SideDrawer: View { @StateObject var viewModel: ViewModel @Binding var isShowing: Bool @@ -164,3 +168,5 @@ struct SideDrawer_Previews: PreviewProvider { SideMenu(viewModel: ViewModel()) } } + +#endif // os(iOS) diff --git a/NetBird/Source/App/Views/MainView.swift b/NetBird/Source/App/Views/MainView.swift index 99c9065..df97c49 100644 --- a/NetBird/Source/App/Views/MainView.swift +++ b/NetBird/Source/App/Views/MainView.swift @@ -10,8 +10,25 @@ import Lottie import NetworkExtension import Combine +// MARK: - Main Entry Point +/// The root view that switches between iOS and tvOS layouts. struct MainView: View { @EnvironmentObject var viewModel: ViewModel + + var body: some View { + #if os(tvOS) + // tvOS uses a completely different navigation structure + TVMainView() + #else + // iOS uses the original MainView implementation + iOSMainView() + #endif + } +} + +#if os(iOS) +struct iOSMainView: View { + @EnvironmentObject var viewModel: ViewModel @State private var isSheetshown = true @State private var animationKey: UUID = UUID() @@ -24,10 +41,6 @@ struct MainView: View { appearance.configureWithOpaqueBackground() appearance.backgroundColor = UIColor(named: "BgNavigationBar") - // Customize the title text color -// appearance.titleTextAttributes = [.foregroundColor: UIColor(named: "TextAlert")] -// appearance.largeTitleTextAttributes = [.foregroundColor: UIColor(named: "TextAlert")] - // Set the appearance for when the navigation bar is displayed regularly UINavigationBar.appearance().standardAppearance = appearance @@ -58,8 +71,8 @@ struct MainView: View { Image(imageName) .resizable(resizingMode: .stretch) .aspectRatio(contentMode: .fit) - // .padding(.top, UIScreen.main.bounds.height * (viewModel.isIpad ? 0.34 : 0.13)) - .padding(.top, UIScreen.main.bounds.height * (viewModel.isIpad ? (isLandscape ? -0.15 : 0.36) : 0.19)) + // .padding(.top, Screen.height * (DeviceType.isPad ? 0.34 : 0.13)) + .padding(.top, Screen.height * (DeviceType.isPad ? (isLandscape ? -0.15 : 0.36) : 0.19)) .padding(.leading, UIScreen.main.bounds.height * (isLandscape ? 0.04 : 0)) .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height) .edgesIgnoringSafeArea(.bottom) @@ -69,7 +82,7 @@ struct MainView: View { Text(viewModel.fqdn) .foregroundColor(Color("TextSecondary")) .font(.system(size: 20, weight: .regular)) - .padding(.top, UIScreen.main.bounds.height * (viewModel.isIpad ? 0.09 : 0.13)) + .padding(.top, Screen.height * (DeviceType.isPad ? 0.09 : 0.13)) .padding(.bottom, 5) Text(viewModel.ip) .foregroundColor(Color("TextSecondary")) @@ -553,3 +566,5 @@ struct MainView_Previews: PreviewProvider { MainView() } } + +#endif // os(iOS) diff --git a/NetBird/Source/App/Views/PeerTabView.swift b/NetBird/Source/App/Views/PeerTabView.swift index f6b3bf7..70f878b 100644 --- a/NetBird/Source/App/Views/PeerTabView.swift +++ b/NetBird/Source/App/Views/PeerTabView.swift @@ -4,9 +4,16 @@ // // Created by Pascal Fischer on 06.05.24. // +// Shared between iOS and tvOS. +// tvOS has its own dedicated view (TVPeersView) but this can be used as fallback. +// import SwiftUI +#if os(iOS) +import UIKit +#endif + struct PeerTabView: View { @EnvironmentObject var viewModel: ViewModel @@ -71,15 +78,15 @@ struct NoPeersView: View { Image("icon-empty-box") .resizable() .scaledToFit() - .frame(height: UIScreen.main.bounds.height * 0.2) - .padding(.top, UIScreen.main.bounds.height * 0.05) + .frame(height: Screen.height * 0.2) + .padding(.top, Screen.height * 0.05) Text("It looks like there are no machines that you can connect to...") - .font(.system(size: 18, weight: .regular)) + .font(.system(size: 18 * Layout.fontScale, weight: .regular)) .foregroundColor(Color("TextPrimary")) .multilineTextAlignment(.center) - .padding(.horizontal, UIScreen.main.bounds.width * 0.075) - .padding(.top, UIScreen.main.bounds.height * 0.04) + .padding(.horizontal, Screen.width * 0.075) + .padding(.top, Screen.height * 0.04) if let url = URL(string: "https://docs.netbird.io") { Link(destination: url) { @@ -97,16 +104,16 @@ struct NoPeersView: View { ) ) } - .padding(.top, UIScreen.main.bounds.height * 0.04) - .padding(.horizontal, UIScreen.main.bounds.width * 0.05) + .padding(.top, Screen.height * 0.04) + .padding(.horizontal, Screen.width * 0.05) } else { Text("Unable to load the documentation link.") .font(.footnote) .foregroundColor(.red) - .padding(.top, UIScreen.main.bounds.height * 0.04) + .padding(.top, Screen.height * 0.04) } } - .padding(.horizontal, UIScreen.main.bounds.width * 0.05) + .padding(.horizontal, Screen.width * 0.05) } } @@ -186,6 +193,8 @@ struct PeerCardView: View { private func contextMenu(for peer: PeerInfo) -> some View { Group { + #if os(iOS) + // Clipboard is only available on iOS Button("Copy FQDN") { UIPasteboard.general.string = peer.fqdn print("Copied FQDN to clipboard") @@ -209,6 +218,11 @@ struct PeerCardView: View { } peerViewModel.unfreezeDisplayedPeerList() } + #else + // tvOS: Show info instead of copy + Text("FQDN: \(peer.fqdn)") + Text("IP: \(peer.ip)") + #endif } } } diff --git a/NetBird/Source/App/Views/RouteTabView.swift b/NetBird/Source/App/Views/RouteTabView.swift index f7b08aa..d158e21 100644 --- a/NetBird/Source/App/Views/RouteTabView.swift +++ b/NetBird/Source/App/Views/RouteTabView.swift @@ -4,6 +4,9 @@ // // Created by Pascal Fischer on 06.05.24. // +// Shared between iOS and tvOS. +// Uses Screen helper for platform-independent sizing. +// import SwiftUI @@ -80,13 +83,13 @@ struct NoRoutesView: View { var body: some View { Group { Image("icon-empty-box") - .padding(.top, UIScreen.main.bounds.height * 0.05) + .padding(.top, Screen.height * 0.05) Text("It looks like there are no resources that you can connect to ...") - .font(.system(size: 18, weight: .regular)) + .font(.system(size: 18 * Layout.fontScale, weight: .regular)) .foregroundColor(Color("TextPrimary")) .multilineTextAlignment(.center) - .padding(.top, UIScreen.main.bounds.height * 0.04) - .padding([.leading, .trailing], UIScreen.main.bounds.width * 0.075) + .padding(.top, Screen.height * 0.04) + .padding([.leading, .trailing], Screen.width * 0.075) Link(destination: URL(string: "https://docs.netbird.io/how-to/networks")!) { Text("Learn why") .font(.headline) @@ -101,9 +104,9 @@ struct NoRoutesView: View { .stroke(Color.orange.darker(), lineWidth: 2) ) ) - .padding(.top, UIScreen.main.bounds.height * 0.04) + .padding(.top, Screen.height * 0.04) } } - .padding([.leading, .trailing], UIScreen.main.bounds.width * 0.05) + .padding([.leading, .trailing], Screen.width * 0.05) } } diff --git a/NetBird/Source/App/Views/ServerView.swift b/NetBird/Source/App/Views/ServerView.swift index b0c585e..cbb3f2b 100644 --- a/NetBird/Source/App/Views/ServerView.swift +++ b/NetBird/Source/App/Views/ServerView.swift @@ -10,7 +10,7 @@ import SwiftUI struct ServerView: View { @EnvironmentObject var viewModel: ViewModel - @StateObject private var serverViewModel = ServerViewModel(configurationFilePath: Preferences.configFile(), deviceName: Device.getName()) + @StateObject private var serverViewModel = ServerViewModel(configurationFilePath: Preferences.configFile() ?? "", deviceName: Device.getName()) private let defaultManagementServerUrl = "https://api.netbird.io" private let addSymbol = "add-symbol" @@ -272,4 +272,4 @@ struct ServerView: View { #Preview { ServerView() .environmentObject(ViewModel()) -} +} \ No newline at end of file diff --git a/NetBird/Source/App/Views/TV/TVAuthView.swift b/NetBird/Source/App/Views/TV/TVAuthView.swift new file mode 100644 index 0000000..79d361a --- /dev/null +++ b/NetBird/Source/App/Views/TV/TVAuthView.swift @@ -0,0 +1,386 @@ +// +// TVAuthView.swift +// NetBird +// +// Authentication view for tvOS. +// Since Safari isn't available on Apple TV, we show users +// a QR code and device code to enter on another device (phone/computer). +// +// This is the "device code flow" pattern used by Netflix, YouTube, etc. +// + +import SwiftUI +import CoreImage.CIFilterBuiltins + +#if os(tvOS) + +/// Displays authentication instructions for tvOS users. +/// Users scan a QR code or visit a URL on their phone/computer to complete sign-in. +struct TVAuthView: View { + /// The URL users should visit to authenticate + let loginURL: String + + /// The user code to display (from device auth flow) + /// If nil, will try to extract from URL + var userCode: String? + + /// Whether authentication is in progress + @Binding var isPresented: Bool + + /// Called when user cancels authentication + var onCancel: (() -> Void)? + + /// Called when authentication completes (detected via polling) + var onComplete: (() -> Void)? + + /// Called when authentication fails (e.g., device code expires, server rejects) + var onError: ((String) -> Void)? + + /// Reference to check login status (async - calls completion with true if login is complete) + var checkLoginComplete: ((@escaping (Bool) -> Void) -> Void)? + + /// Reference to check for login errors (async - calls completion with error message or nil) + var checkLoginError: ((@escaping (String?) -> Void) -> Void)? + + /// Polling timer to check if login completed + @State private var pollTimer: Timer? + + /// QR code image generated from login URL + @State private var qrCodeImage: UIImage? + + /// Error message to display if authentication fails + @State private var errorMessage: String? + + var body: some View { + ZStack { + // Dark overlay background + Color.black.opacity(0.9) + .ignoresSafeArea() + + HStack(spacing: 80) { + // Left Side - QR Code + VStack(spacing: 30) { + Text("Scan to Sign In") + .font(.system(size: 36, weight: .bold)) + .foregroundColor(.white) + + // QR Code + if let qrImage = qrCodeImage { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 280, height: 280) + .background(Color.white) + .cornerRadius(16) + } else { + // Placeholder while generating + RoundedRectangle(cornerRadius: 16) + .fill(Color.white) + .frame(width: 280, height: 280) + .overlay( + ProgressView() + .scaleEffect(2) + ) + } + + Text("Scan with your phone camera") + .font(.system(size: 24)) + .foregroundColor(.gray) + } + .padding(50) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(Color.white.opacity(0.05)) + ) + + // Divider + Rectangle() + .fill(Color.white.opacity(0.2)) + .frame(width: 2, height: 600) + + // Right Side - Device Code + VStack(spacing: 40) { + // App logo + Image("netbird-logo-menu") + .resizable() + .scaledToFit() + .frame(width: 200) + + // Device code display + if let code = displayUserCode { + VStack(spacing: 20) { + Text("Device code:") + .font(.system(size: 28)) + .foregroundColor(.gray) + + Text(code) + .font(.system(size: 64, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + .tracking(6) + .padding(.horizontal, 40) + .padding(.vertical, 20) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.accentColor.opacity(0.2)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.accentColor, lineWidth: 2) + ) + ) + } + } + + // Error message or loading indicator + if let error = errorMessage { + VStack(spacing: 15) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 36)) + .foregroundColor(.orange) + + Text(error) + .font(.system(size: 20)) + .foregroundColor(.orange) + .multilineTextAlignment(.center) + .frame(maxWidth: 400) + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.orange.opacity(0.1)) + ) + .padding(.top, 20) + } else { + HStack(spacing: 15) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.2) + + Text("Waiting for sign-in...") + .font(.system(size: 22)) + .foregroundColor(.gray) + } + .padding(.top, 20) + } + + // Cancel button + Button(action: { + pollTimer?.invalidate() + onCancel?() + isPresented = false + }) { + Text("Cancel") + .font(.system(size: 22)) + .foregroundColor(.white) + .padding(.horizontal, 50) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.white.opacity(0.3), lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + .padding(50) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(Color.white.opacity(0.05)) + ) + } + .padding(60) + } + .onAppear { + generateQRCode() + startPollingForCompletion() + } + .onDisappear { + pollTimer?.invalidate() + } + } + + // Computed Properties + + /// The user code to display - prefers passed-in userCode, falls back to URL extraction + private var displayUserCode: String? { + if let code = userCode, !code.isEmpty { + return code + } + return extractUserCode(from: loginURL) + } + + // Helper Functions + + /// Generates a QR code image from the login URL + private func generateQRCode() { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + + filter.message = Data(loginURL.utf8) + filter.correctionLevel = "M" + + guard let outputImage = filter.outputImage else { return } + + // Scale up the QR code for better visibility + let scale = 10.0 + let transform = CGAffineTransform(scaleX: scale, y: scale) + let scaledImage = outputImage.transformed(by: transform) + + if let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) { + qrCodeImage = UIImage(cgImage: cgImage) + } + } + + /// Extracts the user code from the URL (typically shown to users) + private func extractUserCode(from url: String) -> String? { + guard let urlObj = URL(string: url), + let components = URLComponents(url: urlObj, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return nil + } + + // Look for user_code first (the human-readable code) + // Then fall back to code parameter + for item in queryItems { + let name = item.name.lowercased() + if name == "user_code" { + return item.value + } + } + + // Fallback to generic code parameter + for item in queryItems { + let name = item.name.lowercased() + if name == "code" { + return item.value + } + } + + return nil + } + + /// Starts polling to check if authentication completed + private func startPollingForCompletion() { + #if DEBUG + print("TVAuthView: Starting polling for login completion") + #endif + pollTimer?.invalidate() + + // Capture the closures and bindings we need + // SwiftUI structs are value types, so we capture these by value + let checkComplete = self.checkLoginComplete + let checkError = self.checkLoginError + let onCompleteHandler = self.onComplete + let onErrorHandler = self.onError + + // Schedule timer on main run loop to ensure it fires + let timer = Timer(timeInterval: 2.0, repeats: true) { timer in + #if DEBUG + print("TVAuthView: Poll tick - checking login status via extension IPC...") + #endif + + // First check for errors (if error checker provided) + if let checkError = checkError { + checkError { errorMsg in + DispatchQueue.main.async { + if let errorMsg = errorMsg { + #if DEBUG + print("TVAuthView: Login error detected: \(errorMsg)") + #endif + self.errorMessage = errorMsg + timer.invalidate() + onErrorHandler?(errorMsg) + } else { + // No error - check for completion + self.checkCompletionStatus( + timer: timer, + checkComplete: checkComplete, + onCompleteHandler: onCompleteHandler + ) + } + } + } + return + } + + // Fallback if no error checker provided - just check completion + self.checkCompletionStatus( + timer: timer, + checkComplete: checkComplete, + onCompleteHandler: onCompleteHandler + ) + } + RunLoop.main.add(timer, forMode: .common) + pollTimer = timer + + // Fire immediately once to check current status + #if DEBUG + print("TVAuthView: Performing initial login check...") + #endif + guard let checkComplete = checkComplete else { + #if DEBUG + print("TVAuthView: No checkLoginComplete closure provided") + #endif + return + } + checkComplete { isComplete in + DispatchQueue.main.async { + #if DEBUG + print("TVAuthView: Initial check - login complete = \(isComplete)") + #endif + if isComplete { + #if DEBUG + print("TVAuthView: Login already complete, dismissing auth view") + #endif + onCompleteHandler?() + } + } + } + } + + /// Helper to check completion status - ensures mutual exclusivity with error checking + private func checkCompletionStatus( + timer: Timer, + checkComplete: (((@escaping (Bool) -> Void) -> Void))?, + onCompleteHandler: (() -> Void)? + ) { + guard let checkComplete = checkComplete else { + #if DEBUG + print("TVAuthView: No checkLoginComplete closure provided") + #endif + return + } + + checkComplete { isComplete in + DispatchQueue.main.async { + #if DEBUG + print("TVAuthView: Login complete = \(isComplete)") + #endif + if isComplete { + #if DEBUG + print("TVAuthView: Login detected as complete, dismissing auth view") + #endif + timer.invalidate() + onCompleteHandler?() + } + } + } + } +} + +/// Preview provider for development +struct TVAuthView_Previews: PreviewProvider { + static var previews: some View { + TVAuthView( + loginURL: "https://app.netbird.io/device?user_code=ABCD-1234", + isPresented: .constant(true), + checkLoginComplete: { completion in + // Preview always returns false (not logged in) + completion(false) + } + ) + } +} + +#endif + + diff --git a/NetBird/Source/App/Views/TV/TVColors.swift b/NetBird/Source/App/Views/TV/TVColors.swift new file mode 100644 index 0000000..b4e0f3c --- /dev/null +++ b/NetBird/Source/App/Views/TV/TVColors.swift @@ -0,0 +1,166 @@ +// +// TVColors.swift +// NetBird +// +// Shared styling definitions for tvOS views. +// Provides colors and layout constants for the 10-foot TV experience. +// + +import SwiftUI + +#if os(tvOS) +import UIKit + +// MARK: - TVColors + +/// Centralized color definitions for all tvOS views. +/// Uses named colors from asset catalog with sensible fallbacks. +struct TVColors { + + // MARK: - Text Colors + + static var textPrimary: Color { + colorOrFallback("TextPrimary", fallback: .primary) + } + + static var textSecondary: Color { + colorOrFallback("TextSecondary", fallback: .secondary) + } + + static var textAlert: Color { + colorOrFallback("TextAlert", fallback: .white) + } + + // MARK: - Background Colors + + static var bgMenu: Color { + colorOrFallback("BgMenu", fallback: Color(white: 0.1)) + } + + static var bgPrimary: Color { + colorOrFallback("BgPrimary", fallback: Color(white: 0.15)) + } + + static var bgSecondary: Color { + colorOrFallback("BgSecondary", fallback: Color(white: 0.08)) + } + + static var bgSideDrawer: Color { + colorOrFallback("BgSideDrawer", fallback: Color(white: 0.2)) + } + + // MARK: - Helper + + private static func colorOrFallback(_ name: String, fallback: Color) -> Color { + UIColor(named: name) != nil ? Color(name) : fallback + } +} + +// MARK: - TVLayout + +/// Centralized layout constants for tvOS. +/// All dimensions optimized for the "10-foot experience" (viewing from couch distance). +struct TVLayout { + + // MARK: - Content Padding + + /// Standard content padding from screen edges + static let contentPadding: CGFloat = 80 + + /// Padding inside cards/sections + static let cardPadding: CGFloat = 30 + + /// Padding inside detail panels + static let detailPadding: CGFloat = 40 + + /// Padding for dialog/alert content + static let dialogPadding: CGFloat = 60 + + // MARK: - Spacing + + /// Large spacing between major sections + static let sectionSpacing: CGFloat = 40 + + /// Medium spacing between related elements + static let elementSpacing: CGFloat = 20 + + /// Small spacing within grouped items + static let itemSpacing: CGFloat = 15 + + /// Horizontal spacing between columns + static let columnSpacing: CGFloat = 100 + + /// Spacing between filter buttons + static let filterSpacing: CGFloat = 35 + + // MARK: - Sizes + + /// Logo width on main screens + static let logoWidth: CGFloat = 300 + + /// Logo width on secondary screens (dialogs, info panels) + static let logoWidthSmall: CGFloat = 200 + + /// Side panel/detail view width + static let sidePanelWidth: CGFloat = 500 + + /// Info panel width (server view, etc.) + static let infoPanelWidth: CGFloat = 400 + + /// QR code size for auth view + static let qrCodeSize: CGFloat = 280 + + // MARK: - Corner Radius + + /// Large corner radius for major containers + static let cornerRadiusLarge: CGFloat = 24 + + /// Medium corner radius for cards + static let cornerRadiusMedium: CGFloat = 20 + + /// Small corner radius for buttons/inputs + static let cornerRadiusSmall: CGFloat = 12 + + // MARK: - Font Sizes + + /// Page title (e.g., "Settings", "Peers") + static let fontTitle: CGFloat = 48 + + /// Section header + static let fontHeader: CGFloat = 32 + + /// Card title / primary text + static let fontBody: CGFloat = 26 + + /// Secondary/subtitle text + static let fontSubtitle: CGFloat = 22 + + /// Small/caption text + static let fontCaption: CGFloat = 18 + + /// Device code display (auth view) + static let fontDeviceCode: CGFloat = 64 + + // MARK: - Button Dimensions + + /// Horizontal padding for primary buttons + static let buttonPaddingH: CGFloat = 50 + + /// Vertical padding for primary buttons + static let buttonPaddingV: CGFloat = 18 + + /// Button font size + static let buttonFontSize: CGFloat = 24 + + // MARK: - Focus Effects + + /// Scale factor when element is focused + static let focusScale: CGFloat = 1.02 + + /// Scale factor for large focused buttons + static let focusScaleLarge: CGFloat = 1.1 + + /// Border width when focused + static let focusBorderWidth: CGFloat = 4 +} +#endif \ No newline at end of file diff --git a/NetBird/Source/App/Views/TV/TVMainView.swift b/NetBird/Source/App/Views/TV/TVMainView.swift new file mode 100644 index 0000000..c57abf7 --- /dev/null +++ b/NetBird/Source/App/Views/TV/TVMainView.swift @@ -0,0 +1,354 @@ +// +// TVMainView.swift +// NetBird +// +// Main navigation structure for tvOS. +// +// Key differences from iOS: +// - Uses TabView at the top (tvOS standard) +// - No swipe gestures (uses Siri Remote focus navigation) +// - Larger text and touch targets for "10-foot experience" +// - No side drawer (replaced with Settings tab) +// + +import SwiftUI +import UIKit +import NetworkExtension +import NetBirdSDK +import os + +#if os(tvOS) + +private let buttonLogger = Logger(subsystem: "io.netbird.app", category: "TVConnectionButton") + +struct TVMainView: View { + @EnvironmentObject var viewModel: ViewModel + + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + TVConnectionView() + .tabItem { + Label("Connection", systemImage: "network") + } + .tag(0) + + TVPeersView() + .tabItem { + Label("Peers", systemImage: "person.3.fill") + } + .tag(1) + + TVNetworksView() + .tabItem { + Label("Networks", systemImage: "globe") + } + .tag(2) + + TVSettingsView() + .tabItem { + Label("Settings", systemImage: "gear") + } + .tag(3) + } + .environmentObject(viewModel) + // Server configuration sheet (change server) + .fullScreenCover(isPresented: $viewModel.navigateToServerView) { + TVServerView(isPresented: $viewModel.navigateToServerView) + .environmentObject(viewModel) + } + // Authentication Sheet (QR Code + Device Code) + .fullScreenCover(isPresented: $viewModel.networkExtensionAdapter.showBrowser) { + if let loginURL = viewModel.networkExtensionAdapter.loginURL { + TVAuthView( + loginURL: loginURL, + userCode: viewModel.networkExtensionAdapter.userCode, + isPresented: $viewModel.networkExtensionAdapter.showBrowser, + onCancel: { + viewModel.networkExtensionAdapter.showBrowser = false + }, + onComplete: { + #if DEBUG + print("Login completed, transferring config to extension...") + #endif + viewModel.networkExtensionAdapter.showBrowser = false + + // After login completes, ensure config is transferred to extension before connecting + // On tvOS, shared UserDefaults doesn't work, so we must send via IPC + if let configJSON = Preferences.loadConfigFromUserDefaults(), !configJSON.isEmpty { + #if DEBUG + print("Sending config to extension before starting VPN...") + #endif + viewModel.networkExtensionAdapter.sendConfigToExtension(configJSON) { success in + #if DEBUG + print("Config transfer \(success ? "succeeded" : "failed"), starting VPN connection...") + #endif + // Start VPN only after config transfer completes + viewModel.networkExtensionAdapter.startVPNConnection() + } + } else { + #if DEBUG + print("No config found in UserDefaults, starting VPN anyway...") + #endif + // Fallback - try to connect anyway (will likely fail but better than hanging) + viewModel.networkExtensionAdapter.startVPNConnection() + } + }, + onError: { errorMessage in + #if DEBUG + print("Login error: \(errorMessage)") + #endif + // Error is displayed in the auth view - user can dismiss manually + }, + checkLoginComplete: { completion in + viewModel.networkExtensionAdapter.checkLoginComplete { isComplete in + #if DEBUG + print("TVMainView: checkLoginComplete returned \(isComplete)") + #endif + completion(isComplete) + } + }, + checkLoginError: { completion in + viewModel.networkExtensionAdapter.checkLoginError { errorMessage in + completion(errorMessage) + } + } + ) + } + } + } +} + +struct TVConnectionView: View { + @EnvironmentObject var viewModel: ViewModel + + var body: some View { + ZStack { + // Background + TVColors.bgSecondary + .ignoresSafeArea() + + HStack(spacing: 100) { + // Left Side - Connection Control + VStack(spacing: 40) { + Image("netbird-logo-menu") + .resizable() + .scaledToFit() + .frame(width: 300) + + // Device info + if !viewModel.fqdn.isEmpty { + Text(viewModel.fqdn) + .font(.system(size: 28)) + .foregroundColor(TVColors.textSecondary) + } + + if !viewModel.ip.isEmpty { + Text(viewModel.ip) + .font(.system(size: 24)) + .foregroundColor(TVColors.textSecondary.opacity(0.8)) + } + + TVConnectionButton(viewModel: viewModel) + + // Status text + Text(viewModel.extensionStateText) + .font(.system(size: 32, weight: .medium)) + .foregroundColor(statusColor) + } + .frame(maxWidth: .infinity) + + // Right Side - Quick Stats + VStack(alignment: .leading, spacing: 30) { + Text("Network Status") + .font(.system(size: 32, weight: .bold)) + .foregroundColor(TVColors.textPrimary) + + TVStatCard( + icon: "person.3.fill", + title: "Connected Peers", + value: connectedPeersCount, + total: totalPeersCount + ) + + TVStatCard( + icon: "globe", + title: "Active Networks", + value: activeNetworksCount, + total: totalNetworksCount + ) + + TVStatCard( + icon: "clock.fill", + title: "Connection Status", + value: viewModel.extensionStateText, + total: nil + ) + } + .padding(50) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(TVColors.bgMenu) + ) + .frame(width: 500) + } + .padding(80) + } + } + + // Computed Properties + + private var statusColor: Color { + switch viewModel.extensionStateText { + case "Connected": return .green + case "Connecting": return .orange + case "Disconnecting": return .orange + default: return TVColors.textSecondary + } + } + + private var connectedPeersCount: String { + guard viewModel.extensionStateText == "Connected" else { return "0" } + return viewModel.peerViewModel.peerInfo.filter { $0.connStatus == "Connected" }.count.description + } + + private var totalPeersCount: String { + guard viewModel.extensionStateText == "Connected" else { return "0" } + return viewModel.peerViewModel.peerInfo.count.description + } + + private var activeNetworksCount: String { + guard viewModel.extensionStateText == "Connected" else { return "0" } + return viewModel.routeViewModel.routeInfo.filter { $0.selected }.count.description + } + + private var totalNetworksCount: String { + guard viewModel.extensionStateText == "Connected" else { return "0" } + return viewModel.routeViewModel.routeInfo.count.description + } +} + +struct TVConnectionButton: View { + @ObservedObject var viewModel: ViewModel + + /// Track focus state for visual feedback + @FocusState private var isFocused: Bool + + var body: some View { + Button(action: handleTap) { + HStack(spacing: 20) { + Image(systemName: buttonIcon) + .font(.system(size: 40)) + + Text(buttonText) + .font(.system(size: 32, weight: .semibold)) + } + .foregroundColor(.white) + .padding(.horizontal, 80) + .padding(.vertical, 30) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(buttonColor) + ) + .scaleEffect(isFocused ? 1.1 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isFocused) + } + .buttonStyle(.plain) + .focused($isFocused) + .disabled(viewModel.buttonLock) + } + + private var buttonText: String { + switch viewModel.extensionStateText { + case "Connected": return "Disconnect" + case "Connecting": return "Connecting..." + case "Disconnecting": return "Disconnecting..." + default: return "Connect" + } + } + + private var buttonIcon: String { + switch viewModel.extensionStateText { + case "Connected": return "stop.fill" + case "Connecting", "Disconnecting": return "hourglass" + default: return "play.fill" + } + } + + private var buttonColor: Color { + switch viewModel.extensionStateText { + case "Connected": return .red.opacity(0.8) + case "Connecting", "Disconnecting": return .orange + default: return .accentColor + } + } + + private func handleTap() { + buttonLogger.info("handleTap: called, buttonLock=\(viewModel.buttonLock), extensionStateText=\(viewModel.extensionStateText)") + guard !viewModel.buttonLock else { + buttonLogger.info("handleTap: buttonLock is true, returning early") + return + } + + if viewModel.extensionStateText == "Connected" || + viewModel.extensionStateText == "Connecting" { + buttonLogger.info("handleTap: calling viewModel.close()") + viewModel.close() + } else { + buttonLogger.info("handleTap: calling viewModel.connect()") + viewModel.connect() + } + } +} + +struct TVStatCard: View { + let icon: String + let title: String + let value: String + let total: String? + + var body: some View { + HStack(spacing: 20) { + Image(systemName: icon) + .font(.system(size: 36)) + .foregroundColor(.accentColor) + .frame(width: 50) + + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 20)) + .foregroundColor(TVColors.textSecondary) + + if let total = total { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(value) + .font(.system(size: 36, weight: .bold)) + .foregroundColor(TVColors.textPrimary) + Text("/ \(total)") + .font(.system(size: 24)) + .foregroundColor(TVColors.textSecondary) + } + } else { + Text(value) + .font(.system(size: 28, weight: .semibold)) + .foregroundColor(TVColors.textPrimary) + } + } + + Spacer() + } + .padding(.vertical, 15) + } +} + +struct TVMainView_Previews: PreviewProvider { + static var previews: some View { + TVMainView() + .environmentObject(ViewModel()) + } +} + +#endif + + diff --git a/NetBird/Source/App/Views/TV/TVNetworksView.swift b/NetBird/Source/App/Views/TV/TVNetworksView.swift new file mode 100644 index 0000000..b3a97c7 --- /dev/null +++ b/NetBird/Source/App/Views/TV/TVNetworksView.swift @@ -0,0 +1,245 @@ +// +// TVNetworksView.swift +// NetBird +// +// Networks/Routes view optimized for Apple TV. +// +// Displays network routes that can be enabled/disabled. +// Uses focus-based toggle instead of tap gestures. +// + +import SwiftUI +import UIKit + +#if os(tvOS) + +/// Displays the list of network routes in a tvOS-friendly format. +struct TVNetworksView: View { + @EnvironmentObject var viewModel: ViewModel + + var body: some View { + ZStack { + TVColors.bgMenu + .ignoresSafeArea() + + if viewModel.extensionStateText == "Connected" && + viewModel.routeViewModel.routeInfo.count > 0 { + TVNetworkListContent() + } else { + TVNoNetworksView() + } + } + .onAppear { + viewModel.routeViewModel.getRoutes() + } + } +} + +struct TVNetworkListContent: View { + @EnvironmentObject var viewModel: ViewModel + + /// Refresh animation state + @State private var isRefreshing = false + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + // Header (non-focusable) + HStack { + Text("Networks") + .font(.system(size: 48, weight: .bold)) + .foregroundColor(TVColors.textPrimary) + + Spacer() + + // Stats + Text("\(activeCount) of \(totalCount) enabled") + .font(.system(size: 24)) + .foregroundColor(TVColors.textSecondary) + } + .padding(.horizontal, 80) + .padding(.top, 40) + + // Filter bar with refresh button (all focusable items on same row) + HStack { + TVFilterBar( + options: ["All", "Enabled", "Disabled"], + selected: $viewModel.routeViewModel.selectionFilter + ) + + Spacer() + + Button(action: refresh) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 28)) + .foregroundColor(TVColors.textSecondary) + .rotationEffect(.degrees(isRefreshing ? 360 : 0)) + .animation( + isRefreshing ? .linear(duration: 1).repeatForever(autoreverses: false) : .default, + value: isRefreshing + ) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 80) + .padding(.bottom, 30) + + // Network grid + ScrollView { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 30), + GridItem(.flexible(), spacing: 30) + ], + spacing: 30 + ) { + ForEach(viewModel.routeViewModel.filteredRoutes, id: \.id) { route in + TVNetworkCard( + route: route, + routeViewModel: viewModel.routeViewModel + ) + } + } + .padding(.top, 15) + .padding(.horizontal, 80) + .padding(.bottom, 80) + } + } + } + + // Computed Properties + + private var activeCount: Int { + viewModel.routeViewModel.routeInfo.filter { $0.selected }.count + } + + private var totalCount: Int { + viewModel.routeViewModel.routeInfo.count + } + + // Actions + + private func refresh() { + isRefreshing = true + viewModel.routeViewModel.getRoutes() + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + isRefreshing = false + } + } +} + +// Individual Network Card +struct TVNetworkCard: View { + let route: RoutesSelectionInfo + @ObservedObject var routeViewModel: RoutesViewModel + + @FocusState private var isFocused: Bool + + var body: some View { + Button(action: toggleRoute) { + HStack(spacing: 25) { + // Status toggle indicator + ZStack { + Circle() + .fill(route.selected ? Color.green : Color.gray.opacity(0.3)) + .frame(width: 50, height: 50) + + Image(systemName: route.selected ? "checkmark" : "xmark") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + } + + // Route info + VStack(alignment: .leading, spacing: 10) { + Text(route.name) + .font(.system(size: 26, weight: .semibold)) + .foregroundColor(isFocused ? .white : TVColors.textPrimary) + .lineLimit(1) + + Text(routeDisplayText) + .font(.system(size: 20)) + .foregroundColor(isFocused ? .white.opacity(0.8) : TVColors.textSecondary) + .lineLimit(2) + } + + Spacer() + + Text(route.selected ? "Enabled" : "Disabled") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(isFocused ? .white : (route.selected ? .green : .gray)) + } + .padding(30) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(TVColors.bgPrimary) + ) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke( + isFocused ? Color.accentColor : (route.selected ? Color.green.opacity(0.3) : Color.clear), + lineWidth: isFocused ? 4 : 2 + ) + ) + .scaleEffect(isFocused ? 1.03 : 1.0) + .animation(.easeInOut(duration: 0.15), value: isFocused) + } + .buttonStyle(.plain) + .focused($isFocused) + } + + private var routeDisplayText: String { + if route.network == "invalid Prefix" { + if let domains = route.domains, domains.count > 2 { + return "\(domains.count) Domains" + } + return route.domains?.map { $0.domain }.joined(separator: ", ") ?? "" + } + return route.network ?? "" + } + + private func toggleRoute() { + if route.selected { + routeViewModel.deselectRoute(route: route) + } else { + routeViewModel.selectRoute(route: route) + } + } +} + +struct TVNoNetworksView: View { + var body: some View { + VStack(spacing: 40) { + Image("icon-empty-box") + .resizable() + .scaledToFit() + .frame(height: 200) + + Text("No Networks Available") + .font(.system(size: 40, weight: .bold)) + .foregroundColor(TVColors.textPrimary) + + Text("Connect to NetBird to see available networks,\nor configure network routes in your NetBird admin.") + .font(.system(size: 26)) + .foregroundColor(TVColors.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 700) + + // Learn more link (opens on user's phone via QR or second screen) + Text("Visit docs.netbird.io/how-to/networks for more info") + .font(.system(size: 22)) + .foregroundColor(.accentColor) + .padding(.top, 20) + } + } +} + +struct TVNetworksView_Previews: PreviewProvider { + static var previews: some View { + TVNetworksView() + .environmentObject(ViewModel()) + } +} + +#endif + + diff --git a/NetBird/Source/App/Views/TV/TVPeersView.swift b/NetBird/Source/App/Views/TV/TVPeersView.swift new file mode 100644 index 0000000..b5f7efe --- /dev/null +++ b/NetBird/Source/App/Views/TV/TVPeersView.swift @@ -0,0 +1,316 @@ +// +// TVPeersView.swift +// NetBird +// +// Peers list view optimized for Apple TV. +// +// Key differences from iOS PeerTabView: +// - No swipe gestures or context menus (tvOS uses focus + select) +// - Larger cards for readability from distance +// - Focus-based selection instead of tap +// - No clipboard (tvOS limitation) +// + +import SwiftUI +import UIKit + +#if os(tvOS) + +struct TVPeersView: View { + @EnvironmentObject var viewModel: ViewModel + + var body: some View { + ZStack { + TVColors.bgMenu + .ignoresSafeArea() + + if viewModel.extensionStateText == "Connected" && + viewModel.peerViewModel.peerInfo.count > 0 { + TVPeerListContent() + } else { + TVNoPeersView() + } + } + } +} + +struct TVPeerListContent: View { + @EnvironmentObject var viewModel: ViewModel + + /// Currently selected peer for detail view + @State private var selectedPeer: PeerInfo? + + var body: some View { + HStack(spacing: 0) { + // Left Side - Peer List + VStack(alignment: .leading, spacing: 20) { + // Header with count + HStack { + Text("Peers") + .font(.system(size: 48, weight: .bold)) + .foregroundColor(TVColors.textPrimary) + + Spacer() + + Text("\(connectedCount) of \(totalCount) connected") + .font(.system(size: 24)) + .foregroundColor(TVColors.textSecondary) + } + .padding(.horizontal, 50) + .padding(.top, 40) + + TVFilterBar( + options: ["All", "Connected", "Connecting", "Idle"], + selected: $viewModel.peerViewModel.selectionFilter + ) + .padding(.horizontal, 50) + .padding(.bottom, 30) + + // Peer list (scrollable, focus-navigable) + ScrollView { + LazyVStack(spacing: 15) { + ForEach(filteredPeers, id: \.id) { peer in + TVPeerCard( + peer: peer, + isSelected: selectedPeer?.id == peer.id, + onSelect: { selectedPeer = peer } + ) + } + } + .padding(.top, 15) + .padding(.horizontal, 50) + .padding(.bottom, 50) + } + } + .frame(maxWidth: .infinity) + + // Right Side - Peer Details + if let peer = selectedPeer { + TVPeerDetailView(peer: peer) + .frame(width: 500) + .transition(.move(edge: .trailing)) + } + } + } + + // Computed Properties + + private var connectedCount: Int { + viewModel.peerViewModel.peerInfo.filter { $0.connStatus == "Connected" }.count + } + + private var totalCount: Int { + viewModel.peerViewModel.peerInfo.count + } + + private var filteredPeers: [PeerInfo] { + viewModel.peerViewModel.displayedPeers + } +} + +// Individual Peer Card +struct TVPeerCard: View { + let peer: PeerInfo + let isSelected: Bool + let onSelect: () -> Void + + @FocusState private var isFocused: Bool + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 20) { + // Status indicator + Circle() + .fill(statusColor) + .frame(width: 16, height: 16) + + // Peer info + VStack(alignment: .leading, spacing: 8) { + Text(peer.fqdn) + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(TVColors.textPrimary) + .lineLimit(1) + + Text(peer.ip) + .font(.system(size: 20, design: .monospaced)) + .foregroundColor(TVColors.textSecondary) + } + + Spacer() + + // Connection type badge + if peer.connStatus == "Connected" { + Text(peer.relayed ? "Relayed" : "Direct") + .font(.system(size: 18)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + Capsule() + .fill(peer.relayed ? Color.orange : Color.green) + ) + } + + // Selection indicator + Image(systemName: "chevron.right") + .font(.system(size: 24)) + .foregroundColor(TVColors.textSecondary) + } + .padding(25) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(isSelected || isFocused ? Color.accentColor.opacity(0.2) : TVColors.bgPrimary) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isFocused ? Color.accentColor : Color.clear, lineWidth: 4) + ) + .scaleEffect(isFocused ? 1.02 : 1.0) + .animation(.easeInOut(duration: 0.15), value: isFocused) + } + .buttonStyle(.plain) + .focused($isFocused) + } + + private var statusColor: Color { + switch peer.connStatus { + case "Connected": return .green + case "Connecting": return .orange + default: return .gray + } + } +} + +// Peer Detail Panel +struct TVPeerDetailView: View { + let peer: PeerInfo + + var body: some View { + VStack(alignment: .leading, spacing: 30) { + Text("Peer Details") + .font(.system(size: 32, weight: .bold)) + .foregroundColor(TVColors.textPrimary) + + Divider() + + // Details list + Group { + TVDetailRow(label: "Hostname", value: peer.fqdn) + TVDetailRow(label: "IP Address", value: peer.ip) + TVDetailRow(label: "Status", value: peer.connStatus) + TVDetailRow(label: "Connection", value: peer.relayed ? "Relayed" : "Direct") + + if !peer.routes.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("Routes") + .font(.system(size: 20)) + .foregroundColor(TVColors.textSecondary) + + ForEach(peer.routes, id: \.self) { route in + Text(route) + .font(.system(size: 22, design: .monospaced)) + .foregroundColor(TVColors.textPrimary) + } + } + } + } + + Spacer() + } + .padding(40) + .background(TVColors.bgSideDrawer) + } +} + +struct TVDetailRow: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(label) + .font(.system(size: 20)) + .foregroundColor(TVColors.textSecondary) + + Text(value) + .font(.system(size: 26)) + .foregroundColor(TVColors.textPrimary) + } + } +} + +struct TVFilterBar: View { + let options: [String] + @Binding var selected: String + + var body: some View { + HStack(spacing: 35) { + ForEach(options, id: \.self) { option in + TVFilterButton( + title: option, + isSelected: selected == option, + action: { selected = option } + ) + } + Spacer() + } + } +} + +struct TVFilterButton: View { + let title: String + let isSelected: Bool + let action: () -> Void + + @FocusState private var isFocused: Bool + + var body: some View { + Button(action: action) { + Text(title) + .font(.system(size: 22, weight: isSelected || isFocused ? .semibold : .regular)) + .foregroundColor(isSelected || isFocused ? .white : TVColors.textSecondary) + .padding(.horizontal, 28) + .padding(.vertical, 14) + .background( + Capsule() + .fill(isSelected ? Color.accentColor : (isFocused ? Color.gray.opacity(0.5) : TVColors.bgPrimary)) + ) + .scaleEffect(isFocused ? 1.05 : 1.0) + .animation(.easeInOut(duration: 0.15), value: isFocused) + } + .buttonStyle(.plain) + .focused($isFocused) + } +} + +struct TVNoPeersView: View { + var body: some View { + VStack(spacing: 40) { + Image("icon-empty-box") + .resizable() + .scaledToFit() + .frame(height: 200) + + Text("No Peers Available") + .font(.system(size: 40, weight: .bold)) + .foregroundColor(TVColors.textPrimary) + + Text("Connect to NetBird to see your peers,\nor add devices to your network.") + .font(.system(size: 26)) + .foregroundColor(TVColors.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 600) + } + } +} + +struct TVPeersView_Previews: PreviewProvider { + static var previews: some View { + TVPeersView() + .environmentObject(ViewModel()) + } +} + +#endif + + diff --git a/NetBird/Source/App/Views/TV/TVServerView.swift b/NetBird/Source/App/Views/TV/TVServerView.swift new file mode 100644 index 0000000..0633342 --- /dev/null +++ b/NetBird/Source/App/Views/TV/TVServerView.swift @@ -0,0 +1,381 @@ +// +// TVServerView.swift +// NetBird +// +// Server configuration view for tvOS. +// +// Allows users to change the management server URL and optionally +// use a setup key for registration. +// +// Key differences from iOS ServerView: +// - No keyboard (uses tvOS text input via Siri Remote) +// - Larger text and buttons for "10-foot experience" +// - Focus-based navigation +// + +import SwiftUI +import NetBirdSDK + +#if os(tvOS) + +struct TVServerView: View { + @EnvironmentObject var viewModel: ViewModel + @Binding var isPresented: Bool + + @StateObject private var serverViewModel = ServerViewModel( + configurationFilePath: Preferences.configFile() ?? "", + deviceName: Device.getName() + ) + + private let defaultManagementServerUrl = "https://api.netbird.io" + + // Input field values + @State private var managementServerUrl = "" + @State private var setupKey = "" + @State private var showSetupKeyField = false + + // Focus states + @FocusState private var focusedField: FocusedField? + + enum FocusedField { + case serverUrl + case setupKey + case changeButton + case useNetBirdButton + case cancelButton + case showSetupKeyToggle + } + + var body: some View { + ZStack { + // Background + TVColors.bgMenu + .ignoresSafeArea() + + HStack(spacing: 60) { + // Left Side - Form + VStack(alignment: .leading, spacing: 30) { + // Header + VStack(alignment: .leading, spacing: 10) { + Text("Change Server") + .font(.system(size: 48, weight: .bold)) + .foregroundColor(TVColors.textPrimary) + + Text("Configure the management server for your NetBird connection") + .font(.system(size: 24)) + .foregroundColor(TVColors.textSecondary) + } + .padding(.bottom, 20) + + // Server URL field + VStack(alignment: .leading, spacing: 12) { + Text("Server URL") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(TVColors.textPrimary) + + TextField("", text: $managementServerUrl, prompt: nil) + .textFieldStyle(.plain) + .font(.system(size: 28)) + .padding(20) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(TVColors.bgPrimary) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(focusedField == .serverUrl ? Color.accentColor : Color.clear, lineWidth: 3) + ) + .focused($focusedField, equals: .serverUrl) + .onChange(of: managementServerUrl) { + serverViewModel.clearErrorsFor(field: .url) + } + + // Error messages + if let error = serverViewModel.viewErrors.urlError { + Text(error) + .font(.system(size: 20)) + .foregroundColor(.red) + } + if let error = serverViewModel.viewErrors.generalError { + Text(error) + .font(.system(size: 20)) + .foregroundColor(.red) + } + } + + // SSO not supported message + if let ssoError = serverViewModel.viewErrors.ssoNotSupportedError { + Text(ssoError) + .font(.system(size: 20)) + .foregroundColor(.orange) + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.orange.opacity(0.1)) + ) + } + + // Setup key toggle + Button(action: { + showSetupKeyField.toggle() + if !showSetupKeyField { + setupKey = "" + serverViewModel.clearErrorsFor(field: .setupKey) + } + }) { + HStack(spacing: 15) { + Image(systemName: showSetupKeyField ? "minus.circle.fill" : "plus.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.accentColor) + + Text("Add this device with a setup key") + .font(.system(size: 22)) + .foregroundColor(focusedField == .showSetupKeyToggle ? .white : TVColors.textPrimary) + } + .padding(.vertical, 10) + } + .buttonStyle(.plain) + .focused($focusedField, equals: .showSetupKeyToggle) + .disabled(!serverViewModel.isUiEnabled) + + // Setup key field (conditional) + if showSetupKeyField { + VStack(alignment: .leading, spacing: 12) { + Text("Setup Key") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(TVColors.textPrimary) + + TextField("0EF79C2F-DEE1-419B-BFC8-1BF529332998", text: $setupKey) + .textFieldStyle(.plain) + .font(.system(size: 24, design: .monospaced)) + .padding(20) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(TVColors.bgPrimary) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(focusedField == .setupKey ? Color.accentColor : Color.clear, lineWidth: 3) + ) + .focused($focusedField, equals: .setupKey) + .onChange(of: setupKey) { + serverViewModel.clearErrorsFor(field: .setupKey) + } + + if let error = serverViewModel.viewErrors.setupKeyError { + Text(error) + .font(.system(size: 20)) + .foregroundColor(.red) + } + + // Warning about setup keys + Text("Using setup keys for user devices is not recommended. SSO with MFA provides stronger security.") + .font(.system(size: 18)) + .foregroundColor(.accentColor) + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.accentColor, lineWidth: 1) + ) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + Spacer() + + // Action buttons + HStack(spacing: 30) { + // Cancel button + Button(action: { + isPresented = false + }) { + Text("Cancel") + .font(.system(size: 24)) + .foregroundColor(.white) + .padding(.horizontal, 50) + .padding(.vertical, 18) + .background( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.5), lineWidth: 2) + ) + } + .buttonStyle(.plain) + .focused($focusedField, equals: .cancelButton) + + // Use NetBird button + Button(action: useNetBirdServer) { + HStack(spacing: 12) { + Image("icon-netbird-button") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + + Text("Use NetBird") + .font(.system(size: 24)) + } + .foregroundColor(.accentColor) + .padding(.horizontal, 40) + .padding(.vertical, 18) + .background( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.accentColor, lineWidth: 2) + ) + } + .buttonStyle(.plain) + .focused($focusedField, equals: .useNetBirdButton) + .disabled(!serverViewModel.isUiEnabled) + + // Change button + Button(action: changeServer) { + Group { + if !serverViewModel.isUiEnabled { + HStack(spacing: 10) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + Text("Validating...") + } + } else { + Text("Change") + } + } + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 50) + .padding(.vertical, 18) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.accentColor) + ) + } + .buttonStyle(.plain) + .focused($focusedField, equals: .changeButton) + .disabled(!serverViewModel.isUiEnabled) + } + } + .padding(60) + .frame(maxWidth: .infinity, alignment: .leading) + + // Right Side - Info panel + VStack(alignment: .leading, spacing: 30) { + Image("netbird-logo-menu") + .resizable() + .scaledToFit() + .frame(width: 200) + .opacity(0.5) + + Spacer() + + VStack(alignment: .leading, spacing: 20) { + InfoRow(icon: "checkmark.shield.fill", text: "Self-hosted servers supported") + InfoRow(icon: "lock.fill", text: "Secure WireGuard connection") + InfoRow(icon: "person.2.fill", text: "SSO authentication preferred") + } + + Spacer() + + Text("docs.netbird.io") + .font(.system(size: 20)) + .foregroundColor(TVColors.textSecondary.opacity(0.6)) + } + .padding(50) + .frame(width: 400) + .background(TVColors.bgPrimary.opacity(0.3)) + } + } + .onAppear { + focusedField = .serverUrl + loadCurrentServerUrl() + } + .onChange(of: serverViewModel.viewErrors.ssoNotSupportedError) { _, newValue in + if newValue != nil { + showSetupKeyField = true + } + } + .onChange(of: serverViewModel.isOperationSuccessful) { _, newValue in + if newValue { + viewModel.showServerChangedInfo = true + isPresented = false + } + } + .animation(.easeInOut(duration: 0.2), value: showSetupKeyField) + } + + // MARK: - Actions + + private func changeServer() { + let trimmedUrl = managementServerUrl.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let trimmedKey = setupKey.trimmingCharacters(in: .whitespacesAndNewlines) + + // Nothing to do if both empty + guard !trimmedUrl.isEmpty || !trimmedKey.isEmpty else { return } + + var serverUrl = trimmedUrl + if serverUrl.isEmpty { + serverUrl = defaultManagementServerUrl + } + managementServerUrl = serverUrl + + serverViewModel.clearErrorsFor(field: .all) + + Task { + await Task.yield() + + if !serverUrl.isEmpty && !trimmedKey.isEmpty { + await serverViewModel.loginWithSetupKey(managementServerUrl: serverUrl, setupKey: trimmedKey) + } else if !serverUrl.isEmpty { + await serverViewModel.changeManagementServerAddress(managementServerUrl: serverUrl) + } + } + } + + private func useNetBirdServer() { + managementServerUrl = defaultManagementServerUrl + + let trimmedKey = setupKey.trimmingCharacters(in: .whitespacesAndNewlines) + + serverViewModel.clearErrorsFor(field: .all) + + Task { + await Task.yield() + + if trimmedKey.isEmpty { + await serverViewModel.changeManagementServerAddress(managementServerUrl: defaultManagementServerUrl) + } else { + await serverViewModel.loginWithSetupKey(managementServerUrl: defaultManagementServerUrl, setupKey: trimmedKey) + } + } + } + + private func loadCurrentServerUrl() { + // Leave the text field empty by default - user will enter their own URL + managementServerUrl = "" + } +} + +// Helper view for info rows +private struct InfoRow: View { + let icon: String + let text: String + + var body: some View { + HStack(spacing: 15) { + Image(systemName: icon) + .font(.system(size: 24)) + .foregroundColor(.accentColor) + .frame(width: 30) + + Text(text) + .font(.system(size: 22)) + .foregroundColor(TVColors.textSecondary) + } + } +} + +struct TVServerView_Previews: PreviewProvider { + static var previews: some View { + TVServerView(isPresented: .constant(true)) + .environmentObject(ViewModel()) + } +} + +#endif diff --git a/NetBird/Source/App/Views/TV/TVSettingsView.swift b/NetBird/Source/App/Views/TV/TVSettingsView.swift new file mode 100644 index 0000000..95e0cca --- /dev/null +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -0,0 +1,663 @@ +// +// TVSettingsView.swift +// NetBird +// +// Settings view for Apple TV. +// +// Replaces the iOS side drawer menu. +// Contains all configuration options in a focus-navigable format. +// + +import SwiftUI +import UIKit + +#if os(tvOS) + +/// Settings screen for tvOS, replacing the iOS side drawer. +struct TVSettingsView: View { + @EnvironmentObject var viewModel: ViewModel + @State private var showPreSharedKeyAlert = false + + var body: some View { + ZStack { + TVColors.bgMenu + .ignoresSafeArea() + + HStack(spacing: 0) { + // Left Side - Settings List + VStack(alignment: .leading, spacing: 30) { + Text("Settings") + .font(.system(size: 48, weight: .bold)) + .foregroundColor(TVColors.textPrimary) + .padding(.bottom, 20) + + // Settings options + ScrollView { + VStack(spacing: 20) { + TVSettingsSection(title: "Connection") { + TVSettingsRow( + icon: "server.rack", + title: "Change Server", + subtitle: "Switch to a different NetBird server", + action: { viewModel.showChangeServerAlert = true } + ) + } + + TVSettingsSection(title: "Advanced") { + TVSettingsToggleRow( + icon: "ant.fill", + title: "Trace Logging", + subtitle: "Enable detailed logs for troubleshooting", + isOn: $viewModel.traceLogsEnabled + ) + + TVSettingsToggleRow( + icon: "shield.lefthalf.filled", + title: "Rosenpass", + subtitle: "Post-quantum secure encryption", + isOn: Binding( + get: { viewModel.rosenpassEnabled }, + set: { newValue in + // When disabling Rosenpass, also disable permissive mode + if !newValue { + viewModel.setRosenpassPermissive(permissive: false) + } + viewModel.setRosenpassEnabled(enabled: newValue) + } + ) + ) + + TVSettingsToggleRow( + icon: "shield.checkerboard", + title: "Rosenpass Permissive", + subtitle: "Allow connections with non-Rosenpass peers", + isOn: Binding( + get: { viewModel.rosenpassPermissive }, + set: { newValue in + viewModel.setRosenpassPermissive(permissive: newValue) + } + ), + isDisabled: !viewModel.rosenpassEnabled + ) + } + + TVSettingsSection(title: "Security") { + TVSettingsRow( + icon: "key.fill", + title: "Pre-Shared Key", + subtitle: viewModel.presharedKeySecure ? "Configured" : "Not configured", + action: { showPreSharedKeyAlert = true } + ) + } + + TVSettingsSection(title: "Info") { + TVSettingsInfoRow( + icon: "book.fill", + title: "Documentation", + subtitle: "docs.netbird.io" + ) + + TVSettingsInfoRow( + icon: "info.circle.fill", + title: "Version", + subtitle: appVersion + ) + } + } + .padding(.top, 15) + .padding(.bottom, 50) + } + } + .padding(80) + .frame(maxWidth: .infinity, alignment: .leading) + + // Right Side - NetBird Branding + VStack { + Spacer() + + Image("netbird-logo-menu") + .resizable() + .scaledToFit() + .frame(width: 300) + .opacity(0.3) + + Text("Secure. Simple. Connected.") + .font(.system(size: 24)) + .foregroundColor(TVColors.textSecondary.opacity(0.5)) + .padding(.top, 20) + + Spacer() + } + .frame(width: 500) + .background(TVColors.bgPrimary.opacity(0.3)) + } + + // Change server alert overlay + if viewModel.showChangeServerAlert { + TVChangeServerAlert(viewModel: viewModel) + } + + // Rosenpass changed alert overlay + if viewModel.showRosenpassChangedAlert { + TVRosenpassChangedAlert(viewModel: viewModel) + } + + // Pre-shared key alert overlay + if showPreSharedKeyAlert { + TVPreSharedKeyAlert( + viewModel: viewModel, + isPresented: $showPreSharedKeyAlert + ) + } + } + .onAppear { + // Load settings from storage to sync UI with actual values + viewModel.loadRosenpassSettings() + viewModel.loadPreSharedKey() + } + } + + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + } +} + +struct TVSettingsSection: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 15) { + Text(title.uppercased()) + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(TVColors.textSecondary) + .tracking(2) + + VStack(spacing: 10) { + content() + } + .padding(25) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(TVColors.bgPrimary) + ) + } + } +} + +struct TVSettingsRow: View { + let icon: String + let title: String + let subtitle: String + let action: (() -> Void)? + + @FocusState private var isFocused: Bool + + var body: some View { + Button(action: { action?() }) { + HStack(spacing: 20) { + Image(systemName: icon) + .font(.system(size: 28)) + .foregroundColor(.accentColor) + .frame(width: 40) + + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 24, weight: .medium)) + .foregroundColor(isFocused ? .white : TVColors.textPrimary) + + Text(subtitle) + .font(.system(size: 18)) + .foregroundColor(isFocused ? .white.opacity(0.8) : TVColors.textSecondary) + } + + Spacer() + + if action != nil { + Image(systemName: "chevron.right") + .font(.system(size: 20)) + .foregroundColor(isFocused ? .white : TVColors.textSecondary) + } + } + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isFocused ? Color.accentColor.opacity(0.2) : Color.clear) + ) + } + .buttonStyle(.plain) + .focused($isFocused) + .disabled(action == nil) + } +} + +struct TVSettingsToggleRow: View { + let icon: String + let title: String + let subtitle: String + @Binding var isOn: Bool + var isDisabled: Bool = false + + @FocusState private var isFocused: Bool + + var body: some View { + // Note: We don't use .disabled() because that breaks focus navigation on tvOS. + // Instead, we check isDisabled in the action and show visual disabled state. + Button(action: { if !isDisabled { isOn.toggle() } }) { + HStack(spacing: 20) { + Image(systemName: icon) + .font(.system(size: 28)) + .foregroundColor(isDisabled ? TVColors.textSecondary.opacity(0.5) : .accentColor) + .frame(width: 40) + + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 24, weight: .medium)) + .foregroundColor(isDisabled ? TVColors.textSecondary.opacity(0.5) : (isFocused ? .white : TVColors.textPrimary)) + + Text(subtitle) + .font(.system(size: 18)) + .foregroundColor(isDisabled ? TVColors.textSecondary.opacity(0.4) : (isFocused ? .white.opacity(0.8) : TVColors.textSecondary)) + } + + Spacer() + + // Custom toggle for better TV visibility + ZStack { + Capsule() + .fill(isDisabled ? Color.gray.opacity(0.2) : (isOn ? Color.green : Color.gray.opacity(0.3))) + .frame(width: 70, height: 40) + + Circle() + .fill(isDisabled ? Color.gray.opacity(0.5) : Color.white) + .frame(width: 32, height: 32) + .offset(x: isOn ? 15 : -15) + .animation(.easeInOut(duration: 0.2), value: isOn) + } + } + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isFocused && !isDisabled ? Color.accentColor.opacity(0.2) : Color.clear) + ) + } + .buttonStyle(.plain) + .focused($isFocused) + } +} + +/// Non-focusable informational row (for display-only items like Documentation URL, Version) +struct TVSettingsInfoRow: View { + let icon: String + let title: String + let subtitle: String + + var body: some View { + HStack(spacing: 20) { + Image(systemName: icon) + .font(.system(size: 28)) + .foregroundColor(TVColors.textSecondary.opacity(0.6)) + .frame(width: 40) + + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 24, weight: .medium)) + .foregroundColor(TVColors.textSecondary) + + Text(subtitle) + .font(.system(size: 18)) + .foregroundColor(TVColors.textSecondary.opacity(0.7)) + } + + Spacer() + } + .padding(.vertical, 10) + } +} + +/// Reusable button for TV alert dialogs with proper focus styling. +/// Text turns dark when focused to remain readable against the light highlight. +struct TVAlertButton: View { + enum Style { + case outlined + case filled(Color) + } + + let title: String + let style: Style + let isFocused: Bool + let action: () -> Void + var isSemibold: Bool = false + + var body: some View { + Button(action: action) { + Text(title) + .font(.system(size: 24, weight: isSemibold || isFilled ? .semibold : .regular)) + .foregroundColor(isFocused ? .black : .white) + .padding(.horizontal, isFilled ? 50 : 40) + .padding(.vertical, 16) + .background(backgroundView) + } + .buttonStyle(.plain) + } + + private var isFilled: Bool { + if case .filled = style { return true } + return false + } + + @ViewBuilder + private var backgroundView: some View { + switch style { + case .outlined: + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.5), lineWidth: 2) + case .filled(let color): + RoundedRectangle(cornerRadius: 12) + .fill(color) + } + } +} + +struct TVChangeServerAlert: View { + @ObservedObject var viewModel: ViewModel + + private enum FocusedButton { + case cancel, confirm + } + + @FocusState private var focusedButton: FocusedButton? + @State private var lastFocusedButton: FocusedButton = .cancel + + var body: some View { + ZStack { + // Dimmed background + Color.black.opacity(0.7) + .ignoresSafeArea() + + // Alert box + VStack(spacing: 40) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundColor(.orange) + + Text("Change Server?") + .font(.system(size: 40, weight: .bold)) + .foregroundColor(TVColors.textAlert) + + Text("This will disconnect from the current server and erase local configuration.") + .font(.system(size: 24)) + .foregroundColor(TVColors.textAlert) + .multilineTextAlignment(.center) + .frame(maxWidth: 500) + + HStack(spacing: 40) { + // Cancel button + TVAlertButton( + title: "Cancel", + style: .outlined, + isFocused: focusedButton == .cancel, + action: { viewModel.showChangeServerAlert = false } + ) + .focused($focusedButton, equals: .cancel) + + // Confirm button + TVAlertButton( + title: "Confirm", + style: .filled(Color.red), + isFocused: focusedButton == .confirm, + action: { + viewModel.close() + viewModel.clearDetails() + viewModel.showChangeServerAlert = false + viewModel.navigateToServerView = true + } + ) + .focused($focusedButton, equals: .confirm) + } + .focusSection() + } + .padding(60) + .background( + RoundedRectangle(cornerRadius: 30) + .fill(TVColors.bgSideDrawer) + ) + } + .onAppear { + focusedButton = .cancel + } + .onChange(of: focusedButton) { oldValue, newValue in + _ = oldValue // Suppress unused warning + if let newValue = newValue { + lastFocusedButton = newValue + } else { + // Focus escaped - pull it back + focusedButton = lastFocusedButton + } + } + } +} + +struct TVRosenpassChangedAlert: View { + @ObservedObject var viewModel: ViewModel + + private enum FocusedButton { + case later, reconnect + } + + @FocusState private var focusedButton: FocusedButton? + @State private var lastFocusedButton: FocusedButton = .later + + var body: some View { + ZStack { + // Dimmed background + Color.black.opacity(0.7) + .ignoresSafeArea() + + // Alert box + VStack(spacing: 40) { + Image(systemName: "shield.lefthalf.filled") + .font(.system(size: 60)) + .foregroundColor(.blue) + + Text("Reconnect Required") + .font(.system(size: 40, weight: .bold)) + .foregroundColor(TVColors.textAlert) + + Text("Rosenpass settings have changed. Reconnect to apply the new security settings.") + .font(.system(size: 24)) + .foregroundColor(TVColors.textAlert) + .multilineTextAlignment(.center) + .frame(maxWidth: 500) + + HStack(spacing: 40) { + // Later button + TVAlertButton( + title: "Later", + style: .outlined, + isFocused: focusedButton == .later, + action: { viewModel.showRosenpassChangedAlert = false } + ) + .focused($focusedButton, equals: .later) + + // Reconnect button + TVAlertButton( + title: "Reconnect", + style: .filled(Color.blue), + isFocused: focusedButton == .reconnect, + action: { + viewModel.showRosenpassChangedAlert = false + viewModel.close() + // Small delay before reconnecting to allow disconnect to complete + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + viewModel.connect() + } + } + ) + .focused($focusedButton, equals: .reconnect) + } + .focusSection() + } + .padding(60) + .background( + RoundedRectangle(cornerRadius: 30) + .fill(TVColors.bgSideDrawer) + ) + } + .onAppear { + focusedButton = .reconnect + } + .onChange(of: focusedButton) { oldValue, newValue in + _ = oldValue // Suppress unused warning + if let newValue = newValue { + lastFocusedButton = newValue + } else { + // Focus escaped - pull it back + focusedButton = lastFocusedButton + } + } + } +} + +struct TVPreSharedKeyAlert: View { + @ObservedObject var viewModel: ViewModel + @Binding var isPresented: Bool + @State private var keyText: String = "" + @State private var isInvalid: Bool = false + @State private var lastFocusedField: FocusedField = .textField + + private enum FocusedField { + case textField, remove, save, cancel + } + + @FocusState private var focusedField: FocusedField? + + var body: some View { + ZStack { + // Dimmed background + Color.black.opacity(0.7) + .ignoresSafeArea() + + // Alert box + VStack(spacing: 30) { + Image(systemName: "key.fill") + .font(.system(size: 60)) + .foregroundColor(.orange) + + Text("Pre-Shared Key") + .font(.system(size: 40, weight: .bold)) + .foregroundColor(TVColors.textAlert) + + Text("Enter a 32-byte base64-encoded key. You will only communicate with peers that use the same key.") + .font(.system(size: 22)) + .foregroundColor(TVColors.textAlert) + .multilineTextAlignment(.center) + .frame(maxWidth: 600) + + // Text field for key input + TextField("Pre-shared key", text: $keyText) + .textFieldStyle(.plain) + .font(.system(size: 24)) + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isInvalid ? Color.red : Color.white.opacity(0.3), lineWidth: 2) + ) + .frame(maxWidth: 600) + .focused($focusedField, equals: .textField) + .onChange(of: keyText) { _, newValue in + isInvalid = !isValidBase64Key(newValue) + } + + if isInvalid && !keyText.isEmpty { + Text("Invalid key - must be 32-byte base64 encoded") + .font(.system(size: 18)) + .foregroundColor(.red) + } + + HStack(spacing: 30) { + // Cancel button + TVAlertButton( + title: "Cancel", + style: .outlined, + isFocused: focusedField == .cancel, + action: { isPresented = false } + ) + .focused($focusedField, equals: .cancel) + + // Remove button (only if key is configured) + if viewModel.presharedKeySecure { + TVAlertButton( + title: "Remove", + style: .filled(Color.red), + isFocused: focusedField == .remove, + action: { + viewModel.removePreSharedKey() + isPresented = false + } + ) + .focused($focusedField, equals: .remove) + } + + // Save button + TVAlertButton( + title: "Save", + style: .filled((isInvalid || keyText.isEmpty) ? Color.gray : Color.green), + isFocused: focusedField == .save, + action: { + if !isInvalid && !keyText.isEmpty { + viewModel.presharedKey = keyText + viewModel.updatePreSharedKey() + isPresented = false + } + } + ) + .focused($focusedField, equals: .save) + } + .focusSection() + } + .padding(60) + .background( + RoundedRectangle(cornerRadius: 30) + .fill(TVColors.bgSideDrawer) + ) + } + .onAppear { + // Pre-fill with current key if editing + if viewModel.presharedKeySecure { + keyText = viewModel.presharedKey + } + focusedField = .textField + } + .onChange(of: focusedField) { oldValue, newValue in + _ = oldValue // Suppress unused warning + if let newValue = newValue { + lastFocusedField = newValue + } else { + // Focus escaped - pull it back + focusedField = lastFocusedField + } + } + } + + private func isValidBase64Key(_ input: String) -> Bool { + if input.isEmpty { return true } + guard let data = Data(base64Encoded: input) else { return false } + return data.count == 32 + } +} + +struct TVSettingsView_Previews: PreviewProvider { + static var previews: some View { + TVSettingsView() + .environmentObject(ViewModel()) + } +} + +#endif + + diff --git a/NetBirdTVNetworkExtension/Info.plist b/NetBirdTVNetworkExtension/Info.plist new file mode 100644 index 0000000..deb08dd --- /dev/null +++ b/NetBirdTVNetworkExtension/Info.plist @@ -0,0 +1,17 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PacketTunnelProvider + + UIRequiredDeviceCapabilities + + arm64 + + + diff --git a/NetBirdTVNetworkExtension/NetBirdTVNetworkExtension.entitlements b/NetBirdTVNetworkExtension/NetBirdTVNetworkExtension.entitlements new file mode 100644 index 0000000..5cbe940 --- /dev/null +++ b/NetBirdTVNetworkExtension/NetBirdTVNetworkExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.application-groups + + group.io.netbird.app.tv + + + diff --git a/NetBirdTVNetworkExtension/NetBirdTVNetworkExtensionDebug.entitlements b/NetBirdTVNetworkExtension/NetBirdTVNetworkExtensionDebug.entitlements new file mode 100644 index 0000000..46f1038 --- /dev/null +++ b/NetBirdTVNetworkExtension/NetBirdTVNetworkExtensionDebug.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.application-groups + + group.io.netbird.app.tv + + + diff --git a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift new file mode 100644 index 0000000..3765578 --- /dev/null +++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift @@ -0,0 +1,702 @@ +// +// PacketTunnelProvider.swift +// NetBirdTVNetworkExtension +// +// Created by Ashley Mensah on 02.12.25. +// + +import NetworkExtension +import Network +import os +import NetBirdSDK + +private let logger = Logger(subsystem: "io.netbird.app.tv.extension", category: "PacketTunnelProvider") + +// SSO Listener for config initialization +/// Used by initializeConfig to check if SSO is supported and save initial config +class ConfigInitSSOListener: NSObject, NetBirdSDKSSOListenerProtocol { + var onResult: ((Bool?, Error?) -> Void)? + + func onSuccess(_ ssoSupported: Bool) { + onResult?(ssoSupported, nil) + } + + func onError(_ error: Error?) { + onResult?(nil, error) + } +} + +class PacketTunnelProvider: NEPacketTunnelProvider { + + private lazy var tunnelManager: PacketTunnelProviderSettingsManager = { + return PacketTunnelProviderSettingsManager(with: self) + }() + + private lazy var adapter: NetBirdAdapter? = { + return NetBirdAdapter(with: self.tunnelManager) + }() + + var pathMonitor: NWPathMonitor? + let monitorQueue = DispatchQueue(label: "NetworkMonitor") + var currentNetworkType: NWInterface.InterfaceType? + + override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { + // CRITICAL: Log immediately to confirm startTunnel is being called + // Use privacy: .public to avoid log redaction + logger.info(">>> startTunnel: ENTRY - function was called <<<") + NSLog("NetBirdTV: startTunnel ENTRY - function was called") + + let optionsDesc = options?.description ?? "nil" + logger.info("startTunnel: options = \(optionsDesc, privacy: .public)") + + // On tvOS, config is loaded from UserDefaults directly in NetBirdAdapter.init() + // No need to restore to file - the adapter handles this internally. + if Preferences.hasConfigInUserDefaults() { + logger.info("startTunnel: tvOS - config found in UserDefaults, will be loaded by adapter") + } else { + logger.info("startTunnel: tvOS - no config in UserDefaults, login will be required") + } + + currentNetworkType = nil + startMonitoringNetworkChanges() + + guard let adapter = adapter else { + let error = NSError( + domain: "io.netbird.NetBirdTVNetworkExtension", + code: 1003, + userInfo: [NSLocalizedDescriptionKey: "Failed to initialize NetBird adapter."] + ) + completionHandler(error) + return + } + + let needsLogin = adapter.needsLogin() + + if needsLogin { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + let error = NSError( + domain: "io.netbird.NetBirdTVNetworkExtension", + code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Login required."] + ) + completionHandler(error) + } + return + } + + adapter.start { error in + if let error = error { + logger.error("startTunnel: adapter.start() failed: \(error.localizedDescription, privacy: .public)") + completionHandler(error) + } else { + completionHandler(nil) + } + } + } + + override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + adapter?.stop() + if let pathMonitor = self.pathMonitor { + pathMonitor.cancel() + self.pathMonitor = nil + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + completionHandler() + } + } + + override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { + guard let completionHandler = completionHandler, + let string = String(data: messageData, encoding: .utf8) else { + return + } + + // Use privacy: .public to see the actual message in Console.app + logger.info("handleAppMessage: Received message '\(string, privacy: .public)'") + + switch string { + case "InitializeConfig": + // Initialize config with default management URL (tvOS only) + // This must happen in the extension because it has permission to write to App Group + initializeConfig(completionHandler: completionHandler) + case "Login": + // Legacy login (PKCE flow) + login(completionHandler: completionHandler) + case "LoginTV": + // tvOS login with device code flow + logger.info("handleAppMessage: Processing LoginTV - calling loginTV()") + loginTV(completionHandler: completionHandler) + case "IsLoginComplete": + // Check if login has completed (for tvOS polling) + checkLoginComplete(completionHandler: completionHandler) + case "Status": + getStatus(completionHandler: completionHandler) + case "GetRoutes": + getSelectRoutes(completionHandler: completionHandler) + case let s where s.hasPrefix("Select-"): + let id = String(s.dropFirst("Select-".count)) + selectRoute(id: id) + completionHandler("true".data(using: .utf8)) + case let s where s.hasPrefix("Deselect-"): + let id = String(s.dropFirst("Deselect-".count)) + deselectRoute(id: id) + completionHandler("true".data(using: .utf8)) + case let s where s.hasPrefix("SetConfig:"): + // On tvOS, receive config JSON from main app via IPC + // This bypasses the broken shared UserDefaults + let configJSON = String(s.dropFirst("SetConfig:".count)) + setConfigFromMainApp(configJSON: configJSON, completionHandler: completionHandler) + case "ClearConfig": + // Clear the extension-local config on logout + clearLocalConfig(completionHandler: completionHandler) + default: + logger.warning("handleAppMessage: Unknown message: \(string)") + completionHandler(nil) + } + } + + func startMonitoringNetworkChanges() { + let monitor = NWPathMonitor() + monitor.pathUpdateHandler = { [weak self] path in + self?.handleNetworkChange(path: path) + } + monitor.start(queue: monitorQueue) + + pathMonitor = monitor + } + + func handleNetworkChange(path: Network.NWPath) { + guard path.status == .satisfied else { + logger.info("handleNetworkChange: No network connection.") + return + } + + let newNetworkType: NWInterface.InterfaceType? = { + if path.usesInterfaceType(.wifi) { + return .wifi + } else if path.usesInterfaceType(.wiredEthernet) { + return .wiredEthernet + } else { + return nil + } + }() + + guard let networkType = newNetworkType else { + logger.info("handleNetworkChange: Connected to an unsupported network type.") + return + } + + if currentNetworkType != networkType { + logger.info("handleNetworkChange: Network type changed to \(String(describing: networkType)).") + if currentNetworkType != nil { + restartClient() + } + currentNetworkType = networkType + } else { + logger.debug("handleNetworkChange: Network type remains the same: \(String(describing: networkType)).") + } + } + + func restartClient() { + logger.info("restartClient: Restarting client due to network change") + adapter?.stop() + adapter?.start { error in + if let error = error { + logger.error("restartClient: Error restarting client: \(error.localizedDescription)") + } else { + logger.info("restartClient: Client restarted successfully") + } + } + } + + func login(completionHandler: (Data?) -> Void) { + guard let adapter = adapter else { + completionHandler(nil) + return + } + logger.info("login: Starting PKCE login flow") + let urlString = adapter.login() + let data = urlString.data(using: .utf8) + completionHandler(data) + } + + /// Receive config JSON from main app via IPC and save it locally + /// On tvOS, shared UserDefaults doesn't work between app and extension, + /// so we use IPC to transfer the config directly + func setConfigFromMainApp(configJSON: String, completionHandler: ((Data?) -> Void)?) { + logger.info("setConfigFromMainApp: Received config from main app (\(configJSON.count) chars)") + + // Save to extension-local UserDefaults (not shared App Group) + UserDefaults.standard.set(configJSON, forKey: "netbird_config_json_local") + UserDefaults.standard.synchronize() + + // Also try to load into the adapter's client if it exists + if let adapter = adapter { + let deviceName = Device.getName() + let updatedConfig = NetBirdAdapter.updateDeviceNameInConfig(configJSON, newName: deviceName) + do { + try adapter.client.setConfigFromJSON(updatedConfig) + logger.info("setConfigFromMainApp: Loaded config into SDK client successfully") + } catch { + logger.error("setConfigFromMainApp: Failed to load config into SDK client: \(error.localizedDescription)") + } + } else { + logger.warning("setConfigFromMainApp: Adapter not initialized, config saved to UserDefaults only") + } + + let data = "true".data(using: .utf8) + completionHandler?(data) + } + + /// Load config from extension-local storage (used on tvOS where shared UserDefaults fails) + static func loadLocalConfig() -> String? { + return UserDefaults.standard.string(forKey: "netbird_config_json_local") + } + + /// Clear extension-local config on logout + /// Called via IPC from main app when user logs out + func clearLocalConfig(completionHandler: ((Data?) -> Void)?) { + logger.info("clearLocalConfig: Clearing extension-local config") + + // Remove from extension-local UserDefaults + UserDefaults.standard.removeObject(forKey: "netbird_config_json_local") + UserDefaults.standard.synchronize() + + logger.info("clearLocalConfig: Local config cleared") + let data = "true".data(using: .utf8) + completionHandler?(data) + } + + /// Initialize config with management URL for tvOS + /// This must be done in the extension because it has permission to write to the App Group container + func initializeConfig(completionHandler: @escaping (Data?) -> Void) { + guard let configPath = Preferences.configFile() else { + logger.error("initializeConfig: App group container unavailable") + let data = "false".data(using: .utf8) + completionHandler(data) + return + } + let fileManager = FileManager.default + + // Check if config already exists + if fileManager.fileExists(atPath: configPath) { + logger.info("initializeConfig: Config already exists at \(configPath)") + let data = "true".data(using: .utf8) + completionHandler(data) + return + } + + // On tvOS, try to get management URL from UserDefaults config first + var managementURL = NetBirdAdapter.defaultManagementURL + if let configJSON = Preferences.loadConfigFromUserDefaults(), + let storedURL = NetBirdAdapter.extractManagementURL(from: configJSON) { + logger.info("initializeConfig: Using management URL from UserDefaults: \(storedURL, privacy: .public)") + managementURL = storedURL + } else { + logger.info("initializeConfig: No config in UserDefaults, using default management URL") + } + + logger.info("initializeConfig: No config file found, initializing with management URL: \(managementURL, privacy: .public)") + + // Create Auth object with the management URL + guard let auth = NetBirdSDKNewAuth(configPath, managementURL, nil) else { + logger.error("initializeConfig: Failed to create Auth object") + let data = "false".data(using: .utf8) + completionHandler(data) + return + } + + // Use an SSO listener to save the config + let listener = ConfigInitSSOListener() + listener.onResult = { ssoSupported, error in + if let error = error { + logger.error("initializeConfig: Error checking SSO - \(error.localizedDescription)") + let data = "false".data(using: .utf8) + completionHandler(data) + } else if let supported = ssoSupported { + logger.info("initializeConfig: SSO supported = \(supported), config should be saved") + // Verify config was written + let configExists = fileManager.fileExists(atPath: configPath) + logger.info("initializeConfig: Config exists after save = \(configExists)") + let data = configExists ? "true".data(using: .utf8) : "false".data(using: .utf8) + completionHandler(data) + } else { + logger.warning("initializeConfig: Unknown result") + let data = "false".data(using: .utf8) + completionHandler(data) + } + } + + // This will save the config if SSO is supported + auth.saveConfigIfSSOSupported(listener) + } + + /// Initialize config synchronously during startTunnel + /// On tvOS, config is loaded from UserDefaults directly in NetBirdAdapter.init() + /// This function is kept for compatibility but is mostly a no-op on tvOS. + private func initializeConfigIfNeeded() { + // On tvOS, config loading is handled by NetBirdAdapter.init() + // which reads from UserDefaults and calls setConfigFromJSON() + // Nothing to do here. + logger.info("initializeConfigIfNeeded: tvOS - config loading handled by adapter init") + } + + /// Check if login has completed (for tvOS polling during device auth flow) + /// Returns diagnostic info: "result|isExecuting|loginRequired|configExists|stateExists|lastResult|lastError" + func checkLoginComplete(completionHandler: (Data?) -> Void) { + guard let adapter = adapter else { + logger.error("checkLoginComplete: Adapter not initialized") + let diagnostic = LoginDiagnostics( + isComplete: false, + isExecuting: false, + loginRequired: true, + configExists: false, + stateExists: false, + lastResult: "error", + lastError: "adapter_not_initialized" + ) + do { + let data = try PropertyListEncoder().encode(diagnostic) + completionHandler(data) + } catch { + logger.error("checkLoginComplete: Failed to encode error diagnostic - \(error)") + completionHandler(nil) + } + return + } + + // Check if login is still in progress + let isExecutingLogin = adapter.isExecutingLogin + + // Note: client.isLoginComplete() only works with the legacy LoginForMobile() method. + // For the new Auth.Login() with device auth flow, we need to check lastLoginResult instead. + let sdkLoginComplete = adapter.client.isLoginComplete() + + // Also check loginRequired for comparison (may be stale if Client was created before config) + let loginRequired = adapter.needsLogin() + + // Also check if config file exists now (written after successful auth) + let configPath = Preferences.configFile() ?? "" + let statePath = Preferences.stateFile() ?? "" + let fileManager = FileManager.default + let configExists = !configPath.isEmpty && fileManager.fileExists(atPath: configPath) + let stateExists = !statePath.isEmpty && fileManager.fileExists(atPath: statePath) + + // Get the last login result and error + let lastResult = adapter.lastLoginResult + let lastError = adapter.lastLoginError + + logger.info("checkLoginComplete: isExecutingLogin=\(isExecutingLogin), sdkLoginComplete=\(sdkLoginComplete), loginRequired=\(loginRequired), configExists=\(configExists), stateExists=\(stateExists), lastResult=\(lastResult), lastError=\(lastError)") + + // IMPORTANT: client.isLoginComplete() does NOT work with Auth.Login() / loginAsync() + // because Auth is a separate struct that doesn't have access to Client.loginComplete. + // Instead, use lastLoginResult which IS set by loginAsync() when auth succeeds. + let isComplete = (lastResult == "success") + + let diagnostic = LoginDiagnostics( + isComplete: isComplete, + isExecuting: isExecutingLogin, + loginRequired: loginRequired, + configExists: configExists, + stateExists: stateExists, + lastResult: lastResult, + lastError: lastError + ) + + do { + let data = try PropertyListEncoder().encode(diagnostic) + logger.info("checkLoginComplete: returning diagnostic with isComplete=\(isComplete)") + completionHandler(data) + } catch { + logger.error("checkLoginComplete: Failed to encode diagnostic - \(error)") + completionHandler(nil) + } + } + + /// Login with device code flow for tvOS + /// Returns "url|userCode" format so the app can display both + /// The app is responsible for starting the VPN after login completes + func loginTV(completionHandler: @escaping (Data?) -> Void) { + logger.info("loginTV: Starting device code authentication flow") + + // Log which management URL will be used (from UserDefaults or default) + if let configJSON = Preferences.loadConfigFromUserDefaults(), + let storedURL = NetBirdAdapter.extractManagementURL(from: configJSON) { + logger.info("loginTV: Will use management URL from UserDefaults: \(storedURL, privacy: .public)") + } else { + logger.info("loginTV: No config in UserDefaults, will use default management URL: \(NetBirdAdapter.defaultManagementURL, privacy: .public)") + } + + // Initialize config - mostly a no-op on tvOS since adapter handles it + initializeConfigIfNeeded() + + // Track if we've already sent the URL to the app + var urlSentToApp = false + let urlSentLock = NSLock() + + guard let adapter = adapter else { + logger.error("loginTV: Adapter not initialized") + completionHandler(nil) + return + } + + logger.info("loginTV: Calling adapter.loginAsync with forceDeviceAuth=true") + + adapter.loginAsync( + forceDeviceAuth: true, + onURL: { url, userCode in + logger.info("loginTV: onURL callback triggered!") + logger.info("loginTV: Received URL and userCode, sending to app") + logger.info("loginTV: URL=\(url, privacy: .public), userCode=\(userCode, privacy: .public)") + + urlSentLock.lock() + urlSentToApp = true + urlSentLock.unlock() + + let authResponse = DeviceAuthResponse(url: url, userCode: userCode) + do { + let data = try PropertyListEncoder().encode(authResponse) + completionHandler(data) + } catch { + logger.error("loginTV: Failed to encode DeviceAuthResponse - \(error)") + completionHandler(nil) + } + }, + onSuccess: { + // Login completed - the app will detect this via polling + // and start the VPN tunnel via startVPNConnection() + logger.info("loginTV: Login completed successfully!") + logger.info("loginTV: Config should now be saved to App Group container") + + // Debug: Verify config file was written + let configPath = Preferences.configFile() ?? "" + let statePath = Preferences.stateFile() ?? "" + let fileManager = FileManager.default + logger.info("loginTV: configFile exists = \(!configPath.isEmpty && fileManager.fileExists(atPath: configPath))") + logger.info("loginTV: stateFile exists = \(!statePath.isEmpty && fileManager.fileExists(atPath: statePath))") + }, + onError: { error in + // Log with privacy: .public to avoid iOS privacy redaction + if let nsError = error as NSError? { + logger.error("loginTV: Login failed - domain: \(nsError.domain, privacy: .public), code: \(nsError.code, privacy: .public), description: \(nsError.localizedDescription, privacy: .public)") + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? Error { + logger.error("loginTV: Underlying error: \(String(describing: underlyingError), privacy: .public)") + } + } else { + logger.error("loginTV: Login failed: \(error?.localizedDescription ?? "unknown error", privacy: .public)") + } + + // Only call completion with nil if we never sent the URL + // If URL was sent, the error just means the user didn't complete auth yet + // (e.g., device code expired) - but we already returned control to the app + urlSentLock.lock() + let alreadySentUrl = urlSentToApp + urlSentLock.unlock() + + if !alreadySentUrl { + logger.error("loginTV: Error before URL was sent, returning nil to app") + completionHandler(nil) + } else { + logger.warning("loginTV: Error after URL was sent (device code may have expired), app is still polling") + } + } + ) + } + + func getStatus(completionHandler: (Data?) -> Void) { + guard let adapter = adapter else { + completionHandler(nil) + return + } + guard let statusDetailsMessage = adapter.client.getStatusDetails() else { + logger.warning("getStatus: Did not receive status details.") + completionHandler(nil) + return + } + + var peerInfoArray: [PeerInfo] = [] + for i in 0.. Void) { + guard let adapter = adapter else { + completionHandler(nil) + return + } + do { + let routeSelectionDetailsMessage = try adapter.client.getRoutesSelectionDetails() + + let routeSelectionInfo: [RoutesSelectionInfo] = (0.. DomainDetails? in + guard let domain = route.domains?.get(domainIndex) else { return nil } + return DomainDetails(domain: domain.domain, resolvedips: domain.resolvedIPs) + } + + return RoutesSelectionInfo( + name: route.id_, + network: route.network, + domains: domains, + selected: route.selected + ) + } + + let routeSelectionDetails = RoutesSelectionDetails( + all: routeSelectionDetailsMessage.all, + append: routeSelectionDetailsMessage.append, + routeSelectionInfo: routeSelectionInfo + ) + + let data = try PropertyListEncoder().encode(routeSelectionDetails) + completionHandler(data) + } catch { + logger.error("getSelectRoutes: Error retrieving or encoding route selection details: \(error.localizedDescription)") + let defaultStatus = RoutesSelectionDetails(all: false, append: false, routeSelectionInfo: []) + do { + let data = try PropertyListEncoder().encode(defaultStatus) + completionHandler(data) + } catch { + logger.error("getSelectRoutes: Failed to encode default route selection details: \(error.localizedDescription)") + completionHandler(nil) + } + } + } + + func selectRoute(id: String) { + guard let adapter = adapter else { return } + do { + try adapter.client.selectRoute(id) + logger.info("selectRoute: Selected route \(id)") + } catch { + logger.error("selectRoute: Failed to select route: \(error.localizedDescription)") + } + } + + func deselectRoute(id: String) { + guard let adapter = adapter else { return } + do { + try adapter.client.deselectRoute(id) + logger.info("deselectRoute: Deselected route \(id)") + } catch { + logger.error("deselectRoute: Failed to deselect route: \(error.localizedDescription)") + } + } + + override func sleep(completionHandler: @escaping () -> Void) { + completionHandler() + } + + override func wake() { + } + + func setTunnelSettings(tunnelNetworkSettings: NEPacketTunnelNetworkSettings) { + setTunnelNetworkSettings(tunnelNetworkSettings) { error in + if let error = error { + logger.error("setTunnelSettings: Error assigning routes: \(error.localizedDescription)") + return + } + logger.info("setTunnelSettings: Routes set successfully.") + } + } +} + +func initializeLogging(loglevel: String) { + let fileManager = FileManager.default + + let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: GlobalConstants.userPreferencesSuiteName) + let logURL = groupURL?.appendingPathComponent("logfile.log") + + var error: NSError? + var success = false + + let logMessage = "Starting new log file from TV extension" + "\n" + + guard let logURLValid = logURL else { + print("Failed to get the log file URL.") + return + } + + if fileManager.fileExists(atPath: logURLValid.path) { + if let fileHandle = try? FileHandle(forWritingTo: logURLValid) { + do { + try "".write(to: logURLValid, atomically: true, encoding: .utf8) + } catch { + print("Error handling the log file: \(error)") + } + if let data = logMessage.data(using: .utf8) { + fileHandle.write(data) + } + fileHandle.closeFile() + } else { + print("Failed to open the log file for writing.") + } + } else { + do { + try logMessage.write(to: logURLValid, atomically: true, encoding: .utf8) + } catch { + print("Failed to write to the log file: \(error.localizedDescription)") + } + } + + if let logPath = logURL?.path { + success = NetBirdSDKInitializeLog(loglevel, logPath, &error) + } + if !success, let actualError = error { + print("Failed to initialize log: \(actualError.localizedDescription)") + } +} \ No newline at end of file diff --git a/NetbirdKit/ConfigurationProvider.swift b/NetbirdKit/ConfigurationProvider.swift new file mode 100644 index 0000000..dbe3290 --- /dev/null +++ b/NetbirdKit/ConfigurationProvider.swift @@ -0,0 +1,231 @@ +// +// ConfigurationProvider.swift +// NetBird +// +// Protocol abstraction for platform-specific configuration management. +// iOS uses SDK file-based preferences, tvOS uses IPC-based config transfer. +// + +import Foundation +import NetBirdSDK + +// MARK: - Protocol Definition + +/// Abstracts platform-specific configuration storage and retrieval. +/// - iOS: Uses NetBirdSDKPreferences with file-based storage in App Group container +/// - tvOS: Uses UserDefaults + IPC transfer (App Group files don't work between app/extension) +protocol ConfigurationProvider { + // MARK: - Rosenpass Settings + + /// Whether Rosenpass (post-quantum encryption) is enabled + var rosenpassEnabled: Bool { get set } + + /// Whether Rosenpass permissive mode is enabled (allows non-Rosenpass peers) + var rosenpassPermissive: Bool { get set } + + // MARK: - Pre-Shared Key + + /// The current pre-shared key (empty string if not set) + var preSharedKey: String { get set } + + /// Whether a pre-shared key is configured + var hasPreSharedKey: Bool { get } + + // MARK: - Lifecycle + + /// Commits any pending changes to persistent storage + /// Returns true on success, false on failure + @discardableResult + func commit() -> Bool + + /// Reloads settings from persistent storage + func reload() +} + +// MARK: - iOS Implementation + +#if os(iOS) +/// iOS implementation using NetBirdSDKPreferences (file-based storage) +final class iOSConfigurationProvider: ConfigurationProvider { + + private var preferences: NetBirdSDKPreferences + + init() { + self.preferences = Preferences.newPreferences() + } + + // MARK: - Rosenpass + + var rosenpassEnabled: Bool { + get { + var result = ObjCBool(false) + do { + try preferences.getRosenpassEnabled(&result) + } catch { + print("ConfigurationProvider: Failed to read rosenpassEnabled - \(error)") + } + return result.boolValue + } + set { + preferences.setRosenpassEnabled(newValue) + } + } + + var rosenpassPermissive: Bool { + get { + var result = ObjCBool(false) + do { + try preferences.getRosenpassPermissive(&result) + } catch { + print("ConfigurationProvider: Failed to read rosenpassPermissive - \(error)") + } + return result.boolValue + } + set { + preferences.setRosenpassPermissive(newValue) + } + } + + // MARK: - Pre-Shared Key + + var preSharedKey: String { + get { + return preferences.getPreSharedKey(nil) + } + set { + preferences.setPreSharedKey(newValue) + } + } + + var hasPreSharedKey: Bool { + return !preSharedKey.isEmpty + } + + // MARK: - Lifecycle + + @discardableResult + func commit() -> Bool { + do { + try preferences.commit() + return true + } catch { + print("ConfigurationProvider: Failed to commit - \(error)") + return false + } + } + + func reload() { + // Recreate preferences to pick up new config file after server change + self.preferences = Preferences.newPreferences() + } +} +#endif + +// MARK: - tvOS Implementation + +#if os(tvOS) +/// tvOS implementation that reads/writes settings directly to the config JSON. +/// This mirrors iOS behavior where all settings live in one config file. +/// The config JSON is stored in UserDefaults and sent to the extension via IPC. +final class tvOSConfigurationProvider: ConfigurationProvider { + + init() {} + + // MARK: - Rosenpass + + var rosenpassEnabled: Bool { + get { extractJSONBool(field: "RosenpassEnabled") ?? false } + set { updateJSONField(field: "RosenpassEnabled", value: newValue) } + } + + var rosenpassPermissive: Bool { + get { extractJSONBool(field: "RosenpassPermissive") ?? false } + set { updateJSONField(field: "RosenpassPermissive", value: newValue) } + } + + // MARK: - Pre-Shared Key + + var preSharedKey: String { + get { extractJSONString(field: "PreSharedKey") ?? "" } + set { updateJSONField(field: "PreSharedKey", value: newValue) } + } + + var hasPreSharedKey: Bool { + return !preSharedKey.isEmpty + } + + // MARK: - Lifecycle + + @discardableResult + func commit() -> Bool { + // Settings are written directly to config JSON, no separate commit needed + return true + } + + func reload() { + // Config JSON is always read fresh from UserDefaults + } + + // MARK: - JSON Helpers (read/write to stored config) + + private func getConfigJSON() -> String? { + return Preferences.loadConfigFromUserDefaults() + } + + private func saveConfigJSON(_ json: String) { + _ = Preferences.saveConfigToUserDefaults(json) + } + + private func parseConfigDict() -> [String: Any]? { + guard let json = getConfigJSON(), + let data = json.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return dict + } + + private func extractJSONBool(field: String) -> Bool? { + return parseConfigDict()?[field] as? Bool + } + + private func extractJSONString(field: String) -> String? { + return parseConfigDict()?[field] as? String + } + + private func updateJSONField(field: String, value: T) { + guard var dict = parseConfigDict() else { + AppLogger.shared.log("ConfigurationProvider: No config JSON available for updating '\(field)'") + return + } + + guard dict[field] != nil else { + AppLogger.shared.log("ConfigurationProvider: Field '\(field)' not found in config JSON") + return + } + + dict[field] = value + + guard let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) else { + AppLogger.shared.log("ConfigurationProvider: Failed to serialize config JSON") + return + } + + saveConfigJSON(json) + } +} +#endif + +// MARK: - Factory + +/// Factory for creating the appropriate ConfigurationProvider for the current platform +enum ConfigurationProviderFactory { + static func create() -> ConfigurationProvider { + #if os(iOS) + return iOSConfigurationProvider() + #else + return tvOSConfigurationProvider() + #endif + } +} diff --git a/NetbirdKit/ConnectionListener.swift b/NetbirdKit/ConnectionListener.swift index 440ddfe..5740ede 100644 --- a/NetbirdKit/ConnectionListener.swift +++ b/NetbirdKit/ConnectionListener.swift @@ -6,6 +6,7 @@ // import Foundation +import NetBirdSDK class ConnectionListener: NSObject, NetBirdSDKConnectionListenerProtocol { diff --git a/NetbirdKit/DNSManager.swift b/NetbirdKit/DNSManager.swift index 6ca9ccf..d200db6 100644 --- a/NetbirdKit/DNSManager.swift +++ b/NetbirdKit/DNSManager.swift @@ -6,6 +6,7 @@ // import Foundation +import NetBirdSDK struct DomainConfig: Codable { var disabled: Bool diff --git a/NetbirdKit/Device.swift b/NetbirdKit/Device.swift index 863222f..9e595f6 100644 --- a/NetbirdKit/Device.swift +++ b/NetbirdKit/Device.swift @@ -9,14 +9,46 @@ import UIKit class Device { static func getName() -> String { + #if os(tvOS) + return generateTVDeviceName() + #else return UIDevice.current.name + #endif } - + static func getOsVersion() -> String { return UIDevice.current.systemVersion } - + static func getOsName() -> String { return UIDevice.current.systemName } + + #if os(tvOS) + /// Generate a unique device name for tvOS + /// The name is persisted so it remains consistent across app launches + private static func generateTVDeviceName() -> String { + let key = "netbird_device_name" + let appGroup = GlobalConstants.userPreferencesSuiteName + + // Return cached name if it exists + if let defaults = UserDefaults(suiteName: appGroup), + let cachedName = defaults.string(forKey: key), !cachedName.isEmpty { + return cachedName + } + + // Generate random 6-character alphanumeric string + let characters = "abcdefghijklmnopqrstuvwxyz0123456789" + let randomString = String((0..<6).map { _ in characters.randomElement()! }) + let name = "apple-tv-\(randomString)" + + // Cache the name for future use + if let defaults = UserDefaults(suiteName: appGroup) { + defaults.set(name, forKey: key) + defaults.synchronize() + } + + return name + } + #endif } diff --git a/NetbirdKit/EnvVarPackager.swift b/NetbirdKit/EnvVarPackager.swift index 6635f58..817d6e2 100644 --- a/NetbirdKit/EnvVarPackager.swift +++ b/NetbirdKit/EnvVarPackager.swift @@ -5,13 +5,23 @@ // Created by Diego Romar on 03/12/25. // +import Foundation +import NetBirdSDK + class EnvVarPackager { static func getEnvironmentVariables(defaults: UserDefaults) -> NetBirdSDKEnvList? { guard let envList = NetBirdSDKEnvList() else { return nil } - defaults.register(defaults: [GlobalConstants.keyForceRelayConnection: true]) + #if os(iOS) + let defaultForceRelay = true + #else + // Forced relay battery optimization not needed on Apple TV + let defaultForceRelay = false + #endif + + defaults.register(defaults: [GlobalConstants.keyForceRelayConnection: defaultForceRelay]) let forceRelayConnection = defaults.bool(forKey: GlobalConstants.keyForceRelayConnection) envList.put(NetBirdSDKGetEnvKeyNBForceRelay(), value: String(forceRelayConnection)) diff --git a/NetbirdKit/GlobalConstants.swift b/NetbirdKit/GlobalConstants.swift index 37da200..ba7cdeb 100644 --- a/NetbirdKit/GlobalConstants.swift +++ b/NetbirdKit/GlobalConstants.swift @@ -6,12 +6,16 @@ // struct GlobalConstants { + #if os(tvOS) + static let userPreferencesSuiteName = "group.io.netbird.app.tv" + #else static let userPreferencesSuiteName = "group.io.netbird.app" - + #endif + static let keyForceRelayConnection = "isConnectionForceRelayed" static let keyLoginRequired = "netbird.loginRequired" static let keyNetworkUnavailable = "netbird.networkUnavailable" - + static let configFileName = "netbird.cfg" static let stateFileName = "state.json" } diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index c87f662..e844c93 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -8,21 +8,50 @@ import Foundation import NetworkExtension import SwiftUI +import Combine +import NetBirdSDK +import os + +// SSO Listener for config initialization +/// Used to check if SSO is supported and save initial config +class ConfigSSOListener: NSObject, NetBirdSDKSSOListenerProtocol { + var onResult: ((Bool?, Error?) -> Void)? + + func onSuccess(_ ssoSupported: Bool) { + onResult?(ssoSupported, nil) + } + + func onError(_ error: Error?) { + onResult?(nil, error) + } +} public class NetworkExtensionAdapter: ObservableObject { - var session : NETunnelProviderSession? + private let logger = Logger(subsystem: "io.netbird.app", category: "NetworkExtensionAdapter") + + #if os(tvOS) + static let defaultManagementURL = "https://api.netbird.io" + #endif + + var session: NETunnelProviderSession? var vpnManager: NETunnelProviderManager? + #if os(tvOS) + var extensionID = "io.netbird.app.tv.extension" + var extensionName = "NetBird" + #else var extensionID = "io.netbird.app.NetbirdNetworkExtension" var extensionName = "NetBird Network Extension" + #endif let decoder = PropertyListDecoder() - @Published var timer : Timer + @Published var timer: Timer @Published var showBrowser = false - @Published var loginURL : String? + @Published var loginURL: String? + @Published var userCode: String? private let fetchLock = NSLock() private var _isFetchingStatus = false @@ -30,31 +59,32 @@ public class NetworkExtensionAdapter: ObservableObject { get { fetchLock.lock(); defer { fetchLock.unlock() }; return _isFetchingStatus } set { fetchLock.lock(); defer { fetchLock.unlock() }; _isFetchingStatus = newValue } } - + init() { self.timer = Timer() self.timer.invalidate() - Task { - do { - try await self.configureManager() - } catch { - print("Failed to configure manager") - } - } + // Don't configure manager during init - it's a slow system call that blocks app startup. + // Instead, configureManager is called lazily when needed (start(), stop(), etc.) + // This allows the UI to appear immediately on first launch. } deinit { self.timer.invalidate() } + @MainActor func start() async { + logger.info("start: ENTRY - beginning VPN start sequence") do { + logger.info("start: calling configureManager()...") try await configureManager() - print("extension configured") + logger.info("start: configureManager() completed, calling loginIfRequired()...") await loginIfRequired() + logger.info("start: loginIfRequired() completed") } catch { - print("Failed to start extension: \(error)") + logger.error("start: CAUGHT ERROR - \(error.localizedDescription)") } + logger.info("start: EXIT") } private func configureManager() async throws { @@ -88,23 +118,152 @@ public class NetworkExtensionAdapter: ObservableObject { public func loginIfRequired() async { - if self.isLoginRequired() { - print("require login") - + logger.info("loginIfRequired: starting...") + + #if os(tvOS) + // On tvOS, try to initialize config from the main app first. + // This is needed because the Network Extension may not have write access + // to the App Group container on tvOS. + logger.info("loginIfRequired: tvOS - calling initializeConfigFromApp()") + await initializeConfigFromApp() + #endif + + let needsLogin = self.isLoginRequired() + logger.info("loginIfRequired: isLoginRequired() returned \(needsLogin)") + + if needsLogin { + logger.info("loginIfRequired: login required, calling performLogin()") + // Note: For tvOS, config initialization happens in the extension's startTunnel + // before the needsLogin check. The extension has permission to write to App Group. await performLogin() } else { + logger.info("loginIfRequired: login NOT required, calling startVPNConnection()") startVPNConnection() } - print("will start vpn connection") + logger.info("loginIfRequired: done") } + + #if os(tvOS) + /// Try to initialize the config file from the main app. + /// On tvOS, shared UserDefaults doesn't work, so we send config via IPC. + /// Settings (Rosenpass, PreSharedKey) are already stored in the config JSON. + private func initializeConfigFromApp() async { + // Check if config exists in main app's UserDefaults + // Note: Shared UserDefaults doesn't work on tvOS between app and extension, + // but we can still use it to store config in the main app + if let configJSON = Preferences.loadConfigFromUserDefaults(), !configJSON.isEmpty { + logger.info("initializeConfigFromApp: Config exists in UserDefaults, sending to extension via IPC") + + // Send config to extension via IPC (settings are already in the JSON) + await sendConfigToExtensionAsync(configJSON) + return + } + + guard let configPath = Preferences.configFile() else { + logger.error("initializeConfigFromApp: App group container unavailable") + return + } + let fileManager = FileManager.default + + // Check if config already exists as a file (unlikely on tvOS but check anyway) + if fileManager.fileExists(atPath: configPath) { + logger.info("initializeConfigFromApp: Config already exists at \(configPath)") + return + } + + logger.info("initializeConfigFromApp: No config found, user needs to configure server first") + // Don't automatically create config with default URL - user should go through ServerView + } + + /// Async wrapper for sendConfigToExtension + private func sendConfigToExtensionAsync(_ configJSON: String) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + sendConfigToExtension(configJSON) { _ in + continuation.resume() + } + } + } + #endif public func isLoginRequired() -> Bool { - guard let client = NetBirdSDKNewClient(Preferences.configFile(), Preferences.stateFile(), Device.getName(), Device.getOsVersion(), Device.getOsName(), nil, nil) else { - print("Failed to initialize client") + guard let configPath = Preferences.configFile(), let statePath = Preferences.stateFile() else { + logger.error("isLoginRequired: App group container unavailable - assuming login required") return true } - return client.isLoginRequired() + logger.info("isLoginRequired: checking config at \(configPath), state at \(statePath)") + + // Debug: Check if files exist and their sizes + let fileManager = FileManager.default + let configExists = fileManager.fileExists(atPath: configPath) + let stateExists = fileManager.fileExists(atPath: statePath) + logger.info("isLoginRequired: configFile exists = \(configExists), stateFile exists = \(stateExists)") + + #if os(tvOS) + // On tvOS, the app doesn't have permission to write to App Group container. + // File writes are blocked, so we check UserDefaults instead. + // Config is saved to UserDefaults after successful login. + let hasConfigInUserDefaults = Preferences.hasConfigInUserDefaults() + logger.info("isLoginRequired: tvOS - hasConfigInUserDefaults = \(hasConfigInUserDefaults)") + + if !hasConfigInUserDefaults { + // No config in UserDefaults - user definitely needs to login + logger.info("isLoginRequired: tvOS - no config in UserDefaults, login required") + return true + } + + // Config exists - but we need to verify with the management server + // that the session is still valid (tokens can expire) + logger.info("isLoginRequired: tvOS - config found, checking with management server...") + + // Create a Client and load config from UserDefaults + guard let client = NetBirdSDKNewClient("", "", Device.getName(), Device.getOsVersion(), Device.getOsName(), nil, nil) else { + logger.error("isLoginRequired: tvOS - failed to create SDK client") + return true + } + + // Load the config from UserDefaults into the client + if let configJSON = Preferences.loadConfigFromUserDefaults() { + do { + try client.setConfigFromJSON(configJSON) + logger.info("isLoginRequired: tvOS - loaded config from UserDefaults into client") + } catch { + logger.error("isLoginRequired: tvOS - failed to load config: \(error.localizedDescription)") + return true + } + } else { + logger.error("isLoginRequired: tvOS - no config JSON in UserDefaults") + return true + } + + // Now check with the management server + let result = client.isLoginRequired() + logger.info("isLoginRequired: tvOS - SDK returned \(result)") + return result + #else + if configExists { + if let attrs = try? fileManager.attributesOfItem(atPath: configPath), + let size = attrs[.size] as? Int64 { + logger.debug("isLoginRequired: configFile size = \(size) bytes") + } + } + + if stateExists { + if let attrs = try? fileManager.attributesOfItem(atPath: statePath), + let size = attrs[.size] as? Int64 { + logger.debug("isLoginRequired: stateFile size = \(size) bytes") + } + } + + guard let client = NetBirdSDKNewClient(configPath, statePath, Device.getName(), Device.getOsVersion(), Device.getOsName(), nil, nil) else { + logger.debug("isLoginRequired: Failed to initialize client") + return true + } + + let result = client.isLoginRequired() + print("isLoginRequired: SDK returned \(result)") + return result + #endif } class ObserverBox { @@ -114,26 +273,31 @@ public class NetworkExtensionAdapter: ObservableObject { private func performLogin() async { let loginURLString = await withCheckedContinuation { continuation in self.login { urlString in - print("urlstring: \(urlString)") continuation.resume(returning: urlString) } } - + self.loginURL = loginURLString self.showBrowser = true } public func startVPNConnection() { - print("starting tunnel") + logger.info("startVPNConnection: called") let logLevel = UserDefaults.standard.string(forKey: "logLevel") ?? "INFO" - print("Loglevel: " + logLevel) + logger.info("startVPNConnection: logLevel = \(logLevel)") let options: [String: NSObject] = ["logLevel": logLevel as NSObject] - + + guard let session = self.session else { + logger.error("startVPNConnection: ERROR - session is nil!") + return + } + + logger.info("startVPNConnection: session exists, calling startVPNTunnel...") do { - try self.session?.startVPNTunnel(options: options) - print("VPN Tunnel started.") + try session.startVPNTunnel(options: options) + logger.info("startVPNConnection: startVPNTunnel() returned successfully") } catch let error { - print("Failed to start VPN tunnel: \(error)") + logger.error("startVPNConnection: ERROR - startVPNTunnel failed: \(error.localizedDescription)") } } @@ -141,23 +305,46 @@ public class NetworkExtensionAdapter: ObservableObject { func stop() -> Void { self.vpnManager?.connection.stopVPNTunnel() } - + func login(completion: @escaping (String) -> Void) { if self.session == nil { - print("No session available for login") + logger.error("login: No session available for login") return } do { + // Use LoginTV for tvOS to force device auth flow + #if os(tvOS) + let messageString = "LoginTV" + #else let messageString = "Login" + #endif + if let messageData = messageString.data(using: .utf8) { // Send the message to the network extension try self.session!.sendProviderMessage(messageData) { response in if let response = response { + #if os(tvOS) + // For tvOS, decode DeviceAuthResponse struct + do { + let authResponse = try self.decoder.decode(DeviceAuthResponse.self, from: response) + DispatchQueue.main.async { + self.userCode = authResponse.userCode + } + completion(authResponse.url) + } catch { + print("login: Failed to decode DeviceAuthResponse - \(error)") + // Fallback to plain string for backwards compatibility + if let string = String(data: response, encoding: .utf8) { + completion(string) + } + } + #else if let string = String(data: response, encoding: .utf8) { completion(string) - return } + #endif + return } } } else { @@ -168,6 +355,92 @@ public class NetworkExtensionAdapter: ObservableObject { } } + /// Check if login is complete by asking the Network Extension directly + /// This is more reliable than isLoginRequired() because it queries the same SDK client + /// that is actually performing the login + func checkLoginComplete(completion: @escaping (Bool) -> Void) { + guard let session = self.session else { + logger.error("checkLoginComplete: No session available") + completion(false) + return + } + + let messageString = "IsLoginComplete" + guard let messageData = messageString.data(using: .utf8) else { + print("checkLoginComplete: Failed to encode message") + completion(false) + return + } + + do { + try session.sendProviderMessage(messageData) { response in + if let response = response { + do { + let diagnostic = try self.decoder.decode(LoginDiagnostics.self, from: response) + print("checkLoginComplete: result=\(diagnostic.isComplete), isExecuting=\(diagnostic.isExecuting), loginRequired=\(diagnostic.loginRequired), configExists=\(diagnostic.configExists), stateExists=\(diagnostic.stateExists), lastResult=\(diagnostic.lastResult), lastError=\(diagnostic.lastError)") + completion(diagnostic.isComplete) + } catch { + print("checkLoginComplete: Failed to decode LoginDiagnostics - \(error)") + completion(false) + } + } else { + print("checkLoginComplete: No response from extension") + completion(false) + } + } + } catch { + print("checkLoginComplete: Failed to send message - \(error)") + completion(false) + } + } + + /// Check if there's a login error from the extension + /// Returns the error message via completion handler, or nil if no error + func checkLoginError(completion: @escaping (String?) -> Void) { + guard let session = self.session else { + completion(nil) + return + } + + let messageString = "IsLoginComplete" + guard let messageData = messageString.data(using: .utf8) else { + completion(nil) + return + } + + do { + try session.sendProviderMessage(messageData) { response in + if let response = response { + do { + let diagnostic = try self.decoder.decode(LoginDiagnostics.self, from: response) + // Only report error if lastResult is "error" and there's an actual error message + if diagnostic.lastResult == "error" && !diagnostic.lastError.isEmpty { + // Make the error message more user-friendly + var friendlyError = diagnostic.lastError + if diagnostic.lastError.contains("no peer auth method provided") { + friendlyError = "This server doesn't support device code authentication. Please use a setup key instead." + } else if diagnostic.lastError.contains("expired") || diagnostic.lastError.contains("token") { + friendlyError = "The device code has expired. Please try again." + } else if diagnostic.lastError.contains("denied") || diagnostic.lastError.contains("rejected") { + friendlyError = "Authentication was denied. Please try again." + } + completion(friendlyError) + return + } + completion(nil) + } catch { + print("checkLoginError: Failed to decode LoginDiagnostics - \(error)") + completion(nil) + } + } else { + completion(nil) + } + } + } catch { + completion(nil) + } + } + func getRoutes(completion: @escaping (RoutesSelectionDetails) -> Void) { guard let session = self.session else { let defaultStatus = RoutesSelectionDetails(all: false, append: false, routeSelectionInfo: []) @@ -200,12 +473,12 @@ public class NetworkExtensionAdapter: ObservableObject { print("Error converting message to Data") } } - + func selectRoutes(id: String, completion: @escaping (RoutesSelectionDetails) -> Void) { guard let session = self.session else { return } - + let messageString = "Select-\(id)" if let messageData = messageString.data(using: .utf8) { do { @@ -220,12 +493,12 @@ public class NetworkExtensionAdapter: ObservableObject { print("Error converting message to Data") } } - + func deselectRoutes(id: String, completion: @escaping (RoutesSelectionDetails) -> Void) { guard let session = self.session else { return } - + let messageString = "Deselect-\(id)" if let messageData = messageString.data(using: .utf8) { do { @@ -320,6 +593,77 @@ public class NetworkExtensionAdapter: ObservableObject { self.timer.invalidate() } + #if os(tvOS) + /// Send config JSON to the Network Extension via IPC + /// On tvOS, shared UserDefaults doesn't work between app and extension, + /// so we transfer config directly via IPC + func sendConfigToExtension(_ configJSON: String, completion: ((Bool) -> Void)? = nil) { + guard let session = self.session else { + logger.warning("sendConfigToExtension: No session available") + completion?(false) + return + } + + let messageString = "SetConfig:\(configJSON)" + guard let messageData = messageString.data(using: .utf8) else { + logger.error("sendConfigToExtension: Failed to convert message to Data") + completion?(false) + return + } + + do { + try session.sendProviderMessage(messageData) { response in + if let response = response, + let responseString = String(data: response, encoding: .utf8), + responseString == "true" { + self.logger.info("sendConfigToExtension: Config sent successfully") + completion?(true) + } else { + self.logger.warning("sendConfigToExtension: Extension did not confirm receipt") + completion?(false) + } + } + } catch { + logger.error("sendConfigToExtension: Failed to send message: \(error.localizedDescription)") + completion?(false) + } + } + + /// Clear extension-local config on logout + /// This ensures the extension doesn't have stale credentials after logout + func clearExtensionConfig(completion: ((Bool) -> Void)? = nil) { + guard let session = self.session else { + logger.warning("clearExtensionConfig: No session available") + completion?(false) + return + } + + let messageString = "ClearConfig" + guard let messageData = messageString.data(using: .utf8) else { + logger.error("clearExtensionConfig: Failed to convert message to Data") + completion?(false) + return + } + + do { + try session.sendProviderMessage(messageData) { response in + if let response = response, + let responseString = String(data: response, encoding: .utf8), + responseString == "true" { + self.logger.info("clearExtensionConfig: Extension config cleared successfully") + completion?(true) + } else { + self.logger.warning("clearExtensionConfig: Extension did not confirm clearing") + completion?(false) + } + } + } catch { + logger.error("clearExtensionConfig: Failed to send message: \(error.localizedDescription)") + completion?(false) + } + } + #endif + func getExtensionStatus(completion: @escaping (NEVPNStatus) -> Void) { Task { do { diff --git a/NetbirdKit/Preferences.swift b/NetbirdKit/Preferences.swift index 6e68805..57f2668 100644 --- a/NetbirdKit/Preferences.swift +++ b/NetbirdKit/Preferences.swift @@ -8,33 +8,134 @@ import Foundation import NetBirdSDK +/// Preferences manages configuration file paths and SDK preferences. +/// +/// ## Platform Differences +/// +/// ### iOS +/// Uses file-based storage via App Group shared container. The main app and extension +/// can both read/write files to this shared location. +/// +/// ### tvOS +/// The App Group shared container does NOT work for IPC between the main app and +/// Network Extension due to sandbox restrictions. Config is transferred via IPC +/// (`sendProviderMessage`/`handleAppMessage`) instead. The SDK preferences are not +/// used on tvOS - settings are managed directly in the extension. +/// +/// See NetworkExtensionAdapter and PacketTunnelProvider for tvOS config flow details. class Preferences { + + // MARK: - SDK Preferences + + #if os(iOS) + /// Creates SDK preferences using App Group shared container paths. + /// iOS only - file-based storage works reliably. static func newPreferences() -> NetBirdSDKPreferences { - guard let prefs = NetBirdSDKNewPreferences(configFile(), stateFile()) else { + guard let configPath = configFile(), let statePath = stateFile() else { + preconditionFailure("App group container unavailable - check entitlements for '\(GlobalConstants.userPreferencesSuiteName)'") + } + guard let preferences = NetBirdSDKNewPreferences(configPath, statePath) else { preconditionFailure("Failed to create NetBirdSDKPreferences") } - return prefs + return preferences + } + #else + /// tvOS does not use SDK preferences - config is transferred via IPC. + /// Returns nil by design; callers must handle this case. + static func newPreferences() -> NetBirdSDKPreferences? { + // tvOS uses IPC-based config transfer, not file-based SDK preferences. + // The extension manages its own config via UserDefaults.standard after + // receiving it through handleAppMessage. + return nil } + #endif - static func getFilePath(fileName: String) -> String { + // MARK: - File Paths + + /// Returns the file path for a given filename in the App Group container. + /// Returns nil if the container is unavailable. + static func getFilePath(fileName: String) -> String? { let fileManager = FileManager.default if let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: GlobalConstants.userPreferencesSuiteName) { - return groupURL.appendingPathComponent(fileName).relativePath + return groupURL.appendingPathComponent(fileName).path } - - // Fallback for testing or when app group is not available - // (prefer non-user-visible dir) + + #if DEBUG + // Fallback for testing when app group is not available let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first return (baseURL ?? fileManager.temporaryDirectory).appendingPathComponent(fileName).path + #else + AppLogger.shared.log("ERROR: App group '\(GlobalConstants.userPreferencesSuiteName)' not available. Check entitlements.") + return nil + #endif } - - static func configFile() -> String { + + static func configFile() -> String? { return getFilePath(fileName: GlobalConstants.configFileName) } - - static func stateFile() -> String { + + static func stateFile() -> String? { return getFilePath(fileName: GlobalConstants.stateFileName) } - + + // MARK: - App-Local UserDefaults Storage + // + // These methods store config in the App Group UserDefaults for the MAIN APP's + // own use (e.g., displaying current server URL). On tvOS, this data is NOT + // shared with the extension - it's app-local only. + + private static let configJSONKey = "netbird_config_json" + + /// Get the App Group UserDefaults. + /// Note: On tvOS, this is app-local only - NOT shared with extension. + static func sharedUserDefaults() -> UserDefaults? { + return UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) + } + + /// Save config JSON to UserDefaults (app-local storage). + static func saveConfigToUserDefaults(_ configJSON: String) -> Bool { + guard let defaults = sharedUserDefaults() else { + return false + } + defaults.set(configJSON, forKey: configJSONKey) + defaults.synchronize() + return true + } + + /// Load config JSON from UserDefaults (app-local storage). + static func loadConfigFromUserDefaults() -> String? { + return sharedUserDefaults()?.string(forKey: configJSONKey) + } + + /// Check if config exists in UserDefaults. + static func hasConfigInUserDefaults() -> Bool { + return sharedUserDefaults()?.string(forKey: configJSONKey) != nil + } + + /// Remove config from UserDefaults (for logout). + static func removeConfigFromUserDefaults() { + guard let defaults = sharedUserDefaults() else { + return + } + defaults.removeObject(forKey: configJSONKey) + defaults.synchronize() + } + + /// Restore config from UserDefaults to the config file path. + /// iOS only - needed because the Go SDK reads from the file path. + #if os(iOS) + static func restoreConfigFromUserDefaults() -> Bool { + guard let configJSON = loadConfigFromUserDefaults(), + let path = configFile() else { + return false + } + do { + try configJSON.write(toFile: path, atomically: false, encoding: .utf8) + return true + } catch { + return false + } + } + #endif } diff --git a/NetbirdKit/RoutesSelectionDetails.swift b/NetbirdKit/RoutesSelectionDetails.swift index 1fd5b87..142aeea 100644 --- a/NetbirdKit/RoutesSelectionDetails.swift +++ b/NetbirdKit/RoutesSelectionDetails.swift @@ -1,3 +1,26 @@ +// +// RoutesSelectionDetails.swift +// NetBird +// + +import Foundation +import Combine + +struct LoginDiagnostics: Codable { + var isComplete: Bool + var isExecuting: Bool + var loginRequired: Bool + var configExists: Bool + var stateExists: Bool + var lastResult: String + var lastError: String +} + +struct DeviceAuthResponse: Codable { + var url: String + var userCode: String +} + struct RoutesSelectionDetails: Codable { var all: Bool var append: Bool diff --git a/NetbirdKit/StatusDetails.swift b/NetbirdKit/StatusDetails.swift index 56f4d6f..126bcfb 100644 --- a/NetbirdKit/StatusDetails.swift +++ b/NetbirdKit/StatusDetails.swift @@ -6,6 +6,7 @@ // import Foundation +import Combine struct StatusDetails: Codable { var ip: String diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index 78f72d5..f63ca4a 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -10,22 +10,81 @@ import NetworkExtension import NetBirdSDK import os +/// Logger for NetBirdAdapter - visible in Console.app +private let adapterLogger = Logger(subsystem: "io.netbird.adapter", category: "NetBirdAdapter") + +// URL Opener for Login Flow +class LoginURLOpener: NSObject, NetBirdSDKURLOpenerProtocol { + var onOpen: ((String, String) -> Void)? + var onSuccess: (() -> Void)? + + func open(_ url: String?, userCode: String?) { + guard let url = url else { return } + onOpen?(url, userCode ?? "") + } + + func onLoginSuccess() { + onSuccess?() + } +} + +// Error Listener for Async Operations +class LoginErrListener: NSObject, NetBirdSDKErrListenerProtocol { + var onErrorCallback: ((Error?) -> Void)? + var onSuccessCallback: (() -> Void)? + + func onError(_ err: Error?) { + onErrorCallback?(err) + } + + func onSuccess() { + onSuccessCallback?() + } +} + +// SSO Listener for Config Save +class LoginConfigSaveListener: NSObject, NetBirdSDKSSOListenerProtocol { + var onResult: ((Bool?, Error?) -> Void)? + + func onSuccess(_ ssoSupported: Bool) { + onResult?(ssoSupported, nil) + } + + func onError(_ error: Error?) { + onResult?(nil, error) + } +} + public class NetBirdAdapter { - + + #if os(tvOS) + /// Default management URL for tvOS (public NetBird server) + static let defaultManagementURL = "https://api.netbird.io" + #endif + /// Packet tunnel provider. private weak var packetTunnelProvider: PacketTunnelProvider? - + private weak var tunnelManager: PacketTunnelProviderSettingsManager? - + public let client : NetBirdSDKClient private let networkChangeListener : NetworkChangeListener private let dnsManager: DNSManager - + public var isExecutingLogin = false + /// Tracks the result of the last login attempt for debugging + public var lastLoginResult: String = "none" + public var lastLoginError: String = "" + + /// Stores the login URL opener for the duration of the login flow + private var loginURLOpener: LoginURLOpener? + /// Stores the error listener for the duration of the login flow + private var loginErrListener: LoginErrListener? + private let stateLock = NSLock() private var _clientState: ClientState = .disconnected - + var clientState: ClientState { get { stateLock.lock(); defer { stateLock.unlock() }; return _clientState } set { stateLock.lock(); defer { stateLock.unlock() }; _clientState = newValue } @@ -33,7 +92,7 @@ public class NetBirdAdapter { private let isRestartingLock = NSLock() private var _isRestarting: Bool = false - + /// 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. @@ -41,11 +100,15 @@ public class NetBirdAdapter { get { isRestartingLock.lock(); defer { isRestartingLock.unlock() }; return _isRestarting } set { isRestartingLock.lock(); defer { isRestartingLock.unlock() }; _isRestarting = newValue } } - + private let stopLock = NSLock() - + /// Tunnel device file descriptor. + /// On iOS: searches for the utun control socket file descriptor by iterating through + /// file descriptors and matching against the Apple utun control interface. + /// On tvOS: uses manually defined structures since the SDK doesn't expose them. public var tunnelFileDescriptor: Int32? { + #if os(iOS) var ctlInfo = ctl_info() withUnsafeMutablePointer(to: &ctlInfo.ctl_name) { $0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) { @@ -71,11 +134,104 @@ public class NetBirdAdapter { } } if addr.sc_id == ctlInfo.ctl_id { + adapterLogger.info("tunnelFileDescriptor: Found utun FD = \(fd)") + return fd + } + } + adapterLogger.warning("tunnelFileDescriptor: Could not find utun file descriptor") + return nil + #elseif os(tvOS) + // tvOS SDK doesn't expose ctl_info, sockaddr_ctl, CTLIOCGINFO in headers + // but the kernel structures exist at runtime. Use raw syscalls. + return findTunnelFileDescriptorTvOS() + #else + return nil + #endif + } + + #if os(tvOS) + /// Find the tunnel file descriptor on tvOS using raw syscalls. + /// The tvOS SDK doesn't expose ctl_info/sockaddr_ctl in headers, but they exist at runtime. + private func findTunnelFileDescriptorTvOS() -> Int32? { + // Constants from sys/kern_control.h (not in tvOS SDK but exist in kernel) + let AF_SYSTEM: UInt8 = 32 + // Note: AF_SYS_CONTROL, SYSPROTO_CONTROL, UTUN_OPT_IFNAME are documented here + // but used as literals (2) in getsockopt calls below for clarity + // CTLIOCGINFO = _IOWR('N', 3, struct ctl_info) = 0xC0644E03 + let CTLIOCGINFO: UInt = 0xC0644E03 + + // Structure sizes and offsets based on Darwin kernel headers + // struct ctl_info { u_int32_t ctl_id; char ctl_name[96]; } + let ctlInfoSize = 100 // 4 + 96 bytes + // struct sockaddr_ctl { u_char sc_len; u_char sc_family; u_int16_t ss_sysaddr; u_int32_t sc_id; u_int32_t sc_unit; u_int32_t sc_reserved[5]; } + let sockaddrCtlSize = 32 + + // Allocate ctl_info structure + let ctlInfo = UnsafeMutableRawPointer.allocate(byteCount: ctlInfoSize, alignment: 4) + defer { ctlInfo.deallocate() } + memset(ctlInfo, 0, ctlInfoSize) + + // Set ctl_name to "com.apple.net.utun_control" at offset 4 + let ctlName = "com.apple.net.utun_control" + _ = ctlName.withCString { cstr in + memcpy(ctlInfo.advanced(by: 4), cstr, strlen(cstr) + 1) + } + + // Allocate sockaddr_ctl structure + let sockaddrCtl = UnsafeMutableRawPointer.allocate(byteCount: sockaddrCtlSize, alignment: 4) + defer { sockaddrCtl.deallocate() } + + var ctlIdFound: UInt32 = 0 + + for fd: Int32 in 0...1024 { + memset(sockaddrCtl, 0, sockaddrCtlSize) + var len = socklen_t(sockaddrCtlSize) + + // Call getpeername to get the socket address + let ret = getpeername(fd, sockaddrCtl.assumingMemoryBound(to: sockaddr.self), &len) + if ret != 0 { + continue + } + + // Check sc_family at offset 1 (sc_len is at 0) + let scFamily = sockaddrCtl.load(fromByteOffset: 1, as: UInt8.self) + if scFamily != AF_SYSTEM { + continue + } + + // Log AF_SYSTEM sockets found + let scLen = sockaddrCtl.load(fromByteOffset: 0, as: UInt8.self) + let ssSysaddr = sockaddrCtl.load(fromByteOffset: 2, as: UInt16.self) + let scIdVal = sockaddrCtl.load(fromByteOffset: 4, as: UInt32.self) + let scUnit = sockaddrCtl.load(fromByteOffset: 8, as: UInt32.self) + adapterLogger.info("findTunnelFileDescriptorTvOS: fd=\(fd) is AF_SYSTEM socket: len=\(scLen) sysaddr=\(ssSysaddr) sc_id=\(scIdVal) sc_unit=\(scUnit)") + + // Get ctl_id if we don't have it yet + if ctlIdFound == 0 { + let ioctlRet = ioctl(fd, CTLIOCGINFO, ctlInfo) + if ioctlRet == 0 { + // ctl_id is at offset 0 + ctlIdFound = ctlInfo.load(fromByteOffset: 0, as: UInt32.self) + adapterLogger.info("findTunnelFileDescriptorTvOS: Got ctl_id = \(ctlIdFound) from fd \(fd)") + } + } + + if ctlIdFound == 0 { + continue + } + + // Check sc_id at offset 4 (after sc_len[1], sc_family[1], ss_sysaddr[2]) + let scId = sockaddrCtl.load(fromByteOffset: 4, as: UInt32.self) + if scId == ctlIdFound { + adapterLogger.info("findTunnelFileDescriptorTvOS: Found utun FD = \(fd)") return fd } } + + adapterLogger.warning("findTunnelFileDescriptorTvOS: Could not find utun file descriptor") return nil } + #endif private var stopCompletionHandler: (() -> Void)? @@ -84,11 +240,52 @@ public class NetBirdAdapter { /// Designated initializer. /// - Parameter packetTunnelProvider: an instance of `NEPacketTunnelProvider`. Internally stored /// as a weak reference. - init(with tunnelManager: PacketTunnelProviderSettingsManager) { + /// - Returns: nil if the NetBird SDK client could not be initialized. + init?(with tunnelManager: PacketTunnelProviderSettingsManager) { self.tunnelManager = tunnelManager self.networkChangeListener = NetworkChangeListener(with: tunnelManager) self.dnsManager = DNSManager(with: tunnelManager) - self.client = NetBirdSDKNewClient(Preferences.configFile(), Preferences.stateFile(), Device.getName(), Device.getOsVersion(), Device.getOsName(), self.networkChangeListener, self.dnsManager)! + + let deviceName = Device.getName() + let osVersion = Device.getOsVersion() + let osName = Device.getOsName() + + #if os(tvOS) + // On tvOS, the filesystem is blocked for the App Group container. + // Create the client with empty paths and load config from local storage instead. + guard let client = NetBirdSDKNewClient("", "", deviceName, osVersion, osName, self.networkChangeListener, self.dnsManager) else { + adapterLogger.error("init: tvOS - Failed to create NetBird SDK client") + return nil + } + self.client = client + + // Load config from extension-local storage (set via IPC from main app) + // Note: Shared App Group UserDefaults does NOT work on tvOS between app and extension + // due to sandbox restrictions. Config must be transferred via IPC. + let configJSON: String? = UserDefaults.standard.string(forKey: "netbird_config_json_local") + + if let configJSON = configJSON { + let updatedConfig = Self.updateDeviceNameInConfig(configJSON, newName: deviceName) + do { + try self.client.setConfigFromJSON(updatedConfig) + adapterLogger.info("init: tvOS - loaded config successfully") + } catch { + adapterLogger.error("init: tvOS - failed to load config: \(error.localizedDescription)") + } + } else { + adapterLogger.info("init: tvOS - no config found, client initialized without config") + } + #else + guard let configPath = Preferences.configFile(), let statePath = Preferences.stateFile() else { + adapterLogger.error("init: App group container unavailable - check entitlements") + return nil + } + guard let client = NetBirdSDKNewClient(configPath, statePath, deviceName, osVersion, osName, self.networkChangeListener, self.dnsManager) else { + adapterLogger.error("init: Failed to create NetBird SDK client with configPath=\(configPath), statePath=\(statePath)") + return nil + } + self.client = client + #endif } /// Returns the tunnel device interface name, or nil on error. @@ -118,16 +315,27 @@ public class NetBirdAdapter { } public func start(completionHandler: @escaping (Error?) -> Void) { - // Export env vars here. DispatchQueue.global().async { do { + guard let fd = self.tunnelFileDescriptor, fd > 0 else { + adapterLogger.error("start: Invalid tunnel file descriptor (nil or 0) - cannot start VPN") + completionHandler(NSError( + domain: "io.netbird.NetbirdNetworkExtension", + code: 1004, + userInfo: [NSLocalizedDescriptionKey: "Invalid tunnel file descriptor. The VPN tunnel may not be properly configured."] + )) + return + } + let ifName = self.interfaceName ?? "unknown" + let connectionListener = ConnectionListener(adapter: self, completionHandler: completionHandler) self.client.setConnectionListener(connectionListener) - - let envList = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName).flatMap { EnvVarPackager.getEnvironmentVariables(defaults: $0) + + let envList = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName).flatMap { + EnvVarPackager.getEnvironmentVariables(defaults: $0) } - - try self.client.run(self.tunnelFileDescriptor ?? 0, interfaceName: self.interfaceName, envList: envList) + + try self.client.run(fd, interfaceName: ifName, envList: envList) } catch { completionHandler(NSError(domain: "io.netbird.NetbirdNetworkExtension", code: 1001, userInfo: [NSLocalizedDescriptionKey: "Netbird client startup failed."])) self.stop() @@ -138,15 +346,165 @@ public class NetBirdAdapter { public func needsLogin() -> Bool { return self.client.isLoginRequired() } - + + /// Legacy synchronous login - returns URL string directly + /// Used by iOS which opens Safari public func login() -> String { self.isExecutingLogin = true return self.client.loginForMobile() } - + + /// New async login with device flow support + /// - Parameters: + /// - forceDeviceAuth: If true, forces device code flow (for tvOS/Apple TV) + /// - onURL: Called when the auth URL is ready (includes user code for device flow) + /// - onSuccess: Called when login completes successfully + /// - onError: Called if login fails + public func loginAsync( + forceDeviceAuth: Bool, + onURL: @escaping (String, String) -> Void, + onSuccess: @escaping () -> Void, + onError: @escaping (Error?) -> Void + ) { + self.isExecutingLogin = true + + // Track completion to prevent duplicate callbacks + var completionCalled = false + let completionLock = NSLock() + + // Keep a reference to the auth object so we can save config after login + var authRef: NetBirdSDKAuth? + + let handleSuccess: () -> Void = { [weak self] in + completionLock.lock() + guard !completionCalled else { + completionLock.unlock() + return + } + completionCalled = true + completionLock.unlock() + + // After successful login, save the config to persist credentials + if let auth = authRef { + var getConfigError: NSError? + var configJSON = auth.getConfigJSON(&getConfigError) + if getConfigError == nil && !configJSON.isEmpty { + #if os(tvOS) + let correctDeviceName = Device.getName() + configJSON = Self.updateDeviceNameInConfig(configJSON, newName: correctDeviceName) + #endif + + _ = Preferences.saveConfigToUserDefaults(configJSON) + } + + // Also try the file-based save (may fail on tvOS but works on iOS) + let saveListener = LoginConfigSaveListener() + auth.saveConfigIfSSOSupported(saveListener) + } + + self?.lastLoginResult = "success" + self?.lastLoginError = "" + self?.isExecutingLogin = false + self?.loginURLOpener = nil + self?.loginErrListener = nil + authRef = nil + onSuccess() + } + + let handleError: (Error?) -> Void = { [weak self] error in + completionLock.lock() + guard !completionCalled else { + completionLock.unlock() + return + } + completionCalled = true + completionLock.unlock() + + self?.lastLoginResult = "error" + self?.lastLoginError = error?.localizedDescription ?? "unknown" + self?.isExecutingLogin = false + self?.loginURLOpener = nil + self?.loginErrListener = nil + onError(error) + } + + // Create URL opener + let urlOpener = LoginURLOpener() + urlOpener.onOpen = { url, userCode in + DispatchQueue.main.async { + onURL(url, userCode) + } + } + urlOpener.onSuccess = { + DispatchQueue.main.async { + handleSuccess() + } + } + + // Create error listener + let errListener = LoginErrListener() + errListener.onSuccessCallback = { + DispatchQueue.main.async { + handleSuccess() + } + } + errListener.onErrorCallback = { error in + DispatchQueue.main.async { + handleError(error) + } + } + + // Keep strong references during login + self.loginURLOpener = urlOpener + self.loginErrListener = errListener + + // Use default management URL for tvOS, empty for iOS (which handles it via ServerView) + #if os(tvOS) + // On tvOS, config is stored in extension-local UserDefaults (transferred via IPC from main app). + // Note: Shared App Group UserDefaults does NOT work on tvOS due to sandbox restrictions. + var managementURL = Self.defaultManagementURL + + let configJSON: String? = UserDefaults.standard.string(forKey: "netbird_config_json_local") + + if let configJSON = configJSON, + let storedURL = Self.extractManagementURL(from: configJSON) { + adapterLogger.info("loginAsync: Using management URL from config: \(storedURL, privacy: .public)") + managementURL = storedURL + } else { + adapterLogger.info("loginAsync: No config found, using default management URL") + } + #else + let managementURL = "" + #endif + + // Get Auth object and call login + #if os(tvOS) + // On tvOS, config is stored in UserDefaults (not files) due to sandbox restrictions. + // Pass empty path - the SDK will use setConfigFromJSON() instead. + let configPath = "" + #else + guard let configPath = Preferences.configFile() else { + handleError(NSError(domain: "io.netbird", code: 1003, userInfo: [NSLocalizedDescriptionKey: "App group container unavailable"])) + return + } + #endif + if let auth = NetBirdSDKNewAuth(configPath, managementURL, nil) { + authRef = auth + + #if os(tvOS) + let deviceName = Device.getName() + auth.login(withDeviceName: errListener, urlOpener: urlOpener, forceDeviceAuth: forceDeviceAuth, deviceName: deviceName) + #else + auth.login(errListener, urlOpener: urlOpener, forceDeviceAuth: forceDeviceAuth) + #endif + } else { + handleError(NSError(domain: "io.netbird", code: 1002, userInfo: [NSLocalizedDescriptionKey: "Failed to create Auth object"])) + } + } + public func stop(completionHandler: (() -> Void)? = nil) { stopLock.lock() - + // Call any pending handler before setting a new one if let existingHandler = self.stopCompletionHandler { self.stopCompletionHandler = nil @@ -159,28 +517,64 @@ public class NetBirdAdapter { stopLock.lock() self.stopCompletionHandler = completionHandler stopLock.unlock() - + 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() + self?.notifyStopCompleted() } } } - + func notifyStopCompleted() { stopLock.lock() - + guard let handler = self.stopCompletionHandler else { stopLock.unlock() return } - + self.stopCompletionHandler = nil stopLock.unlock() handler() } + + // MARK: - Config Helpers + + /// Extract the management URL from a config JSON string + /// Returns nil if not found or empty + static func extractManagementURL(from configJSON: String) -> String? { + // Look for "ManagementURL":"..." pattern + let pattern = "\"ManagementURL\"\\s*:\\s*\"([^\"]*)\"" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []), + let match = regex.firstMatch(in: configJSON, options: [], range: NSRange(configJSON.startIndex..., in: configJSON)), + let urlRange = Range(match.range(at: 1), in: configJSON) else { + return nil + } + let url = String(configJSON[urlRange]) + return url.isEmpty ? nil : url + } + + /// Update the device name in a config JSON string + static func updateDeviceNameInConfig(_ configJSON: String, newName: String) -> String { + // Escape special characters for JSON string + let escapedName = newName + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + + let pattern = "\"DeviceName\"\\s*:\\s*\"[^\"]*\"" + let replacement = "\"DeviceName\":\"\(escapedName)\"" + + if let regex = try? NSRegularExpression(pattern: pattern, options: []) { + let range = NSRange(configJSON.startIndex..., in: configJSON) + return regex.stringByReplacingMatches(in: configJSON, options: [], range: range, withTemplate: replacement) + } + + return configJSON + } } diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index f7aeeec..92cf195 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -17,7 +17,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return PacketTunnelProviderSettingsManager(with: self) }() - private lazy var adapter: NetBirdAdapter = { + private lazy var adapter: NetBirdAdapter? = { return NetBirdAdapter(with: self.tunnelManager) }() @@ -43,6 +43,16 @@ class PacketTunnelProvider: NEPacketTunnelProvider { self?.startMonitoringNetworkChanges() } + guard let adapter = adapter else { + let error = NSError( + domain: "io.netbird.NetbirdNetworkExtension", + code: 1003, + userInfo: [NSLocalizedDescriptionKey: "Failed to initialize NetBird adapter."] + ) + completionHandler(error) + return + } + if adapter.needsLogin() { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { let error = NSError( @@ -66,7 +76,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { self?.wasStoppedDueToNoNetwork = false self?.isRestartInProgress = false } - adapter.stop() + adapter?.stop() guard let pathMonitor = self.pathMonitor else { AppLogger.shared.log("pathMonitor is nil; nothing to cancel.") DispatchQueue.main.asyncAfter(deadline: .now() + 2) { @@ -97,11 +107,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider { case let s where s.hasPrefix("Select-"): let id = String(s.dropFirst("Select-".count)) selectRoute(id: id) + completionHandler("true".data(using: .utf8)) case let s where s.hasPrefix("Deselect-"): let id = String(s.dropFirst("Deselect-".count)) deselectRoute(id: id) + completionHandler("true".data(using: .utf8)) default: AppLogger.shared.log("Unknown message: \(string)") + completionHandler(nil) } } @@ -136,7 +149,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // 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)") + let stateDesc = adapter?.clientState.description ?? "unknown" + AppLogger.shared.log("Network unavailable - signaling UI for disconnecting animation, clientState=\(stateDesc)") wasStoppedDueToNoNetwork = true setNetworkUnavailableFlag(true) } @@ -193,6 +207,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func restartClient() { + guard let adapter = adapter else { + AppLogger.shared.log("restartClient: adapter is nil") + return + } + if isRestartInProgress { AppLogger.shared.log("restartClient: skipping - restart already in progress") return @@ -200,27 +219,27 @@ class PacketTunnelProvider: NEPacketTunnelProvider { AppLogger.shared.log("restartClient: starting restart sequence") isRestartInProgress = true adapter.isRestarting = true - + // Timeout after 30 seconds to reset flags if restart hangs let timeoutWorkItem = DispatchWorkItem { [weak self] in guard let self = self, self.isRestartInProgress else { return } AppLogger.shared.log("restartClient: timeout - resetting flags") - self.adapter.isRestarting = false + self.adapter?.isRestarting = false self.isRestartInProgress = false } monitorQueue.asyncAfter(deadline: .now() + 30, execute: timeoutWorkItem) - + adapter.stop { [weak self] in AppLogger.shared.log("restartClient: stop completed, starting client") - self?.adapter.start { error in + self?.adapter?.start { error in // Cancel timeout whether start succeeds or not timeoutWorkItem.cancel() - + self?.monitorQueue.async { - self?.adapter.isRestarting = false + self?.adapter?.isRestarting = false self?.isRestartInProgress = false } - + if let error = error { AppLogger.shared.log("restartClient: start failed - \(error.localizedDescription)") } else { @@ -280,12 +299,20 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func login(completionHandler: (Data?) -> Void) { + guard let adapter = adapter else { + completionHandler(nil) + return + } let urlString = adapter.login() let data = urlString.data(using: .utf8) completionHandler(data) } func getStatus(completionHandler: (Data?) -> Void) { + guard let adapter = adapter else { + completionHandler(nil) + return + } guard let statusDetailsMessage = adapter.client.getStatusDetails() else { AppLogger.shared.log("Did not receive status details.") completionHandler(nil) @@ -326,10 +353,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider { peerInfoArray.append(peerInfo) } + let clientState = adapter.clientState let statusDetails = StatusDetails( ip: statusDetailsMessage.getIP(), fqdn: statusDetailsMessage.getFQDN(), - managementStatus: adapter.clientState, + managementStatus: clientState, peerInfo: peerInfoArray ) @@ -339,7 +367,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } catch { AppLogger.shared.log("Failed to encode status details: \(error.localizedDescription)") do { - let defaultStatus = StatusDetails(ip: "", fqdn: "", managementStatus: adapter.clientState, peerInfo: []) + let defaultStatus = StatusDetails(ip: "", fqdn: "", managementStatus: clientState, peerInfo: []) let data = try PropertyListEncoder().encode(defaultStatus) completionHandler(data) } catch { @@ -350,6 +378,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func getSelectRoutes(completionHandler: (Data?) -> Void) { + guard let adapter = adapter else { + completionHandler(nil) + return + } do { let routeSelectionDetailsMessage = try adapter.client.getRoutesSelectionDetails() @@ -391,6 +423,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func selectRoute(id: String) { + guard let adapter = adapter else { return } do { try adapter.client.selectRoute(id) } catch { @@ -399,6 +432,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func deselectRoute(id: String) { + guard let adapter = adapter else { return } do { try adapter.client.deselectRoute(id) } catch { diff --git a/README.md b/README.md index 146df06..1378d92 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@
-# NetBird iOS Client +# NetBird iOS & tvOS Client -The NetBird iOS client allows connections from mobile devices running iOS 14.0+ to private resources in the NetBird network. +The NetBird iOS/tvOS client allows connections from mobile devices running iOS 14.0+ and Apple TV running tvOS 17.0+ to private resources in the NetBird network. ## Install You can download and install the app from the App Store: @@ -60,10 +60,21 @@ The code is divided into 4 parts: ## Requirements -- iOS 14.0+ +- iOS 14.0+ / tvOS 17.0+ - Xcode 16.1+ - Go 1.24+ -- gomobile +- gomobile-netbird (NetBird's fork with tvOS support) + +### gomobile-netbird + +This project requires `gomobile-netbird`, NetBird's fork of gomobile that adds tvOS support. See: https://github.com/netbirdio/gomobile-tvos-fork + +To install: + +```bash +go install github.com/netbirdio/gomobile-tvos-fork/cmd/gomobile-netbird@latest +gomobile-netbird init +``` ## Run locally @@ -75,19 +86,28 @@ git clone https://github.com/netbirdio/ios-client.git cd ios-client ``` -Install gomobile if you haven't already: +Build the XCFramework from the main netbird repo using the build script: ```bash -go install golang.org/x/mobile/cmd/gomobile@latest +./build-go-lib.sh ../netbird ``` -Build the xcframework from the main netbird repo using the build script: +Or manually with gomobile-netbird: ```bash -./build-go-lib.sh ../netbird +cd netbird +gomobile-netbird bind -target=ios,iossimulator,tvos,tvossimulator -bundleid=io.netbird.framework -o ../ios-client/NetBirdSDK.xcframework ./client/ios/NetBirdSDK ``` +This builds a single universal XCFramework that supports iOS, iOS Simulator, tvOS, and tvOS Simulator. + Open the Xcode project, and we are ready to go. -> **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. +### Running on iOS Device + +> **Note:** The app cannot 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. + +### Running on Apple TV + +> **Note:** The app cannot run in the tvOS simulator. To test the app, a physical device running tvOS 17.0 or later needs to be [paired with Xcode](https://support.apple.com/en-us/101262). ### Firebase Configuration (Optional) @@ -100,3 +120,4 @@ NetBird project is composed of multiple repositories: - Dashboard: https://github.com/netbirdio/dashboard, contains the Administration UI for the management service - Documentations: https://github.com/netbirdio/docs, contains the documentation from https://netbird.io/docs - Android Client: https://github.com/netbirdio/android-client +- iOS/tvOS Client: https://github.com/netbirdio/ios-client (this repository) diff --git a/build-go-lib.sh b/build-go-lib.sh index 32aaa32..7951e23 100755 --- a/build-go-lib.sh +++ b/build-go-lib.sh @@ -15,7 +15,8 @@ then 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/NetBird/NetBirdSDK.xcframework $netbirdPath/client/ios/NetBirdSDK + +gomobile-netbird init +CGO_ENABLED=0 gomobile-netbird bind -target=ios,iossimulator,tvos,tvossimulator -bundleid=io.netbird.framework -ldflags="-X github.com/netbirdio/netbird/version.version=$version" -o $rn_app_path/NetBirdSDK.xcframework $netbirdPath/client/ios/NetBirdSDK cd -