Private
Public Access
1
0

Implements signals

This commit is contained in:
2025-08-24 18:41:42 -07:00
parent 3ee94a3bea
commit b38df68eb2
6 changed files with 191 additions and 22 deletions

View File

@@ -26,9 +26,6 @@ struct KordophoneApp: App
ConversationListView(model: $conversationListModel)
.frame(minWidth: 330.0)
.xpcClient(xpcClient)
.task {
await refreshConversations()
}
} detail: {
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
.xpcClient(xpcClient)
@@ -42,15 +39,6 @@ struct KordophoneApp: App
}
}
private func refreshConversations() async {
do {
let conversations = try await xpcClient.getConversations()
conversationListModel.conversations = conversations.map { Display.Conversation(from: $0) }
} catch {
reportError(error)
}
}
private func reportError(_ e: Error) {
// Just printing for now.
print("Error: \(e.localizedDescription)")

View File

@@ -11,6 +11,8 @@ struct ChatTranscriptView: View
{
@Binding var model: ViewModel
@Environment(\.xpcClient) private var xpcClient
var body: some View {
ScrollView {
LazyVStack(spacing: 17.0) {
@@ -18,11 +20,26 @@ struct ChatTranscriptView: View
MessageCellView(message: message)
.id(message.id)
.scaleEffect(CGSize(width: 1.0, height: -1.0))
.transition(
.push(from: .bottom)
.combined(with: .opacity)
)
}
}
.padding()
}
.scaleEffect(CGSize(width: 1.0, height: -1.0))
.task { await watchForMessageListChanges() }
}
private func watchForMessageListChanges() async {
for await event in xpcClient.eventStream() {
if case let .messagesUpdated(conversationId) = event {
if conversationId == model.displayedConversation {
model.setNeedsReload()
}
}
}
}
// MARK: - Types
@@ -32,7 +49,8 @@ struct ChatTranscriptView: View
{
var messages: [Display.Message]
var displayedConversation: Display.Conversation.ID? = nil
var needsReload: Bool = true
init(messages: [Display.Message] = []) {
self.messages = messages
observeDisplayedConversation()
@@ -45,21 +63,37 @@ struct ChatTranscriptView: View
Task { @MainActor [weak self] in
guard let self else { return }
await loadMessages()
setNeedsReload()
observeDisplayedConversation()
}
}
}
func setNeedsReload() {
needsReload = true
Task { @MainActor [weak self] in
guard let self else { return }
await reloadMessages()
}
}
private func loadMessages() async {
self.messages = []
func reloadMessages() async {
guard needsReload else { return }
needsReload = false
guard let displayedConversation else { return }
do {
let client = XPCClient()
let messages = try await client.getMessages(conversationId: displayedConversation)
self.messages = messages.map { Display.Message(from: $0) }
let clientMessages = try await client.getMessages(conversationId: displayedConversation)
.map { Display.Message(from: $0) }
let newMessages = Set(clientMessages).subtracting(Set(self.messages))
let animation: Animation? = newMessages.count == 1 ? .default : nil
withAnimation(animation) {
self.messages = clientMessages
}
} catch {
print("Message fetch error: \(error)")
}

View File

@@ -10,6 +10,7 @@ import SwiftUI
struct ConversationListView: View
{
@Binding var model: ViewModel
@Environment(\.xpcClient) private var xpcClient
var body: some View {
List($model.conversations, selection: $model.selectedConversations) { conv in
@@ -19,9 +20,26 @@ struct ConversationListView: View
Text(conv.wrappedValue.messagePreview)
}
.id(conv.id)
.padding(8.0)
}
.listStyle(.sidebar)
.task { await watchForConversationListChanges() }
}
private func watchForConversationListChanges() async {
for await event in xpcClient.eventStream() {
switch event {
case .conversationsUpdated:
model.setNeedsReload()
case .messagesUpdated(_):
model.setNeedsReload()
case .updateStreamReconnected:
model.setNeedsReload()
default:
break
}
}
}
// MARK: - Types
@@ -32,9 +50,35 @@ struct ConversationListView: View
var conversations: [Display.Conversation]
var selectedConversations: Set<Display.Conversation.ID>
private var needsReload: Bool = true
public init(conversations: [Display.Conversation] = []) {
self.conversations = conversations
self.selectedConversations = Set()
setNeedsReload()
}
func setNeedsReload() {
needsReload = true
Task { @MainActor [weak self] in
guard let self else { return }
await reloadConversations()
}
}
func reloadConversations() async {
guard needsReload else { return }
needsReload = false
do {
let client = XPCClient()
let clientConversations = try await client.getConversations()
.map { Display.Conversation(from: $0) }
self.conversations = clientConversations
} catch {
print("Error reloading conversations: \(error)")
}
}
}
}

View File

@@ -10,7 +10,6 @@ import SwiftUI
struct MessageEntryView: View
{
@Binding var viewModel: ViewModel
@Environment(\.selectedConversation) private var selectedConversation
var body: some View {
@@ -55,6 +54,8 @@ struct MessageEntryView: View
guard !draftText.isEmpty else { return }
let messageText = self.draftText
.trimmingCharacters(in: .whitespacesAndNewlines)
self.draftText = ""
Task {

View File

@@ -37,7 +37,7 @@ enum Display
}
}
struct Message: Identifiable
struct Message: Identifiable, Hashable
{
let id: String
let sender: Sender
@@ -63,6 +63,14 @@ enum Display
self.sender = sender
self.text = text
}
static func == (lhs: Message, rhs: Message) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
enum Sender
{

View File

@@ -13,15 +13,47 @@ private let serviceName = "net.buzzert.kordophonecd"
final class XPCClient
{
private let connection: xpc_connection_t
private let signalLock = NSLock()
private var signalSinks: [UUID: (Signal) -> Void] = [:]
private var didSubscribeSignals: Bool = false
init() {
self.connection = xpc_connection_create_mach_service(serviceName, nil, 0)
let handler: xpc_handler_t = { _ in }
let handler: xpc_handler_t = { [weak self] event in
self?.handleIncomingXPCEvent(event)
}
xpc_connection_set_event_handler(connection, handler)
xpc_connection_resume(connection)
}
public func eventStream() -> AsyncStream<Signal> {
// Auto-subscribe on first stream creation
if !didSubscribeSignals {
didSubscribeSignals = true
Task {
try? await subscribeToSignals()
}
}
return AsyncStream { continuation in
let id = UUID()
signalLock.withLock {
signalSinks[id] = { signal in
continuation.yield(signal)
}
}
continuation.onTermination = { [weak self] _ in
guard let self else { return }
_ = self.signalLock.withLock {
self.signalSinks.removeValue(forKey: id)
}
}
}
}
public func getVersion() async throws -> String {
let req = makeRequest(method: "GetVersion")
guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError }
@@ -46,7 +78,8 @@ final class XPCClient
if xpc_get_type(element) == XPC_TYPE_DICTIONARY, let conv = Serialized.Conversation(xpc: element) {
results.append(conv)
}
return true
return true
}
return results
}
@@ -66,6 +99,7 @@ final class XPCClient
if xpc_get_type(element) == XPC_TYPE_DICTIONARY, let msg = Serialized.Message(xpc: element) {
results.append(msg)
}
return true
}
@@ -88,10 +122,24 @@ final class XPCClient
case typeError
case encodingError
}
enum Signal
{
case conversationsUpdated
case messagesUpdated(conversationId: String)
case attachmentDownloaded(attachmentId: String)
case attachmentUploaded(uploadGuid: String, attachmentGuid: String)
case updateStreamReconnected
}
}
extension XPCClient
{
private func subscribeToSignals() async throws {
let req = makeRequest(method: "SubscribeSignals")
_ = try await sendSync(req)
}
private func makeRequest(method: String, arguments: [String: xpc_object_t]? = nil) -> xpc_object_t {
let dict = xpc_dictionary_create(nil, nil, 0)
xpc_dictionary_set_string(dict, "method", method)
@@ -132,6 +180,52 @@ extension XPCClient
}
}
}
private func handleIncomingXPCEvent(_ event: xpc_object_t) {
let type = xpc_get_type(event)
switch type {
case XPC_TYPE_DICTIONARY:
guard let namePtr = xpc_dictionary_get_string(event, "name") else { return }
let name = String(cString: namePtr)
var signal: Signal?
if name == "ConversationsUpdated" {
signal = .conversationsUpdated
} else if name == "MessagesUpdated" {
if let args = xpc_dictionary_get_value(event, "arguments"), xpc_get_type(args) == XPC_TYPE_DICTIONARY,
let cidPtr = xpc_dictionary_get_string(args, "conversation_id") {
signal = .messagesUpdated(conversationId: String(cString: cidPtr))
}
} else if name == "AttachmentDownloadCompleted" {
if let args = xpc_dictionary_get_value(event, "arguments"), xpc_get_type(args) == XPC_TYPE_DICTIONARY,
let aidPtr = xpc_dictionary_get_string(args, "attachment_id") {
signal = .attachmentDownloaded(attachmentId: String(cString: aidPtr))
}
} else if name == "AttachmentUploadCompleted" {
if let args = xpc_dictionary_get_value(event, "arguments"), xpc_get_type(args) == XPC_TYPE_DICTIONARY,
let ugPtr = xpc_dictionary_get_string(args, "upload_guid"),
let agPtr = xpc_dictionary_get_string(args, "attachment_guid") {
signal = .attachmentUploaded(uploadGuid: String(cString: ugPtr), attachmentGuid: String(cString: agPtr))
}
} else if name == "UpdateStreamReconnected" {
signal = .updateStreamReconnected
}
if let signal {
signalLock.lock()
let sinks = signalSinks.values
signalLock.unlock()
for sink in sinks { sink(signal) }
}
case XPC_TYPE_ERROR:
if let errStr = xpc_string_get_string_ptr(event) {
print("XPC event error: \(String(cString: errStr))")
}
default:
break
}
}
}