Files
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

171 lines
6.3 KiB
Swift

//
// Preferences.swift
// NetBirdiOS
//
// Created by Pascal Fischer on 03.08.23.
//
import Foundation
import NetBirdSDK
/// Preferences manages configuration file paths and SDK preferences.
///
/// ## Platform Differences
///
/// ### iOS
/// Uses file-based storage via App Group shared container. The main app and extension
/// can both read/write files to this shared location.
///
/// ### tvOS
/// The App Group shared container does NOT work for IPC between the main app and
/// Network Extension due to sandbox restrictions. Config is transferred via IPC
/// (`sendProviderMessage`/`handleAppMessage`) instead. The SDK preferences are not
/// used on tvOS - settings are managed directly in the extension.
///
/// See NetworkExtensionAdapter and PacketTunnelProvider for tvOS config flow details.
class Preferences {
// MARK: - SDK Preferences
#if os(iOS)
/// 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 {
preconditionFailure("App group container unavailable - check entitlements for '\(GlobalConstants.userPreferencesSuiteName)'")
}
guard let preferences = NetBirdSDKNewPreferences(configPath, statePath) else {
preconditionFailure("Failed to create NetBirdSDKPreferences")
}
return preferences
}
#else
/// tvOS does not use SDK preferences - config is transferred via IPC.
/// Returns nil by design; callers must handle this case.
static func newPreferences() -> NetBirdSDKPreferences? {
// tvOS uses IPC-based config transfer, not file-based SDK preferences.
// The extension manages its own config via UserDefaults.standard after
// receiving it through handleAppMessage.
return nil
}
#endif
// MARK: - File Paths
/// Returns the file path for a given filename in the App Group container.
/// Returns nil if the container is unavailable.
static func getFilePath(fileName: String) -> String? {
let fileManager = FileManager.default
if let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: GlobalConstants.userPreferencesSuiteName) {
return groupURL.appendingPathComponent(fileName).path
}
#if DEBUG
// Fallback for testing when app group is not available
let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
return (baseURL ?? fileManager.temporaryDirectory).appendingPathComponent(fileName).path
#else
AppLogger.shared.log("ERROR: App group '\(GlobalConstants.userPreferencesSuiteName)' not available. Check entitlements.")
return nil
#endif
}
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
//
// These methods store config in the App Group UserDefaults for the MAIN APP's
// own use (e.g., displaying current server URL). On tvOS, this data is NOT
// shared with the extension - it's app-local only.
private static let configJSONKey = "netbird_config_json"
/// Get the App Group UserDefaults.
/// Note: On tvOS, this is app-local only - NOT shared with extension.
static func sharedUserDefaults() -> UserDefaults? {
return UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName)
}
/// Save config JSON to UserDefaults (app-local storage).
static func saveConfigToUserDefaults(_ configJSON: String) -> Bool {
guard let defaults = sharedUserDefaults() else {
return false
}
defaults.set(configJSON, forKey: configJSONKey)
defaults.synchronize()
return true
}
/// Load config JSON from UserDefaults (app-local storage).
static func loadConfigFromUserDefaults() -> String? {
return sharedUserDefaults()?.string(forKey: configJSONKey)
}
/// Check if config exists in UserDefaults.
static func hasConfigInUserDefaults() -> Bool {
return sharedUserDefaults()?.string(forKey: configJSONKey) != nil
}
/// Remove config from UserDefaults (for logout).
static func removeConfigFromUserDefaults() {
guard let defaults = sharedUserDefaults() else {
return
}
defaults.removeObject(forKey: configJSONKey)
defaults.removeObject(forKey: managementURLKey)
defaults.synchronize()
}
// MARK: - Management URL Storage
//
// Stored separately because the config JSON from Go SDK serializes ManagementURL
// as a nested object (not a plain string), making regex/JSON extraction unreliable.
private static let managementURLKey = "netbird_management_url"
/// Save the management URL explicitly (called when user changes server).
static func saveManagementURL(_ url: String) {
sharedUserDefaults()?.set(url, forKey: managementURLKey)
sharedUserDefaults()?.synchronize()
}
/// Load the explicitly saved management URL.
static func loadManagementURL() -> String? {
return sharedUserDefaults()?.string(forKey: managementURLKey)
}
/// Restore config from UserDefaults to the config file path.
/// iOS only - needed because the Go SDK reads from the file path.
#if os(iOS)
static func restoreConfigFromUserDefaults() -> Bool {
guard let configJSON = loadConfigFromUserDefaults(),
let path = configFile() else {
return false
}
do {
try configJSON.write(toFile: path, atomically: false, encoding: .utf8)
return true
} catch {
return false
}
}
#endif
}