From 413f113ab7926ecca542ee427b5296800da36193 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Tue, 2 Dec 2025 14:41:24 +0100 Subject: [PATCH 01/19] Add tvOS (Apple TV) support for NetBird VPN client This commit introduces full tvOS support for the NetBird iOS client, enabling VPN connectivity on Apple TV devices. - Added NetBird TV app target with tvOS 16.0+ deployment - Created tvOS-specific UI using SwiftUI optimized for "10-foot experience" - Tab-based navigation: Connection, Peers, Networks, Settings - Large touch targets and text for Siri Remote navigation - TVMainView: Main tab navigation and connection status - TVConnectionView: Large connect/disconnect button with status display - TVPeersView: List of connected peers with connection details - TVNetworksView: Network routes selection and management - TVSettingsView: App settings and logout functionality - TVAuthView: QR code + device code authentication flow - Implemented OAuth device authorization flow for tvOS - Displays QR code that user scans with phone to authenticate - Shows user code as fallback for manual entry - Polls for authentication completion and auto-dismisses on success tvOS has stricter sandbox restrictions than iOS: 1. **UserDefaults-based Config Storage** - tvOS blocks file writes to App Group containers - Config stored in shared UserDefaults instead of files - Added Preferences methods: saveConfigToUserDefaults(), loadConfigFromUserDefaults(), hasConfigInUserDefaults() 2. **Preloaded Config in Go SDK** - SDK modified to accept config via setConfigFromJSON() - Avoids file I/O that would fail in tvOS sandbox - Config passed from UserDefaults to SDK at runtime 3. **Raw Syscall Tunnel FD Discovery** - tvOS SDK doesn't expose ctl_info, sockaddr_ctl, CTLIOCGINFO - Implemented findTunnelFileDescriptorTvOS() using raw memory ops - Manually defines kernel structure layouts at byte level - Uses getpeername() and ioctl() which ARE available on tvOS - Added NetBirdTVNetworkExtension target - Separate PacketTunnelProvider.swift with tvOS-specific handling - Extensive logging for debugging via Console.app - Handles "LoginTV" message for device auth flow - Loads config from UserDefaults into SDK memory - isLoginRequired() now verifies session with management server - Previously only checked if config existed (caused post-restart failures) - Shows QR code re-auth flow when OAuth session expires - Added Platform.swift for iOS/tvOS conditional compilation - Shared code uses #if os(tvOS) / #if os(iOS) where needed - Common ViewModels work across both platforms --- .../AccentColor.colorset/Contents.json | 11 + .../Content.imageset/Contents.json | 11 + .../Back.imagestacklayer/Contents.json | 6 + .../Contents.json | 17 + .../Content.imageset/Contents.json | 11 + .../Front.imagestacklayer/Contents.json | 6 + .../Content.imageset/Contents.json | 11 + .../Middle.imagestacklayer/Contents.json | 6 + .../Content.imageset/Contents.json | 16 + .../Back.imagestacklayer/Contents.json | 6 + .../App Icon.imagestack/Contents.json | 17 + .../Content.imageset/Contents.json | 16 + .../Front.imagestacklayer/Contents.json | 6 + .../Content.imageset/Contents.json | 16 + .../Middle.imagestacklayer/Contents.json | 6 + .../Contents.json | 32 + .../Contents.json | 16 + .../Top Shelf Image.imageset/Contents.json | 16 + NetBird TV/Assets.xcassets/Contents.json | 6 + NetBird TV/ContentView.swift | 24 + NetBird TV/NetBird TVDebug.entitlements | 14 + NetBird.xcodeproj/project.pbxproj | 477 +++++++++++- NetBird/Source/App/NetBirdApp.swift | 70 +- NetBird/Source/App/Platform/Platform.swift | 215 ++++++ .../Source/App/ViewModels/MainViewModel.swift | 239 ++++-- .../Source/App/ViewModels/PeerViewModel.swift | 1 + .../App/ViewModels/RoutesViewModel.swift | 1 + .../App/Views/Components/SafariView.swift | 14 + .../App/Views/Components/SideDrawer.swift | 6 + NetBird/Source/App/Views/MainView.swift | 25 +- NetBird/Source/App/Views/PeerTabView.swift | 32 +- NetBird/Source/App/Views/RouteTabView.swift | 15 +- NetBird/Source/App/Views/ServerView.swift | 48 +- NetBird/Source/App/Views/TV/TVAuthView.swift | 301 ++++++++ NetBird/Source/App/Views/TV/TVMainView.swift | 351 +++++++++ .../Source/App/Views/TV/TVNetworksView.swift | 252 +++++++ NetBird/Source/App/Views/TV/TVPeersView.swift | 346 +++++++++ .../Source/App/Views/TV/TVSettingsView.swift | 355 +++++++++ NetBirdTV/Info.plist | 27 + NetBirdTV/NetBirdTV.entitlements | 19 + .../NetBirdTVNetworkExtension.entitlements | 19 + NetBirdTVNetworkExtension/Info.plist | 13 + .../NetBirdTVNetworkExtension.entitlements | 14 + ...etBirdTVNetworkExtensionDebug.entitlements | 14 + .../PacketTunnelProvider.swift | 690 ++++++++++++++++++ NetbirdKit/ConnectionListener.swift | 1 + NetbirdKit/DNSManager.swift | 1 + NetbirdKit/NetworkExtensionAdapter.swift | 311 +++++++- NetbirdKit/Preferences.swift | 100 ++- NetbirdKit/RoutesSelectionDetails.swift | 8 + NetbirdKit/StatusDetails.swift | 1 + NetbirdNetworkExtension/NetBirdAdapter.swift | 361 ++++++++- README.md | 49 +- 53 files changed, 4495 insertions(+), 151 deletions(-) create mode 100644 NetBird TV/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json create mode 100644 NetBird TV/Assets.xcassets/Contents.json create mode 100644 NetBird TV/ContentView.swift create mode 100644 NetBird TV/NetBird TVDebug.entitlements create mode 100644 NetBird/Source/App/Platform/Platform.swift create mode 100644 NetBird/Source/App/Views/TV/TVAuthView.swift create mode 100644 NetBird/Source/App/Views/TV/TVMainView.swift create mode 100644 NetBird/Source/App/Views/TV/TVNetworksView.swift create mode 100644 NetBird/Source/App/Views/TV/TVPeersView.swift create mode 100644 NetBird/Source/App/Views/TV/TVSettingsView.swift create mode 100644 NetBirdTV/Info.plist create mode 100644 NetBirdTV/NetBirdTV.entitlements create mode 100644 NetBirdTV/NetBirdTVNetworkExtension.entitlements create mode 100644 NetBirdTVNetworkExtension/Info.plist create mode 100644 NetBirdTVNetworkExtension/NetBirdTVNetworkExtension.entitlements create mode 100644 NetBirdTVNetworkExtension/NetBirdTVNetworkExtensionDebug.entitlements create mode 100644 NetBirdTVNetworkExtension/PacketTunnelProvider.swift 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..de59d88 --- /dev/null +++ b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.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 - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.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/Front.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/Front.imagestacklayer/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.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 - App Store.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 - 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..795cce1 --- /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,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/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..795cce1 --- /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,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/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..795cce1 --- /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,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/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/ContentView.swift b/NetBird TV/ContentView.swift new file mode 100644 index 0000000..da7351a --- /dev/null +++ b/NetBird TV/ContentView.swift @@ -0,0 +1,24 @@ +// +// ContentView.swift +// NetBird TV +// +// Created by Ashley Mensah on 02.12.25. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} 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 8b21dea..c6cdee7 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -3,10 +3,52 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ + 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 */; }; + 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, ); }; }; + 44DCF5A92EDF45E10026078E /* NetBirdSDK.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; }; + 44DCF5AC2EDF45FC0026078E /* NetBirdSDK.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; }; + 44DCF5AF2EDF46140026078E /* NetBirdSDK.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; }; + 44DCF5B02EDF46140026078E /* NetBirdSDK.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 44DCF5B32EDF48310026078E /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 44DCF5B22EDF48310026078E /* FirebaseAnalytics */; }; + 44DCF5B52EDF48310026078E /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 44DCF5B42EDF48310026078E /* FirebaseCrashlytics */; }; + 44DCF5B72EDF48310026078E /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 44DCF5B62EDF48310026078E /* Lottie */; }; + 44DCF5B92EDF4DB10026078E /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 44DCF5B82EDF4D900026078E /* libresolv.tbd */; }; + 44F3E38B2EE214D300C87FEC /* PacketTunnelProviderSettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CD814E2AD0355000CF830B /* PacketTunnelProviderSettingsManager.swift */; }; + 44F3E38C2EE214E300C87FEC /* NetBirdAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C727EB2A824812006E898D /* NetBirdAdapter.swift */; }; + 44F3E38D2EE2151100C87FEC /* Device.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608022A7950CB00BAF09B /* Device.swift */; }; + 44F3E38E2EE2151100C87FEC /* RoutesSelectionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C5D30E2BDD96CF003159BE /* RoutesSelectionDetails.swift */; }; + 44F3E38F2EE2151100C87FEC /* DNSManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CD81612AD0595E00CF830B /* DNSManager.swift */; }; + 44F3E3902EE2151100C87FEC /* StatusDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CD81A62AD5504B00CF830B /* StatusDetails.swift */; }; + 44F3E3912EE2151100C87FEC /* ClientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50003BCB2AFD3B0C00E5EB6B /* ClientState.swift */; }; + 44F3E3922EE2151100C87FEC /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50245A292A7BDB590034792B /* Preferences.swift */; }; + 44F3E3932EE2151100C87FEC /* NetworkExtensionAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50216D922ACB2488009574C9 /* NetworkExtensionAdapter.swift */; }; + 44F3E3942EE2151100C87FEC /* ConnectionListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50003BC82AFD2F0C00E5EB6B /* ConnectionListener.swift */; }; + 44F3E3952EE2F6F900C87FEC /* NetBirdSDK.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; }; + 44F3E3982EE2F89200C87FEC /* NetworkChangeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50245A2D2A7BDC470034792B /* NetworkChangeListener.swift */; }; + 44F3E3992EE2F90900C87FEC /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 44DCF5B82EDF4D900026078E /* libresolv.tbd */; }; + 44F3E39B2EE2F9FA00C87FEC /* TVAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44F3E39A2EE2F9FA00C87FEC /* TVAuthView.swift */; }; 50003BBC2AFBCA6B00E5EB6B /* FirebasePerformance in Frameworks */ = {isa = PBXBuildFile; productRef = 50003BBB2AFBCA6B00E5EB6B /* FirebasePerformance */; }; 50003BBE2AFBCA7900E5EB6B /* FirebasePerformance in Frameworks */ = {isa = PBXBuildFile; productRef = 50003BBD2AFBCA7900E5EB6B /* FirebasePerformance */; }; 50003BC42AFBD7D500E5EB6B /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50245A562A80431C0034792B /* PacketTunnelProvider.swift */; }; @@ -84,8 +126,6 @@ 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 */; }; @@ -94,6 +134,13 @@ /* 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 */; @@ -104,6 +151,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; @@ -118,6 +198,15 @@ /* 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 = ""; }; 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 = ""; }; @@ -170,7 +259,7 @@ 50CD81A62AD5504B00CF830B /* StatusDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusDetails.swift; sourceTree = ""; }; 50CD81AF2AD5B94D00CF830B /* PeerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerCard.swift; sourceTree = ""; }; 50CD84352AD82F9400CF830B /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; - 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = NetBirdSDK.xcframework; path = NetBird/NetBirdSDK.xcframework; sourceTree = ""; }; + 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = NetBirdSDK.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 = ""; }; @@ -179,13 +268,51 @@ 50E608252A79968500BAF09B /* AdvancedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedView.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 */, + 44DCF5A92EDF45E10026078E /* NetBirdSDK.xcframework 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 */, @@ -205,13 +332,34 @@ 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 */, + 443782C02EDF288A00F9FA94 /* TVMainView.swift */, + 443782C12EDF288A00F9FA94 /* TVNetworksView.swift */, + 443782C22EDF288A00F9FA94 /* TVPeersView.swift */, + 443782C32EDF288A00F9FA94 /* TVSettingsView.swift */, + ); + name = TV; + path = Views/TV; + sourceTree = ""; + }; 501B0DC42AE04DDE004BE7A7 /* animations */ = { isa = PBXGroup; children = ( @@ -232,6 +380,7 @@ isa = PBXGroup; children = ( 50245A192A7BCE830034792B /* libresolv.tbd */, + 44DCF5B82EDF4D900026078E /* libresolv.tbd */, 50245A532A80431B0034792B /* NetworkExtension.framework */, ); name = Frameworks; @@ -272,6 +421,8 @@ 50C727EA2A82479B006E898D /* NetbirdKit */, 50245A552A80431C0034792B /* NetbirdNetworkExtension */, 505118C72AD96ECA003027D3 /* WireGuardKitC */, + 441C5AEF2EDF0DAE0055EEFC /* NetBird TV */, + 441C5AFF2EDF0DD20055EEFC /* NetBirdTVNetworkExtension */, 50A891182A792A15007C48FC /* Products */, 50245A182A7BCE830034792B /* Frameworks */, ); @@ -282,6 +433,8 @@ children = ( 50A891172A792A15007C48FC /* NetBird.app */, 50245A522A80431B0034792B /* NetbirdNetworkExtension.appex */, + 441C5AEE2EDF0DAE0055EEFC /* NetBird TV.app */, + 441C5AFD2EDF0DD20055EEFC /* NetBirdTVNetworkExtension.appex */, ); name = Products; sourceTree = ""; @@ -366,6 +519,8 @@ 50E6080A2A79568800BAF09B /* App */ = { isa = PBXGroup; children = ( + 443782C42EDF288A00F9FA94 /* TV */, + 443782BE2EDF284A00F9FA94 /* Platform */, 50A8911A2A792A15007C48FC /* NetBirdApp.swift */, 50E607FF2A794F8200BAF09B /* Views */, 50E608012A7950C000BAF09B /* ViewModels */, @@ -376,6 +531,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" */; @@ -409,6 +614,7 @@ 50A891152A792A15007C48FC /* Resources */, 50245A602A80431C0034792B /* Embed Foundation Extensions */, 508BD8502AF153350055E415 /* ShellScript */, + 44DCF5B12EDF46140026078E /* Embed Frameworks */, ); buildRules = ( ); @@ -435,9 +641,15 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1430; + LastSwiftUpdateCheck = 2610; LastUpgradeCheck = 1430; TargetAttributes = { + 441C5AED2EDF0DAE0055EEFC = { + CreatedOnToolsVersion = 26.1; + }; + 441C5AFC2EDF0DD20055EEFC = { + CreatedOnToolsVersion = 26.1; + }; 50245A512A80431B0034792B = { CreatedOnToolsVersion = 14.3.1; LastSwiftMigration = 1430; @@ -467,11 +679,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; @@ -556,6 +784,48 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 441C5AEA2EDF0DAE0055EEFC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 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 */, + 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 */, + 44F3E38D2EE2151100C87FEC /* Device.swift in Sources */, + 44F3E38E2EE2151100C87FEC /* RoutesSelectionDetails.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; @@ -621,6 +891,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 441C5B052EDF0DD20055EEFC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 441C5AFC2EDF0DD20055EEFC /* NetBirdTVNetworkExtension */; + targetProxy = 441C5B042EDF0DD20055EEFC /* PBXContainerItemProxy */; + }; 50245A5B2A80431C0034792B /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 50245A512A80431B0034792B /* NetbirdNetworkExtension */; @@ -629,6 +904,155 @@ /* 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; + 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_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.6; + }; + 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; + 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_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.NetBird-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 = 26.1; + }; + 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 = 26.1; + }; + 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.NetBird-TV.NetBirdTVNetworkExtension"; + 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 = 26.1; + }; + name = Release; + }; 50245A5E2A80431C0034792B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -812,6 +1236,7 @@ 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 = 2; DEVELOPMENT_ASSET_PATHS = ""; @@ -845,6 +1270,7 @@ MARKETING_VERSION = 0.0.10; 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; @@ -864,10 +1290,11 @@ 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 = 2; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = TA739QLA7A; + DEVELOPMENT_TEAM = 94333M4JTA; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/**"; GENERATE_INFOPLIST_FILE = YES; @@ -897,6 +1324,7 @@ MARKETING_VERSION = 0.0.10; 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; @@ -910,6 +1338,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 = ( @@ -959,6 +1405,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/Source/App/NetBirdApp.swift b/NetBird/Source/App/NetBirdApp.swift index f7a6932..e0b3193 100644 --- a/NetBird/Source/App/NetBirdApp.swift +++ b/NetBird/Source/App/NetBirdApp.swift @@ -1,44 +1,92 @@ // -// NetBirdiOSApp.swift -// NetBirdiOS +// NetBirdApp.swift +// NetBird // // Created by Pascal Fischer on 01.08.23. // +// Main entry point for the NetBird app. +// Supports both iOS and tvOS platforms. +// import SwiftUI import FirebaseCore + +// Firebase Performance is only available on iOS +#if os(iOS) import FirebasePerformance +#endif +// MARK: - App Delegate (iOS only) +#if os(iOS) class AppDelegate: NSObject, UIApplicationDelegate { - func application(_ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> 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 - +// MARK: - Main App Entry Point @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) + // iOS uses UIApplication notifications + .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) { phase in + switch phase { + 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..563a44f --- /dev/null +++ b/NetBird/Source/App/Platform/Platform.swift @@ -0,0 +1,215 @@ +// +// 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 + +// MARK: - Screen Size Abstraction +/// Replaces direct UIScreen.main.bounds usage which isn't ideal for tvOS. +/// tvOS has fixed resolutions (1080p or 4K), while iOS varies by device. +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 + } + + /// Screen height in points + 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) + } +} + +// MARK: - Device Type Detection +/// Identifies what type of Apple device we're running on. +/// Useful for conditional UI layouts and feature availability. +/// Named DeviceType to avoid conflict with NetbirdKit/Device.swift +struct DeviceType { + + /// True if running on Apple TV + static var isTV: Bool { + #if os(tvOS) + return true + #else + return false + #endif + } + + /// True if running on iPad + static var isPad: Bool { + #if os(tvOS) + return false + #else + return UIDevice.current.userInterfaceIdiom == .pad + #endif + } + + /// True if running on iPhone + 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 for 10-foot experience + } else if isPad { + return 1.3 + } else { + return 1.0 + } + } +} + +// MARK: - Platform Capabilities +/// Describes what features are available on the current platform. +/// Use this to conditionally show/hide UI or enable/disable features. +struct PlatformCapabilities { + + /// Whether the device supports VPN/Network Extensions + /// Note: Requires tvOS 17+ for Apple TV + 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 + } + + /// Whether SFSafariViewController is available for in-app web browsing + /// tvOS doesn't have Safari, so we need alternative auth flows + static var supportsSafariView: Bool { + #if os(tvOS) + return false + #else + return true + #endif + } + + /// Whether the device has a touchscreen + /// tvOS uses the Siri Remote (focus-based navigation) + static var hasTouchScreen: Bool { + #if os(tvOS) + return false + #else + return true + #endif + } + + /// Whether clipboard/pasteboard is available + /// tvOS has limited clipboard support + static var supportsClipboard: Bool { + #if os(tvOS) + return false + #else + return true + #endif + } + + /// Whether keyboard input is available + static var supportsKeyboard: Bool { + #if os(tvOS) + // tvOS has on-screen keyboard but it's clunky + return true + #else + return true + #endif + } +} + +// MARK: - Layout Constants +/// Pre-calculated layout values for consistent UI across platforms. +/// These are tuned for each platform's typical viewing distance and interaction model. +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 + } +} + +// MARK: - 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) + } +} + +// MARK: - 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 3112e3a..03a9c4a 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -4,15 +4,60 @@ // // 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 + +#if os(iOS) +import UIKit +#endif + +// MARK: - SSO Listener for checking SSO support +/// 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) + } +} + +// MARK: - 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) + } +} + +// MARK: - Main ViewModel +/// Central ViewModel for the NetBird app, managing VPN state and UI. +/// Works on both iOS and tvOS (tvOS 17+ required for VPN support). @MainActor class ViewModel: ObservableObject { + + private let logger = Logger(subsystem: "io.netbird.app", category: "ViewModel") + + // MARK: - VPN Adapter (shared) @Published var networkExtensionAdapter: NetworkExtensionAdapter + + // MARK: - UI State (shared) @Published var showSetupKeyPopup = false @Published var showChangeServerAlert = false @Published var showInvalidServerAlert = false @@ -28,8 +73,17 @@ class ViewModel: ObservableObject { @Published var showAuthenticationRequired = false @Published var isSheetExpanded = false @Published var presentSideDrawer = false - @Published var extensionState : NEVPNStatus = .disconnected @Published var navigateToServerView = false + + // MARK: - VPN State + @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 + + // MARK: - Settings @Published var rosenpassEnabled = false @Published var rosenpassPermissive = false @Published var managementURL = "" @@ -37,13 +91,12 @@ class ViewModel: ObservableObject { @Published var server: String = "" @Published var setupKey: String = "" @Published var presharedKeySecure = true + + // MARK: - Device Info (persisted) @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 + + // MARK: - Trace Logging @Published var traceLogsEnabled: Bool { didSet { self.showLogLevelChangedAlert = true @@ -55,16 +108,37 @@ class ViewModel: ObservableObject { UserDefaults.standard.synchronize() } } - var preferences = Preferences.newPreferences() + + // MARK: - Properties + var preferences: NetBirdSDKPreferences? = Preferences.newPreferences() var buttonLock = false let defaults = UserDefaults.standard - let isIpad = UIDevice.current.userInterfaceIdiom == .pad + + /// Device type detection - platform-safe + var isIpad: Bool { + #if os(iOS) + return UIDevice.current.userInterfaceIdiom == .pad + #else + return false + #endif + } + + /// True if running on Apple TV + var isTV: Bool { + #if os(tvOS) + return true + #else + return false + #endif + } private var cancellables = Set() + // MARK: - Child ViewModels @Published var peerViewModel: PeerViewModel @Published var routeViewModel: RoutesViewModel + // MARK: - Initialization init() { let networkExtensionAdapter = NetworkExtensionAdapter() self.networkExtensionAdapter = networkExtensionAdapter @@ -85,18 +159,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") } } @@ -168,31 +245,58 @@ 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 + 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? { + func updateManagementURL(url: String, completion: @escaping (Bool?) -> Void) { 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 + + let listener = SSOCheckListener() + listener.onResult = { ssoSupported, error in + DispatchQueue.main.async { + if let error = error { + print("Failed to check SSO support: \(error.localizedDescription)") + completion(nil) + } else if let supported = ssoSupported { + if supported { + print("SSO is supported") + completion(true) + } else { + print("SSO is not supported. Fallback to setup key") + completion(false) + } + } else { + completion(nil) + } } - } catch { - print("Failed to check SSO support") } - return nil + + newAuth?.saveConfigIfSSOSupported(listener) } func clearDetails() { @@ -202,13 +306,30 @@ class ViewModel: ObservableObject { defaults.removeObject(forKey: "fqdn") } - func setSetupKey(key: String) throws { + func setSetupKey(key: String, completion: @escaping (Error?) -> Void) { let newAuth = NetBirdSDKNewAuth(Preferences.configFile(), self.managementURL, nil) - try newAuth?.login(withSetupKeyAndSaveConfig: key, deviceName: Device.getName()) - self.managementURL = "" + + let listener = SetupKeyErrListener() + listener.onResult = { error in + DispatchQueue.main.async { + if let error = error { + print("Setup key login failed: \(error.localizedDescription)") + completion(error) + } else { + self.managementURL = "" + completion(nil) + } + } + } + + newAuth?.login(withSetupKeyAndSaveConfig: listener, setupKey: key, deviceName: Device.getName()) } func updatePreSharedKey() { + guard let preferences = preferences else { + print("updatePreSharedKey: Preferences not available") + return + } preferences.setPreSharedKey(presharedKey) do { try preferences.commit() @@ -220,8 +341,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 { @@ -232,13 +357,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() @@ -246,32 +379,44 @@ 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() diff --git a/NetBird/Source/App/ViewModels/PeerViewModel.swift b/NetBird/Source/App/ViewModels/PeerViewModel.swift index 2053925..5eb6137 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 { 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/Views/Components/SafariView.swift b/NetBird/Source/App/Views/Components/SafariView.swift index 9a83afa..56c2d88 100644 --- a/NetBird/Source/App/Views/Components/SafariView.swift +++ b/NetBird/Source/App/Views/Components/SafariView.swift @@ -1,6 +1,19 @@ +// +// SafariView.swift +// NetBird +// +// iOS-only: Wraps SFSafariViewController for in-app web authentication. +// tvOS does not have Safari, so it uses TVAuthView instead. +// + import SwiftUI + +// Safari is only available on iOS +#if os(iOS) import SafariServices +/// Presents Safari in-app for OAuth authentication flows. +/// Used to handle login redirects without leaving the app. struct SafariView: UIViewControllerRepresentable { @Binding var isPresented: Bool let url: URL @@ -48,3 +61,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..71c8ab9 100644 --- a/NetBird/Source/App/Views/MainView.swift +++ b/NetBird/Source/App/Views/MainView.swift @@ -10,8 +10,27 @@ 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 + } +} + +// MARK: - iOS Main View +/// The original iOS implementation, now wrapped for platform selection. +#if os(iOS) +struct iOSMainView: View { + @EnvironmentObject var viewModel: ViewModel @State private var isSheetshown = true @State private var animationKey: UUID = UUID() @@ -24,10 +43,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 @@ -549,3 +564,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..ee95560 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 (no clipboard) + 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 cb1f9f9..7b0f2ff 100644 --- a/NetBird/Source/App/Views/ServerView.swift +++ b/NetBird/Source/App/Views/ServerView.swift @@ -46,11 +46,8 @@ struct ServerView: View { return } if viewModel.setupKey == "" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isVerifyingServer = true - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - let sso = viewModel.updateManagementURL(url: viewModel.server) + isVerifyingServer = true + viewModel.updateManagementURL(url: viewModel.server) { sso in switch sso { case .none: viewModel.showInvalidServerAlert = true @@ -66,28 +63,22 @@ struct ServerView: View { case .some(false): showSetupKeyField = true } - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - isVerifyingServer = false - } + isVerifyingServer = false } } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isVerifyingKey = true - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - do { - try viewModel.setSetupKey(key: viewModel.setupKey) + isVerifyingKey = true + viewModel.setSetupKey(key: viewModel.setupKey) { error in + if error != nil { + viewModel.showInvalidSetupKeyAlert = true + } else { self.presentationMode.wrappedValue.dismiss() viewModel.showServerChangedInfo = true DispatchQueue.main.asyncAfter(deadline: .now() + 3) { viewModel.showServerChangedInfo = false } viewModel.setupKey = "" - isVerifyingKey = false - } catch { - viewModel.showInvalidSetupKeyAlert = true - isVerifyingKey = false } + isVerifyingKey = false } } print("use custom server") @@ -95,16 +86,19 @@ struct ServerView: View { .padding(.top, 5) Button { if !isVerifyingKey && !isVerifyingServer { - let sso = viewModel.updateManagementURL(url: "https://api.netbird.io") - print("use netbird server") - if sso ?? false { - self.presentationMode.wrappedValue.dismiss() - viewModel.showServerChangedInfo = true - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - viewModel.showServerChangedInfo = false + isVerifyingServer = true + viewModel.updateManagementURL(url: "https://api.netbird.io") { sso in + print("use netbird server") + if sso ?? false { + self.presentationMode.wrappedValue.dismiss() + viewModel.showServerChangedInfo = true + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + viewModel.showServerChangedInfo = false + } + } else { + showSetupKeyField = true } - } else { - showSetupKeyField = true + isVerifyingServer = false } } } label: { diff --git a/NetBird/Source/App/Views/TV/TVAuthView.swift b/NetBird/Source/App/Views/TV/TVAuthView.swift new file mode 100644 index 0000000..7cb7575 --- /dev/null +++ b/NetBird/Source/App/Views/TV/TVAuthView.swift @@ -0,0 +1,301 @@ +// +// 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)? + + /// Reference to check login status (async - calls completion with true if login is complete) + var checkLoginComplete: ((@escaping (Bool) -> 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? + + var body: some View { + ZStack { + // Dark overlay background + Color.black.opacity(0.9) + .ignoresSafeArea() + + HStack(spacing: 80) { + // MARK: 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)) + ) + + // MARK: Divider + Rectangle() + .fill(Color.white.opacity(0.2)) + .frame(width: 2, height: 600) + + // MARK: 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) + ) + ) + } + } + + // Loading indicator + 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() + } + } + + // MARK: - 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) + } + + // MARK: - 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() { + print("TVAuthView: Starting polling for login completion") + pollTimer?.invalidate() + + // Capture the closures we need + let checkComplete = self.checkLoginComplete + let onCompleteHandler = self.onComplete + + // Schedule timer on main run loop to ensure it fires + let timer = Timer(timeInterval: 2.0, repeats: true) { [self] timer in + print("TVAuthView: Poll tick - checking login status via extension IPC...") + + guard let checkComplete = checkComplete else { + print("TVAuthView: No checkLoginComplete closure provided") + return + } + + checkComplete { isComplete in + DispatchQueue.main.async { + print("TVAuthView: Login complete = \(isComplete)") + if isComplete { + print("TVAuthView: Login detected as complete, dismissing auth view") + timer.invalidate() + onCompleteHandler?() + self.isPresented = false + } + } + } + } + RunLoop.main.add(timer, forMode: .common) + pollTimer = timer + + // Fire immediately once to check current status + print("TVAuthView: Performing initial login check...") + guard let checkComplete = checkComplete else { + print("TVAuthView: No checkLoginComplete closure provided") + return + } + checkComplete { isComplete in + DispatchQueue.main.async { + print("TVAuthView: Initial check - login complete = \(isComplete)") + if isComplete { + print("TVAuthView: Login already complete, dismissing auth view") + self.pollTimer?.invalidate() + onCompleteHandler?() + self.isPresented = false + } + } + } + } +} + +/// 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/TVMainView.swift b/NetBird/Source/App/Views/TV/TVMainView.swift new file mode 100644 index 0000000..eb3adac --- /dev/null +++ b/NetBird/Source/App/Views/TV/TVMainView.swift @@ -0,0 +1,351 @@ +// +// 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") + +// MARK: - tvOS Color Helpers (local definition) +private struct TVColors { + static var textPrimary: Color { + UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary + } + static var textSecondary: Color { + UIColor(named: "TextSecondary") != nil ? Color("TextSecondary") : .secondary + } + static var bgMenu: Color { + UIColor(named: "BgMenu") != nil ? Color("BgMenu") : Color(white: 0.1) + } + static var bgPrimary: Color { + UIColor(named: "BgPrimary") != nil ? Color("BgPrimary") : Color(white: 0.15) + } + static var bgSecondary: Color { + UIColor(named: "BgSecondary") != nil ? Color("BgSecondary") : Color(white: 0.08) + } +} + +/// The main view for Apple TV, using top-level tab navigation. +struct TVMainView: View { + @EnvironmentObject var viewModel: ViewModel + + /// Currently selected tab + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + // MARK: - Connection Tab (Home) + TVConnectionView() + .tabItem { + Label("Connection", systemImage: "network") + } + .tag(0) + + // MARK: - Peers Tab + TVPeersView() + .tabItem { + Label("Peers", systemImage: "person.3.fill") + } + .tag(1) + + // MARK: - Networks Tab + TVNetworksView() + .tabItem { + Label("Networks", systemImage: "globe") + } + .tag(2) + + // MARK: - Settings Tab (replaces side drawer) + TVSettingsView() + .tabItem { + Label("Settings", systemImage: "gear") + } + .tag(3) + } + .environmentObject(viewModel) + // MARK: - 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: { + // User cancelled authentication + viewModel.networkExtensionAdapter.showBrowser = false + }, + onComplete: { + // Authentication completed - start VPN connection + print("Login completed, starting VPN connection...") + viewModel.networkExtensionAdapter.startVPNConnection() + }, + checkLoginComplete: { completion in + // Check if login is complete by asking the Network Extension directly + // This is more reliable because it queries the same SDK client doing the login + viewModel.networkExtensionAdapter.checkLoginComplete { isComplete in + print("TVMainView: checkLoginComplete returned \(isComplete)") + completion(isComplete) + } + } + ) + } + } + } +} + +// MARK: - Connection View (Home Screen) +/// The main connection screen showing VPN status and quick actions. +struct TVConnectionView: View { + @EnvironmentObject var viewModel: ViewModel + + var body: some View { + ZStack { + // Background + TVColors.bgSecondary + .ignoresSafeArea() + + HStack(spacing: 100) { + // MARK: Left Side - Connection Control + VStack(spacing: 40) { + // Logo + 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)) + } + + // Big Connect/Disconnect Button + TVConnectionButton(viewModel: viewModel) + + // Status text + Text(viewModel.extensionStateText) + .font(.system(size: 32, weight: .medium)) + .foregroundColor(statusColor) + } + .frame(maxWidth: .infinity) + + // MARK: 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) + } + } + + // MARK: 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 + } +} + +// MARK: - Connection Button +/// Large, focusable connect/disconnect button for tvOS. +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() + } + } +} + +// MARK: - Stat Card +/// Displays a single statistic in a card format. +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) + } +} + +// MARK: - Preview +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..3ce8883 --- /dev/null +++ b/NetBird/Source/App/Views/TV/TVNetworksView.swift @@ -0,0 +1,252 @@ +// +// 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) + +// MARK: - tvOS Color Helpers (local definition) +private struct TVColors { + static var textPrimary: Color { + UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary + } + static var textSecondary: Color { + UIColor(named: "TextSecondary") != nil ? Color("TextSecondary") : .secondary + } + static var bgMenu: Color { + UIColor(named: "BgMenu") != nil ? Color("BgMenu") : Color(white: 0.1) + } + static var bgPrimary: Color { + UIColor(named: "BgPrimary") != nil ? Color("BgPrimary") : Color(white: 0.15) + } +} + +/// 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() + } + } +} + +// MARK: - Network List Content +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 + 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) + + // Refresh button + Button(action: refresh) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 28)) + .rotationEffect(.degrees(isRefreshing ? 360 : 0)) + .animation( + isRefreshing ? .linear(duration: 1).repeatForever(autoreverses: false) : .default, + value: isRefreshing + ) + } + .buttonStyle(.plain) + .padding(.leading, 30) + } + .padding(.horizontal, 80) + .padding(.top, 40) + + // Filter bar + TVFilterBar( + options: ["All", "Enabled", "Disabled"], + selected: $viewModel.routeViewModel.selectionFilter + ) + .padding(.horizontal, 80) + + // 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(.horizontal, 80) + .padding(.bottom, 80) + } + } + } + + // MARK: Computed Properties + + private var activeCount: Int { + viewModel.routeViewModel.routeInfo.filter { $0.selected }.count + } + + private var totalCount: Int { + viewModel.routeViewModel.routeInfo.count + } + + // MARK: Actions + + private func refresh() { + isRefreshing = true + viewModel.routeViewModel.getRoutes() + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + isRefreshing = false + } + } +} + +// MARK: - 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.network ?? route.name) + .font(.system(size: 26, weight: .semibold)) + .foregroundColor(TVColors.textPrimary) + .lineLimit(1) + + if let domains = route.domains, !domains.isEmpty { + Text(domains.map { $0.domain }.joined(separator: ", ")) + .font(.system(size: 20)) + .foregroundColor(TVColors.textSecondary) + .lineLimit(2) + } + } + + Spacer() + + // Enabled/Disabled badge + Text(route.selected ? "Enabled" : "Disabled") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(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 func toggleRoute() { + if route.selected { + routeViewModel.deselectRoute(route: route) + } else { + routeViewModel.selectRoute(route: route) + } + } +} + +// MARK: - Empty State +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) + } + } +} + +// MARK: - Preview +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..ed18466 --- /dev/null +++ b/NetBird/Source/App/Views/TV/TVPeersView.swift @@ -0,0 +1,346 @@ +// +// 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) + +// MARK: - tvOS Color Helpers (local definition) +private struct TVColors { + static var textPrimary: Color { + UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary + } + static var textSecondary: Color { + UIColor(named: "TextSecondary") != nil ? Color("TextSecondary") : .secondary + } + static var bgMenu: Color { + UIColor(named: "BgMenu") != nil ? Color("BgMenu") : Color(white: 0.1) + } + static var bgPrimary: Color { + UIColor(named: "BgPrimary") != nil ? Color("BgPrimary") : Color(white: 0.15) + } + static var bgSideDrawer: Color { + UIColor(named: "BgSideDrawer") != nil ? Color("BgSideDrawer") : Color(white: 0.2) + } +} + +/// Displays the list of peers in a tvOS-friendly format. +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() + } + } + } +} + +// MARK: - Peer List Content +struct TVPeerListContent: View { + @EnvironmentObject var viewModel: ViewModel + + /// Currently selected peer for detail view + @State private var selectedPeer: PeerInfo? + + /// Search/filter text + @State private var searchText = "" + + var body: some View { + HStack(spacing: 0) { + // MARK: 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) + + // Filter buttons + TVFilterBar( + options: ["All", "Connected", "Connecting", "Idle"], + selected: $viewModel.peerViewModel.selectionFilter + ) + .padding(.horizontal, 50) + + // 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(.horizontal, 50) + .padding(.bottom, 50) + } + } + .frame(maxWidth: .infinity) + + // MARK: Right Side - Peer Details + if let peer = selectedPeer { + TVPeerDetailView(peer: peer) + .frame(width: 500) + .transition(.move(edge: .trailing)) + } + } + } + + // MARK: 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 + } +} + +// MARK: - 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 + } + } +} + +// MARK: - Peer Detail Panel +struct TVPeerDetailView: View { + let peer: PeerInfo + + var body: some View { + VStack(alignment: .leading, spacing: 30) { + // Header + 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) + } +} + +// MARK: - Detail Row +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) + } + } +} + +// MARK: - Filter Bar +struct TVFilterBar: View { + let options: [String] + @Binding var selected: String + + var body: some View { + HStack(spacing: 15) { + 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 ? .semibold : .regular)) + .foregroundColor(isSelected ? .white : TVColors.textSecondary) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background( + Capsule() + .fill(isSelected ? Color.accentColor : TVColors.bgPrimary) + ) + .overlay( + Capsule() + .stroke(isFocused ? Color.white : Color.clear, lineWidth: 3) + ) + } + .buttonStyle(.plain) + .focused($isFocused) + } +} + +// MARK: - Empty State +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) + } + } +} + +// MARK: - Preview +struct TVPeersView_Previews: PreviewProvider { + static var previews: some View { + TVPeersView() + .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..a343f69 --- /dev/null +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -0,0 +1,355 @@ +// +// 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) + +// MARK: - tvOS Color Helpers (local definition) +private struct TVColors { + static var textPrimary: Color { + UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary + } + static var textSecondary: Color { + UIColor(named: "TextSecondary") != nil ? Color("TextSecondary") : .secondary + } + static var textAlert: Color { + UIColor(named: "TextAlert") != nil ? Color("TextAlert") : .white + } + static var bgMenu: Color { + UIColor(named: "BgMenu") != nil ? Color("BgMenu") : Color(white: 0.1) + } + static var bgPrimary: Color { + UIColor(named: "BgPrimary") != nil ? Color("BgPrimary") : Color(white: 0.15) + } + static var bgSideDrawer: Color { + UIColor(named: "BgSideDrawer") != nil ? Color("BgSideDrawer") : Color(white: 0.2) + } +} + +/// 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) { + // MARK: Left Side - Settings List + VStack(alignment: .leading, spacing: 30) { + // Header + Text("Settings") + .font(.system(size: 48, weight: .bold)) + .foregroundColor(TVColors.textPrimary) + .padding(.bottom, 20) + + // Settings options + ScrollView { + VStack(spacing: 20) { + // Server settings + TVSettingsSection(title: "Connection") { + TVSettingsRow( + icon: "server.rack", + title: "Change Server", + subtitle: "Switch to a different NetBird server", + action: { viewModel.showChangeServerAlert = true } + ) + } + + // Advanced settings + 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) } + ) + ) + } + + // Help section + TVSettingsSection(title: "Help") { + TVSettingsRow( + icon: "book.fill", + title: "Documentation", + subtitle: "docs.netbird.io", + action: nil // Can't open URLs directly on tvOS + ) + + TVSettingsRow( + icon: "info.circle.fill", + title: "About", + subtitle: "Version \(appVersion)", + action: nil + ) + } + } + } + } + .padding(80) + .frame(maxWidth: .infinity, alignment: .leading) + + // MARK: 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" + } +} + +// MARK: - Settings Section +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) + ) + } + } +} + +// MARK: - Settings Row (Tappable) +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(TVColors.textPrimary) + + Text(subtitle) + .font(.system(size: 18)) + .foregroundColor(TVColors.textSecondary) + } + + Spacer() + + if action != nil { + Image(systemName: "chevron.right") + .font(.system(size: 20)) + .foregroundColor(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) + } +} + +// MARK: - Settings Toggle Row +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(TVColors.textPrimary) + + Text(subtitle) + .font(.system(size: 18)) + .foregroundColor(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) + } +} + +// MARK: - Change Server Alert +struct TVChangeServerAlert: View { + @ObservedObject var viewModel: ViewModel + + @FocusState private var confirmFocused: Bool + @FocusState private var cancelFocused: Bool + + 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($cancelFocused) + + // 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($confirmFocused) + } + } + .padding(60) + .background( + RoundedRectangle(cornerRadius: 30) + .fill(TVColors.bgSideDrawer) + ) + } + } +} + +// MARK: - Preview +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..3ffb0ba --- /dev/null +++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift @@ -0,0 +1,690 @@ +// +// 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") + +// MARK: - 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)") + NSLog("NetBirdTV: skipping file-based logging on tvOS") + #endif + + currentNetworkType = nil + startMonitoringNetworkChanges() + logger.info("startTunnel: network monitoring started") + + // Initialize config file if it doesn't exist (tvOS only) + // This must be done in the extension because it has permission to write to the App Group + logger.info("startTunnel: calling initializeConfigIfNeeded()...") + NSLog("NetBirdTV: calling initializeConfigIfNeeded...") + initializeConfigIfNeeded() + logger.info("startTunnel: initializeConfigIfNeeded() completed") + NSLog("NetBirdTV: initializeConfigIfNeeded completed") + + logger.info("startTunnel: calling adapter.needsLogin()...") + NSLog("NetBirdTV: calling adapter.needsLogin...") + let needsLogin = adapter.needsLogin() + logger.info("startTunnel: needsLogin = \(needsLogin, privacy: .public)") + NSLog("NetBirdTV: startTunnel needsLogin = %@", needsLogin ? "true" : "false") + + if needsLogin { + logger.info("startTunnel: Login required, returning error after 2 second delay") + NSLog("NetBirdTV: startTunnel Login required, returning error") + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + let error = NSError( + domain: "io.netbird.NetBirdTVNetworkExtension", + code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Login required."] + ) + completionHandler(error) + } + return + } + + logger.info("startTunnel: Login NOT required, starting adapter...") + NSLog("NetBirdTV: startTunnel Login NOT required, starting adapter") + adapter.start { [self] error in + if let error = error { + logger.error("startTunnel: adapter.start() FAILED: \(error.localizedDescription, privacy: .public)") + NSLog("NetBirdTV: adapter.start FAILED: %@", error.localizedDescription) + completionHandler(error) + } else { + logger.info("startTunnel: adapter.start() SUCCEEDED - VPN is connected!") + NSLog("NetBirdTV: adapter.start SUCCEEDED - VPN is connected!") + completionHandler(nil) + } + } + logger.info("startTunnel: adapter.start() called, waiting for completion...") + } + + override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + logger.info("stopTunnel: Stopping tunnel, reason: \(String(describing: reason))") + adapter.stop() + guard let pathMonitor = self.pathMonitor else { + logger.info("stopTunnel: pathMonitor is nil; nothing to cancel.") + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + completionHandler() + } + return + } + pathMonitor.cancel() + self.pathMonitor = nil + logger.info("stopTunnel: Tunnel stopped successfully") + 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) + case let s where s.hasPrefix("Deselect-"): + let id = String(s.dropFirst("Deselect-".count)) + deselectRoute(id: id) + default: + logger.warning("handleAppMessage: Unknown message: \(string)") + } + } + + 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 { [self] 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) { + logger.info("login: Starting PKCE login flow") + let urlString = adapter.login() + let data = urlString.data(using: .utf8) + completionHandler(data) + } + + /// Initialize config with default 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) { + let configPath = Preferences.configFile() + 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 + } + + logger.info("initializeConfig: No config found, initializing with default management URL") + + // Create Auth object with default management URL + guard let auth = NetBirdSDKNewAuth(configPath, NetBirdAdapter.defaultManagementURL, 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 = { [self] 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 + /// This ensures the config is available before we check needsLogin() + /// On tvOS, config is loaded from UserDefaults directly into memory (file writes are blocked) + private func initializeConfigIfNeeded() { + logger.info("initializeConfigIfNeeded: ENTRY") + NSLog("NetBirdTV: initializeConfigIfNeeded ENTRY") + + let configPath = Preferences.configFile() + let fileManager = FileManager.default + logger.info("initializeConfigIfNeeded: configPath = \(configPath, privacy: .public)") + + // Check if config already exists as a file + let fileExists = fileManager.fileExists(atPath: configPath) + logger.info("initializeConfigIfNeeded: fileExists = \(fileExists, privacy: .public)") + NSLog("NetBirdTV: configPath=%@, fileExists=%@", configPath, fileExists ? "true" : "false") + + if fileExists { + logger.info("initializeConfigIfNeeded: Config file exists, returning early") + NSLog("NetBirdTV: Config file exists, returning early") + return + } + + // On tvOS, try to load config from UserDefaults directly into memory + // (file writes to App Group are blocked on tvOS) + logger.info("initializeConfigIfNeeded: No config file, checking UserDefaults...") + let hasConfig = Preferences.hasConfigInUserDefaults() + logger.info("initializeConfigIfNeeded: hasConfigInUserDefaults = \(hasConfig, privacy: .public)") + NSLog("NetBirdTV: hasConfigInUserDefaults = %@", hasConfig ? "true" : "false") + + if hasConfig { + logger.info("initializeConfigIfNeeded: Found config in UserDefaults, loading...") + NSLog("NetBirdTV: Found config in UserDefaults, loading...") + if let configJSON = Preferences.loadConfigFromUserDefaults() { + let configSize = configJSON.count + logger.info("initializeConfigIfNeeded: Got config JSON (\(configSize, privacy: .public) bytes)") + NSLog("NetBirdTV: Got config JSON (%d bytes)", configSize) + + // Log first 200 chars of config for debugging (remove sensitive data) + let preview = String(configJSON.prefix(200)) + logger.info("initializeConfigIfNeeded: Config preview: \(preview, privacy: .public)...") + + do { + logger.info("initializeConfigIfNeeded: Calling adapter.client.setConfigFromJSON()...") + NSLog("NetBirdTV: Calling setConfigFromJSON...") + try adapter.client.setConfigFromJSON(configJSON) + logger.info("initializeConfigIfNeeded: SUCCESS - config loaded into Client memory") + NSLog("NetBirdTV: SUCCESS - config loaded into Client memory") + return + } catch { + let errorMsg = error.localizedDescription + logger.error("initializeConfigIfNeeded: FAILED to set config: \(errorMsg, privacy: .public)") + NSLog("NetBirdTV: FAILED to set config: %@", errorMsg) + // On tvOS, we cannot fall back to file-based config - it will fail + #if os(tvOS) + logger.error("initializeConfigIfNeeded: tvOS - cannot fall back to file-based config") + NSLog("NetBirdTV: tvOS - cannot fall back to file-based config, returning") + return + #endif + } + } else { + logger.warning("initializeConfigIfNeeded: Config key exists but failed to load string") + NSLog("NetBirdTV: Config key exists but failed to load string") + } + } else { + logger.info("initializeConfigIfNeeded: No config in UserDefaults") + NSLog("NetBirdTV: No config in UserDefaults") + } + + #if os(tvOS) + // On tvOS, if we get here without config, we cannot create one via file writes + // The user needs to authenticate first via the device code flow + logger.warning("initializeConfigIfNeeded: tvOS - no config available, user needs to authenticate") + NSLog("NetBirdTV: tvOS - no config available, user needs to authenticate") + // Return early on tvOS - file-based config initialization will fail + #else + // On iOS, try to create config via file writes (this works on iOS) + logger.info("initializeConfigIfNeeded: No config found, initializing with default management URL: \(NetBirdAdapter.defaultManagementURL)") + + // Create Auth object with default management URL + guard let auth = NetBirdSDKNewAuth(configPath, NetBirdAdapter.defaultManagementURL, nil) else { + logger.error("initializeConfigIfNeeded: Failed to create Auth object") + return + } + + // Use a semaphore to make this synchronous + let semaphore = DispatchSemaphore(value: 0) + + let listener = ConfigInitSSOListener() + listener.onResult = { ssoSupported, error in + if let error = error { + self.logger.error("initializeConfigIfNeeded: Error checking SSO - \(error.localizedDescription)") + } else if let supported = ssoSupported { + self.logger.info("initializeConfigIfNeeded: SSO supported = \(supported)") + let configExists = fileManager.fileExists(atPath: configPath) + self.logger.info("initializeConfigIfNeeded: Config exists after save = \(configExists)") + } else { + self.logger.warning("initializeConfigIfNeeded: Unknown result") + } + semaphore.signal() + } + + auth.saveConfigIfSSOSupported(listener) + + // Wait for completion (with timeout) + let result = semaphore.wait(timeout: .now() + 10) + if result == .timedOut { + logger.warning("initializeConfigIfNeeded: Timed out waiting for config initialization") + } + #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) { + // 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 = fileManager.fileExists(atPath: configPath) + let stateExists = 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") + + // Initialize config file BEFORE attempting login + // This ensures the Auth object has a valid config to save credentials to + initializeConfigIfNeeded() + + // Verify config was created + let configPath = Preferences.configFile() + let configExists = FileManager.default.fileExists(atPath: configPath) + logger.info("loginTV: After initializeConfigIfNeeded, configExists=\(configExists), path=\(configPath)") + + // Track if we've already sent the URL to the app + var urlSentToApp = false + let urlSentLock = NSLock() + + logger.info("loginTV: Calling adapter.loginAsync with forceDeviceAuth=true") + + adapter.loginAsync( + forceDeviceAuth: true, + onURL: { [self] 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: { [self] in + // 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 = \(fileManager.fileExists(atPath: configPath))") + logger.info("loginTV: stateFile exists = \(fileManager.fileExists(atPath: statePath))") + }, + onError: { [self] 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 statusDetailsMessage = adapter.client.getStatusDetails() else { + logger.warning("getStatus: Did not receive status details.") + completionHandler(nil) + return + } + + var peerInfoArray: [PeerInfo] = [] + for i in 0.. Void) { + 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) { + 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) { + 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) { [self] 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 8fff1f7..cc0f855 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/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index d52c44f..604f97e 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 + +// MARK: - 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 TV Network Extension" + #else var extensionID = "io.netbird.app.NetbirdNetworkExtension" var extensionName = "NetBird Network Extension" + #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,193 @@ 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. + /// This may work on tvOS where the extension doesn't have write access. + private func initializeConfigFromApp() async { + let configPath = Preferences.configFile() + let fileManager = FileManager.default + + // Check if config already exists + if fileManager.fileExists(atPath: configPath) { + print("initializeConfigFromApp: Config already exists at \(configPath)") + return + } + + print("initializeConfigFromApp: No config found, attempting to create from main app...") + + // Try to create the config using the SDK + // This creates a new config with WireGuard keys and saves it + guard let auth = NetBirdSDKNewAuth(configPath, "https://api.netbird.io", nil) else { + print("initializeConfigFromApp: Failed to create Auth object") + return + } + + // Use withCheckedContinuation for proper async/await pattern + let success: Bool = await withCheckedContinuation { continuation in + let listener = ConfigSSOListener() + listener.onResult = { ssoSupported, error in + if let error = error { + print("initializeConfigFromApp: Error - \(error.localizedDescription)") + continuation.resume(returning: false) + } else if ssoSupported != nil { + let configExists = fileManager.fileExists(atPath: configPath) + print("initializeConfigFromApp: Config exists after save = \(configExists)") + continuation.resume(returning: configExists) + } else { + continuation.resume(returning: false) + } + } + auth.saveConfigIfSSOSupported(listener) + } + + if success { + print("initializeConfigFromApp: Successfully created config from main app!") + } else { + print("initializeConfigFromApp: Failed to create config from main app (extension will try)") + } } + #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") + let configPath = Preferences.configFile() + let statePath = Preferences.stateFile() + logger.info("isLoginRequired: checking config at \(configPath), state at \(statePath)") + + // Debug: Check if files exist and their sizes + let fileManager = FileManager.default + let configExists = fileManager.fileExists(atPath: configPath) + let stateExists = fileManager.fileExists(atPath: statePath) + logger.info("isLoginRequired: configFile exists = \(configExists), stateFile exists = \(stateExists)") + + #if os(tvOS) + // On tvOS, the app doesn't have permission to write to App Group container. + // File writes are blocked, so we check UserDefaults instead. + // Config is saved to UserDefaults after successful login. + let hasConfigInUserDefaults = Preferences.hasConfigInUserDefaults() + logger.info("isLoginRequired: tvOS - hasConfigInUserDefaults = \(hasConfigInUserDefaults)") + + if !hasConfigInUserDefaults { + // No config in UserDefaults - user definitely needs to login + logger.info("isLoginRequired: tvOS - no config in UserDefaults, login required") + return true + } + + // Config exists - but we need to verify with the management server + // that the session is still valid (tokens can expire) + logger.info("isLoginRequired: tvOS - config found, checking with management server...") + + // Create a Client and load config from UserDefaults + guard let client = NetBirdSDKNewClient("", "", Device.getName(), Device.getOsVersion(), Device.getOsName(), nil, nil) else { + logger.error("isLoginRequired: tvOS - failed to create SDK client") + return true + } + + // Load the config from UserDefaults into the client + if let configJSON = Preferences.loadConfigFromUserDefaults() { + do { + try client.setConfigFromJSON(configJSON) + logger.info("isLoginRequired: tvOS - loaded config from UserDefaults into client") + } catch { + logger.error("isLoginRequired: tvOS - failed to load config: \(error.localizedDescription)") + return true + } + } else { + logger.error("isLoginRequired: tvOS - no config JSON in UserDefaults") return true } - return client.isLoginRequired() + + // 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 { @@ -117,16 +321,22 @@ public class NetworkExtensionAdapter: ObservableObject { } 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)") } } @@ -137,18 +347,37 @@ public class NetworkExtensionAdapter: ObservableObject { 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 +390,54 @@ 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) + } + } + func getRoutes(completion: @escaping (RoutesSelectionDetails) -> Void) { guard let session = self.session else { let defaultStatus = RoutesSelectionDetails(all: false, append: false, routeSelectionInfo: []) diff --git a/NetbirdKit/Preferences.swift b/NetbirdKit/Preferences.swift index 74e959e..429863b 100644 --- a/NetbirdKit/Preferences.swift +++ b/NetbirdKit/Preferences.swift @@ -9,22 +9,110 @@ import Foundation import NetBirdSDK 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? { + #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(configFile(), stateFile()) + #else + return NetBirdSDKNewPreferences(configFile(), stateFile()) + #endif } static func configFile() -> String { let fileManager = FileManager.default - let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.io.netbird.app") + let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) let logURL = groupURL?.appendingPathComponent("netbird.cfg") return logURL!.relativePath } - + static func stateFile() -> String { let fileManager = FileManager.default - let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.io.netbird.app") + let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) let logURL = groupURL?.appendingPathComponent("state.json") return logURL!.relativePath } - + + // MARK: - 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 { + print("Preferences: Failed to get shared UserDefaults") + return false + } + defaults.set(configJSON, forKey: configJSONKey) + defaults.synchronize() + print("Preferences: Saved config to UserDefaults (\(configJSON.count) bytes)") + return true + } + + /// Load config JSON from UserDefaults + static func loadConfigFromUserDefaults() -> String? { + guard let defaults = sharedUserDefaults() else { + print("Preferences: Failed to get shared UserDefaults") + return nil + } + let config = defaults.string(forKey: configJSONKey) + if let config = config { + print("Preferences: Loaded config from UserDefaults (\(config.count) bytes)") + } else { + print("Preferences: No config found in UserDefaults") + } + return config + } + + /// 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() + print("Preferences: Removed config from UserDefaults") + } + + /// Restore config from UserDefaults to the config file path + /// This is needed because the Go SDK reads from the file path + /// Returns true if config was restored successfully + static func restoreConfigFromUserDefaults() -> Bool { + guard let configJSON = loadConfigFromUserDefaults() else { + return false + } + + let path = configFile() + do { + try configJSON.write(toFile: path, atomically: false, encoding: .utf8) + print("Preferences: Restored config to file: \(path)") + return true + } catch { + print("Preferences: Failed to write config to file: \(error.localizedDescription)") + 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 a98eef1..bb3c35f 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -10,23 +10,102 @@ import NetworkExtension import NetBirdSDK import os +/// Logger for NetBirdAdapter - visible in Console.app +private let adapterLogger = Logger(subsystem: "io.netbird.adapter", category: "NetBirdAdapter") + +// MARK: - URL Opener for Login Flow +/// Handles OAuth URL opening and login success callbacks +class LoginURLOpener: NSObject, NetBirdSDKURLOpenerProtocol { + /// Callback when URL needs to be opened (with user code for device flow) + var onOpen: ((String, String) -> Void)? + /// Callback when login succeeds + var onSuccess: (() -> Void)? + + func open(_ url: String?, userCode: String?) { + adapterLogger.info("LoginURLOpener.open() called with url=\(url ?? "nil", privacy: .public), userCode=\(userCode ?? "nil", privacy: .public)") + guard let url = url else { return } + onOpen?(url, userCode ?? "") + } + + func onLoginSuccess() { + adapterLogger.info("LoginURLOpener.onLoginSuccess() called!") + print(">>> LoginURLOpener.onLoginSuccess() called! <<<") + onSuccess?() + } +} + +// MARK: - Error Listener for Async Operations +/// Handles error callbacks from async SDK operations +class LoginErrListener: NSObject, NetBirdSDKErrListenerProtocol { + var onErrorCallback: ((Error?) -> Void)? + var onSuccessCallback: (() -> Void)? + + func onError(_ err: Error?) { + adapterLogger.error("LoginErrListener.onError() called with: \(err?.localizedDescription ?? "nil", privacy: .public)") + print(">>> LoginErrListener.onError() called with: \(err?.localizedDescription ?? "nil") <<<") + onErrorCallback?(err) + } + + func onSuccess() { + // SDK calls this when the operation succeeds (e.g., device auth completed) + // This is NOT an error - call the success handler + adapterLogger.info("LoginErrListener.onSuccess() called!") + print(">>> LoginErrListener.onSuccess() called! <<<") + onSuccessCallback?() + } +} + +// MARK: - SSO Listener for Config Save +/// Used to save config after successful login +class LoginConfigSaveListener: NSObject, NetBirdSDKSSOListenerProtocol { + var onResult: ((Bool?, Error?) -> Void)? + + func onSuccess(_ ssoSupported: Bool) { + adapterLogger.info("LoginConfigSaveListener.onSuccess() called with ssoSupported=\(ssoSupported)") + onResult?(ssoSupported, nil) + } + + func onError(_ error: Error?) { + adapterLogger.error("LoginConfigSaveListener.onError() called with: \(error?.localizedDescription ?? "nil", privacy: .public)") + 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,11 +131,105 @@ 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 + let AF_SYS_CONTROL: UInt16 = 2 + let SYSPROTO_CONTROL: Int32 = 2 + let UTUN_OPT_IFNAME: Int32 = 2 + // 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 @@ -99,10 +272,20 @@ public class NetBirdAdapter { public func start(completionHandler: @escaping (Error?) -> Void) { DispatchQueue.global().async { do { + let fd = self.tunnelFileDescriptor ?? 0 + let ifName = self.interfaceName ?? "unknown" + adapterLogger.info("start: tunnelFileDescriptor = \(fd), interfaceName = \(ifName, privacy: .public)") + + if fd == 0 { + adapterLogger.error("start: WARNING - File descriptor is 0, WireGuard may not work properly!") + } + let connectionListener = ConnectionListener(adapter: self, completionHandler: completionHandler) self.client.setConnectionListener(connectionListener) - try self.client.run(self.tunnelFileDescriptor ?? 0, interfaceName: self.interfaceName) + adapterLogger.info("start: Calling client.run() with fd=\(fd), interfaceName=\(ifName, privacy: .public)") + try self.client.run(fd, interfaceName: ifName) } catch { + adapterLogger.error("start: client.run() failed: \(error.localizedDescription, privacy: .public)") completionHandler(NSError(domain: "io.netbird.NetbirdNetworkExtension", code: 1001, userInfo: [NSLocalizedDescriptionKey: "Netbird client startup failed."])) self.stop() } @@ -112,12 +295,174 @@ 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 + ) { + adapterLogger.info("loginAsync: Starting async login with forceDeviceAuth=\(forceDeviceAuth)") + self.isExecutingLogin = true + + // Track completion to prevent duplicate callbacks + // Both urlOpener.onLoginSuccess and errListener.onSuccess might be called + 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 + adapterLogger.info("loginAsync: handleSuccess called") + completionLock.lock() + guard !completionCalled else { + completionLock.unlock() + adapterLogger.info("loginAsync: Success already handled, ignoring duplicate") + return + } + completionCalled = true + completionLock.unlock() + + adapterLogger.info("loginAsync: Login succeeded, now saving config...") + + // After successful login, save the config to persist credentials + // The Auth.login() may authenticate but not write to disk + if let auth = authRef { + // First, try to get config JSON and save to UserDefaults + // This is the tvOS-compatible storage that works when file writes fail + var getConfigError: NSError? + let configJSON = auth.getConfigJSON(&getConfigError) + if let error = getConfigError { + adapterLogger.error("loginAsync: Failed to get config JSON: \(error.localizedDescription, privacy: .public)") + } else if !configJSON.isEmpty { + adapterLogger.info("loginAsync: Got config JSON (\(configJSON.count) bytes), saving to UserDefaults") + if Preferences.saveConfigToUserDefaults(configJSON) { + adapterLogger.info("loginAsync: Config saved to UserDefaults successfully") + } else { + adapterLogger.error("loginAsync: Failed to save config to UserDefaults") + } + } else { + adapterLogger.warning("loginAsync: getConfigJSON returned empty string") + } + + // Also try the file-based save (may fail on tvOS but works on iOS) + let saveListener = LoginConfigSaveListener() + saveListener.onResult = { success, error in + if let error = error { + adapterLogger.error("loginAsync: Failed to save config to file after login: \(error.localizedDescription, privacy: .public)") + } else { + adapterLogger.info("loginAsync: Config saved to file successfully after login, ssoSupported=\(success ?? false)") + } + } + auth.saveConfigIfSSOSupported(saveListener) + } + + adapterLogger.info("loginAsync: Setting isExecutingLogin=false and calling onSuccess callback") + 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 + adapterLogger.error("loginAsync: handleError called with: \(error?.localizedDescription ?? "nil", privacy: .public)") + completionLock.lock() + guard !completionCalled else { + completionLock.unlock() + adapterLogger.info("loginAsync: Completion already handled, ignoring error") + return + } + completionCalled = true + completionLock.unlock() + + adapterLogger.info("loginAsync: Setting isExecutingLogin=false and calling onError callback") + 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 + // Go SDK calls this from a goroutine - dispatch to main thread + DispatchQueue.main.async { + onURL(url, userCode) + } + } + urlOpener.onSuccess = { + // Go SDK calls this from a goroutine - dispatch to main thread + DispatchQueue.main.async { + adapterLogger.info("loginAsync: urlOpener.onLoginSuccess called via onSuccess closure") + handleSuccess() + } + } + + // Create error listener + // Note: The SDK's ErrListener protocol has both onSuccess() and onError() + // onSuccess() is called when device auth completes successfully via this listener + let errListener = LoginErrListener() + errListener.onSuccessCallback = { + // Go SDK calls this from a goroutine - dispatch to main thread + // This is called when the device auth polling succeeds + DispatchQueue.main.async { + adapterLogger.info("loginAsync: errListener.onSuccessCallback called") + handleSuccess() + } + } + errListener.onErrorCallback = { error in + // Go SDK calls this from a goroutine - dispatch to main thread + DispatchQueue.main.async { + adapterLogger.error("loginAsync: errListener.onErrorCallback called with: \(error?.localizedDescription ?? "nil", privacy: .public)") + 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) + let managementURL = Self.defaultManagementURL + #else + let managementURL = "" + #endif + + adapterLogger.info("loginAsync: Creating Auth object with configFile=\(Preferences.configFile(), privacy: .public), managementURL=\(managementURL, privacy: .public)") + + // Get Auth object and call login + if let auth = NetBirdSDKNewAuth(Preferences.configFile(), managementURL, nil) { + // Store reference so handleSuccess can save config + authRef = auth + adapterLogger.info("loginAsync: Auth object created, calling auth.login()") + auth.login(errListener, urlOpener: urlOpener, forceDeviceAuth: forceDeviceAuth) + adapterLogger.info("loginAsync: auth.login() returned (async operation started)") + } else { + adapterLogger.error("loginAsync: Failed to create Auth object") + handleError(NSError(domain: "io.netbird", code: 1002, userInfo: [NSLocalizedDescriptionKey: "Failed to create Auth object"])) + } + } + public func stop() { self.client.stop() } diff --git a/README.md b/README.md index 3e8ce80..240e7fa 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,9 @@ 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 (with tvOS support - see build instructions) ## Run locally @@ -67,15 +67,47 @@ 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 on Apple TV: +> +> 1. **Pair Apple TV with Xcode:** +> - Ensure your Mac and Apple TV are on the same Wi-Fi network +> - On Apple TV: Settings → Remotes and Devices → Remote App and Devices +> - In Xcode: Window → Devices and Simulators (⇧⌘2) +> - Select your Apple TV from "Discovered" and click "Pair" +> - Enter the 6-digit code shown on your Apple TV +> +> 2. **Enable Developer Mode on Apple TV (tvOS 16+):** +> - Settings → Privacy & Security → Developer Mode → ON +> - Apple TV will restart +> +> 3. **Build and Run:** +> - Select the "NetBird TV" scheme in Xcode +> - Choose your paired Apple TV as the run destination +> - Press ⌘R to build and run +> +> **Minimum Requirement:** Apple TV must be running tvOS 17.0 or later for VPN support. ## Other project repositories @@ -84,3 +116,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) From d4e573f86aec64603bb9d4bfcd9fb348990dd262 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Tue, 9 Dec 2025 14:41:36 +0100 Subject: [PATCH 02/19] cleanup --- NetBird/Source/App/NetBirdApp.swift | 4 +-- NetBird/Source/App/Platform/Platform.swift | 33 +++---------------- .../Source/App/ViewModels/MainViewModel.swift | 19 +++-------- .../App/Views/Components/SafariView.swift | 3 -- NetBird/Source/App/Views/MainView.swift | 2 -- NetBird/Source/App/Views/PeerTabView.swift | 2 +- NetBird/Source/App/Views/TV/TVAuthView.swift | 10 +++--- NetBird/Source/App/Views/TV/TVMainView.swift | 28 +++------------- .../Source/App/Views/TV/TVNetworksView.swift | 16 +++------ NetBird/Source/App/Views/TV/TVPeersView.swift | 20 +++-------- .../Source/App/Views/TV/TVSettingsView.swift | 14 ++------ .../PacketTunnelProvider.swift | 4 +-- NetbirdKit/NetworkExtensionAdapter.swift | 2 +- NetbirdKit/Preferences.swift | 2 +- NetbirdNetworkExtension/NetBirdAdapter.swift | 6 ++-- 15 files changed, 39 insertions(+), 126 deletions(-) diff --git a/NetBird/Source/App/NetBirdApp.swift b/NetBird/Source/App/NetBirdApp.swift index e0b3193..816519f 100644 --- a/NetBird/Source/App/NetBirdApp.swift +++ b/NetBird/Source/App/NetBirdApp.swift @@ -16,7 +16,7 @@ import FirebaseCore import FirebasePerformance #endif -// MARK: - App Delegate (iOS only) +// App Delegate is iOS only #if os(iOS) class AppDelegate: NSObject, UIApplicationDelegate { func application( @@ -33,7 +33,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { } #endif -// MARK: - Main App Entry Point @main struct NetBirdApp: App { @StateObject var viewModel = ViewModel() @@ -58,7 +57,6 @@ struct NetBirdApp: App { MainView() .environmentObject(viewModel) #if os(iOS) - // iOS uses UIApplication notifications .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in print("App is active!") viewModel.checkExtensionState() diff --git a/NetBird/Source/App/Platform/Platform.swift b/NetBird/Source/App/Platform/Platform.swift index 563a44f..f3be4ff 100644 --- a/NetBird/Source/App/Platform/Platform.swift +++ b/NetBird/Source/App/Platform/Platform.swift @@ -10,9 +10,8 @@ import SwiftUI import Combine -// MARK: - Screen Size Abstraction +// Screen Size Abstraction /// Replaces direct UIScreen.main.bounds usage which isn't ideal for tvOS. -/// tvOS has fixed resolutions (1080p or 4K), while iOS varies by device. struct Screen { /// Screen width in points @@ -25,7 +24,6 @@ struct Screen { #endif } - /// Screen height in points static var height: CGFloat { #if os(tvOS) return 1080 @@ -49,13 +47,10 @@ struct Screen { } } -// MARK: - Device Type Detection +// Device Type Detection /// Identifies what type of Apple device we're running on. /// Useful for conditional UI layouts and feature availability. -/// Named DeviceType to avoid conflict with NetbirdKit/Device.swift struct DeviceType { - - /// True if running on Apple TV static var isTV: Bool { #if os(tvOS) return true @@ -64,7 +59,6 @@ struct DeviceType { #endif } - /// True if running on iPad static var isPad: Bool { #if os(tvOS) return false @@ -73,7 +67,6 @@ struct DeviceType { #endif } - /// True if running on iPhone static var isPhone: Bool { #if os(tvOS) return false @@ -86,7 +79,7 @@ struct DeviceType { /// Useful for sizing UI elements proportionally. static var scaleFactor: CGFloat { if isTV { - return 2.0 // TV needs larger UI elements for 10-foot experience + return 2.0 // TV needs larger UI elements } else if isPad { return 1.3 } else { @@ -95,13 +88,7 @@ struct DeviceType { } } -// MARK: - Platform Capabilities -/// Describes what features are available on the current platform. -/// Use this to conditionally show/hide UI or enable/disable features. struct PlatformCapabilities { - - /// Whether the device supports VPN/Network Extensions - /// Note: Requires tvOS 17+ for Apple TV static var supportsVPN: Bool { #if os(tvOS) if #available(tvOS 17.0, *) { @@ -113,8 +100,6 @@ struct PlatformCapabilities { #endif } - /// Whether SFSafariViewController is available for in-app web browsing - /// tvOS doesn't have Safari, so we need alternative auth flows static var supportsSafariView: Bool { #if os(tvOS) return false @@ -123,8 +108,6 @@ struct PlatformCapabilities { #endif } - /// Whether the device has a touchscreen - /// tvOS uses the Siri Remote (focus-based navigation) static var hasTouchScreen: Bool { #if os(tvOS) return false @@ -133,8 +116,6 @@ struct PlatformCapabilities { #endif } - /// Whether clipboard/pasteboard is available - /// tvOS has limited clipboard support static var supportsClipboard: Bool { #if os(tvOS) return false @@ -143,7 +124,6 @@ struct PlatformCapabilities { #endif } - /// Whether keyboard input is available static var supportsKeyboard: Bool { #if os(tvOS) // tvOS has on-screen keyboard but it's clunky @@ -154,9 +134,6 @@ struct PlatformCapabilities { } } -// MARK: - Layout Constants -/// Pre-calculated layout values for consistent UI across platforms. -/// These are tuned for each platform's typical viewing distance and interaction model. struct Layout { /// Standard padding for content edges @@ -185,7 +162,7 @@ struct Layout { } } -// MARK: - Scaled Font Helper +// Scaled Font Helper /// Creates fonts that scale appropriately for each platform. extension Font { /// Creates a system font scaled for the current platform @@ -194,7 +171,7 @@ extension Font { } } -// MARK: - View Modifiers for Platform Adaptation +// View Modifiers for Platform Adaptation extension View { /// Applies platform-appropriate padding func platformPadding(_ edges: Edge.Set = .all) -> some View { diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 03a9c4a..40d5d45 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -18,7 +18,6 @@ import NetBirdSDK import UIKit #endif -// MARK: - SSO Listener for checking SSO support /// Used by updateManagementURL to check if SSO is supported class SSOCheckListener: NSObject, NetBirdSDKSSOListenerProtocol { var onResult: ((Bool?, Error?) -> Void)? @@ -32,7 +31,7 @@ class SSOCheckListener: NSObject, NetBirdSDKSSOListenerProtocol { } } -// MARK: - Error Listener for setup key login +// Error Listener for setup key login /// Used by setSetupKey to handle async login result class SetupKeyErrListener: NSObject, NetBirdSDKErrListenerProtocol { var onResult: ((Error?) -> Void)? @@ -46,18 +45,16 @@ class SetupKeyErrListener: NSObject, NetBirdSDKErrListenerProtocol { } } -// MARK: - Main ViewModel -/// Central ViewModel for the NetBird app, managing VPN state and UI. -/// Works on both iOS and tvOS (tvOS 17+ required for VPN support). +/// 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") - // MARK: - VPN Adapter (shared) + // VPN Adapter (shared) @Published var networkExtensionAdapter: NetworkExtensionAdapter - // MARK: - UI State (shared) + // UI State (shared) @Published var showSetupKeyPopup = false @Published var showChangeServerAlert = false @Published var showInvalidServerAlert = false @@ -75,7 +72,6 @@ class ViewModel: ObservableObject { @Published var presentSideDrawer = false @Published var navigateToServerView = false - // MARK: - VPN State @Published var extensionState: NEVPNStatus = .disconnected @Published var managementStatus: ClientState = .disconnected @Published var statusDetailsValid = false @@ -83,7 +79,6 @@ class ViewModel: ObservableObject { @Published var connectPressed = false @Published var disconnectPressed = false - // MARK: - Settings @Published var rosenpassEnabled = false @Published var rosenpassPermissive = false @Published var managementURL = "" @@ -92,11 +87,10 @@ class ViewModel: ObservableObject { @Published var setupKey: String = "" @Published var presharedKeySecure = true - // MARK: - Device Info (persisted) @Published var fqdn = UserDefaults.standard.string(forKey: "fqdn") ?? "" @Published var ip = UserDefaults.standard.string(forKey: "ip") ?? "" - // MARK: - Trace Logging + // Debug @Published var traceLogsEnabled: Bool { didSet { self.showLogLevelChangedAlert = true @@ -109,7 +103,6 @@ class ViewModel: ObservableObject { } } - // MARK: - Properties var preferences: NetBirdSDKPreferences? = Preferences.newPreferences() var buttonLock = false let defaults = UserDefaults.standard @@ -134,11 +127,9 @@ class ViewModel: ObservableObject { private var cancellables = Set() - // MARK: - Child ViewModels @Published var peerViewModel: PeerViewModel @Published var routeViewModel: RoutesViewModel - // MARK: - Initialization init() { let networkExtensionAdapter = NetworkExtensionAdapter() self.networkExtensionAdapter = networkExtensionAdapter diff --git a/NetBird/Source/App/Views/Components/SafariView.swift b/NetBird/Source/App/Views/Components/SafariView.swift index 56c2d88..c881844 100644 --- a/NetBird/Source/App/Views/Components/SafariView.swift +++ b/NetBird/Source/App/Views/Components/SafariView.swift @@ -3,7 +3,6 @@ // NetBird // // iOS-only: Wraps SFSafariViewController for in-app web authentication. -// tvOS does not have Safari, so it uses TVAuthView instead. // import SwiftUI @@ -12,8 +11,6 @@ import SwiftUI #if os(iOS) import SafariServices -/// Presents Safari in-app for OAuth authentication flows. -/// Used to handle login redirects without leaving the app. struct SafariView: UIViewControllerRepresentable { @Binding var isPresented: Bool let url: URL diff --git a/NetBird/Source/App/Views/MainView.swift b/NetBird/Source/App/Views/MainView.swift index 71c8ab9..3d06a6a 100644 --- a/NetBird/Source/App/Views/MainView.swift +++ b/NetBird/Source/App/Views/MainView.swift @@ -26,8 +26,6 @@ struct MainView: View { } } -// MARK: - iOS Main View -/// The original iOS implementation, now wrapped for platform selection. #if os(iOS) struct iOSMainView: View { @EnvironmentObject var viewModel: ViewModel diff --git a/NetBird/Source/App/Views/PeerTabView.swift b/NetBird/Source/App/Views/PeerTabView.swift index ee95560..70f878b 100644 --- a/NetBird/Source/App/Views/PeerTabView.swift +++ b/NetBird/Source/App/Views/PeerTabView.swift @@ -219,7 +219,7 @@ struct PeerCardView: View { peerViewModel.unfreezeDisplayedPeerList() } #else - // tvOS: Show info instead of copy (no clipboard) + // tvOS: Show info instead of copy Text("FQDN: \(peer.fqdn)") Text("IP: \(peer.ip)") #endif diff --git a/NetBird/Source/App/Views/TV/TVAuthView.swift b/NetBird/Source/App/Views/TV/TVAuthView.swift index 7cb7575..ff46900 100644 --- a/NetBird/Source/App/Views/TV/TVAuthView.swift +++ b/NetBird/Source/App/Views/TV/TVAuthView.swift @@ -49,7 +49,7 @@ struct TVAuthView: View { .ignoresSafeArea() HStack(spacing: 80) { - // MARK: Left Side - QR Code + // Left Side - QR Code VStack(spacing: 30) { Text("Scan to Sign In") .font(.system(size: 36, weight: .bold)) @@ -85,12 +85,12 @@ struct TVAuthView: View { .fill(Color.white.opacity(0.05)) ) - // MARK: Divider + // Divider Rectangle() .fill(Color.white.opacity(0.2)) .frame(width: 2, height: 600) - // MARK: Right Side - Device Code + // Right Side - Device Code VStack(spacing: 40) { // App logo Image("netbird-logo-menu") @@ -169,7 +169,7 @@ struct TVAuthView: View { } } - // MARK: - Computed Properties + // Computed Properties /// The user code to display - prefers passed-in userCode, falls back to URL extraction private var displayUserCode: String? { @@ -179,7 +179,7 @@ struct TVAuthView: View { return extractUserCode(from: loginURL) } - // MARK: - Helper Functions + // Helper Functions /// Generates a QR code image from the login URL private func generateQRCode() { diff --git a/NetBird/Source/App/Views/TV/TVMainView.swift b/NetBird/Source/App/Views/TV/TVMainView.swift index eb3adac..2b6a2b8 100644 --- a/NetBird/Source/App/Views/TV/TVMainView.swift +++ b/NetBird/Source/App/Views/TV/TVMainView.swift @@ -21,7 +21,6 @@ import os private let buttonLogger = Logger(subsystem: "io.netbird.app", category: "TVConnectionButton") -// MARK: - tvOS Color Helpers (local definition) private struct TVColors { static var textPrimary: Color { UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary @@ -40,37 +39,31 @@ private struct TVColors { } } -/// The main view for Apple TV, using top-level tab navigation. struct TVMainView: View { @EnvironmentObject var viewModel: ViewModel - /// Currently selected tab @State private var selectedTab = 0 var body: some View { TabView(selection: $selectedTab) { - // MARK: - Connection Tab (Home) TVConnectionView() .tabItem { Label("Connection", systemImage: "network") } .tag(0) - // MARK: - Peers Tab TVPeersView() .tabItem { Label("Peers", systemImage: "person.3.fill") } .tag(1) - // MARK: - Networks Tab TVNetworksView() .tabItem { Label("Networks", systemImage: "globe") } .tag(2) - // MARK: - Settings Tab (replaces side drawer) TVSettingsView() .tabItem { Label("Settings", systemImage: "gear") @@ -78,7 +71,7 @@ struct TVMainView: View { .tag(3) } .environmentObject(viewModel) - // MARK: - Authentication Sheet (QR Code + Device Code) + // Authentication Sheet (QR Code + Device Code) .fullScreenCover(isPresented: $viewModel.networkExtensionAdapter.showBrowser) { if let loginURL = viewModel.networkExtensionAdapter.loginURL { TVAuthView( @@ -86,17 +79,13 @@ struct TVMainView: View { userCode: viewModel.networkExtensionAdapter.userCode, isPresented: $viewModel.networkExtensionAdapter.showBrowser, onCancel: { - // User cancelled authentication viewModel.networkExtensionAdapter.showBrowser = false }, onComplete: { - // Authentication completed - start VPN connection print("Login completed, starting VPN connection...") viewModel.networkExtensionAdapter.startVPNConnection() }, checkLoginComplete: { completion in - // Check if login is complete by asking the Network Extension directly - // This is more reliable because it queries the same SDK client doing the login viewModel.networkExtensionAdapter.checkLoginComplete { isComplete in print("TVMainView: checkLoginComplete returned \(isComplete)") completion(isComplete) @@ -108,8 +97,6 @@ struct TVMainView: View { } } -// MARK: - Connection View (Home Screen) -/// The main connection screen showing VPN status and quick actions. struct TVConnectionView: View { @EnvironmentObject var viewModel: ViewModel @@ -120,9 +107,8 @@ struct TVConnectionView: View { .ignoresSafeArea() HStack(spacing: 100) { - // MARK: Left Side - Connection Control + // Left Side - Connection Control VStack(spacing: 40) { - // Logo Image("netbird-logo-menu") .resizable() .scaledToFit() @@ -141,7 +127,6 @@ struct TVConnectionView: View { .foregroundColor(TVColors.textSecondary.opacity(0.8)) } - // Big Connect/Disconnect Button TVConnectionButton(viewModel: viewModel) // Status text @@ -151,7 +136,7 @@ struct TVConnectionView: View { } .frame(maxWidth: .infinity) - // MARK: Right Side - Quick Stats + // Right Side - Quick Stats VStack(alignment: .leading, spacing: 30) { Text("Network Status") .font(.system(size: 32, weight: .bold)) @@ -189,7 +174,7 @@ struct TVConnectionView: View { } } - // MARK: Computed Properties + // Computed Properties private var statusColor: Color { switch viewModel.extensionStateText { @@ -221,8 +206,6 @@ struct TVConnectionView: View { } } -// MARK: - Connection Button -/// Large, focusable connect/disconnect button for tvOS. struct TVConnectionButton: View { @ObservedObject var viewModel: ViewModel @@ -296,8 +279,6 @@ struct TVConnectionButton: View { } } -// MARK: - Stat Card -/// Displays a single statistic in a card format. struct TVStatCard: View { let icon: String let title: String @@ -338,7 +319,6 @@ struct TVStatCard: View { } } -// MARK: - Preview struct TVMainView_Previews: PreviewProvider { static var previews: some View { TVMainView() diff --git a/NetBird/Source/App/Views/TV/TVNetworksView.swift b/NetBird/Source/App/Views/TV/TVNetworksView.swift index 3ce8883..6b6b318 100644 --- a/NetBird/Source/App/Views/TV/TVNetworksView.swift +++ b/NetBird/Source/App/Views/TV/TVNetworksView.swift @@ -13,7 +13,6 @@ import UIKit #if os(tvOS) -// MARK: - tvOS Color Helpers (local definition) private struct TVColors { static var textPrimary: Color { UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary @@ -35,7 +34,7 @@ struct TVNetworksView: View { var body: some View { ZStack { - TVColors.bgMenu + TVColors.bgMenu .ignoresSafeArea() if viewModel.extensionStateText == "Connected" && @@ -51,7 +50,6 @@ struct TVNetworksView: View { } } -// MARK: - Network List Content struct TVNetworkListContent: View { @EnvironmentObject var viewModel: ViewModel @@ -60,7 +58,6 @@ struct TVNetworkListContent: View { var body: some View { VStack(alignment: .leading, spacing: 20) { - // Header HStack { Text("Networks") .font(.system(size: 48, weight: .bold)) @@ -73,7 +70,6 @@ struct TVNetworkListContent: View { .font(.system(size: 24)) .foregroundColor(TVColors.textSecondary) - // Refresh button Button(action: refresh) { Image(systemName: "arrow.clockwise") .font(.system(size: 28)) @@ -89,7 +85,6 @@ struct TVNetworkListContent: View { .padding(.horizontal, 80) .padding(.top, 40) - // Filter bar TVFilterBar( options: ["All", "Enabled", "Disabled"], selected: $viewModel.routeViewModel.selectionFilter @@ -118,7 +113,7 @@ struct TVNetworkListContent: View { } } - // MARK: Computed Properties + // Computed Properties private var activeCount: Int { viewModel.routeViewModel.routeInfo.filter { $0.selected }.count @@ -128,7 +123,7 @@ struct TVNetworkListContent: View { viewModel.routeViewModel.routeInfo.count } - // MARK: Actions + // Actions private func refresh() { isRefreshing = true @@ -140,7 +135,7 @@ struct TVNetworkListContent: View { } } -// MARK: - Individual Network Card +// Individual Network Card struct TVNetworkCard: View { let route: RoutesSelectionInfo @ObservedObject var routeViewModel: RoutesViewModel @@ -178,7 +173,6 @@ struct TVNetworkCard: View { Spacer() - // Enabled/Disabled badge Text(route.selected ? "Enabled" : "Disabled") .font(.system(size: 18, weight: .medium)) .foregroundColor(route.selected ? .green : .gray) @@ -211,7 +205,6 @@ struct TVNetworkCard: View { } } -// MARK: - Empty State struct TVNoNetworksView: View { var body: some View { VStack(spacing: 40) { @@ -239,7 +232,6 @@ struct TVNoNetworksView: View { } } -// MARK: - Preview struct TVNetworksView_Previews: PreviewProvider { static var previews: some View { TVNetworksView() diff --git a/NetBird/Source/App/Views/TV/TVPeersView.swift b/NetBird/Source/App/Views/TV/TVPeersView.swift index ed18466..604745d 100644 --- a/NetBird/Source/App/Views/TV/TVPeersView.swift +++ b/NetBird/Source/App/Views/TV/TVPeersView.swift @@ -16,7 +16,6 @@ import UIKit #if os(tvOS) -// MARK: - tvOS Color Helpers (local definition) private struct TVColors { static var textPrimary: Color { UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary @@ -35,7 +34,6 @@ private struct TVColors { } } -/// Displays the list of peers in a tvOS-friendly format. struct TVPeersView: View { @EnvironmentObject var viewModel: ViewModel @@ -54,19 +52,17 @@ struct TVPeersView: View { } } -// MARK: - Peer List Content struct TVPeerListContent: View { @EnvironmentObject var viewModel: ViewModel /// Currently selected peer for detail view @State private var selectedPeer: PeerInfo? - /// Search/filter text @State private var searchText = "" var body: some View { HStack(spacing: 0) { - // MARK: Left Side - Peer List + // Left Side - Peer List VStack(alignment: .leading, spacing: 20) { // Header with count HStack { @@ -83,7 +79,6 @@ struct TVPeerListContent: View { .padding(.horizontal, 50) .padding(.top, 40) - // Filter buttons TVFilterBar( options: ["All", "Connected", "Connecting", "Idle"], selected: $viewModel.peerViewModel.selectionFilter @@ -107,7 +102,7 @@ struct TVPeerListContent: View { } .frame(maxWidth: .infinity) - // MARK: Right Side - Peer Details + // Right Side - Peer Details if let peer = selectedPeer { TVPeerDetailView(peer: peer) .frame(width: 500) @@ -116,7 +111,7 @@ struct TVPeerListContent: View { } } - // MARK: Computed Properties + // Computed Properties private var connectedCount: Int { viewModel.peerViewModel.peerInfo.filter { $0.connStatus == "Connected" }.count @@ -131,7 +126,7 @@ struct TVPeerListContent: View { } } -// MARK: - Individual Peer Card +// Individual Peer Card struct TVPeerCard: View { let peer: PeerInfo let isSelected: Bool @@ -204,13 +199,12 @@ struct TVPeerCard: View { } } -// MARK: - Peer Detail Panel +// Peer Detail Panel struct TVPeerDetailView: View { let peer: PeerInfo var body: some View { VStack(alignment: .leading, spacing: 30) { - // Header Text("Peer Details") .font(.system(size: 32, weight: .bold)) .foregroundColor(TVColors.textPrimary) @@ -246,7 +240,6 @@ struct TVPeerDetailView: View { } } -// MARK: - Detail Row struct TVDetailRow: View { let label: String let value: String @@ -264,7 +257,6 @@ struct TVDetailRow: View { } } -// MARK: - Filter Bar struct TVFilterBar: View { let options: [String] @Binding var selected: String @@ -311,7 +303,6 @@ struct TVFilterButton: View { } } -// MARK: - Empty State struct TVNoPeersView: View { var body: some View { VStack(spacing: 40) { @@ -333,7 +324,6 @@ struct TVNoPeersView: View { } } -// MARK: - Preview struct TVPeersView_Previews: PreviewProvider { static var previews: some View { TVPeersView() diff --git a/NetBird/Source/App/Views/TV/TVSettingsView.swift b/NetBird/Source/App/Views/TV/TVSettingsView.swift index a343f69..12bd139 100644 --- a/NetBird/Source/App/Views/TV/TVSettingsView.swift +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -13,7 +13,6 @@ import UIKit #if os(tvOS) -// MARK: - tvOS Color Helpers (local definition) private struct TVColors { static var textPrimary: Color { UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary @@ -45,9 +44,8 @@ struct TVSettingsView: View { .ignoresSafeArea() HStack(spacing: 0) { - // MARK: Left Side - Settings List + // Left Side - Settings List VStack(alignment: .leading, spacing: 30) { - // Header Text("Settings") .font(.system(size: 48, weight: .bold)) .foregroundColor(TVColors.textPrimary) @@ -56,7 +54,6 @@ struct TVSettingsView: View { // Settings options ScrollView { VStack(spacing: 20) { - // Server settings TVSettingsSection(title: "Connection") { TVSettingsRow( icon: "server.rack", @@ -66,7 +63,6 @@ struct TVSettingsView: View { ) } - // Advanced settings TVSettingsSection(title: "Advanced") { TVSettingsToggleRow( icon: "ant.fill", @@ -86,7 +82,6 @@ struct TVSettingsView: View { ) } - // Help section TVSettingsSection(title: "Help") { TVSettingsRow( icon: "book.fill", @@ -108,7 +103,7 @@ struct TVSettingsView: View { .padding(80) .frame(maxWidth: .infinity, alignment: .leading) - // MARK: Right Side - NetBird Branding + // Right Side - NetBird Branding VStack { Spacer() @@ -141,7 +136,6 @@ struct TVSettingsView: View { } } -// MARK: - Settings Section struct TVSettingsSection: View { let title: String @ViewBuilder let content: () -> Content @@ -165,7 +159,6 @@ struct TVSettingsSection: View { } } -// MARK: - Settings Row (Tappable) struct TVSettingsRow: View { let icon: String let title: String @@ -212,7 +205,6 @@ struct TVSettingsRow: View { } } -// MARK: - Settings Toggle Row struct TVSettingsToggleRow: View { let icon: String let title: String @@ -265,7 +257,6 @@ struct TVSettingsToggleRow: View { } } -// MARK: - Change Server Alert struct TVChangeServerAlert: View { @ObservedObject var viewModel: ViewModel @@ -342,7 +333,6 @@ struct TVChangeServerAlert: View { } } -// MARK: - Preview struct TVSettingsView_Previews: PreviewProvider { static var previews: some View { TVSettingsView() diff --git a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift index 3ffb0ba..7ae87a3 100644 --- a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift +++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift @@ -12,7 +12,7 @@ import NetBirdSDK private let logger = Logger(subsystem: "io.netbird.app.tv.extension", category: "PacketTunnelProvider") -// MARK: - SSO Listener for config initialization +// 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)? @@ -65,7 +65,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { logger.info("startTunnel: network monitoring started") // Initialize config file if it doesn't exist (tvOS only) - // This must be done in the extension because it has permission to write to the App Group + // This MUST be done in the extension because it has permission to write to the App Group logger.info("startTunnel: calling initializeConfigIfNeeded()...") NSLog("NetBirdTV: calling initializeConfigIfNeeded...") initializeConfigIfNeeded() diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index 604f97e..2e0949b 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -12,7 +12,7 @@ import Combine import NetBirdSDK import os -// MARK: - SSO Listener for config initialization +// 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)? diff --git a/NetbirdKit/Preferences.swift b/NetbirdKit/Preferences.swift index 429863b..351a2f4 100644 --- a/NetbirdKit/Preferences.swift +++ b/NetbirdKit/Preferences.swift @@ -41,7 +41,7 @@ class Preferences { return logURL!.relativePath } - // MARK: - UserDefaults-based config storage for tvOS + // 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" diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index bb3c35f..4c594a9 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -13,7 +13,7 @@ import os /// Logger for NetBirdAdapter - visible in Console.app private let adapterLogger = Logger(subsystem: "io.netbird.adapter", category: "NetBirdAdapter") -// MARK: - URL Opener for Login Flow +// URL Opener for Login Flow /// Handles OAuth URL opening and login success callbacks class LoginURLOpener: NSObject, NetBirdSDKURLOpenerProtocol { /// Callback when URL needs to be opened (with user code for device flow) @@ -34,7 +34,7 @@ class LoginURLOpener: NSObject, NetBirdSDKURLOpenerProtocol { } } -// MARK: - Error Listener for Async Operations +// Error Listener for Async Operations /// Handles error callbacks from async SDK operations class LoginErrListener: NSObject, NetBirdSDKErrListenerProtocol { var onErrorCallback: ((Error?) -> Void)? @@ -55,7 +55,7 @@ class LoginErrListener: NSObject, NetBirdSDKErrListenerProtocol { } } -// MARK: - SSO Listener for Config Save +// SSO Listener for Config Save /// Used to save config after successful login class LoginConfigSaveListener: NSObject, NetBirdSDKSSOListenerProtocol { var onResult: ((Bool?, Error?) -> Void)? From 2e01447e43f224e7b827e4af7b7bd060ec0f86c3 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Tue, 9 Dec 2025 14:58:11 +0100 Subject: [PATCH 03/19] fixed slow startup + added images --- .../netbird-logo-menu.imageset/Contents.json | 56 ++++++++++++++++++ .../netbird-logo-menu 1.png | Bin 0 -> 6974 bytes .../netbird-logo-menu.png | Bin 0 -> 3703 bytes .../netbird-logo-menu@2x 1.png | Bin 0 -> 10320 bytes .../netbird-logo-menu@2x.png | Bin 0 -> 8079 bytes .../netbird-logo-menu@3x 1.png | Bin 0 -> 9593 bytes .../netbird-logo-menu@3x.png | Bin 0 -> 6831 bytes .../Source/App/ViewModels/MainViewModel.swift | 22 +++++-- NetbirdKit/NetworkExtensionAdapter.swift | 9 ++- 9 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/Contents.json create mode 100644 NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu 1.png create mode 100644 NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu.png create mode 100644 NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@2x 1.png create mode 100644 NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@2x.png create mode 100644 NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@3x 1.png create mode 100644 NetBird TV/Assets.xcassets/netbird-logo-menu.imageset/netbird-logo-menu@3x.png 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 0000000000000000000000000000000000000000..31196115b96735a2030b37193dbf5b841a09450d GIT binary patch literal 6974 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 bToNfidL#SlfshQ~@fSoiG&d;Ga|!<+K@(9Z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..63eed5ab46b6e856cb03c31e31434fae103f32c1 GIT binary patch literal 3703 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..020daa6c9bcf0f62bb66eb1c78d73390c6de0d20 GIT binary patch literal 10320 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f98e90ef85982dfb5a36dceefdc14af325ac7df7 GIT binary patch literal 9593 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 Ck{e9` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7e139f393597072af3f988cf41b94d8808f31c5a GIT binary patch literal 6831 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

