diff --git a/osx/kordophone2.xcodeproj/project.pbxproj b/osx/kordophone2.xcodeproj/project.pbxproj new file mode 100644 index 0000000..3f6c97e --- /dev/null +++ b/osx/kordophone2.xcodeproj/project.pbxproj @@ -0,0 +1,413 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + CD41F5D32E62431D00E0027B /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = CD41F5D22E62431D00E0027B /* KeychainAccess */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + CD41F5D92E6284FD00E0027B /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/Library/LaunchAgents; + dstSubfolderSpec = 1; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CD41F5DD2E6285E800E0027B /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 6; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + CD41F5972E5B8E7300E0027B /* kordophone2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = kordophone2.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Daemon/kordophoned, + Daemon/net.buzzert.kordophonecd.plist, + ); + target = CD41F5962E5B8E7300E0027B /* kordophone2 */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */ = { + isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; + buildPhase = CD41F5D92E6284FD00E0027B /* CopyFiles */; + membershipExceptions = ( + Daemon/net.buzzert.kordophonecd.plist, + ); + }; + CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */ = { + isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; + attributesByRelativePath = { + Daemon/kordophoned = (CodeSignOnCopy, ); + }; + buildPhase = CD41F5DD2E6285E800E0027B /* CopyFiles */; + membershipExceptions = ( + Daemon/kordophoned, + ); + }; +/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + CD41F5992E5B8E7300E0027B /* kordophone2 */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */, + CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */, + CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */, + ); + path = kordophone2; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + CD41F5942E5B8E7300E0027B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CD41F5D32E62431D00E0027B /* KeychainAccess in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + CD41F58E2E5B8E7300E0027B = { + isa = PBXGroup; + children = ( + CD41F5992E5B8E7300E0027B /* kordophone2 */, + CD41F5982E5B8E7300E0027B /* Products */, + ); + sourceTree = ""; + }; + CD41F5982E5B8E7300E0027B /* Products */ = { + isa = PBXGroup; + children = ( + CD41F5972E5B8E7300E0027B /* kordophone2.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + CD41F5962E5B8E7300E0027B /* kordophone2 */ = { + isa = PBXNativeTarget; + buildConfigurationList = CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */; + buildPhases = ( + CD41F5932E5B8E7300E0027B /* Sources */, + CD41F5942E5B8E7300E0027B /* Frameworks */, + CD41F5952E5B8E7300E0027B /* Resources */, + CD41F5D92E6284FD00E0027B /* CopyFiles */, + CD41F5DD2E6285E800E0027B /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + CD41F5992E5B8E7300E0027B /* kordophone2 */, + ); + name = kordophone2; + packageProductDependencies = ( + CD41F5D22E62431D00E0027B /* KeychainAccess */, + ); + productName = kordophone2; + productReference = CD41F5972E5B8E7300E0027B /* kordophone2.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + CD41F58F2E5B8E7300E0027B /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1630; + LastUpgradeCheck = 1630; + TargetAttributes = { + CD41F5962E5B8E7300E0027B = { + CreatedOnToolsVersion = 16.3; + }; + }; + }; + buildConfigurationList = CD41F5922E5B8E7300E0027B /* Build configuration list for PBXProject "kordophone2" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = CD41F58E2E5B8E7300E0027B; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + CD41F5D12E62431D00E0027B /* XCRemoteSwiftPackageReference "KeychainAccess" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = CD41F5982E5B8E7300E0027B /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + CD41F5962E5B8E7300E0027B /* kordophone2 */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + CD41F5952E5B8E7300E0027B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + CD41F5932E5B8E7300E0027B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + CD41F5A12E5B8E7400E0027B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = DQQH5H6GBD; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.3; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + CD41F5A22E5B8E7400E0027B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = DQQH5H6GBD; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.3; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + CD41F5A42E5B8E7400E0027B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "kordophone2/Supporting Files/kordophone2.entitlements"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = DQQH5H6GBD; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.kordophone2; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + CD41F5A52E5B8E7400E0027B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "kordophone2/Supporting Files/kordophone2.entitlements"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = DQQH5H6GBD; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.kordophone2; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + CD41F5922E5B8E7300E0027B /* Build configuration list for PBXProject "kordophone2" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CD41F5A12E5B8E7400E0027B /* Debug */, + CD41F5A22E5B8E7400E0027B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CD41F5A42E5B8E7400E0027B /* Debug */, + CD41F5A52E5B8E7400E0027B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + CD41F5D12E62431D00E0027B /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kishikawakatsumi/KeychainAccess.git"; + requirement = { + branch = master; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + CD41F5D22E62431D00E0027B /* KeychainAccess */ = { + isa = XCSwiftPackageProductDependency; + package = CD41F5D12E62431D00E0027B /* XCRemoteSwiftPackageReference "KeychainAccess" */; + productName = KeychainAccess; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = CD41F58F2E5B8E7300E0027B /* Project object */; +} diff --git a/osx/kordophone2/App.swift b/osx/kordophone2/App.swift new file mode 100644 index 0000000..5f36b24 --- /dev/null +++ b/osx/kordophone2/App.swift @@ -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)") + } +} diff --git a/osx/kordophone2/Attachments.swift b/osx/kordophone2/Attachments.swift new file mode 100644 index 0000000..497d29e --- /dev/null +++ b/osx/kordophone2/Attachments.swift @@ -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 + } + } + } +} diff --git a/osx/kordophone2/ConversationListView.swift b/osx/kordophone2/ConversationListView.swift new file mode 100644 index 0000000..656de92 --- /dev/null +++ b/osx/kordophone2/ConversationListView.swift @@ -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 + + 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) +} diff --git a/osx/kordophone2/ConversationView.swift b/osx/kordophone2/ConversationView.swift new file mode 100644 index 0000000..100aea8 --- /dev/null +++ b/osx/kordophone2/ConversationView.swift @@ -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 + } + } +} diff --git a/osx/kordophone2/Daemon/kordophoned b/osx/kordophone2/Daemon/kordophoned new file mode 100755 index 0000000..546c78d Binary files /dev/null and b/osx/kordophone2/Daemon/kordophoned differ diff --git a/osx/kordophone2/Daemon/net.buzzert.kordophonecd.plist b/osx/kordophone2/Daemon/net.buzzert.kordophonecd.plist new file mode 100644 index 0000000..c112e26 --- /dev/null +++ b/osx/kordophone2/Daemon/net.buzzert.kordophonecd.plist @@ -0,0 +1,26 @@ + + + + + Label + net.buzzert.kordophonecd + BundleProgram + Contents/MacOS/kordophoned + EnvironmentVariables + + RUST_LOG + info + + MachServices + + net.buzzert.kordophonecd + + + KeepAlive + + StandardOutPath + /tmp/kordophoned.out.log + StandardErrorPath + /tmp/kordophoned.err.log + + diff --git a/osx/kordophone2/Environment.swift b/osx/kordophone2/Environment.swift new file mode 100644 index 0000000..4483850 --- /dev/null +++ b/osx/kordophone2/Environment.swift @@ -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) + } +} + diff --git a/osx/kordophone2/MessageEntryView.swift b/osx/kordophone2/MessageEntryView.swift new file mode 100644 index 0000000..51438b4 --- /dev/null +++ b/osx/kordophone2/MessageEntryView.swift @@ -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 { + 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 && 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) + } +} diff --git a/osx/kordophone2/Models.swift b/osx/kordophone2/Models.swift new file mode 100644 index 0000000..a53ce69 --- /dev/null +++ b/osx/kordophone2/Models.swift @@ -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 + ) + } +} diff --git a/osx/kordophone2/PreferencesView.swift b/osx/kordophone2/PreferencesView.swift new file mode 100644 index 0000000..24de68c --- /dev/null +++ b/osx/kordophone2/PreferencesView.swift @@ -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() + } + } + } + } +} diff --git a/osx/kordophone2/SplitView.swift b/osx/kordophone2/SplitView.swift new file mode 100644 index 0000000..5a29adf --- /dev/null +++ b/osx/kordophone2/SplitView.swift @@ -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 } + } + } + } +} diff --git a/osx/kordophone2/Supporting Files/Assets.xcassets/AccentColor.colorset/Contents.json b/osx/kordophone2/Supporting Files/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/osx/kordophone2/Supporting Files/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/osx/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/osx/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..36ef03e Binary files /dev/null and b/osx/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/osx/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json b/osx/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..bc26afc --- /dev/null +++ b/osx/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/osx/kordophone2/Supporting Files/Assets.xcassets/Contents.json b/osx/kordophone2/Supporting Files/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/osx/kordophone2/Supporting Files/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/osx/kordophone2/Supporting Files/kordophone2.entitlements b/osx/kordophone2/Supporting Files/kordophone2.entitlements new file mode 100644 index 0000000..d56bd85 --- /dev/null +++ b/osx/kordophone2/Supporting Files/kordophone2.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.temporary-exception.mach-lookup.global-name + + net.buzzert.kordophonecd + + + diff --git a/osx/kordophone2/Transcript/TranscriptDisplayItemViews.swift b/osx/kordophone2/Transcript/TranscriptDisplayItemViews.swift new file mode 100644 index 0000000..34b471a --- /dev/null +++ b/osx/kordophone2/Transcript/TranscriptDisplayItemViews.swift @@ -0,0 +1,241 @@ +// +// TranscriptDisplayItemViews.swift +// kordophone2 +// +// Created by James Magahern on 8/24/25. +// + +import SwiftUI + +struct BubbleView: 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 + } +} diff --git a/osx/kordophone2/Transcript/TranscriptDisplayItems.swift b/osx/kordophone2/Transcript/TranscriptDisplayItems.swift new file mode 100644 index 0000000..f98a1e0 --- /dev/null +++ b/osx/kordophone2/Transcript/TranscriptDisplayItems.swift @@ -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)" + } + } +} diff --git a/osx/kordophone2/Transcript/TranscriptView.swift b/osx/kordophone2/Transcript/TranscriptView.swift new file mode 100644 index 0000000..fae1124 --- /dev/null +++ b/osx/kordophone2/Transcript/TranscriptView.swift @@ -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) +} diff --git a/osx/kordophone2/XPC/XPCClient.swift b/osx/kordophone2/XPC/XPCClient.swift new file mode 100644 index 0000000..a0a5f09 --- /dev/null +++ b/osx/kordophone2/XPC/XPCClient.swift @@ -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 { + // 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) 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) -> 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) + ) + } +} + diff --git a/osx/kordophone2/XPC/XPCConvertible.swift b/osx/kordophone2/XPC/XPCConvertible.swift new file mode 100644 index 0000000..4dd164b --- /dev/null +++ b/osx/kordophone2/XPC/XPCConvertible.swift @@ -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(_ 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) } + + 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(_ key: String) -> T? { + guard let value = object(key) else { return nil } + return T.fromXPC(value) + } + + subscript(_ key: String) -> T? { return get(key) } +} + +extension XPCDictionary +{ + static func wrap(_ value: xpc_object_t) -> XPCDictionary? { + return XPCDictionary(value) + } +}