Files

560 lines
21 KiB
Swift
Raw Permalink Normal View History

2024-02-28 19:22:16 +01:00
//
// MainViewModel.swift
// NetBirdiOS
//
// Created by Pascal Fischer on 01.08.23.
//
// This ViewModel is shared between iOS and tvOS.
// Platform-specific code is wrapped with #if os() directives.
//
2024-02-28 19:22:16 +01:00
import SwiftUI
2024-02-28 19:22:16 +01:00
import NetworkExtension
2026-03-02 15:23:51 +01:00
import Network
2024-02-28 19:22:16 +01:00
import os
import Combine
import NetBirdSDK
import UserNotifications
2024-02-28 19:22:16 +01:00
/// Used by updateManagementURL to check if SSO is supported
class SSOCheckListener: NSObject, NetBirdSDKSSOListenerProtocol {
var onResult: ((Bool?, Error?) -> Void)?
func onError(_ p0: Error?) {
onResult?(nil, p0)
}
func onSuccess(_ p0: Bool) {
onResult?(p0, nil)
}
}
// Error Listener for setup key login
/// Used by setSetupKey to handle async login result
class SetupKeyErrListener: NSObject, NetBirdSDKErrListenerProtocol {
var onResult: ((Error?) -> Void)?
func onError(_ p0: Error?) {
onResult?(p0)
}
func onSuccess() {
onResult?(nil)
}
}
2026-03-02 15:23:51 +01:00
enum VPNDisplayState {
case connected
case connecting
case disconnecting
case disconnected
}
/// For both iOS and tvOS (tvOS 17+ required for VPN support).
2024-02-28 19:22:16 +01:00
@MainActor
class ViewModel: ObservableObject {
private let logger = Logger(subsystem: "io.netbird.app", category: "ViewModel")
// VPN Adapter (shared)
2024-07-17 16:00:30 +02:00
@Published var networkExtensionAdapter: NetworkExtensionAdapter
// UI State (shared)
2024-02-28 19:22:16 +01:00
@Published var showSetupKeyPopup = false
@Published var showChangeServerAlert = false
@Published var showInvalidServerAlert = false
@Published var showInvalidSetupKeyHint = false
@Published var showInvalidSetupKeyAlert = false
2024-04-30 11:45:27 +02:00
@Published var showLogLevelChangedAlert = false
2024-07-17 16:00:30 +02:00
@Published var showBetaProgramAlert = false
2024-02-28 19:22:16 +01:00
@Published var showInvalidPresharedKeyAlert = false
@Published var showServerChangedInfo = false
@Published var showPreSharedKeyChangedInfo = false
@Published var showFqdnCopiedAlert = false
@Published var showIpCopiedAlert = false
2024-02-28 19:22:16 +01:00
@Published var showAuthenticationRequired = false
@Published var navigateToServerView = false
@Published var extensionState: NEVPNStatus = .disconnected
@Published var managementStatus: ClientState = .disconnected
2024-02-28 19:22:16 +01:00
@Published var statusDetailsValid = false
@Published var extensionStateText = "Disconnected"
2026-03-02 15:23:51 +01:00
@Published var vpnDisplayState: VPNDisplayState = .disconnected
var connectPressed = false
var disconnectPressed = false
@Published var rosenpassEnabled = false
@Published var rosenpassPermissive = false
@Published var presharedKey = ""
@Published var server: String = ""
@Published var setupKey: String = ""
@Published var presharedKeySecure = true
@Published var fqdn = UserDefaults.standard.string(forKey: "fqdn") ?? ""
@Published var ip = UserDefaults.standard.string(forKey: "ip") ?? ""
// Debug
2024-04-30 11:45:27 +02:00
@Published var traceLogsEnabled: Bool {
didSet {
self.showLogLevelChangedAlert = true
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.showLogLevelChangedAlert = false
}
let logLevel = traceLogsEnabled ? "TRACE" : "INFO"
UserDefaults.standard.set(logLevel, forKey: "logLevel")
UserDefaults.standard.synchronize()
}
}
2025-12-12 14:30:17 -03:00
@Published var forceRelayConnection = true
@Published var showForceRelayAlert = false
@Published var showRosenpassChangedAlert = false
@Published var networkUnavailable = false
2026-03-02 15:23:51 +01:00
@Published var isInternetConnected = true
/// Platform-agnostic configuration provider.
/// Abstracts iOS SDK preferences vs tvOS UserDefaults + IPC.
private lazy var configProvider: ConfigurationProvider = ConfigurationProviderFactory.create()
2024-02-28 19:22:16 +01:00
var buttonLock = false
let defaults = UserDefaults.standard
2026-03-02 15:23:51 +01:00
2024-02-28 19:22:16 +01:00
private var cancellables = Set<AnyCancellable>()
2026-03-02 15:23:51 +01:00
private let networkMonitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "io.netbird.networkMonitor")
2024-02-28 19:22:16 +01:00
2024-07-17 16:00:30 +02:00
@Published var peerViewModel: PeerViewModel
@Published var routeViewModel: RoutesViewModel
2024-02-28 19:22:16 +01:00
init() {
2024-07-17 16:00:30 +02:00
let networkExtensionAdapter = NetworkExtensionAdapter()
self.networkExtensionAdapter = networkExtensionAdapter
2024-04-30 11:45:27 +02:00
let logLevel = UserDefaults.standard.string(forKey: "logLevel") ?? "INFO"
self.traceLogsEnabled = logLevel == "TRACE"
2024-07-17 16:00:30 +02:00
self.peerViewModel = PeerViewModel()
self.routeViewModel = RoutesViewModel(networkExtensionAdapter: networkExtensionAdapter)
// Don't load rosenpass settings during init - they trigger expensive SDK initialization.
// These will be loaded lazily when the settings view is accessed.
// self.rosenpassEnabled = self.getRosenpassEnabled()
// self.rosenpassPermissive = self.getRosenpassPermissive()
// forceRelayConnection uses UserDefaults (not SDK), so it's safe to load during init
2025-12-12 14:30:17 -03:00
self.forceRelayConnection = self.getForcedRelayConnectionEnabled()
2026-03-02 15:23:51 +01:00
networkMonitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.isInternetConnected = path.status == .satisfied
self?.updateVPNDisplayState()
}
}
networkMonitor.start(queue: monitorQueue)
$setupKey
.removeDuplicates()
.debounce(for: .seconds(0.5), scheduler: RunLoop.main)
.map { setupKey in
!self.isValidSetupKey(setupKey)
}
.assign(to: &$showInvalidSetupKeyHint)
2024-02-28 19:22:16 +01:00
}
func connect() {
logger.info("connect: ENTRY POINT - function called")
2024-02-28 19:22:16 +01:00
self.connectPressed = true
self.buttonLock = true
// Reset networkUnavailable flag when user initiates connection
self.networkUnavailable = false
#if os(iOS)
let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName)
userDefaults?.set(false, forKey: GlobalConstants.keyNetworkUnavailable)
userDefaults?.synchronize()
#endif
2026-03-02 15:23:51 +01:00
updateVPNDisplayState()
logger.info("connect: connectPressed=true, buttonLock=true, starting adapter...")
// Reset buttonLock after 3 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.buttonLock = false
}
// Start the VPN connection
Task {
self.logger.info("connect: Task started, calling networkExtensionAdapter.start()")
await self.networkExtensionAdapter.start()
self.logger.info("connect: networkExtensionAdapter.start() completed")
2024-02-28 19:22:16 +01:00
}
}
func close() -> Void {
self.disconnectPressed = true
DispatchQueue.main.async {
print("Stopping extension")
self.buttonLock = true
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.buttonLock = false
}
self.networkExtensionAdapter.stop()
2026-03-02 15:23:51 +01:00
self.updateVPNDisplayState()
}
}
func updateVPNDisplayState() {
let newState: VPNDisplayState
// Extension state is the source of truth.
// Flags only provide immediate UI feedback for the brief gap
// between button press and extension state change.
switch extensionState {
case .connected:
// Extension confirmed connected — clear both flags
connectPressed = false
disconnectPressed = false
newState = .connected
case .connecting:
connectPressed = false
newState = .connecting
case .disconnecting:
disconnectPressed = false
newState = .disconnecting
case .disconnected:
// Extension confirmed disconnected — clear both flags,
// unless a flag was JUST set (immediate feedback)
if connectPressed {
newState = .connecting
} else {
disconnectPressed = false
newState = .disconnected
}
default:
connectPressed = false
disconnectPressed = false
newState = .disconnected
}
vpnDisplayState = newState
switch newState {
case .connected:
extensionStateText = isInternetConnected ? "Connected" : "Offline"
case .connecting:
extensionStateText = "Connecting..."
case .disconnecting:
extensionStateText = "Disconnecting..."
case .disconnected:
extensionStateText = "Disconnected"
2024-02-28 19:22:16 +01:00
}
}
func startPollingDetails() {
networkExtensionAdapter.startTimer { details in
2024-02-28 19:22:16 +01:00
self.checkExtensionState()
self.checkNetworkUnavailableFlag()
2026-03-02 15:23:51 +01:00
if self.extensionState == .disconnected && self.vpnDisplayState == .connected {
2024-02-28 19:22:16 +01:00
self.showAuthenticationRequired = true
2026-03-02 15:23:51 +01:00
self.updateVPNDisplayState()
2024-02-28 19:22:16 +01:00
}
if details.ip != self.ip || details.fqdn != self.fqdn || details.managementStatus != self.managementStatus
{
if !details.fqdn.isEmpty && details.fqdn != self.fqdn {
self.defaults.set(details.fqdn, forKey: "fqdn")
2024-02-28 19:22:16 +01:00
self.fqdn = details.fqdn
2024-02-28 19:22:16 +01:00
}
if !details.ip.isEmpty && details.ip != self.ip {
self.defaults.set(details.ip, forKey: "ip")
2024-02-28 19:22:16 +01:00
self.ip = details.ip
}
print("Status: \(details.managementStatus) - Extension: \(self.extensionState)")
if details.managementStatus != self.managementStatus {
self.managementStatus = details.managementStatus
2026-03-02 15:23:51 +01:00
self.updateVPNDisplayState()
}
// Login required detection is handled by the network extension via signalLoginRequired()
// The app checks for this flag in checkLoginRequiredFlag() when becoming active
2024-02-28 19:22:16 +01:00
}
2024-02-28 19:22:16 +01:00
self.statusDetailsValid = true
let sortedPeerInfo = details.peerInfo.sorted(by: { a, b in
a.ip < b.ip
})
if sortedPeerInfo.count != self.peerViewModel.peerInfo.count || !sortedPeerInfo.elementsEqual(self.peerViewModel.peerInfo, by: { a, b in
a.ip == b.ip && a.connStatus == b.connStatus && a.relayed == b.relayed && a.direct == b.direct && a.connStatusUpdate == b.connStatusUpdate && a.routes.count == b.routes.count
}) {
print("Setting new peer info: \(sortedPeerInfo.count) Peers")
self.peerViewModel.peerInfo = sortedPeerInfo
}
}
}
2024-02-28 19:22:16 +01:00
func stopPollingDetails() {
networkExtensionAdapter.stopTimer()
}
func checkExtensionState() {
networkExtensionAdapter.getExtensionStatus { status in
let statuses : [NEVPNStatus] = [.connected, .disconnected, .connecting, .disconnecting]
DispatchQueue.main.async {
if statuses.contains(status) && self.extensionState != status {
print("Changing extension status to \(status.rawValue)")
2024-02-28 19:22:16 +01:00
self.extensionState = status
2026-03-02 15:23:51 +01:00
self.updateVPNDisplayState()
2026-03-02 15:23:51 +01:00
if status == .connected {
self.routeViewModel.getRoutes()
}
2024-02-28 19:22:16 +01:00
}
}
}
}
func clearDetails() {
self.ip = ""
self.fqdn = ""
defaults.removeObject(forKey: "ip")
defaults.removeObject(forKey: "fqdn")
// Clear config JSON (contains server credentials and all settings)
Preferences.removeConfigFromUserDefaults()
// Reset @Published properties to reflect cleared state in UI
self.rosenpassEnabled = false
self.rosenpassPermissive = false
self.presharedKey = ""
self.presharedKeySecure = false
#if os(tvOS)
// Also clear extension-local config to prevent stale credentials
networkExtensionAdapter.clearExtensionConfig()
#endif
2024-02-28 19:22:16 +01:00
}
// MARK: - Configuration Methods (via ConfigurationProvider)
2024-02-28 19:22:16 +01:00
func updatePreSharedKey() {
configProvider.preSharedKey = presharedKey
if configProvider.commit() {
2024-02-28 19:22:16 +01:00
self.close()
self.presharedKeySecure = true
self.showPreSharedKeyChangedInfo = true
} else {
2024-02-28 19:22:16 +01:00
print("Failed to update preshared key")
}
}
2024-02-28 19:22:16 +01:00
func removePreSharedKey() {
presharedKey = ""
configProvider.preSharedKey = ""
if configProvider.commit() {
2024-02-28 19:22:16 +01:00
self.close()
self.presharedKeySecure = false
} else {
2024-02-28 19:22:16 +01:00
print("Failed to remove preshared key")
}
}
2024-02-28 19:22:16 +01:00
func loadPreSharedKey() {
self.presharedKey = configProvider.preSharedKey
self.presharedKeySecure = configProvider.hasPreSharedKey
2024-02-28 19:22:16 +01:00
}
2024-04-26 22:37:52 +02:00
func setRosenpassEnabled(enabled: Bool) {
// Update @Published property for immediate UI feedback
self.rosenpassEnabled = enabled
// Persist to storage (on tvOS this writes directly to config JSON)
configProvider.rosenpassEnabled = enabled
if !configProvider.commit() {
2024-04-26 22:37:52 +02:00
print("Failed to update rosenpass settings")
}
#if os(tvOS)
// Show reconnect alert if currently connected
if extensionState == .connected {
showRosenpassChangedAlert = true
}
#endif
2024-04-26 22:37:52 +02:00
}
2024-04-26 22:37:52 +02:00
func getRosenpassEnabled() -> Bool {
return configProvider.rosenpassEnabled
2024-04-26 22:37:52 +02:00
}
2024-04-26 22:37:52 +02:00
func getRosenpassPermissive() -> Bool {
return configProvider.rosenpassPermissive
2024-04-26 22:37:52 +02:00
}
/// Loads Rosenpass settings from the configuration provider into the @Published properties.
/// Call this when opening settings views to sync UI with stored values.
/// On iOS, this triggers SDK initialization, so it's deferred until needed.
/// On tvOS, this reads from UserDefaults which is fast.
func loadRosenpassSettings() {
self.rosenpassEnabled = configProvider.rosenpassEnabled
self.rosenpassPermissive = configProvider.rosenpassPermissive
}
2024-04-26 22:37:52 +02:00
func setRosenpassPermissive(permissive: Bool) {
// Update @Published property for immediate UI feedback
self.rosenpassPermissive = permissive
// Persist to storage (on tvOS this writes directly to config JSON)
configProvider.rosenpassPermissive = permissive
if !configProvider.commit() {
2024-04-26 22:37:52 +02:00
print("Failed to update rosenpass permissive settings")
}
}
/// Reloads configuration from persistent storage.
/// Call this after server changes or when returning to settings view.
func reloadConfiguration() {
configProvider.reload()
// Sync @Published properties with reloaded config values
loadRosenpassSettings()
}
2024-04-26 22:37:52 +02:00
2025-12-12 14:30:17 -03:00
func setForcedRelayConnection(isEnabled: Bool) {
let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName)
userDefaults?.set(isEnabled, forKey: GlobalConstants.keyForceRelayConnection)
self.forceRelayConnection = isEnabled
self.showForceRelayAlert = true
}
func getForcedRelayConnectionEnabled() -> Bool {
let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName)
#if os(iOS)
2025-12-12 14:30:17 -03:00
userDefaults?.register(defaults: [GlobalConstants.keyForceRelayConnection: true])
return userDefaults?.bool(forKey: GlobalConstants.keyForceRelayConnection) ?? true
#else
// forced relay battery optimization not need on Apple Tv
userDefaults?.register(defaults: [GlobalConstants.keyForceRelayConnection: false])
return userDefaults?.bool(forKey: GlobalConstants.keyForceRelayConnection) ?? false
#endif
2025-12-12 14:30:17 -03:00
}
2024-02-28 19:22:16 +01:00
func getDefaultStatus() -> StatusDetails {
return StatusDetails(ip: "", fqdn: "", managementStatus: .disconnected, peerInfo: [])
}
func isValidSetupKey(_ string: String) -> Bool {
if string.isEmpty { return true }
let pattern = "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$"
let isMatch = string.range(of: pattern, options: .regularExpression, range: nil, locale: nil) != nil
return isMatch
}
func printLogContents(from logURL: URL) {
do {
let logContents = try String(contentsOf: logURL, encoding: .utf8)
print(logContents)
} catch {
print("Failed to read the log file: \(error.localizedDescription)")
}
2024-02-28 19:22:16 +01:00
}
/// Handles server change completion by stopping the engine and resetting all connection state.
func handleServerChanged() {
AppLogger.shared.log("Server changed - stopping engine and resetting state")
// Stop polling to prevent transitional states from updating UI
stopPollingDetails()
// Reset connection flags first to update UI immediately
connectPressed = false
disconnectPressed = false
buttonLock = false
// Reset connection state
extensionState = .disconnected
managementStatus = .disconnected
2026-03-02 15:23:51 +01:00
updateVPNDisplayState()
// Clear peer info
peerViewModel.peerInfo = []
// Clear connection details
clearDetails()
// Stop the network extension in background (non-blocking)
Task { @MainActor in
self.networkExtensionAdapter.stop()
}
// Reload configuration for new server
reloadConfiguration()
}
/// Checks shared app-group container for network unavailable flag set by the network extension.
/// Updates the networkUnavailable property to trigger UI animation changes.
/// iOS only - tvOS has a platform limitation where `UserDefaults(suiteName:)` does not
/// reliably synchronize between the main app and network extension processes, even with
/// a correctly configured App Group. On tvOS, we use IPC messaging instead.
func checkNetworkUnavailableFlag() {
#if os(iOS)
let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName)
let isUnavailable = userDefaults?.bool(forKey: GlobalConstants.keyNetworkUnavailable) ?? false
if isUnavailable != networkUnavailable {
AppLogger.shared.log("Network unavailable flag changed: \(isUnavailable)")
networkUnavailable = isUnavailable
2026-03-02 15:23:51 +01:00
updateVPNDisplayState()
}
#endif
// tvOS: Network status is determined by extension state, not a shared flag
}
/// Checks shared app-group container for login required flag set by the network extension.
/// If set, schedules a local notification (if authorized) and shows the authentication UI.
/// iOS only - tvOS cannot share UserDefaults between app and extension, and uses IPC
/// via `checkLoginError` instead.
func checkLoginRequiredFlag() {
#if os(iOS)
let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName)
guard userDefaults?.bool(forKey: GlobalConstants.keyLoginRequired) == true else {
return
}
// Clear the flag immediately
userDefaults?.set(false, forKey: GlobalConstants.keyLoginRequired)
userDefaults?.synchronize()
AppLogger.shared.log("Login required flag detected from extension")
// Show authentication required UI
self.showAuthenticationRequired = true
// Schedule local notification if authorized
UNUserNotificationCenter.current().getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized else {
AppLogger.shared.log("Notifications not authorized, skipping notification")
return
}
let content = UNMutableNotificationContent()
content.title = "NetBird"
content.body = "Login required. Please open the app to reconnect."
content.sound = .default
let request = UNNotificationRequest(
identifier: "netbird.login.required",
content: content,
trigger: nil
)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
AppLogger.shared.log("Failed to schedule login notification: \(error.localizedDescription)")
} else {
AppLogger.shared.log("Login required notification scheduled from main app")
}
}
}
#endif
// tvOS: Login errors are detected via IPC (checkLoginError in TVAuthView)
}
2024-02-28 19:22:16 +01:00
}