Private
Public Access
1
0

Add 'osx/' from commit '46755a07ef2e7aa9852d74c30e2c12f9fe8f2278'

git-subtree-dir: osx
git-subtree-mainline: 034026e88a
git-subtree-split: 46755a07ef
This commit is contained in:
2025-09-06 19:38:26 -07:00
22 changed files with 2598 additions and 0 deletions

27
osx/kordophone2/App.swift Normal file
View File

@@ -0,0 +1,27 @@
//
// kordophone2App.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import SwiftUI
@main
struct KordophoneApp: App
{
var body: some Scene {
WindowGroup {
SplitView()
}
Settings {
PreferencesView()
}
}
private func reportError(_ e: Error) {
// Just printing for now.
print("Error: \(e.localizedDescription)")
}
}

View File

@@ -0,0 +1,102 @@
//
// Attachments.swift
// kordophone2
//
// Created by Assistant on 8/31/25.
//
import Foundation
import UniformTypeIdentifiers
enum AttachmentState
{
case staged
case uploading(progress: Double)
case uploaded(String) // fileTransferGUID
case failed
}
@Observable
class OutgoingAttachment: Identifiable, Hashable
{
let id: String
let originalURL: URL
let stagedURL: URL
let fileName: String
let contentType: UTType?
var state: AttachmentState
init(id: String = UUID().uuidString,
originalURL: URL,
stagedURL: URL,
fileName: String? = nil,
contentType: UTType? = nil,
state: AttachmentState = .staged)
{
self.id = id
self.originalURL = originalURL
self.stagedURL = stagedURL
self.fileName = fileName ?? originalURL.lastPathComponent
self.contentType = contentType
self.state = state
}
static func ==(lhs: OutgoingAttachment, rhs: OutgoingAttachment) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(originalURL)
hasher.combine(stagedURL)
hasher.combine(fileName)
}
}
final class AttachmentManager
{
static let shared = AttachmentManager()
private let fileManager = FileManager.default
private let stagingRoot: URL
private init() {
let base = fileManager.temporaryDirectory.appendingPathComponent("kordophone-staging", isDirectory: true)
try? fileManager.createDirectory(at: base, withIntermediateDirectories: true)
self.stagingRoot = base
}
func stageIfImage(url: URL) -> OutgoingAttachment? {
guard isImage(url: url) else { return nil }
let staged = stage(url: url)
let type = (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType
?? UTType(filenameExtension: url.pathExtension)
return OutgoingAttachment(originalURL: url, stagedURL: staged, fileName: url.lastPathComponent, contentType: type)
}
// MARK: - Helpers
private func isImage(url: URL) -> Bool {
if let type = (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType {
return type.conforms(to: .image)
}
return UTType(filenameExtension: url.pathExtension)?.conforms(to: .image) ?? false
}
private func stage(url: URL) -> URL {
let ext = url.pathExtension
let dest = stagingRoot.appendingPathComponent(UUID().uuidString).appendingPathExtension(ext)
do {
if fileManager.fileExists(atPath: dest.path) { try? fileManager.removeItem(at: dest) }
try fileManager.copyItem(at: url, to: dest)
return dest
} catch {
do {
try fileManager.moveItem(at: url, to: dest)
return dest
} catch {
return url
}
}
}
}

View File

@@ -0,0 +1,118 @@
//
// ConversationListView.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import SwiftUI
struct ConversationListView: View
{
@Binding var model: ViewModel
@Environment(\.xpcClient) private var xpcClient
var body: some View {
List($model.conversations, selection: $model.selectedConversations) { conv in
let isUnread = conv.wrappedValue.unreadCount > 0
HStack(spacing: 0.0) {
if isUnread {
Image(systemName: "circlebadge.fill")
.foregroundStyle(.tint)
.frame(width: 10.0)
} else {
Rectangle()
.foregroundStyle(.clear)
.frame(width: 10.0)
}
VStack(alignment: .leading) {
Text(conv.wrappedValue.displayName)
.bold()
Text(conv.wrappedValue.messagePreview)
.foregroundStyle(.secondary)
}
.padding(8.0)
}
.id(conv.id)
}
.listStyle(.sidebar)
.task { await watchForConversationListChanges() }
.task { await model.triggerSync() }
}
private func watchForConversationListChanges() async {
for await event in xpcClient.eventStream() {
switch event {
case .conversationsUpdated:
model.setNeedsReload()
case .messagesUpdated(_):
await model.triggerSync()
case .updateStreamReconnected:
await model.triggerSync()
default:
break
}
}
}
// MARK: - Types
@Observable
class ViewModel
{
var conversations: [Display.Conversation]
var selectedConversations: Set<Display.Conversation.ID>
private var needsReload: Bool = true
private let client = XPCClient()
public init(conversations: [Display.Conversation] = []) {
self.conversations = conversations
self.selectedConversations = Set()
setNeedsReload()
}
func triggerSync() async {
do {
try await client.syncConversationList()
} catch {
print("Conversation List Sync Error: \(error)")
}
}
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 clientConversations = try await client.getConversations()
.map { Display.Conversation(from: $0) }
self.conversations = clientConversations
} catch {
print("Error reloading conversations: \(error)")
}
}
}
}
#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)
}

