Files
ios-client/NetbirdKit/NetworkExtensionAdapter.swift
T
evgeniyChepelev 0327624b30 Multi-profiles (#80)
* fix layout on ipad

* Create multi profiles

* multi-profile server config, connection cache, and auth fixes

 Split ProfilesView into ProfilesListView, AddProfileSheet, ProfileBadge, AddProfileViewModel
- Add server URL + setup key fields to Add Profile screen
- Store per-profile connection data (ip/fqdn/managementURL) as typed model in ProfileConnectionCache
- Show cached connection info immediately on profile switch; empty if no prior data
- Fix profile deletion persistence via tombstone in profiles.json
- Fix logout to remove both netbird.cfg and state.json (cfg holds auth tokens)
- Preserve managementURL in UI after logout via cache fallback
- Guard polling from overwriting new profile's data during disconnect/reconnect cycle

* fix(multi-profile): reinitialize VPN adapter on profile switch and show current server URL

* Update project.pbxproj

* fix(multi-profile): prevent default server overwrite on logout/re-login

* Update PacketTunnelProvider.swift

* fix(multi-profile): address code review findings

- Normalize only scheme and host to lowercase when saving management
  server URL; previously the full URL was lowercased which could corrupt
  case-sensitive paths on self-hosted servers
- Move switchConnectionInfo(to:) inside the do-block so the connection
  UI is only updated after a successful profile switch
- Add ProfileConnectionCache.remove(for:) and clearConnectionData(for:)
  to prevent stale ip/fqdn/managementURL from persisting after a profile
  is deleted or logged out; call them from removeProfile and logoutProfile
- Use ASWebAuthenticationSession.Callback.customScheme on iOS 17.4+,
  falling back to the deprecated callbackURLScheme initializer on older
  versions; add a comment explaining why "http" is used and that a
  proper fix requires custom-scheme support in the SDK
- Guard presentationAnchor against a missing key window with
  assertionFailure in debug builds instead of silently returning an
  empty UIWindow
2026-04-22 10:06:51 +02:00

999 lines
42 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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()...")
#if os(iOS)
// Restore the config file before login if it was deleted (e.g. after logout).
// This must happen in the main app — not via IPC — because the extension
// process may not be running yet when start() is called.
restoreConfigIfMissing()
#endif
await loginIfRequired()
logger.info("start: loginIfRequired() completed")
} catch {
logger.error("start: CAUGHT ERROR - \(error.localizedDescription)")
}
logger.info("start: EXIT")
}
#if os(iOS)
/// If the active profile's config file is missing (deleted after logout) but we have
/// a saved management URL, write a minimal config so the SDK uses the correct server
/// instead of falling back to the default api.netbird.io.
private func restoreConfigIfMissing() {
guard let configPath = Preferences.configFile() else { return }
guard !FileManager.default.fileExists(atPath: configPath) else { return }
let profileName = ProfileManager.shared.getActiveProfileName()
// Prefer the dedicated server URL file (survives logout) over the in-memory cache
let managementURL = ProfileManager.shared.savedServerURL(for: profileName)
?? ProfileConnectionCache().managementURL(for: profileName)
guard let url = managementURL, !url.isEmpty else {
logger.info("restoreConfigIfMissing: no saved URL for '\(profileName)', will use default server")
return
}
logger.info("restoreConfigIfMissing: writing minimal config for '\(profileName)' with URL '\(url)'")
// The Go SDK serializes url.URL as a nested JSON object {Scheme, Host, Path, ...}.
// Writing ManagementURL as a plain string causes Go's json.Unmarshal to fail silently,
// leaving ManagementURL nil and falling back to the default api.netbird.io server.
// We must write the same nested-object format that the Go SDK expects.
guard let parsedURL = URL(string: url) else {
logger.error("restoreConfigIfMissing: could not parse URL '\(url)'")
return
}
let scheme = parsedURL.scheme ?? "https"
// Go's url.URL.Host includes the port (e.g. "my.server.io:443")
var goHost = parsedURL.host ?? ""
if let port = parsedURL.port { goHost += ":\(port)" }
let path = parsedURL.path
// Escape values for safe embedding in JSON
func jsonEscape(_ s: String) -> String {
s.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
}
let minimalConfig = "{\"ManagementURL\":{\"Scheme\":\"\(jsonEscape(scheme))\",\"Host\":\"\(jsonEscape(goHost))\",\"Path\":\"\(jsonEscape(path))\"}}"
do {
try minimalConfig.write(toFile: configPath, atomically: true, encoding: .utf8)
logger.info("restoreConfigIfMissing: config written successfully (Scheme=\(scheme) Host=\(goHost) Path=\(path))")
} catch {
logger.error("restoreConfigIfMissing: failed to write config – \(error.localizedDescription)")
}
}
#endif
private func configureManager() async throws {
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
if let manager = managers.first(where: { $0.localizedDescription == self.extensionName }) {
self.vpnManager = manager
// Only write preferences when strictly necessary.
// Calling saveToPreferences() on an already-configured manager triggers
// NEVPNStatusDidChange notifications — including a transient .disconnecting —
// that the polling timer picks up, producing the wrong UI sequence:
// Connecting → Disconnecting → Connected.
if !manager.isEnabled {
manager.isEnabled = true
try await manager.saveToPreferences()
try await manager.loadFromPreferences()
}
} else {
let newManager = createNewManager()
try await newManager.saveToPreferences()
try await newManager.loadFromPreferences()
self.vpnManager = newManager
}
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()
#if os(iOS)
// If performLogin() didn't open the browser, the IPC failed because the extension
// is not running. Start the VPN connection anyway so the extension process starts,
// detects login required, signals the main app via UserDefaults, and stays alive
// long enough for the user to press Connect from the auth alert (which will retry
// IPC to a still-alive extension and succeed).
if !self.showBrowser {
logger.info("loginIfRequired: IPC failed (extension not running), starting VPN to launch extension")
startVPNConnection()
}
#endif
} 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 (\(configJSON.count) chars), sending to extension via IPC")
// Check if session exists for IPC
if self.session == nil {
logger.error("initializeConfigFromApp: session is nil! IPC will fail. Config won't reach extension.")
}
// Send config to extension via IPC (settings are already in the JSON)
await sendConfigToExtensionAsync(configJSON)
// Also send the management URL separately — the config JSON from Go SDK
// serializes ManagementURL as a nested object, not a plain string,
// so regex extraction in the extension fails.
if let managementURL = Preferences.loadManagementURL() {
logger.info("initializeConfigFromApp: Sending management URL separately: \(managementURL, privacy: .public)")
await sendManagementURLToExtensionAsync(managementURL)
} else {
logger.warning("initializeConfigFromApp: No separate management URL saved")
}
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) { [weak self] success in
self?.logger.info("sendConfigToExtensionAsync: IPC result = \(success)")
continuation.resume()
}
}
}
/// Send the management URL to the extension separately via IPC.
/// This is needed because the Go SDK config JSON serializes ManagementURL
/// as a nested object, making regex extraction unreliable.
private func sendManagementURLToExtensionAsync(_ url: String) async {
guard let session = self.session else {
logger.warning("sendManagementURLToExtensionAsync: No session available")
return
}
let messageString = "SetManagementURL:\(url)"
guard let messageData = messageString.data(using: .utf8) else { return }
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
do {
try session.sendProviderMessage(messageData) { [weak self] response in
let success = response.flatMap { String(data: $0, encoding: .utf8) } == "true"
self?.logger.info("sendManagementURLToExtensionAsync: result = \(success)")
continuation.resume()
}
} catch {
self.logger.error("sendManagementURLToExtensionAsync: Failed: \(error.localizedDescription)")
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: String? = await withCheckedContinuation { continuation in
self.login { urlString in
continuation.resume(returning: urlString)
}
}
guard let url = loginURLString, !url.isEmpty else {
logger.error("performLogin: no login URL received from extension, aborting")
return
}
self.loginURL = url
self.showBrowser = true
}
public func startVPNConnection() {
logger.info("startVPNConnection: called")
let logLevel = UserDefaults.standard.string(forKey: "logLevel") ?? "INFO"
logger.info("startVPNConnection: logLevel = \(logLevel)")
var options: [String: NSObject] = ["logLevel": logLevel as NSObject]
#if os(iOS)
// Pass active profile paths so the extension can reinitialize the adapter
// if the profile changed while the extension process was still alive.
if let configPath = Preferences.configFile() {
options["configPath"] = configPath as NSObject
}
if let statePath = Preferences.stateFile() {
options["statePath"] = statePath as NSObject
}
logger.info("startVPNConnection: configPath=\(Preferences.configFile() ?? "nil")")
#endif
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()
}
/// Updates the VPN On Demand configuration on the current manager.
/// When enabled, iOS will automatically reconnect the VPN after network changes or reboot.
/// Should only be enabled when the user is logged in to avoid reconnect loops.
func setOnDemandEnabled(_ enabled: Bool) {
guard let manager = self.vpnManager else {
logger.warning("setOnDemandEnabled: No VPN manager available")
return
}
if enabled {
let defaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName)
let loginRequired = defaults?.bool(forKey: GlobalConstants.keyLoginRequired) ?? true
if loginRequired {
logger.warning("setOnDemandEnabled: Refusing to enable On Demand — user is not logged in")
return
}
}
if enabled {
// Build rules from saved settings
let rules = buildOnDemandRules()
if rules.isEmpty {
// All policies are "Do Nothing" — don't interfere with connection state
let ignoreRule = NEOnDemandRuleIgnore()
ignoreRule.interfaceTypeMatch = .any
manager.onDemandRules = [ignoreRule]
} else {
manager.onDemandRules = rules
}
} else {
manager.onDemandRules = []
}
manager.isOnDemandEnabled = enabled
manager.saveToPreferences { error in
if let error = error {
self.logger.error("setOnDemandEnabled: Failed to save preferences: \(error.localizedDescription)")
} else {
self.logger.info("setOnDemandEnabled: On Demand \(enabled ? "enabled" : "disabled") successfully")
}
}
}
/// Applies granular On Demand rules based on Wi-Fi/Cellular policies and network lists.
func applyOnDemandRules(wifiPolicy: WiFiOnDemandPolicy, cellularPolicy: CellularOnDemandPolicy, wifiNetworks: [String]) {
guard let manager = self.vpnManager else {
logger.warning("applyOnDemandRules: No VPN manager available")
return
}
let rules = buildOnDemandRulesFrom(wifiPolicy: wifiPolicy, cellularPolicy: cellularPolicy, wifiNetworks: wifiNetworks)
if rules.isEmpty {
// All policies are "Do Nothing" — don't interfere with connection state
let ignoreRule = NEOnDemandRuleIgnore()
ignoreRule.interfaceTypeMatch = .any
manager.onDemandRules = [ignoreRule]
} else {
manager.onDemandRules = rules
}
manager.saveToPreferences { error in
if let error = error {
self.logger.error("applyOnDemandRules: Failed to save preferences: \(error.localizedDescription)")
} else {
self.logger.info("applyOnDemandRules: Rules applied successfully")
}
}
}
/// Builds NEOnDemandRule array from persisted UserDefaults settings.
private func buildOnDemandRules() -> [NEOnDemandRule] {
let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName)
let wifiRaw = userDefaults?.string(forKey: GlobalConstants.keyOnDemandWiFiPolicy) ?? WiFiOnDemandPolicy.always.rawValue
let cellularRaw = userDefaults?.string(forKey: GlobalConstants.keyOnDemandCellularPolicy) ?? CellularOnDemandPolicy.always.rawValue
let wifiPolicy = WiFiOnDemandPolicy(rawValue: wifiRaw) ?? .always
let cellularPolicy = CellularOnDemandPolicy(rawValue: cellularRaw) ?? .always
let wifiNetworks = userDefaults?.stringArray(forKey: GlobalConstants.keyOnDemandWiFiNetworks) ?? []
return buildOnDemandRulesFrom(wifiPolicy: wifiPolicy, cellularPolicy: cellularPolicy, wifiNetworks: wifiNetworks)
}
/// Converts policy enums into NEOnDemandRule objects.
private func buildOnDemandRulesFrom(wifiPolicy: WiFiOnDemandPolicy, cellularPolicy: CellularOnDemandPolicy, wifiNetworks: [String]) -> [NEOnDemandRule] {
var rules: [NEOnDemandRule] = []
// Wi-Fi rules
switch wifiPolicy {
case .always:
let rule = NEOnDemandRuleConnect()
rule.interfaceTypeMatch = .wiFi
rules.append(rule)
case .onlyOn:
if !wifiNetworks.isEmpty {
let connectRule = NEOnDemandRuleConnect()
connectRule.interfaceTypeMatch = .wiFi
connectRule.ssidMatch = wifiNetworks
rules.append(connectRule)
}
// Disconnect on all other Wi-Fi networks (or all Wi-Fi if list is empty)
let disconnectRule = NEOnDemandRuleDisconnect()
disconnectRule.interfaceTypeMatch = .wiFi
rules.append(disconnectRule)
case .exceptOn:
if !wifiNetworks.isEmpty {
let disconnectRule = NEOnDemandRuleDisconnect()
disconnectRule.interfaceTypeMatch = .wiFi
disconnectRule.ssidMatch = wifiNetworks
rules.append(disconnectRule)
}
let connectRule = NEOnDemandRuleConnect()
connectRule.interfaceTypeMatch = .wiFi
rules.append(connectRule)
case .never:
let rule = NEOnDemandRuleDisconnect()
rule.interfaceTypeMatch = .wiFi
rules.append(rule)
case .doNothing:
break
}
// Cellular rules (cellular is not available on tvOS)
#if !os(tvOS)
switch cellularPolicy {
case .always:
let rule = NEOnDemandRuleConnect()
rule.interfaceTypeMatch = .cellular
rules.append(rule)
case .never:
let rule = NEOnDemandRuleDisconnect()
rule.interfaceTypeMatch = .cellular
rules.append(rule)
case .doNothing:
break
}
#endif
return rules
}
/// Returns the current On Demand enabled state from the VPN manager.
var isOnDemandEnabled: Bool {
return vpnManager?.isOnDemandEnabled ?? false
}
func login(completion: @escaping (String?) -> Void) {
guard let session = self.session else {
logger.error("login: No session available for login")
completion(nil)
return
}
do {
// Use LoginTV for tvOS to force device auth flow
#if os(tvOS)
let messageString = "LoginTV"
#else
// Include active profile paths so the extension can reinitialize
// its adapter for the correct profile before performing login.
// Also include the cached management URL so the extension can restore
// a missing config (e.g. after logout) and use the correct server.
// Format: "Login:<configPath>|<statePath>[|<managementURL>]"
var messageString = "Login"
if let configPath = Preferences.configFile(), let statePath = Preferences.stateFile() {
messageString = "Login:\(configPath)|\(statePath)"
let profileName = ProfileManager.shared.getActiveProfileName()
if let cachedURL = ProfileConnectionCache().managementURL(for: profileName),
!cachedURL.isEmpty {
messageString += "|\(cachedURL)"
}
}
#endif
if let messageData = messageString.data(using: .utf8) {
// Send the message to the network extension
try session.sendProviderMessage(messageData) { response in
guard let response = response else {
self.logger.error("login: No response from extension")
completion(nil)
return
}
#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
completion(String(data: response, encoding: .utf8))
}
#else
completion(String(data: response, encoding: .utf8))
#endif
}
} else {
print("Error converting message to Data")
completion(nil)
}
} catch {
print("error when performing network extension action")
completion(nil)
}
}
/// 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)
} else {
// No VPN manager exists yet (e.g. first connect before the iOS permission
// dialog completes). Must still call completion so that isCheckingExtensionState
// is reset to false; otherwise checkExtensionState() is permanently blocked.
completion(.disconnected)
}
} catch {
print("Error loading from preferences: \(error)")
completion(.disconnected)
}
}
}
}