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)