View File

@@ -0,0 +1,27 @@
//
// ConversationView.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import SwiftUI
import UniformTypeIdentifiers
struct ConversationView: View
{
@Binding var transcriptModel: TranscriptView.ViewModel
@Binding var entryModel: MessageEntryView.ViewModel
var body: some View {
VStack {
TranscriptView(model: $transcriptModel)
MessageEntryView(viewModel: $entryModel)
}
.onDrop(of: [UTType.image, UTType.fileURL], isTargeted: $entryModel.isDropTargeted) { providers in
entryModel.handleDroppedProviders(providers)
return true
}
}
}

Binary file not shown.

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>net.buzzert.kordophonecd</string>
<key>BundleProgram</key>
<string>Contents/MacOS/kordophoned</string>
<key>EnvironmentVariables</key>
<dict>
<key>RUST_LOG</key>
<string>info</string>
</dict>
<key>MachServices</key>
<dict>
<key>net.buzzert.kordophonecd</key>
<true/>
</dict>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/kordophoned.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/kordophoned.err.log</string>
</dict>
</plist>

View File

@@ -0,0 +1,26 @@
//
// Environment.swift
// kordophone2
//
// Created by James Magahern on 8/29/25.
//
import SwiftUI
extension EnvironmentValues
{
@Entry var xpcClient: XPCClient = XPCClient()
@Entry var selectedConversation: Display.Conversation? = nil
}
extension View
{
func xpcClient(_ client: XPCClient) -> some View {
environment(\.xpcClient, client)
}
func selectedConversation(_ convo: Display.Conversation?) -> some View {
environment(\.selectedConversation, convo)
}
}

View File

@@ -0,0 +1,302 @@
//
// MessageEntryView.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import AppKit
import SwiftUI
import UniformTypeIdentifiers
struct MessageEntryView: View
{
@Binding var viewModel: ViewModel
@Environment(\.selectedConversation) private var selectedConversation
var body: some View {
VStack(spacing: 0.0) {
Separator()
HStack {
VStack {
if !viewModel.attachments.isEmpty {
AttachmentWell(attachments: $viewModel.attachments)
.frame(height: 150.0)
Separator()
}
TextField("iMessage", text: $viewModel.draftText, axis: .vertical)
.focusEffectDisabled(true)
.textFieldStyle(.plain)
.lineLimit(nil)
.scrollContentBackground(.hidden)
.fixedSize(horizontal: false, vertical: true)
.font(.body)
.scrollDisabled(true)
.disabled(selectedConversation == nil)
}
.padding(8.0)
.background {
RoundedRectangle(cornerRadius: 8.0)
.stroke(SeparatorShapeStyle())
.fill(.background)
}
Button("Send") {
viewModel.sendDraft(to: selectedConversation)
}
.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 = ""
}
}
}
// MARK: - Types
@Observable
class ViewModel
{
var draftText: String = ""
var attachments: [OutgoingAttachment] = []
var isDropTargeted: Bool = false
var uploadedAttachmentGUIDs: Set<String> {
attachments.reduce(Set<String>()) { 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<Void, Never>? = 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 && uploadedAttachmentGUIDs.isEmpty) else { return }
let messageText = self.draftText
.trimmingCharacters(in: .whitespacesAndNewlines)
let transferGuids = self.uploadedAttachmentGUIDs
self.draftText = ""
self.attachments = []
Task {
do {
try await client.sendMessage(
conversationId: convo.id,
message: messageText,
transferGuids: transferGuids
)
} catch {
print("Sending error: \(error)")
}
}
}
// MARK: - Attachments
func handleDroppedProviders(_ providers: [NSItemProvider]) {
let imageId = UTType.image.identifier
let fileURLId = UTType.fileURL.identifier
for provider in providers {
if provider.hasItemConformingToTypeIdentifier(fileURLId) {
provider.loadItem(forTypeIdentifier: fileURLId, options: nil) { item, error in
if let error { print("Drop load fileURL error: \(error)"); return }
if let url = item as? URL {
self.stageAndAppend(url: url)
} else if let data = item as? Data,
let s = String(data: data, encoding: .utf8),
let url = URL(string: s.trimmingCharacters(in: .whitespacesAndNewlines)) {
self.stageAndAppend(url: url)
}
}
continue
}
if provider.hasItemConformingToTypeIdentifier(imageId) {
provider.loadFileRepresentation(forTypeIdentifier: imageId) { url, error in
if let error { print("Drop load image error: \(error)"); return }
guard let url else { return }
self.stageAndAppend(url: url)
}
continue
}
}
}
private func stageAndAppend(url: URL) {
guard let att = AttachmentManager.shared.stageIfImage(url: url) else { return }
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
}
}
}
struct Separator: View
{
var body: some View {
Rectangle()
.fill(.separator)
.frame(height: 1.0)
}
}
struct AttachmentWell: View
{
@Binding var attachments: [OutgoingAttachment]
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 8.0) {
ForEach(attachments) { attachment in
ZStack(alignment: .topTrailing) {
AttachmentThumbnail(attachment: attachment)
.clipShape(RoundedRectangle(cornerRadius: 8.0))
.overlay {
RoundedRectangle(cornerRadius: 8.0)
.stroke(.separator)
}
Button {
if let idx = attachments.firstIndex(where: { $0.id == attachment.id }) {
attachments.remove(at: idx)
}
} label: {
Image(systemName: "xmark.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.padding(6.0)
}
}
Spacer()
}
.padding(8.0)
}
}
}
struct AttachmentThumbnail: View
{
let attachment: OutgoingAttachment
var body: some View {
ZStack {
if let img = NSImage(contentsOf: attachment.stagedURL) {
Image(nsImage: img)
.resizable()
.scaledToFill()
.frame(width: 180.0, height: 120.0)
.clipped()
.background(.quaternary)
} else {
ZStack {
Rectangle()
.fill(.quaternary)
Image(systemName: "photo")
.font(.title2)
.foregroundStyle(.secondary)
}
.frame(width: 180.0, height: 120.0)
}
switch attachment.state {
case .uploading(let progress):
VStack {
Spacer()
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(.accentColor)
.padding(.horizontal, 8.0)
.padding(.bottom, 6.0)
}
case .failed:
HStack {
Spacer()
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
.padding(6.0)
}
default:
EmptyView()
}
}
}
}
}
#Preview {
@Previewable @State var model = MessageEntryView.ViewModel()
VStack {
Spacer()
MessageEntryView(viewModel: $model)
}
}

