initial commit
This commit is contained in:
44
kordophone2/App.swift
Normal file
44
kordophone2/App.swift
Normal file
@@ -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)")
|
||||
}
|
||||
}
|
||||
7
kordophone2/ChatTranscriptView.swift
Normal file
7
kordophone2/ChatTranscriptView.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
//
|
||||
// ChatTranscriptView.swift
|
||||
// kordophone2
|
||||
//
|
||||
// Created by James Magahern on 8/24/25.
|
||||
//
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
49
kordophone2/ConversationListView.swift
Normal file
49
kordophone2/ConversationListView.swift
Normal file
@@ -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<Display.Conversation.ID>
|
||||
|
||||
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)
|
||||
}
|
||||
73
kordophone2/Models.swift
Normal file
73
kordophone2/Models.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,9 @@
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||
<array>
|
||||
<string>net.buzzert.kordophonecd</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
107
kordophone2/XPC/XPCClient.swift
Normal file
107
kordophone2/XPC/XPCClient.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
94
kordophone2/XPC/XPCConvertible.swift
Normal file
94
kordophone2/XPC/XPCConvertible.swift
Normal file
@@ -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<T: XPCConvertible>(_ 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<T: XPCConvertible>(key: String) -> T? {
|
||||
return get(key)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user