mirror of
https://github.com/netbirdio/ios-client.git
synced 2026-09-23 09:28:33 -07:00
* 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
* cleanup
* fixed slow startup + added images
* fixed active networks not refreshing
* fix occasional failure of first connection
* hostname properly set + removed debug logging
* - 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
* Fixed focus escape bug on 'change server' dialog
* fixed switching of management servers
* fixes for change server settings menu
* - 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
* apply recommended xcode settings
* display correct app name in tvOS UI
* added tv-specific assets, updated readme
* updated readme
* - 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
* 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
* - 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)
* 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
* added empty box asset to tvOS app
* - 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)
* 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)
* implemented rosenpass toggle
* implemented preshared key setting
* 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.
* 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
* Fix tvOS SDK path and add config update logging
- Use SDKROOT for libresolv.tbd instead of hardcoded SDK version
- Add AppLogger warnings when config JSON updates fail
* re-added test targets to project.pbxproj (lost in merge)
* Replace regex-based JSON manipulation with JSONSerialization for
reading and writing config fields
* Address PR review feedback for tvOS support
- Add detailed comment explaining tvOS UserDefaults sync limitation
- Use AppLogger instead of print in Preferences.swift
- Remove unnecessary #if os() checks in tvOS PacketTunnelProvider
- Remove duplicate tvOS entitlement/plist files from NetBirdTV/
- Fix CI build destinations to use simulator
* Removed dead variables from TVPeersListContent, Updated tvOS config JSON parsing method
* update build platform (iphone 16 -> iphone 15) in an attempt to resolve github actions error
* update build platform in an attempt to resolve github actions error
* update xcode build runner an attempt to resolve github actions error
* update build.yml to specify downloading of ios 18.2 image to fix build error
* update build.yml to remove specifying of ios 18.2 image download
* downgrade macos build image to 14 to resolve ios build error on github
* downgrade macos build image to 14 to resolve ios build error on github
* upgrade build runner xcode version to 26.2 to resolve ios build error on github
* upgrade build runner xcode version to 26.2 to resolve ios build error on github
* upgrade build runner xcode version to 26.2 to resolve ios build error on github
* upgrade build runner xcode version to 26.1.1 to resolve ios build error on github
* upgrade build script sdk output path to resolve ios build error on github
* added create dummy GoogleService-Info.plist build step to github workflows
* Fix TVAuthView error UI not displaying by setting errorMessage state
* - replace pipe-delimited string parsing with typed LoginDiagnostics
struct
- removed unused initializeConfigViaExtension method and converted
some print statements to logger.debug for consistency
* more pipe -> struct refactoring
* update readme and buildscript to use gomobile-netbird for building sdk, sdk version bump
* fix initial connection bug that required user to press 'connect' again after completing sso flow
* improve app loading speed + add splash screen logo
* Fix race condition in tvOS VPN connection and revert Firebase to main thread
- Move startVPNConnection() inside sendConfigToExtension completion handler to ensure config transfer completes before VPN starts
- Revert Firebase initialization to main thread as required by Firebase documentation
- Keep ViewModelLoader with loading screen to improve perceived startup time
- Keep lazy NetworkExtensionAdapter.configureManager() call removed from init
* fix test github workflow
* fix TVColors.swift UUID in project.pbxproj
* fix gomobile-netbird run in github workflow by adding 'gomobile-netbird init' step
* Add gomobile-netbird init step to GitHub Actions workflow with proper PATH configuration
* revert build script changes
* bump network extension ver to 7 - apple states this should match the main app's ver #
* remove unused wireguardkitgo paths
* Fix version mismatch between iOS app and network extension
* Add diagnostic logging to test workflow to capture crash details
Capture full xcodebuild output and system crash logs when tests fail.
This will help debug the test crash that occurs during app bootstrapping
in CI but not locally.
- Tee test output to file before xcpretty filtering
- Search for and print crash reports from DiagnosticReports
- Upload test logs and crash reports as artifacts on failure
* use gomobile-netbird in build script, workflows
* update build workflow to use gomobile-netbird
* force pulling of latest gomobile-netbird
* install gobind manually rather than relying on gomobile init
* add gomobile-tvos-for support download module step to workflows
* checkout gomobile-tv-fork module instead of go mod, which doesn't play nicely with github actions
* Add gomobile-tvos-fork to netbird's go.mod so gobind can find bind package
* revert debugging for test workflow
* fix rosenpass toggle bug for ios - view does not read value from saved config so always appears as disabled
* temporarily disable test workflow
680 lines
26 KiB
Swift
680 lines
26 KiB
Swift
//
|
|
// NetworkExtensionAdapter.swift
|
|
// NetBirdiOS
|
|
//
|
|
// Created by Pascal Fischer on 02.10.23.
|
|
//
|
|
|
|
import Foundation
|
|
import NetworkExtension
|
|
import SwiftUI
|
|
import Combine
|
|
import NetBirdSDK
|
|
import os
|
|
|
|
// SSO Listener for config initialization
|
|
/// Used to check if SSO is supported and save initial config
|
|
class ConfigSSOListener: NSObject, NetBirdSDKSSOListenerProtocol {
|
|
var onResult: ((Bool?, Error?) -> Void)?
|
|
|
|
func onSuccess(_ ssoSupported: Bool) {
|
|
onResult?(ssoSupported, nil)
|
|
}
|
|
|
|
func onError(_ error: Error?) {
|
|
onResult?(nil, error)
|
|
}
|
|
}
|
|
|
|
public class NetworkExtensionAdapter: ObservableObject {
|
|
|
|
private let logger = Logger(subsystem: "io.netbird.app", category: "NetworkExtensionAdapter")
|
|
|
|
#if os(tvOS)
|
|
static let defaultManagementURL = "https://api.netbird.io"
|
|
#endif
|
|
|
|
var session: NETunnelProviderSession?
|
|
var vpnManager: NETunnelProviderManager?
|
|
|
|
#if os(tvOS)
|
|
var extensionID = "io.netbird.app.tv.extension"
|
|
var extensionName = "NetBird"
|
|
#else
|
|
var extensionID = "io.netbird.app.NetbirdNetworkExtension"
|
|
var extensionName = "NetBird Network Extension"
|
|
#endif
|
|
|
|
let decoder = PropertyListDecoder()
|
|
|
|
@Published var timer: Timer
|
|
|
|
@Published var showBrowser = false
|
|
@Published var loginURL: String?
|
|
@Published var userCode: String?
|
|
|
|
private let fetchLock = NSLock()
|
|
private var _isFetchingStatus = false
|
|
private var isFetchingStatus: Bool {
|
|
get { fetchLock.lock(); defer { fetchLock.unlock() }; return _isFetchingStatus }
|
|
set { fetchLock.lock(); defer { fetchLock.unlock() }; _isFetchingStatus = newValue }
|
|
}
|
|
|
|
init() {
|
|
self.timer = Timer()
|
|
self.timer.invalidate()
|
|
// Don't configure manager during init - it's a slow system call that blocks app startup.
|
|
// Instead, configureManager is called lazily when needed (start(), stop(), etc.)
|
|
// This allows the UI to appear immediately on first launch.
|
|
}
|
|
|
|
deinit {
|
|
self.timer.invalidate()
|
|
}
|
|
|
|
@MainActor
|
|
func start() async {
|
|
logger.info("start: ENTRY - beginning VPN start sequence")
|
|
do {
|
|
logger.info("start: calling configureManager()...")
|
|
try await configureManager()
|
|
logger.info("start: configureManager() completed, calling loginIfRequired()...")
|
|
await loginIfRequired()
|
|
logger.info("start: loginIfRequired() completed")
|
|
} catch {
|
|
logger.error("start: CAUGHT ERROR - \(error.localizedDescription)")
|
|
}
|
|
logger.info("start: EXIT")
|
|
}
|
|
|
|
private func configureManager() async throws {
|
|
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
|
|
if let manager = managers.first(where: { $0.localizedDescription == self.extensionName }) {
|
|
self.vpnManager = manager
|
|
} else {
|
|
let newManager = createNewManager()
|
|
try await newManager.saveToPreferences()
|
|
self.vpnManager = newManager
|
|
}
|
|
self.vpnManager?.isEnabled = true
|
|
try await self.vpnManager?.saveToPreferences()
|
|
try await self.vpnManager?.loadFromPreferences()
|
|
self.session = self.vpnManager?.connection as? NETunnelProviderSession
|
|
}
|
|
|
|
private func createNewManager() -> NETunnelProviderManager {
|
|
let tunnelProviderProtocol = NETunnelProviderProtocol()
|
|
tunnelProviderProtocol.providerBundleIdentifier = self.extensionID
|
|
tunnelProviderProtocol.serverAddress = "multiple endpoints"
|
|
|
|
let newManager = NETunnelProviderManager()
|
|
newManager.protocolConfiguration = tunnelProviderProtocol
|
|
newManager.localizedDescription = self.extensionName
|
|
newManager.isEnabled = true
|
|
|
|
return newManager
|
|
}
|
|
|
|
|
|
|
|
public func loginIfRequired() async {
|
|
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()
|
|
}
|
|
|
|
logger.info("loginIfRequired: done")
|
|
}
|
|
|
|
#if os(tvOS)
|
|
/// Try to initialize the config file from the main app.
|
|
/// On tvOS, shared UserDefaults doesn't work, so we send config via IPC.
|
|
/// Settings (Rosenpass, PreSharedKey) are already stored in the config JSON.
|
|
private func initializeConfigFromApp() async {
|
|
// Check if config exists in main app's UserDefaults
|
|
// Note: Shared UserDefaults doesn't work on tvOS between app and extension,
|
|
// but we can still use it to store config in the main app
|
|
if let configJSON = Preferences.loadConfigFromUserDefaults(), !configJSON.isEmpty {
|
|
logger.info("initializeConfigFromApp: Config exists in UserDefaults, sending to extension via IPC")
|
|
|
|
// Send config to extension via IPC (settings are already in the JSON)
|
|
await sendConfigToExtensionAsync(configJSON)
|
|
return
|
|
}
|
|
|
|
guard let configPath = Preferences.configFile() else {
|
|
logger.error("initializeConfigFromApp: App group container unavailable")
|
|
return
|
|
}
|
|
let fileManager = FileManager.default
|
|
|
|
// Check if config already exists as a file (unlikely on tvOS but check anyway)
|
|
if fileManager.fileExists(atPath: configPath) {
|
|
logger.info("initializeConfigFromApp: Config already exists at \(configPath)")
|
|
return
|
|
}
|
|
|
|
logger.info("initializeConfigFromApp: No config found, user needs to configure server first")
|
|
// Don't automatically create config with default URL - user should go through ServerView
|
|
}
|
|
|
|
/// Async wrapper for sendConfigToExtension
|
|
private func sendConfigToExtensionAsync(_ configJSON: String) async {
|
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
|
sendConfigToExtension(configJSON) { _ in
|
|
continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
public func isLoginRequired() -> Bool {
|
|
guard let configPath = Preferences.configFile(), let statePath = Preferences.stateFile() else {
|
|
logger.error("isLoginRequired: App group container unavailable - assuming login required")
|
|
return true
|
|
}
|
|
logger.info("isLoginRequired: checking config at \(configPath), state at \(statePath)")
|
|
|
|
// Debug: Check if files exist and their sizes
|
|
let fileManager = FileManager.default
|
|
let configExists = fileManager.fileExists(atPath: configPath)
|
|
let stateExists = fileManager.fileExists(atPath: statePath)
|
|
logger.info("isLoginRequired: configFile exists = \(configExists), stateFile exists = \(stateExists)")
|
|
|
|
#if os(tvOS)
|
|
// On tvOS, the app doesn't have permission to write to App Group container.
|
|
// File writes are blocked, so we check UserDefaults instead.
|
|
// Config is saved to UserDefaults after successful login.
|
|
let hasConfigInUserDefaults = Preferences.hasConfigInUserDefaults()
|
|
logger.info("isLoginRequired: tvOS - hasConfigInUserDefaults = \(hasConfigInUserDefaults)")
|
|
|
|
if !hasConfigInUserDefaults {
|
|
// No config in UserDefaults - user definitely needs to login
|
|
logger.info("isLoginRequired: tvOS - no config in UserDefaults, login required")
|
|
return true
|
|
}
|
|
|
|
// Config exists - but we need to verify with the management server
|
|
// that the session is still valid (tokens can expire)
|
|
logger.info("isLoginRequired: tvOS - config found, checking with management server...")
|
|
|
|
// Create a Client and load config from UserDefaults
|
|
guard let client = NetBirdSDKNewClient("", "", Device.getName(), Device.getOsVersion(), Device.getOsName(), nil, nil) else {
|
|
logger.error("isLoginRequired: tvOS - failed to create SDK client")
|
|
return true
|
|
}
|
|
|
|
// Load the config from UserDefaults into the client
|
|
if let configJSON = Preferences.loadConfigFromUserDefaults() {
|
|
do {
|
|
try client.setConfigFromJSON(configJSON)
|
|
logger.info("isLoginRequired: tvOS - loaded config from UserDefaults into client")
|
|
} catch {
|
|
logger.error("isLoginRequired: tvOS - failed to load config: \(error.localizedDescription)")
|
|
return true
|
|
}
|
|
} else {
|
|
logger.error("isLoginRequired: tvOS - no config JSON in UserDefaults")
|
|
return true
|
|
}
|
|
|
|
// Now check with the management server
|
|
let result = client.isLoginRequired()
|
|
logger.info("isLoginRequired: tvOS - SDK returned \(result)")
|
|
return result
|
|
#else
|
|
if configExists {
|
|
if let attrs = try? fileManager.attributesOfItem(atPath: configPath),
|
|
let size = attrs[.size] as? Int64 {
|
|
logger.debug("isLoginRequired: configFile size = \(size) bytes")
|
|
}
|
|
}
|
|
|
|
if stateExists {
|
|
if let attrs = try? fileManager.attributesOfItem(atPath: statePath),
|
|
let size = attrs[.size] as? Int64 {
|
|
logger.debug("isLoginRequired: stateFile size = \(size) bytes")
|
|
}
|
|
}
|
|
|
|
guard let client = NetBirdSDKNewClient(configPath, statePath, Device.getName(), Device.getOsVersion(), Device.getOsName(), nil, nil) else {
|
|
logger.debug("isLoginRequired: Failed to initialize client")
|
|
return true
|
|
}
|
|
|
|
let result = client.isLoginRequired()
|
|
print("isLoginRequired: SDK returned \(result)")
|
|
return result
|
|
#endif
|
|
}
|
|
|
|
class ObserverBox {
|
|
var observer: NSObjectProtocol?
|
|
}
|
|
|
|
private func performLogin() async {
|
|
let loginURLString = await withCheckedContinuation { continuation in
|
|
self.login { urlString in
|
|
continuation.resume(returning: urlString)
|
|
}
|
|
}
|
|
|
|
self.loginURL = loginURLString
|
|
self.showBrowser = true
|
|
}
|
|
|
|
public func startVPNConnection() {
|
|
logger.info("startVPNConnection: called")
|
|
let logLevel = UserDefaults.standard.string(forKey: "logLevel") ?? "INFO"
|
|
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 session.startVPNTunnel(options: options)
|
|
logger.info("startVPNConnection: startVPNTunnel() returned successfully")
|
|
} catch let error {
|
|
logger.error("startVPNConnection: ERROR - startVPNTunnel failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
|
|
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")
|
|
return
|
|
}
|
|
|
|
do {
|
|
// Use LoginTV for tvOS to force device auth flow
|
|
#if os(tvOS)
|
|
let messageString = "LoginTV"
|
|
#else
|
|
let messageString = "Login"
|
|
#endif
|
|
|
|
if let messageData = messageString.data(using: .utf8) {
|
|
// Send the message to the network extension
|
|
try self.session!.sendProviderMessage(messageData) { response in
|
|
if let response = response {
|
|
#if os(tvOS)
|
|
// For tvOS, decode DeviceAuthResponse struct
|
|
do {
|
|
let authResponse = try self.decoder.decode(DeviceAuthResponse.self, from: response)
|
|
DispatchQueue.main.async {
|
|
self.userCode = authResponse.userCode
|
|
}
|
|
completion(authResponse.url)
|
|
} catch {
|
|
print("login: Failed to decode DeviceAuthResponse - \(error)")
|
|
// Fallback to plain string for backwards compatibility
|
|
if let string = String(data: response, encoding: .utf8) {
|
|
completion(string)
|
|
}
|
|
}
|
|
#else
|
|
if let string = String(data: response, encoding: .utf8) {
|
|
completion(string)
|
|
}
|
|
#endif
|
|
return
|
|
}
|
|
}
|
|
} else {
|
|
print("Error converting message to Data")
|
|
}
|
|
} catch {
|
|
print("error when performing network extension action")
|
|
}
|
|
}
|
|
|
|
/// Check if login is complete by asking the Network Extension directly
|
|
/// This is more reliable than isLoginRequired() because it queries the same SDK client
|
|
/// that is actually performing the login
|
|
func checkLoginComplete(completion: @escaping (Bool) -> Void) {
|
|
guard let session = self.session else {
|
|
logger.error("checkLoginComplete: No session available")
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
let messageString = "IsLoginComplete"
|
|
guard let messageData = messageString.data(using: .utf8) else {
|
|
print("checkLoginComplete: Failed to encode message")
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
do {
|
|
try session.sendProviderMessage(messageData) { response in
|
|
if let response = response {
|
|
do {
|
|
let diagnostic = try self.decoder.decode(LoginDiagnostics.self, from: response)
|
|
print("checkLoginComplete: result=\(diagnostic.isComplete), isExecuting=\(diagnostic.isExecuting), loginRequired=\(diagnostic.loginRequired), configExists=\(diagnostic.configExists), stateExists=\(diagnostic.stateExists), lastResult=\(diagnostic.lastResult), lastError=\(diagnostic.lastError)")
|
|
completion(diagnostic.isComplete)
|
|
} catch {
|
|
print("checkLoginComplete: Failed to decode LoginDiagnostics - \(error)")
|
|
completion(false)
|
|
}
|
|
} else {
|
|
print("checkLoginComplete: No response from extension")
|
|
completion(false)
|
|
}
|
|
}
|
|
} catch {
|
|
print("checkLoginComplete: Failed to send message - \(error)")
|
|
completion(false)
|
|
}
|
|
}
|
|
|
|
/// Check if there's a login error from the extension
|
|
/// Returns the error message via completion handler, or nil if no error
|
|
func checkLoginError(completion: @escaping (String?) -> Void) {
|
|
guard let session = self.session else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
|
|
let messageString = "IsLoginComplete"
|
|
guard let messageData = messageString.data(using: .utf8) else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
|
|
do {
|
|
try session.sendProviderMessage(messageData) { response in
|
|
if let response = response {
|
|
do {
|
|
let diagnostic = try self.decoder.decode(LoginDiagnostics.self, from: response)
|
|
// Only report error if lastResult is "error" and there's an actual error message
|
|
if diagnostic.lastResult == "error" && !diagnostic.lastError.isEmpty {
|
|
// Make the error message more user-friendly
|
|
var friendlyError = diagnostic.lastError
|
|
if diagnostic.lastError.contains("no peer auth method provided") {
|
|
friendlyError = "This server doesn't support device code authentication. Please use a setup key instead."
|
|
} else if diagnostic.lastError.contains("expired") || diagnostic.lastError.contains("token") {
|
|
friendlyError = "The device code has expired. Please try again."
|
|
} else if diagnostic.lastError.contains("denied") || diagnostic.lastError.contains("rejected") {
|
|
friendlyError = "Authentication was denied. Please try again."
|
|
}
|
|
completion(friendlyError)
|
|
return
|
|
}
|
|
completion(nil)
|
|
} catch {
|
|
print("checkLoginError: Failed to decode LoginDiagnostics - \(error)")
|
|
completion(nil)
|
|
}
|
|
} else {
|
|
completion(nil)
|
|
}
|
|
}
|
|
} catch {
|
|
completion(nil)
|
|
}
|
|
}
|
|
|
|
func getRoutes(completion: @escaping (RoutesSelectionDetails) -> Void) {
|
|
guard let session = self.session else {
|
|
let defaultStatus = RoutesSelectionDetails(all: false, append: false, routeSelectionInfo: [])
|
|
completion(defaultStatus)
|
|
return
|
|
}
|
|
|
|
let messageString = "GetRoutes"
|
|
if let messageData = messageString.data(using: .utf8) {
|
|
do {
|
|
try session.sendProviderMessage(messageData) { response in
|
|
if let response = response {
|
|
do {
|
|
let decodedStatus = try self.decoder.decode(RoutesSelectionDetails.self, from: response)
|
|
completion(decodedStatus)
|
|
return
|
|
} catch {
|
|
print("Failed to decode route selection details.")
|
|
}
|
|
} else {
|
|
let defaultStatus = RoutesSelectionDetails(all: false, append: false, routeSelectionInfo: [])
|
|
completion(defaultStatus)
|
|
return
|
|
}
|
|
}
|
|
} catch {
|
|
print("Failed to send Provider message")
|
|
}
|
|
} else {
|
|
print("Error converting message to Data")
|
|
}
|
|
}
|
|
|
|
func selectRoutes(id: String, completion: @escaping (RoutesSelectionDetails) -> Void) {
|
|
guard let session = self.session else {
|
|
return
|
|
}
|
|
|
|
let messageString = "Select-\(id)"
|
|
if let messageData = messageString.data(using: .utf8) {
|
|
do {
|
|
try session.sendProviderMessage(messageData) { response in
|
|
let routes = RoutesSelectionDetails(all: false, append: false, routeSelectionInfo: [])
|
|
completion(routes)
|
|
}
|
|
} catch {
|
|
print("Failed to send Provider message")
|
|
}
|
|
} else {
|
|
print("Error converting message to Data")
|
|
}
|
|
}
|
|
|
|
func deselectRoutes(id: String, completion: @escaping (RoutesSelectionDetails) -> Void) {
|
|
guard let session = self.session else {
|
|
return
|
|
}
|
|
|
|
let messageString = "Deselect-\(id)"
|
|
if let messageData = messageString.data(using: .utf8) {
|
|
do {
|
|
try session.sendProviderMessage(messageData) { response in
|
|
let routes = RoutesSelectionDetails(all: false, append: false, routeSelectionInfo: [])
|
|
completion(routes)
|
|
}
|
|
} catch {
|
|
print("Failed to send Provider message")
|
|
}
|
|
} else {
|
|
print("Error converting message to Data")
|
|
}
|
|
}
|
|
|
|
func fetchData(completion: @escaping (StatusDetails) -> Void) {
|
|
guard !isFetchingStatus else {
|
|
return
|
|
}
|
|
|
|
let defaultStatus = StatusDetails(ip: "", fqdn: "", managementStatus: .disconnected, peerInfo: [])
|
|
|
|
guard let session = self.session else {
|
|
completion(defaultStatus)
|
|
return
|
|
}
|
|
|
|
isFetchingStatus = true
|
|
var hasCompleted = false
|
|
let completionLock = NSLock()
|
|
|
|
// This is to make sure completion is called only once
|
|
let safeCompletion: (StatusDetails) -> Void = { [weak self] status in
|
|
completionLock.lock()
|
|
defer { completionLock.unlock() }
|
|
|
|
guard !hasCompleted else { return }
|
|
hasCompleted = true
|
|
|
|
self?.isFetchingStatus = false
|
|
completion(status)
|
|
}
|
|
|
|
// Timeout after 10 seconds to reset fetching status to false
|
|
let timeoutWorkItem = DispatchWorkItem {
|
|
AppLogger.shared.log("fetchData timed out")
|
|
safeCompletion(defaultStatus)
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: timeoutWorkItem)
|
|
|
|
let messageString = "Status"
|
|
|
|
if let messageData = messageString.data(using: .utf8) {
|
|
do {
|
|
try session.sendProviderMessage(messageData) { [weak self] response in
|
|
timeoutWorkItem.cancel()
|
|
|
|
guard let response = response else {
|
|
safeCompletion(defaultStatus)
|
|
return
|
|
}
|
|
|
|
do {
|
|
let decodedStatus = try self?.decoder.decode(StatusDetails.self, from: response)
|
|
safeCompletion(decodedStatus ?? defaultStatus)
|
|
} catch {
|
|
AppLogger.shared.log("Failed to decode status details: \(error)")
|
|
safeCompletion(defaultStatus)
|
|
}
|
|
}
|
|
} catch {
|
|
timeoutWorkItem.cancel()
|
|
AppLogger.shared.log("Failed to send Provider message")
|
|
safeCompletion(defaultStatus)
|
|
}
|
|
} else {
|
|
timeoutWorkItem.cancel()
|
|
AppLogger.shared.log("Error converting message to Data")
|
|
safeCompletion(defaultStatus)
|
|
}
|
|
}
|
|
|
|
func startTimer(completion: @escaping (StatusDetails) -> Void) {
|
|
self.timer.invalidate()
|
|
self.fetchData(completion: completion)
|
|
self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true, block: { _ in
|
|
self.fetchData(completion: completion)
|
|
})
|
|
}
|
|
|
|
func stopTimer() {
|
|
self.timer.invalidate()
|
|
}
|
|
|
|
#if os(tvOS)
|
|
/// Send config JSON to the Network Extension via IPC
|
|
/// On tvOS, shared UserDefaults doesn't work between app and extension,
|
|
/// so we transfer config directly via IPC
|
|
func sendConfigToExtension(_ configJSON: String, completion: ((Bool) -> Void)? = nil) {
|
|
guard let session = self.session else {
|
|
logger.warning("sendConfigToExtension: No session available")
|
|
completion?(false)
|
|
return
|
|
}
|
|
|
|
let messageString = "SetConfig:\(configJSON)"
|
|
guard let messageData = messageString.data(using: .utf8) else {
|
|
logger.error("sendConfigToExtension: Failed to convert message to Data")
|
|
completion?(false)
|
|
return
|
|
}
|
|
|
|
do {
|
|
try session.sendProviderMessage(messageData) { response in
|
|
if let response = response,
|
|
let responseString = String(data: response, encoding: .utf8),
|
|
responseString == "true" {
|
|
self.logger.info("sendConfigToExtension: Config sent successfully")
|
|
completion?(true)
|
|
} else {
|
|
self.logger.warning("sendConfigToExtension: Extension did not confirm receipt")
|
|
completion?(false)
|
|
}
|
|
}
|
|
} catch {
|
|
logger.error("sendConfigToExtension: Failed to send message: \(error.localizedDescription)")
|
|
completion?(false)
|
|
}
|
|
}
|
|
|
|
/// Clear extension-local config on logout
|
|
/// This ensures the extension doesn't have stale credentials after logout
|
|
func clearExtensionConfig(completion: ((Bool) -> Void)? = nil) {
|
|
guard let session = self.session else {
|
|
logger.warning("clearExtensionConfig: No session available")
|
|
completion?(false)
|
|
return
|
|
}
|
|
|
|
let messageString = "ClearConfig"
|
|
guard let messageData = messageString.data(using: .utf8) else {
|
|
logger.error("clearExtensionConfig: Failed to convert message to Data")
|
|
completion?(false)
|
|
return
|
|
}
|
|
|
|
do {
|
|
try session.sendProviderMessage(messageData) { response in
|
|
if let response = response,
|
|
let responseString = String(data: response, encoding: .utf8),
|
|
responseString == "true" {
|
|
self.logger.info("clearExtensionConfig: Extension config cleared successfully")
|
|
completion?(true)
|
|
} else {
|
|
self.logger.warning("clearExtensionConfig: Extension did not confirm clearing")
|
|
completion?(false)
|
|
}
|
|
}
|
|
} catch {
|
|
logger.error("clearExtensionConfig: Failed to send message: \(error.localizedDescription)")
|
|
completion?(false)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
func getExtensionStatus(completion: @escaping (NEVPNStatus) -> Void) {
|
|
Task {
|
|
do {
|
|
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
|
|
if let manager = managers.first(where: { $0.localizedDescription == self.extensionName }) {
|
|
completion(manager.connection.status)
|
|
}
|
|
} catch {
|
|
print("Error loading from preferences: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|