View File

@@ -0,0 +1,282 @@
//
// Models.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import Foundation
import XPC
enum Display
{
struct Conversation: Identifiable, Hashable
{
let id: String
let name: String?
let participants: [String]
let messagePreview: String
let unreadCount: Int
var displayName: String {
if let name, name.count > 0 { return name }
else { return participants.joined(separator: ", ") }
}
var isGroupChat: Bool {
participants.count > 1
}
init(from c: Serialized.Conversation) {
self.id = c.guid
self.name = c.displayName
self.participants = c.participants
self.messagePreview = c.lastMessagePreview ?? ""
self.unreadCount = c.unreadCount
}
init(id: String = UUID().uuidString, name: String? = nil, participants: [String], messagePreview: String) {
self.id = id
self.name = name
self.participants = participants
self.messagePreview = messagePreview
self.unreadCount = 0
}
}
struct Message: Identifiable, Hashable
{
let id: String
let sender: Sender
let text: String
let date: Date
let attachments: [ImageAttachment]
var isFromMe: Bool { sender.isMe }
init(from m: Serialized.Message) {
self.id = m.guid
self.text = m.text
self.date = m.date
let sender: Sender = if m.sender == "(Me)" {
.me
} else {
.counterpart(m.sender)
}
self.attachments = m.attachments.map { attachment in
ImageAttachment(from: attachment, dateSent: m.date, sender: sender)
}
self.sender = sender
}
init(id: String = UUID().uuidString, sender: Sender = .me, date: Date = .now, text: String) {
self.id = id
self.sender = sender
self.text = text
self.date = date
self.attachments = []
}
static func == (lhs: Message, rhs: Message) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
struct ImageAttachment: Identifiable
{
let id: String
let sender: Sender
let dateSent: Date
let data: Serialized.Attachment
var size: CGSize? {
if let attr = data.metadata?.attributionInfo, let width = attr.width, let height = attr.height {
return CGSize(width: width, height: height)
}
return nil
}
var isPreviewDownloaded: Bool {
data.isPreviewDownloaded
}
var previewPath: String {
data.previewPath
}
init(from serialized: Serialized.Attachment, dateSent: Date, sender: Sender) {
self.id = serialized.guid
self.sender = sender
self.data = serialized
self.dateSent = dateSent
}
}
enum Sender: Identifiable, Equatable
{
case me
case counterpart(String)
var id: String { displayName }
var isMe: Bool {
if case .me = self { true } else { false }
}
var displayName: String {
switch self {
case .me:
"Me"
case .counterpart(let string):
string
}
}
static func ==(lhs: Sender, rhs: Sender) -> Bool {
return lhs.displayName == rhs.displayName
}
}
}
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 d = XPCDictionary(dict), let g: String = d["guid"] else { return nil }
let dn: String? = d["display_name"]
let lmp: String? = d["last_message_preview"]
let names: [String] = d["participants"] ?? []
let unread: Int = d["unread_count"] ?? 0
let dt: Date = d["date"] ?? Date(timeIntervalSince1970: 0)
self.guid = g
self.displayName = dn
self.participants = names
self.lastMessagePreview = lmp
self.unreadCount = unread
self.date = dt
}
}
struct Message: Decodable
{
let guid: String
let sender: String
let text: String
let date: Date
let attachments: [Attachment]
init?(xpc dict: xpc_object_t)
{
guard let d = XPCDictionary(dict), let g: String = d["id"] else { return nil }
let s: String = d["sender"] ?? ""
let t: String = d["text"] ?? ""
let dd: Date = d["date"] ?? Date(timeIntervalSince1970: 0)
let atts: [Attachment] = d["attachments"] ?? []
self.guid = g
self.sender = s
self.text = t
self.date = dd
self.attachments = atts
}
}
struct Attachment: Decodable
{
let guid: String
let path: String
let previewPath: String
let isDownloaded: Bool
let isPreviewDownloaded: Bool
let metadata: Metadata?
struct Metadata: Decodable
{
let attributionInfo: AttributionInfo?
}
struct AttributionInfo: Decodable
{
let width: Int?
let height: Int?
}
}
struct Settings: Decodable
{
let serverUrl: String
let username: String
}
}
extension Serialized.Settings: XPCConvertible
{
static func fromXPC(_ value: xpc_object_t) -> Serialized.Settings? {
guard let d = XPCDictionary(value) else { return nil }
let su: String = d["server_url"] ?? ""
let un: String = d["username"] ?? ""
return Serialized.Settings(
serverUrl: su,
username: un
)
}
}
extension Serialized.Attachment: XPCConvertible
{
static func fromXPC(_ value: xpc_object_t) -> Serialized.Attachment? {
guard let d = XPCDictionary(value), let guid: String = d["guid"] else { return nil }
let path: String = d["path"] ?? ""
let previewPath: String = d["preview_path"] ?? ""
// Booleans are encoded as strings in XPC
let downloadedStr: String = d["downloaded"] ?? "false"
let previewDownloadedStr: String = d["preview_downloaded"] ?? "false"
let isDownloaded = downloadedStr == "true"
let isPreviewDownloaded = previewDownloadedStr == "true"
var metadata: Serialized.Attachment.Metadata? = nil
if let metadataObj = d.object("metadata"), let md = XPCDictionary(metadataObj) {
var attribution: Serialized.Attachment.AttributionInfo? = nil
if let attrObj = md.object("attribution_info"), let ad = XPCDictionary(attrObj) {
let width: Int? = ad["width"]
let height: Int? = ad["height"]
attribution = Serialized.Attachment.AttributionInfo(width: width, height: height)
}
metadata = Serialized.Attachment.Metadata(attributionInfo: attribution)
}
return Serialized.Attachment(
guid: guid,
path: path,
previewPath: previewPath,
isDownloaded: isDownloaded,
isPreviewDownloaded: isPreviewDownloaded,
metadata: metadata
)
}
}

