From 413f113ab7926ecca542ee427b5296800da36193 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Tue, 2 Dec 2025 14:41:24 +0100 Subject: [PATCH 01/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] - 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/60] 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/60] 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/60] 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/60] - 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/60] 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/60] 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/60] 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/60] 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/60] - 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/60] 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/60] - 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/60] 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) From 0a65a14ae49d407284ada36e2b6389e1f03cadf2 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Tue, 23 Dec 2025 18:38:14 +0000 Subject: [PATCH 20/60] added empty box asset to tvOS app --- .../icon-empty-box.imageset/Contents.json | 17 +++++++++++++++++ .../icon-empty-box.imageset/icon-empty-box.png | Bin 0 -> 3149 bytes 2 files changed, 17 insertions(+) create mode 100644 NetBird TV/Assets.xcassets/icon-empty-box.imageset/Contents.json create mode 100644 NetBird TV/Assets.xcassets/icon-empty-box.imageset/icon-empty-box.png diff --git a/NetBird TV/Assets.xcassets/icon-empty-box.imageset/Contents.json b/NetBird TV/Assets.xcassets/icon-empty-box.imageset/Contents.json new file mode 100644 index 0000000..9ad38af --- /dev/null +++ b/NetBird TV/Assets.xcassets/icon-empty-box.imageset/Contents.json @@ -0,0 +1,17 @@ +{ + "images" : [ + { + "filename" : "icon-empty-box.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NetBird TV/Assets.xcassets/icon-empty-box.imageset/icon-empty-box.png b/NetBird TV/Assets.xcassets/icon-empty-box.imageset/icon-empty-box.png new file mode 100644 index 0000000000000000000000000000000000000000..f51c1d2856abc4385ebde34316bff90bd5271c50 GIT binary patch literal 3149 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 Date: Tue, 23 Dec 2025 19:00:41 +0000 Subject: [PATCH 21/60] - Preferences.swift: Use #if os(iOS) / #else for platform-specific newPreferences() - iOS returns non-optional with preconditionFailure on misconfiguration, tvOS returns nil by design (uses IPC not SDK) - MainViewModel: Wrap preferences property and SDK methods (rosenpass, presharedKey) in platform conditionals. Add tvOS stubs for methods called from shared UI. Wrap IPC flag methods (checkLoginRequiredFlag, checkNetworkUnavailableFlag) since App Group UserDefaults are isolated sandboxes on tvOS. - PacketTunnelProvider: Fix optional unwrapping for clientState logging - project.pbxproj: Add Platform.swift to iOS target (was tvOS-only) --- NetBird.xcodeproj/project.pbxproj | 2 + .../Source/App/ViewModels/MainViewModel.swift | 72 +++++------ NetbirdKit/Preferences.swift | 120 ++++++++---------- .../PacketTunnelProvider.swift | 3 +- 4 files changed, 95 insertions(+), 102 deletions(-) diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 93962ca..a5bf895 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 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 */; }; + 978FC4742EEDF168002D0EB8 /* 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 */; }; @@ -935,6 +936,7 @@ 50E608132A7958B100BAF09B /* MainViewModel.swift in Sources */, F1B2920A2EE0BC46001D91B8 /* GlobalConstants.swift in Sources */, 978FC4702EEDF167002D0EB8 /* AppLogger.swift in Sources */, + 978FC4742EEDF168002D0EB8 /* Platform.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 7c4ff4a..70c8690 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -103,23 +103,28 @@ class ViewModel: ObservableObject { @Published var networkUnavailable = false /// 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. + /// - iOS: Returns non-optional NetBirdSDKPreferences (crashes if misconfigured) + /// - tvOS: Returns nil (tvOS uses IPC-based config, not SDK preferences) + #if os(iOS) private var _preferences: NetBirdSDKPreferences? private var _preferencesLoaded = false - var preferences: NetBirdSDKPreferences? { + var preferences: NetBirdSDKPreferences { get { if !_preferencesLoaded { _preferencesLoaded = true _preferences = Preferences.newPreferences() } - return _preferences + return _preferences! } set { _preferencesLoaded = true _preferences = newValue } } + #else + // tvOS: SDK preferences not available - config managed via IPC + var preferences: NetBirdSDKPreferences? { nil } + #endif var buttonLock = false let defaults = UserDefaults.standard @@ -284,11 +289,11 @@ class ViewModel: ObservableObject { #endif } + // MARK: - SDK Preferences Methods (iOS only) + // tvOS doesn't use SDK preferences - config is managed via IPC + + #if os(iOS) func updatePreSharedKey() { - guard let preferences = preferences else { - print("updatePreSharedKey: Preferences not available") - return - } preferences.setPreSharedKey(presharedKey) do { try preferences.commit() @@ -302,10 +307,6 @@ class ViewModel: ObservableObject { } func removePreSharedKey() { - guard let preferences = preferences else { - print("removePreSharedKey: Preferences not available") - return - } presharedKey = "" preferences.setPreSharedKey(presharedKey) do { @@ -318,19 +319,11 @@ class ViewModel: ObservableObject { } 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() @@ -340,40 +333,26 @@ class ViewModel: ObservableObject { } 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() @@ -381,6 +360,16 @@ class ViewModel: ObservableObject { print("Failed to update rosenpass permissive settings") } } + #else + // tvOS stubs - these settings are managed differently on tvOS + func updatePreSharedKey() {} + func removePreSharedKey() {} + func loadPreSharedKey() {} + func setRosenpassEnabled(enabled: Bool) {} + func getRosenpassEnabled() -> Bool { false } + func getRosenpassPermissive() -> Bool { false } + func setRosenpassPermissive(permissive: Bool) {} + #endif func setForcedRelayConnection(isEnabled: Bool) { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) @@ -449,13 +438,17 @@ class ViewModel: ObservableObject { self.networkExtensionAdapter.stop() } - // Reload preferences for new server + // Reload preferences for new server (iOS only - tvOS uses IPC) + #if os(iOS) preferences = Preferences.newPreferences() + #endif } /// Checks shared app-group container for network unavailable flag set by the network extension. /// Updates the networkUnavailable property to trigger UI animation changes. + /// iOS only - tvOS cannot share UserDefaults between app and extension. func checkNetworkUnavailableFlag() { + #if os(iOS) let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) let isUnavailable = userDefaults?.bool(forKey: GlobalConstants.keyNetworkUnavailable) ?? false @@ -463,11 +456,16 @@ class ViewModel: ObservableObject { AppLogger.shared.log("Network unavailable flag changed: \(isUnavailable)") networkUnavailable = isUnavailable } + #endif + // tvOS: Network status is determined by extension state, not a shared flag } /// Checks shared app-group container for login required flag set by the network extension. /// If set, schedules a local notification (if authorized) and shows the authentication UI. + /// iOS only - tvOS cannot share UserDefaults between app and extension, and uses IPC + /// via `checkLoginError` instead. func checkLoginRequiredFlag() { + #if os(iOS) let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) guard userDefaults?.bool(forKey: GlobalConstants.keyLoginRequired) == true else { return @@ -482,8 +480,7 @@ class ViewModel: ObservableObject { // Show authentication required UI self.showAuthenticationRequired = true - // Schedule local notification if authorized (iOS only - tvOS doesn't support these notification properties) - #if os(iOS) + // Schedule local notification if authorized UNUserNotificationCenter.current().getNotificationSettings { settings in guard settings.authorizationStatus == .authorized else { AppLogger.shared.log("Notifications not authorized, skipping notification") @@ -510,5 +507,6 @@ class ViewModel: ObservableObject { } } #endif + // tvOS: Login errors are detected via IPC (checkLoginError in TVAuthView) } } diff --git a/NetbirdKit/Preferences.swift b/NetbirdKit/Preferences.swift index 579d69f..a833a2d 100644 --- a/NetbirdKit/Preferences.swift +++ b/NetbirdKit/Preferences.swift @@ -8,55 +8,52 @@ import Foundation import NetBirdSDK -/// Preferences manages configuration file paths and UserDefaults-based config storage. +/// Preferences manages configuration file paths and SDK preferences. /// -/// ## tvOS Config Storage Architecture +/// ## Platform Differences /// -/// 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` +/// ### iOS +/// Uses file-based storage via App Group shared container. The main app and extension +/// can both read/write files to this shared location. /// -/// To work around this, tvOS uses a different architecture: +/// ### tvOS +/// The App Group shared container does NOT work for IPC between the main app and +/// Network Extension due to sandbox restrictions. Config is transferred via IPC +/// (`sendProviderMessage`/`handleAppMessage`) instead. The SDK preferences are not +/// used on tvOS - settings are managed directly in the extension. /// -/// ### 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. +/// See NetworkExtensionAdapter and PacketTunnelProvider for tvOS config flow details. class Preferences { - static func newPreferences() -> NetBirdSDKPreferences? { + // MARK: - SDK Preferences + + #if os(iOS) + /// Creates SDK preferences using App Group shared container paths. + /// iOS only - file-based storage works reliably. + static func newPreferences() -> NetBirdSDKPreferences { guard let configPath = configFile(), let statePath = stateFile() else { - print("ERROR: Cannot create preferences - app group container unavailable") - return nil + preconditionFailure("App group container unavailable - check entitlements for '\(GlobalConstants.userPreferencesSuiteName)'") } - #if os(tvOS) - // On tvOS, creating SDK Preferences may fail if the app doesn't have write access - // to the App Group container. Try anyway - if it fails, settings will be managed - // via the extension instead. - // Note: The SDK now uses DirectWriteOutConfig which may work better on tvOS. - return NetBirdSDKNewPreferences(configPath, statePath) - #else - return NetBirdSDKNewPreferences(configPath, statePath) - #endif + guard let preferences = NetBirdSDKNewPreferences(configPath, statePath) else { + preconditionFailure("Failed to create NetBirdSDKPreferences") + } + return preferences + } + #else + /// tvOS does not use SDK preferences - config is transferred via IPC. + /// Returns nil by design; callers must handle this case. + static func newPreferences() -> NetBirdSDKPreferences? { + // tvOS uses IPC-based config transfer, not file-based SDK preferences. + // The extension manages its own config via UserDefaults.standard after + // receiving it through handleAppMessage. + return nil } + #endif + // MARK: - File Paths + + /// Returns the file path for a given filename in the App Group container. + /// Returns nil if the container is unavailable. static func getFilePath(fileName: String) -> String? { let fileManager = FileManager.default if let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: GlobalConstants.userPreferencesSuiteName) { @@ -64,8 +61,7 @@ class Preferences { } #if DEBUG - // Fallback for testing or when app group is not available - // (prefer non-user-visible dir) + // Fallback for testing when app group is not available let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first return (baseURL ?? fileManager.temporaryDirectory).appendingPathComponent(fileName).path @@ -83,17 +79,21 @@ class Preferences { return getFilePath(fileName: GlobalConstants.stateFileName) } - // MARK: - UserDefaults-based config storage for tvOS - // tvOS sandbox prevents file writes to App Group containers, so we use UserDefaults instead + // MARK: - App-Local UserDefaults Storage + // + // These methods store config in the App Group UserDefaults for the MAIN APP's + // own use (e.g., displaying current server URL). On tvOS, this data is NOT + // shared with the extension - it's app-local only. private static let configJSONKey = "netbird_config_json" - /// Get the shared UserDefaults for the App Group + /// Get the App Group UserDefaults. + /// Note: On tvOS, this is app-local only - NOT shared with extension. static func sharedUserDefaults() -> UserDefaults? { return UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) } - /// Save config JSON to UserDefaults (works on tvOS where file writes fail) + /// Save config JSON to UserDefaults (app-local storage). static func saveConfigToUserDefaults(_ configJSON: String) -> Bool { guard let defaults = sharedUserDefaults() else { return false @@ -103,23 +103,17 @@ class Preferences { return true } - /// Load config JSON from UserDefaults + /// Load config JSON from UserDefaults (app-local storage). static func loadConfigFromUserDefaults() -> String? { - guard let defaults = sharedUserDefaults() else { - return nil - } - return defaults.string(forKey: configJSONKey) + return sharedUserDefaults()?.string(forKey: configJSONKey) } - /// Check if config exists in UserDefaults + /// Check if config exists in UserDefaults. static func hasConfigInUserDefaults() -> Bool { - guard let defaults = sharedUserDefaults() else { - return false - } - return defaults.string(forKey: configJSONKey) != nil + return sharedUserDefaults()?.string(forKey: configJSONKey) != nil } - /// Remove config from UserDefaults (for logout) + /// Remove config from UserDefaults (for logout). static func removeConfigFromUserDefaults() { guard let defaults = sharedUserDefaults() else { return @@ -128,15 +122,12 @@ class Preferences { defaults.synchronize() } - /// Restore config from UserDefaults to the config file path - /// This is needed because the Go SDK reads from the file path + /// Restore config from UserDefaults to the config file path. + /// iOS only - needed because the Go SDK reads from the file path. + #if os(iOS) static func restoreConfigFromUserDefaults() -> Bool { - guard let configJSON = loadConfigFromUserDefaults() else { - return false - } - - guard let path = configFile() else { - print("ERROR: Cannot restore config - app group container unavailable") + guard let configJSON = loadConfigFromUserDefaults(), + let path = configFile() else { return false } do { @@ -146,4 +137,5 @@ class Preferences { return false } } + #endif } diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 8e33fa3..92cf195 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -149,7 +149,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // We don't call adapter.stop() to avoid race conditions with Go SDK callbacks // The Go SDK will handle network loss internally and reconnect when available if !wasStoppedDueToNoNetwork { - AppLogger.shared.log("Network unavailable - signaling UI for disconnecting animation, clientState=\(adapter.clientState)") + let stateDesc = adapter?.clientState.description ?? "unknown" + AppLogger.shared.log("Network unavailable - signaling UI for disconnecting animation, clientState=\(stateDesc)") wasStoppedDueToNoNetwork = true setNetworkUnavailableFlag(true) } From 77ef043d45f30e4e2c54d5ebac41e6394410fff0 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Tue, 23 Dec 2025 19:12:58 +0000 Subject: [PATCH 22/60] Add ConfigurationProvider protocol to abstract platform config storage - Create ConfigurationProvider protocol with iOS/tvOS implementations - iOS uses NetBirdSDKPreferences (file-based), tvOS uses UserDefaults - Refactor MainViewModel to use ConfigurationProvider, removing platform conditionals and empty tvOS stubs - tvOS now stores rosenpass/presharedKey settings locally (ready for future IPC integration) --- NetBird.xcodeproj/project.pbxproj | 6 + .../Source/App/ViewModels/MainViewModel.swift | 98 ++----- NetbirdKit/ConfigurationProvider.swift | 273 ++++++++++++++++++ 3 files changed, 305 insertions(+), 72 deletions(-) create mode 100644 NetbirdKit/ConfigurationProvider.swift diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index a5bf895..b7761b9 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -28,6 +28,8 @@ 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 */; }; + A1B2C3D52EEDF501001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; + A1B2C3D62EEDF502001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.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 */; }; @@ -244,6 +246,7 @@ 50245A192A7BCE830034792B /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; }; 50245A1B2A7BCF120034792B /* NetBird.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NetBird.entitlements; sourceTree = ""; }; 50245A292A7BDB590034792B /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; + A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationProvider.swift; sourceTree = ""; }; 50245A2D2A7BDC470034792B /* NetworkChangeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkChangeListener.swift; sourceTree = ""; }; 50245A3B2A7FD1CD0034792B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 50245A522A80431B0034792B /* NetbirdNetworkExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NetbirdNetworkExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -484,6 +487,7 @@ F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */, F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */, 50245A292A7BDB590034792B /* Preferences.swift */, + A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */, 50245A2D2A7BDC470034792B /* NetworkChangeListener.swift */, 50CD81612AD0595E00CF830B /* DNSManager.swift */, 50E608022A7950CB00BAF09B /* Device.swift */, @@ -825,6 +829,7 @@ 445B5F762EECAF02008932B8 /* EnvVarPackager.swift in Sources */, 443782D02EDF29A800F9FA94 /* Device.swift in Sources */, 443782D12EDF29A800F9FA94 /* Preferences.swift in Sources */, + A1B2C3D52EEDF501001A2B3C /* ConfigurationProvider.swift in Sources */, 443782D42EDF29A800F9FA94 /* RoutesSelectionDetails.swift in Sources */, 443782D52EDF29A800F9FA94 /* StatusDetails.swift in Sources */, 443782D62EDF29A800F9FA94 /* ClientState.swift in Sources */, @@ -929,6 +934,7 @@ 509CCD682BE8FFBF00B7C2D8 /* TabBarButton.swift in Sources */, 502455BD2A79B0480034792B /* CustomBackButton.swift in Sources */, 50216D892ACB18EE009574C9 /* Preferences.swift in Sources */, + A1B2C3D62EEDF502001A2B3C /* ConfigurationProvider.swift in Sources */, 50CD81B02AD5B94D00CF830B /* PeerCard.swift in Sources */, 50003BCE2AFD405600E5EB6B /* ConnectionListener.swift in Sources */, 50003BC72AFBD7F200E5EB6B /* NetBirdAdapter.swift in Sources */, diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 70c8690..d381c6a 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -102,29 +102,9 @@ class ViewModel: ObservableObject { @Published var showForceRelayAlert = false @Published var networkUnavailable = false - /// Preferences are loaded lazily on first access to avoid blocking app startup. - /// - iOS: Returns non-optional NetBirdSDKPreferences (crashes if misconfigured) - /// - tvOS: Returns nil (tvOS uses IPC-based config, not SDK preferences) - #if os(iOS) - private var _preferences: NetBirdSDKPreferences? - private var _preferencesLoaded = false - var preferences: NetBirdSDKPreferences { - get { - if !_preferencesLoaded { - _preferencesLoaded = true - _preferences = Preferences.newPreferences() - } - return _preferences! - } - set { - _preferencesLoaded = true - _preferences = newValue - } - } - #else - // tvOS: SDK preferences not available - config managed via IPC - var preferences: NetBirdSDKPreferences? { nil } - #endif + /// Platform-agnostic configuration provider. + /// Abstracts iOS SDK preferences vs tvOS UserDefaults + IPC. + private lazy var configProvider: ConfigurationProvider = ConfigurationProviderFactory.create() var buttonLock = false let defaults = UserDefaults.standard @@ -289,87 +269,63 @@ class ViewModel: ObservableObject { #endif } - // MARK: - SDK Preferences Methods (iOS only) - // tvOS doesn't use SDK preferences - config is managed via IPC + // MARK: - Configuration Methods (via ConfigurationProvider) - #if os(iOS) func updatePreSharedKey() { - preferences.setPreSharedKey(presharedKey) - do { - try preferences.commit() + configProvider.preSharedKey = presharedKey + if configProvider.commit() { self.close() self.presharedKeySecure = true self.presentSideDrawer = false self.showPreSharedKeyChangedInfo = true - } catch { + } else { print("Failed to update preshared key") } } func removePreSharedKey() { presharedKey = "" - preferences.setPreSharedKey(presharedKey) - do { - try preferences.commit() + configProvider.preSharedKey = "" + if configProvider.commit() { self.close() self.presharedKeySecure = false - } catch { + } else { print("Failed to remove preshared key") } } func loadPreSharedKey() { - self.presharedKey = preferences.getPreSharedKey(nil) - self.presharedKeySecure = self.presharedKey != "" + self.presharedKey = configProvider.preSharedKey + self.presharedKeySecure = configProvider.hasPreSharedKey } func setRosenpassEnabled(enabled: Bool) { - preferences.setRosenpassEnabled(enabled) - do { - try preferences.commit() - } catch { + configProvider.rosenpassEnabled = enabled + if !configProvider.commit() { print("Failed to update rosenpass settings") } } func getRosenpassEnabled() -> Bool { - var result = ObjCBool(false) - do { - try preferences.getRosenpassEnabled(&result) - } catch { - print("Failed to read rosenpass settings") - } - return result.boolValue + return configProvider.rosenpassEnabled } func getRosenpassPermissive() -> Bool { - var result = ObjCBool(false) - do { - try preferences.getRosenpassPermissive(&result) - } catch { - print("Failed to read rosenpass permissive settings") - } - return result.boolValue + return configProvider.rosenpassPermissive } func setRosenpassPermissive(permissive: Bool) { - preferences.setRosenpassPermissive(permissive) - do { - try preferences.commit() - } catch { + configProvider.rosenpassPermissive = permissive + if !configProvider.commit() { print("Failed to update rosenpass permissive settings") } } - #else - // tvOS stubs - these settings are managed differently on tvOS - func updatePreSharedKey() {} - func removePreSharedKey() {} - func loadPreSharedKey() {} - func setRosenpassEnabled(enabled: Bool) {} - func getRosenpassEnabled() -> Bool { false } - func getRosenpassPermissive() -> Bool { false } - func setRosenpassPermissive(permissive: Bool) {} - #endif + + /// Reloads configuration from persistent storage. + /// Call this after server changes or when returning to settings view. + func reloadConfiguration() { + configProvider.reload() + } func setForcedRelayConnection(isEnabled: Bool) { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) @@ -438,10 +394,8 @@ class ViewModel: ObservableObject { self.networkExtensionAdapter.stop() } - // Reload preferences for new server (iOS only - tvOS uses IPC) - #if os(iOS) - preferences = Preferences.newPreferences() - #endif + // Reload configuration for new server + reloadConfiguration() } /// Checks shared app-group container for network unavailable flag set by the network extension. diff --git a/NetbirdKit/ConfigurationProvider.swift b/NetbirdKit/ConfigurationProvider.swift new file mode 100644 index 0000000..254d6cb --- /dev/null +++ b/NetbirdKit/ConfigurationProvider.swift @@ -0,0 +1,273 @@ +// +// ConfigurationProvider.swift +// NetBird +// +// Protocol abstraction for platform-specific configuration management. +// iOS uses SDK file-based preferences, tvOS uses IPC-based config transfer. +// + +import Foundation +import NetBirdSDK + +// MARK: - Protocol Definition + +/// Abstracts platform-specific configuration storage and retrieval. +/// - iOS: Uses NetBirdSDKPreferences with file-based storage in App Group container +/// - tvOS: Uses UserDefaults + IPC transfer (App Group files don't work between app/extension) +protocol ConfigurationProvider { + // MARK: - Rosenpass Settings + + /// Whether Rosenpass (post-quantum encryption) is enabled + var rosenpassEnabled: Bool { get set } + + /// Whether Rosenpass permissive mode is enabled (allows non-Rosenpass peers) + var rosenpassPermissive: Bool { get set } + + // MARK: - Pre-Shared Key + + /// The current pre-shared key (empty string if not set) + var preSharedKey: String { get set } + + /// Whether a pre-shared key is configured + var hasPreSharedKey: Bool { get } + + // MARK: - Lifecycle + + /// Commits any pending changes to persistent storage + /// Returns true on success, false on failure + @discardableResult + func commit() -> Bool + + /// Reloads settings from persistent storage + func reload() +} + +// MARK: - iOS Implementation + +#if os(iOS) +/// iOS implementation using NetBirdSDKPreferences (file-based storage) +final class iOSConfigurationProvider: ConfigurationProvider { + + private var preferences: NetBirdSDKPreferences + + init() { + self.preferences = Preferences.newPreferences() + } + + // MARK: - Rosenpass + + var rosenpassEnabled: Bool { + get { + var result = ObjCBool(false) + do { + try preferences.getRosenpassEnabled(&result) + } catch { + print("ConfigurationProvider: Failed to read rosenpassEnabled - \(error)") + } + return result.boolValue + } + set { + preferences.setRosenpassEnabled(newValue) + } + } + + var rosenpassPermissive: Bool { + get { + var result = ObjCBool(false) + do { + try preferences.getRosenpassPermissive(&result) + } catch { + print("ConfigurationProvider: Failed to read rosenpassPermissive - \(error)") + } + return result.boolValue + } + set { + preferences.setRosenpassPermissive(newValue) + } + } + + // MARK: - Pre-Shared Key + + var preSharedKey: String { + get { + return preferences.getPreSharedKey(nil) + } + set { + preferences.setPreSharedKey(newValue) + } + } + + var hasPreSharedKey: Bool { + return !preSharedKey.isEmpty + } + + // MARK: - Lifecycle + + @discardableResult + func commit() -> Bool { + do { + try preferences.commit() + return true + } catch { + print("ConfigurationProvider: Failed to commit - \(error)") + return false + } + } + + func reload() { + // Recreate preferences to pick up new config file after server change + self.preferences = Preferences.newPreferences() + } +} +#endif + +// MARK: - tvOS Implementation + +#if os(tvOS) +/// tvOS implementation using UserDefaults + config JSON manipulation +/// Settings are stored locally and injected into config JSON before IPC transfer +final class tvOSConfigurationProvider: ConfigurationProvider { + + private let defaults = UserDefaults.standard + + // UserDefaults keys for tvOS-local settings + private enum Keys { + static let rosenpassEnabled = "netbird_rosenpass_enabled" + static let rosenpassPermissive = "netbird_rosenpass_permissive" + static let preSharedKey = "netbird_preshared_key" + } + + init() {} + + // MARK: - Rosenpass + + var rosenpassEnabled: Bool { + get { defaults.bool(forKey: Keys.rosenpassEnabled) } + set { defaults.set(newValue, forKey: Keys.rosenpassEnabled) } + } + + var rosenpassPermissive: Bool { + get { defaults.bool(forKey: Keys.rosenpassPermissive) } + set { defaults.set(newValue, forKey: Keys.rosenpassPermissive) } + } + + // MARK: - Pre-Shared Key + + var preSharedKey: String { + get { defaults.string(forKey: Keys.preSharedKey) ?? "" } + set { defaults.set(newValue, forKey: Keys.preSharedKey) } + } + + var hasPreSharedKey: Bool { + return !preSharedKey.isEmpty + } + + // MARK: - Lifecycle + + @discardableResult + func commit() -> Bool { + defaults.synchronize() + // On tvOS, settings are applied when the config JSON is transferred via IPC + // The actual injection happens in applySettingsToConfig() + return true + } + + func reload() { + // UserDefaults are always fresh, no explicit reload needed + } + + // MARK: - Config JSON Integration + + /// Applies current settings to a config JSON string. + /// Called before transferring config to the extension via IPC. + func applySettingsToConfig(_ configJSON: String) -> String { + var result = configJSON + result = updateJSONField(result, field: "RosenpassEnabled", value: rosenpassEnabled) + result = updateJSONField(result, field: "RosenpassPermissive", value: rosenpassPermissive) + if hasPreSharedKey { + result = updateJSONStringField(result, field: "PreSharedKey", value: preSharedKey) + } + return result + } + + /// Extracts settings from a config JSON string and stores them locally. + /// Called after receiving config from the extension. + func extractSettingsFromConfig(_ configJSON: String) { + if let enabled = extractJSONBool(configJSON, field: "RosenpassEnabled") { + rosenpassEnabled = enabled + } + if let permissive = extractJSONBool(configJSON, field: "RosenpassPermissive") { + rosenpassPermissive = permissive + } + if let key = extractJSONString(configJSON, field: "PreSharedKey"), !key.isEmpty { + preSharedKey = key + } + } + + // MARK: - JSON Helpers + + private func updateJSONField(_ json: String, field: String, value: Bool) -> String { + let pattern = "\"\(field)\"\\s*:\\s*(true|false)" + let replacement = "\"\(field)\":\(value)" + + if let regex = try? NSRegularExpression(pattern: pattern, options: []) { + let range = NSRange(json.startIndex..., in: json) + if regex.firstMatch(in: json, options: [], range: range) != nil { + return regex.stringByReplacingMatches(in: json, options: [], range: range, withTemplate: replacement) + } + } + + // Field doesn't exist - insert before closing brace + // This is a simple approach; a proper JSON parser would be more robust + return json + } + + private func updateJSONStringField(_ json: String, field: String, value: String) -> String { + let escapedValue = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + + let pattern = "\"\(field)\"\\s*:\\s*\"[^\"]*\"" + let replacement = "\"\(field)\":\"\(escapedValue)\"" + + if let regex = try? NSRegularExpression(pattern: pattern, options: []) { + let range = NSRange(json.startIndex..., in: json) + return regex.stringByReplacingMatches(in: json, options: [], range: range, withTemplate: replacement) + } + return json + } + + private func extractJSONBool(_ json: String, field: String) -> Bool? { + let pattern = "\"\(field)\"\\s*:\\s*(true|false)" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []), + let match = regex.firstMatch(in: json, options: [], range: NSRange(json.startIndex..., in: json)), + let valueRange = Range(match.range(at: 1), in: json) else { + return nil + } + return String(json[valueRange]) == "true" + } + + private func extractJSONString(_ json: String, field: String) -> String? { + let pattern = "\"\(field)\"\\s*:\\s*\"([^\"]*)\"" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []), + let match = regex.firstMatch(in: json, options: [], range: NSRange(json.startIndex..., in: json)), + let valueRange = Range(match.range(at: 1), in: json) else { + return nil + } + return String(json[valueRange]) + } +} +#endif + +// MARK: - Factory + +/// Factory for creating the appropriate ConfigurationProvider for the current platform +enum ConfigurationProviderFactory { + static func create() -> ConfigurationProvider { + #if os(iOS) + return iOSConfigurationProvider() + #else + return tvOSConfigurationProvider() + #endif + } +} From c37ee47aff52aa0fbbe4890a204bb7d0e6271ded Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Tue, 23 Dec 2025 20:23:11 +0000 Subject: [PATCH 23/60] implemented rosenpass toggle --- NetBird.xcodeproj/project.pbxproj | 2 + .../Source/App/ViewModels/MainViewModel.swift | 35 ++++- .../Source/App/Views/TV/TVSettingsView.swift | 142 +++++++++++++++++- NetbirdKit/ConfigurationProvider.swift | 117 ++++++--------- NetbirdKit/NetworkExtensionAdapter.swift | 6 +- 5 files changed, 219 insertions(+), 83 deletions(-) diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index b7761b9..fa42544 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 443782D12EDF29A800F9FA94 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50245A292A7BDB590034792B /* Preferences.swift */; }; A1B2C3D52EEDF501001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; A1B2C3D62EEDF502001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; + A1B2C3D72EEDF503001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; 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 */; }; @@ -866,6 +867,7 @@ 44F3E3902EE2151100C87FEC /* StatusDetails.swift in Sources */, 44F3E3912EE2151100C87FEC /* ClientState.swift in Sources */, 44F3E3922EE2151100C87FEC /* Preferences.swift in Sources */, + A1B2C3D72EEDF503001A2B3C /* ConfigurationProvider.swift in Sources */, 44F3E3932EE2151100C87FEC /* NetworkExtensionAdapter.swift in Sources */, 44F3E3942EE2151100C87FEC /* ConnectionListener.swift in Sources */, 44F3E38C2EE214E300C87FEC /* NetBirdAdapter.swift in Sources */, diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index d381c6a..993c61a 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -100,6 +100,7 @@ class ViewModel: ObservableObject { } @Published var forceRelayConnection = true @Published var showForceRelayAlert = false + @Published var showRosenpassChangedAlert = false @Published var networkUnavailable = false /// Platform-agnostic configuration provider. @@ -260,9 +261,15 @@ class ViewModel: ObservableObject { defaults.removeObject(forKey: "ip") defaults.removeObject(forKey: "fqdn") - // Clear config from UserDefaults (used on tvOS) + // Clear config JSON (contains server credentials and all settings) Preferences.removeConfigFromUserDefaults() + // Reset @Published properties to reflect cleared state in UI + self.rosenpassEnabled = false + self.rosenpassPermissive = false + self.presharedKey = "" + self.presharedKeySecure = false + #if os(tvOS) // Also clear extension-local config to prevent stale credentials networkExtensionAdapter.clearExtensionConfig() @@ -300,10 +307,21 @@ class ViewModel: ObservableObject { } func setRosenpassEnabled(enabled: Bool) { + // Update @Published property for immediate UI feedback + self.rosenpassEnabled = enabled + + // Persist to storage (on tvOS this writes directly to config JSON) configProvider.rosenpassEnabled = enabled if !configProvider.commit() { print("Failed to update rosenpass settings") } + + #if os(tvOS) + // Show reconnect alert if currently connected + if extensionState == .connected { + showRosenpassChangedAlert = true + } + #endif } func getRosenpassEnabled() -> Bool { @@ -314,7 +332,20 @@ class ViewModel: ObservableObject { return configProvider.rosenpassPermissive } + /// Loads Rosenpass settings from the configuration provider into the @Published properties. + /// Call this when opening settings views to sync UI with stored values. + /// On iOS, this triggers SDK initialization, so it's deferred until needed. + /// On tvOS, this reads from UserDefaults which is fast. + func loadRosenpassSettings() { + self.rosenpassEnabled = configProvider.rosenpassEnabled + self.rosenpassPermissive = configProvider.rosenpassPermissive + } + func setRosenpassPermissive(permissive: Bool) { + // Update @Published property for immediate UI feedback + self.rosenpassPermissive = permissive + + // Persist to storage (on tvOS this writes directly to config JSON) configProvider.rosenpassPermissive = permissive if !configProvider.commit() { print("Failed to update rosenpass permissive settings") @@ -325,6 +356,8 @@ class ViewModel: ObservableObject { /// Call this after server changes or when returning to settings view. func reloadConfiguration() { configProvider.reload() + // Sync @Published properties with reloaded config values + loadRosenpassSettings() } func setForcedRelayConnection(isEnabled: Bool) { diff --git a/NetBird/Source/App/Views/TV/TVSettingsView.swift b/NetBird/Source/App/Views/TV/TVSettingsView.swift index 4a2b899..37634bf 100644 --- a/NetBird/Source/App/Views/TV/TVSettingsView.swift +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -56,9 +56,28 @@ struct TVSettingsView: View { subtitle: "Post-quantum secure encryption", isOn: Binding( get: { viewModel.rosenpassEnabled }, - set: { viewModel.setRosenpassEnabled(enabled: $0) } + set: { newValue in + // When disabling Rosenpass, also disable permissive mode + if !newValue { + viewModel.setRosenpassPermissive(permissive: false) + } + viewModel.setRosenpassEnabled(enabled: newValue) + } ) ) + + TVSettingsToggleRow( + icon: "shield.checkerboard", + title: "Rosenpass Permissive", + subtitle: "Allow connections with non-Rosenpass peers", + isOn: Binding( + get: { viewModel.rosenpassPermissive }, + set: { newValue in + viewModel.setRosenpassPermissive(permissive: newValue) + } + ), + isDisabled: !viewModel.rosenpassEnabled + ) } TVSettingsSection(title: "Info") { @@ -107,6 +126,15 @@ struct TVSettingsView: View { if viewModel.showChangeServerAlert { TVChangeServerAlert(viewModel: viewModel) } + + // Rosenpass changed alert overlay + if viewModel.showRosenpassChangedAlert { + TVRosenpassChangedAlert(viewModel: viewModel) + } + } + .onAppear { + // Load Rosenpass settings from storage to sync UI with actual values + viewModel.loadRosenpassSettings() } } @@ -189,25 +217,28 @@ struct TVSettingsToggleRow: View { let title: String let subtitle: String @Binding var isOn: Bool + var isDisabled: Bool = false @FocusState private var isFocused: Bool var body: some View { - Button(action: { isOn.toggle() }) { + // Note: We don't use .disabled() because that breaks focus navigation on tvOS. + // Instead, we check isDisabled in the action and show visual disabled state. + Button(action: { if !isDisabled { isOn.toggle() } }) { HStack(spacing: 20) { Image(systemName: icon) .font(.system(size: 28)) - .foregroundColor(.accentColor) + .foregroundColor(isDisabled ? TVColors.textSecondary.opacity(0.5) : .accentColor) .frame(width: 40) VStack(alignment: .leading, spacing: 6) { Text(title) .font(.system(size: 24, weight: .medium)) - .foregroundColor(isFocused ? .white : TVColors.textPrimary) + .foregroundColor(isDisabled ? TVColors.textSecondary.opacity(0.5) : (isFocused ? .white : TVColors.textPrimary)) Text(subtitle) .font(.system(size: 18)) - .foregroundColor(isFocused ? .white.opacity(0.8) : TVColors.textSecondary) + .foregroundColor(isDisabled ? TVColors.textSecondary.opacity(0.4) : (isFocused ? .white.opacity(0.8) : TVColors.textSecondary)) } Spacer() @@ -215,11 +246,11 @@ struct TVSettingsToggleRow: View { // Custom toggle for better TV visibility ZStack { Capsule() - .fill(isOn ? Color.green : Color.gray.opacity(0.3)) + .fill(isDisabled ? Color.gray.opacity(0.2) : (isOn ? Color.green : Color.gray.opacity(0.3))) .frame(width: 70, height: 40) Circle() - .fill(Color.white) + .fill(isDisabled ? Color.gray.opacity(0.5) : Color.white) .frame(width: 32, height: 32) .offset(x: isOn ? 15 : -15) .animation(.easeInOut(duration: 0.2), value: isOn) @@ -228,7 +259,7 @@ struct TVSettingsToggleRow: View { .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 10) - .fill(isFocused ? Color.accentColor.opacity(0.2) : Color.clear) + .fill(isFocused && !isDisabled ? Color.accentColor.opacity(0.2) : Color.clear) ) } .buttonStyle(.plain) @@ -358,6 +389,101 @@ struct TVChangeServerAlert: View { } } +struct TVRosenpassChangedAlert: View { + @ObservedObject var viewModel: ViewModel + + private enum FocusedButton { + case later, reconnect + } + + @FocusState private var focusedButton: FocusedButton? + @State private var lastFocusedButton: FocusedButton = .later + + var body: some View { + ZStack { + // Dimmed background + Color.black.opacity(0.7) + .ignoresSafeArea() + + // Alert box + VStack(spacing: 40) { + Image(systemName: "shield.lefthalf.filled") + .font(.system(size: 60)) + .foregroundColor(.blue) + + Text("Reconnect Required") + .font(.system(size: 40, weight: .bold)) + .foregroundColor(TVColors.textAlert) + + Text("Rosenpass settings have changed. Reconnect to apply the new security settings.") + .font(.system(size: 24)) + .foregroundColor(TVColors.textAlert) + .multilineTextAlignment(.center) + .frame(maxWidth: 500) + + HStack(spacing: 40) { + // Later button + Button(action: { + viewModel.showRosenpassChangedAlert = false + }) { + Text("Later") + .font(.system(size: 24)) + .foregroundColor(.white) + .padding(.horizontal, 50) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.5), lineWidth: 2) + ) + } + .buttonStyle(.plain) + .focused($focusedButton, equals: .later) + + // Reconnect button + Button(action: { + viewModel.showRosenpassChangedAlert = false + viewModel.close() + // Small delay before reconnecting to allow disconnect to complete + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + viewModel.connect() + } + }) { + Text("Reconnect") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 50) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.blue) + ) + } + .buttonStyle(.plain) + .focused($focusedButton, equals: .reconnect) + } + .focusSection() + } + .padding(60) + .background( + RoundedRectangle(cornerRadius: 30) + .fill(TVColors.bgSideDrawer) + ) + } + .onAppear { + focusedButton = .reconnect + } + .onChange(of: focusedButton) { oldValue, newValue in + _ = oldValue // Suppress unused warning + if let newValue = newValue { + lastFocusedButton = newValue + } else { + // Focus escaped - pull it back + focusedButton = lastFocusedButton + } + } + } +} + struct TVSettingsView_Previews: PreviewProvider { static var previews: some View { TVSettingsView() diff --git a/NetbirdKit/ConfigurationProvider.swift b/NetbirdKit/ConfigurationProvider.swift index 254d6cb..f8744a2 100644 --- a/NetbirdKit/ConfigurationProvider.swift +++ b/NetbirdKit/ConfigurationProvider.swift @@ -124,38 +124,30 @@ final class iOSConfigurationProvider: ConfigurationProvider { // MARK: - tvOS Implementation #if os(tvOS) -/// tvOS implementation using UserDefaults + config JSON manipulation -/// Settings are stored locally and injected into config JSON before IPC transfer +/// tvOS implementation that reads/writes settings directly to the config JSON. +/// This mirrors iOS behavior where all settings live in one config file. +/// The config JSON is stored in UserDefaults and sent to the extension via IPC. final class tvOSConfigurationProvider: ConfigurationProvider { - private let defaults = UserDefaults.standard - - // UserDefaults keys for tvOS-local settings - private enum Keys { - static let rosenpassEnabled = "netbird_rosenpass_enabled" - static let rosenpassPermissive = "netbird_rosenpass_permissive" - static let preSharedKey = "netbird_preshared_key" - } - init() {} // MARK: - Rosenpass var rosenpassEnabled: Bool { - get { defaults.bool(forKey: Keys.rosenpassEnabled) } - set { defaults.set(newValue, forKey: Keys.rosenpassEnabled) } + get { extractJSONBool(field: "RosenpassEnabled") ?? false } + set { updateJSONField(field: "RosenpassEnabled", value: newValue) } } var rosenpassPermissive: Bool { - get { defaults.bool(forKey: Keys.rosenpassPermissive) } - set { defaults.set(newValue, forKey: Keys.rosenpassPermissive) } + get { extractJSONBool(field: "RosenpassPermissive") ?? false } + set { updateJSONField(field: "RosenpassPermissive", value: newValue) } } // MARK: - Pre-Shared Key var preSharedKey: String { - get { defaults.string(forKey: Keys.preSharedKey) ?? "" } - set { defaults.set(newValue, forKey: Keys.preSharedKey) } + get { extractJSONString(field: "PreSharedKey") ?? "" } + set { updateJSONStringField(field: "PreSharedKey", value: newValue) } } var hasPreSharedKey: Bool { @@ -166,63 +158,64 @@ final class tvOSConfigurationProvider: ConfigurationProvider { @discardableResult func commit() -> Bool { - defaults.synchronize() - // On tvOS, settings are applied when the config JSON is transferred via IPC - // The actual injection happens in applySettingsToConfig() + // Settings are written directly to config JSON, no separate commit needed return true } func reload() { - // UserDefaults are always fresh, no explicit reload needed + // Config JSON is always read fresh from UserDefaults } - // MARK: - Config JSON Integration + // MARK: - JSON Helpers (read/write to stored config) - /// Applies current settings to a config JSON string. - /// Called before transferring config to the extension via IPC. - func applySettingsToConfig(_ configJSON: String) -> String { - var result = configJSON - result = updateJSONField(result, field: "RosenpassEnabled", value: rosenpassEnabled) - result = updateJSONField(result, field: "RosenpassPermissive", value: rosenpassPermissive) - if hasPreSharedKey { - result = updateJSONStringField(result, field: "PreSharedKey", value: preSharedKey) - } - return result + private func getConfigJSON() -> String? { + return Preferences.loadConfigFromUserDefaults() } - /// Extracts settings from a config JSON string and stores them locally. - /// Called after receiving config from the extension. - func extractSettingsFromConfig(_ configJSON: String) { - if let enabled = extractJSONBool(configJSON, field: "RosenpassEnabled") { - rosenpassEnabled = enabled - } - if let permissive = extractJSONBool(configJSON, field: "RosenpassPermissive") { - rosenpassPermissive = permissive + private func saveConfigJSON(_ json: String) { + _ = Preferences.saveConfigToUserDefaults(json) + } + + private func extractJSONBool(field: String) -> Bool? { + guard let json = getConfigJSON() else { return nil } + let pattern = "\"\(field)\"\\s*:\\s*(true|false)" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []), + let match = regex.firstMatch(in: json, options: [], range: NSRange(json.startIndex..., in: json)), + let valueRange = Range(match.range(at: 1), in: json) else { + return nil } - if let key = extractJSONString(configJSON, field: "PreSharedKey"), !key.isEmpty { - preSharedKey = key + return String(json[valueRange]) == "true" + } + + private func extractJSONString(field: String) -> String? { + guard let json = getConfigJSON() else { return nil } + let pattern = "\"\(field)\"\\s*:\\s*\"([^\"]*)\"" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []), + let match = regex.firstMatch(in: json, options: [], range: NSRange(json.startIndex..., in: json)), + let valueRange = Range(match.range(at: 1), in: json) else { + return nil } + return String(json[valueRange]) } - // MARK: - JSON Helpers + private func updateJSONField(field: String, value: Bool) { + guard var json = getConfigJSON() else { return } - private func updateJSONField(_ json: String, field: String, value: Bool) -> String { let pattern = "\"\(field)\"\\s*:\\s*(true|false)" let replacement = "\"\(field)\":\(value)" if let regex = try? NSRegularExpression(pattern: pattern, options: []) { let range = NSRange(json.startIndex..., in: json) if regex.firstMatch(in: json, options: [], range: range) != nil { - return regex.stringByReplacingMatches(in: json, options: [], range: range, withTemplate: replacement) + json = regex.stringByReplacingMatches(in: json, options: [], range: range, withTemplate: replacement) + saveConfigJSON(json) } } - - // Field doesn't exist - insert before closing brace - // This is a simple approach; a proper JSON parser would be more robust - return json } - private func updateJSONStringField(_ json: String, field: String, value: String) -> String { + private func updateJSONStringField(field: String, value: String) { + guard var json = getConfigJSON() else { return } + let escapedValue = value .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") @@ -232,29 +225,9 @@ final class tvOSConfigurationProvider: ConfigurationProvider { if let regex = try? NSRegularExpression(pattern: pattern, options: []) { let range = NSRange(json.startIndex..., in: json) - return regex.stringByReplacingMatches(in: json, options: [], range: range, withTemplate: replacement) - } - return json - } - - private func extractJSONBool(_ json: String, field: String) -> Bool? { - let pattern = "\"\(field)\"\\s*:\\s*(true|false)" - guard let regex = try? NSRegularExpression(pattern: pattern, options: []), - let match = regex.firstMatch(in: json, options: [], range: NSRange(json.startIndex..., in: json)), - let valueRange = Range(match.range(at: 1), in: json) else { - return nil + json = regex.stringByReplacingMatches(in: json, options: [], range: range, withTemplate: replacement) + saveConfigJSON(json) } - return String(json[valueRange]) == "true" - } - - private func extractJSONString(_ json: String, field: String) -> String? { - let pattern = "\"\(field)\"\\s*:\\s*\"([^\"]*)\"" - guard let regex = try? NSRegularExpression(pattern: pattern, options: []), - let match = regex.firstMatch(in: json, options: [], range: NSRange(json.startIndex..., in: json)), - let valueRange = Range(match.range(at: 1), in: json) else { - return nil - } - return String(json[valueRange]) } } #endif diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index 57d8249..0fef303 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -150,14 +150,16 @@ public class NetworkExtensionAdapter: ObservableObject { #if os(tvOS) /// Try to initialize the config file from the main app. - /// On tvOS, shared UserDefaults doesn't work, so we also send config via IPC. + /// On tvOS, shared UserDefaults doesn't work, so we send config via IPC. + /// Settings (Rosenpass, PreSharedKey) are already stored in the config JSON. private func initializeConfigFromApp() async { // Check if config exists in main app's UserDefaults // Note: Shared UserDefaults doesn't work on tvOS between app and extension, // but we can still use it to store config in the main app if let configJSON = Preferences.loadConfigFromUserDefaults(), !configJSON.isEmpty { logger.info("initializeConfigFromApp: Config exists in UserDefaults, sending to extension via IPC") - // Send config to extension via IPC since shared UserDefaults doesn't work + + // Send config to extension via IPC (settings are already in the JSON) await sendConfigToExtensionAsync(configJSON) return } From 2462905a8342ca6a92d9401f2f89b8d0fe94eb3f Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Tue, 23 Dec 2025 20:43:57 +0000 Subject: [PATCH 24/60] implemented preshared key setting --- .../Source/App/Views/TV/TVSettingsView.swift | 297 ++++++++++++++---- 1 file changed, 232 insertions(+), 65 deletions(-) diff --git a/NetBird/Source/App/Views/TV/TVSettingsView.swift b/NetBird/Source/App/Views/TV/TVSettingsView.swift index 37634bf..95e0cca 100644 --- a/NetBird/Source/App/Views/TV/TVSettingsView.swift +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -16,7 +16,8 @@ import UIKit /// Settings screen for tvOS, replacing the iOS side drawer. struct TVSettingsView: View { @EnvironmentObject var viewModel: ViewModel - + @State private var showPreSharedKeyAlert = false + var body: some View { ZStack { TVColors.bgMenu @@ -80,6 +81,15 @@ struct TVSettingsView: View { ) } + TVSettingsSection(title: "Security") { + TVSettingsRow( + icon: "key.fill", + title: "Pre-Shared Key", + subtitle: viewModel.presharedKeySecure ? "Configured" : "Not configured", + action: { showPreSharedKeyAlert = true } + ) + } + TVSettingsSection(title: "Info") { TVSettingsInfoRow( icon: "book.fill", @@ -131,10 +141,19 @@ struct TVSettingsView: View { if viewModel.showRosenpassChangedAlert { TVRosenpassChangedAlert(viewModel: viewModel) } + + // Pre-shared key alert overlay + if showPreSharedKeyAlert { + TVPreSharedKeyAlert( + viewModel: viewModel, + isPresented: $showPreSharedKeyAlert + ) + } } .onAppear { - // Load Rosenpass settings from storage to sync UI with actual values + // Load settings from storage to sync UI with actual values viewModel.loadRosenpassSettings() + viewModel.loadPreSharedKey() } } @@ -296,6 +315,50 @@ struct TVSettingsInfoRow: View { } } +/// Reusable button for TV alert dialogs with proper focus styling. +/// Text turns dark when focused to remain readable against the light highlight. +struct TVAlertButton: View { + enum Style { + case outlined + case filled(Color) + } + + let title: String + let style: Style + let isFocused: Bool + let action: () -> Void + var isSemibold: Bool = false + + var body: some View { + Button(action: action) { + Text(title) + .font(.system(size: 24, weight: isSemibold || isFilled ? .semibold : .regular)) + .foregroundColor(isFocused ? .black : .white) + .padding(.horizontal, isFilled ? 50 : 40) + .padding(.vertical, 16) + .background(backgroundView) + } + .buttonStyle(.plain) + } + + private var isFilled: Bool { + if case .filled = style { return true } + return false + } + + @ViewBuilder + private var backgroundView: some View { + switch style { + case .outlined: + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.5), lineWidth: 2) + case .filled(let color): + RoundedRectangle(cornerRadius: 12) + .fill(color) + } + } +} + struct TVChangeServerAlert: View { @ObservedObject var viewModel: ViewModel @@ -330,40 +393,26 @@ struct TVChangeServerAlert: View { 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) + TVAlertButton( + title: "Cancel", + style: .outlined, + isFocused: focusedButton == .cancel, + action: { viewModel.showChangeServerAlert = false } + ) .focused($focusedButton, equals: .cancel) // Confirm button - Button(action: { - viewModel.close() - viewModel.clearDetails() - viewModel.showChangeServerAlert = false - viewModel.navigateToServerView = true - }) { - Text("Confirm") - .font(.system(size: 24, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 50) - .padding(.vertical, 16) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color.red) - ) - } - .buttonStyle(.plain) + TVAlertButton( + title: "Confirm", + style: .filled(Color.red), + isFocused: focusedButton == .confirm, + action: { + viewModel.close() + viewModel.clearDetails() + viewModel.showChangeServerAlert = false + viewModel.navigateToServerView = true + } + ) .focused($focusedButton, equals: .confirm) } .focusSection() @@ -423,42 +472,28 @@ struct TVRosenpassChangedAlert: View { HStack(spacing: 40) { // Later button - Button(action: { - viewModel.showRosenpassChangedAlert = false - }) { - Text("Later") - .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) + TVAlertButton( + title: "Later", + style: .outlined, + isFocused: focusedButton == .later, + action: { viewModel.showRosenpassChangedAlert = false } + ) .focused($focusedButton, equals: .later) // Reconnect button - Button(action: { - viewModel.showRosenpassChangedAlert = false - viewModel.close() - // Small delay before reconnecting to allow disconnect to complete - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - viewModel.connect() + TVAlertButton( + title: "Reconnect", + style: .filled(Color.blue), + isFocused: focusedButton == .reconnect, + action: { + viewModel.showRosenpassChangedAlert = false + viewModel.close() + // Small delay before reconnecting to allow disconnect to complete + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + viewModel.connect() + } } - }) { - Text("Reconnect") - .font(.system(size: 24, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 50) - .padding(.vertical, 16) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color.blue) - ) - } - .buttonStyle(.plain) + ) .focused($focusedButton, equals: .reconnect) } .focusSection() @@ -484,6 +519,138 @@ struct TVRosenpassChangedAlert: View { } } +struct TVPreSharedKeyAlert: View { + @ObservedObject var viewModel: ViewModel + @Binding var isPresented: Bool + @State private var keyText: String = "" + @State private var isInvalid: Bool = false + @State private var lastFocusedField: FocusedField = .textField + + private enum FocusedField { + case textField, remove, save, cancel + } + + @FocusState private var focusedField: FocusedField? + + var body: some View { + ZStack { + // Dimmed background + Color.black.opacity(0.7) + .ignoresSafeArea() + + // Alert box + VStack(spacing: 30) { + Image(systemName: "key.fill") + .font(.system(size: 60)) + .foregroundColor(.orange) + + Text("Pre-Shared Key") + .font(.system(size: 40, weight: .bold)) + .foregroundColor(TVColors.textAlert) + + Text("Enter a 32-byte base64-encoded key. You will only communicate with peers that use the same key.") + .font(.system(size: 22)) + .foregroundColor(TVColors.textAlert) + .multilineTextAlignment(.center) + .frame(maxWidth: 600) + + // Text field for key input + TextField("Pre-shared key", text: $keyText) + .textFieldStyle(.plain) + .font(.system(size: 24)) + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isInvalid ? Color.red : Color.white.opacity(0.3), lineWidth: 2) + ) + .frame(maxWidth: 600) + .focused($focusedField, equals: .textField) + .onChange(of: keyText) { _, newValue in + isInvalid = !isValidBase64Key(newValue) + } + + if isInvalid && !keyText.isEmpty { + Text("Invalid key - must be 32-byte base64 encoded") + .font(.system(size: 18)) + .foregroundColor(.red) + } + + HStack(spacing: 30) { + // Cancel button + TVAlertButton( + title: "Cancel", + style: .outlined, + isFocused: focusedField == .cancel, + action: { isPresented = false } + ) + .focused($focusedField, equals: .cancel) + + // Remove button (only if key is configured) + if viewModel.presharedKeySecure { + TVAlertButton( + title: "Remove", + style: .filled(Color.red), + isFocused: focusedField == .remove, + action: { + viewModel.removePreSharedKey() + isPresented = false + } + ) + .focused($focusedField, equals: .remove) + } + + // Save button + TVAlertButton( + title: "Save", + style: .filled((isInvalid || keyText.isEmpty) ? Color.gray : Color.green), + isFocused: focusedField == .save, + action: { + if !isInvalid && !keyText.isEmpty { + viewModel.presharedKey = keyText + viewModel.updatePreSharedKey() + isPresented = false + } + } + ) + .focused($focusedField, equals: .save) + } + .focusSection() + } + .padding(60) + .background( + RoundedRectangle(cornerRadius: 30) + .fill(TVColors.bgSideDrawer) + ) + } + .onAppear { + // Pre-fill with current key if editing + if viewModel.presharedKeySecure { + keyText = viewModel.presharedKey + } + focusedField = .textField + } + .onChange(of: focusedField) { oldValue, newValue in + _ = oldValue // Suppress unused warning + if let newValue = newValue { + lastFocusedField = newValue + } else { + // Focus escaped - pull it back + focusedField = lastFocusedField + } + } + } + + private func isValidBase64Key(_ input: String) -> Bool { + if input.isEmpty { return true } + guard let data = Data(base64Encoded: input) else { return false } + return data.count == 32 + } +} + struct TVSettingsView_Previews: PreviewProvider { static var previews: some View { TVSettingsView() From 418af69df6ed8bed74c0231e64e4a40b4646644e Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Tue, 23 Dec 2025 20:55:52 +0000 Subject: [PATCH 25/60] Update GitHub Actions to use macOS 15 with Xcode 16 Project uses Xcode 16 project format (objectVersion 70) which isn't compatible with Xcode 15.x on macos-14 runners. --- .github/workflows/build.yml | 5 ++++- .github/workflows/test.yml | 5 ++++- NetBird.xcodeproj/project.pbxproj | 20 ++++++++++---------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3f20292..e061f4e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,9 +13,12 @@ permissions: jobs: build: name: Build iOS App - runs-on: macos-14 + runs-on: macos-15 steps: + - name: Select Xcode 16 + run: sudo xcode-select -s /Applications/Xcode_16.1.app/Contents/Developer + - name: Checkout ios-client uses: actions/checkout@v4 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f5dbc0a..b8f197b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,9 +13,12 @@ permissions: jobs: test: name: Build and Test - runs-on: macos-14 + runs-on: macos-15 steps: + - name: Select Xcode 16 + run: sudo xcode-select -s /Applications/Xcode_16.1.app/Contents/Developer + - name: Checkout ios-client uses: actions/checkout@v4 with: diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index fa42544..253b88f 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -8,15 +8,10 @@ /* Begin PBXBuildFile section */ 36F90EF57603411B9916FDD6 /* ServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE12ED4EE4900C0D205 /* ServerViewModel.swift */; }; - 978FC4702EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; - 978FC4712EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; - 978FC4722EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; - 978FC4732EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; 3A9A981B20EF47C1907CC877 /* TVServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8BCB110545747678AA5A9C2 /* TVServerView.swift */; }; 441C5AFE2EDF0DD20055EEFC /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50245A532A80431B0034792B /* NetworkExtension.framework */; }; 441C5B062EDF0DD20055EEFC /* NetBirdTVNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 441C5AFD2EDF0DD20055EEFC /* NetBirdTVNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 443782BF2EDF284A00F9FA94 /* Platform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443782BD2EDF284A00F9FA94 /* Platform.swift */; }; - 978FC4742EEDF168002D0EB8 /* 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 */; }; @@ -28,9 +23,6 @@ 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 */; }; - A1B2C3D52EEDF501001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; - A1B2C3D62EEDF502001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; - A1B2C3D72EEDF503001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; 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 */; }; @@ -145,6 +137,14 @@ 50E608202A7979D600BAF09B /* SideDrawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E6081F2A7979D600BAF09B /* SideDrawer.swift */; }; 50E608242A79966600BAF09B /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608232A79966600BAF09B /* AboutView.swift */; }; 50E608262A79968500BAF09B /* AdvancedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608252A79968500BAF09B /* AdvancedView.swift */; }; + 978FC4702EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; + 978FC4712EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; + 978FC4722EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; + 978FC4732EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; + 978FC4742EEDF168002D0EB8 /* Platform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443782BD2EDF284A00F9FA94 /* Platform.swift */; }; + A1B2C3D52EEDF501001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; + A1B2C3D62EEDF502001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; + A1B2C3D72EEDF503001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; F1258DE22ED4EE5000C0D205 /* ServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE12ED4EE4900C0D205 /* ServerViewModel.swift */; }; F1258DEA2ED7B7D600C0D205 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1258DE92ED7B7D200C0D205 /* Extensions.swift */; }; F1B292052EDE5610001D91B8 /* JustifiedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B292042EDE5608001D91B8 /* JustifiedText.swift */; }; @@ -219,7 +219,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogger.swift; sourceTree = ""; }; 441C5AEE2EDF0DAE0055EEFC /* NetBird TV.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "NetBird TV.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 441C5AFD2EDF0DD20055EEFC /* NetBirdTVNetworkExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NetBirdTVNetworkExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 443782BD2EDF284A00F9FA94 /* Platform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Platform.swift; sourceTree = ""; }; @@ -247,7 +246,6 @@ 50245A192A7BCE830034792B /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; }; 50245A1B2A7BCF120034792B /* NetBird.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NetBird.entitlements; sourceTree = ""; }; 50245A292A7BDB590034792B /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; - A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationProvider.swift; sourceTree = ""; }; 50245A2D2A7BDC470034792B /* NetworkChangeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkChangeListener.swift; sourceTree = ""; }; 50245A3B2A7FD1CD0034792B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 50245A522A80431B0034792B /* NetbirdNetworkExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NetbirdNetworkExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -290,6 +288,8 @@ 50E6081F2A7979D600BAF09B /* SideDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideDrawer.swift; sourceTree = ""; }; 50E608232A79966600BAF09B /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 50E608252A79968500BAF09B /* AdvancedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedView.swift; sourceTree = ""; }; + 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogger.swift; sourceTree = ""; }; + A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationProvider.swift; sourceTree = ""; }; F1258DE12ED4EE4900C0D205 /* ServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerViewModel.swift; sourceTree = ""; }; F1258DE92ED7B7D200C0D205 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; F1B292042EDE5608001D91B8 /* JustifiedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JustifiedText.swift; sourceTree = ""; }; From def906ac3e2e1fca944456cfa0e0748419f4a89b Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Thu, 8 Jan 2026 18:12:55 +0100 Subject: [PATCH 26/60] Add tvOS app icons, Top Shelf images, and fix archive validation errors - Add complete tvOS icon assets (App Icon, App Store Icon, Top Shelf) - Add arm64 to UIRequiredDeviceCapabilities for TV Network Extension - Add TVTopShelfImage configuration to tvOS Info.plist - Fix iOS build cycle by reordering Embed Frameworks before Crashlytics script --- .gitignore | 2 ++ .../Content.imageset/Contents.json | 1 + .../Content.imageset/app-store-icon.png | Bin 0 -> 5953 bytes .../Content.imageset/Contents.json | 1 + .../Content.imageset/app-store-icon.png | Bin 0 -> 59365 bytes .../Content.imageset/Contents.json | 1 + .../Content.imageset/netbird-tvos-icon.png | Bin 15418 -> 2116 bytes .../Content.imageset/netbird-tvos-icon@2x.png | Bin 0 -> 3511 bytes .../Content.imageset/Contents.json | 1 + .../Content.imageset/netbird-tvos-icon.png | Bin 15418 -> 16915 bytes .../Content.imageset/netbird-tvos-icon@2x.png | Bin 0 -> 23497 bytes .../Content.imageset/Contents.json | 1 + .../Content.imageset/netbird-tvos-icon.png | Bin 15418 -> 2116 bytes .../Content.imageset/netbird-tvos-icon@2x.png | Bin 0 -> 3511 bytes .../Contents.json | 2 ++ .../top-shelf-wide.png | Bin 0 -> 86840 bytes .../top-shelf-wide@2x.png | Bin 0 -> 251702 bytes .../Top Shelf Image.imageset/Contents.json | 2 ++ .../Top Shelf Image.imageset/top-shelf.png | Bin 0 -> 78044 bytes .../Top Shelf Image.imageset/top-shelf@2x.png | Bin 0 -> 229411 bytes NetBird.xcodeproj/project.pbxproj | 10 +++++----- NetBirdTV/Info.plist | 17 +++++++++++++---- NetBirdTVNetworkExtension/Info.plist | 4 ++++ NetbirdKit/ConfigurationProvider.swift | 7 +++++-- 24 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/app-store-icon.png create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/app-store-icon.png create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/netbird-tvos-icon@2x.png create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/netbird-tvos-icon@2x.png create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/netbird-tvos-icon@2x.png create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide.png create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide@2x.png create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top-shelf.png create mode 100644 NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top-shelf@2x.png diff --git a/.gitignore b/.gitignore index 1d52342..d8e889f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ NetBirdSDK.xcframework/ Netbird.xcframework/ GoogleService-Info*.plist +NetBirdTV/Info.plist +NetBirdTVNetworkExtension/Info.plist .DS_Store xcuserdata/ 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 index 2e00335..2fedeeb 100644 --- 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 @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "app-store-icon.png", "idiom" : "tv" } ], diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/app-store-icon.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/app-store-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..32da6f2eecf736e43d59fc335b2cb9e624ec19ba GIT binary patch literal 5953 zcmeHK&u`pB6rRvDqN_I09-smaQ-Lt3^7=>C$=cXk+-#ZzNED@8NDq~E)-%ak@!H0o zWu3hcDsezahzkNC{shE7KqZ32nIlyXl~8+Zt6q?hI8fermrYXjfM`)0C1pn!Z}8rrC#F4+>b&mFi9)waz~O% zcnIxsp7^a1dYG0;-qRIR$`G_dXsT#sRcs4md*E-mG!^Dxk@M{R+!$DapRW{?BxQ}Q zm^soaOQUY$u^d568iLFwjBrM4x~fmhswS&dTe)vYtg$;`F5`=|R$*+~&>>*6YEP|C}o7jcAIyNsyNS^%NGc4hy*;NgI+xJ&XH1 zV7nB#0pCv%)SeZPaKn)@nX+}#4Y*Xh5a!b;rY!Y0LM&tL0f7y%Y-P_f4-r?B;IoZ zz|G1lH%b~&6x3!(_yNJMt+J4^ge9+I8hK9Loj0{AsjirFxSO)Xx!}$lD~yH$ zI4tttbOljs&o*K3xuW~A<>VCPzhLP}uf>9;gP<4%O9e}hP00T@@6(TqH_`$4Ix4iT v(7HnF9?f4&ah49wS-MmF&py$A?WZn%Iq|!;^X<>@6@eD#mh0c0ZC?HhHwW@I literal 0 HcmV?d00001 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 index 2e00335..2fedeeb 100644 --- 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 @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "app-store-icon.png", "idiom" : "tv" } ], diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/app-store-icon.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/app-store-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a06c5f8a64b241e2f3458a29e1b58e8a3e402329 GIT binary patch literal 59365 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%0AguY8@WQLN1iD_*g83qlPU!NX`<~#e`uo> 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 O0Mp}h^*5uB9{&Owyng5a literal 15418 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 AyZ`_I literal 0 HcmV?d00001 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 index bf734b8..7799777 100644 --- 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 @@ -6,6 +6,7 @@ "scale" : "1x" }, { + "filename" : "netbird-tvos-icon@2x.png", "idiom" : "tv", "scale" : "2x" } diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/netbird-tvos-icon.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/netbird-tvos-icon.png index 6f8b6935d0b835f785a935bfd706fe0e34be3569..d6274fd8195cfb2f596ee35bddcad9c2ec41870e 100644 GIT binary patch literal 16915 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 PZFnR9R<2&=L+JkiO5o}4 literal 15418 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 literal 0 HcmV?d00001 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 index bf734b8..7799777 100644 --- 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 @@ -6,6 +6,7 @@ "scale" : "1x" }, { + "filename" : "netbird-tvos-icon@2x.png", "idiom" : "tv", "scale" : "2x" } diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/netbird-tvos-icon.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/netbird-tvos-icon.png index 6f8b6935d0b835f785a935bfd706fe0e34be3569..fe51d1f3245595c3cfeae91a507ab649ce80f76e 100644 GIT binary patch literal 2116 zcmeHJJ#W)M7`^~S5UCv)5Fd*r27|z{osTBA;uN>(7p0|Y9kpE$XWyl<>e%l2+_;%4 zhDuCSNQk8i608WZB0&fK021O4FhltXoSoo)r~`tL4z};Td*0{nec$I^`N{m;hwO9FpIQk382S`Hndi5Mt^;uHgKaF+<=OkH<+XDV zc_z+9ZJ(h5sE$NFwAL`?OuTBl8o&SkBhT4LqbUL|CA?J}*QC1X1 zl=5OeFEE4p99Qk(RUHMf+1q3huO~*&a_nR$ACdqYn?Ba~1sr%Gv2el=JM~G% z2!<4+T3*Tz3z95Im8y7bDc)5@dBt}0g zsYfqmJ>a3T{CTO6m6pj5iIYeGSt53D8@n_vlH4y6vQ%nQ2OpG3cj(a)G3}`ibXPS# z5)f{}(4lCy9g6ZZgI%7w7Nc7eqb17VWz=_0VY~QpDfKA-IY?I$e9GXNE?l3pnPwpt8)~$ zeTUr!ttuX;KQW|Ul&nRtW+A36g&Ap6VyDq~!dlKygX{uu_Re%&H=^C|U*ti+fjwz& z@ciee1GRUr8gJKv9|e&8x(~LNwg=Fa%R6xS?l#O^JvB~q@IUHcJ#*>BnXP97-`F1j O0Mp}h^*5uB9{&Owyng5a literal 15418 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 AyZ`_I literal 0 HcmV?d00001 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 index 795cce1..4952949 100644 --- 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 @@ -1,10 +1,12 @@ { "images" : [ { + "filename" : "top-shelf-wide.png", "idiom" : "tv", "scale" : "1x" }, { + "filename" : "top-shelf-wide@2x.png", "idiom" : "tv", "scale" : "2x" } diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide.png new file mode 100644 index 0000000000000000000000000000000000000000..6165fc34ab76e5b730c2723d734ee4d59c1fe201 GIT binary patch literal 86840 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 literal 0 HcmV?d00001 diff --git a/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide@2x.png b/NetBird TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..81d5ab9f2dff41d274105dd0595e52cf8d7d993c GIT binary patch literal 251702 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%0Ai>H@Ss(&b& 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