Initial commit
This commit is contained in:
227
apple-client/Sources/App/SettingsStore.swift
Normal file
227
apple-client/Sources/App/SettingsStore.swift
Normal file
@@ -0,0 +1,227 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user