View File

@@ -0,0 +1,110 @@
//
// PreferencesView.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import KeychainAccess
import SwiftUI
struct PreferencesView: View
{
@State var accountSettingsModel = AccountSettings.ViewModel()
var body: some View {
TabView {
AccountSettings(model: $accountSettingsModel)
.tabItem { Label("Account", systemImage: "person.crop.circle") }
}
.frame(width: 480.0, height: 300.0)
.padding(20.0)
}
}
struct AccountSettings: View
{
@Binding var model: ViewModel
var body: some View {
Form {
Section("Server Settings") {
TextField("Server", text: $model.serverURL)
}
Spacer()
.frame(height: 44.0)
Section("Authentication") {
TextField("Username", text: $model.username)
.textContentType(.username)
SecureField("Password", text: $model.password)
.textContentType(.password)
}
}
.task { await model.load() }
}
// MARK: - Types
@Observable
class ViewModel
{
var serverURL: String
var username: String
var password: String
private let xpc = XPCClient()
private let keychain = Keychain(service: "net.buzzert.kordophonecd")
init(serverURL: String = "", username: String = "", password: String = "") {
self.serverURL = serverURL
self.username = username
self.password = password
autosave()
}
func load() async {
do {
let settings = try await xpc.getSettings()
self.serverURL = settings.serverUrl
self.username = settings.username
self.password = keychain[settings.username] ?? ""
} catch {
print("Error getting settings: \(error)")
}
}
private func autosave() {
withObservationTracking {
_ = serverURL
_ = username
_ = password
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
do {
let currentSettings = try await xpc.getSettings()
if currentSettings.serverUrl != serverURL || currentSettings.username != username {
try await xpc.setSettings(settings: Serialized.Settings(
serverUrl: serverURL,
username: username
))
}
keychain[username] = password
} catch {
print("Error saving settings: \(error)")
}
autosave()
}
}
}
}
}

View File

