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

* Create multi profiles

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

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

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

* Update project.pbxproj

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

* Update PacketTunnelProvider.swift

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

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

239 lines
6.7 KiB
Swift

//
// ConfigurationProvider.swift
// NetBird
//
// Protocol abstraction for platform-specific configuration management.
// iOS uses SDK file-based preferences, tvOS uses IPC-based config transfer.
//
import Foundation
import NetBirdSDK
// MARK: - Protocol Definition
/// Abstracts platform-specific configuration storage and retrieval.
/// - iOS: Uses NetBirdSDKPreferences with file-based storage in App Group container
/// - tvOS: Uses UserDefaults + IPC transfer (App Group files don't work between app/extension)
protocol ConfigurationProvider {
// MARK: - Rosenpass Settings
/// Whether Rosenpass (post-quantum encryption) is enabled
var rosenpassEnabled: Bool { get set }
/// Whether Rosenpass permissive mode is enabled (allows non-Rosenpass peers)
var rosenpassPermissive: Bool { get set }
// MARK: - Pre-Shared Key
/// The current pre-shared key (empty string if not set)
var preSharedKey: String { get set }
/// Whether a pre-shared key is configured
var hasPreSharedKey: Bool { get }
// MARK: - Lifecycle
/// Commits any pending changes to persistent storage
/// Returns true on success, false on failure
@discardableResult
func commit() -> Bool
/// Reloads settings from persistent storage
func reload()
}
// MARK: - iOS Implementation
#if os(iOS)
/// iOS implementation using NetBirdSDKPreferences (file-based storage)
final class iOSConfigurationProvider: ConfigurationProvider {
private var preferences: NetBirdSDKPreferences
init() {
self.preferences = Preferences.newPreferences()
}
// MARK: - Rosenpass
var rosenpassEnabled: Bool {
get {
var result = ObjCBool(false)
do {
try preferences.getRosenpassEnabled(&result)
} catch {
print("ConfigurationProvider: Failed to read rosenpassEnabled - \(error)")
}
return result.boolValue
}
set {
preferences.setRosenpassEnabled(newValue)
}
}
var rosenpassPermissive: Bool {
get {
var result = ObjCBool(false)
do {
try preferences.getRosenpassPermissive(&result)
} catch {
print("ConfigurationProvider: Failed to read rosenpassPermissive - \(error)")
}
return result.boolValue
}
set {
preferences.setRosenpassPermissive(newValue)
}
}
// MARK: - Pre-Shared Key
var preSharedKey: String {
get {
return preferences.getPreSharedKey(nil)
}
set {
preferences.setPreSharedKey(newValue)
}
}
var hasPreSharedKey: Bool {
return !preSharedKey.isEmpty
}
// MARK: - Lifecycle
@discardableResult
func commit() -> Bool {
do {
try preferences.commit()
return true
} catch {
print("ConfigurationProvider: Failed to commit - \(error)")
return false
}
}
func reload() {
// 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()
}
}
#endif
// MARK: - tvOS Implementation
#if os(tvOS)
/// tvOS implementation that reads/writes settings directly to the config JSON.
/// This mirrors iOS behavior where all settings live in one config file.
/// The config JSON is stored in UserDefaults and sent to the extension via IPC.
final class tvOSConfigurationProvider: ConfigurationProvider {
init() {}
// MARK: - Rosenpass
var rosenpassEnabled: Bool {
get { extractJSONBool(field: "RosenpassEnabled") ?? false }
set { updateJSONField(field: "RosenpassEnabled", value: newValue) }
}
var rosenpassPermissive: Bool {
get { extractJSONBool(field: "RosenpassPermissive") ?? false }
set { updateJSONField(field: "RosenpassPermissive", value: newValue) }
}
// MARK: - Pre-Shared Key
var preSharedKey: String {
get { extractJSONString(field: "PreSharedKey") ?? "" }
set { updateJSONField(field: "PreSharedKey", value: newValue) }
}
var hasPreSharedKey: Bool {
return !preSharedKey.isEmpty
}
// MARK: - Lifecycle
@discardableResult
func commit() -> Bool {
// Settings are written directly to config JSON, no separate commit needed
return true
}
func reload() {
// Config JSON is always read fresh from UserDefaults
}
// MARK: - JSON Helpers (read/write to stored config)
private func getConfigJSON() -> String? {
return Preferences.loadConfigFromUserDefaults()
}
private func saveConfigJSON(_ json: String) {
_ = Preferences.saveConfigToUserDefaults(json)
}
private func parseConfigDict() -> [String: Any]? {
guard let json = getConfigJSON(),
let data = json.data(using: .utf8),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
return dict
}
private func extractJSONBool(field: String) -> Bool? {
return parseConfigDict()?[field] as? Bool
}
private func extractJSONString(field: String) -> String? {
return parseConfigDict()?[field] as? String
}
private func updateJSONField<T>(field: String, value: T) {
guard var dict = parseConfigDict() else {
AppLogger.shared.log("ConfigurationProvider: No config JSON available for updating '\(field)'")
return
}
guard dict[field] != nil else {
AppLogger.shared.log("ConfigurationProvider: Field '\(field)' not found in config JSON")
return
}
dict[field] = value
guard let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]),
let json = String(data: data, encoding: .utf8) else {
AppLogger.shared.log("ConfigurationProvider: Failed to serialize config JSON")
return
}
saveConfigJSON(json)
}
}
#endif
// MARK: - Factory
/// Factory for creating the appropriate ConfigurationProvider for the current platform
enum ConfigurationProviderFactory {
static func create() -> ConfigurationProvider {
#if os(iOS)
return iOSConfigurationProvider()
#else
return tvOSConfigurationProvider()
#endif
}
}