diff --git a/kordophone2/App.swift b/kordophone2/App.swift index 7290120..918a3ad 100644 --- a/kordophone2/App.swift +++ b/kordophone2/App.swift @@ -37,6 +37,10 @@ struct KordophoneApp: App } } } + + Settings { + PreferencesView() + } } private func reportError(_ e: Error) { diff --git a/kordophone2/Models.swift b/kordophone2/Models.swift index 0e48397..2b01daa 100644 --- a/kordophone2/Models.swift +++ b/kordophone2/Models.swift @@ -197,6 +197,28 @@ enum Serialized let height: Int? } } + + struct Settings: Decodable + { + let serverUrl: String + let username: String + } +} + +extension Serialized.Settings: XPCConvertible +{ + static func fromXPC(_ value: xpc_object_t) -> Serialized.Settings? { + guard let d = XPCDictionary(value) else { return nil } + + let su: String = d["server_url"] ?? "" + let un: String = d["username"] ?? "" + + return Serialized.Settings( + serverUrl: su, + username: un + ) + } + } extension Serialized.Attachment: XPCConvertible diff --git a/kordophone2/PreferencesView.swift b/kordophone2/PreferencesView.swift new file mode 100644 index 0000000..7bac6ac --- /dev/null +++ b/kordophone2/PreferencesView.swift @@ -0,0 +1,105 @@ +// +// PreferencesView.swift +// kordophone2 +// +// Created by James Magahern on 8/24/25. +// + +import SwiftUI + +struct PreferencesView: View +{ + @State var accountSettingsModel = AccountSettings.ViewModel() + + var body: some View { + TabView { + AccountSettings(model: $accountSettingsModel) + .tabItem { Label("Account", systemImage: "person.crop.circle") } + } + .frame(width: 480.0, height: 300.0) + .padding(20.0) + } +} + +struct AccountSettings: View +{ + @Binding var model: ViewModel + + var body: some View { + Form { + Section("Server Settings") { + TextField("Server", text: $model.serverURL) + } + + Spacer() + .frame(height: 44.0) + + Section("Authentication") { + TextField("Username", text: $model.username) + .textContentType(.username) + + SecureField("Password", text: $model.password) + .textContentType(.password) + } + } + + .task { await model.load() } + } + + // MARK: - Types + + @Observable + class ViewModel + { + var serverURL: String + var username: String + var password: String + + private let xpc = XPCClient() + + init(serverURL: String = "", username: String = "", password: String = "") { + self.serverURL = serverURL + self.username = username + self.password = password + + autosave() + } + + func load() async { + do { + let settings = try await xpc.getSettings() + self.serverURL = settings.serverUrl + self.username = settings.username + } catch { + print("Error getting settings: \(error)") + } + } + + private func autosave() { + withObservationTracking { + _ = serverURL + _ = username + _ = password + } onChange: { + Task { @MainActor [weak self] in + guard let self else { return } + + do { + let currentSettings = try await xpc.getSettings() + + if currentSettings.serverUrl != serverURL || currentSettings.username != username { + try await xpc.setSettings(settings: Serialized.Settings( + serverUrl: serverURL, + username: username + )) + } + } catch { + print("Error saving settings: \(error)") + } + + autosave() + } + } + } + } +} diff --git a/kordophone2/Transcript/TranscriptView.swift b/kordophone2/Transcript/TranscriptView.swift index 535f7e2..a9a20e3 100644 --- a/kordophone2/Transcript/TranscriptView.swift +++ b/kordophone2/Transcript/TranscriptView.swift @@ -42,7 +42,7 @@ struct TranscriptView: View model.setNeedsReload(animated: true) } case .updateStreamReconnected: - model.setNeedsReload(animated: false) + await model.triggerSync() default: break } @@ -79,8 +79,9 @@ struct TranscriptView: View var displayItems: [DisplayItem] = [] var displayedConversation: Display.Conversation.ID? = nil - internal var needsReload: NeedsReload = .yes(false) + internal var needsReload: NeedsReload = .no internal var messages: [Display.Message] + internal let client = XPCClient() init(messages: [Display.Message] = []) { self.messages = messages @@ -89,6 +90,10 @@ struct TranscriptView: View } func setNeedsReload(animated: Bool) { + guard case .no = needsReload else { + return + } + needsReload = .yes(animated) Task { @MainActor [weak self] in guard let self else { return } @@ -108,12 +113,24 @@ struct TranscriptView: View Task { @MainActor [weak self] in guard let self else { return } + await triggerSync() + setNeedsReload(animated: false) observeDisplayedConversation() } } } + func triggerSync() async { + guard let displayedConversation else { return } + + do { + try await client.syncConversation(conversationId: displayedConversation) + } catch { + print("Error triggering sync: \(error)") + } + } + private func reloadMessages() async { guard case .yes(let animated) = needsReload else { return } needsReload = .no @@ -121,7 +138,6 @@ struct TranscriptView: View guard let displayedConversation else { return } do { - let client = XPCClient() let clientMessages = try await client.getMessages(conversationId: displayedConversation) .map { Display.Message(from: $0) } diff --git a/kordophone2/XPC/XPCClient.swift b/kordophone2/XPC/XPCClient.swift index dcb8434..fb78d8a 100644 --- a/kordophone2/XPC/XPCClient.swift +++ b/kordophone2/XPC/XPCClient.swift @@ -84,6 +84,11 @@ final class XPCClient return results } + public func syncConversation(conversationId: String) async throws { + let req = makeRequest(method: "SyncConversation", arguments: ["conversation_id": xpcString(conversationId)]) + _ = try await sendSync(req) + } + public func getMessages(conversationId: String, limit: Int = 100, offset: Int = 0) async throws -> [Serialized.Message] { var args: [String: xpc_object_t] = [:] args["conversation_id"] = xpcString(conversationId) @@ -137,6 +142,24 @@ final class XPCClient return FileHandle(fileDescriptor: fd, closeOnDealloc: true) } + + public func getSettings() async throws -> Serialized.Settings { + let req = makeRequest(method: "GetAllSettings") + guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError } + return Serialized.Settings.fromXPC(reply) ?? Serialized.Settings(serverUrl: "", username: "") + } + + public func setSettings(settings: Serialized.Settings) async throws { + let req = makeRequest( + method: "UpdateSettings", + arguments: [ + "server_url": xpcString(settings.serverUrl), + "username": xpcString(settings.username), + ] + ) + + _ = try await sendSync(req) + } // MARK: - Types