@@ -0,0 +1,38 @@
//
// SplitView.swift
// kordophone2
//
// Created by James Magahern on 8/29/25.
//
import SwiftUI
struct SplitView: View
{
@State var conversationListModel = ConversationListView.ViewModel()
@State var transcriptViewModel = TranscriptView.ViewModel()
@State var entryViewModel = MessageEntryView.ViewModel()
private let xpcClient = XPCClient()
private var selectedConversation: Display.Conversation? {
guard let id = conversationListModel.selectedConversations.first else { return nil }
return conversationListModel.conversations.first { $0.id == id }
}
var body: some View {
NavigationSplitView {
ConversationListView(model: $conversationListModel)
.frame(minWidth: 330.0)
.xpcClient(xpcClient)
} detail: {
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
.xpcClient(xpcClient)
.selectedConversation(selectedConversation)
.navigationTitle("Kordophone")
.navigationSubtitle(selectedConversation?.displayName ?? "")
.onChange(of: conversationListModel.selectedConversations) { oldValue, newValue in
transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue.first }
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

View File

@@ -0,0 +1,59 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>net.buzzert.kordophonecd</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,241 @@
//
// TranscriptDisplayItemViews.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import SwiftUI
struct BubbleView<Content: View>: View
{
let date: Date
let sender: Display.Sender
let content: () -> Content
private var isFromMe: Bool { sender.isMe }
private let tooltipDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEEE, MMMM dd, HH:mm"
return f
}()
init(sender: Display.Sender, date: Date, @ViewBuilder content: @escaping () -> Content) {
self.sender = sender
self.content = content
self.date = date
}
var body: some View {
VStack(alignment: isFromMe ? .trailing : .leading) {
HStack(alignment: .bottom) {
if isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) }
content()
.mask {
UnevenRoundedRectangle(cornerRadii: RectangleCornerRadii(
topLeading: isFromMe ? .dominantCornerRadius : .minorCornerRadius,
bottomLeading: .dominantCornerRadius,
bottomTrailing: .dominantCornerRadius,
topTrailing: isFromMe ? .minorCornerRadius : .dominantCornerRadius
))
}
if !isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) }
}
}
.help(tooltipDateFormatter.string(from: date))
}
}
struct TextBubbleItemView: View
{
let text: String
let sender: Display.Sender
let date: Date
private var isFromMe: Bool { sender.isMe }
var body: some View {
let bubbleColor: Color = isFromMe ? .blue : Color(NSColor(name: "grayish", dynamicProvider: { appearance in
appearance.name == .darkAqua ? .darkGray : NSColor(white: 0.78, alpha: 1.0)
}))
let textColor: Color = isFromMe ? .white : .primary
BubbleView(sender: sender, date: date) {
HStack {
Text(text)
.foregroundStyle(textColor)
.multilineTextAlignment(.leading)
}
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 16.0)
.padding(.vertical, 10.0)
.background(bubbleColor)
}
}
}
struct ImageItemView: View
{
let sender: Display.Sender
let date: Date
let attachment: Display.ImageAttachment
@State private var img: NSImage?
@Environment(\.xpcClient) var xpcClient
@State private var containerWidth: CGFloat? = nil
private var aspectRatio: CGFloat {
attachment.size?.aspectRatio ?? 1.0
}
private var preferredWidth: CGFloat {
preferredBubbleWidth(forAttachmentSize: attachment.size, containerWidth: containerWidth, maxWidth: .imageMaxWidth)
}
var body: some View {
BubbleView(sender: sender, date: date) {
let maxWidth = CGFloat.minimum(.imageMaxWidth, containerWidth ?? .imageMaxWidth)
if let img {
Image(nsImage: img)
.resizable()
.scaledToFit()
.frame(maxWidth: maxWidth)
} else {
Rectangle()
.fill(.gray.opacity(0.4))
.frame(width: preferredWidth, height: preferredWidth / aspectRatio)
.frame(maxWidth: maxWidth)
}
}
.onGeometryChange(for: CGFloat.self,
of: { $0.size.width },
action: { containerWidth = $0 })
.task {
do {
let handle = try await xpcClient.openAttachmentFileHandle(
attachmentId: attachment.id,
preview: true
)
try? handle.seek(toOffset: 0)
if let data = try? handle.readToEnd(),
let ns = NSImage(data: data) {
img = ns
}
try handle.close()
} catch {
print("Attachment file handle acquisition error: \(error)")
}
}
}
}
struct PlaceholderImageItemView: View
{
let sender: Display.Sender
let date: Date
let size: CGSize?
@State private var containerWidth: CGFloat? = nil
private var aspectRatio: CGFloat {
size?.aspectRatio ?? 1.0
}
private var preferredWidth: CGFloat {
preferredBubbleWidth(forAttachmentSize: size, containerWidth: containerWidth, maxWidth: .imageMaxWidth)
}
init(sender: Display.Sender, date: Date, size: CGSize?) {
self.sender = sender
self.date = date
self.size = size
}
var body: some View {
BubbleView(sender: sender, date: date) {
ZStack {
Rectangle()
.fill(.gray.opacity(0.4))
.frame(width: preferredWidth, height: preferredWidth / aspectRatio)
.frame(maxWidth: .imageMaxWidth)
ProgressView()
}
}
.onGeometryChange(for: CGFloat.self,
of: { $0.size.width },
action: { containerWidth = $0 })
}
}
struct DateItemView: View
{
let date: Date
private let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEEE HH:mm"
return f
}()
var body: some View {
VStack {
Spacer(minLength: 34.0)
Text(formatter.string(from: date))
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 4)
Spacer()
}
}
}
struct SenderAttributionView: View
{
let sender: Display.Sender
var body: some View {
HStack {
if sender.isMe { Spacer() }
Text(sender.displayName)
.foregroundStyle(.secondary)
.font(.caption2)
if !sender.isMe { Spacer() }
}
.padding(.top, 10.0)
}
}
fileprivate extension CGFloat {
static let dominantCornerRadius = 16.0
static let minorCornerRadius = 4.0
static let minimumBubbleHorizontalPadding = 80.0
static let imageMaxWidth = 380.0
}
fileprivate extension CGSize {
var aspectRatio: CGFloat { width / height }
}
fileprivate func preferredBubbleWidth(forAttachmentSize attachmentSize: CGSize?, containerWidth: CGFloat?, maxWidth: CGFloat) -> CGFloat {
if let containerWidth, let attachmentWidth = attachmentSize?.width {
return .minimum(maxWidth, .minimum(containerWidth, attachmentWidth))
} else if let containerWidth {
return containerWidth
} else {
return 200.0 // fallback
}
}

