import Foundation import Observation /// Top-level app state. Owns the active `MCMSConnection`, persists the /// (non-secret) `APIConfiguration` to UserDefaults, and stores credentials in /// the Keychain. Rebuild the connection by calling `configure(_:)`. @MainActor @Observable final class AppEnvironment { private(set) var configuration: APIConfiguration? private(set) var connection: MCMSConnection? var isAuthenticated = false private let keychain = KeychainStore() private let defaults = UserDefaults.standard private let configKey = "mcms.configuration" init() { loadConfiguration() } var isConfigured: Bool { configuration != nil } // MARK: Configuration func configure(_ config: APIConfiguration) { configuration = config connection = makeConnection(config) isAuthenticated = false persist(config) } /// Builds a connection wired to route 401/403 back to the login screen. private func makeConnection(_ config: APIConfiguration) -> MCMSConnection { let connection = MCMSConnection(config: config) connection.onAuthExpired = { [weak self] in self?.handleAuthExpired() } return connection } /// A request hit 401/403 — the session expired. Drop to login; saved /// credentials remain so the user can sign back in. private func handleAuthExpired() { guard isAuthenticated else { return } isAuthenticated = false } private func persist(_ config: APIConfiguration) { if let data = try? JSONEncoder().encode(config) { defaults.set(data, forKey: configKey) } } private func loadConfiguration() { guard let data = defaults.data(forKey: configKey), let config = try? JSONDecoder().decode(APIConfiguration.self, from: data) else { return } configuration = config connection = makeConnection(config) } // MARK: Credentials (Keychain) var savedEmail: String? { keychain.get(account: "email") } var savedPassword: String? { keychain.get(account: "password") } private func saveCredentials(email: String, password: String) { keychain.set(email, account: "email") keychain.set(password, account: "password") } func forgetCredentials() { keychain.delete(account: "email") keychain.delete(account: "password") } // MARK: Session func signIn(email: String, password: String, remember: Bool) async throws { guard let connection else { throw APIError.transport(message: "No connection configured") } try await connection.auth.login(email: email, password: password) if remember { saveCredentials(email: email, password: password) } isAuthenticated = true } func signOut() async { await connection?.auth.logout() isAuthenticated = false } /// Reachability / trust probe used by the Settings screen. Builds a /// throwaway connection so testing does NOT disturb the active session. /// A 401/403/404 still means the host answered over a trusted TLS channel — /// that's a successful reachability result; only transport/trust errors fail. func probe(_ config: APIConfiguration) async throws -> String { let connection = MCMSConnection(config: config) do { let version = try await connection.auth.version() return version.displayVersion.map { "PON Manager \($0)" } ?? "Reachable" } catch let error as APIError { switch error { case .unauthorized, .forbidden, .notFound: return "Reachable — sign in to continue" default: throw error } } } }