Files
PrivateChat/apple-client/Sources/App/SettingsStore.swift

228 lines
6.5 KiB
Swift
Raw Normal View History

2026-03-09 19:35:08 +01:00
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
}
}
}