View File

@@ -0,0 +1,82 @@
//
// TranscriptDisplayItems.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import Foundation
import SwiftUI
extension TranscriptView.ViewModel
{
internal func rebuildDisplayItems(animated: Bool = false) {
var displayItems: [DisplayItem] = []
var lastDate: Date = .distantPast
var lastSender: Display.Sender? = nil
let client = XPCClient()
let isGroupChat = displayedConversation?.isGroupChat ?? false
let dateAnnotationTimeInterval: TimeInterval = 60 * 30
for message in messages {
if message.sender != lastSender {
displayItems.append(.spacer(15.0, message.id))
}
let isPastDateThreshold = message.date.timeIntervalSince(lastDate) > dateAnnotationTimeInterval
if isPastDateThreshold {
displayItems.append(.date(message.date))
}
if isGroupChat && !message.sender.isMe && (isPastDateThreshold || message.sender != lastSender) {
displayItems.append(.senderAttribition(message))
}
for attachment in message.attachments {
displayItems.append(.attachment(attachment))
if !attachment.isPreviewDownloaded {
Task.detached {
try await client.downloadAttachment(attachmentId: attachment.id, preview: true)
}
}
}
if !message.text.isEmpty {
displayItems.append(.message(message))
}
lastSender = message.sender
lastDate = message.date
}
let animation: Animation? = animated ? .default : nil
withAnimation(animation) {
self.displayItems = displayItems
}
}
}
enum DisplayItem: Identifiable
{
case message(Display.Message)
case attachment(Display.ImageAttachment)
case senderAttribition(Display.Message)
case spacer(CGFloat, Display.Message.ID)
case date(Date)
var id: String {
switch self {
case .message(let message):
message.id
case .attachment(let attachment):
attachment.id
case .senderAttribition(let message):
"\(message.sender.displayName) @ \(message.id)"
case .date(let date):
date.description
case .spacer(let space, let id):
"\(space)=\(id)"
}
}
}

View File

@@ -0,0 +1,194 @@
//
// TranscriptView.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import SwiftUI
struct TranscriptView: View
{
@Binding var model: ViewModel
@Environment(\.xpcClient) private var xpcClient
var body: some View {
ScrollView {
LazyVStack(spacing: 6.0) {
ForEach($model.displayItems.reversed()) { item in
displayItemView(item.wrappedValue)
.id(item.id)
.scaleEffect(CGSize(width: 1.0, height: -1.0))
.transition(
.push(from: .top)
.combined(with: .opacity)
)
}
}
.padding()
}
.scaleEffect(CGSize(width: 1.0, height: -1.0))
.id(model.displayedConversation?.id)
.task { await watchForMessageListChanges() }
}
private func watchForMessageListChanges() async {
for await event in xpcClient.eventStream() {
switch event {
case .attachmentDownloaded(let attachmentId):
model.attachmentDownloaded(id: attachmentId)
case .messagesUpdated(let conversationId):
if let displayedConversation = model.displayedConversation,
conversationId == displayedConversation.id
{
model.setNeedsReload(animated: true)
}
case .updateStreamReconnected:
await model.triggerSync()
default:
break
}
}
}
@ViewBuilder
private func displayItemView(_ item: DisplayItem) -> some View {
switch item {
case .message(let message):
TextBubbleItemView(text: message.text, sender: message.sender, date: message.date)
case .date(let date):
DateItemView(date: date)
case .senderAttribition(let message):
SenderAttributionView(sender: message.sender)
case .spacer(let length, _):
Spacer(minLength: length)
case .attachment(let attachment):
if attachment.isPreviewDownloaded {
ImageItemView(
sender: attachment.sender,
date: attachment.dateSent,
attachment: attachment
)
} else {
PlaceholderImageItemView(
sender: attachment.sender,
date: attachment.dateSent,
size: attachment.size
)
}
}
}
// MARK: - Types
@Observable
class ViewModel
{
var displayItems: [DisplayItem] = []
var displayedConversation: Display.Conversation? = nil
internal var needsReload: NeedsReload = .no
internal var messages: [Display.Message]
internal let client = XPCClient()
init(messages: [Display.Message] = []) {
self.messages = messages
observeDisplayedConversation()
rebuildDisplayItems()
}
func setNeedsReload(animated: Bool) {
guard case .no = needsReload else {
return
}
needsReload = .yes(animated)
Task { @MainActor [weak self] in
guard let self else { return }
await reloadMessages()
}
}
func attachmentDownloaded(id: String) {
// TODO: should be smarter here
setNeedsReload(animated: false)
}
private func observeDisplayedConversation() {
withObservationTracking {
_ = displayedConversation
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
await markAsRead()
await triggerSync()
setNeedsReload(animated: false)
observeDisplayedConversation()
}
}
}
func markAsRead() async {
guard let displayedConversation else { return }
do {
try await client.markConversationAsRead(conversationId: displayedConversation.id)
} catch {
print("Error triggering sync: \(error)")
}
}
func triggerSync() async {
guard let displayedConversation else { return }
do {
try await client.syncConversation(conversationId: displayedConversation.id)
} catch {
print("Error triggering sync: \(error)")
}
}
private func reloadMessages() async {
guard case .yes(let animated) = needsReload else { return }
needsReload = .no
guard let displayedConversation else { return }
do {
let clientMessages = try await client.getMessages(conversationId: displayedConversation.id)
.map { Display.Message(from: $0) }
let newIds = Set(clientMessages.map(\.id))
.subtracting(self.messages.map(\.id))
// Only animate for incoming messages.
let shouldAnimate = (newIds.count == 1)
self.messages = clientMessages
self.rebuildDisplayItems(animated: animated && shouldAnimate)
} catch {
print("Message fetch error: \(error)")
}
}
// MARK: - Types
enum NeedsReload
{
case no
case yes(Bool) // animated
}
}
}
#Preview {
@Previewable @State var model = TranscriptView.ViewModel(messages: [
.init(sender: .me, text: "Hello, how are you?"),
.init(sender: .counterpart("Bob"), text: "I am doing fine!")
])
TranscriptView(model: $model)
}

