Add 'osx/' from commit '46755a07ef2e7aa9852d74c30e2c12f9fe8f2278'
git-subtree-dir: osx git-subtree-mainline:034026e88agit-subtree-split:46755a07ef
This commit is contained in:
413
osx/kordophone2.xcodeproj/project.pbxproj
Normal file
413
osx/kordophone2.xcodeproj/project.pbxproj
Normal file
@@ -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 = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
CD41F5982E5B8E7300E0027B /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CD41F5972E5B8E7300E0027B /* kordophone2.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 */;
|
||||||
|
}
|
||||||
27
osx/kordophone2/App.swift
Normal file
27
osx/kordophone2/App.swift
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
102
osx/kordophone2/Attachments.swift
Normal file
102
osx/kordophone2/Attachments.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
osx/kordophone2/ConversationListView.swift
Normal file
118
osx/kordophone2/ConversationListView.swift
Normal 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)
|
||||||
|
}
|
||||||
27
osx/kordophone2/ConversationView.swift
Normal file
27
osx/kordophone2/ConversationView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
osx/kordophone2/Daemon/kordophoned
Executable file
BIN
osx/kordophone2/Daemon/kordophoned
Executable file
Binary file not shown.
26
osx/kordophone2/Daemon/net.buzzert.kordophonecd.plist
Normal file
26
osx/kordophone2/Daemon/net.buzzert.kordophonecd.plist
Normal 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>
|
||||||
26
osx/kordophone2/Environment.swift
Normal file
26
osx/kordophone2/Environment.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
302
osx/kordophone2/MessageEntryView.swift
Normal file
302
osx/kordophone2/MessageEntryView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
282
osx/kordophone2/Models.swift
Normal file
282
osx/kordophone2/Models.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
osx/kordophone2/PreferencesView.swift
Normal file
110
osx/kordophone2/PreferencesView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
osx/kordophone2/SplitView.swift
Normal file
38
osx/kordophone2/SplitView.swift
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
10
osx/kordophone2/Supporting Files/kordophone2.entitlements
Normal file
10
osx/kordophone2/Supporting Files/kordophone2.entitlements
Normal 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>
|
||||||
241
osx/kordophone2/Transcript/TranscriptDisplayItemViews.swift
Normal file
241
osx/kordophone2/Transcript/TranscriptDisplayItemViews.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
82
osx/kordophone2/Transcript/TranscriptDisplayItems.swift
Normal file
82
osx/kordophone2/Transcript/TranscriptDisplayItems.swift
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
194
osx/kordophone2/Transcript/TranscriptView.swift
Normal file
194
osx/kordophone2/Transcript/TranscriptView.swift
Normal 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)
|
||||||
|
}
|
||||||
386
osx/kordophone2/XPC/XPCClient.swift
Normal file
386
osx/kordophone2/XPC/XPCClient.swift
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
138
osx/kordophone2/XPC/XPCConvertible.swift
Normal file
138
osx/kordophone2/XPC/XPCConvertible.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user