initial commit
This commit is contained in:
@@ -250,7 +250,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
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;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -277,7 +277,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
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;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
|||||||
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/>
|
<true/>
|
||||||
<key>com.apple.security.files.user-selected.read-only</key>
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||||
|
<array>
|
||||||
|
<string>net.buzzert.kordophonecd</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</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