From 46755a07ef2e7aa9852d74c30e2c12f9fe8f2278 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 3 Sep 2025 22:38:26 -0700 Subject: [PATCH] Implements attachment uploading --- kordophone2/Attachments.swift | 5 +- kordophone2/MessageEntryView.swift | 87 +++++++++++++++++++++++++++--- kordophone2/XPC/XPCClient.swift | 25 ++++++++- 3 files changed, 106 insertions(+), 11 deletions(-) diff --git a/kordophone2/Attachments.swift b/kordophone2/Attachments.swift index c8643a3..497d29e 100644 --- a/kordophone2/Attachments.swift +++ b/kordophone2/Attachments.swift @@ -12,11 +12,12 @@ enum AttachmentState { case staged case uploading(progress: Double) - case uploaded + case uploaded(String) // fileTransferGUID case failed } -struct OutgoingAttachment: Identifiable, Hashable +@Observable +class OutgoingAttachment: Identifiable, Hashable { let id: String let originalURL: URL diff --git a/kordophone2/MessageEntryView.swift b/kordophone2/MessageEntryView.swift index deacbb8..51438b4 100644 --- a/kordophone2/MessageEntryView.swift +++ b/kordophone2/MessageEntryView.swift @@ -47,12 +47,11 @@ struct MessageEntryView: View Button("Send") { viewModel.sendDraft(to: selectedConversation) } - .disabled(viewModel.draftText.isEmpty) + .disabled(viewModel.draftText.isEmpty && viewModel.uploadedAttachmentGUIDs.isEmpty) .keyboardShortcut(.defaultAction) } .padding(10.0) } - .onChange(of: selectedConversation) { oldValue, newValue in if oldValue?.id != newValue?.id { viewModel.draftText = "" @@ -69,20 +68,61 @@ struct MessageEntryView: View var attachments: [OutgoingAttachment] = [] var isDropTargeted: Bool = false + var uploadedAttachmentGUIDs: Set { + attachments.reduce(Set()) { partialResult, attachment in + switch attachment.state { + case .uploaded(let guid): partialResult.union([ guid ]) + default: partialResult + } + } + } + + private let client = XPCClient() + private var uploadGuidToAttachmentId: [String: String] = [:] + private var signalTask: Task? = nil + + init() { + signalTask = Task { [weak self] in + guard let self else { return } + for await signal in client.eventStream() { + switch signal { + case .attachmentUploaded(let uploadGuid, let attachmentGuid): + // Mark local attachment as uploaded when the daemon confirms + await MainActor.run { + if let localId = self.uploadGuidToAttachmentId[uploadGuid], + let idx = self.attachments.firstIndex(where: { $0.id == localId }) { + self.attachments[idx].state = .uploaded(attachmentGuid) + self.uploadGuidToAttachmentId.removeValue(forKey: uploadGuid) + } + } + default: + break + } + } + } + } + + deinit { signalTask?.cancel() } + func sendDraft(to convo: Display.Conversation?) { guard let convo else { return } - guard !draftText.isEmpty else { return } + guard !(draftText.isEmpty && uploadedAttachmentGUIDs.isEmpty) else { return } let messageText = self.draftText .trimmingCharacters(in: .whitespacesAndNewlines) + let transferGuids = self.uploadedAttachmentGUIDs + self.draftText = "" + self.attachments = [] Task { - let xpc = XPCClient() - do { - try await xpc.sendMessage(conversationId: convo.id, message: messageText) + try await client.sendMessage( + conversationId: convo.id, + message: messageText, + transferGuids: transferGuids + ) } catch { print("Sending error: \(error)") } @@ -123,7 +163,37 @@ struct MessageEntryView: View private func stageAndAppend(url: URL) { guard let att = AttachmentManager.shared.stageIfImage(url: url) else { return } - Task { @MainActor in attachments.append(att) } + Task { @MainActor in + attachments.append(att) + startUploadIfNeeded(for: att.id) + } + } + + private func startUploadIfNeeded(for attachmentId: String) { + guard let idx = attachments.firstIndex(where: { $0.id == attachmentId }) else { return } + + let attachment = attachments[idx] + switch attachment.state { + case .staged, .failed: + attachments[idx].state = .uploading(progress: 0.0) + + Task { + do { + let guid = try await client.uploadAttachment(path: attachment.stagedURL.path) + await MainActor.run { + self.uploadGuidToAttachmentId[guid] = attachmentId + } + } catch { + await MainActor.run { + if let i = self.attachments.firstIndex(where: { $0.id == attachmentId }) { + self.attachments[i].state = .failed + } + } + } + } + default: + break + } } } @@ -173,7 +243,8 @@ struct MessageEntryView: View } } - struct AttachmentThumbnail: View { + struct AttachmentThumbnail: View + { let attachment: OutgoingAttachment var body: some View { diff --git a/kordophone2/XPC/XPCClient.swift b/kordophone2/XPC/XPCClient.swift index 8725944..a0a5f09 100644 --- a/kordophone2/XPC/XPCClient.swift +++ b/kordophone2/XPC/XPCClient.swift @@ -133,10 +133,14 @@ final class XPCClient return results } - public func sendMessage(conversationId: String, message: String) async throws { + public func sendMessage(conversationId: String, message: String, transferGuids: Set) async throws { var args: [String: xpc_object_t] = [:] args["conversation_id"] = xpcString(conversationId) args["text"] = xpcString(message) + + if !transferGuids.isEmpty { + args["attachment_guids"] = xpcStringArray(transferGuids) + } let req = makeRequest(method: "SendMessage", arguments: args) guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError } @@ -150,6 +154,16 @@ final class XPCClient let req = makeRequest(method: "DownloadAttachment", arguments: args) _ = try await sendSync(req) } + + public func uploadAttachment(path: String) async throws -> String { + var args: [String: xpc_object_t] = [:] + args["path"] = xpcString(path) + + let req = makeRequest(method: "UploadAttachment", arguments: args) + guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError } + guard let guid: String = reply["upload_guid"] else { throw Error.encodingError } + return guid + } public func openAttachmentFileHandle(attachmentId: String, preview: Bool) async throws -> FileHandle { var args: [String: xpc_object_t] = [:] @@ -274,6 +288,15 @@ extension XPCClient return s.withCString { ptr in xpc_string_create(ptr) } } + private func xpcStringArray(_ set: Set) -> xpc_object_t { + let array = xpc_array_create(nil, 0) + for str in set { + xpc_array_append_value(array, xpcString(str)) + } + + return array + } + private func sendSync(_ request: xpc_object_t) async throws -> xpc_object_t? { try await withCheckedThrowingContinuation { continuation in let conn: xpc_connection_t? = self.connectionQueue.sync { self.connection }