import Foundation import Observation @MainActor @Observable final class SettingsStore { enum AuthMode: String, CaseIterable, Identifiable { case login case register var id: String { rawValue } } var backendURLString: String var currentUser: UserProfile? var accessKeys: [AccessKeySummary] var authMode: AuthMode = .login var username = "" var password = "" var displayName = "" var accessKeyLabel = "" var infoMessage: String? var errorMessage: String? var isBusy = false private(set) var webStateVersion = UUID() private let defaults = UserDefaults.standard private let passkeyManager = PasskeyManager() private enum Keys { static let backendURL = "privatechat.apple.backendURL" static let token = "privatechat.apple.token" static let user = "privatechat.apple.user" } init() { backendURLString = defaults.string(forKey: Keys.backendURL) ?? "http://localhost:3000" accessKeys = [] if let data = defaults.data(forKey: Keys.user), let user = try? JSONDecoder().decode(UserProfile.self, from: data) { currentUser = user } Task { await restoreSessionIfPossible() } } var token: String? { defaults.string(forKey: Keys.token) } var isAuthenticated: Bool { token != nil && currentUser != nil } var injectionScript: String { let serverValue = backendURLString.jsonString() let tokenValue = (token ?? "").jsonString() let userValue: String if let currentUser { userValue = currentUser.jsonString() } else { userValue = "null" } return """ (function() { try { localStorage.setItem('privatechat.embeddedMode', '1'); localStorage.setItem('privatechat.serverUrl', \(serverValue)); if (\(tokenValue) && \(tokenValue) !== '""') { localStorage.setItem('privatechat.token', \(tokenValue)); } else { localStorage.removeItem('privatechat.token'); } if (\(userValue) !== null) { localStorage.setItem('privatechat.user', JSON.stringify(\(userValue))); } else { localStorage.removeItem('privatechat.user'); } } catch (error) { console.error('Failed to sync native settings into localStorage', error); } })(); """ } func saveBackendURL() { defaults.set(backendURLString, forKey: Keys.backendURL) invalidateWebState(info: "Backend URL updated.") } func authenticate() async { await runTask { [self] in switch authMode { case .login: let response = try await self.apiClient().login(username: self.username, password: self.password) try await self.applyAuthResponse(response, success: "Signed in as \(response.user.displayName).") case .register: let response = try await self.apiClient().register( username: self.username, password: self.password, displayName: self.displayName ) try await self.applyAuthResponse(response, success: "Account created for \(response.user.displayName).") } } } func signInWithAccessKey() async { await runTask { [self] in let options = try await self.apiClient().startAccessKeyAuthentication() let credential = try await self.passkeyManager.authenticate(options: options) let response = try await self.apiClient().finishAccessKeyAuthentication( attemptId: options.attemptId, payload: credential ) try await self.applyAuthResponse(response, success: "Signed in as \(response.user.displayName).") } } func registerAccessKey() async { guard let token else { errorMessage = "Sign in before registering an access key." return } await runTask { [self] in let options = try await self.apiClient().startAccessKeyRegistration( label: self.accessKeyLabel.isEmpty ? nil : self.accessKeyLabel, token: token ) let payload = try await self.passkeyManager.register(options: options) try await self.apiClient().finishAccessKeyRegistration(payload: payload, token: token) self.accessKeyLabel = "" self.accessKeys = try await self.apiClient().listAccessKeys(token: token) self.infoMessage = "Access key registered." } } func signOut() async { guard let token else { clearAuthState(info: "Signed out.") return } await runTask { [self] in try? await self.apiClient().logout(token: token) self.clearAuthState(info: "Signed out.") self.authMode = .login } } func restoreSessionIfPossible() async { guard let token else { return } await runTask(clearMessages: false) { [self] in let response = try await self.apiClient().restoreSession(token: token) self.currentUser = response.user self.accessKeys = try await self.apiClient().listAccessKeys(token: token) self.infoMessage = "Restored session for \(response.user.displayName)." self.invalidateWebState() } } private func apiClient() throws -> BackendClient { guard let url = URL(string: backendURLString.trimmingCharacters(in: .whitespacesAndNewlines)), url.scheme?.hasPrefix("http") == true else { throw APIErrorResponse(message: "Enter a valid backend URL before continuing.") } return BackendClient(baseURL: url) } private func applyAuthResponse(_ response: AuthResponse, success: String) async throws { currentUser = response.user defaults.set(response.token, forKey: Keys.token) defaults.set(try JSONEncoder().encode(response.user), forKey: Keys.user) accessKeys = try await apiClient().listAccessKeys(token: response.token) password = "" infoMessage = success invalidateWebState() } private func clearAuthState(info: String) { currentUser = nil accessKeys = [] defaults.removeObject(forKey: Keys.token) defaults.removeObject(forKey: Keys.user) infoMessage = info errorMessage = nil invalidateWebState() } private func invalidateWebState(info: String? = nil) { if let info { infoMessage = info } webStateVersion = UUID() } private func runTask(clearMessages: Bool = true, operation: @escaping () async throws -> Void) async { if clearMessages { errorMessage = nil infoMessage = nil } isBusy = true defer { isBusy = false } do { try await operation() } catch let apiError as APIErrorResponse { errorMessage = apiError.message } catch { errorMessage = error.localizedDescription } } }