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..2e00335
--- /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,11 @@
+{
+ "images" : [
+ {
+ "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/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..2e00335
--- /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,11 @@
+{
+ "images" : [
+ {
+ "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/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..bf734b8
--- /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,17 @@
+{
+ "images" : [
+ {
+ "filename" : "netbird-tvos-icon.png",
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "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..6f8b693
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/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..bf734b8
--- /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,17 @@
+{
+ "images" : [
+ {
+ "filename" : "netbird-tvos-icon.png",
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "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..6f8b693
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/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..bf734b8
--- /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,17 @@
+{
+ "images" : [
+ {
+ "filename" : "netbird-tvos-icon.png",
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "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..6f8b693
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/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..795cce1
--- /dev/null
+++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "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/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..795cce1
--- /dev/null
+++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "tv",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
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-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 226252c..d154541 100644
--- a/NetBird.xcodeproj/project.pbxproj
+++ b/NetBird.xcodeproj/project.pbxproj
@@ -3,10 +3,58 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 56;
+ objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
+ 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 */; };
+ 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 */; };
+ 44TVCOLORS12345678901234 /* TVColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44TVCOLORSF12345678901234 /* TVColors.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 */; };
@@ -84,23 +132,28 @@
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 */; };
50E608242A79966600BAF09B /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608232A79966600BAF09B /* AboutView.swift */; };
50E608262A79968500BAF09B /* AdvancedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608252A79968500BAF09B /* AdvancedView.swift */; };
+ F1258DE22ED4EE5000C0D205 /* ServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE12ED4EE4900C0D205 /* ServerViewModel.swift */; };
+ F1258DEA2ED7B7D600C0D205 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE92ED7B7D200C0D205 /* Extensions.swift */; };
+ F1B292052EDE5610001D91B8 /* JustifiedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292042EDE5608001D91B8 /* JustifiedText.swift */; };
F1B292072EE0AC2A001D91B8 /* EnvVarPackager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */; };
F1B292082EE0AC2A001D91B8 /* EnvVarPackager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */; };
F1B2920A2EE0BC46001D91B8 /* GlobalConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */; };
F1B2920B2EE0BC46001D91B8 /* GlobalConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */; };
- F1258DE22ED4EE5000C0D205 /* ServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE12ED4EE4900C0D205 /* ServerViewModel.swift */; };
- F1258DEA2ED7B7D600C0D205 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE92ED7B7D200C0D205 /* Extensions.swift */; };
- F1B292052EDE5610001D91B8 /* JustifiedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292042EDE5608001D91B8 /* JustifiedText.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
+ 441C5B042EDF0DD20055EEFC /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 50A8910F2A792A15007C48FC /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 441C5AFC2EDF0DD20055EEFC;
+ remoteInfo = NetBirdTVNetworkExtension;
+ };
50245A5A2A80431C0034792B /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 50A8910F2A792A15007C48FC /* Project object */;
@@ -111,6 +164,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;
@@ -125,6 +211,16 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
+ 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 = ""; };
+ 44DCF5B82EDF4D900026078E /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS26.1.sdk/usr/lib/libresolv.tbd; sourceTree = DEVELOPER_DIR; };
+ 44F3E39A2EE2F9FA00C87FEC /* TVAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVAuthView.swift; sourceTree = ""; };
+ 44TVCOLORSF12345678901234 /* TVColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVColors.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 = ""; };
@@ -177,27 +273,65 @@
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 = ""; };
50E6081F2A7979D600BAF09B /* SideDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideDrawer.swift; sourceTree = ""; };
50E608232A79966600BAF09B /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; };
50E608252A79968500BAF09B /* AdvancedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedView.swift; sourceTree = ""; };
- F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvVarPackager.swift; sourceTree = ""; };
- F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalConstants.swift; sourceTree = ""; };
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 */,
@@ -217,13 +351,36 @@
50051DE02AE69A8100AFBDC4 /* FirebaseCrashlytics in Frameworks */,
50003BBC2AFBCA6B00E5EB6B /* FirebasePerformance in Frameworks */,
5051190F2AE03F68003027D3 /* FirebaseAnalytics in Frameworks */,
- 50D402942BD9143900D4AC5B /* NetBirdSDK.xcframework in Frameworks */,
+ 44DCF5AF2EDF46140026078E /* NetBirdSDK.xcframework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
+ 443782BE2EDF284A00F9FA94 /* Platform */ = {
+ isa = PBXGroup;
+ children = (
+ 443782BD2EDF284A00F9FA94 /* Platform.swift */,
+ );
+ path = Platform;
+ sourceTree = "";
+ };
+ 443782C42EDF288A00F9FA94 /* TV */ = {
+ isa = PBXGroup;
+ children = (
+ 44F3E39A2EE2F9FA00C87FEC /* TVAuthView.swift */,
+ 44TVCOLORSF12345678901234 /* TVColors.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 = (
@@ -244,6 +401,7 @@
isa = PBXGroup;
children = (
50245A192A7BCE830034792B /* libresolv.tbd */,
+ 44DCF5B82EDF4D900026078E /* libresolv.tbd */,
50245A532A80431B0034792B /* NetworkExtension.framework */,
);
name = Frameworks;
@@ -284,6 +442,8 @@
50C727EA2A82479B006E898D /* NetbirdKit */,
50245A552A80431C0034792B /* NetbirdNetworkExtension */,
505118C72AD96ECA003027D3 /* WireGuardKitC */,
+ 441C5AEF2EDF0DAE0055EEFC /* NetBird TV */,
+ 441C5AFF2EDF0DD20055EEFC /* NetBirdTVNetworkExtension */,
50A891182A792A15007C48FC /* Products */,
50245A182A7BCE830034792B /* Frameworks */,
);
@@ -294,6 +454,8 @@
children = (
50A891172A792A15007C48FC /* NetBird.app */,
50245A522A80431B0034792B /* NetbirdNetworkExtension.appex */,
+ 441C5AEE2EDF0DAE0055EEFC /* NetBird TV.app */,
+ 441C5AFD2EDF0DD20055EEFC /* NetBirdTVNetworkExtension.appex */,
);
name = Products;
sourceTree = "";
@@ -383,6 +545,8 @@
50E6080A2A79568800BAF09B /* App */ = {
isa = PBXGroup;
children = (
+ 443782C42EDF288A00F9FA94 /* TV */,
+ 443782BE2EDF284A00F9FA94 /* Platform */,
50A8911A2A792A15007C48FC /* NetBirdApp.swift */,
50E607FF2A794F8200BAF09B /* Views */,
50E608012A7950C000BAF09B /* ViewModels */,
@@ -393,6 +557,56 @@
/* 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" */;
@@ -426,6 +640,7 @@
50A891152A792A15007C48FC /* Resources */,
50245A602A80431C0034792B /* Embed Foundation Extensions */,
508BD8502AF153350055E415 /* ShellScript */,
+ 44DCF5B12EDF46140026078E /* Embed Frameworks */,
);
buildRules = (
);
@@ -452,9 +667,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;
@@ -484,11 +705,27 @@
targets = (
50A891162A792A15007C48FC /* NetBird */,
50245A512A80431B0034792B /* NetbirdNetworkExtension */,
+ 441C5AED2EDF0DAE0055EEFC /* NetBird TV */,
+ 441C5AFC2EDF0DD20055EEFC /* NetBirdTVNetworkExtension */,
);
};
/* 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;
@@ -573,6 +810,55 @@
/* 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 */,
+ 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 */,
+ 44TVCOLORS12345678901234 /* TVColors.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 */,
+ 443782C82EDF288A00F9FA94 /* TVMainView.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 */,
+ 44F3E3932EE2151100C87FEC /* NetworkExtensionAdapter.swift in Sources */,
+ 44F3E3942EE2151100C87FEC /* ConnectionListener.swift in Sources */,
+ 44F3E38C2EE214E300C87FEC /* NetBirdAdapter.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
50245A4E2A80431B0034792B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -645,6 +931,11 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
+ 441C5B052EDF0DD20055EEFC /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 441C5AFC2EDF0DD20055EEFC /* NetBirdTVNetworkExtension */;
+ targetProxy = 441C5B042EDF0DD20055EEFC /* PBXContainerItemProxy */;
+ };
50245A5B2A80431C0034792B /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 50245A512A80431B0034792B /* NetbirdNetworkExtension */;
@@ -653,6 +944,160 @@
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
+ 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 = NO;
+ 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_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UIUserInterfaceStyle = Automatic;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 1.0;
+ 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 = NO;
+ 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_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UIUserInterfaceStyle = Automatic;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 1.0;
+ 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 = {
@@ -767,6 +1212,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;
@@ -821,6 +1267,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;
@@ -831,11 +1278,11 @@
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 = 5;
DEVELOPMENT_ASSET_PATHS = "";
@@ -869,6 +1316,7 @@
MARKETING_VERSION = 0.0.13;
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;
@@ -883,11 +1331,11 @@
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 = 5;
DEVELOPMENT_ASSET_PATHS = "";
@@ -921,6 +1369,7 @@
MARKETING_VERSION = 0.0.13;
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;
@@ -934,6 +1383,24 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
+ 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 = (
@@ -983,6 +1450,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 54896d3..0a2208a 100644
--- a/NetBird.xcodeproj/xcshareddata/xcschemes/NetBird.xcscheme
+++ b/NetBird.xcodeproj/xcshareddata/xcschemes/NetBird.xcscheme
@@ -1,6 +1,6 @@
Bool {
- let options = FirebaseOptions(contentsOfFile: Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist")!)
- FirebaseApp.configure(options: options!)
- return true
- }
+ func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
+ ) -> Bool {
+ // Configure Firebase with the plist file
+ 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()
@Environment(\.scenePhase) var scenePhase
+ #if os(iOS)
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
+ #endif
+
+ init() {
+ // Configure Firebase on tvOS (no AppDelegate available)
+ #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
+ #if os(iOS)
+ .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
print("App is active!")
viewModel.checkExtensionState()
viewModel.startPollingDetails()
}
- .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) {_ in
+ .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
}
}
}
+
+
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 1f8f542..87ef534 100644
--- a/NetBird/Source/App/ViewModels/MainViewModel.swift
+++ b/NetBird/Source/App/ViewModels/MainViewModel.swift
@@ -4,15 +4,53 @@
//
// 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
+
+/// 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
@@ -28,22 +66,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
@@ -57,11 +99,21 @@ class ViewModel: ObservableObject {
}
@Published var forceRelayConnection = true
@Published var showForceRelayAlert = false
-
- var preferences = Preferences.newPreferences()
+
+ /// Preferences are loaded lazily on first access to avoid blocking app startup.
+ /// On tvOS, SDK initialization is expensive (generates WireGuard/SSH keys) and
+ /// should only happen when actually needed.
+ private var _preferences: NetBirdSDKPreferences?
+ private var _preferencesLoaded = false
+ var preferences: NetBirdSDKPreferences? {
+ if !_preferencesLoaded {
+ _preferencesLoaded = true
+ _preferences = Preferences.newPreferences()
+ }
+ return _preferences
+ }
var buttonLock = false
let defaults = UserDefaults.standard
- let isIpad = UIDevice.current.userInterfaceIdiom == .pad
private var cancellables = Set()
@@ -75,10 +127,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)
@@ -89,18 +146,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")
}
}
@@ -172,47 +232,54 @@ 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")
- }
-
- func setSetupKey(key: String) throws {
- let newAuth = NetBirdSDKNewAuth(Preferences.configFile(), self.managementURL, nil)
- try newAuth?.login(withSetupKeyAndSaveConfig: key, deviceName: Device.getName())
- self.managementURL = ""
+
+ // Clear config from UserDefaults (used on tvOS)
+ Preferences.removeConfigFromUserDefaults()
+
+ #if os(tvOS)
+ // Also clear extension-local config to prevent stale credentials
+ networkExtensionAdapter.clearExtensionConfig()
+ #endif
}
func updatePreSharedKey() {
+ guard let preferences = preferences else {
+ print("updatePreSharedKey: Preferences not available")
+ return
+ }
preferences.setPreSharedKey(presharedKey)
do {
try preferences.commit()
@@ -224,8 +291,12 @@ class ViewModel: ObservableObject {
print("Failed to update preshared key")
}
}
-
+
func removePreSharedKey() {
+ guard let preferences = preferences else {
+ print("removePreSharedKey: Preferences not available")
+ return
+ }
presharedKey = ""
preferences.setPreSharedKey(presharedKey)
do {
@@ -236,13 +307,21 @@ class ViewModel: ObservableObject {
print("Failed to remove preshared key")
}
}
-
+
func loadPreSharedKey() {
+ guard let preferences = preferences else {
+ print("loadPreSharedKey: Preferences not available")
+ return
+ }
self.presharedKey = preferences.getPreSharedKey(nil)
self.presharedKeySecure = self.presharedKey != ""
}
-
+
func setRosenpassEnabled(enabled: Bool) {
+ guard let preferences = preferences else {
+ print("setRosenpassEnabled: Preferences not available")
+ return
+ }
preferences.setRosenpassEnabled(enabled)
do {
try preferences.commit()
@@ -250,30 +329,42 @@ class ViewModel: ObservableObject {
print("Failed to update rosenpass settings")
}
}
-
+
func getRosenpassEnabled() -> Bool {
+ guard let preferences = preferences else {
+ print("getRosenpassEnabled: Preferences not available")
+ return false
+ }
var result = ObjCBool(false)
do {
try preferences.getRosenpassEnabled(&result)
} catch {
print("Failed to read rosenpass settings")
}
-
+
return result.boolValue
}
-
+
func getRosenpassPermissive() -> Bool {
+ guard let preferences = preferences else {
+ print("getRosenpassPermissive: Preferences not available")
+ return false
+ }
var result = ObjCBool(false)
do {
try preferences.getRosenpassPermissive(&result)
} catch {
print("Failed to read rosenpass permissive settings")
}
-
+
return result.boolValue
}
-
+
func setRosenpassPermissive(permissive: Bool) {
+ guard let preferences = preferences else {
+ print("setRosenpassPermissive: Preferences not available")
+ return
+ }
preferences.setRosenpassPermissive(permissive)
do {
try preferences.commit()
@@ -291,8 +382,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 {
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 b45d86e..3f23ee6 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"))
@@ -549,3 +562,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..5bc3a20
--- /dev/null
+++ b/NetBird/Source/App/Views/TV/TVAuthView.swift
@@ -0,0 +1,365 @@
+//
+// 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 let checkError = checkError {
+ checkError { errorMsg in
+ DispatchQueue.main.async {
+ if let errorMsg = errorMsg {
+ #if DEBUG
+ print("TVAuthView: Login error detected: \(errorMsg)")
+ #endif
+ timer.invalidate()
+ onErrorHandler?(errorMsg)
+ // Don't auto-dismiss - let user see the error and cancel
+ return
+ }
+ }
+ }
+ }
+
+ 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?()
+ }
+ }
+ }
+ }
+ 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?()
+ }
+ }
+ }
+ }
+}
+
+/// 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..2d54c51
--- /dev/null
+++ b/NetBird/Source/App/Views/TV/TVMainView.swift
@@ -0,0 +1,334 @@
+//
+// 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, starting VPN connection...")
+ #endif
+ viewModel.networkExtensionAdapter.showBrowser = false
+ 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..94609c0
--- /dev/null
+++ b/NetBird/Source/App/Views/TV/TVPeersView.swift
@@ -0,0 +1,318 @@
+//
+// 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?
+
+ @State private var searchText = ""
+
+ 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..4a2b899
--- /dev/null
+++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift
@@ -0,0 +1,370 @@
+//
+// 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
+
+ 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: { viewModel.setRosenpassEnabled(enabled: $0) }
+ )
+ )
+ }
+
+ 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)
+ }
+ }
+ }
+
+ 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
+
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ Button(action: { isOn.toggle() }) {
+ 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()
+
+ // Custom toggle for better TV visibility
+ ZStack {
+ Capsule()
+ .fill(isOn ? Color.green : Color.gray.opacity(0.3))
+ .frame(width: 70, height: 40)
+
+ Circle()
+ .fill(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 ? 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)
+ }
+}
+
+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
+ Button(action: {
+ viewModel.showChangeServerAlert = false
+ }) {
+ Text("Cancel")
+ .font(.system(size: 24))
+ .foregroundColor(.white)
+ .padding(.horizontal, 50)
+ .padding(.vertical, 16)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(Color.white.opacity(0.5), lineWidth: 2)
+ )
+ }
+ .buttonStyle(.plain)
+ .focused($focusedButton, equals: .cancel)
+
+ // Confirm button
+ Button(action: {
+ viewModel.close()
+ viewModel.clearDetails()
+ viewModel.showChangeServerAlert = false
+ viewModel.navigateToServerView = true
+ }) {
+ Text("Confirm")
+ .font(.system(size: 24, weight: .semibold))
+ .foregroundColor(.white)
+ .padding(.horizontal, 50)
+ .padding(.vertical, 16)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(Color.red)
+ )
+ }
+ .buttonStyle(.plain)
+ .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 TVSettingsView_Previews: PreviewProvider {
+ static var previews: some View {
+ TVSettingsView()
+ .environmentObject(ViewModel())
+ }
+}
+
+#endif
+
+
diff --git a/NetBirdTV/Info.plist b/NetBirdTV/Info.plist
new file mode 100644
index 0000000..78fdd56
--- /dev/null
+++ b/NetBirdTV/Info.plist
@@ -0,0 +1,27 @@
+
+
+
+
+
+ CFBundleDisplayName
+ NetBird
+
+
+ LSApplicationCategoryType
+ public.app-category.utilities
+
+
+ MinimumOSVersion
+ 17.0
+
+
+ UILaunchScreen
+
+
+
+ UIUserInterfaceStyle
+ Automatic
+
+
+
+
diff --git a/NetBirdTV/NetBirdTV.entitlements b/NetBirdTV/NetBirdTV.entitlements
new file mode 100644
index 0000000..90b82e3
--- /dev/null
+++ b/NetBirdTV/NetBirdTV.entitlements
@@ -0,0 +1,19 @@
+
+
+
+
+
+ com.apple.developer.networking.networkextension
+
+ packet-tunnel-provider
+
+
+
+ com.apple.security.application-groups
+
+ group.io.netbird.app.tv
+
+
+
+
+
diff --git a/NetBirdTV/NetBirdTVNetworkExtension.entitlements b/NetBirdTV/NetBirdTVNetworkExtension.entitlements
new file mode 100644
index 0000000..e9b73c2
--- /dev/null
+++ b/NetBirdTV/NetBirdTVNetworkExtension.entitlements
@@ -0,0 +1,19 @@
+
+
+
+
+
+ com.apple.developer.networking.networkextension
+
+ packet-tunnel-provider
+
+
+
+ com.apple.security.application-groups
+
+ group.io.netbird.app.tv
+
+
+
+
+
diff --git a/NetBirdTVNetworkExtension/Info.plist b/NetBirdTVNetworkExtension/Info.plist
new file mode 100644
index 0000000..3059459
--- /dev/null
+++ b/NetBirdTVNetworkExtension/Info.plist
@@ -0,0 +1,13 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.networkextension.packet-tunnel
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).PacketTunnelProvider
+
+
+
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..0a76616
--- /dev/null
+++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift
@@ -0,0 +1,710 @@
+//
+// 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)")
+
+ // Skip file-based logging on tvOS - it will fail due to sandbox
+ #if !os(tvOS)
+ if let options = options, let logLevel = options["logLevel"] as? String {
+ logger.info("startTunnel: initializing logging with level \(logLevel, privacy: .public)")
+ initializeLogging(loglevel: logLevel)
+ }
+ #else
+ logger.info("startTunnel: skipping file-based logging on tvOS (sandbox blocks writes)")
+
+ // 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")
+ }
+ #endif
+
+ 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 {
+ do {
+ let deviceName = Device.getName()
+ let updatedConfig = NetBirdAdapter.updateDeviceNameInConfig(configJSON, newName: deviceName)
+ 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() {
+ #if os(tvOS)
+ // 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")
+ #else
+ guard let configPath = Preferences.configFile() else {
+ logger.error("initializeConfigIfNeeded: App group container unavailable")
+ return
+ }
+ let fileManager = FileManager.default
+
+ // Check if config already exists as a file
+ if fileManager.fileExists(atPath: configPath) {
+ return
+ }
+
+ // On iOS, try to create config via file writes
+ guard let auth = NetBirdSDKNewAuth(configPath, NetBirdAdapter.defaultManagementURL, nil) else {
+ return
+ }
+
+ let semaphore = DispatchSemaphore(value: 0)
+
+ let listener = ConfigInitSSOListener()
+ listener.onResult = { _, _ in
+ semaphore.signal()
+ }
+
+ auth.saveConfigIfSSOSupported(listener)
+
+ _ = semaphore.wait(timeout: .now() + 10)
+ #endif
+ }
+
+ /// 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 data = "false|false|true|false|false|error|adapter_not_initialized".data(using: .utf8)
+ completionHandler(data)
+ 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")
+
+ // Return diagnostic info in format: "result|isExecuting|loginRequired|configExists|stateExists|lastResult|lastError"
+ let response = "\(isComplete)|\(isExecutingLogin)|\(loginRequired)|\(configExists)|\(stateExists)|\(lastResult)|\(lastError)"
+ logger.info("checkLoginComplete: returning \(response)")
+ let data = response.data(using: .utf8)
+ completionHandler(data)
+ }
+
+ /// 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
+ // Return URL and user code in pipe-separated format
+ 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 response = "\(url)|\(userCode)"
+ let data = response.data(using: .utf8)
+ completionHandler(data)
+ },
+ 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: Preferences.appGroupIdentifier)
+ 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/ConnectionListener.swift b/NetbirdKit/ConnectionListener.swift
index fc53470..c9170d6 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..61572fb 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 = Preferences.appGroupIdentifier
+
+ // 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 68f4de7..17513ec 100644
--- a/NetbirdKit/EnvVarPackager.swift
+++ b/NetbirdKit/EnvVarPackager.swift
@@ -5,17 +5,26 @@
// 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
+ let defaultForceRelay = false
+ #endif
+
+ defaults.register(defaults: [GlobalConstants.keyForceRelayConnection: defaultForceRelay])
let forceRelayConnection = defaults.bool(forKey: GlobalConstants.keyForceRelayConnection)
-
+
envList.put(NetBirdSDKGetEnvKeyNBForceRelay(), value: String(forceRelayConnection))
-
+
return envList
}
}
diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift
index d52c44f..0b1770e 100644
--- a/NetbirdKit/NetworkExtensionAdapter.swift
+++ b/NetbirdKit/NetworkExtensionAdapter.swift
@@ -8,14 +8,42 @@
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 {
-
+
+ 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"
+ var extensionName = "NetBird"
+ #endif
let decoder = PropertyListDecoder()
@@ -23,6 +51,7 @@ public class NetworkExtensionAdapter: ObservableObject {
@Published var showBrowser = false
@Published var loginURL : String?
+ @Published var userCode : String?
init() {
self.timer = Timer()
@@ -40,14 +69,19 @@ public class NetworkExtensionAdapter: ObservableObject {
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 {
@@ -81,23 +115,187 @@ 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 also send config via IPC.
+ 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 since shared UserDefaults doesn't work
+ 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
+
+ #if os(tvOS)
+ /// Ask the Network Extension to initialize config with default management URL
+ /// This is required because the app doesn't have permission to write to the App Group container,
+ /// but the extension does.
+ private func initializeConfigViaExtension() async -> Bool {
+ guard let session = self.session else {
+ print("initializeConfigViaExtension: No session available")
+ return false
+ }
+
+ let messageString = "InitializeConfig"
+ guard let messageData = messageString.data(using: .utf8) else {
+ print("initializeConfigViaExtension: Failed to encode message")
+ return false
+ }
+
+ return await withCheckedContinuation { continuation in
+ do {
+ try session.sendProviderMessage(messageData) { response in
+ if let response = response,
+ let responseString = String(data: response, encoding: .utf8) {
+ let success = responseString == "true"
+ print("initializeConfigViaExtension: Extension returned '\(responseString)', success=\(success)")
+ continuation.resume(returning: success)
+ } else {
+ print("initializeConfigViaExtension: No response from extension")
+ continuation.resume(returning: false)
+ }
+ }
+ } catch {
+ print("initializeConfigViaExtension: Failed to send message - \(error)")
+ continuation.resume(returning: false)
+ }
+ }
+ }
+ #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
+ }
+ 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
}
- return client.isLoginRequired()
+
+ // 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 {
+ print("isLoginRequired: configFile size = \(size) bytes")
+ }
+ }
+
+ if stateExists {
+ if let attrs = try? fileManager.attributesOfItem(atPath: statePath),
+ let size = attrs[.size] as? Int64 {
+ print("isLoginRequired: stateFile size = \(size) bytes")
+ }
+ }
+
+ guard let client = NetBirdSDKNewClient(configPath, statePath, Device.getName(), Device.getOsVersion(), Device.getOsName(), nil, nil) else {
+ print("isLoginRequired: Failed to initialize client")
+ return true
+ }
+
+ let result = client.isLoginRequired()
+ print("isLoginRequired: SDK returned \(result)")
+ return result
+ #endif
}
class ObserverBox {
@@ -111,22 +309,28 @@ public class NetworkExtensionAdapter: ObservableObject {
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)")
}
}
@@ -134,21 +338,40 @@ 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 let string = String(data: response, encoding: .utf8) {
+ #if os(tvOS)
+ // For tvOS, response format is "url|userCode"
+ let parts = string.components(separatedBy: "|")
+ if parts.count >= 2 {
+ DispatchQueue.main.async {
+ self.userCode = parts[1]
+ }
+ completion(parts[0])
+ } else {
+ completion(string)
+ }
+ #else
completion(string)
+ #endif
return
}
}
@@ -161,6 +384,102 @@ 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,
+ let responseString = String(data: response, encoding: .utf8) {
+ // Parse diagnostic format: "result|isExecuting|loginRequired|configExists|stateExists|lastResult|lastError"
+ let parts = responseString.components(separatedBy: "|")
+ if parts.count >= 7 {
+ let isComplete = parts[0] == "true"
+ print("checkLoginComplete: result=\(parts[0]), isExecuting=\(parts[1]), loginRequired=\(parts[2]), configExists=\(parts[3]), stateExists=\(parts[4]), lastResult=\(parts[5]), lastError=\(parts[6])")
+ completion(isComplete)
+ } else if parts.count >= 5 {
+ let isComplete = parts[0] == "true"
+ print("checkLoginComplete: result=\(parts[0]), isExecuting=\(parts[1]), loginRequired=\(parts[2]), configExists=\(parts[3]), stateExists=\(parts[4])")
+ completion(isComplete)
+ } else {
+ // Fallback for old format
+ let isComplete = responseString == "true"
+ print("checkLoginComplete: Extension returned '\(responseString)', isComplete=\(isComplete)")
+ completion(isComplete)
+ }
+ } 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,
+ let responseString = String(data: response, encoding: .utf8) {
+ // Parse diagnostic format: "result|isExecuting|loginRequired|configExists|stateExists|lastResult|lastError"
+ let parts = responseString.components(separatedBy: "|")
+ if parts.count >= 7 {
+ let lastResult = parts[5]
+ let lastError = parts[6]
+ // Only report error if lastResult is "error" and there's an actual error message
+ if lastResult == "error" && !lastError.isEmpty {
+ // Make the error message more user-friendly
+ var friendlyError = lastError
+ if lastError.contains("no peer auth method provided") {
+ friendlyError = "This server doesn't support device code authentication. Please use a setup key instead."
+ } else if lastError.contains("expired") || lastError.contains("token") {
+ friendlyError = "The device code has expired. Please try again."
+ } else if lastError.contains("denied") || lastError.contains("rejected") {
+ friendlyError = "Authentication was denied. Please try again."
+ }
+ completion(friendlyError)
+ return
+ }
+ }
+ 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: [])
@@ -279,6 +598,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 74e959e..4ab1736 100644
--- a/NetbirdKit/Preferences.swift
+++ b/NetbirdKit/Preferences.swift
@@ -8,23 +8,140 @@
import Foundation
import NetBirdSDK
+/// Preferences manages configuration file paths and UserDefaults-based config storage.
+///
+/// ## tvOS Config Storage Architecture
+///
+/// On tvOS, the standard App Group shared container does NOT work for IPC between the main app
+/// and the Network Extension due to sandbox restrictions. The error you'll see is:
+/// `Using kCFPreferencesAnyUser with a container is only allowed for System Containers`
+///
+/// To work around this, tvOS uses a different architecture:
+///
+/// ### Config Flow on tvOS:
+/// 1. **Main App** → User enters server URL in TVServerView
+/// 2. **Main App** → ServerViewModel saves config to shared UserDefaults (`saveConfigToUserDefaults`)
+/// - This step is for the main app's own reference only
+/// 3. **Main App** → NetworkExtensionAdapter sends config via IPC (`sendConfigToExtension`)
+/// - Uses `sendProviderMessage` with "SetConfig:{json}" format
+/// 4. **Extension** → PacketTunnelProvider receives config via `handleAppMessage`
+/// 5. **Extension** → Saves to extension-local UserDefaults (`UserDefaults.standard`)
+/// - Key: "netbird_config_json_local"
+/// - This is the authoritative source for the extension
+/// 6. **Extension** → NetBirdAdapter.init() loads from extension-local UserDefaults
+///
+/// ### Key Points:
+/// - Shared App Group UserDefaults does NOT work between app and extension on tvOS
+/// - Extension-local `UserDefaults.standard` is the authoritative config source for the extension
+/// - Config must be transferred via IPC using `sendProviderMessage`/`handleAppMessage`
+/// - The main app's shared UserDefaults is only for the app's own use (e.g., displaying current URL)
+///
+/// ### iOS Behavior:
+/// On iOS, file-based config storage works normally via the App Group container.
+/// The UserDefaults methods here are primarily for tvOS compatibility.
class Preferences {
- static func newPreferences() -> NetBirdSDKPreferences {
- return NetBirdSDKNewPreferences(configFile(), stateFile())!
+ #if os(tvOS)
+ static let appGroupIdentifier = "group.io.netbird.app.tv"
+ #else
+ static let appGroupIdentifier = "group.io.netbird.app"
+ #endif
+
+ static func newPreferences() -> NetBirdSDKPreferences? {
+ guard let configPath = configFile(), let statePath = stateFile() else {
+ print("ERROR: Cannot create preferences - app group container unavailable")
+ return nil
+ }
+ #if os(tvOS)
+ // On tvOS, creating SDK Preferences may fail if the app doesn't have write access
+ // to the App Group container. Try anyway - if it fails, settings will be managed
+ // via the extension instead.
+ // Note: The SDK now uses DirectWriteOutConfig which may work better on tvOS.
+ return NetBirdSDKNewPreferences(configPath, statePath)
+ #else
+ return NetBirdSDKNewPreferences(configPath, statePath)
+ #endif
}
- static func configFile() -> String {
+ static func configFile() -> String? {
let fileManager = FileManager.default
- let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.io.netbird.app")
- let logURL = groupURL?.appendingPathComponent("netbird.cfg")
- return logURL!.relativePath
+ guard let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else {
+ print("ERROR: App group '\(appGroupIdentifier)' not available. Check entitlements.")
+ return nil
+ }
+ return groupURL.appendingPathComponent("netbird.cfg").path
}
-
- static func stateFile() -> String {
+
+ static func stateFile() -> String? {
let fileManager = FileManager.default
- let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.io.netbird.app")
- let logURL = groupURL?.appendingPathComponent("state.json")
- return logURL!.relativePath
+ guard let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else {
+ print("ERROR: App group '\(appGroupIdentifier)' not available. Check entitlements.")
+ return nil
+ }
+ return groupURL.appendingPathComponent("state.json").path
+ }
+
+ // UserDefaults-based config storage for tvOS
+ // tvOS sandbox prevents file writes to App Group containers, so we use UserDefaults instead
+
+ private static let configJSONKey = "netbird_config_json"
+
+ /// Get the shared UserDefaults for the App Group
+ static func sharedUserDefaults() -> UserDefaults? {
+ return UserDefaults(suiteName: appGroupIdentifier)
+ }
+
+ /// Save config JSON to UserDefaults (works on tvOS where file writes fail)
+ 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
+ static func loadConfigFromUserDefaults() -> String? {
+ guard let defaults = sharedUserDefaults() else {
+ return nil
+ }
+ return defaults.string(forKey: configJSONKey)
+ }
+
+ /// Check if config exists in UserDefaults
+ static func hasConfigInUserDefaults() -> Bool {
+ guard let defaults = sharedUserDefaults() else {
+ return false
+ }
+ return defaults.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
+ /// This is needed because the Go SDK reads from the file path
+ static func restoreConfigFromUserDefaults() -> Bool {
+ guard let configJSON = loadConfigFromUserDefaults() else {
+ return false
+ }
+
+ guard let path = configFile() else {
+ print("ERROR: Cannot restore config - app group container unavailable")
+ return false
+ }
+ do {
+ try configJSON.write(toFile: path, atomically: false, encoding: .utf8)
+ return true
+ } catch {
+ return false
+ }
}
-
}
+
diff --git a/NetbirdKit/RoutesSelectionDetails.swift b/NetbirdKit/RoutesSelectionDetails.swift
index 1fd5b87..68e9241 100644
--- a/NetbirdKit/RoutesSelectionDetails.swift
+++ b/NetbirdKit/RoutesSelectionDetails.swift
@@ -1,3 +1,11 @@
+//
+// RoutesSelectionDetails.swift
+// NetBird
+//
+
+import Foundation
+import Combine
+
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 72ec2f4..76d69ea 100644
--- a/NetbirdNetworkExtension/NetBirdAdapter.swift
+++ b/NetbirdNetworkExtension/NetBirdAdapter.swift
@@ -10,23 +10,86 @@ 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?
+
var clientState : ClientState = .disconnected
/// 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)) {
@@ -52,22 +115,156 @@ 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
// MARK: - Initialization
/// 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.
@@ -97,16 +294,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()
@@ -117,13 +325,200 @@ 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() {
self.client.stop()
}
+
+ // 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 822f944..23fa153 100644
--- a/NetbirdNetworkExtension/PacketTunnelProvider.swift
+++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift
@@ -20,7 +20,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
return PacketTunnelProviderSettingsManager(with: self)
}()
- private lazy var adapter: NetBirdAdapter = {
+ private lazy var adapter: NetBirdAdapter? = {
return NetBirdAdapter(with: self.tunnelManager)
}()
@@ -49,6 +49,16 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
currentNetworkType = nil
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(
@@ -65,7 +75,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
}
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
- adapter.stop()
+ adapter?.stop()
guard let pathMonitor = self.pathMonitor else {
print("pathMonitor is nil; nothing to cancel.")
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
@@ -96,11 +106,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:
print("Unknown message: \(string)")
+ completionHandler(nil)
}
}
@@ -147,8 +160,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
}
func restartClient() {
- adapter.stop()
- adapter.start { error in
+ adapter?.stop()
+ adapter?.start { error in
if let error = error {
print("Error restarting client: \(error.localizedDescription)")
}
@@ -156,12 +169,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 {
print("Did not receive status details.")
completionHandler(nil)
@@ -202,10 +223,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
)
@@ -215,7 +237,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
} catch {
print("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 {
@@ -226,6 +248,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
}
func getSelectRoutes(completionHandler: (Data?) -> Void) {
+ guard let adapter = adapter else {
+ completionHandler(nil)
+ return
+ }
do {
let routeSelectionDetailsMessage = try adapter.client.getRoutesSelectionDetails()
@@ -267,6 +293,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
}
func selectRoute(id: String) {
+ guard let adapter = adapter else { return }
do {
try adapter.client.selectRoute(id)
} catch {
@@ -275,6 +302,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 3e8ce80..2c34c43 100644
--- a/README.md
+++ b/README.md
@@ -28,9 +28,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:
@@ -54,9 +54,13 @@ The code is divided into 4 parts:
## Requirements
-- iOS 14.0+
-- Xcode 12.0+
-- gomobile
+- iOS 14.0+ / tvOS 17.0+
+- Xcode 15.0+
+- gomobile (netbird forked version with tvOS support - see below)
+
+## gomobile-fork
+
+Since gomobile doesn't natively support tvOS targets, NetBird has created a fork that does. Please see the repo for more information: https://github.com/netbirdio/gomobile-tvos-fork
## Run locally
@@ -67,15 +71,29 @@ git clone https://github.com/netbirdio/netbird.git
git clone https://github.com/netbirdio/ios-client.git
```
-Building the xcframework from the main netbird repo. This needs to be stored in the root directory of the app
-```
+Building the xcframework from the main netbird repo. This needs to be stored in the root directory of the app.
+
+**For iOS only:**
+```bash
cd netbird
gomobile bind -target=ios -bundleid=io.netbird.framework -o ../ios-client/NetBirdSDK.xcframework ./client/ios/NetBirdSDK
```
+**For iOS + tvOS (requires gomobile fork with tvOS support):**
+```bash
+cd netbird
+gomobile bind -target=ios,tvos -bundleid=io.netbird.framework -o ../ios-client/NetBirdSDK.xcframework ./client/ios/NetBirdSDK
+```
+
Open the Xcode project, and we are ready to go.
-> **Note:** The app can not be run in the iOS simulator. To test the app, a physical device needs to be connected to Xcode via cable and set as the run destination.
+### 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).
## Other project repositories
@@ -84,3 +102,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)