Implements signals
This commit is contained in:
@@ -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)")
|
||||
|
||||
@@ -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,6 +49,7 @@ struct ChatTranscriptView: View
|
||||
{
|
||||
var messages: [Display.Message]
|
||||
var displayedConversation: Display.Conversation.ID? = nil
|
||||
var needsReload: Bool = true
|
||||
|
||||
init(messages: [Display.Message] = []) {
|
||||
self.messages = messages
|
||||
@@ -45,21 +63,37 @@ struct ChatTranscriptView: View
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
await loadMessages()
|
||||
setNeedsReload()
|
||||
observeDisplayedConversation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMessages() async {
|
||||
self.messages = []
|
||||
func setNeedsReload() {
|
||||
needsReload = true
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await reloadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
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)")
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -37,7 +37,7 @@ enum Display
|
||||
}
|
||||
}
|
||||
|
||||
struct Message: Identifiable
|
||||
struct Message: Identifiable, Hashable
|
||||
{
|
||||
let id: String
|
||||
let sender: Sender
|
||||
@@ -64,6 +64,14 @@ enum Display
|
||||
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
|
||||
{
|
||||
case me
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user