You've already forked ios-client
mirror of
https://github.com/netbirdio/ios-client.git
synced 2026-05-22 17:10:12 -07:00
905e0d8dc2
* fix ui state for airplaine mode * fix slide bar * Keep VPN tunnel alive during network unavailability - Add isNetworkUnavailable flag to NetBirdAdapter to track network state - Modify ConnectionListener to stay in 'connecting' state when network is unavailable instead of transitioning to 'disconnected' - Update PacketTunnelProvider to set network unavailable flag and trigger automatic reconnect when network returns - Fix CustomLottieView to show grey 'Disconnected' state immediately when network is lost, without closing the VPN tunnel - Ensure UI shows correct state after app foreground/background cycle This allows the VPN tunnel to survive temporary network outages (e.g. airplane mode) and automatically reconnect when network returns. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix UI state after app foreground/background cycle Show correct connected/disconnected state immediately when app returns from background, without replaying animations. Use extensionStatus (iOS VPN state) as the source of truth for UI state. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * netbird credential * Update MainView.swift * Separate button by state * Add gogoleServiceInfo plist reference * Remove dead code in animation state machine and fix copy-to-clipboard UX - Remove unreachable shouldForceReset block in CustomLottieView (already handled by earlier return) - Guard against copying empty fqdn/ip strings when disconnected - Use consistent .smooth animation for both fqdn and ip copy feedback - Remove unreachable shouldForceReset block in CustomLottieView (already handled by earlier return) - Guard against copying empty fqdn/ip strings when disconnected - Use consistent .smooth animation for both fqdn and ip copy feedback * Tab bar * Update peer view * Update fonts * Redesign tvOS connection screen and add peer search - Move logo to top-left brand anchor (smaller, semi-transparent) - Restyle connect button with gradient fill, glow shadow, and press-down scale animation (TVConnectButtonStyle) - Add search bar with clear button to peers list view * Redesign tvOS UI with gradient backgrounds * Update logo position * Redesign tvOS peers & resources lists to match settings UI style * Add color-coded connection status indicator and debug state overlay * Stabilize connection screen layout and remove debug overlay * Update project.pbxproj * Delete mock data * Update info plist * Update project.pbxproj * add go get * fix Self-hosted management URL * Fix infinite connecting state after canceling QR code auth on tvOS * Code refactoring --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
336 lines
12 KiB
Swift
336 lines
12 KiB
Swift
//
|
|
// ServerViewModel.swift
|
|
// NetBird
|
|
//
|
|
// Created by Diego Romar on 24/11/25.
|
|
//
|
|
|
|
import Combine
|
|
import NetBirdSDK
|
|
import Foundation
|
|
|
|
// MARK: - SDK Listener Implementations
|
|
|
|
/// Listener for SSO support check
|
|
class SSOListenerImpl: NSObject, NetBirdSDKSSOListenerProtocol {
|
|
private let onSuccessHandler: (Bool) -> Void
|
|
private let onErrorHandler: (Error) -> Void
|
|
|
|
init(onSuccess: @escaping (Bool) -> Void, onError: @escaping (Error) -> Void) {
|
|
self.onSuccessHandler = onSuccess
|
|
self.onErrorHandler = onError
|
|
}
|
|
|
|
func onSuccess(_ ssoSupported: Bool) {
|
|
onSuccessHandler(ssoSupported)
|
|
}
|
|
|
|
func onError(_ error: (any Error)?) {
|
|
if let error = error {
|
|
onErrorHandler(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Listener for login operations
|
|
class ErrListenerImpl: NSObject, NetBirdSDKErrListenerProtocol {
|
|
private let onSuccessHandler: () -> Void
|
|
private let onErrorHandler: (Error) -> Void
|
|
|
|
init(onSuccess: @escaping () -> Void, onError: @escaping (Error) -> Void) {
|
|
self.onSuccessHandler = onSuccess
|
|
self.onErrorHandler = onError
|
|
}
|
|
|
|
func onSuccess() {
|
|
onSuccessHandler()
|
|
}
|
|
|
|
func onError(_ error: (any Error)?) {
|
|
if let error = error {
|
|
onErrorHandler(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - ServerViewModel
|
|
|
|
@MainActor
|
|
class ServerViewModel : ObservableObject {
|
|
let configurationFilePath: String
|
|
let deviceName: String
|
|
|
|
@Published var isOperationSuccessful: Bool = false
|
|
@Published var isUiEnabled: Bool = true
|
|
|
|
@Published var viewErrors = ServerViewErrors()
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
init(configurationFilePath: String, deviceName: String) {
|
|
self.configurationFilePath = configurationFilePath
|
|
self.deviceName = deviceName
|
|
|
|
// Forward viewErrors changes to trigger ServerViewModel's objectWillChange
|
|
// This is to make ServerViewModel react to changes made on ServerViewErrors.
|
|
viewErrors.objectWillChange
|
|
.sink { [weak self] _ in
|
|
self?.objectWillChange.send()
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
private func isSetupKeyInvalid(setupKey: String) -> Bool {
|
|
if setupKey.isEmpty || setupKey.count != 36 {
|
|
return true
|
|
}
|
|
|
|
let uuid = UUID(uuidString: setupKey)
|
|
|
|
if uuid == nil {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private func isUrlInvalid(url: String) -> Bool {
|
|
if let url = URL(string: url), url.host != nil {
|
|
return false
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
private func handleSdkErrorMessage(errorMessage: String) {
|
|
let reviewUrl = "Review the URL:\n\(errorMessage)"
|
|
let reviewSetupKey = "Review the setup key:\n\(errorMessage)"
|
|
|
|
if errorMessage.localizedCaseInsensitiveContains("dial context: context deadline exceeded") {
|
|
viewErrors.urlError = reviewUrl
|
|
} else if errorMessage.localizedCaseInsensitiveContains("failed while getting management service public key") {
|
|
viewErrors.urlError = reviewUrl
|
|
} else if errorMessage.localizedCaseInsensitiveContains("couldn't add peer: setup key is invalid") {
|
|
viewErrors.setupKeyError = reviewSetupKey
|
|
} else {
|
|
// generic error
|
|
viewErrors.generalError = errorMessage
|
|
}
|
|
}
|
|
|
|
private func getAuthenticator(url managementServerUrl: String) async -> NetBirdSDKAuth? {
|
|
let configPath = self.configurationFilePath
|
|
let detachedTask = Task.detached(priority: .background) { () -> (NetBirdSDKAuth?, String?) in
|
|
var error: NSError?
|
|
let authenticator = NetBirdSDKNewAuth(configPath, managementServerUrl, &error)
|
|
|
|
if let error = error {
|
|
print(error.domain, error.code, error.description)
|
|
return (authenticator, error.description)
|
|
}
|
|
|
|
return (authenticator, nil)
|
|
}
|
|
|
|
let (authenticator, errorMessage) = await detachedTask.value
|
|
|
|
if let errorMessage = errorMessage {
|
|
handleSdkErrorMessage(errorMessage: errorMessage)
|
|
return nil
|
|
} else {
|
|
return authenticator
|
|
}
|
|
}
|
|
|
|
func changeManagementServerAddress(managementServerUrl: String) async {
|
|
// disable UI here
|
|
isUiEnabled = false
|
|
await Task.yield()
|
|
|
|
let isUrlInvalid = isUrlInvalid(url: managementServerUrl)
|
|
|
|
if isUrlInvalid {
|
|
viewErrors.urlError = "Invalid URL format"
|
|
// error state emitted, enable UI here
|
|
isUiEnabled = true
|
|
return
|
|
}
|
|
|
|
guard let authenticator = await getAuthenticator(url: managementServerUrl) else {
|
|
isUiEnabled = true
|
|
return
|
|
}
|
|
|
|
// Use continuation to bridge async callback to async/await
|
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
|
let listener = SSOListenerImpl(
|
|
onSuccess: { [weak self] ssoSupported in
|
|
Task { @MainActor in
|
|
if ssoSupported {
|
|
#if os(tvOS)
|
|
self?.saveConfigToUserDefaults(authenticator: authenticator)
|
|
Preferences.saveManagementURL(managementServerUrl)
|
|
#endif
|
|
self?.isOperationSuccessful = true
|
|
} else {
|
|
// On tvOS, embedded IdP servers report ssoSupported=false but still
|
|
// support device code auth flow. Save the config and proceed — the
|
|
// actual login flow (loginTV with forceDeviceAuth) will handle auth.
|
|
#if os(tvOS)
|
|
self?.saveConfigToUserDefaults(authenticator: authenticator)
|
|
Preferences.saveManagementURL(managementServerUrl)
|
|
print("tvOS: ssoSupported=false (embedded IdP), config and management URL saved")
|
|
self?.isOperationSuccessful = true
|
|
#else
|
|
self?.isUiEnabled = true
|
|
self?.viewErrors.ssoNotSupportedError = "SSO isn't available for the provided server, register this device with a setup key"
|
|
#endif
|
|
}
|
|
continuation.resume()
|
|
}
|
|
},
|
|
onError: { [weak self] error in
|
|
Task { @MainActor in
|
|
let errorMessage = error.localizedDescription
|
|
|
|
// On tvOS, file permission errors mean SSO check succeeded but file save failed
|
|
// We can still proceed by saving to UserDefaults instead
|
|
#if os(tvOS)
|
|
if errorMessage.contains("operation not permitted") || errorMessage.contains("permission denied") {
|
|
print("tvOS: File write failed, saving config to UserDefaults")
|
|
self?.saveConfigToUserDefaults(authenticator: authenticator)
|
|
Preferences.saveManagementURL(managementServerUrl)
|
|
self?.isOperationSuccessful = true
|
|
continuation.resume()
|
|
return
|
|
}
|
|
#endif
|
|
|
|
self?.isUiEnabled = true
|
|
self?.handleSdkErrorMessage(errorMessage: errorMessage)
|
|
continuation.resume()
|
|
}
|
|
}
|
|
)
|
|
|
|
authenticator.saveConfigIfSSOSupported(listener)
|
|
}
|
|
}
|
|
|
|
#if os(tvOS)
|
|
/// On tvOS, save the config JSON to UserDefaults since file writes are blocked
|
|
private func saveConfigToUserDefaults(authenticator: NetBirdSDKAuth) {
|
|
var error: NSError?
|
|
let configJSON = authenticator.getConfigJSON(&error)
|
|
|
|
if let error = error {
|
|
print("tvOS: Failed to get config JSON: \(error.localizedDescription)")
|
|
return
|
|
}
|
|
|
|
if !configJSON.isEmpty {
|
|
if Preferences.saveConfigToUserDefaults(configJSON) {
|
|
print("tvOS: Config saved to UserDefaults successfully")
|
|
} else {
|
|
print("tvOS: Failed to save config to UserDefaults")
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
func loginWithSetupKey(managementServerUrl: String, setupKey: String) async {
|
|
// disable UI here
|
|
isUiEnabled = false
|
|
await Task.yield()
|
|
|
|
let isSetupKeyInvalid = isSetupKeyInvalid(setupKey: setupKey)
|
|
let isUrlInvalid = isUrlInvalid(url: managementServerUrl)
|
|
|
|
if isUrlInvalid {
|
|
viewErrors.urlError = "Invalid URL format"
|
|
}
|
|
|
|
if isSetupKeyInvalid {
|
|
viewErrors.setupKeyError = "Invalid setup key format"
|
|
}
|
|
|
|
if isSetupKeyInvalid || isUrlInvalid {
|
|
// error states emitted, enable UI here
|
|
isUiEnabled = true
|
|
return
|
|
}
|
|
|
|
guard let authenticator = await getAuthenticator(url: managementServerUrl) else {
|
|
isUiEnabled = true
|
|
return
|
|
}
|
|
|
|
let deviceName = self.deviceName
|
|
|
|
// Use continuation to bridge async callback to async/await
|
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
|
let listener = ErrListenerImpl(
|
|
onSuccess: { [weak self] in
|
|
Task { @MainActor in
|
|
// On tvOS, try to save config to UserDefaults since file writes may have failed
|
|
#if os(tvOS)
|
|
self?.saveConfigToUserDefaults(authenticator: authenticator)
|
|
#endif
|
|
self?.isOperationSuccessful = true
|
|
continuation.resume()
|
|
}
|
|
},
|
|
onError: { [weak self] error in
|
|
Task { @MainActor in
|
|
let errorMessage = error.localizedDescription
|
|
|
|
// On tvOS, file permission errors mean login succeeded but file save failed
|
|
// We can still proceed by saving to UserDefaults instead
|
|
#if os(tvOS)
|
|
if errorMessage.contains("operation not permitted") || errorMessage.contains("permission denied") {
|
|
print("tvOS: File write failed, saving config to UserDefaults")
|
|
self?.saveConfigToUserDefaults(authenticator: authenticator)
|
|
self?.isOperationSuccessful = true
|
|
continuation.resume()
|
|
return
|
|
}
|
|
#endif
|
|
|
|
self?.isUiEnabled = true
|
|
self?.handleSdkErrorMessage(errorMessage: errorMessage)
|
|
continuation.resume()
|
|
}
|
|
}
|
|
)
|
|
|
|
authenticator.login(withSetupKeyAndSaveConfig: listener, setupKey: setupKey, deviceName: deviceName)
|
|
}
|
|
}
|
|
|
|
func clearErrorsFor(field: Field) {
|
|
switch field {
|
|
case .url:
|
|
viewErrors.urlError = nil
|
|
viewErrors.generalError = nil
|
|
viewErrors.ssoNotSupportedError = nil
|
|
case .setupKey:
|
|
viewErrors.setupKeyError = nil
|
|
case .all:
|
|
clearErrorsFor(field: .url)
|
|
clearErrorsFor(field: .setupKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum Field {
|
|
case url
|
|
case setupKey
|
|
case all
|
|
}
|
|
|
|
class ServerViewErrors : ObservableObject {
|
|
@Published var urlError: String?
|
|
@Published var setupKeyError: String?
|
|
@Published var ssoNotSupportedError: String?
|
|
@Published var generalError: String?
|
|
}
|