diff --git a/kordophone2.xcodeproj/project.pbxproj b/kordophone2.xcodeproj/project.pbxproj index 8916910..764b51e 100644 --- a/kordophone2.xcodeproj/project.pbxproj +++ b/kordophone2.xcodeproj/project.pbxproj @@ -250,7 +250,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = kordophone2/kordophone2.entitlements; + CODE_SIGN_ENTITLEMENTS = "kordophone2/Supporting Files/kordophone2.entitlements"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; @@ -277,7 +277,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = kordophone2/kordophone2.entitlements; + CODE_SIGN_ENTITLEMENTS = "kordophone2/Supporting Files/kordophone2.entitlements"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; diff --git a/kordophone2/App.swift b/kordophone2/App.swift new file mode 100644 index 0000000..82e0dcb --- /dev/null +++ b/kordophone2/App.swift @@ -0,0 +1,44 @@ +// +// kordophone2App.swift +// kordophone2 +// +// Created by James Magahern on 8/24/25. +// + +import SwiftUI + +@main +struct KordophoneApp: App +{ + @State var conversationListModel = ConversationListView.ViewModel() + + private let xpcClient = XPCClient() + + var body: some Scene { + WindowGroup { + NavigationSplitView { + ConversationListView(model: $conversationListModel) + .frame(minWidth: 330.0) + .task { + await refreshConversations() + } + } detail: { + // Detail + } + } + } + + 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)") + } +} diff --git a/kordophone2/ChatTranscriptView.swift b/kordophone2/ChatTranscriptView.swift new file mode 100644 index 0000000..69e63f1 --- /dev/null +++ b/kordophone2/ChatTranscriptView.swift @@ -0,0 +1,7 @@ +// +// ChatTranscriptView.swift +// kordophone2 +// +// Created by James Magahern on 8/24/25. +// + diff --git a/kordophone2/ContentView.swift b/kordophone2/ContentView.swift deleted file mode 100644 index cf1b332..0000000 --- a/kordophone2/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// kordophone2 -// -// Created by James Magahern on 8/24/25. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/kordophone2/ConversationListView.swift b/kordophone2/ConversationListView.swift new file mode 100644 index 0000000..2682cce --- /dev/null +++ b/kordophone2/ConversationListView.swift @@ -0,0 +1,49 @@ +// +// ConversationListView.swift +// kordophone2 +// +// Created by James Magahern on 8/24/25. +// + +import SwiftUI + +struct ConversationListView: View +{ + @Binding var model: ViewModel + + var body: some View { + List($model.conversations, selection: $model.selectedConversations) { conv in + VStack(alignment: .leading) { + Text(conv.wrappedValue.displayName) + .bold() + + Text(conv.wrappedValue.messagePreview) + } + .padding(8.0) + } + .listStyle(.sidebar) + } + + // MARK: - Types + + @Observable + class ViewModel + { + var conversations: [Display.Conversation] + var selectedConversations: Set + + public init(conversations: [Display.Conversation] = []) { + self.conversations = conversations + self.selectedConversations = Set() + } + } +} + +#Preview { + @Previewable @State var viewModel = ConversationListView.ViewModel(conversations: [ + .init(id: "asdf", name: "Cool", participants: ["me"], messagePreview: "Hello there"), + .init(id: "gjkl", name: "Nice", participants: ["me"], messagePreview: "How are you"), + ]) + + ConversationListView(model: $viewModel) +} diff --git a/kordophone2/Models.swift b/kordophone2/Models.swift new file mode 100644 index 0000000..e71041f --- /dev/null +++ b/kordophone2/Models.swift @@ -0,0 +1,73 @@ +// +// Models.swift +// kordophone2 +// +// Created by James Magahern on 8/24/25. +// + +import Foundation +import XPC + +enum Display +{ + struct Conversation: Identifiable + { + let id: String + let name: String? + let participants: [String] + let messagePreview: String + + var displayName: String { + if let name, name.count > 0 { return name } + else { return participants.joined(separator: ", ") } + } + + init(from c: Serialized.Conversation) { + self.id = c.guid + self.name = c.displayName + self.participants = c.participants + self.messagePreview = c.lastMessagePreview ?? "" + } + + init(id: String = UUID().uuidString, name: String? = nil, participants: [String], messagePreview: String) { + self.id = id + self.name = name + self.participants = participants + self.messagePreview = messagePreview + } + } +} + +enum Serialized +{ + struct Conversation: Decodable + { + let guid: String + let displayName: String? + let participants: [String] + let lastMessagePreview: String? + let unreadCount: Int + let date: Date + + init?(xpc dict: xpc_object_t) + { + guard let g: String = dict["guid"] else { return nil } + + let dn: String? = dict["display_name"] + let lmp: String? = dict["last_message_preview"] + + let names: [String] = dict["participants"] ?? [] + + let unread: Int = dict["unread_count"] ?? 0 + + let dt: Date = dict["date"] ?? Date(timeIntervalSince1970: 0) + + self.guid = g + self.displayName = dn + self.participants = names + self.lastMessagePreview = lmp + self.unreadCount = unread + self.date = dt + } + } +} diff --git a/kordophone2/Assets.xcassets/AccentColor.colorset/Contents.json b/kordophone2/Supporting Files/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from kordophone2/Assets.xcassets/AccentColor.colorset/Contents.json rename to kordophone2/Supporting Files/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/kordophone2/Assets.xcassets/AppIcon.appiconset/Contents.json b/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from kordophone2/Assets.xcassets/AppIcon.appiconset/Contents.json rename to kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/kordophone2/Assets.xcassets/Contents.json b/kordophone2/Supporting Files/Assets.xcassets/Contents.json similarity index 100% rename from kordophone2/Assets.xcassets/Contents.json rename to kordophone2/Supporting Files/Assets.xcassets/Contents.json diff --git a/kordophone2/kordophone2.entitlements b/kordophone2/Supporting Files/kordophone2.entitlements similarity index 69% rename from kordophone2/kordophone2.entitlements rename to kordophone2/Supporting Files/kordophone2.entitlements index 18aff0c..8f230e7 100644 --- a/kordophone2/kordophone2.entitlements +++ b/kordophone2/Supporting Files/kordophone2.entitlements @@ -6,5 +6,9 @@ com.apple.security.files.user-selected.read-only + com.apple.security.temporary-exception.mach-lookup.global-name + + net.buzzert.kordophonecd + diff --git a/kordophone2/XPC/XPCClient.swift b/kordophone2/XPC/XPCClient.swift new file mode 100644 index 0000000..d82c263 --- /dev/null +++ b/kordophone2/XPC/XPCClient.swift @@ -0,0 +1,107 @@ +// +// XPCClient.swift +// kordophone2 +// +// Created by James Magahern on 8/24/25. +// + +import Foundation +import XPC + +private let serviceName = "net.buzzert.kordophonecd" + +final class XPCClient +{ + private let connection: xpc_connection_t + + init() { + self.connection = xpc_connection_create_mach_service(serviceName, nil, 0) + + let handler: xpc_handler_t = { _ in } + xpc_connection_set_event_handler(connection, handler) + xpc_connection_resume(connection) + } + + 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 } + if let cstr = xpc_dictionary_get_string(reply, "version") { + return String(cString: cstr) + } + + throw Error.typeError + } + + public func getConversations(limit: Int = 100, offset: Int = 0) async throws -> [Serialized.Conversation] { + var args: [String: xpc_object_t] = [:] + args["limit"] = xpcString(String(limit)) + args["offset"] = xpcString(String(offset)) + + let req = makeRequest(method: "GetConversations", arguments: args) + guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { return [] } + guard let items = xpc_dictionary_get_value(reply, "conversations"), xpc_get_type(items) == XPC_TYPE_ARRAY else { return [] } + + var results: [Serialized.Conversation] = [] + xpc_array_apply(items) { _, element in + if xpc_get_type(element) == XPC_TYPE_DICTIONARY, let conv = Serialized.Conversation(xpc: element) { + results.append(conv) + } + return true + } + return results + } + + // MARK: - Types + + enum Error: Swift.Error + { + case typeError + case encodingError + } +} + +extension XPCClient +{ + 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) + if let args = arguments { + let argsDict = xpc_dictionary_create(nil, nil, 0) + for (k, v) in args { + k.withCString { cKey in + xpc_dictionary_set_value(argsDict, cKey, v) + } + } + xpc_dictionary_set_value(dict, "arguments", argsDict) + } + return dict + } + + private func xpcString(_ s: String) -> xpc_object_t { + return s.withCString { ptr in xpc_string_create(ptr) } + } + + private func sendSync(_ request: xpc_object_t) async throws -> xpc_object_t? { + try await withCheckedThrowingContinuation { continuation in + xpc_connection_send_message_with_reply(connection, request, DispatchQueue.global(qos: .userInitiated)) { r in + switch xpc_get_type(r) { + case XPC_TYPE_ERROR: + let error = xpc_dictionary_get_value(r, "error") + if let error = error, let errorString = xpc_string_get_string_ptr(error) { + print("XPC error: \(String(cString: errorString))") + } + + continuation.resume(throwing: Error.typeError) + + case XPC_TYPE_DICTIONARY: + continuation.resume(returning: r) + + default: + continuation.resume(throwing: Error.typeError) + } + } + } + } +} + + diff --git a/kordophone2/XPC/XPCConvertible.swift b/kordophone2/XPC/XPCConvertible.swift new file mode 100644 index 0000000..1613273 --- /dev/null +++ b/kordophone2/XPC/XPCConvertible.swift @@ -0,0 +1,94 @@ +// +// XPCConvertible.swift +// kordophone2 +// +// Created by James Magahern on 8/24/25. +// + +import Foundation +import XPC + +protocol XPCConvertible +{ + static func fromXPC(_ value: xpc_object_t) -> Self? +} + +extension String: XPCConvertible +{ + static func fromXPC(_ value: xpc_object_t) -> String? { + guard xpc_get_type(value) == XPC_TYPE_STRING, let cstr = xpc_string_get_string_ptr(value) else { + return nil + } + + return String(cString: cstr) + } +} + +extension Int: XPCConvertible +{ + static func fromXPC(_ value: xpc_object_t) -> Int? { + switch xpc_get_type(value) { + case XPC_TYPE_INT64: + return Int(xpc_int64_get_value(value)) + case XPC_TYPE_UINT64: + return Int(xpc_uint64_get_value(value)) + case XPC_TYPE_STRING: + if let cstr = xpc_string_get_string_ptr(value) { + return Int(String(cString: cstr)) + } + default: + return nil + } + + return nil + } +} + +extension Date: XPCConvertible +{ + static func fromXPC(_ value: xpc_object_t) -> Date? { + // Accept seconds since epoch as int/uint or string + if let seconds: Int = Int.fromXPC(value) { + return Date(timeIntervalSince1970: TimeInterval(seconds)) + } + + return nil + } +} + +extension Array: XPCConvertible where Element: XPCConvertible +{ + static func fromXPC(_ value: xpc_object_t) -> [Element]? { + guard xpc_get_type(value) == XPC_TYPE_ARRAY else { + return nil + } + + var result: [Element] = [] + xpc_array_apply(value) { _, item in + if let element = Element.fromXPC(item) { + result.append(element) + } + + return true + } + + return result + } +} + +extension xpc_object_t +{ + func get(_ key: String) -> T? { + var raw: xpc_object_t? + key.withCString { cKey in + raw = xpc_dictionary_get_value(self, cKey) + } + + guard let value = raw else { return nil } + return T.fromXPC(value) + } + + subscript(key: String) -> T? { + return get(key) + } +} diff --git a/kordophone2/kordophone2App.swift b/kordophone2/kordophone2App.swift deleted file mode 100644 index d9de3f9..0000000 --- a/kordophone2/kordophone2App.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// kordophone2App.swift -// kordophone2 -// -// Created by James Magahern on 8/24/25. -// - -import SwiftUI - -@main -struct kordophone2App: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -}