Implements signals
This commit is contained in:
@@ -26,9 +26,6 @@ struct KordophoneApp: App
|
|||||||
ConversationListView(model: $conversationListModel)
|
ConversationListView(model: $conversationListModel)
|
||||||
.frame(minWidth: 330.0)
|
.frame(minWidth: 330.0)
|
||||||
.xpcClient(xpcClient)
|
.xpcClient(xpcClient)
|
||||||
.task {
|
|
||||||
await refreshConversations()
|
|
||||||
}
|
|
||||||
} detail: {
|
} detail: {
|
||||||
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
|
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
|
||||||
.xpcClient(xpcClient)
|
.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) {
|
private func reportError(_ e: Error) {
|
||||||
// Just printing for now.
|
// Just printing for now.
|
||||||
print("Error: \(e.localizedDescription)")
|
print("Error: \(e.localizedDescription)")
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ struct ChatTranscriptView: View
|
|||||||
{
|
{
|
||||||
@Binding var model: ViewModel
|
@Binding var model: ViewModel
|
||||||
|
|
||||||
|
@Environment(\.xpcClient) private var xpcClient
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 17.0) {
|
LazyVStack(spacing: 17.0) {
|
||||||
@@ -18,11 +20,26 @@ struct ChatTranscriptView: View
|
|||||||
MessageCellView(message: message)
|
MessageCellView(message: message)
|
||||||
.id(message.id)
|
.id(message.id)
|
||||||
.scaleEffect(CGSize(width: 1.0, height: -1.0))
|
.scaleEffect(CGSize(width: 1.0, height: -1.0))
|
||||||
|
.transition(
|
||||||
|
.push(from: .bottom)
|
||||||
|
.combined(with: .opacity)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.scaleEffect(CGSize(width: 1.0, height: -1.0))
|
.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
|
// MARK: - Types
|
||||||
@@ -32,7 +49,8 @@ struct ChatTranscriptView: View
|
|||||||
{
|
{
|
||||||
var messages: [Display.Message]
|
var messages: [Display.Message]
|
||||||
var displayedConversation: Display.Conversation.ID? = nil
|
var displayedConversation: Display.Conversation.ID? = nil
|
||||||
|
var needsReload: Bool = true
|
||||||
|
|
||||||
init(messages: [Display.Message] = []) {
|
init(messages: [Display.Message] = []) {
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
observeDisplayedConversation()
|
observeDisplayedConversation()
|
||||||
@@ -45,21 +63,37 @@ struct ChatTranscriptView: View
|
|||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
await loadMessages()
|
setNeedsReload()
|
||||||
observeDisplayedConversation()
|
observeDisplayedConversation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setNeedsReload() {
|
||||||
|
needsReload = true
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await reloadMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func loadMessages() async {
|
func reloadMessages() async {
|
||||||
self.messages = []
|
guard needsReload else { return }
|
||||||
|
needsReload = false
|
||||||
|
|
||||||
guard let displayedConversation else { return }
|
guard let displayedConversation else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let client = XPCClient()
|
let client = XPCClient()
|
||||||
let messages = try await client.getMessages(conversationId: displayedConversation)
|
let clientMessages = try await client.getMessages(conversationId: displayedConversation)
|
||||||
self.messages = messages.map { Display.Message(from: $0) }
|
.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 {
|
} catch {
|
||||||
print("Message fetch error: \(error)")
|
print("Message fetch error: \(error)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import SwiftUI
|
|||||||
struct ConversationListView: View
|
struct ConversationListView: View
|
||||||
{
|
{
|
||||||
@Binding var model: ViewModel
|
@Binding var model: ViewModel
|
||||||
|
@Environment(\.xpcClient) private var xpcClient
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List($model.conversations, selection: $model.selectedConversations) { conv in
|
List($model.conversations, selection: $model.selectedConversations) { conv in
|
||||||
@@ -19,9 +20,26 @@ struct ConversationListView: View
|
|||||||
|
|
||||||
Text(conv.wrappedValue.messagePreview)
|
Text(conv.wrappedValue.messagePreview)
|
||||||
}
|
}
|
||||||
|
.id(conv.id)
|
||||||
.padding(8.0)
|
.padding(8.0)
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.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
|
// MARK: - Types
|
||||||
@@ -32,9 +50,35 @@ struct ConversationListView: View
|
|||||||
var conversations: [Display.Conversation]
|
var conversations: [Display.Conversation]
|
||||||
var selectedConversations: Set<Display.Conversation.ID>
|
var selectedConversations: Set<Display.Conversation.ID>
|
||||||
|
|
||||||
|
private var needsReload: Bool = true
|
||||||
|
|
||||||
public init(conversations: [Display.Conversation] = []) {
|
public init(conversations: [Display.Conversation] = []) {
|
||||||
self.conversations = conversations
|
self.conversations = conversations
|
||||||
self.selectedConversations = Set()
|
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
|
struct MessageEntryView: View
|
||||||
{
|
{
|
||||||
@Binding var viewModel: ViewModel
|
@Binding var viewModel: ViewModel
|
||||||
|
|
||||||
@Environment(\.selectedConversation) private var selectedConversation
|
@Environment(\.selectedConversation) private var selectedConversation
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -55,6 +54,8 @@ struct MessageEntryView: View
|
|||||||
guard !draftText.isEmpty else { return }
|
guard !draftText.isEmpty else { return }
|
||||||
|
|
||||||
let messageText = self.draftText
|
let messageText = self.draftText
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
self.draftText = ""
|
self.draftText = ""
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ enum Display
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Message: Identifiable
|
struct Message: Identifiable, Hashable
|
||||||
{
|
{
|
||||||
let id: String
|
let id: String
|
||||||
let sender: Sender
|
let sender: Sender
|
||||||
@@ -63,6 +63,14 @@ enum Display
|
|||||||
self.sender = sender
|
self.sender = sender
|
||||||
self.text = text
|
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
|
enum Sender
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,15 +13,47 @@ private let serviceName = "net.buzzert.kordophonecd"
|
|||||||
final class XPCClient
|
final class XPCClient
|
||||||
{
|
{
|
||||||
private let connection: xpc_connection_t
|
private let connection: xpc_connection_t
|
||||||
|
private let signalLock = NSLock()
|
||||||
|
private var signalSinks: [UUID: (Signal) -> Void] = [:]
|
||||||
|
private var didSubscribeSignals: Bool = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.connection = xpc_connection_create_mach_service(serviceName, nil, 0)
|
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_set_event_handler(connection, handler)
|
||||||
xpc_connection_resume(connection)
|
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 {
|
public func getVersion() async throws -> String {
|
||||||
let req = makeRequest(method: "GetVersion")
|
let req = makeRequest(method: "GetVersion")
|
||||||
guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError }
|
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) {
|
if xpc_get_type(element) == XPC_TYPE_DICTIONARY, let conv = Serialized.Conversation(xpc: element) {
|
||||||
results.append(conv)
|
results.append(conv)
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
@@ -66,6 +99,7 @@ final class XPCClient
|
|||||||
if xpc_get_type(element) == XPC_TYPE_DICTIONARY, let msg = Serialized.Message(xpc: element) {
|
if xpc_get_type(element) == XPC_TYPE_DICTIONARY, let msg = Serialized.Message(xpc: element) {
|
||||||
results.append(msg)
|
results.append(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,10 +122,24 @@ final class XPCClient
|
|||||||
case typeError
|
case typeError
|
||||||
case encodingError
|
case encodingError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Signal
|
||||||
|
{
|
||||||
|
case conversationsUpdated
|
||||||
|
case messagesUpdated(conversationId: String)
|
||||||
|
case attachmentDownloaded(attachmentId: String)
|
||||||
|
case attachmentUploaded(uploadGuid: String, attachmentGuid: String)
|
||||||
|
case updateStreamReconnected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension XPCClient
|
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 {
|
private func makeRequest(method: String, arguments: [String: xpc_object_t]? = nil) -> xpc_object_t {
|
||||||
let dict = xpc_dictionary_create(nil, nil, 0)
|
let dict = xpc_dictionary_create(nil, nil, 0)
|
||||||
xpc_dictionary_set_string(dict, "method", method)
|
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