!O*37hkOI;AD!pL#`v#uSD!Jl)zzpC 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 CKLeWp literal 0 HcmV?d00001 diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 40d5d45..a13f284 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -103,7 +103,18 @@ class ViewModel: ObservableObject { } } - var preferences: NetBirdSDKPreferences? = 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 @@ -137,9 +148,12 @@ 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() + $setupKey .removeDuplicates() .debounce(for: .seconds(0.5), scheduler: RunLoop.main) diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index 2e0949b..fcbe8ed 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -145,10 +145,17 @@ public class NetworkExtensionAdapter: ObservableObject { /// Try to initialize the config file from the main app. /// This may work on tvOS where the extension doesn't have write access. private func initializeConfigFromApp() async { + // IMPORTANT: Skip initialization if config already exists in UserDefaults. + // SDK initialization is expensive (generates WireGuard/SSH keys ~5+ seconds). + if Preferences.hasConfigInUserDefaults() { + print("initializeConfigFromApp: Config already exists in UserDefaults, skipping SDK init") + return + } + let configPath = Preferences.configFile() let fileManager = FileManager.default - // Check if config already exists + // Check if config already exists as a file if fileManager.fileExists(atPath: configPath) { print("initializeConfigFromApp: Config already exists at \(configPath)") return From 7af9d7194308cb99fa15a63cffdc9fa72162d43e Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Tue, 9 Dec 2025 15:15:49 +0100 Subject: [PATCH 04/19] fixed active networks not refreshing --- NetBird/Source/App/ViewModels/MainViewModel.swift | 2 ++ NetbirdNetworkExtension/NetBirdAdapter.swift | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index a13f284..26d2831 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -259,6 +259,8 @@ class ViewModel: ObservableObject { 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 diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index 4c594a9..a333bed 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -444,6 +444,19 @@ public class NetBirdAdapter { // Use default management URL for tvOS, empty for iOS (which handles it via ServerView) #if os(tvOS) let managementURL = Self.defaultManagementURL + + // CRITICAL: On tvOS, config is stored in UserDefaults because file writes are blocked. + // Before creating the Auth object, we must restore the config to the file path so that + // NetBirdSDKNewAuth can read the existing identity (WireGuard keys, peer ID). + // Without this, a new identity would be created on every re-auth! + if Preferences.hasConfigInUserDefaults() { + adapterLogger.info("loginAsync: tvOS - restoring config from UserDefaults to file for re-auth") + if Preferences.restoreConfigFromUserDefaults() { + adapterLogger.info("loginAsync: tvOS - config restored successfully, existing identity will be preserved") + } else { + adapterLogger.warning("loginAsync: tvOS - failed to restore config, a new identity may be created") + } + } #else let managementURL = "" #endif From 2b056c3917d0aa1a9625cd9be548f12c2f8b1bdc Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Tue, 9 Dec 2025 15:23:19 +0100 Subject: [PATCH 05/19] fix occasional failure of first connection --- .../PacketTunnelProvider.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift index 7ae87a3..dc2fda4 100644 --- a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift +++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift @@ -58,6 +58,22 @@ class PacketTunnelProvider: NEPacketTunnelProvider { #else logger.info("startTunnel: skipping file-based logging on tvOS (sandbox blocks writes)") NSLog("NetBirdTV: skipping file-based logging on tvOS") + + // CRITICAL: On tvOS, restore config from UserDefaults to file BEFORE the adapter is created. + // The lazy adapter creates NetBirdSDKNewClient() which reads from the config file path. + // If we don't restore the file first, the Client will be initialized with empty/missing config. + // This must happen BEFORE any access to `adapter` property. + if Preferences.hasConfigInUserDefaults() { + logger.info("startTunnel: tvOS - restoring config from UserDefaults to file BEFORE adapter init") + NSLog("NetBirdTV: restoring config from UserDefaults to file BEFORE adapter init") + if Preferences.restoreConfigFromUserDefaults() { + logger.info("startTunnel: tvOS - config file restored successfully") + NSLog("NetBirdTV: config file restored successfully") + } else { + logger.warning("startTunnel: tvOS - failed to restore config file, adapter may not work correctly") + NSLog("NetBirdTV: WARNING - failed to restore config file") + } + } #endif currentNetworkType = nil From 4c79a6c512a9c056a1c1ff4b5b4f04a867294043 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Tue, 9 Dec 2025 16:40:34 +0100 Subject: [PATCH 06/19] hostname properly set + removed debug logging --- .../PacketTunnelProvider.swift | 116 +++-------------- NetbirdKit/Device.swift | 36 +++++- NetbirdKit/Preferences.swift | 15 +-- NetbirdNetworkExtension/NetBirdAdapter.swift | 120 ++++++------------ 4 files changed, 88 insertions(+), 199 deletions(-) diff --git a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift index dc2fda4..88189ef 100644 --- a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift +++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift @@ -78,25 +78,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { currentNetworkType = nil startMonitoringNetworkChanges() - logger.info("startTunnel: network monitoring started") - // Initialize config file if it doesn't exist (tvOS only) - // This MUST be done in the extension because it has permission to write to the App Group - logger.info("startTunnel: calling initializeConfigIfNeeded()...") - NSLog("NetBirdTV: calling initializeConfigIfNeeded...") + // Initialize config if it doesn't exist (tvOS only) initializeConfigIfNeeded() - logger.info("startTunnel: initializeConfigIfNeeded() completed") - NSLog("NetBirdTV: initializeConfigIfNeeded completed") - logger.info("startTunnel: calling adapter.needsLogin()...") - NSLog("NetBirdTV: calling adapter.needsLogin...") let needsLogin = adapter.needsLogin() - logger.info("startTunnel: needsLogin = \(needsLogin, privacy: .public)") - NSLog("NetBirdTV: startTunnel needsLogin = %@", needsLogin ? "true" : "false") if needsLogin { - logger.info("startTunnel: Login required, returning error after 2 second delay") - NSLog("NetBirdTV: startTunnel Login required, returning error") DispatchQueue.main.asyncAfter(deadline: .now() + 2) { let error = NSError( domain: "io.netbird.NetBirdTVNetworkExtension", @@ -108,35 +96,22 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } - logger.info("startTunnel: Login NOT required, starting adapter...") - NSLog("NetBirdTV: startTunnel Login NOT required, starting adapter") adapter.start { [self] error in if let error = error { - logger.error("startTunnel: adapter.start() FAILED: \(error.localizedDescription, privacy: .public)") - NSLog("NetBirdTV: adapter.start FAILED: %@", error.localizedDescription) + logger.error("startTunnel: adapter.start() failed: \(error.localizedDescription, privacy: .public)") completionHandler(error) } else { - logger.info("startTunnel: adapter.start() SUCCEEDED - VPN is connected!") - NSLog("NetBirdTV: adapter.start SUCCEEDED - VPN is connected!") completionHandler(nil) } } - logger.info("startTunnel: adapter.start() called, waiting for completion...") } override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { - logger.info("stopTunnel: Stopping tunnel, reason: \(String(describing: reason))") adapter.stop() - guard let pathMonitor = self.pathMonitor else { - logger.info("stopTunnel: pathMonitor is nil; nothing to cancel.") - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - completionHandler() - } - return + if let pathMonitor = self.pathMonitor { + pathMonitor.cancel() + self.pathMonitor = nil } - pathMonitor.cancel() - self.pathMonitor = nil - logger.info("stopTunnel: Tunnel stopped successfully") DispatchQueue.main.asyncAfter(deadline: .now() + 2) { completionHandler() } @@ -292,113 +267,52 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } /// Initialize config synchronously during startTunnel - /// This ensures the config is available before we check needsLogin() /// On tvOS, config is loaded from UserDefaults directly into memory (file writes are blocked) private func initializeConfigIfNeeded() { - logger.info("initializeConfigIfNeeded: ENTRY") - NSLog("NetBirdTV: initializeConfigIfNeeded ENTRY") - let configPath = Preferences.configFile() let fileManager = FileManager.default - logger.info("initializeConfigIfNeeded: configPath = \(configPath, privacy: .public)") // Check if config already exists as a file - let fileExists = fileManager.fileExists(atPath: configPath) - logger.info("initializeConfigIfNeeded: fileExists = \(fileExists, privacy: .public)") - NSLog("NetBirdTV: configPath=%@, fileExists=%@", configPath, fileExists ? "true" : "false") - - if fileExists { - logger.info("initializeConfigIfNeeded: Config file exists, returning early") - NSLog("NetBirdTV: Config file exists, returning early") + if fileManager.fileExists(atPath: configPath) { return } // On tvOS, try to load config from UserDefaults directly into memory - // (file writes to App Group are blocked on tvOS) - logger.info("initializeConfigIfNeeded: No config file, checking UserDefaults...") - let hasConfig = Preferences.hasConfigInUserDefaults() - logger.info("initializeConfigIfNeeded: hasConfigInUserDefaults = \(hasConfig, privacy: .public)") - NSLog("NetBirdTV: hasConfigInUserDefaults = %@", hasConfig ? "true" : "false") - - if hasConfig { - logger.info("initializeConfigIfNeeded: Found config in UserDefaults, loading...") - NSLog("NetBirdTV: Found config in UserDefaults, loading...") - if let configJSON = Preferences.loadConfigFromUserDefaults() { - let configSize = configJSON.count - logger.info("initializeConfigIfNeeded: Got config JSON (\(configSize, privacy: .public) bytes)") - NSLog("NetBirdTV: Got config JSON (%d bytes)", configSize) - - // Log first 200 chars of config for debugging (remove sensitive data) - let preview = String(configJSON.prefix(200)) - logger.info("initializeConfigIfNeeded: Config preview: \(preview, privacy: .public)...") + if Preferences.hasConfigInUserDefaults() { + if var configJSON = Preferences.loadConfigFromUserDefaults() { + // Update the device name in config before loading + let correctDeviceName = Device.getName() + configJSON = NetBirdAdapter.updateDeviceNameInConfig(configJSON, newName: correctDeviceName) do { - logger.info("initializeConfigIfNeeded: Calling adapter.client.setConfigFromJSON()...") - NSLog("NetBirdTV: Calling setConfigFromJSON...") try adapter.client.setConfigFromJSON(configJSON) - logger.info("initializeConfigIfNeeded: SUCCESS - config loaded into Client memory") - NSLog("NetBirdTV: SUCCESS - config loaded into Client memory") return } catch { - let errorMsg = error.localizedDescription - logger.error("initializeConfigIfNeeded: FAILED to set config: \(errorMsg, privacy: .public)") - NSLog("NetBirdTV: FAILED to set config: %@", errorMsg) - // On tvOS, we cannot fall back to file-based config - it will fail #if os(tvOS) - logger.error("initializeConfigIfNeeded: tvOS - cannot fall back to file-based config") - NSLog("NetBirdTV: tvOS - cannot fall back to file-based config, returning") return #endif } - } else { - logger.warning("initializeConfigIfNeeded: Config key exists but failed to load string") - NSLog("NetBirdTV: Config key exists but failed to load string") } - } else { - logger.info("initializeConfigIfNeeded: No config in UserDefaults") - NSLog("NetBirdTV: No config in UserDefaults") } #if os(tvOS) - // On tvOS, if we get here without config, we cannot create one via file writes - // The user needs to authenticate first via the device code flow - logger.warning("initializeConfigIfNeeded: tvOS - no config available, user needs to authenticate") - NSLog("NetBirdTV: tvOS - no config available, user needs to authenticate") - // Return early on tvOS - file-based config initialization will fail + // On tvOS, if we get here without config, user needs to authenticate first #else - // On iOS, try to create config via file writes (this works on iOS) - logger.info("initializeConfigIfNeeded: No config found, initializing with default management URL: \(NetBirdAdapter.defaultManagementURL)") - - // Create Auth object with default management URL + // On iOS, try to create config via file writes guard let auth = NetBirdSDKNewAuth(configPath, NetBirdAdapter.defaultManagementURL, nil) else { - logger.error("initializeConfigIfNeeded: Failed to create Auth object") return } - // Use a semaphore to make this synchronous let semaphore = DispatchSemaphore(value: 0) let listener = ConfigInitSSOListener() - listener.onResult = { ssoSupported, error in - if let error = error { - self.logger.error("initializeConfigIfNeeded: Error checking SSO - \(error.localizedDescription)") - } else if let supported = ssoSupported { - self.logger.info("initializeConfigIfNeeded: SSO supported = \(supported)") - let configExists = fileManager.fileExists(atPath: configPath) - self.logger.info("initializeConfigIfNeeded: Config exists after save = \(configExists)") - } else { - self.logger.warning("initializeConfigIfNeeded: Unknown result") - } + listener.onResult = { _, _ in semaphore.signal() } auth.saveConfigIfSSOSupported(listener) - // Wait for completion (with timeout) - let result = semaphore.wait(timeout: .now() + 10) - if result == .timedOut { - logger.warning("initializeConfigIfNeeded: Timed out waiting for config initialization") - } + _ = semaphore.wait(timeout: .now() + 10) #endif } 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/Preferences.swift b/NetbirdKit/Preferences.swift index 351a2f4..41a9ba4 100644 --- a/NetbirdKit/Preferences.swift +++ b/NetbirdKit/Preferences.swift @@ -54,28 +54,19 @@ class Preferences { /// Save config JSON to UserDefaults (works on tvOS where file writes fail) static func saveConfigToUserDefaults(_ configJSON: String) -> Bool { guard let defaults = sharedUserDefaults() else { - print("Preferences: Failed to get shared UserDefaults") return false } defaults.set(configJSON, forKey: configJSONKey) defaults.synchronize() - print("Preferences: Saved config to UserDefaults (\(configJSON.count) bytes)") return true } /// Load config JSON from UserDefaults static func loadConfigFromUserDefaults() -> String? { guard let defaults = sharedUserDefaults() else { - print("Preferences: Failed to get shared UserDefaults") return nil } - let config = defaults.string(forKey: configJSONKey) - if let config = config { - print("Preferences: Loaded config from UserDefaults (\(config.count) bytes)") - } else { - print("Preferences: No config found in UserDefaults") - } - return config + return defaults.string(forKey: configJSONKey) } /// Check if config exists in UserDefaults @@ -93,12 +84,10 @@ class Preferences { } defaults.removeObject(forKey: configJSONKey) defaults.synchronize() - print("Preferences: Removed config from UserDefaults") } /// Restore config from UserDefaults to the config file path /// This is needed because the Go SDK reads from the file path - /// Returns true if config was restored successfully static func restoreConfigFromUserDefaults() -> Bool { guard let configJSON = loadConfigFromUserDefaults() else { return false @@ -107,10 +96,8 @@ class Preferences { let path = configFile() do { try configJSON.write(toFile: path, atomically: false, encoding: .utf8) - print("Preferences: Restored config to file: \(path)") return true } catch { - print("Preferences: Failed to write config to file: \(error.localizedDescription)") return false } } diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index a333bed..5e9fc4c 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -14,59 +14,43 @@ import os private let adapterLogger = Logger(subsystem: "io.netbird.adapter", category: "NetBirdAdapter") // URL Opener for Login Flow -/// Handles OAuth URL opening and login success callbacks class LoginURLOpener: NSObject, NetBirdSDKURLOpenerProtocol { - /// Callback when URL needs to be opened (with user code for device flow) var onOpen: ((String, String) -> Void)? - /// Callback when login succeeds var onSuccess: (() -> Void)? func open(_ url: String?, userCode: String?) { - adapterLogger.info("LoginURLOpener.open() called with url=\(url ?? "nil", privacy: .public), userCode=\(userCode ?? "nil", privacy: .public)") guard let url = url else { return } onOpen?(url, userCode ?? "") } func onLoginSuccess() { - adapterLogger.info("LoginURLOpener.onLoginSuccess() called!") - print(">>> LoginURLOpener.onLoginSuccess() called! <<<") onSuccess?() } } // Error Listener for Async Operations -/// Handles error callbacks from async SDK operations class LoginErrListener: NSObject, NetBirdSDKErrListenerProtocol { var onErrorCallback: ((Error?) -> Void)? var onSuccessCallback: (() -> Void)? func onError(_ err: Error?) { - adapterLogger.error("LoginErrListener.onError() called with: \(err?.localizedDescription ?? "nil", privacy: .public)") - print(">>> LoginErrListener.onError() called with: \(err?.localizedDescription ?? "nil") <<<") onErrorCallback?(err) } func onSuccess() { - // SDK calls this when the operation succeeds (e.g., device auth completed) - // This is NOT an error - call the success handler - adapterLogger.info("LoginErrListener.onSuccess() called!") - print(">>> LoginErrListener.onSuccess() called! <<<") onSuccessCallback?() } } // SSO Listener for Config Save -/// Used to save config after successful login class LoginConfigSaveListener: NSObject, NetBirdSDKSSOListenerProtocol { var onResult: ((Bool?, Error?) -> Void)? func onSuccess(_ ssoSupported: Bool) { - adapterLogger.info("LoginConfigSaveListener.onSuccess() called with ssoSupported=\(ssoSupported)") onResult?(ssoSupported, nil) } func onError(_ error: Error?) { - adapterLogger.error("LoginConfigSaveListener.onError() called with: \(error?.localizedDescription ?? "nil", privacy: .public)") onResult?(nil, error) } } @@ -240,7 +224,12 @@ public class NetBirdAdapter { 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() + + self.client = NetBirdSDKNewClient(Preferences.configFile(), Preferences.stateFile(), deviceName, osVersion, osName, self.networkChangeListener, self.dnsManager)! } /// Returns the tunnel device interface name, or nil on error. @@ -274,18 +263,11 @@ public class NetBirdAdapter { do { let fd = self.tunnelFileDescriptor ?? 0 let ifName = self.interfaceName ?? "unknown" - adapterLogger.info("start: tunnelFileDescriptor = \(fd), interfaceName = \(ifName, privacy: .public)") - - if fd == 0 { - adapterLogger.error("start: WARNING - File descriptor is 0, WireGuard may not work properly!") - } let connectionListener = ConnectionListener(adapter: self, completionHandler: completionHandler) self.client.setConnectionListener(connectionListener) - adapterLogger.info("start: Calling client.run() with fd=\(fd), interfaceName=\(ifName, privacy: .public)") try self.client.run(fd, interfaceName: ifName) } catch { - adapterLogger.error("start: client.run() failed: \(error.localizedDescription, privacy: .public)") completionHandler(NSError(domain: "io.netbird.NetbirdNetworkExtension", code: 1001, userInfo: [NSLocalizedDescriptionKey: "Netbird client startup failed."])) self.stop() } @@ -315,11 +297,9 @@ public class NetBirdAdapter { onSuccess: @escaping () -> Void, onError: @escaping (Error?) -> Void ) { - adapterLogger.info("loginAsync: Starting async login with forceDeviceAuth=\(forceDeviceAuth)") self.isExecutingLogin = true // Track completion to prevent duplicate callbacks - // Both urlOpener.onLoginSuccess and errListener.onSuccess might be called var completionCalled = false let completionLock = NSLock() @@ -327,51 +307,32 @@ public class NetBirdAdapter { var authRef: NetBirdSDKAuth? let handleSuccess: () -> Void = { [weak self] in - adapterLogger.info("loginAsync: handleSuccess called") completionLock.lock() guard !completionCalled else { completionLock.unlock() - adapterLogger.info("loginAsync: Success already handled, ignoring duplicate") return } completionCalled = true completionLock.unlock() - adapterLogger.info("loginAsync: Login succeeded, now saving config...") - // After successful login, save the config to persist credentials - // The Auth.login() may authenticate but not write to disk if let auth = authRef { - // First, try to get config JSON and save to UserDefaults - // This is the tvOS-compatible storage that works when file writes fail var getConfigError: NSError? - let configJSON = auth.getConfigJSON(&getConfigError) - if let error = getConfigError { - adapterLogger.error("loginAsync: Failed to get config JSON: \(error.localizedDescription, privacy: .public)") - } else if !configJSON.isEmpty { - adapterLogger.info("loginAsync: Got config JSON (\(configJSON.count) bytes), saving to UserDefaults") - if Preferences.saveConfigToUserDefaults(configJSON) { - adapterLogger.info("loginAsync: Config saved to UserDefaults successfully") - } else { - adapterLogger.error("loginAsync: Failed to save config to UserDefaults") - } - } else { - adapterLogger.warning("loginAsync: getConfigJSON returned empty string") + 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() - saveListener.onResult = { success, error in - if let error = error { - adapterLogger.error("loginAsync: Failed to save config to file after login: \(error.localizedDescription, privacy: .public)") - } else { - adapterLogger.info("loginAsync: Config saved to file successfully after login, ssoSupported=\(success ?? false)") - } - } auth.saveConfigIfSSOSupported(saveListener) } - adapterLogger.info("loginAsync: Setting isExecutingLogin=false and calling onSuccess callback") self?.lastLoginResult = "success" self?.lastLoginError = "" self?.isExecutingLogin = false @@ -382,17 +343,14 @@ public class NetBirdAdapter { } let handleError: (Error?) -> Void = { [weak self] error in - adapterLogger.error("loginAsync: handleError called with: \(error?.localizedDescription ?? "nil", privacy: .public)") completionLock.lock() guard !completionCalled else { completionLock.unlock() - adapterLogger.info("loginAsync: Completion already handled, ignoring error") return } completionCalled = true completionLock.unlock() - adapterLogger.info("loginAsync: Setting isExecutingLogin=false and calling onError callback") self?.lastLoginResult = "error" self?.lastLoginError = error?.localizedDescription ?? "unknown" self?.isExecutingLogin = false @@ -404,35 +362,25 @@ public class NetBirdAdapter { // Create URL opener let urlOpener = LoginURLOpener() urlOpener.onOpen = { url, userCode in - // Go SDK calls this from a goroutine - dispatch to main thread DispatchQueue.main.async { onURL(url, userCode) } } urlOpener.onSuccess = { - // Go SDK calls this from a goroutine - dispatch to main thread DispatchQueue.main.async { - adapterLogger.info("loginAsync: urlOpener.onLoginSuccess called via onSuccess closure") handleSuccess() } } // Create error listener - // Note: The SDK's ErrListener protocol has both onSuccess() and onError() - // onSuccess() is called when device auth completes successfully via this listener let errListener = LoginErrListener() errListener.onSuccessCallback = { - // Go SDK calls this from a goroutine - dispatch to main thread - // This is called when the device auth polling succeeds DispatchQueue.main.async { - adapterLogger.info("loginAsync: errListener.onSuccessCallback called") handleSuccess() } } errListener.onErrorCallback = { error in - // Go SDK calls this from a goroutine - dispatch to main thread DispatchQueue.main.async { - adapterLogger.error("loginAsync: errListener.onErrorCallback called with: \(error?.localizedDescription ?? "nil", privacy: .public)") handleError(error) } } @@ -445,33 +393,26 @@ public class NetBirdAdapter { #if os(tvOS) let managementURL = Self.defaultManagementURL - // CRITICAL: On tvOS, config is stored in UserDefaults because file writes are blocked. - // Before creating the Auth object, we must restore the config to the file path so that - // NetBirdSDKNewAuth can read the existing identity (WireGuard keys, peer ID). - // Without this, a new identity would be created on every re-auth! + // On tvOS, config is stored in UserDefaults because file writes are blocked. + // Restore the config to the file path so NetBirdSDKNewAuth can read the existing identity. if Preferences.hasConfigInUserDefaults() { - adapterLogger.info("loginAsync: tvOS - restoring config from UserDefaults to file for re-auth") - if Preferences.restoreConfigFromUserDefaults() { - adapterLogger.info("loginAsync: tvOS - config restored successfully, existing identity will be preserved") - } else { - adapterLogger.warning("loginAsync: tvOS - failed to restore config, a new identity may be created") - } + _ = Preferences.restoreConfigFromUserDefaults() } #else let managementURL = "" #endif - adapterLogger.info("loginAsync: Creating Auth object with configFile=\(Preferences.configFile(), privacy: .public), managementURL=\(managementURL, privacy: .public)") - // Get Auth object and call login if let auth = NetBirdSDKNewAuth(Preferences.configFile(), managementURL, nil) { - // Store reference so handleSuccess can save config authRef = auth - adapterLogger.info("loginAsync: Auth object created, calling auth.login()") + + #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) - adapterLogger.info("loginAsync: auth.login() returned (async operation started)") + #endif } else { - adapterLogger.error("loginAsync: Failed to create Auth object") handleError(NSError(domain: "io.netbird", code: 1002, userInfo: [NSLocalizedDescriptionKey: "Failed to create Auth object"])) } } @@ -479,4 +420,19 @@ public class NetBirdAdapter { public func stop() { self.client.stop() } + + // MARK: - Config Helpers + + /// Update the device name in a config JSON string + static func updateDeviceNameInConfig(_ configJSON: String, newName: String) -> String { + let pattern = "\"DeviceName\"\\s*:\\s*\"[^\"]*\"" + let replacement = "\"DeviceName\":\"\(newName)\"" + + 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 + } } From a75a39dfb1bf07d2af949ee96d4f9a4348306128 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Thu, 11 Dec 2025 12:29:06 +0100 Subject: [PATCH 07/19] - Fix "invalid Prefix" display on Networks tab by showing route name - Update ServerViewModel to use new SDK callback-based API - Fix focus navigation on Networks and Settings tabs - Add white text on focus for better readability across all cards - Increase filter bar spacing to prevent highlight overlap - Add TVSettingsInfoRow for non-interactive display items --- NetBird/Source/App/NetBirdApp.swift | 4 +- .../Source/App/ViewModels/PeerViewModel.swift | 3 - .../App/ViewModels/ServerViewModel.swift | 228 ++++++++++-------- .../Source/App/Views/TV/TVNetworksView.swift | 72 +++--- NetBird/Source/App/Views/TV/TVPeersView.swift | 28 +-- .../Source/App/Views/TV/TVSettingsView.swift | 89 ++++--- .../PacketTunnelProvider.swift | 14 +- NetbirdNetworkExtension/NetBirdAdapter.swift | 7 +- 8 files changed, 252 insertions(+), 193 deletions(-) diff --git a/NetBird/Source/App/NetBirdApp.swift b/NetBird/Source/App/NetBirdApp.swift index 816519f..5e3d8bd 100644 --- a/NetBird/Source/App/NetBirdApp.swift +++ b/NetBird/Source/App/NetBirdApp.swift @@ -69,8 +69,8 @@ struct NetBirdApp: App { #endif #if os(tvOS) // tvOS uses scenePhase changes - .onChange(of: scenePhase) { phase in - switch phase { + .onChange(of: scenePhase) { _, newPhase in + switch newPhase { case .active: print("App is active!") viewModel.checkExtensionState() diff --git a/NetBird/Source/App/ViewModels/PeerViewModel.swift b/NetBird/Source/App/ViewModels/PeerViewModel.swift index 5eb6137..9b2ffcf 100644 --- a/NetBird/Source/App/ViewModels/PeerViewModel.swift +++ b/NetBird/Source/App/ViewModels/PeerViewModel.swift @@ -42,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/ServerViewModel.swift b/NetBird/Source/App/ViewModels/ServerViewModel.swift index 6b60af3..6282043 100644 --- a/NetBird/Source/App/ViewModels/ServerViewModel.swift +++ b/NetBird/Source/App/ViewModels/ServerViewModel.swift @@ -6,22 +6,69 @@ // import Combine +import NetBirdSDK + +// 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 +77,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 +99,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 +115,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 +139,104 @@ 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 { + 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 + self?.isUiEnabled = true + self?.handleSdkErrorMessage(errorMessage: error.localizedDescription) + 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) } } - + 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 + self?.isOperationSuccessful = true + continuation.resume() + } + }, + onError: { [weak self] error in + Task { @MainActor in + self?.isUiEnabled = true + self?.handleSdkErrorMessage(errorMessage: error.localizedDescription) + 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/TV/TVNetworksView.swift b/NetBird/Source/App/Views/TV/TVNetworksView.swift index 6b6b318..1ae7963 100644 --- a/NetBird/Source/App/Views/TV/TVNetworksView.swift +++ b/NetBird/Source/App/Views/TV/TVNetworksView.swift @@ -58,21 +58,35 @@ struct TVNetworkListContent: View { 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, @@ -80,17 +94,10 @@ struct TVNetworkListContent: View { ) } .buttonStyle(.plain) - .padding(.leading, 30) } .padding(.horizontal, 80) - .padding(.top, 40) - - TVFilterBar( - options: ["All", "Enabled", "Disabled"], - selected: $viewModel.routeViewModel.selectionFilter - ) - .padding(.horizontal, 80) - + .padding(.bottom, 30) + // Network grid ScrollView { LazyVGrid( @@ -107,6 +114,7 @@ struct TVNetworkListContent: View { ) } } + .padding(.top, 15) .padding(.horizontal, 80) .padding(.bottom, 80) } @@ -139,9 +147,9 @@ struct TVNetworkListContent: View { 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) { @@ -150,32 +158,30 @@ struct TVNetworkCard: View { 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.network ?? route.name) + Text(route.name) .font(.system(size: 26, weight: .semibold)) - .foregroundColor(TVColors.textPrimary) + .foregroundColor(isFocused ? .white : TVColors.textPrimary) .lineLimit(1) - - if let domains = route.domains, !domains.isEmpty { - Text(domains.map { $0.domain }.joined(separator: ", ")) - .font(.system(size: 20)) - .foregroundColor(TVColors.textSecondary) - .lineLimit(2) - } + + 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(route.selected ? .green : .gray) + .foregroundColor(isFocused ? .white : (route.selected ? .green : .gray)) } .padding(30) .background( @@ -196,6 +202,16 @@ struct TVNetworkCard: View { .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) diff --git a/NetBird/Source/App/Views/TV/TVPeersView.swift b/NetBird/Source/App/Views/TV/TVPeersView.swift index 604745d..9a18f38 100644 --- a/NetBird/Source/App/Views/TV/TVPeersView.swift +++ b/NetBird/Source/App/Views/TV/TVPeersView.swift @@ -84,7 +84,8 @@ struct TVPeerListContent: View { selected: $viewModel.peerViewModel.selectionFilter ) .padding(.horizontal, 50) - + .padding(.bottom, 30) + // Peer list (scrollable, focus-navigable) ScrollView { LazyVStack(spacing: 15) { @@ -96,6 +97,7 @@ struct TVPeerListContent: View { ) } } + .padding(.top, 15) .padding(.horizontal, 50) .padding(.bottom, 50) } @@ -260,9 +262,9 @@ struct TVDetailRow: View { struct TVFilterBar: View { let options: [String] @Binding var selected: String - + var body: some View { - HStack(spacing: 15) { + HStack(spacing: 35) { ForEach(options, id: \.self) { option in TVFilterButton( title: option, @@ -279,24 +281,22 @@ 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 ? .semibold : .regular)) - .foregroundColor(isSelected ? .white : TVColors.textSecondary) - .padding(.horizontal, 24) - .padding(.vertical, 12) + .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 : TVColors.bgPrimary) - ) - .overlay( - Capsule() - .stroke(isFocused ? Color.white : Color.clear, lineWidth: 3) + .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) diff --git a/NetBird/Source/App/Views/TV/TVSettingsView.swift b/NetBird/Source/App/Views/TV/TVSettingsView.swift index 12bd139..f4c7696 100644 --- a/NetBird/Source/App/Views/TV/TVSettingsView.swift +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -62,7 +62,7 @@ struct TVSettingsView: View { action: { viewModel.showChangeServerAlert = true } ) } - + TVSettingsSection(title: "Advanced") { TVSettingsToggleRow( icon: "ant.fill", @@ -70,7 +70,7 @@ struct TVSettingsView: View { subtitle: "Enable detailed logs for troubleshooting", isOn: $viewModel.traceLogsEnabled ) - + TVSettingsToggleRow( icon: "shield.lefthalf.filled", title: "Rosenpass", @@ -81,23 +81,23 @@ struct TVSettingsView: View { ) ) } - - TVSettingsSection(title: "Help") { - TVSettingsRow( + + TVSettingsSection(title: "Info") { + TVSettingsInfoRow( icon: "book.fill", title: "Documentation", - subtitle: "docs.netbird.io", - action: nil // Can't open URLs directly on tvOS + subtitle: "docs.netbird.io" ) - - TVSettingsRow( + + TVSettingsInfoRow( icon: "info.circle.fill", - title: "About", - subtitle: "Version \(appVersion)", - action: nil + title: "Version", + subtitle: appVersion ) } } + .padding(.top, 15) + .padding(.bottom, 50) } } .padding(80) @@ -164,9 +164,9 @@ struct TVSettingsRow: View { let title: String let subtitle: String let action: (() -> Void)? - + @FocusState private var isFocused: Bool - + var body: some View { Button(action: { action?() }) { HStack(spacing: 20) { @@ -174,23 +174,23 @@ struct TVSettingsRow: View { .font(.system(size: 28)) .foregroundColor(.accentColor) .frame(width: 40) - + VStack(alignment: .leading, spacing: 6) { Text(title) .font(.system(size: 24, weight: .medium)) - .foregroundColor(TVColors.textPrimary) - + .foregroundColor(isFocused ? .white : TVColors.textPrimary) + Text(subtitle) .font(.system(size: 18)) - .foregroundColor(TVColors.textSecondary) + .foregroundColor(isFocused ? .white.opacity(0.8) : TVColors.textSecondary) } - + Spacer() - + if action != nil { Image(systemName: "chevron.right") .font(.system(size: 20)) - .foregroundColor(TVColors.textSecondary) + .foregroundColor(isFocused ? .white : TVColors.textSecondary) } } .padding(.vertical, 10) @@ -210,9 +210,9 @@ struct TVSettingsToggleRow: View { 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) { @@ -220,25 +220,25 @@ struct TVSettingsToggleRow: View { .font(.system(size: 28)) .foregroundColor(.accentColor) .frame(width: 40) - + VStack(alignment: .leading, spacing: 6) { Text(title) .font(.system(size: 24, weight: .medium)) - .foregroundColor(TVColors.textPrimary) - + .foregroundColor(isFocused ? .white : TVColors.textPrimary) + Text(subtitle) .font(.system(size: 18)) - .foregroundColor(TVColors.textSecondary) + .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) @@ -257,6 +257,35 @@ struct TVSettingsToggleRow: View { } } +/// 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 diff --git a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift index 88189ef..f5f377a 100644 --- a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift +++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift @@ -96,7 +96,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } - adapter.start { [self] error in + adapter.start { error in if let error = error { logger.error("startTunnel: adapter.start() failed: \(error.localizedDescription, privacy: .public)") completionHandler(error) @@ -201,7 +201,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { func restartClient() { logger.info("restartClient: Restarting client due to network change") adapter.stop() - adapter.start { [self] error in + adapter.start { error in if let error = error { logger.error("restartClient: Error restarting client: \(error.localizedDescription)") } else { @@ -243,7 +243,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // Use an SSO listener to save the config let listener = ConfigInitSSOListener() - listener.onResult = { [self] ssoSupported, error in + listener.onResult = { ssoSupported, error in if let error = error { logger.error("initializeConfig: Error checking SSO - \(error.localizedDescription)") let data = "false".data(using: .utf8) @@ -377,7 +377,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { adapter.loginAsync( forceDeviceAuth: true, - onURL: { [self] url, userCode in + 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") @@ -391,7 +391,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let data = response.data(using: .utf8) completionHandler(data) }, - onSuccess: { [self] in + onSuccess: { // Login completed - the app will detect this via polling // and start the VPN tunnel via startVPNConnection() logger.info("loginTV: Login completed successfully!") @@ -404,7 +404,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { logger.info("loginTV: configFile exists = \(fileManager.fileExists(atPath: configPath))") logger.info("loginTV: stateFile exists = \(fileManager.fileExists(atPath: statePath))") }, - onError: { [self] error in + 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)") @@ -563,7 +563,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func setTunnelSettings(tunnelNetworkSettings: NEPacketTunnelNetworkSettings) { - setTunnelNetworkSettings(tunnelNetworkSettings) { [self] error in + setTunnelNetworkSettings(tunnelNetworkSettings) { error in if let error = error { logger.error("setTunnelSettings: Error assigning routes: \(error.localizedDescription)") return diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index 5e9fc4c..c1a682d 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -136,9 +136,8 @@ public class NetBirdAdapter { private func findTunnelFileDescriptorTvOS() -> Int32? { // Constants from sys/kern_control.h (not in tvOS SDK but exist in kernel) let AF_SYSTEM: UInt8 = 32 - let AF_SYS_CONTROL: UInt16 = 2 - let SYSPROTO_CONTROL: Int32 = 2 - let UTUN_OPT_IFNAME: Int32 = 2 + // 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 @@ -155,7 +154,7 @@ public class NetBirdAdapter { // Set ctl_name to "com.apple.net.utun_control" at offset 4 let ctlName = "com.apple.net.utun_control" - ctlName.withCString { cstr in + _ = ctlName.withCString { cstr in memcpy(ctlInfo.advanced(by: 4), cstr, strlen(cstr) + 1) } From 7f45512fef9fd59b84202d700427c0df34787518 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Thu, 11 Dec 2025 12:41:25 +0100 Subject: [PATCH 08/19] Fixed focus escape bug on 'change server' dialog --- .../Source/App/Views/TV/TVSettingsView.swift | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/NetBird/Source/App/Views/TV/TVSettingsView.swift b/NetBird/Source/App/Views/TV/TVSettingsView.swift index f4c7696..c3e197e 100644 --- a/NetBird/Source/App/Views/TV/TVSettingsView.swift +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -288,32 +288,36 @@ struct TVSettingsInfoRow: View { struct TVChangeServerAlert: View { @ObservedObject var viewModel: ViewModel - - @FocusState private var confirmFocused: Bool - @FocusState private var cancelFocused: Bool - + + 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: { @@ -330,8 +334,8 @@ struct TVChangeServerAlert: View { ) } .buttonStyle(.plain) - .focused($cancelFocused) - + .focused($focusedButton, equals: .cancel) + // Confirm button Button(action: { viewModel.close() @@ -350,8 +354,9 @@ struct TVChangeServerAlert: View { ) } .buttonStyle(.plain) - .focused($confirmFocused) + .focused($focusedButton, equals: .confirm) } + .focusSection() } .padding(60) .background( @@ -359,6 +364,17 @@ struct TVChangeServerAlert: View { .fill(TVColors.bgSideDrawer) ) } + .onAppear { + focusedButton = .cancel + } + .onChange(of: focusedButton) { newValue in + if let newValue = newValue { + lastFocusedButton = newValue + } else { + // Focus escaped - pull it back + focusedButton = lastFocusedButton + } + } } } From f3dbbd43f1dccd26411b6bbd2bbc63000043bd4b Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Thu, 11 Dec 2025 13:55:18 +0100 Subject: [PATCH 09/19] fixed switching of management servers --- .../Contents.json | 23 + .../icon-netbird-button.png | Bin 0 -> 383 bytes .../icon-netbird-button@2x.png | Bin 0 -> 584 bytes .../icon-netbird-button@3x.png | Bin 0 -> 794 bytes NetBird.xcodeproj/project.pbxproj | 8 +- .../App/ViewModels/ServerViewModel.swift | 64 ++- NetBird/Source/App/Views/TV/TVMainView.swift | 5 + .../Source/App/Views/TV/TVServerView.swift | 399 ++++++++++++++++++ .../Source/App/Views/TV/TVSettingsView.swift | 2 +- .../PacketTunnelProvider.swift | 121 +++--- NetbirdKit/NetworkExtensionAdapter.swift | 91 ++-- NetbirdNetworkExtension/NetBirdAdapter.swift | 63 ++- 12 files changed, 681 insertions(+), 95 deletions(-) create mode 100644 NetBird TV/Assets.xcassets/icon-netbird-button.imageset/Contents.json create mode 100644 NetBird TV/Assets.xcassets/icon-netbird-button.imageset/icon-netbird-button.png create mode 100644 NetBird TV/Assets.xcassets/icon-netbird-button.imageset/icon-netbird-button@2x.png create mode 100644 NetBird TV/Assets.xcassets/icon-netbird-button.imageset/icon-netbird-button@3x.png create mode 100644 NetBird/Source/App/Views/TV/TVServerView.swift 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 0000000000000000000000000000000000000000..884e37875aaa256d603ccd74d9b4506192b386ce GIT binary patch literal 383 zcmV-_0f7FAP)+0NaMxB^>5B-@h zu2_-_&<~&4|M=#OQw-Z2^CLus76${ZY=U_W+{SIBmMy9Y;EAIpW$Uogxc zmfhUEppJ2kYXWj*<;RCAk`^m{_gGg#8!G$eyfn$R0EDV)cgIH>d5la|(GvVUw dE|$1E`~e#BTfC#!n4|yz002ovPDHLkV1gT^nx6mw literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4bb45e05057dac20f8a8c1f34ae6a5245367b65f GIT binary patch literal 584 zcmV-O0=NB%P)1>b-Ob-@b4LsV50596eT zCbg|rW!Zm|WX3aJ#-1@S(@Yae;2-oB)>r3%fPht-KLONQsN$)C3<~C}xBy{KIAE-3 z=}c`8KC1Bit&j+WMy!FD6UMbrsbSbc1ucdJ@JLJS4}TXb{JyHU__5;(=AVc;;!!8K zt5k5yrT`uZf#%|=Ivlpi!h#gW895V}stE@&7OnJI2Ig?QO3)~q3c$rsMP>`KN?hvk zHhnU>3#4rc>e`oaj)uwS1sc^(lp={;7gWEB4EhUasHM_qDaDN~UfPTqlyE%aU;urT z8k;*xzZ7kn&=CDGD9bw*Uzha*DYE9s6Fm#wLVJkZ))0Bm7sg;@9XzU?tEM>%^KL40 z!7~4Q?kkVML_nkEGZh+Wc6L5>@-Z;SfoXvKMI=_pyM_{B9Ro`)zs!xoeQ_k$31ZCy z?b*j*VxYMz2lu4hV5p^10!4|`HyA_SgX!OwPi4Dqa|3n$LW%U@%Drl040$VL*n>4_ zlPg`wD>c=P3-bca4DzGyijrFloFmADykk&S)#<4~r5d|VKSRYj#kFhy7|b-&)cOGj WtF_SD4$2w;0000R>3K##x)Xiorprqrbi);U1~90YDq9U%)?uqb$-kQtIl zycgoumH$XGv@8E6nb`X0&!3*26riQ0rKROx5V$73C2@ZZcw!{_uC_*FaywBU^JN`e zF=wyi-ALe>A(4FVA`iDr#{l4g#pnlXkl?!ZF36*vMPvh^u_D|3N zb1M4!ivWKy59&Iq63J99rYXglA!iW~XXg7#U&PGn)3V zqENQ@B%RJ`gF`rQrYCk*M>wH|wTYUYWJgOMvSA>%Dq~iWo7-?8T@Bb-+D&#i*ee^& zP$`lz)0&r9;3+(C3S~B5D#c=}>I+lu*`_WX*?0+MSS-a-VQXWj>fAhsU7zOq@eIw~ zv6ocDJSe0i7i3HHSo4)L9hGzq=hnVT^a7(0 zH4^68U3u3aRP5~3c%>toMnadTqilft z{$e>D4aWWpC{M6ZKC!c#mj<~ NetBirdSDKAuth? { let configPath = self.configurationFilePath - let detachedTask = Task.detached(priority: .background) -> (NetBirdSDKAuth?, String?) in + let detachedTask = Task.detached(priority: .background) { () -> (NetBirdSDKAuth?, String?) in var error: NSError? let authenticator = NetBirdSDKNewAuth(configPath, managementServerUrl, &error) @@ -165,6 +166,10 @@ class ServerViewModel : ObservableObject { 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 @@ -175,8 +180,22 @@ class ServerViewModel : ObservableObject { }, 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: error.localizedDescription) + self?.handleSdkErrorMessage(errorMessage: errorMessage) continuation.resume() } } @@ -186,6 +205,27 @@ class ServerViewModel : ObservableObject { } } + #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 @@ -220,14 +260,32 @@ class ServerViewModel : ObservableObject { 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: error.localizedDescription) + self?.handleSdkErrorMessage(errorMessage: errorMessage) continuation.resume() } } diff --git a/NetBird/Source/App/Views/TV/TVMainView.swift b/NetBird/Source/App/Views/TV/TVMainView.swift index 2b6a2b8..4644cc4 100644 --- a/NetBird/Source/App/Views/TV/TVMainView.swift +++ b/NetBird/Source/App/Views/TV/TVMainView.swift @@ -71,6 +71,11 @@ struct TVMainView: View { .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 { diff --git a/NetBird/Source/App/Views/TV/TVServerView.swift b/NetBird/Source/App/Views/TV/TVServerView.swift new file mode 100644 index 0000000..fe88e3c --- /dev/null +++ b/NetBird/Source/App/Views/TV/TVServerView.swift @@ -0,0 +1,399 @@ +// +// 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) + +private struct TVColors { + static var textPrimary: Color { + UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary + } + static var textSecondary: Color { + UIColor(named: "TextSecondary") != nil ? Color("TextSecondary") : .secondary + } + static var bgMenu: Color { + UIColor(named: "BgMenu") != nil ? Color("BgMenu") : Color(white: 0.1) + } + static var bgPrimary: Color { + UIColor(named: "BgPrimary") != nil ? Color("BgPrimary") : Color(white: 0.15) + } + static var bgSideDrawer: Color { + UIColor(named: "BgSideDrawer") != nil ? Color("BgSideDrawer") : Color(white: 0.2) + } +} + +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 index c3e197e..052d6ef 100644 --- a/NetBird/Source/App/Views/TV/TVSettingsView.swift +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -367,7 +367,7 @@ struct TVChangeServerAlert: View { .onAppear { focusedButton = .cancel } - .onChange(of: focusedButton) { newValue in + .onChange(of: focusedButton) { _, newValue in if let newValue = newValue { lastFocusedButton = newValue } else { diff --git a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift index f5f377a..098a688 100644 --- a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift +++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift @@ -59,29 +59,20 @@ class PacketTunnelProvider: NEPacketTunnelProvider { logger.info("startTunnel: skipping file-based logging on tvOS (sandbox blocks writes)") NSLog("NetBirdTV: skipping file-based logging on tvOS") - // CRITICAL: On tvOS, restore config from UserDefaults to file BEFORE the adapter is created. - // The lazy adapter creates NetBirdSDKNewClient() which reads from the config file path. - // If we don't restore the file first, the Client will be initialized with empty/missing config. - // This must happen BEFORE any access to `adapter` property. + // 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 - restoring config from UserDefaults to file BEFORE adapter init") - NSLog("NetBirdTV: restoring config from UserDefaults to file BEFORE adapter init") - if Preferences.restoreConfigFromUserDefaults() { - logger.info("startTunnel: tvOS - config file restored successfully") - NSLog("NetBirdTV: config file restored successfully") - } else { - logger.warning("startTunnel: tvOS - failed to restore config file, adapter may not work correctly") - NSLog("NetBirdTV: WARNING - failed to restore config file") - } + logger.info("startTunnel: tvOS - config found in UserDefaults, will be loaded by adapter") + NSLog("NetBirdTV: config found in UserDefaults") + } else { + logger.info("startTunnel: tvOS - no config in UserDefaults, login will be required") + NSLog("NetBirdTV: no config in UserDefaults") } #endif currentNetworkType = nil startMonitoringNetworkChanges() - // Initialize config if it doesn't exist (tvOS only) - initializeConfigIfNeeded() - let needsLogin = adapter.needsLogin() if needsLogin { @@ -151,6 +142,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider { case let s where s.hasPrefix("Deselect-"): let id = String(s.dropFirst("Deselect-".count)) deselectRoute(id: id) + 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) default: logger.warning("handleAppMessage: Unknown message: \(string)") } @@ -217,7 +213,36 @@ class PacketTunnelProvider: NEPacketTunnelProvider { completionHandler(data) } - /// Initialize config with default management URL for tvOS + /// 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 + 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)") + } + + 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") + } + + /// 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) { let configPath = Preferences.configFile() @@ -231,10 +256,20 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } - logger.info("initializeConfig: No config found, initializing with default management URL") + // 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") + } - // Create Auth object with default management URL - guard let auth = NetBirdSDKNewAuth(configPath, NetBirdAdapter.defaultManagementURL, nil) else { + 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) @@ -267,8 +302,15 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } /// Initialize config synchronously during startTunnel - /// On tvOS, config is loaded from UserDefaults directly into memory (file writes are blocked) + /// 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 let configPath = Preferences.configFile() let fileManager = FileManager.default @@ -277,27 +319,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } - // On tvOS, try to load config from UserDefaults directly into memory - if Preferences.hasConfigInUserDefaults() { - if var configJSON = Preferences.loadConfigFromUserDefaults() { - // Update the device name in config before loading - let correctDeviceName = Device.getName() - configJSON = NetBirdAdapter.updateDeviceNameInConfig(configJSON, newName: correctDeviceName) - - do { - try adapter.client.setConfigFromJSON(configJSON) - return - } catch { - #if os(tvOS) - return - #endif - } - } - } - - #if os(tvOS) - // On tvOS, if we get here without config, user needs to authenticate first - #else // On iOS, try to create config via file writes guard let auth = NetBirdSDKNewAuth(configPath, NetBirdAdapter.defaultManagementURL, nil) else { return @@ -360,14 +381,16 @@ class PacketTunnelProvider: NEPacketTunnelProvider { func loginTV(completionHandler: @escaping (Data?) -> Void) { logger.info("loginTV: Starting device code authentication flow") - // Initialize config file BEFORE attempting login - // This ensures the Auth object has a valid config to save credentials to - initializeConfigIfNeeded() + // 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)") + } - // Verify config was created - let configPath = Preferences.configFile() - let configExists = FileManager.default.fileExists(atPath: configPath) - logger.info("loginTV: After initializeConfigIfNeeded, configExists=\(configExists), path=\(configPath)") + // 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 diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index fcbe8ed..bca2872 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -143,55 +143,37 @@ public class NetworkExtensionAdapter: ObservableObject { #if os(tvOS) /// Try to initialize the config file from the main app. - /// This may work on tvOS where the extension doesn't have write access. + /// On tvOS, shared UserDefaults doesn't work, so we also send config via IPC. private func initializeConfigFromApp() async { - // IMPORTANT: Skip initialization if config already exists in UserDefaults. - // SDK initialization is expensive (generates WireGuard/SSH keys ~5+ seconds). - if Preferences.hasConfigInUserDefaults() { - print("initializeConfigFromApp: Config already exists in UserDefaults, skipping SDK init") + // 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 } let configPath = Preferences.configFile() let fileManager = FileManager.default - // Check if config already exists as a file + // Check if config already exists as a file (unlikely on tvOS but check anyway) if fileManager.fileExists(atPath: configPath) { - print("initializeConfigFromApp: Config already exists at \(configPath)") + logger.info("initializeConfigFromApp: Config already exists at \(configPath)") return } - print("initializeConfigFromApp: No config found, attempting to create from main app...") - - // Try to create the config using the SDK - // This creates a new config with WireGuard keys and saves it - guard let auth = NetBirdSDKNewAuth(configPath, "https://api.netbird.io", nil) else { - print("initializeConfigFromApp: Failed to create Auth object") - 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 + } - // Use withCheckedContinuation for proper async/await pattern - let success: Bool = await withCheckedContinuation { continuation in - let listener = ConfigSSOListener() - listener.onResult = { ssoSupported, error in - if let error = error { - print("initializeConfigFromApp: Error - \(error.localizedDescription)") - continuation.resume(returning: false) - } else if ssoSupported != nil { - let configExists = fileManager.fileExists(atPath: configPath) - print("initializeConfigFromApp: Config exists after save = \(configExists)") - continuation.resume(returning: configExists) - } else { - continuation.resume(returning: false) - } + /// Async wrapper for sendConfigToExtension + private func sendConfigToExtensionAsync(_ configJSON: String) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + sendConfigToExtension(configJSON) { _ in + continuation.resume() } - auth.saveConfigIfSSOSupported(listener) - } - - if success { - print("initializeConfigFromApp: Successfully created config from main app!") - } else { - print("initializeConfigFromApp: Failed to create config from main app (extension will try)") } } #endif @@ -563,6 +545,43 @@ 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) + } + } + #endif + func getExtensionStatus(completion: @escaping (NEVPNStatus) -> Void) { Task { do { diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index c1a682d..95b1511 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -228,7 +228,34 @@ public class NetBirdAdapter { 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. + self.client = NetBirdSDKNewClient("", "", deviceName, osVersion, osName, self.networkChangeListener, self.dnsManager)! + + // Try to load config from extension-local storage first (set via IPC from main app) + // This is more reliable than shared UserDefaults which doesn't work on tvOS + var configJSON: String? = UserDefaults.standard.string(forKey: "netbird_config_json_local") + + // Fall back to shared UserDefaults (may work in some cases) + if configJSON == nil { + configJSON = Preferences.loadConfigFromUserDefaults() + } + + 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 self.client = NetBirdSDKNewClient(Preferences.configFile(), Preferences.stateFile(), deviceName, osVersion, osName, self.networkChangeListener, self.dnsManager)! + #endif } /// Returns the tunnel device interface name, or nil on error. @@ -390,12 +417,24 @@ public class NetBirdAdapter { // Use default management URL for tvOS, empty for iOS (which handles it via ServerView) #if os(tvOS) - let managementURL = Self.defaultManagementURL + // On tvOS, config may be stored in extension-local UserDefaults (via IPC) or shared UserDefaults. + // Try local first, then fall back to shared. + var managementURL = Self.defaultManagementURL + + // First try extension-local storage (set via IPC from main app) + var configJSON: String? = UserDefaults.standard.string(forKey: "netbird_config_json_local") - // On tvOS, config is stored in UserDefaults because file writes are blocked. - // Restore the config to the file path so NetBirdSDKNewAuth can read the existing identity. - if Preferences.hasConfigInUserDefaults() { - _ = Preferences.restoreConfigFromUserDefaults() + // Fall back to shared UserDefaults + if configJSON == nil { + configJSON = Preferences.loadConfigFromUserDefaults() + } + + 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 = "" @@ -422,6 +461,20 @@ public class NetBirdAdapter { // 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 { let pattern = "\"DeviceName\"\\s*:\\s*\"[^\"]*\"" From 1d7c0d2f73035bd2b247c95fef04729000c51b8d Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Thu, 11 Dec 2025 17:02:49 +0100 Subject: [PATCH 10/19] fixes for change server settings menu --- NetBird/Source/App/Views/TV/TVSettingsView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/NetBird/Source/App/Views/TV/TVSettingsView.swift b/NetBird/Source/App/Views/TV/TVSettingsView.swift index 052d6ef..b8052a2 100644 --- a/NetBird/Source/App/Views/TV/TVSettingsView.swift +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -367,7 +367,8 @@ struct TVChangeServerAlert: View { .onAppear { focusedButton = .cancel } - .onChange(of: focusedButton) { _, newValue in + .onChange(of: focusedButton) { oldValue, newValue in + _ = oldValue // Suppress unused warning if let newValue = newValue { lastFocusedButton = newValue } else { From 6579821d23d92b305013be8ecf8aa8a8d7e6b906 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Thu, 11 Dec 2025 17:58:18 +0100 Subject: [PATCH 11/19] - Extract TVColors and TVLayout to shared TVColors.swift - Remove duplicate TVColors structs from 5 TV view files - Add ClearConfig IPC message to clear extension-local config on logout - Switch MainView from viewModel.isIpad to DeviceType.isPad - Remove unused isTV/isIpad properties from MainViewModel - Add TVColors.swift to Xcode project --- NetBird.xcodeproj/project.pbxproj | 4 + .../Source/App/ViewModels/MainViewModel.swift | 30 +--- NetBird/Source/App/Views/MainView.swift | 6 +- NetBird/Source/App/Views/TV/TVColors.swift | 166 ++++++++++++++++++ NetBird/Source/App/Views/TV/TVMainView.swift | 18 -- .../Source/App/Views/TV/TVNetworksView.swift | 15 -- NetBird/Source/App/Views/TV/TVPeersView.swift | 18 -- .../Source/App/Views/TV/TVServerView.swift | 18 -- .../Source/App/Views/TV/TVSettingsView.swift | 21 --- .../PacketTunnelProvider.swift | 17 ++ NetbirdKit/NetworkExtensionAdapter.swift | 34 ++++ 11 files changed, 232 insertions(+), 115 deletions(-) create mode 100644 NetBird/Source/App/Views/TV/TVColors.swift diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index ee4b994..6f10919 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 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 */; }; @@ -212,6 +213,7 @@ 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 = ""; }; @@ -360,6 +362,7 @@ isa = PBXGroup; children = ( 44F3E39A2EE2F9FA00C87FEC /* TVAuthView.swift */, + 44TVCOLORSF12345678901234 /* TVColors.swift */, 443782C02EDF288A00F9FA94 /* TVMainView.swift */, 443782C12EDF288A00F9FA94 /* TVNetworksView.swift */, 443782C22EDF288A00F9FA94 /* TVPeersView.swift */, @@ -813,6 +816,7 @@ 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 */, diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 26d2831..151f874 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -14,10 +14,6 @@ import os import Combine import NetBirdSDK -#if os(iOS) -import UIKit -#endif - /// Used by updateManagementURL to check if SSO is supported class SSOCheckListener: NSObject, NetBirdSDKSSOListenerProtocol { var onResult: ((Bool?, Error?) -> Void)? @@ -118,24 +114,6 @@ class ViewModel: ObservableObject { var buttonLock = false let defaults = UserDefaults.standard - /// Device type detection - platform-safe - var isIpad: Bool { - #if os(iOS) - return UIDevice.current.userInterfaceIdiom == .pad - #else - return false - #endif - } - - /// True if running on Apple TV - var isTV: Bool { - #if os(tvOS) - return true - #else - return false - #endif - } - private var cancellables = Set() @Published var peerViewModel: PeerViewModel @@ -311,6 +289,14 @@ class ViewModel: ObservableObject { self.fqdn = "" defaults.removeObject(forKey: "ip") defaults.removeObject(forKey: "fqdn") + + // 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 setSetupKey(key: String, completion: @escaping (Error?) -> Void) { diff --git a/NetBird/Source/App/Views/MainView.swift b/NetBird/Source/App/Views/MainView.swift index 3d06a6a..3f23ee6 100644 --- a/NetBird/Source/App/Views/MainView.swift +++ b/NetBird/Source/App/Views/MainView.swift @@ -71,8 +71,8 @@ struct iOSMainView: 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) @@ -82,7 +82,7 @@ struct iOSMainView: 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")) 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 index 4644cc4..2175933 100644 --- a/NetBird/Source/App/Views/TV/TVMainView.swift +++ b/NetBird/Source/App/Views/TV/TVMainView.swift @@ -21,24 +21,6 @@ import os private let buttonLogger = Logger(subsystem: "io.netbird.app", category: "TVConnectionButton") -private struct TVColors { - static var textPrimary: Color { - UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary - } - static var textSecondary: Color { - UIColor(named: "TextSecondary") != nil ? Color("TextSecondary") : .secondary - } - static var bgMenu: Color { - UIColor(named: "BgMenu") != nil ? Color("BgMenu") : Color(white: 0.1) - } - static var bgPrimary: Color { - UIColor(named: "BgPrimary") != nil ? Color("BgPrimary") : Color(white: 0.15) - } - static var bgSecondary: Color { - UIColor(named: "BgSecondary") != nil ? Color("BgSecondary") : Color(white: 0.08) - } -} - struct TVMainView: View { @EnvironmentObject var viewModel: ViewModel diff --git a/NetBird/Source/App/Views/TV/TVNetworksView.swift b/NetBird/Source/App/Views/TV/TVNetworksView.swift index 1ae7963..b3a97c7 100644 --- a/NetBird/Source/App/Views/TV/TVNetworksView.swift +++ b/NetBird/Source/App/Views/TV/TVNetworksView.swift @@ -13,21 +13,6 @@ import UIKit #if os(tvOS) -private struct TVColors { - static var textPrimary: Color { - UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary - } - static var textSecondary: Color { - UIColor(named: "TextSecondary") != nil ? Color("TextSecondary") : .secondary - } - static var bgMenu: Color { - UIColor(named: "BgMenu") != nil ? Color("BgMenu") : Color(white: 0.1) - } - static var bgPrimary: Color { - UIColor(named: "BgPrimary") != nil ? Color("BgPrimary") : Color(white: 0.15) - } -} - /// Displays the list of network routes in a tvOS-friendly format. struct TVNetworksView: View { @EnvironmentObject var viewModel: ViewModel diff --git a/NetBird/Source/App/Views/TV/TVPeersView.swift b/NetBird/Source/App/Views/TV/TVPeersView.swift index 9a18f38..94609c0 100644 --- a/NetBird/Source/App/Views/TV/TVPeersView.swift +++ b/NetBird/Source/App/Views/TV/TVPeersView.swift @@ -16,24 +16,6 @@ import UIKit #if os(tvOS) -private struct TVColors { - static var textPrimary: Color { - UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary - } - static var textSecondary: Color { - UIColor(named: "TextSecondary") != nil ? Color("TextSecondary") : .secondary - } - static var bgMenu: Color { - UIColor(named: "BgMenu") != nil ? Color("BgMenu") : Color(white: 0.1) - } - static var bgPrimary: Color { - UIColor(named: "BgPrimary") != nil ? Color("BgPrimary") : Color(white: 0.15) - } - static var bgSideDrawer: Color { - UIColor(named: "BgSideDrawer") != nil ? Color("BgSideDrawer") : Color(white: 0.2) - } -} - struct TVPeersView: View { @EnvironmentObject var viewModel: ViewModel diff --git a/NetBird/Source/App/Views/TV/TVServerView.swift b/NetBird/Source/App/Views/TV/TVServerView.swift index fe88e3c..486880e 100644 --- a/NetBird/Source/App/Views/TV/TVServerView.swift +++ b/NetBird/Source/App/Views/TV/TVServerView.swift @@ -18,24 +18,6 @@ import NetBirdSDK #if os(tvOS) -private struct TVColors { - static var textPrimary: Color { - UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary - } - static var textSecondary: Color { - UIColor(named: "TextSecondary") != nil ? Color("TextSecondary") : .secondary - } - static var bgMenu: Color { - UIColor(named: "BgMenu") != nil ? Color("BgMenu") : Color(white: 0.1) - } - static var bgPrimary: Color { - UIColor(named: "BgPrimary") != nil ? Color("BgPrimary") : Color(white: 0.15) - } - static var bgSideDrawer: Color { - UIColor(named: "BgSideDrawer") != nil ? Color("BgSideDrawer") : Color(white: 0.2) - } -} - struct TVServerView: View { @EnvironmentObject var viewModel: ViewModel @Binding var isPresented: Bool diff --git a/NetBird/Source/App/Views/TV/TVSettingsView.swift b/NetBird/Source/App/Views/TV/TVSettingsView.swift index b8052a2..4a2b899 100644 --- a/NetBird/Source/App/Views/TV/TVSettingsView.swift +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -13,27 +13,6 @@ import UIKit #if os(tvOS) -private struct TVColors { - static var textPrimary: Color { - UIColor(named: "TextPrimary") != nil ? Color("TextPrimary") : .primary - } - static var textSecondary: Color { - UIColor(named: "TextSecondary") != nil ? Color("TextSecondary") : .secondary - } - static var textAlert: Color { - UIColor(named: "TextAlert") != nil ? Color("TextAlert") : .white - } - static var bgMenu: Color { - UIColor(named: "BgMenu") != nil ? Color("BgMenu") : Color(white: 0.1) - } - static var bgPrimary: Color { - UIColor(named: "BgPrimary") != nil ? Color("BgPrimary") : Color(white: 0.15) - } - static var bgSideDrawer: Color { - UIColor(named: "BgSideDrawer") != nil ? Color("BgSideDrawer") : Color(white: 0.2) - } -} - /// Settings screen for tvOS, replacing the iOS side drawer. struct TVSettingsView: View { @EnvironmentObject var viewModel: ViewModel diff --git a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift index 098a688..9899b34 100644 --- a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift +++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift @@ -147,6 +147,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // 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)") } @@ -242,6 +245,20 @@ class PacketTunnelProvider: NEPacketTunnelProvider { 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) { diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index bca2872..808307e 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -580,6 +580,40 @@ public class NetworkExtensionAdapter: ObservableObject { 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) { From 72acdc157dd4549b2c8f2a1e5da5014bc09a32d3 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Thu, 11 Dec 2025 18:01:31 +0100 Subject: [PATCH 12/19] apply recommended xcode settings --- NetBird.xcodeproj/project.pbxproj | 8 +++----- NetBird.xcodeproj/xcshareddata/xcschemes/NetBird.xcscheme | 2 +- .../xcschemes/NetbirdNetworkExtension.xcscheme | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 6f10919..8007ef2 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -29,7 +29,6 @@ 443782D72EDF29A800F9FA94 /* NetworkExtensionAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50216D922ACB2488009574C9 /* NetworkExtensionAdapter.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, ); }; }; - 44DCF5A92EDF45E10026078E /* NetBirdSDK.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; }; 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, ); }; }; @@ -313,7 +312,6 @@ files = ( 44F3E3952EE2F6F900C87FEC /* NetBirdSDK.xcframework in Frameworks */, 441C5AFE2EDF0DD20055EEFC /* NetworkExtension.framework in Frameworks */, - 44DCF5A92EDF45E10026078E /* NetBirdSDK.xcframework in Frameworks */, 44F3E3992EE2F90900C87FEC /* libresolv.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -658,7 +656,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2610; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 2610; TargetAttributes = { 441C5AED2EDF0DAE0055EEFC = { CreatedOnToolsVersion = 26.1; @@ -1189,6 +1187,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; @@ -1243,6 +1242,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; @@ -1253,7 +1253,6 @@ 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; @@ -1307,7 +1306,6 @@ 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; 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 @@ Date: Thu, 11 Dec 2025 18:17:23 +0100 Subject: [PATCH 13/19] display correct app name in tvOS UI --- NetBird.xcodeproj/project.pbxproj | 2 ++ NetbirdKit/NetworkExtensionAdapter.swift | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 8007ef2..7a7e0a0 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -940,6 +940,7 @@ 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 = ( @@ -977,6 +978,7 @@ 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 = ( diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index 808307e..4b078a9 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -39,10 +39,10 @@ public class NetworkExtensionAdapter: ObservableObject { #if os(tvOS) var extensionID = "io.netbird.app.tv.extension" - var extensionName = "NetBird TV Network Extension" + var extensionName = "NetBird" #else var extensionID = "io.netbird.app.NetbirdNetworkExtension" - var extensionName = "NetBird Network Extension" + var extensionName = "NetBird" #endif let decoder = PropertyListDecoder() From 559294492691b72c1bee3ce306d68b47743f7e26 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Fri, 12 Dec 2025 17:03:30 +0100 Subject: [PATCH 14/19] added tv-specific assets, updated readme --- .../Contents.json | 3 --- .../Content.imageset/Contents.json | 11 ---------- .../Front.imagestacklayer/Contents.json | 6 ------ .../Content.imageset/Contents.json | 1 + .../Content.imageset/netbird-tvos-icon.png | Bin 0 -> 15418 bytes .../Content.imageset/Contents.json | 1 + .../Content.imageset/netbird-tvos-icon.png | Bin 0 -> 15418 bytes .../Content.imageset/Contents.json | 1 + .../Content.imageset/netbird-tvos-icon.png | Bin 0 -> 15418 bytes NetBird.xcodeproj/project.pbxproj | 2 ++ README.md | 20 +----------------- 11 files changed, 6 insertions(+), 39 deletions(-) delete mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json delete mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/netbird-tvos-icon.png create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/netbird-tvos-icon.png create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/netbird-tvos-icon.png 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 index de59d88..95d75a5 100644 --- 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 @@ -4,9 +4,6 @@ "version" : 1 }, "layers" : [ - { - "filename" : "Front.imagestacklayer" - }, { "filename" : "Middle.imagestacklayer" }, diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json deleted file mode 100644 index 2e00335..0000000 --- a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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/Front.imagestacklayer/Contents.json b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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 index 795cce1..bf734b8 100644 --- 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 @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "netbird-tvos-icon.png", "idiom" : "tv", "scale" : "1x" }, 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 0000000000000000000000000000000000000000..6f8b6935d0b835f785a935bfd706fe0e34be3569 GIT binary patch literal 15418 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 **Note:** The app cannot run in the tvOS simulator. To test on Apple TV: -> -> 1. **Pair Apple TV with Xcode:** -> - Ensure your Mac and Apple TV are on the same Wi-Fi network -> - On Apple TV: Settings → Remotes and Devices → Remote App and Devices -> - In Xcode: Window → Devices and Simulators (⇧⌘2) -> - Select your Apple TV from "Discovered" and click "Pair" -> - Enter the 6-digit code shown on your Apple TV -> -> 2. **Enable Developer Mode on Apple TV (tvOS 16+):** -> - Settings → Privacy & Security → Developer Mode → ON -> - Apple TV will restart -> -> 3. **Build and Run:** -> - Select the "NetBird TV" scheme in Xcode -> - Choose your paired Apple TV as the run destination -> - Press ⌘R to build and run -> -> **Minimum Requirement:** Apple TV must be running tvOS 17.0 or later for VPN support. +> **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 ## Other project repositories From 6c1a9d0b3471f497ccd2d6fb32c2e29db7b18759 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Fri, 12 Dec 2025 17:04:20 +0100 Subject: [PATCH 15/19] updated readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8afb747..f5f74d1 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Open the Xcode project, and we are ready to go. ### 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 +> **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 From 79afaae707135b90dc0d8466d6689770224d8f3b Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Fri, 12 Dec 2025 19:29:54 +0100 Subject: [PATCH 16/19] - Add checkLoginError IPC to detect extension login failures - Remove dead shared UserDefaults fallback code in NetBirdAdapter - Document tvOS config storage architecture in Preferences.swift - Fix onChange deprecation warnings in TVSettingsView - Add "no peer auth method" detection in ServerViewModel --- NetBird/Source/App/Views/TV/TVAuthView.swift | 92 ++++++++++++++++--- NetBird/Source/App/Views/TV/TVMainView.swift | 16 ++++ .../PacketTunnelProvider.swift | 3 - NetbirdKit/NetworkExtensionAdapter.swift | 52 ++++++++++- NetbirdKit/Preferences.swift | 31 +++++++ NetbirdNetworkExtension/NetBirdAdapter.swift | 24 ++--- 6 files changed, 182 insertions(+), 36 deletions(-) diff --git a/NetBird/Source/App/Views/TV/TVAuthView.swift b/NetBird/Source/App/Views/TV/TVAuthView.swift index ff46900..5bc3a20 100644 --- a/NetBird/Source/App/Views/TV/TVAuthView.swift +++ b/NetBird/Source/App/Views/TV/TVAuthView.swift @@ -33,15 +33,24 @@ struct TVAuthView: View { /// 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 @@ -122,17 +131,37 @@ struct TVAuthView: View { } } - // Loading indicator - HStack(spacing: 15) { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .scaleEffect(1.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) + Text("Waiting for sign-in...") + .font(.system(size: 22)) + .foregroundColor(.gray) + } + .padding(.top, 20) } - .padding(.top, 20) // Cancel button Button(action: { @@ -231,30 +260,59 @@ struct TVAuthView: View { /// 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 we need + // 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) { [self] timer in + 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?() - self.isPresented = false } } } @@ -263,19 +321,25 @@ struct TVAuthView: View { 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") - self.pollTimer?.invalidate() + #endif onCompleteHandler?() - self.isPresented = false } } } diff --git a/NetBird/Source/App/Views/TV/TVMainView.swift b/NetBird/Source/App/Views/TV/TVMainView.swift index 2175933..2d54c51 100644 --- a/NetBird/Source/App/Views/TV/TVMainView.swift +++ b/NetBird/Source/App/Views/TV/TVMainView.swift @@ -69,14 +69,30 @@ struct TVMainView: View { 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) + } } ) } diff --git a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift index 9899b34..aaabb9d 100644 --- a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift +++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift @@ -57,16 +57,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } #else logger.info("startTunnel: skipping file-based logging on tvOS (sandbox blocks writes)") - NSLog("NetBirdTV: skipping file-based logging on tvOS") // 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") - NSLog("NetBirdTV: config found in UserDefaults") } else { logger.info("startTunnel: tvOS - no config in UserDefaults, login will be required") - NSLog("NetBirdTV: no config in UserDefaults") } #endif diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index 4b078a9..b5b70c8 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -304,7 +304,7 @@ public class NetworkExtensionAdapter: ObservableObject { continuation.resume(returning: urlString) } } - + self.loginURL = loginURLString self.showBrowser = true } @@ -333,7 +333,7 @@ public class NetworkExtensionAdapter: ObservableObject { func stop() -> Void { self.vpnManager?.connection.stopVPNTunnel() } - + func login(completion: @escaping (String) -> Void) { if self.session == nil { logger.error("login: No session available for login") @@ -427,6 +427,54 @@ public class NetworkExtensionAdapter: ObservableObject { } } + /// 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: []) diff --git a/NetbirdKit/Preferences.swift b/NetbirdKit/Preferences.swift index 41a9ba4..e8cf124 100644 --- a/NetbirdKit/Preferences.swift +++ b/NetbirdKit/Preferences.swift @@ -8,6 +8,37 @@ 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 { #if os(tvOS) static let appGroupIdentifier = "group.io.netbird.app.tv" diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index 95b1511..899e198 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -233,14 +233,10 @@ public class NetBirdAdapter { // Create the client with empty paths and load config from local storage instead. self.client = NetBirdSDKNewClient("", "", deviceName, osVersion, osName, self.networkChangeListener, self.dnsManager)! - // Try to load config from extension-local storage first (set via IPC from main app) - // This is more reliable than shared UserDefaults which doesn't work on tvOS - var configJSON: String? = UserDefaults.standard.string(forKey: "netbird_config_json_local") - - // Fall back to shared UserDefaults (may work in some cases) - if configJSON == nil { - configJSON = Preferences.loadConfigFromUserDefaults() - } + // 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) @@ -417,17 +413,11 @@ public class NetBirdAdapter { // Use default management URL for tvOS, empty for iOS (which handles it via ServerView) #if os(tvOS) - // On tvOS, config may be stored in extension-local UserDefaults (via IPC) or shared UserDefaults. - // Try local first, then fall back to shared. + // 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 - // First try extension-local storage (set via IPC from main app) - var configJSON: String? = UserDefaults.standard.string(forKey: "netbird_config_json_local") - - // Fall back to shared UserDefaults - if configJSON == nil { - configJSON = Preferences.loadConfigFromUserDefaults() - } + let configJSON: String? = UserDefaults.standard.string(forKey: "netbird_config_json_local") if let configJSON = configJSON, let storedURL = Self.extractManagementURL(from: configJSON) { From a8fa1569a68395b49a318b7362bb7c87562ea6ac Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Wed, 17 Dec 2025 19:05:12 +0100 Subject: [PATCH 17/19] Fix CodeRabbit review issues: bundle IDs, force-unwraps, and error handling - Fix mismatched bundle IDs between Debug/Release configs for tvOS targets - Make Preferences.configFile() and stateFile() return optionals instead of force-unwrapping, with proper error logging when app group unavailable - Make NetBirdAdapter.init failable to handle SDK client creation failures - Fail fast in NetBirdAdapter.start() when tunnel file descriptor is invalid instead of silently passing fd=0 (stdin) to the SDK - Ensure all handleAppMessage switch cases call completionHandler to prevent IPC callers from hanging indefinitely --- NetBird TV/ContentView.swift | 24 ----- NetBird.xcodeproj/project.pbxproj | 4 +- .../Source/App/ViewModels/MainViewModel.swift | 14 ++- NetBird/Source/App/Views/ServerView.swift | 2 +- .../Source/App/Views/TV/TVServerView.swift | 2 +- .../PacketTunnelProvider.swift | 99 ++++++++++++++----- NetbirdKit/NetworkExtensionAdapter.swift | 11 ++- NetbirdKit/Preferences.swift | 33 ++++--- NetbirdNetworkExtension/NetBirdAdapter.swift | 35 ++++++- .../PacketTunnelProvider.swift | 40 ++++++-- 10 files changed, 186 insertions(+), 78 deletions(-) delete mode 100644 NetBird TV/ContentView.swift diff --git a/NetBird TV/ContentView.swift b/NetBird TV/ContentView.swift deleted file mode 100644 index da7351a..0000000 --- a/NetBird TV/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// NetBird TV -// -// Created by Ashley Mensah on 02.12.25. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 5b551e8..1e1b607 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -1009,7 +1009,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "io.netbird.app.NetBird-TV"; + PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app.tv; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -1083,7 +1083,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "io.netbird.app.NetBird-TV.NetBirdTVNetworkExtension"; + PRODUCT_BUNDLE_IDENTIFIER = io.netbird.app.tv.extension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; SKIP_INSTALL = YES; diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index a866288..3958db5 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -263,7 +263,12 @@ class ViewModel: ObservableObject { func updateManagementURL(url: String, completion: @escaping (Bool?) -> Void) { let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) - let newAuth = NetBirdSDKNewAuth(Preferences.configFile(), trimmedURL, nil) + guard let configPath = Preferences.configFile() else { + print("updateManagementURL: App group container unavailable") + completion(nil) + return + } + let newAuth = NetBirdSDKNewAuth(configPath, trimmedURL, nil) self.managementURL = trimmedURL let listener = SSOCheckListener() @@ -305,7 +310,12 @@ class ViewModel: ObservableObject { } func setSetupKey(key: String, completion: @escaping (Error?) -> Void) { - let newAuth = NetBirdSDKNewAuth(Preferences.configFile(), self.managementURL, nil) + guard let configPath = Preferences.configFile() else { + print("setSetupKey: App group container unavailable") + completion(NSError(domain: "io.netbird", code: 1003, userInfo: [NSLocalizedDescriptionKey: "App group container unavailable"])) + return + } + let newAuth = NetBirdSDKNewAuth(configPath, self.managementURL, nil) let listener = SetupKeyErrListener() listener.onResult = { error in diff --git a/NetBird/Source/App/Views/ServerView.swift b/NetBird/Source/App/Views/ServerView.swift index 6fb244a..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" diff --git a/NetBird/Source/App/Views/TV/TVServerView.swift b/NetBird/Source/App/Views/TV/TVServerView.swift index 486880e..0633342 100644 --- a/NetBird/Source/App/Views/TV/TVServerView.swift +++ b/NetBird/Source/App/Views/TV/TVServerView.swift @@ -23,7 +23,7 @@ struct TVServerView: View { @Binding var isPresented: Bool @StateObject private var serverViewModel = ServerViewModel( - configurationFilePath: Preferences.configFile(), + configurationFilePath: Preferences.configFile() ?? "", deviceName: Device.getName() ) diff --git a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift index aaabb9d..0a76616 100644 --- a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift +++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift @@ -32,7 +32,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return PacketTunnelProviderSettingsManager(with: self) }() - private lazy var adapter: NetBirdAdapter = { + private lazy var adapter: NetBirdAdapter? = { return NetBirdAdapter(with: self.tunnelManager) }() @@ -70,6 +70,16 @@ class PacketTunnelProvider: NEPacketTunnelProvider { 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 { @@ -95,7 +105,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { - adapter.stop() + adapter?.stop() if let pathMonitor = self.pathMonitor { pathMonitor.cancel() self.pathMonitor = nil @@ -136,9 +146,11 @@ 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)) case let s where s.hasPrefix("SetConfig:"): // On tvOS, receive config JSON from main app via IPC // This bypasses the broken shared UserDefaults @@ -149,6 +161,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { clearLocalConfig(completionHandler: completionHandler) default: logger.warning("handleAppMessage: Unknown message: \(string)") + completionHandler(nil) } } @@ -196,8 +209,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { func restartClient() { logger.info("restartClient: Restarting client due to network change") - adapter.stop() - adapter.start { error in + adapter?.stop() + adapter?.start { error in if let error = error { logger.error("restartClient: Error restarting client: \(error.localizedDescription)") } else { @@ -207,6 +220,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } 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) @@ -224,13 +241,17 @@ class PacketTunnelProvider: NEPacketTunnelProvider { UserDefaults.standard.synchronize() // Also try to load into the adapter's client if it exists - 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)") + 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) @@ -259,7 +280,12 @@ class PacketTunnelProvider: NEPacketTunnelProvider { /// 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) { - let configPath = Preferences.configFile() + 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 @@ -325,7 +351,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // Nothing to do here. logger.info("initializeConfigIfNeeded: tvOS - config loading handled by adapter init") #else - let configPath = Preferences.configFile() + 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 @@ -354,6 +383,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { /// 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 @@ -365,11 +401,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let loginRequired = adapter.needsLogin() // Also check if config file exists now (written after successful auth) - let configPath = Preferences.configFile() - let statePath = Preferences.stateFile() + let configPath = Preferences.configFile() ?? "" + let statePath = Preferences.stateFile() ?? "" let fileManager = FileManager.default - let configExists = fileManager.fileExists(atPath: configPath) - let stateExists = fileManager.fileExists(atPath: statePath) + 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 @@ -410,6 +446,12 @@ class PacketTunnelProvider: NEPacketTunnelProvider { 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( @@ -435,11 +477,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider { 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 configPath = Preferences.configFile() ?? "" + let statePath = Preferences.stateFile() ?? "" let fileManager = FileManager.default - logger.info("loginTV: configFile exists = \(fileManager.fileExists(atPath: configPath))") - logger.info("loginTV: stateFile exists = \(fileManager.fileExists(atPath: statePath))") + 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 @@ -470,6 +512,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } 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) @@ -510,10 +556,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 ) @@ -523,7 +570,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } catch { logger.error("getStatus: 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 { @@ -534,6 +581,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func getSelectRoutes(completionHandler: (Data?) -> Void) { + guard let adapter = adapter else { + completionHandler(nil) + return + } do { let routeSelectionDetailsMessage = try adapter.client.getRoutesSelectionDetails() @@ -575,6 +626,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func selectRoute(id: String) { + guard let adapter = adapter else { return } do { try adapter.client.selectRoute(id) logger.info("selectRoute: Selected route \(id)") @@ -584,6 +636,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func deselectRoute(id: String) { + guard let adapter = adapter else { return } do { try adapter.client.deselectRoute(id) logger.info("deselectRoute: Deselected route \(id)") diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index b5b70c8..0b1770e 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -155,7 +155,10 @@ public class NetworkExtensionAdapter: ObservableObject { return } - let configPath = Preferences.configFile() + 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) @@ -216,8 +219,10 @@ public class NetworkExtensionAdapter: ObservableObject { #endif public func isLoginRequired() -> Bool { - let configPath = Preferences.configFile() - let statePath = Preferences.stateFile() + 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 diff --git a/NetbirdKit/Preferences.swift b/NetbirdKit/Preferences.swift index e8cf124..4ab1736 100644 --- a/NetbirdKit/Preferences.swift +++ b/NetbirdKit/Preferences.swift @@ -47,29 +47,37 @@ class Preferences { #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(configFile(), stateFile()) + return NetBirdSDKNewPreferences(configPath, statePath) #else - return NetBirdSDKNewPreferences(configFile(), stateFile()) + return NetBirdSDKNewPreferences(configPath, statePath) #endif } - static func configFile() -> String { + static func configFile() -> String? { let fileManager = FileManager.default - let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) - 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: appGroupIdentifier) - 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 @@ -124,7 +132,10 @@ class Preferences { return false } - let path = configFile() + 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 diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index fa7694c..35f9526 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -219,7 +219,8 @@ public class NetBirdAdapter { /// Designated initializer. /// - Parameter packetTunnelProvider: an instance of `NEPacketTunnelProvider`. Internally stored /// as a weak reference. - init(with tunnelManager: PacketTunnelProviderSettingsManager) { + /// - Returns: nil if the NetBird SDK client could not be initialized. + init?(with tunnelManager: PacketTunnelProviderSettingsManager) { self.tunnelManager = tunnelManager self.networkChangeListener = NetworkChangeListener(with: tunnelManager) self.dnsManager = DNSManager(with: tunnelManager) @@ -231,7 +232,11 @@ public class NetBirdAdapter { #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. - self.client = NetBirdSDKNewClient("", "", deviceName, osVersion, osName, self.networkChangeListener, self.dnsManager)! + 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 @@ -250,7 +255,15 @@ public class NetBirdAdapter { adapterLogger.info("init: tvOS - no config found, client initialized without config") } #else - self.client = NetBirdSDKNewClient(Preferences.configFile(), Preferences.stateFile(), deviceName, osVersion, osName, self.networkChangeListener, self.dnsManager)! + 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 } @@ -283,7 +296,15 @@ public class NetBirdAdapter { public func start(completionHandler: @escaping (Error?) -> Void) { DispatchQueue.global().async { do { - let fd = self.tunnelFileDescriptor ?? 0 + 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) @@ -436,7 +457,11 @@ public class NetBirdAdapter { #endif // Get Auth object and call login - if let auth = NetBirdSDKNewAuth(Preferences.configFile(), managementURL, nil) { + guard let configPath = Preferences.configFile() else { + handleError(NSError(domain: "io.netbird", code: 1003, userInfo: [NSLocalizedDescriptionKey: "App group container unavailable"])) + return + } + if let auth = NetBirdSDKNewAuth(configPath, managementURL, nil) { authRef = auth #if os(tvOS) 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 { From 3dbe268f89f448efda10e887d4b3302aff9b8dcf Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Wed, 17 Dec 2025 19:18:21 +0100 Subject: [PATCH 18/19] - Delete unused updateManagementURL() and setSetupKey() functions (functionality migrated to ServerViewModel) - Remove orphaned managementURL property - Simplify supportsKeyboard to just return true (both branches were identical) - Updated README instructions on buiilding tvOS SDK (needs NetBird gomobile fork) --- NetBird/Source/App/Platform/Platform.swift | 7 +-- .../Source/App/ViewModels/MainViewModel.swift | 58 ------------------- README.md | 6 +- 3 files changed, 6 insertions(+), 65 deletions(-) diff --git a/NetBird/Source/App/Platform/Platform.swift b/NetBird/Source/App/Platform/Platform.swift index f3be4ff..cbae8ac 100644 --- a/NetBird/Source/App/Platform/Platform.swift +++ b/NetBird/Source/App/Platform/Platform.swift @@ -125,12 +125,7 @@ struct PlatformCapabilities { } static var supportsKeyboard: Bool { - #if os(tvOS) - // tvOS has on-screen keyboard but it's clunky - return true - #else - return true - #endif + true } } diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 3958db5..87ef534 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -77,7 +77,6 @@ class ViewModel: ObservableObject { @Published var rosenpassEnabled = false @Published var rosenpassPermissive = false - @Published var managementURL = "" @Published var presharedKey = "" @Published var server: String = "" @Published var setupKey: String = "" @@ -261,39 +260,6 @@ class ViewModel: ObservableObject { } } - func updateManagementURL(url: String, completion: @escaping (Bool?) -> Void) { - let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) - guard let configPath = Preferences.configFile() else { - print("updateManagementURL: App group container unavailable") - completion(nil) - return - } - let newAuth = NetBirdSDKNewAuth(configPath, trimmedURL, nil) - self.managementURL = trimmedURL - - let listener = SSOCheckListener() - listener.onResult = { ssoSupported, error in - DispatchQueue.main.async { - if let error = error { - print("Failed to check SSO support: \(error.localizedDescription)") - completion(nil) - } else if let supported = ssoSupported { - if supported { - print("SSO is supported") - completion(true) - } else { - print("SSO is not supported. Fallback to setup key") - completion(false) - } - } else { - completion(nil) - } - } - } - - newAuth?.saveConfigIfSSOSupported(listener) - } - func clearDetails() { self.ip = "" self.fqdn = "" @@ -309,30 +275,6 @@ class ViewModel: ObservableObject { #endif } - func setSetupKey(key: String, completion: @escaping (Error?) -> Void) { - guard let configPath = Preferences.configFile() else { - print("setSetupKey: App group container unavailable") - completion(NSError(domain: "io.netbird", code: 1003, userInfo: [NSLocalizedDescriptionKey: "App group container unavailable"])) - return - } - let newAuth = NetBirdSDKNewAuth(configPath, self.managementURL, nil) - - let listener = SetupKeyErrListener() - listener.onResult = { error in - DispatchQueue.main.async { - if let error = error { - print("Setup key login failed: \(error.localizedDescription)") - completion(error) - } else { - self.managementURL = "" - completion(nil) - } - } - } - - newAuth?.login(withSetupKeyAndSaveConfig: listener, setupKey: key, deviceName: Device.getName()) - } - func updatePreSharedKey() { guard let preferences = preferences else { print("updatePreSharedKey: Preferences not available") diff --git a/README.md b/README.md index f5f74d1..2c34c43 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,11 @@ The code is divided into 4 parts: - iOS 14.0+ / tvOS 17.0+ - Xcode 15.0+ -- gomobile (with tvOS support - see build instructions) +- 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 From 71f06dd42a06080eeaca3aec0e87036cabc48618 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Wed, 17 Dec 2025 20:06:25 +0100 Subject: [PATCH 19/19] Fix tvOS build issues from CodeRabbit review - Use empty configPath on tvOS in loginAsync() since config uses UserDefaults - Add JSON escaping in updateDeviceNameInConfig() for special characters - Standardize TVOS_DEPLOYMENT_TARGET to 17.0 across all configurations - Add missing CODE_SIGN_ENTITLEMENTS to NetBird TV Release config --- NetBird TV/NetBird TV.entitlements | 14 ++++++++++++++ NetBird.xcodeproj/project.pbxproj | 11 ++++++----- NetbirdNetworkExtension/NetBirdAdapter.swift | 16 +++++++++++++++- 3 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 NetBird TV/NetBird TV.entitlements 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.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 1e1b607..d154541 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -982,7 +982,7 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 17.6; + TVOS_DEPLOYMENT_TARGET = 17.0; }; name = Debug; }; @@ -993,6 +993,7 @@ 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; @@ -1019,7 +1020,7 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 26.1; + TVOS_DEPLOYMENT_TARGET = 17.0; }; name = Release; }; @@ -1058,7 +1059,7 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 26.1; + TVOS_DEPLOYMENT_TARGET = 17.0; }; name = Debug; }; @@ -1093,7 +1094,7 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 26.1; + TVOS_DEPLOYMENT_TARGET = 17.0; }; name = Release; }; @@ -1338,7 +1339,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 94333M4JTA; + DEVELOPMENT_TEAM = TA739QLA7A; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/**"; GENERATE_INFOPLIST_FILE = YES; diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index 35f9526..76d69ea 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -457,10 +457,16 @@ public class NetBirdAdapter { #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 @@ -497,8 +503,16 @@ public class NetBirdAdapter { /// 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\":\"\(newName)\"" + let replacement = "\"DeviceName\":\"\(escapedName)\"" if let regex = try? NSRegularExpression(pattern: pattern, options: []) { let range = NSRange(configJSON.startIndex..., in: configJSON)