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 -