View File

@@ -0,0 +1,386 @@
//
// XPCClient.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import Foundation
import ServiceManagement
import XPC
private let serviceName = "net.buzzert.kordophonecd"
final class XPCClient
{
private var connection: xpc_connection_t?
private let connectionQueue = DispatchQueue(label: "net.buzzert.kordophone.xpc.connection")
private var isReconnecting: Bool = false
private var reconnectAttempt: Int = 0
private let signalLock = NSLock()
private var signalSinks: [UUID: (Signal) -> Void] = [:]
private var didSubscribeSignals: Bool = false
static let appService: SMAppService = {
do {
let service = SMAppService.agent(plistName: "net.buzzert.kordophonecd.plist")
if service.status != .enabled {
try service.register()
}
return service
} catch {
print("Unable to register agent: \(error)")
fatalError()
}
}()
init() {
_ = Self.appService
connect()
}
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 {
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
}
public func syncConversation(conversationId: String) async throws {
let req = makeRequest(method: "SyncConversation", arguments: ["conversation_id": xpcString(conversationId)])
_ = try await sendSync(req)
}
public func syncConversationList() async throws {
let req = makeRequest(method: "SyncConversationList")
_ = try await sendSync(req)
}
public func markConversationAsRead(conversationId: String) async throws {
let req = makeRequest(method: "MarkConversationAsRead", arguments: ["conversation_id": xpcString(conversationId)])
_ = try await sendSync(req)
}
public func getMessages(conversationId: String, limit: Int = 100, offset: Int = 0) async throws -> [Serialized.Message] {
var args: [String: xpc_object_t] = [:]
args["conversation_id"] = xpcString(conversationId)
args["limit"] = xpcString(String(limit))
args["offset"] = xpcString(String(offset))
let req = makeRequest(method: "GetMessages", 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, "messages"), xpc_get_type(items) == XPC_TYPE_ARRAY else { return [] }
var results: [Serialized.Message] = []
xpc_array_apply(items) { _, element in
if xpc_get_type(element) == XPC_TYPE_DICTIONARY, let msg = Serialized.Message(xpc: element) {
results.append(msg)
}
return true
}
return results
}
public func sendMessage(conversationId: String, message: String, transferGuids: Set<String>) 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 }
}
public func downloadAttachment(attachmentId: String, preview: Bool) async throws {
var args: [String: xpc_object_t] = [:]
args["attachment_id"] = xpcString(attachmentId)
args["preview"] = xpcString(preview ? "true" : "false")
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] = [:]
args["attachment_id"] = xpcString(attachmentId)
args["preview"] = xpcString(preview ? "true" : "false")
let req = makeRequest(method: "OpenAttachmentFd", arguments: args)
guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError }
let fd = xpc_dictionary_dup_fd(reply, "fd")
let fileHandler = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
if fd < 0 { throw Error.badFileHandle }
return fileHandler
}
public func getSettings() async throws -> Serialized.Settings {
let req = makeRequest(method: "GetAllSettings")
guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError }
return Serialized.Settings.fromXPC(reply) ?? Serialized.Settings(serverUrl: "", username: "")
}
public func setSettings(settings: Serialized.Settings) async throws {
let req = makeRequest(
method: "UpdateSettings",
arguments: [
"server_url": xpcString(settings.serverUrl),
"username": xpcString(settings.username),
]
)
_ = try await sendSync(req)
}
// MARK: - Types
enum Error: Swift.Error
{
case typeError
case encodingError
case badFileHandle
case connectionError
}
enum Signal
{
case conversationsUpdated
case messagesUpdated(conversationId: String)
case attachmentDownloaded(attachmentId: String)
case attachmentUploaded(uploadGuid: String, attachmentGuid: String)
case updateStreamReconnected
}
}
extension XPCClient
{
private func connect() {
connectionQueue.async { [weak self] in
guard let self else { return }
if let existing = connection {
xpc_connection_cancel(existing)
connection = nil
}
let newConn = xpc_connection_create_mach_service(serviceName, nil, 0)
let handler: xpc_handler_t = { [weak self] event in
self?.handleIncomingXPCEvent(event)
}
xpc_connection_set_event_handler(newConn, handler)
xpc_connection_resume(newConn)
self.connection = newConn
self.isReconnecting = false
self.reconnectAttempt = 0
}
if didSubscribeSignals {
Task { try? await subscribeToSignals() }
}
}
private func scheduleReconnect() {
connectionQueue.async { [weak self] in
guard let self else { return }
if self.isReconnecting { return }
self.isReconnecting = true
let attempt = self.reconnectAttempt
self.reconnectAttempt += 1
let delaySeconds = min(pow(2.0, Double(attempt)), 30.0)
self.connectionQueue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
self?.connect()
}
}
}
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 {
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 xpcStringArray(_ set: Set<String>) -> 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 }
guard let conn else {
self.scheduleReconnect()
continuation.resume(throwing: Error.connectionError)
return
}
xpc_connection_send_message_with_reply(conn, request, DispatchQueue.global(qos: .userInitiated)) { r in
switch xpc_get_type(r) {
case XPC_TYPE_ERROR:
if r.isInterruptionError {
self.scheduleReconnect()
continuation.resume(throwing: Error.connectionError)
} else {
continuation.resume(throwing: Error.typeError)
}
case XPC_TYPE_DICTIONARY:
continuation.resume(returning: r)
default:
continuation.resume(throwing: Error.typeError)
}
}
}
}
private func handleIncomingXPCEvent(_ event: xpc_object_t) {
switch xpc_get_type(event) {
case XPC_TYPE_DICTIONARY:
guard let eventDict = XPCDictionary(event), let name: String = eventDict["name"] else { return }
let args = eventDict.object("arguments").flatMap { XPCDictionary($0) }
let signal: Signal? = {
switch name {
case "ConversationsUpdated":
return .conversationsUpdated
case "MessagesUpdated":
if let args, let cid: String = args["conversation_id"] { return .messagesUpdated(conversationId: cid) }
return nil
case "AttachmentDownloadCompleted":
if let args, let aid: String = args["attachment_id"] { return .attachmentDownloaded(attachmentId: aid) }
return nil
case "AttachmentUploadCompleted":
if let args,
let uploadGuid: String = args["upload_guid"],
let attachmentGuid: String = args["attachment_guid"] {
return .attachmentUploaded(uploadGuid: uploadGuid, attachmentGuid: attachmentGuid)
}
return nil
case "UpdateStreamReconnected":
return .updateStreamReconnected
default:
return nil
}
}()
if let signal {
signalLock.lock()
let sinks = signalSinks.values
signalLock.unlock()
for sink in sinks { sink(signal) }
}
case XPC_TYPE_ERROR:
if event.isInterruptionError {
scheduleReconnect()
}
default:
break
}
}
}
extension xpc_object_t
{
var isInterruptionError: Bool {
return (
xpc_equal(self, XPC_ERROR_CONNECTION_INTERRUPTED) ||
xpc_equal(self, XPC_ERROR_CONNECTION_INVALID) ||
xpc_equal(self, XPC_ERROR_TERMINATION_IMMINENT)
)
}
}

