mirror of
https://github.com/netbirdio/ios-client.git
synced 2026-09-23 09:28:33 -07:00
* Add method to return the current VPN connection state By checking if there's a VPN Manager instance from preferences whose name matches the extension's. If so, it sets the extension's session with its connection, and returns the current VPN status. Returns false, otherwise. * Call loadCurrentConnectionState when app becomes active This will be used to both report the current connection state properly and configure the ExtensionAdpater's session before polling. This doesn't start or create a new VPN manager, it only checks for one that is already running in case the VPN is connected by the time the app cold starts. * Guard active state after async operations to prevent background polling * Bump version to v0.0.16.2
704 lines
28 KiB
Swift
704 lines
28 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
|
|
}
|
|
|
|
/// Loads an existing VPN manager from preferences and returns the current connection state.
|
|
/// This is used on app startup to establish the session for status polling and get the
|
|
/// initial connection state, without triggering VPN configuration or starting a connection.
|
|
/// Returns the current VPN connection status if a manager was found, nil otherwise.
|
|
@MainActor
|
|
public func loadCurrentConnectionState() async -> NEVPNStatus? {
|
|
do {
|
|
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
|
|
if let manager = managers.first(where: { $0.localizedDescription == self.extensionName }) {
|
|
self.vpnManager = manager
|
|
self.session = manager.connection as? NETunnelProviderSession
|
|
let status = manager.connection.status
|
|
logger.info("loadCurrentConnectionState: Found existing manager, session established, status: \(status.rawValue)")
|
|
return status
|
|
} else {
|
|
logger.info("loadCurrentConnectionState: No existing manager found")
|
|
return nil
|
|
}
|
|
} catch {
|
|
logger.error("loadCurrentConnectionState: Error loading managers: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
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)")
|
|
}
|
|
}
|
|
}
|
|
}
|