228 lines
6.5 KiB
Swift
228 lines
6.5 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|