View File

@@ -0,0 +1,138 @@
//
// 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 getObject(_ key: String) -> xpc_object_t? {
var raw: xpc_object_t?
key.withCString { cKey in
raw = xpc_dictionary_get_value(self, cKey)
}
return raw
}
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) }
var isDictionary: Bool { xpc_get_type(self) == XPC_TYPE_DICTIONARY }
var isArray: Bool { xpc_get_type(self) == XPC_TYPE_ARRAY }
}
// MARK: - Dictionary wrapper
struct XPCDictionary
{
let raw: xpc_object_t
init?(_ value: xpc_object_t) {
guard xpc_get_type(value) == XPC_TYPE_DICTIONARY else { return nil }
self.raw = value
}
func object(_ key: String) -> xpc_object_t? {
var rawValue: xpc_object_t?
key.withCString { cKey in
rawValue = xpc_dictionary_get_value(raw, cKey)
}
return rawValue
}
func get<T: XPCConvertible>(_ key: String) -> T? {
guard let value = object(key) else { return nil }
return T.fromXPC(value)
}
subscript<T: XPCConvertible>(_ key: String) -> T? { return get(key) }
}
extension XPCDictionary
{
static func wrap(_ value: xpc_object_t) -> XPCDictionary? {
return XPCDictionary(value)
}
}