Files
ios-client/NetBird/Source/App/ViewModels/ServerViewModel.swift
evgeniyChepelev 905e0d8dc2 Tv os changes (#72)
* 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>
2026-03-22 11:52:24 +01:00

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?
}