diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 9e1e5cc..c7732a2 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -133,10 +133,10 @@ 50E608132A7958B100BAF09B /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608122A7958B100BAF09B /* MainViewModel.swift */; }; 50E608242A79966600BAF09B /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608232A79966600BAF09B /* AboutView.swift */; }; 50E608262A79968500BAF09B /* AdvancedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608252A79968500BAF09B /* AdvancedView.swift */; }; - 55472AC52F59E3C000382947 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 55472AC42F59E3C000382947 /* GoogleService-Info.plist */; }; - 55472AC62F59E3C000382947 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 55472AC42F59E3C000382947 /* GoogleService-Info.plist */; }; - 55472AC72F59E3C000382947 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 55472AC42F59E3C000382947 /* GoogleService-Info.plist */; }; - 55472AC82F59E3C000382947 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 55472AC42F59E3C000382947 /* GoogleService-Info.plist */; }; + 5554ACC22F7E59700047BDAC /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5554ACC12F7E59700047BDAC /* GoogleService-Info.plist */; }; + 5554ACC32F7E59700047BDAC /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5554ACC12F7E59700047BDAC /* GoogleService-Info.plist */; }; + 5554ACC42F7E59700047BDAC /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5554ACC12F7E59700047BDAC /* GoogleService-Info.plist */; }; + 5554ACC52F7E59700047BDAC /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5554ACC12F7E59700047BDAC /* GoogleService-Info.plist */; }; 55B5E81B2F39158200852AA7 /* InternetStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B5E81A2F39158200852AA7 /* InternetStatusView.swift */; }; 55D865852F70982000A2EFF8 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 55D865842F70982000A2EFF8 /* WidgetKit.framework */; }; 55D865872F70982000A2EFF8 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 55D865862F70982000A2EFF8 /* SwiftUI.framework */; }; @@ -162,6 +162,18 @@ A1C3D5EE2F000008001A2B3C /* CellularOnDemandPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3D5E82F000002001A2B3C /* CellularOnDemandPolicy.swift */; }; A1C3D5EF2F000009001A2B3C /* CellularOnDemandPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3D5E82F000002001A2B3C /* CellularOnDemandPolicy.swift */; }; A1C3D5F02F00000A001A2B3C /* CellularOnDemandPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3D5E82F000002001A2B3C /* CellularOnDemandPolicy.swift */; }; + AA0001012F22000100000001 /* ProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001002F22000100000001 /* ProfileManager.swift */; }; + AA0001022F22000100000001 /* ProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001002F22000100000001 /* ProfileManager.swift */; }; + AA0001032F22000100000001 /* ProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001002F22000100000001 /* ProfileManager.swift */; }; + AA0001042F22000100000001 /* ProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001002F22000100000001 /* ProfileManager.swift */; }; + AA0002052F22000200000001 /* ProfileBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0002012F22000200000001 /* ProfileBadge.swift */; }; + AA0002062F22000200000001 /* ProfilesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0002022F22000200000001 /* ProfilesListView.swift */; }; + AA0002072F22000200000001 /* AddProfileSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0002032F22000200000001 /* AddProfileSheet.swift */; }; + AA0002082F22000200000001 /* AddProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0002042F22000200000001 /* AddProfileViewModel.swift */; }; + AA0009012F22000900000001 /* ProfileConnectionCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0009002F22000900000001 /* ProfileConnectionCache.swift */; }; + AA0009022F22000900000001 /* ProfileConnectionCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0009002F22000900000001 /* ProfileConnectionCache.swift */; }; + AA0009032F22000900000001 /* ProfileConnectionCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0009002F22000900000001 /* ProfileConnectionCache.swift */; }; + AA0009042F22000900000001 /* ProfileConnectionCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0009002F22000900000001 /* ProfileConnectionCache.swift */; }; AA1B2C022F4E5A0100D1E2F3 /* TVGradientBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C012F4E5A0100D1E2F3 /* TVGradientBackground.swift */; }; B1A2C3D42F3A000100000001 /* PeerDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D32F3A000100000001 /* PeerDetailSheet.swift */; }; BB3D4E022F4E5A0200D1E2F3 /* TVPreSharedKeyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3D4E012F4E5A0200D1E2F3 /* TVPreSharedKeyButton.swift */; }; @@ -325,7 +337,7 @@ 50E608232A79966600BAF09B /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 50E608252A79968500BAF09B /* AdvancedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedView.swift; sourceTree = ""; }; 53CB9305A9DC6CAD1895495A /* SharedUserDefaultsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedUserDefaultsTests.swift; sourceTree = ""; }; - 55472AC42F59E3C000382947 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 5554ACC12F7E59700047BDAC /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 55B5E81A2F39158200852AA7 /* InternetStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetStatusView.swift; sourceTree = ""; }; 55D865832F70982000A2EFF8 /* NetBirdWidgetExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NetBirdWidgetExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 55D865842F70982000A2EFF8 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -339,6 +351,12 @@ A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationProvider.swift; sourceTree = ""; }; A1C3D5E72F000001001A2B3C /* WiFiOnDemandPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WiFiOnDemandPolicy.swift; sourceTree = ""; }; A1C3D5E82F000002001A2B3C /* CellularOnDemandPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellularOnDemandPolicy.swift; sourceTree = ""; }; + AA0001002F22000100000001 /* ProfileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileManager.swift; sourceTree = ""; }; + AA0002012F22000200000001 /* ProfileBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileBadge.swift; sourceTree = ""; }; + AA0002022F22000200000001 /* ProfilesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesListView.swift; sourceTree = ""; }; + AA0002032F22000200000001 /* AddProfileSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProfileSheet.swift; sourceTree = ""; }; + AA0002042F22000200000001 /* AddProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProfileViewModel.swift; sourceTree = ""; }; + AA0009002F22000900000001 /* ProfileConnectionCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileConnectionCache.swift; sourceTree = ""; }; AA1B2C012F4E5A0100D1E2F3 /* TVGradientBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVGradientBackground.swift; sourceTree = ""; }; B1A2C3D32F3A000100000001 /* PeerDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDetailSheet.swift; sourceTree = ""; }; BB3D4E012F4E5A0200D1E2F3 /* TVPreSharedKeyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVPreSharedKeyButton.swift; sourceTree = ""; }; @@ -531,7 +549,7 @@ 50A8910E2A792A15007C48FC = { isa = PBXGroup; children = ( - 55472AC42F59E3C000382947 /* GoogleService-Info.plist */, + 5554ACC12F7E59700047BDAC /* GoogleService-Info.plist */, 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */, 50245A0A2A7AA9390034792B /* NetBird-Bridging-Header.h */, 50A891192A792A15007C48FC /* NetBird */, @@ -580,6 +598,8 @@ A1C3D5E82F000002001A2B3C /* CellularOnDemandPolicy.swift */, F1B292062EE0AC25001D91B8 /* EnvVarPackager.swift */, 50245A292A7BDB590034792B /* Preferences.swift */, + AA0001002F22000100000001 /* ProfileManager.swift */, + AA0009002F22000900000001 /* ProfileConnectionCache.swift */, A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */, 50245A2D2A7BDC470034792B /* NetworkChangeListener.swift */, 50CD81612AD0595E00CF830B /* DNSManager.swift */, @@ -615,6 +635,7 @@ 50E608122A7958B100BAF09B /* MainViewModel.swift */, 50D402962BD9B89300D4AC5B /* PeerViewModel.swift */, 509CCD692BE908C000B7C2D8 /* RoutesViewModel.swift */, + AA0002042F22000200000001 /* AddProfileViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -674,6 +695,9 @@ 42873052F9544A89B1408339 /* iOSPeersView.swift */, C7A1CFF65CC44187912007EC /* iOSNetworksView.swift */, 2F8A4B98A775451786C5DDF8 /* iOSSettingsView.swift */, + AA0002012F22000200000001 /* ProfileBadge.swift */, + AA0002022F22000200000001 /* ProfilesListView.swift */, + AA0002032F22000200000001 /* AddProfileSheet.swift */, 9CD257EF78F038560FF3112D /* VPNOnDemandView.swift */, 5BFDF94163AF4C32A44DB3CD /* FirstLaunchView.swift */, ); @@ -889,7 +913,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 55472AC52F59E3C000382947 /* GoogleService-Info.plist in Resources */, + 5554ACC42F7E59700047BDAC /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -897,7 +921,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 55472AC82F59E3C000382947 /* GoogleService-Info.plist in Resources */, + 5554ACC32F7E59700047BDAC /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -907,7 +931,7 @@ files = ( 501B0DCE2AE04DDE004BE7A7 /* button-disconnecting.json in Resources */, 501B0DD22AE04DDE004BE7A7 /* logo_NetBird.json in Resources */, - 55472AC62F59E3C000382947 /* GoogleService-Info.plist in Resources */, + 5554ACC22F7E59700047BDAC /* GoogleService-Info.plist in Resources */, 501B0DDC2AE04DDE004BE7A7 /* button-connected.json in Resources */, 501B0DD62AE04DDE004BE7A7 /* button-start-connecting.json in Resources */, 501B0DDA2AE04DDE004BE7A7 /* button-full.json in Resources */, @@ -929,7 +953,7 @@ 501B0DCD2AE04DDE004BE7A7 /* button-disconnecting.json in Resources */, 501B0DD12AE04DDE004BE7A7 /* logo_NetBird.json in Resources */, 501B0DDB2AE04DDE004BE7A7 /* button-connected.json in Resources */, - 55472AC72F59E3C000382947 /* GoogleService-Info.plist in Resources */, + 5554ACC52F7E59700047BDAC /* GoogleService-Info.plist in Resources */, 501B0DCF2AE04DDE004BE7A7 /* button-connecting-loop.json in Resources */, 501B0DD72AE04DDE004BE7A7 /* button-full2.json in Resources */, ); @@ -1009,6 +1033,8 @@ 445B5F762EECAF02008932B8 /* EnvVarPackager.swift in Sources */, 443782D02EDF29A800F9FA94 /* Device.swift in Sources */, 443782D12EDF29A800F9FA94 /* Preferences.swift in Sources */, + AA0001012F22000100000001 /* ProfileManager.swift in Sources */, + AA0009012F22000900000001 /* ProfileConnectionCache.swift in Sources */, A1B2C3D52EEDF501001A2B3C /* ConfigurationProvider.swift in Sources */, 443782D42EDF29A800F9FA94 /* RoutesSelectionDetails.swift in Sources */, 443782D52EDF29A800F9FA94 /* StatusDetails.swift in Sources */, @@ -1051,6 +1077,8 @@ 44F3E3902EE2151100C87FEC /* StatusDetails.swift in Sources */, 44F3E3912EE2151100C87FEC /* ClientState.swift in Sources */, 44F3E3922EE2151100C87FEC /* Preferences.swift in Sources */, + AA0001022F22000100000001 /* ProfileManager.swift in Sources */, + AA0009022F22000900000001 /* ProfileConnectionCache.swift in Sources */, A1B2C3D72EEDF503001A2B3C /* ConfigurationProvider.swift in Sources */, 44F3E3932EE2151100C87FEC /* NetworkExtensionAdapter.swift in Sources */, 44F3E3942EE2151100C87FEC /* ConnectionListener.swift in Sources */, @@ -1069,6 +1097,8 @@ 50245A572A80431C0034792B /* PacketTunnelProvider.swift in Sources */, 505118D12AD96ECA003027D3 /* key.c in Sources */, 50213A2D2A8D0AA30031D993 /* Preferences.swift in Sources */, + AA0001032F22000100000001 /* ProfileManager.swift in Sources */, + AA0009032F22000900000001 /* ProfileConnectionCache.swift in Sources */, 50CD81A82AD5504B00CF830B /* StatusDetails.swift in Sources */, F1B2920B2EE0BC46001D91B8 /* GlobalConstants.swift in Sources */, A1C3D5EB2F000005001A2B3C /* WiFiOnDemandPolicy.swift in Sources */, @@ -1119,6 +1149,8 @@ 50E608242A79966600BAF09B /* AboutView.swift in Sources */, 50CD81622AD0595E00CF830B /* DNSManager.swift in Sources */, 50216D892ACB18EE009574C9 /* Preferences.swift in Sources */, + AA0001042F22000100000001 /* ProfileManager.swift in Sources */, + AA0009042F22000900000001 /* ProfileConnectionCache.swift in Sources */, A1B2C3D62EEDF502001A2B3C /* ConfigurationProvider.swift in Sources */, 50CD81B02AD5B94D00CF830B /* PeerCard.swift in Sources */, B1A2C3D42F3A000100000001 /* PeerDetailSheet.swift in Sources */, @@ -1134,6 +1166,10 @@ 962925F1DAA24D40B98D395B /* iOSPeersView.swift in Sources */, 71DD928A21BE4943BB0FEC1D /* iOSNetworksView.swift in Sources */, 0C71C019350D42AB8F110336 /* iOSSettingsView.swift in Sources */, + AA0002052F22000200000001 /* ProfileBadge.swift in Sources */, + AA0002062F22000200000001 /* ProfilesListView.swift in Sources */, + AA0002072F22000200000001 /* AddProfileSheet.swift in Sources */, + AA0002082F22000200000001 /* AddProfileViewModel.swift in Sources */, 4849965EC2515950756C8F10 /* VPNOnDemandView.swift in Sources */, F2DCB2437ECA42C3AEC3C895 /* FirstLaunchView.swift in Sources */, ); diff --git a/NetBird/Source/App/ViewModels/AddProfileViewModel.swift b/NetBird/Source/App/ViewModels/AddProfileViewModel.swift new file mode 100644 index 0000000..cadffa1 --- /dev/null +++ b/NetBird/Source/App/ViewModels/AddProfileViewModel.swift @@ -0,0 +1,83 @@ +// +// AddProfileViewModel.swift +// NetBird +// + +import Foundation + +#if os(iOS) + +@MainActor +class AddProfileViewModel: ObservableObject { + @Published var isLoading = false + @Published var isSuccess = false + @Published var profileError: String? + @Published var urlError: String? + @Published var setupKeyError: String? + @Published var generalError: String? + @Published var ssoNotSupportedError: String? + + private let defaultManagementServerUrl = "https://api.netbird.io" + + func create(name: String, serverUrl: String, setupKey: String) { + clearErrors() + + // 1. Create profile directory + do { + try ProfileManager.shared.addProfile(name) + } catch { + profileError = error.localizedDescription + return + } + + // 2. Get config path for the new profile + guard let configPath = ProfileManager.shared.configPath(for: name) else { + profileError = "Unable to access profile directory" + try? ProfileManager.shared.removeProfile(name) + return + } + + // 3. Configure the management server for this profile + let serverVM = ServerViewModel(configurationFilePath: configPath, deviceName: Device.getName()) + let trimmed = serverUrl.trimmingCharacters(in: .whitespacesAndNewlines) + var urlComponents = URLComponents(string: trimmed) + if let scheme = urlComponents?.scheme { urlComponents?.scheme = scheme.lowercased() } + if let host = urlComponents?.host { urlComponents?.host = host.lowercased() } + let trimmedUrl = urlComponents?.string ?? trimmed + let finalUrl = trimmedUrl.isEmpty ? defaultManagementServerUrl : trimmedUrl + let key = setupKey.trimmingCharacters(in: .whitespacesAndNewlines) + + isLoading = true + + Task { + if !key.isEmpty { + await serverVM.loginWithSetupKey(managementServerUrl: finalUrl, setupKey: key) + } else { + await serverVM.changeManagementServerAddress(managementServerUrl: finalUrl) + } + + isLoading = false + + if serverVM.isOperationSuccessful { + isSuccess = true + } else { + // Surface errors and rollback profile creation + urlError = serverVM.viewErrors.urlError + setupKeyError = serverVM.viewErrors.setupKeyError + generalError = serverVM.viewErrors.generalError + ssoNotSupportedError = serverVM.viewErrors.ssoNotSupportedError + try? ProfileManager.shared.removeProfile(name) + } + } + } + + func clearErrors() { + profileError = nil + urlError = nil + setupKeyError = nil + generalError = nil + ssoNotSupportedError = nil + } +} + +#endif diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index b626767..0ddf9ce 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -77,7 +77,12 @@ class ViewModel: ObservableObject { @Published var showIpCopiedAlert = false @Published var showAuthenticationRequired = false @Published var navigateToServerView = false - + @Published var navigateToProfilesView = false + + #if os(iOS) + @Published var activeProfileName: String = ProfileManager.shared.getActiveProfileName() + #endif + @Published var extensionState: NEVPNStatus = .disconnected @Published var managementStatus: ClientState = .disconnected @Published var statusDetailsValid = false @@ -93,8 +98,8 @@ class ViewModel: ObservableObject { @Published var setupKey: String = "" @Published var presharedKeySecure = true - @Published var fqdn = UserDefaults.standard.string(forKey: "fqdn") ?? "" - @Published var ip = UserDefaults.standard.string(forKey: "ip") ?? "" + @Published var fqdn = "" + @Published var ip = "" // Debug @Published var traceLogsEnabled: Bool { @@ -129,6 +134,28 @@ class ViewModel: ObservableObject { var buttonLock = false let defaults = UserDefaults.standard + // MARK: - Per-profile connection info + + #if os(iOS) + private let profileConnectionCache = ProfileConnectionCache() + #endif + + /// While true the polling timer must not overwrite ip/fqdn/peers. + /// Set when switching profiles; cleared once the extension fully + /// disconnects and then reconnects to the new profile. + private var profileSwitchPending = false + private var previousExtensionState: NEVPNStatus = .disconnected + + /// Loads cached ip/fqdn for the given profile into the published properties. + /// Shows empty strings if no data has been saved for that profile yet. + func loadConnectionInfoForProfile(_ profileName: String) { + #if os(iOS) + let entry = profileConnectionCache.entry(for: profileName) + ip = entry?.ip ?? "" + fqdn = entry?.fqdn ?? "" + #endif + } + private var cancellables = Set() private let networkMonitor = NWPathMonitor() private let monitorQueue = DispatchQueue(label: "io.netbird.networkMonitor") @@ -144,6 +171,15 @@ class ViewModel: ObservableObject { self.peerViewModel = PeerViewModel() self.routeViewModel = RoutesViewModel(networkExtensionAdapter: networkExtensionAdapter) + // Load cached connection info for the active profile + #if os(iOS) + let activeProfile = ProfileManager.shared.getActiveProfileName() + let cache = ProfileConnectionCache() + let cached = cache.entry(for: activeProfile) + self.ip = cached?.ip ?? "" + self.fqdn = cached?.fqdn ?? "" + #endif + // 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() @@ -211,6 +247,13 @@ class ViewModel: ObservableObject { self.logger.info("connect: Task started, calling networkExtensionAdapter.start()") await self.networkExtensionAdapter.start() self.logger.info("connect: networkExtensionAdapter.start() completed") + // If start() returned but VPN never launched (e.g. IPC failed to get login URL) + // and the browser login sheet is not showing, the tunnel won't start on its own. + // Reset the stuck "Connecting..." state so the user can try again. + if self.extensionState == .disconnected && !self.networkExtensionAdapter.showBrowser { + self.connectPressed = false + self.updateVPNDisplayState() + } } } @@ -435,52 +478,56 @@ class ViewModel: ObservableObject { self.checkExtensionState() self.checkNetworkUnavailableFlag() self.checkLoginRequiredFlag() - self.updateDetailsIfChanged(details) - self.updatePeersIfChanged(details) - self.statusDetailsValid = true - } - } - private func updateDetailsIfChanged(_ details: StatusDetails) { - let ipChanged = details.ip != ip - let fqdnChanged = details.fqdn != fqdn - let statusChanged = details.managementStatus != managementStatus + let currentState = self.extensionState - guard ipChanged || fqdnChanged || statusChanged else { return } + // Detect reconnection after a profile switch: + // the guard lifts only once the extension has gone through a + // non-connected state and then comes back as .connected. + if self.profileSwitchPending { + if self.previousExtensionState != .connected && currentState == .connected { + self.profileSwitchPending = false + } + self.previousExtensionState = currentState + } - if ipChanged { - defaults.set(details.ip, forKey: "ip") - ip = details.ip - } - if fqdnChanged { - defaults.set(details.fqdn, forKey: "fqdn") - fqdn = details.fqdn - } - if statusChanged { - managementStatus = details.managementStatus - updateVPNDisplayState() - } else if ipChanged || fqdnChanged { - #if os(iOS) - updateWidgetState() - #endif - } - } + if currentState == .disconnected && self.vpnDisplayState == .connected { + self.showAuthenticationRequired = true + self.updateVPNDisplayState() + } - private func updatePeersIfChanged(_ details: StatusDetails) { - let sorted = details.peerInfo.sorted { $0.ip < $1.ip } - let current = peerViewModel.peerInfo + // Only update ip/fqdn/peers when the extension is connected + // AND we are not mid-profile-switch (guard ensures we don't + // overwrite the new profile's cached data with the old tunnel's values). + if !self.profileSwitchPending && currentState == .connected { + let newFqdn = details.fqdn.isEmpty ? self.fqdn : details.fqdn + let newIp = details.ip.isEmpty ? self.ip : details.ip + let changed = newFqdn != self.fqdn || newIp != self.ip + if changed { + self.fqdn = newFqdn + self.ip = newIp + #if os(iOS) + let profile = ProfileManager.shared.getActiveProfileName() + self.profileConnectionCache.save(ip: newIp, fqdn: newFqdn, for: profile) + #endif + } - let changed = sorted.count != current.count || !sorted.elementsEqual(current) { 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 - } + let sortedPeerInfo = details.peerInfo.sorted { $0.ip < $1.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 + } + } - if changed { - peerViewModel.peerInfo = sorted + if details.managementStatus != self.managementStatus { + print("Status: \(details.managementStatus) - Extension: \(currentState)") + self.managementStatus = details.managementStatus + self.updateVPNDisplayState() + } + + self.statusDetailsValid = true } } @@ -625,6 +672,24 @@ class ViewModel: ObservableObject { // Sync @Published properties with reloaded config values loadRosenpassSettings() } + + /// Switches connection display data to the given profile's cached values. + /// Call this when switching profiles so the new profile's last known info is shown immediately. + func switchConnectionInfo(to profileName: String) { + // Load cached data for the target profile so the UI shows it right away. + loadConnectionInfoForProfile(profileName) + peerViewModel.peerInfo = [] + managementStatus = .disconnected + updateVPNDisplayState() + // Block polling from overwriting the new profile's data until the extension + // has fully disconnected and reconnected to the new profile. + // Seed previousExtensionState with the CURRENT extension state so the guard + // only fires on a genuine non-connected → connected transition. + // (Setting it to .disconnected would falsely trigger on the very next tick + // while the old tunnel is still connected.) + profileSwitchPending = true + previousExtensionState = extensionState + } func setForcedRelayConnection(isEnabled: Bool) { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) diff --git a/NetBird/Source/App/Views/Components/SafariView.swift b/NetBird/Source/App/Views/Components/SafariView.swift index c881844..f415458 100644 --- a/NetBird/Source/App/Views/Components/SafariView.swift +++ b/NetBird/Source/App/Views/Components/SafariView.swift @@ -2,59 +2,99 @@ // SafariView.swift // NetBird // -// iOS-only: Wraps SFSafariViewController for in-app web authentication. +// iOS-only: Wraps ASWebAuthenticationSession for in-app web authentication. +// Uses ephemeral session so each login starts fresh (no shared cookies), +// which is required for multi-profile support. // import SwiftUI // Safari is only available on iOS #if os(iOS) -import SafariServices +import AuthenticationServices struct SafariView: UIViewControllerRepresentable { @Binding var isPresented: Bool let url: URL let didFinish: () -> Void - func makeUIViewController(context: Context) -> SFSafariViewController { - let controller = SFSafariViewController(url: url) - controller.delegate = context.coordinator - return controller + func makeUIViewController(context: Context) -> UIViewController { + let vc = UIViewController() + // Start the auth session after the VC is presented + DispatchQueue.main.async { + context.coordinator.startSession(from: vc) + } + return vc } - func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {} + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } - class Coordinator: NSObject, SFSafariViewControllerDelegate { + class Coordinator: NSObject, ASWebAuthenticationPresentationContextProviding { let parent: SafariView + private var session: ASWebAuthenticationSession? init(_ parent: SafariView) { self.parent = parent } - func safariViewController(_ controller: SFSafariViewController, initialLoadDidRedirectTo URL: URL) { - print("Url is: \(URL.absoluteString)") - if isSuccessURL(URL.absoluteString) { - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + func startSession(from viewController: UIViewController) { + // The NetBird SDK uses a PKCE flow with an http://localhost redirect URI. + // ASWebAuthenticationSession intercepts that navigation before the browser + // follows it, so "http" works as a callback scheme in practice. + // A proper long-term fix requires the SDK to expose a custom-scheme + // redirect URI (e.g. "netbird://") for mobile OAuth flows. + let completionHandler: ASWebAuthenticationSession.CompletionHandler = { [weak self] callbackURL, error in + guard let self else { return } + + DispatchQueue.main.async { + if let callbackURL = callbackURL { + print("Auth callback URL: \(callbackURL.absoluteString)") + } + if let error = error as? ASWebAuthenticationSessionError, + error.code == .canceledLogin { + print("User cancelled login") + } self.parent.isPresented = false self.parent.didFinish() } } + + let session: ASWebAuthenticationSession + if #available(iOS 17.4, *) { + session = ASWebAuthenticationSession( + url: parent.url, + callback: .customScheme("http"), + completionHandler: completionHandler + ) + } else { + session = ASWebAuthenticationSession( + url: parent.url, + callbackURLScheme: "http", + completionHandler: completionHandler + ) + } + + // Ephemeral = no shared cookies, fresh login every time + session.prefersEphemeralWebBrowserSession = true + session.presentationContextProvider = self + self.session = session + session.start() } - - func safariViewControllerDidFinish(_ controller: SFSafariViewController) { - parent.isPresented = false - parent.didFinish() - } - - func isSuccessURL(_ string: String) -> Bool { - if string.isEmpty { return true } - let pattern = "^(http|https)://(localhost:53000/\\?code=.*|[a-zA-Z0-9.-]+/device/success)$" - let isMatch = string.range(of: pattern, options: .regularExpression, range: nil, locale: nil) != nil - return isMatch + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + guard let keyWindow = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap({ $0.windows }) + .first(where: { $0.isKeyWindow }) + else { + assertionFailure("No key window found — auth session may fail to present") + return UIWindow() + } + return keyWindow } } } diff --git a/NetBird/Source/App/Views/MainView.swift b/NetBird/Source/App/Views/MainView.swift index b1bbc7e..06398f7 100644 --- a/NetBird/Source/App/Views/MainView.swift +++ b/NetBird/Source/App/Views/MainView.swift @@ -101,6 +101,16 @@ struct iOSMainView: View { dismissButton: .default(Text("OK")) ) case .authenticationRequired: + if viewModel.connectOnDemand { + return Alert( + title: Text("Authentication required"), + message: Text("The server requires a new authentication."), + primaryButton: .default(Text("Connect")) { + viewModel.connect() + }, + secondaryButton: .cancel(Text("Later")) + ) + } return Alert( title: Text("Authentication required"), message: Text("The server requires a new authentication."), diff --git a/NetBird/Source/App/Views/ServerView.swift b/NetBird/Source/App/Views/ServerView.swift index f25255a..35e50a8 100644 --- a/NetBird/Source/App/Views/ServerView.swift +++ b/NetBird/Source/App/Views/ServerView.swift @@ -23,7 +23,18 @@ struct ServerView: View { var body: some View { Form { - Section(header: Text("Server")) { + Section( + header: Text("Server"), + footer: HStack(spacing: 6) { + Text("Current:") + .foregroundColor(Color("TextSecondary")) + Text(ProfileManager.shared.managementURL(for: ProfileManager.shared.getActiveProfileName()) ?? defaultManagementServerUrl) + .foregroundColor(Color("TextPrimary")) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .font(.footnote) + ) { ZStack(alignment: .leading) { TextField("https://example-api.domain.com:443", text: $managementServerUrl) .foregroundColor(Color("TextPrimary")) @@ -150,7 +161,11 @@ struct ServerView: View { return } - var serverUrl = managementServerUrl.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let rawUrl = managementServerUrl.trimmingCharacters(in: .whitespacesAndNewlines) + var urlComponents = URLComponents(string: rawUrl) + if let scheme = urlComponents?.scheme { urlComponents?.scheme = scheme.lowercased() } + if let host = urlComponents?.host { urlComponents?.host = host.lowercased() } + var serverUrl = urlComponents?.string ?? rawUrl if serverUrl.isEmpty { serverUrl = defaultManagementServerUrl } diff --git a/NetBird/Source/App/Views/iOS/AddProfileSheet.swift b/NetBird/Source/App/Views/iOS/AddProfileSheet.swift new file mode 100644 index 0000000..6cb3577 --- /dev/null +++ b/NetBird/Source/App/Views/iOS/AddProfileSheet.swift @@ -0,0 +1,171 @@ +// +// AddProfileSheet.swift +// NetBird +// + +import SwiftUI + +#if os(iOS) + +struct AddProfileSheet: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var addVM = AddProfileViewModel() + + @State private var profileName = "" + @State private var managementServerUrl = "" + @State private var setupKey = "" + @State private var showSetupKeyField = false + @State private var showNameValidationAlert = false + + var onCreated: (() -> Void)? + + private let defaultManagementServerUrl = "https://api.netbird.io" + + private var isNameValid: Bool { + !profileName.isEmpty && profileName.range(of: "^[a-zA-Z0-9_-]+$", options: .regularExpression) != nil + } + + var body: some View { + NavigationView { + List { + // Profile name + Section { + TextField("Profile name", text: $profileName) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .disabled(addVM.isLoading) + } header: { + Text("Profile") + } footer: { + if let error = addVM.profileError { + Text(error) + .foregroundColor(.red) + } else { + Text("Only letters, numbers, underscores and hyphens allowed") + .foregroundColor(Color("TextSecondary")) + } + } + + // Server URL + Section(header: Text("Server")) { + TextField("https://api.netbird.io", text: $managementServerUrl) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.URL) + .disabled(addVM.isLoading) + .onChange(of: managementServerUrl) { _ in + addVM.urlError = nil + addVM.generalError = nil + addVM.ssoNotSupportedError = nil + } + if let error = addVM.urlError { + Text(error).foregroundColor(.red).font(.footnote) + } + if let error = addVM.generalError { + Text(error).foregroundColor(.red).font(.footnote) + } + } + + // Setup key (optional) + Section { + DisclosureGroup("Add this device with a setup key", isExpanded: $showSetupKeyField) { + TextField("0EF79C2F-DEE1-419B-BFC8-1BF529332998", text: $setupKey) + .disableAutocorrection(true) + .autocapitalization(.allCharacters) + .disabled(addVM.isLoading) + .onChange(of: setupKey) { _ in + addVM.setupKeyError = nil + } + if let error = addVM.setupKeyError { + Text(error).foregroundColor(.red).font(.footnote) + } + if let error = addVM.ssoNotSupportedError { + Text(error).foregroundColor(.red).font(.footnote) + } + Text("Using setup keys for user devices is not recommended. SSO with MFA provides stronger security, proper user-device association, and periodic re-authentication.") + .font(.footnote) + .foregroundColor(.accentColor) + .padding(.vertical, 4) + } + } + .onChange(of: showSetupKeyField) { expanded in + if !expanded { + setupKey = "" + addVM.setupKeyError = nil + } + } + + // Use NetBird server shortcut + Section { + Button { + managementServerUrl = defaultManagementServerUrl + } label: { + HStack { + Spacer() + Image("icon-netbird-button") + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + Text("Use NetBird server") + Spacer() + } + } + .disabled(addVM.isLoading) + } + } + .listStyle(.insetGrouped) + .navigationTitle("New Profile") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + .foregroundColor(.accentColor) + .disabled(addVM.isLoading) + } + ToolbarItem(placement: .navigationBarTrailing) { + if addVM.isLoading { + ProgressView() + } else { + Button("Create") { + guard isNameValid else { + showNameValidationAlert = true + return + } + addVM.create( + name: profileName, + serverUrl: managementServerUrl, + setupKey: setupKey + ) + } + .foregroundColor(.accentColor) + } + } + } + .alert("Invalid Profile Name", isPresented: $showNameValidationAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("Only letters, numbers, underscores and hyphens are allowed.") + } + .onChange(of: addVM.ssoNotSupportedError) { error in + if error != nil { + showSetupKeyField = true + } + } + .onChange(of: addVM.isSuccess) { success in + if success { + onCreated?() + dismiss() + } + } + } + .navigationViewStyle(.stack) + } +} + +#Preview { + AddProfileSheet() +} + +#endif diff --git a/NetBird/Source/App/Views/iOS/ProfileBadge.swift b/NetBird/Source/App/Views/iOS/ProfileBadge.swift new file mode 100644 index 0000000..6b1954f --- /dev/null +++ b/NetBird/Source/App/Views/iOS/ProfileBadge.swift @@ -0,0 +1,37 @@ +// +// ProfileBadge.swift +// NetBird +// + +import SwiftUI + +#if os(iOS) + +struct ProfileBadge: View { + let profileName: String + var onTap: (() -> Void)? + + var body: some View { + Button { + onTap?() + } label: { + HStack(spacing: 6) { + Image(systemName: "person.fill") + .font(.caption2) + Text(profileName) + .font(.caption.bold()) + } + .foregroundColor(.accentColor) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.accentColor.opacity(0.15)) + .clipShape(Capsule()) + } + } +} + +#Preview { + ProfileBadge(profileName: "work") +} + +#endif diff --git a/NetBird/Source/App/Views/iOS/ProfilesListView.swift b/NetBird/Source/App/Views/iOS/ProfilesListView.swift new file mode 100644 index 0000000..d548db8 --- /dev/null +++ b/NetBird/Source/App/Views/iOS/ProfilesListView.swift @@ -0,0 +1,217 @@ +// +// ProfilesListView.swift +// NetBird +// + +import SwiftUI + +#if os(iOS) + +struct ProfilesListView: View { + @EnvironmentObject var viewModel: ViewModel + @State private var profiles: [Profile] = [] + @State private var showAddSheet = false + @State private var showSwitchAlert = false + @State private var showRemoveAlert = false + @State private var showLogoutAlert = false + @State private var showErrorAlert = false + @State private var errorMessage = "" + @State private var selectedProfile: Profile? + + private var activeProfile: Profile? { + profiles.first(where: { $0.isActive }) + } + + private var inactiveProfiles: [Profile] { + profiles.filter { !$0.isActive } + } + + var body: some View { + List { + if let active = activeProfile { + Section("Active") { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(active.name) + .font(.body.bold()) + .foregroundColor(Color("TextPrimary")) + if let url = ProfileManager.shared.managementURL(for: active.name) { + Text(url) + .font(.footnote) + .foregroundColor(Color("TextSecondary")) + } + } + Spacer() + Text("Active") + .font(.caption2.bold()) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.green) + .clipShape(Capsule()) + } + } + } + + Section("All Profiles") { + if inactiveProfiles.isEmpty { + VStack(spacing: 8) { + Image(systemName: "person.2.slash") + .font(.title2) + .foregroundColor(Color("TextSecondary")) + Text("No Additional Profiles") + .font(.subheadline.bold()) + .foregroundColor(Color("TextPrimary")) + Text("Tap + to add a new profile") + .font(.footnote) + .foregroundColor(Color("TextSecondary")) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } else { + ForEach(inactiveProfiles) { profile in + Button { + selectedProfile = profile + showSwitchAlert = true + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(profile.name) + .font(.body) + .foregroundColor(Color("TextPrimary")) + if let url = ProfileManager.shared.managementURL(for: profile.name) { + Text(url) + .font(.footnote) + .foregroundColor(Color("TextSecondary")) + } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if profile.name != "default" { + Button(role: .destructive) { + selectedProfile = profile + showRemoveAlert = true + } label: { + Label("Remove", systemImage: "trash") + } + } + + Button { + selectedProfile = profile + showLogoutAlert = true + } label: { + Label("Logout", systemImage: "rectangle.portrait.and.arrow.right") + } + .tint(.gray) + } + } + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Profiles") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showAddSheet = true + } label: { + Image(systemName: "plus") + .foregroundColor(.accentColor) + } + } + } + .onAppear { + loadProfiles() + } + .sheet(isPresented: $showAddSheet) { + AddProfileSheet { + loadProfiles() + } + } + .alert("Switch Profile", isPresented: $showSwitchAlert, presenting: selectedProfile) { profile in + Button("Cancel", role: .cancel) {} + Button("Switch", role: .destructive) { + switchToProfile(profile) + } + } message: { profile in + Text("VPN will be disconnected to switch to \u{00AB}\(profile.name)\u{00BB}. Continue?") + } + .alert("Remove Profile", isPresented: $showRemoveAlert, presenting: selectedProfile) { profile in + Button("Cancel", role: .cancel) {} + Button("Remove", role: .destructive) { + removeProfile(profile) + } + } message: { profile in + Text("Profile \u{00AB}\(profile.name)\u{00BB} and all its data will be deleted. This action cannot be undone.") + } + .alert("Logout from Profile", isPresented: $showLogoutAlert, presenting: selectedProfile) { profile in + Button("Cancel", role: .cancel) {} + Button("Logout", role: .destructive) { + logoutProfile(profile) + } + } message: { profile in + Text("You will need to re-authenticate to use profile \u{00AB}\(profile.name)\u{00BB} again.") + } + .alert("Error", isPresented: $showErrorAlert) { + Button("OK") {} + } message: { + Text(errorMessage) + } + } + + // MARK: - Actions + + private func loadProfiles() { + profiles = ProfileManager.shared.listProfiles() + } + + private func switchToProfile(_ profile: Profile) { + viewModel.performClose() + + do { + try ProfileManager.shared.switchProfile(profile.name) + viewModel.switchConnectionInfo(to: profile.name) + viewModel.reloadConfiguration() + viewModel.activeProfileName = ProfileManager.shared.getActiveProfileName() + if let url = ProfileManager.shared.managementURL(for: profile.name) { + Preferences.saveManagementURL(url) + } + loadProfiles() + } catch { + errorMessage = error.localizedDescription + showErrorAlert = true + } + } + + private func removeProfile(_ profile: Profile) { + do { + try ProfileManager.shared.removeProfile(profile.name) + loadProfiles() + } catch { + errorMessage = error.localizedDescription + showErrorAlert = true + } + } + + private func logoutProfile(_ profile: Profile) { + if profile.isActive { + viewModel.performClose() + } + do { + try ProfileManager.shared.logoutProfile(profile.name) + loadProfiles() + } catch { + errorMessage = error.localizedDescription + showErrorAlert = true + } + } +} + +#Preview { + NavigationView { + ProfilesListView() + .environmentObject(ViewModel()) + } +} + +#endif diff --git a/NetBird/Source/App/Views/iOS/iOSConnectionView.swift b/NetBird/Source/App/Views/iOS/iOSConnectionView.swift index e2df908..043730a 100644 --- a/NetBird/Source/App/Views/iOS/iOSConnectionView.swift +++ b/NetBird/Source/App/Views/iOS/iOSConnectionView.swift @@ -47,6 +47,11 @@ struct iOSConnectionView: View { // FQDN + IP + internet status VStack { + ProfileBadge(profileName: viewModel.activeProfileName) { + viewModel.navigateToProfilesView = true + } + .padding(.top, 8) + Text(fqdnCopied ? "Copied" : viewModel.fqdn) .foregroundColor(Color("TextPrimary")) .font(.system(size: 20, weight: .regular)) @@ -139,6 +144,10 @@ struct iOSConnectionView: View { .animation(.easeInOut(duration: 0.3), value: viewModel.isInternetConnected) } + // Hidden NavigationLink for ProfilesView + NavigationLink("", destination: ProfilesListView(), isActive: $viewModel.navigateToProfilesView) + .hidden() + // Hidden NavigationLink for ServerView NavigationLink("", destination: ServerView(), isActive: $viewModel.navigateToServerView) .hidden() diff --git a/NetBird/Source/App/Views/iOS/iOSSettingsView.swift b/NetBird/Source/App/Views/iOS/iOSSettingsView.swift index d227355..f8c4d4b 100644 --- a/NetBird/Source/App/Views/iOS/iOSSettingsView.swift +++ b/NetBird/Source/App/Views/iOS/iOSSettingsView.swift @@ -14,6 +14,24 @@ struct iOSSettingsView: View { var body: some View { List { + Section { + NavigationLink { + ProfilesListView() + } label: { + HStack { + Image(systemName: "person.2.fill") + .foregroundColor(.accentColor) + .frame(width: 24) + Text("Profiles") + .foregroundColor(Color("TextPrimary")) + Spacer() + Text(viewModel.activeProfileName) + .foregroundColor(Color("TextSecondary")) + .font(.system(size: 14)) + } + } + } + Section(header: Text("Connection")) { Button { viewModel.showChangeServerAlert = true diff --git a/NetbirdKit/ConfigurationProvider.swift b/NetbirdKit/ConfigurationProvider.swift index dbe3290..7bc2977 100644 --- a/NetbirdKit/ConfigurationProvider.swift +++ b/NetbirdKit/ConfigurationProvider.swift @@ -115,7 +115,14 @@ final class iOSConfigurationProvider: ConfigurationProvider { } func reload() { - // Recreate preferences to pick up new config file after server change + // Only recreate preferences if the config file exists. + // If the config was deleted by a logout, NetBirdSDKNewPreferences would create + // a new file with the default server URL (api.netbird.io), overwriting any + // saved custom server URL in netbird_server_url. + guard let configPath = Preferences.configFile(), + FileManager.default.fileExists(atPath: configPath) else { + return + } self.preferences = Preferences.newPreferences() } } diff --git a/NetbirdKit/GlobalConstants.swift b/NetbirdKit/GlobalConstants.swift index 1f7ec67..9834ed4 100644 --- a/NetbirdKit/GlobalConstants.swift +++ b/NetbirdKit/GlobalConstants.swift @@ -28,4 +28,5 @@ struct GlobalConstants { static let configFileName = "netbird.cfg" static let stateFileName = "state.json" + static let serverURLFileName = "netbird_server_url" } diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index 8a34451..bcdce05 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -79,6 +79,12 @@ public class NetworkExtensionAdapter: ObservableObject { 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 { @@ -87,6 +93,54 @@ public class NetworkExtensionAdapter: ObservableObject { 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 }) { @@ -376,7 +430,18 @@ public class NetworkExtensionAdapter: ObservableObject { 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] + 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!") @@ -552,7 +617,20 @@ public class NetworkExtensionAdapter: ObservableObject { #if os(tvOS) let messageString = "LoginTV" #else - let messageString = "Login" + // 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:|[|]" + 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) { @@ -589,7 +667,7 @@ public class NetworkExtensionAdapter: ObservableObject { 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 diff --git a/NetbirdKit/Preferences.swift b/NetbirdKit/Preferences.swift index f663c3b..55a0eb5 100644 --- a/NetbirdKit/Preferences.swift +++ b/NetbirdKit/Preferences.swift @@ -28,7 +28,7 @@ class Preferences { // MARK: - SDK Preferences #if os(iOS) - /// Creates SDK preferences using App Group shared container paths. + /// Creates SDK preferences using the active profile's config/state paths. /// iOS only - file-based storage works reliably. static func newPreferences() -> NetBirdSDKPreferences { guard let configPath = configFile(), let statePath = stateFile() else { @@ -72,11 +72,21 @@ class Preferences { } static func configFile() -> String? { + #if os(iOS) + // Use profile-aware paths on iOS + return ProfileManager.shared.activeConfigPath() + #else return getFilePath(fileName: GlobalConstants.configFileName) + #endif } static func stateFile() -> String? { + #if os(iOS) + // Use profile-aware paths on iOS + return ProfileManager.shared.activeStatePath() + #else return getFilePath(fileName: GlobalConstants.stateFileName) + #endif } // MARK: - App-Local UserDefaults Storage diff --git a/NetbirdKit/ProfileConnectionCache.swift b/NetbirdKit/ProfileConnectionCache.swift new file mode 100644 index 0000000..117b573 --- /dev/null +++ b/NetbirdKit/ProfileConnectionCache.swift @@ -0,0 +1,90 @@ +// +// ProfileConnectionCache.swift +// NetBird +// + +import Foundation + +// MARK: - Model + +struct ProfileConnectionEntry: Codable, Equatable { + var ip: String + var fqdn: String + var managementURL: String? +} + +// MARK: - Cache + +/// Stores and retrieves last-known connection data (ip/fqdn/managementURL) per profile. +/// Persisted as a JSON-encoded dictionary under a single UserDefaults key. +struct ProfileConnectionCache { + + private static let storageKey = "netbird_profiles_connection_data" + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + // MARK: - Read + + func entry(for profile: String) -> ProfileConnectionEntry? { + return load()[profile] + } + + func managementURL(for profile: String) -> String? { + return load()[profile]?.managementURL + } + + // MARK: - Write + + func save(ip: String, fqdn: String, for profile: String) { + var all = load() + var entry = all[profile] ?? ProfileConnectionEntry(ip: "", fqdn: "", managementURL: nil) + entry.ip = ip + entry.fqdn = fqdn + all[profile] = entry + persist(all) + } + + func saveManagementURL(_ url: String, for profile: String) { + var all = load() + var entry = all[profile] ?? ProfileConnectionEntry(ip: "", fqdn: "", managementURL: nil) + entry.managementURL = url + all[profile] = entry + persist(all) + } + + /// Clears ip/fqdn for a profile after logout, preserving managementURL for re-login. + func clearConnectionData(for profile: String) { + var all = load() + guard var entry = all[profile] else { return } + entry.ip = "" + entry.fqdn = "" + all[profile] = entry + persist(all) + } + + /// Removes all cached data for a deleted profile. + func remove(for profile: String) { + var all = load() + guard all[profile] != nil else { return } + all.removeValue(forKey: profile) + persist(all) + } + + // MARK: - Private + + private func load() -> [String: ProfileConnectionEntry] { + guard + let data = defaults.data(forKey: Self.storageKey), + let decoded = try? JSONDecoder().decode([String: ProfileConnectionEntry].self, from: data) + else { return [:] } + return decoded + } + + private func persist(_ entries: [String: ProfileConnectionEntry]) { + guard let data = try? JSONEncoder().encode(entries) else { return } + defaults.set(data, forKey: Self.storageKey) + } +} diff --git a/NetbirdKit/ProfileManager.swift b/NetbirdKit/ProfileManager.swift new file mode 100644 index 0000000..1c1d6d6 --- /dev/null +++ b/NetbirdKit/ProfileManager.swift @@ -0,0 +1,406 @@ +// +// ProfileManager.swift +// NetBird +// +// Native Swift implementation of multi-profile management. +// Mirrors the Go ProfileManager logic: each profile is a subdirectory +// containing its own netbird.cfg and state.json files. +// + +import Foundation + +// MARK: - Profile Model + +struct Profile: Identifiable, Equatable { + let name: String + let isActive: Bool + + var id: String { name } + + static func == (lhs: Profile, rhs: Profile) -> Bool { + lhs.name == rhs.name + } +} + +// MARK: - ProfileManager + +/// Manages multiple VPN profiles, each with its own config/state files. +/// +/// Directory layout inside the App Group container: +/// ``` +/// profiles/ +/// profiles.json ← stores active profile name +/// default/ +/// netbird.cfg +/// state.json +/// work/ +/// netbird.cfg +/// state.json +/// ``` +/// +/// The "default" profile always exists. Legacy (pre-profile) config files +/// at the container root are migrated into `profiles/default/` on first use. +class ProfileManager { + + static let shared = ProfileManager() + + private let fileManager = FileManager.default + + /// Name validation: only letters, digits, underscore, hyphen (matches Go client). + private static let validNamePattern = "^[a-zA-Z0-9_-]+$" + + private let defaultProfileName = "default" + private let profilesDirName = "profiles" + private let metaFileName = "profiles.json" + + // MARK: - Init + + private init() { + ensureProfilesDirectory() + migrateIfNeeded() + } + + // MARK: - Public API + + /// Returns all profiles with their active status. + func listProfiles() -> [Profile] { + let meta = readMeta() + let activeProfile = meta?.activeProfile.isEmpty == false ? meta!.activeProfile : defaultProfileName + let deletedSet = Set(meta?.deletedProfiles ?? []) + + // Retry deletion of any directories still present after a previous attempt. + for name in deletedSet { + if let dir = profileDirectory(for: name), fileManager.fileExists(atPath: dir) { + try? fileManager.removeItem(atPath: dir) + } + } + + guard let profilesDir = profilesDirectory() else { return [] } + + do { + let contents = try fileManager.contentsOfDirectory(atPath: profilesDir) + var profiles: [Profile] = [] + for name in contents.sorted() { + guard !deletedSet.contains(name) else { continue } + let fullPath = (profilesDir as NSString).appendingPathComponent(name) + var isDir: ObjCBool = false + if fileManager.fileExists(atPath: fullPath, isDirectory: &isDir), isDir.boolValue { + profiles.append(Profile(name: name, isActive: name == activeProfile)) + } + } + // Ensure default always appears even if directory listing is empty + if !profiles.contains(where: { $0.name == defaultProfileName }) { + profiles.insert(Profile(name: defaultProfileName, isActive: defaultProfileName == activeProfile), at: 0) + } + return profiles + } catch { + AppLogger.shared.log("ProfileManager: Failed to list profiles: \(error)") + return [Profile(name: defaultProfileName, isActive: true)] + } + } + + /// Name of the currently active profile. + func getActiveProfileName() -> String { + guard let meta = readMeta() else { return defaultProfileName } + return meta.activeProfile.isEmpty ? defaultProfileName : meta.activeProfile + } + + /// Adds a new profile. Throws if the name is invalid or already exists. + func addProfile(_ name: String) throws { + let sanitized = sanitizeName(name) + guard isValidName(sanitized) else { + throw ProfileError.invalidName(sanitized) + } + guard let dir = profileDirectory(for: sanitized) else { + throw ProfileError.containerUnavailable + } + + // If the profile was previously deleted but SDK goroutines recreated its directory, + // remove the stale directory and tombstone so the profile can be created fresh. + var meta = readMeta() ?? ProfileMeta(activeProfile: defaultProfileName) + if meta.deletedProfiles.contains(sanitized) { + try? fileManager.removeItem(atPath: dir) + meta.deletedProfiles.removeAll { $0 == sanitized } + try? writeMeta(meta) + } + + guard !fileManager.fileExists(atPath: dir) else { + throw ProfileError.alreadyExists(sanitized) + } + do { + try fileManager.createDirectory(atPath: dir, withIntermediateDirectories: true) + } catch { + throw ProfileError.fileSystemError(error) + } + } + + /// Switches the active profile. The caller must stop VPN before calling this. + func switchProfile(_ name: String) throws { + guard let dir = profileDirectory(for: name), fileManager.fileExists(atPath: dir) else { + throw ProfileError.notFound(name) + } + var meta = readMeta() ?? ProfileMeta(activeProfile: defaultProfileName) + meta.activeProfile = name + try writeMeta(meta) + } + + /// Removes a profile. Cannot remove "default" or the currently active profile. + func removeProfile(_ name: String) throws { + guard name != defaultProfileName else { + throw ProfileError.cannotRemoveDefault + } + guard name != getActiveProfileName() else { + throw ProfileError.cannotRemoveActive + } + guard let dir = profileDirectory(for: name), fileManager.fileExists(atPath: dir) else { + throw ProfileError.notFound(name) + } + + // Persist the tombstone BEFORE deleting the directory. + // The Go SDK may recreate the directory via background goroutines; the tombstone + // ensures the profile stays hidden in listProfiles() even if that happens. + var meta = readMeta() ?? ProfileMeta(activeProfile: defaultProfileName) + if !meta.deletedProfiles.contains(name) { + meta.deletedProfiles.append(name) + try writeMeta(meta) + } + + try fileManager.removeItem(atPath: dir) + ProfileConnectionCache().remove(for: name) + } + + /// Clears authentication data for a profile by removing its config and state files. + /// Both files must be removed: state.json holds runtime state, + /// netbird.cfg holds the auth tokens — removing only one is insufficient. + func logoutProfile(_ name: String) throws { + guard let dir = profileDirectory(for: name) else { + throw ProfileError.containerUnavailable + } + let statePath = (dir as NSString).appendingPathComponent(GlobalConstants.stateFileName) + let configPath = (dir as NSString).appendingPathComponent(GlobalConstants.configFileName) + + // Preserve the management URL for re-login, but clear stale connection data. + let cache = ProfileConnectionCache() + if let url = managementURL(for: name) { + cache.saveManagementURL(url, for: name) + } + cache.clearConnectionData(for: name) + + if fileManager.fileExists(atPath: statePath) { + try fileManager.removeItem(atPath: statePath) + } + if fileManager.fileExists(atPath: configPath) { + try fileManager.removeItem(atPath: configPath) + } + } + + // MARK: - Path Accessors + + /// Config file path for the active profile. + func activeConfigPath() -> String? { + guard let dir = profileDirectory(for: getActiveProfileName()) else { return nil } + return (dir as NSString).appendingPathComponent(GlobalConstants.configFileName) + } + + /// State file path for the active profile. + func activeStatePath() -> String? { + guard let dir = profileDirectory(for: getActiveProfileName()) else { return nil } + return (dir as NSString).appendingPathComponent(GlobalConstants.stateFileName) + } + + /// Config file path for a specific profile. + func configPath(for profile: String) -> String? { + guard let dir = profileDirectory(for: profile) else { return nil } + return (dir as NSString).appendingPathComponent(GlobalConstants.configFileName) + } + + /// State file path for a specific profile. + func statePath(for profile: String) -> String? { + guard let dir = profileDirectory(for: profile) else { return nil } + return (dir as NSString).appendingPathComponent(GlobalConstants.stateFileName) + } + + /// Returns the management URL for a specific profile. + /// Reads from netbird.cfg first; falls back to the dedicated server URL file, + /// then ProfileConnectionCache as last resort. + func managementURL(for profile: String) -> String? { + if let cfgPath = configPath(for: profile), + fileManager.fileExists(atPath: cfgPath), + let data = fileManager.contents(atPath: cfgPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + // ManagementURL can be a string or a nested object with Scheme/Host/Path + var urlFromFile: String? + if let urlString = json["ManagementURL"] as? String { + urlFromFile = urlString + } else if let urlObj = json["ManagementURL"] as? [String: Any], + let scheme = urlObj["Scheme"] as? String, + let host = urlObj["Host"] as? String { + let path = urlObj["Path"] as? String ?? "" + urlFromFile = "\(scheme)://\(host)\(path)" + } + if let url = urlFromFile { + // Persist to dedicated file and cache so it survives logout + saveServerURL(url, for: profile) + ProfileConnectionCache().saveManagementURL(url, for: profile) + return url + } + } + // Config missing (e.g. after logout) — try dedicated server URL file first + if let url = savedServerURL(for: profile) { + return url + } + return ProfileConnectionCache().managementURL(for: profile) + } + + /// Saves the management URL to a dedicated file inside the profile directory. + /// This file is NOT deleted by logoutProfile(), so it survives logout. + func saveServerURL(_ url: String, for profile: String) { + guard let dir = profileDirectory(for: profile) else { return } + let filePath = (dir as NSString).appendingPathComponent(GlobalConstants.serverURLFileName) + try? url.write(toFile: filePath, atomically: true, encoding: .utf8) + } + + /// Reads the management URL from the dedicated server URL file. + func savedServerURL(for profile: String) -> String? { + guard let dir = profileDirectory(for: profile) else { return nil } + let filePath = (dir as NSString).appendingPathComponent(GlobalConstants.serverURLFileName) + guard fileManager.fileExists(atPath: filePath), + let url = try? String(contentsOfFile: filePath, encoding: .utf8), + !url.isEmpty else { return nil } + return url + } + + // MARK: - Private Helpers + + private func containerURL() -> URL? { + fileManager.containerURL(forSecurityApplicationGroupIdentifier: GlobalConstants.userPreferencesSuiteName) + } + + private func profilesDirectory() -> String? { + guard let container = containerURL() else { return nil } + return container.appendingPathComponent(profilesDirName).path + } + + private func profileDirectory(for name: String) -> String? { + guard let profilesDir = profilesDirectory() else { return nil } + return (profilesDir as NSString).appendingPathComponent(name) + } + + private func metaFilePath() -> String? { + guard let profilesDir = profilesDirectory() else { return nil } + return (profilesDir as NSString).appendingPathComponent(metaFileName) + } + + private func ensureProfilesDirectory() { + guard let profilesDir = profilesDirectory() else { return } + if !fileManager.fileExists(atPath: profilesDir) { + try? fileManager.createDirectory(atPath: profilesDir, withIntermediateDirectories: true) + } + // Ensure default profile directory exists + guard let defaultDir = profileDirectory(for: defaultProfileName) else { return } + if !fileManager.fileExists(atPath: defaultDir) { + try? fileManager.createDirectory(atPath: defaultDir, withIntermediateDirectories: true) + } + } + + /// Migrates legacy config/state from the container root into profiles/default/. + private func migrateIfNeeded() { + guard let container = containerURL() else { return } + guard let defaultDir = profileDirectory(for: defaultProfileName) else { return } + + let legacyConfig = container.appendingPathComponent(GlobalConstants.configFileName).path + let legacyState = container.appendingPathComponent(GlobalConstants.stateFileName).path + let newConfig = (defaultDir as NSString).appendingPathComponent(GlobalConstants.configFileName) + let newState = (defaultDir as NSString).appendingPathComponent(GlobalConstants.stateFileName) + + // Only migrate if legacy files exist and new ones don't + if fileManager.fileExists(atPath: legacyConfig) && !fileManager.fileExists(atPath: newConfig) { + try? fileManager.copyItem(atPath: legacyConfig, toPath: newConfig) + AppLogger.shared.log("ProfileManager: Migrated legacy config to default profile") + } + if fileManager.fileExists(atPath: legacyState) && !fileManager.fileExists(atPath: newState) { + try? fileManager.copyItem(atPath: legacyState, toPath: newState) + AppLogger.shared.log("ProfileManager: Migrated legacy state to default profile") + } + + // Set default as active if no meta exists + if readMeta() == nil { + try? writeMeta(ProfileMeta(activeProfile: defaultProfileName)) + } + } + + private func isValidName(_ name: String) -> Bool { + !name.isEmpty && name.range(of: ProfileManager.validNamePattern, options: .regularExpression) != nil + } + + private func sanitizeName(_ name: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_-")) + return String(name.unicodeScalars.filter { allowed.contains($0) }) + } + + // MARK: - Meta File (profiles.json) + + private struct ProfileMeta: Codable { + var activeProfile: String + /// Profiles pending deletion — kept as a tombstone so that directories + /// recreated by SDK background goroutines don't reappear in the list. + var deletedProfiles: [String] + + init(activeProfile: String, deletedProfiles: [String] = []) { + self.activeProfile = activeProfile + self.deletedProfiles = deletedProfiles + } + + /// Backward-compatible decode: old profiles.json files have no deletedProfiles field. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + activeProfile = try c.decode(String.self, forKey: .activeProfile) + deletedProfiles = (try? c.decode([String].self, forKey: .deletedProfiles)) ?? [] + } + } + + private func readMeta() -> ProfileMeta? { + guard let path = metaFilePath(), + let data = fileManager.contents(atPath: path) else { return nil } + return try? JSONDecoder().decode(ProfileMeta.self, from: data) + } + + private func writeMeta(_ meta: ProfileMeta) throws { + guard let path = metaFilePath() else { + throw ProfileError.containerUnavailable + } + let data = try JSONEncoder().encode(meta) + try data.write(to: URL(fileURLWithPath: path), options: .atomic) + } +} + +// MARK: - Errors + +enum ProfileError: LocalizedError { + case invalidName(String) + case alreadyExists(String) + case notFound(String) + case cannotRemoveDefault + case cannotRemoveActive + case containerUnavailable + case fileSystemError(Error) + + var errorDescription: String? { + switch self { + case .invalidName(let name): + return "Invalid profile name: '\(name)'. Only letters, numbers, underscores and hyphens are allowed." + case .alreadyExists(let name): + return "Profile '\(name)' already exists." + case .notFound(let name): + return "Profile '\(name)' not found." + case .cannotRemoveDefault: + return "Cannot remove the default profile." + case .cannotRemoveActive: + return "Cannot remove the active profile. Switch to another profile first." + case .containerUnavailable: + return "App group container is unavailable." + case .fileSystemError(let error): + return "File system error: \(error.localizedDescription)" + } + } +} diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index c846af3..b22b807 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -252,7 +252,11 @@ public class NetBirdAdapter { /// - Parameter packetTunnelProvider: an instance of `NEPacketTunnelProvider`. Internally stored /// as a weak reference. /// - Returns: nil if the NetBird SDK client could not be initialized. - init?(with tunnelManager: PacketTunnelProviderSettingsManager) { + /// The config path this adapter was initialized with (iOS only). + /// Used to detect profile switches and force adapter reinitialization. + private(set) var initializedConfigPath: String? + + init?(with tunnelManager: PacketTunnelProviderSettingsManager, configPath: String? = nil, statePath: String? = nil) { self.tunnelManager = tunnelManager self.networkChangeListener = NetworkChangeListener(with: tunnelManager) self.dnsManager = DNSManager(with: tunnelManager) @@ -287,15 +291,18 @@ public class NetBirdAdapter { adapterLogger.info("init: tvOS - no config found, client initialized without config") } #else - guard let configPath = Preferences.configFile(), let statePath = Preferences.stateFile() else { + let resolvedConfigPath = configPath ?? Preferences.configFile() + let resolvedStatePath = statePath ?? Preferences.stateFile() + guard let resolvedConfigPath = resolvedConfigPath, let resolvedStatePath = resolvedStatePath else { adapterLogger.error("init: App group container unavailable - check entitlements") return nil } - guard let client = NetBirdSDKNewClient(configPath, statePath, deviceName, osVersion, osName, self.networkChangeListener, self.dnsManager) else { - adapterLogger.error("init: Failed to create NetBird SDK client with configPath=\(configPath), statePath=\(statePath)") + guard let client = NetBirdSDKNewClient(resolvedConfigPath, resolvedStatePath, deviceName, osVersion, osName, self.networkChangeListener, self.dnsManager) else { + adapterLogger.error("init: Failed to create NetBird SDK client with configPath=\(resolvedConfigPath), statePath=\(resolvedStatePath)") return nil } self.client = client + self.initializedConfigPath = resolvedConfigPath #endif } diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 7cbf4ad..380ee20 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -7,6 +7,7 @@ import NetworkExtension import Network +import NetBirdSDK import os import UserNotifications @@ -17,9 +18,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return PacketTunnelProviderSettingsManager(with: self) }() - private lazy var adapter: NetBirdAdapter? = { - return NetBirdAdapter(with: self.tunnelManager) - }() + private var adapter: NetBirdAdapter? var pathMonitor: NWPathMonitor? let monitorQueue = DispatchQueue(label: "NetworkMonitor") @@ -36,6 +35,22 @@ class PacketTunnelProvider: NEPacketTunnelProvider { initializeLogging(loglevel: logLevel) } + // Extract profile paths passed from the main app via startVPNTunnel(options:). + // If paths differ from what the current adapter was initialized with, recreate + // the adapter so it uses the correct profile's config and state files. + #if os(iOS) + let configPath = (options?["configPath"] as? String).flatMap { $0.isEmpty ? nil : $0 } + let statePath = (options?["statePath"] as? String).flatMap { $0.isEmpty ? nil : $0 } + if adapter == nil || (configPath != nil && configPath != adapter?.initializedConfigPath) { + AppLogger.shared.log("PacketTunnelProvider: (re)creating adapter for configPath=\(configPath ?? "default")") + adapter = NetBirdAdapter(with: tunnelManager, configPath: configPath, statePath: statePath) + } + #else + if adapter == nil { + adapter = NetBirdAdapter(with: tunnelManager) + } + #endif + monitorQueue.async { [weak self] in self?.currentNetworkType = nil self?.wasStoppedDueToNoNetwork = false @@ -106,6 +121,44 @@ class PacketTunnelProvider: NEPacketTunnelProvider { switch string { case "Login": login(completionHandler: completionHandler) + case let s where s.hasPrefix("Login:"): + // Format: "Login:|[|]" + let payload = String(s.dropFirst("Login:".count)) + let parts = payload.components(separatedBy: "|") + if parts.count >= 2 { + let configPath = parts[0] + let statePath = parts[1] + let managementURL = parts.count >= 3 ? parts[2] : nil + + // If the config file is missing (e.g. after logout) but we received + // the profile's management URL, write a minimal config in the Go SDK's + // url.URL nested-object format so the SDK uses the correct server instead + // of falling back to the default api.netbird.io. + var configRestored = false + if let url = managementURL, !url.isEmpty, + !FileManager.default.fileExists(atPath: configPath), + let parsedURL = URL(string: url) { + let scheme = parsedURL.scheme ?? "https" + var goHost = parsedURL.host ?? "" + if let port = parsedURL.port { goHost += ":\(port)" } + let path = parsedURL.path + func jsonEscape(_ s: String) -> String { + s.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + let minimalConfig = "{\"ManagementURL\":{\"Scheme\":\"\(jsonEscape(scheme))\",\"Host\":\"\(jsonEscape(goHost))\",\"Path\":\"\(jsonEscape(path))\"}}" + AppLogger.shared.log("handleAppMessage: config missing, writing minimal config for URL \(url)") + if (try? minimalConfig.write(toFile: configPath, atomically: true, encoding: .utf8)) != nil { + configRestored = true + } + } + + if configPath != adapter?.initializedConfigPath || configRestored { + AppLogger.shared.log("handleAppMessage: (re)creating adapter for \(configPath)") + adapter = NetBirdAdapter(with: tunnelManager, configPath: configPath, statePath: statePath) + } + } + login(completionHandler: completionHandler) case "Status": getStatus(completionHandler: completionHandler) case "GetRoutes":