2025-12-03 15:53:48 -03:00
|
|
|
//
|
|
|
|
|
// ServerViewModel.swift
|
|
|
|
|
// NetBird
|
|
|
|
|
//
|
|
|
|
|
// Created by Diego Romar on 24/11/25.
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
import Combine
|
2026-01-16 20:58:34 +01:00
|
|
|
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
|
2025-12-03 15:53:48 -03:00
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
class ServerViewModel : ObservableObject {
|
|
|
|
|
let configurationFilePath: String
|
|
|
|
|
let deviceName: String
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
@Published var isOperationSuccessful: Bool = false
|
|
|
|
|
@Published var isUiEnabled: Bool = true
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
@Published var viewErrors = ServerViewErrors()
|
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
init(configurationFilePath: String, deviceName: String) {
|
|
|
|
|
self.configurationFilePath = configurationFilePath
|
|
|
|
|
self.deviceName = deviceName
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
private func isSetupKeyInvalid(setupKey: String) -> Bool {
|
|
|
|
|
if setupKey.isEmpty || setupKey.count != 36 {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
let uuid = UUID(uuidString: setupKey)
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
if uuid == nil {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
return false
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
private func isUrlInvalid(url: String) -> Bool {
|
|
|
|
|
if let url = URL(string: url), url.host != nil {
|
|
|
|
|
return false
|
|
|
|
|
} else {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
private func handleSdkErrorMessage(errorMessage: String) {
|
|
|
|
|
let reviewUrl = "Review the URL:\n\(errorMessage)"
|
|
|
|
|
let reviewSetupKey = "Review the setup key:\n\(errorMessage)"
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
private func getAuthenticator(url managementServerUrl: String) async -> NetBirdSDKAuth? {
|
|
|
|
|
let configPath = self.configurationFilePath
|
2026-01-16 20:58:34 +01:00
|
|
|
let detachedTask = Task.detached(priority: .background) { () -> (NetBirdSDKAuth?, String?) in
|
2025-12-03 15:53:48 -03:00
|
|
|
var error: NSError?
|
2026-01-16 20:58:34 +01:00
|
|
|
let authenticator = NetBirdSDKNewAuth(configPath, managementServerUrl, &error)
|
|
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
if let error = error {
|
|
|
|
|
print(error.domain, error.code, error.description)
|
2026-01-16 20:58:34 +01:00
|
|
|
return (authenticator, error.description)
|
2025-12-03 15:53:48 -03:00
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
return (authenticator, nil)
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
let (authenticator, errorMessage) = await detachedTask.value
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
if let errorMessage = errorMessage {
|
|
|
|
|
handleSdkErrorMessage(errorMessage: errorMessage)
|
|
|
|
|
return nil
|
|
|
|
|
} else {
|
|
|
|
|
return authenticator
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
func changeManagementServerAddress(managementServerUrl: String) async {
|
|
|
|
|
// disable UI here
|
|
|
|
|
isUiEnabled = false
|
|
|
|
|
await Task.yield()
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
let isUrlInvalid = isUrlInvalid(url: managementServerUrl)
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
if isUrlInvalid {
|
|
|
|
|
viewErrors.urlError = "Invalid URL format"
|
|
|
|
|
// error state emitted, enable UI here
|
|
|
|
|
isUiEnabled = true
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
|
|
|
|
guard let authenticator = await getAuthenticator(url: managementServerUrl) else {
|
2025-12-03 15:53:48 -03:00
|
|
|
isUiEnabled = true
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
|
|
|
|
// 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)
|
2026-03-22 11:52:24 +01:00
|
|
|
Preferences.saveManagementURL(managementServerUrl)
|
2026-01-16 20:58:34 +01:00
|
|
|
#endif
|
|
|
|
|
self?.isOperationSuccessful = true
|
|
|
|
|
} else {
|
2026-03-22 11:52:24 +01:00
|
|
|
// 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
|
2026-01-16 20:58:34 +01:00
|
|
|
self?.isUiEnabled = true
|
|
|
|
|
self?.viewErrors.ssoNotSupportedError = "SSO isn't available for the provided server, register this device with a setup key"
|
2026-03-22 11:52:24 +01:00
|
|
|
#endif
|
2026-01-16 20:58:34 +01:00
|
|
|
}
|
|
|
|
|
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)
|
2026-03-22 11:52:24 +01:00
|
|
|
Preferences.saveManagementURL(managementServerUrl)
|
2026-01-16 20:58:34 +01:00
|
|
|
self?.isOperationSuccessful = true
|
|
|
|
|
continuation.resume()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
self?.isUiEnabled = true
|
|
|
|
|
self?.handleSdkErrorMessage(errorMessage: errorMessage)
|
|
|
|
|
continuation.resume()
|
|
|
|
|
}
|
2025-12-03 15:53:48 -03:00
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
authenticator.saveConfigIfSSOSupported(listener)
|
2025-12-03 15:53:48 -03:00
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#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")
|
2025-12-03 15:53:48 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
#endif
|
|
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
func loginWithSetupKey(managementServerUrl: String, setupKey: String) async {
|
|
|
|
|
// disable UI here
|
|
|
|
|
isUiEnabled = false
|
|
|
|
|
await Task.yield()
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
let isSetupKeyInvalid = isSetupKeyInvalid(setupKey: setupKey)
|
|
|
|
|
let isUrlInvalid = isUrlInvalid(url: managementServerUrl)
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
if isUrlInvalid {
|
|
|
|
|
viewErrors.urlError = "Invalid URL format"
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
if isSetupKeyInvalid {
|
|
|
|
|
viewErrors.setupKeyError = "Invalid setup key format"
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
if isSetupKeyInvalid || isUrlInvalid {
|
|
|
|
|
// error states emitted, enable UI here
|
|
|
|
|
isUiEnabled = true
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
|
|
|
|
guard let authenticator = await getAuthenticator(url: managementServerUrl) else {
|
2025-12-03 15:53:48 -03:00
|
|
|
isUiEnabled = true
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 20:58:34 +01:00
|
|
|
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)
|
2025-12-03 15:53:48 -03:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-16 20:58:34 +01:00
|
|
|
|
2025-12-03 15:53:48 -03:00
|
|
|
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?
|
|
|
|
|
}
|