Initial commit of KDChat
This is an iOS chat client for Kirandur, a multiplayer RPG. Reimplements the LiteNetLib UDP protocol in Swift for reliable ordered messaging. Renders server-driven UI via a RemoteForms widget system for login, character creation and more inside the client. Uses msgpack-swift for serialization, Keychain for credentials, and targets iOS 18.6+/Xcode 26+. No message persistence yet. No auto-reconnect. Game-specific packets (movement, combat, etc.) are silently ignored. No profile viewing yet. Assisted by Claude Code (Opus 4.6)
This commit is contained in:
commit
2d3fefa013
34 changed files with 3504 additions and 0 deletions
617
KDChat.xcodeproj/project.pbxproj
Normal file
617
KDChat.xcodeproj/project.pbxproj
Normal file
|
|
@ -0,0 +1,617 @@
|
||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
6A3445BA2F82695300B35186 /* DMMessagePack in Frameworks */ = {isa = PBXBuildFile; productRef = 6A3445962F8266BD00B35186 /* DMMessagePack */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
6AE33C3C2F6F4C5200AA7CA1 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 6AE33C262F6F4C5100AA7CA1 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 6AE33C2D2F6F4C5100AA7CA1;
|
||||||
|
remoteInfo = KDChat;
|
||||||
|
};
|
||||||
|
6AE33C462F6F4C5200AA7CA1 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 6AE33C262F6F4C5100AA7CA1 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 6AE33C2D2F6F4C5100AA7CA1;
|
||||||
|
remoteInfo = KDChat;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
6AE33C2E2F6F4C5100AA7CA1 /* KDChat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KDChat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
6AE33C3B2F6F4C5200AA7CA1 /* KDChatTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KDChatTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
6AE33C452F6F4C5200AA7CA1 /* KDChatUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KDChatUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
6AE33C302F6F4C5100AA7CA1 /* KDChat */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = KDChat;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
6AE33C3E2F6F4C5200AA7CA1 /* KDChatTests */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = KDChatTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
6AE33C482F6F4C5200AA7CA1 /* KDChatUITests */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = KDChatUITests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
6AE33C2B2F6F4C5100AA7CA1 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
6A3445BA2F82695300B35186 /* DMMessagePack in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
6AE33C382F6F4C5200AA7CA1 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
6AE33C422F6F4C5200AA7CA1 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
6AE33C252F6F4C5100AA7CA1 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
6AE33C302F6F4C5100AA7CA1 /* KDChat */,
|
||||||
|
6AE33C3E2F6F4C5200AA7CA1 /* KDChatTests */,
|
||||||
|
6AE33C482F6F4C5200AA7CA1 /* KDChatUITests */,
|
||||||
|
6AE33C2F2F6F4C5100AA7CA1 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
6AE33C2F2F6F4C5100AA7CA1 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
6AE33C2E2F6F4C5100AA7CA1 /* KDChat.app */,
|
||||||
|
6AE33C3B2F6F4C5200AA7CA1 /* KDChatTests.xctest */,
|
||||||
|
6AE33C452F6F4C5200AA7CA1 /* KDChatUITests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
6AE33C2D2F6F4C5100AA7CA1 /* KDChat */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 6AE33C4F2F6F4C5200AA7CA1 /* Build configuration list for PBXNativeTarget "KDChat" */;
|
||||||
|
buildPhases = (
|
||||||
|
6AE33C2A2F6F4C5100AA7CA1 /* Sources */,
|
||||||
|
6AE33C2B2F6F4C5100AA7CA1 /* Frameworks */,
|
||||||
|
6AE33C2C2F6F4C5100AA7CA1 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
6AE33C302F6F4C5100AA7CA1 /* KDChat */,
|
||||||
|
);
|
||||||
|
name = KDChat;
|
||||||
|
packageProductDependencies = (
|
||||||
|
6A3445962F8266BD00B35186 /* DMMessagePack */,
|
||||||
|
);
|
||||||
|
productName = KDChat;
|
||||||
|
productReference = 6AE33C2E2F6F4C5100AA7CA1 /* KDChat.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
6AE33C3A2F6F4C5200AA7CA1 /* KDChatTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 6AE33C522F6F4C5200AA7CA1 /* Build configuration list for PBXNativeTarget "KDChatTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
6AE33C372F6F4C5200AA7CA1 /* Sources */,
|
||||||
|
6AE33C382F6F4C5200AA7CA1 /* Frameworks */,
|
||||||
|
6AE33C392F6F4C5200AA7CA1 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
6AE33C3D2F6F4C5200AA7CA1 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
6AE33C3E2F6F4C5200AA7CA1 /* KDChatTests */,
|
||||||
|
);
|
||||||
|
name = KDChatTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = KDChatTests;
|
||||||
|
productReference = 6AE33C3B2F6F4C5200AA7CA1 /* KDChatTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
6AE33C442F6F4C5200AA7CA1 /* KDChatUITests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 6AE33C552F6F4C5200AA7CA1 /* Build configuration list for PBXNativeTarget "KDChatUITests" */;
|
||||||
|
buildPhases = (
|
||||||
|
6AE33C412F6F4C5200AA7CA1 /* Sources */,
|
||||||
|
6AE33C422F6F4C5200AA7CA1 /* Frameworks */,
|
||||||
|
6AE33C432F6F4C5200AA7CA1 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
6AE33C472F6F4C5200AA7CA1 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
6AE33C482F6F4C5200AA7CA1 /* KDChatUITests */,
|
||||||
|
);
|
||||||
|
name = KDChatUITests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = KDChatUITests;
|
||||||
|
productReference = 6AE33C452F6F4C5200AA7CA1 /* KDChatUITests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
6AE33C262F6F4C5100AA7CA1 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2630;
|
||||||
|
LastUpgradeCheck = 2640;
|
||||||
|
TargetAttributes = {
|
||||||
|
6AE33C2D2F6F4C5100AA7CA1 = {
|
||||||
|
CreatedOnToolsVersion = 26.3;
|
||||||
|
};
|
||||||
|
6AE33C3A2F6F4C5200AA7CA1 = {
|
||||||
|
CreatedOnToolsVersion = 26.3;
|
||||||
|
TestTargetID = 6AE33C2D2F6F4C5100AA7CA1;
|
||||||
|
};
|
||||||
|
6AE33C442F6F4C5200AA7CA1 = {
|
||||||
|
CreatedOnToolsVersion = 26.3;
|
||||||
|
TestTargetID = 6AE33C2D2F6F4C5100AA7CA1;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 6AE33C292F6F4C5100AA7CA1 /* Build configuration list for PBXProject "KDChat" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 6AE33C252F6F4C5100AA7CA1;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
packageReferences = (
|
||||||
|
6A3445952F8266BD00B35186 /* XCRemoteSwiftPackageReference "msgpack-swift" */,
|
||||||
|
);
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 6AE33C2F2F6F4C5100AA7CA1 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
6AE33C2D2F6F4C5100AA7CA1 /* KDChat */,
|
||||||
|
6AE33C3A2F6F4C5200AA7CA1 /* KDChatTests */,
|
||||||
|
6AE33C442F6F4C5200AA7CA1 /* KDChatUITests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
6AE33C2C2F6F4C5100AA7CA1 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
6AE33C392F6F4C5200AA7CA1 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
6AE33C432F6F4C5200AA7CA1 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
6AE33C2A2F6F4C5100AA7CA1 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
6AE33C372F6F4C5200AA7CA1 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
6AE33C412F6F4C5200AA7CA1 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
6AE33C3D2F6F4C5200AA7CA1 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 6AE33C2D2F6F4C5100AA7CA1 /* KDChat */;
|
||||||
|
targetProxy = 6AE33C3C2F6F4C5200AA7CA1 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
6AE33C472F6F4C5200AA7CA1 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 6AE33C2D2F6F4C5100AA7CA1 /* KDChat */;
|
||||||
|
targetProxy = 6AE33C462F6F4C5200AA7CA1 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
6AE33C4D2F6F4C5200AA7CA1 /* 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 = F3AZTWNYRT;
|
||||||
|
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;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
6AE33C4E2F6F4C5200AA7CA1 /* 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 = F3AZTWNYRT;
|
||||||
|
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;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
6AE33C502F6F4C5200AA7CA1 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3AZTWNYRT;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.smoll.KDChat;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
6AE33C512F6F4C5200AA7CA1 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3AZTWNYRT;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.smoll.KDChat;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
6AE33C532F6F4C5200AA7CA1 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3AZTWNYRT;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.smoll.KDChatTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KDChat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/KDChat";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
6AE33C542F6F4C5200AA7CA1 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3AZTWNYRT;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.smoll.KDChatTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KDChat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/KDChat";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
6AE33C562F6F4C5200AA7CA1 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3AZTWNYRT;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.smoll.KDChatUITests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = KDChat;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
6AE33C572F6F4C5200AA7CA1 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3AZTWNYRT;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.smoll.KDChatUITests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = KDChat;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
6AE33C292F6F4C5100AA7CA1 /* Build configuration list for PBXProject "KDChat" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
6AE33C4D2F6F4C5200AA7CA1 /* Debug */,
|
||||||
|
6AE33C4E2F6F4C5200AA7CA1 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
6AE33C4F2F6F4C5200AA7CA1 /* Build configuration list for PBXNativeTarget "KDChat" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
6AE33C502F6F4C5200AA7CA1 /* Debug */,
|
||||||
|
6AE33C512F6F4C5200AA7CA1 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
6AE33C522F6F4C5200AA7CA1 /* Build configuration list for PBXNativeTarget "KDChatTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
6AE33C532F6F4C5200AA7CA1 /* Debug */,
|
||||||
|
6AE33C542F6F4C5200AA7CA1 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
6AE33C552F6F4C5200AA7CA1 /* Build configuration list for PBXNativeTarget "KDChatUITests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
6AE33C562F6F4C5200AA7CA1 /* Debug */,
|
||||||
|
6AE33C572F6F4C5200AA7CA1 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
6A3445952F8266BD00B35186 /* XCRemoteSwiftPackageReference "msgpack-swift" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/fumoboy007/msgpack-swift";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 2.0.6;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
6A3445962F8266BD00B35186 /* DMMessagePack */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 6A3445952F8266BD00B35186 /* XCRemoteSwiftPackageReference "msgpack-swift" */;
|
||||||
|
productName = DMMessagePack;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
};
|
||||||
|
rootObject = 6AE33C262F6F4C5100AA7CA1 /* Project object */;
|
||||||
|
}
|
||||||
7
KDChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
KDChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"originHash" : "b222ceda7783bbb31c386a75cfe3872ad89689e2c2993499258ff93c755b719a",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "msgpack-swift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/fumoboy007/msgpack-swift",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "fe3101819f4f82b086da5279a9df81f6754798e9",
|
||||||
|
"version" : "2.0.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?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>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>KDChat.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
7
KDChat/AppInfo.swift
Normal file
7
KDChat/AppInfo.swift
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AppInfo {
|
||||||
|
static let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "KDChat"
|
||||||
|
static let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "–"
|
||||||
|
static let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "–"
|
||||||
|
}
|
||||||
11
KDChat/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
11
KDChat/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
35
KDChat/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
35
KDChat/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "tinted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
KDChat/Assets.xcassets/Contents.json
Normal file
6
KDChat/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
21
KDChat/ContentView.swift
Normal file
21
KDChat/ContentView.swift
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch manager.screen {
|
||||||
|
case .login:
|
||||||
|
LoginView()
|
||||||
|
case .connecting:
|
||||||
|
ConnectingView()
|
||||||
|
case .forms:
|
||||||
|
RemoteFormView()
|
||||||
|
case .chat:
|
||||||
|
ChatView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.default, value: manager.screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
KDChat/KDChatApp.swift
Normal file
13
KDChat/KDChatApp.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct KDChatApp: App {
|
||||||
|
@State private var connectionManager = ConnectionManager()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environment(connectionManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
KDChat/Models/ChatModels.swift
Normal file
74
KDChat/Models/ChatModels.swift
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import Foundation
|
||||||
|
import MessagePack
|
||||||
|
|
||||||
|
// MARK: - Chat Buffer
|
||||||
|
|
||||||
|
struct ChatBuffer: Identifiable, Sendable, Decodable {
|
||||||
|
let name: String
|
||||||
|
let canSend: Bool
|
||||||
|
|
||||||
|
var id: String { name }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch name {
|
||||||
|
case "global": "Global"
|
||||||
|
case "map": "Map"
|
||||||
|
case "debug": "Debug"
|
||||||
|
case "direct-messages": "DMs"
|
||||||
|
default: name.capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(name: String, canSend: Bool) {
|
||||||
|
self.name = name
|
||||||
|
self.canSend = canSend
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
var c = try decoder.unkeyedContainer()
|
||||||
|
name = try c.decode(String.self)
|
||||||
|
canSend = c.isAtEnd ? false : try c.decode(Bool.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chat Message
|
||||||
|
|
||||||
|
struct ChatMessage: Identifiable, Sendable, Decodable {
|
||||||
|
let id: String
|
||||||
|
let buffer: String
|
||||||
|
let senderId: String?
|
||||||
|
let senderName: String
|
||||||
|
let content: String
|
||||||
|
let isInteractable: Bool
|
||||||
|
let senderIsRecipient: Bool
|
||||||
|
let isSystem: Bool
|
||||||
|
let timestamp: Date
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
var c = try decoder.unkeyedContainer()
|
||||||
|
buffer = try c.decode(String.self) // [0]
|
||||||
|
let rawId: String? = try c.decodeNil() ? nil : c.decode(String.self) // [1]
|
||||||
|
id = rawId ?? UUID().uuidString
|
||||||
|
senderId = try c.decodeNil() ? nil : c.decode(String.self) // [2]
|
||||||
|
senderName = try c.decode(String.self) // [3]
|
||||||
|
isInteractable = try c.decode(Bool.self) // [4]
|
||||||
|
content = try c.decode(String.self) // [5]
|
||||||
|
_ = c.isAtEnd ? nil : try (c.decodeNil() ? nil : c.decode(String.self)) // [6] sound
|
||||||
|
senderIsRecipient = c.isAtEnd ? false : try c.decode(Bool.self) // [7]
|
||||||
|
isSystem = false
|
||||||
|
timestamp = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a system message (from Say packets)
|
||||||
|
init(systemMessage: String) {
|
||||||
|
self.id = UUID().uuidString
|
||||||
|
self.buffer = "system"
|
||||||
|
self.senderId = nil
|
||||||
|
self.senderName = "System"
|
||||||
|
self.content = systemMessage
|
||||||
|
self.isInteractable = false
|
||||||
|
self.senderIsRecipient = false
|
||||||
|
self.isSystem = true
|
||||||
|
self.timestamp = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
786
KDChat/Networking/LiteNetLib.swift
Normal file
786
KDChat/Networking/LiteNetLib.swift
Normal file
|
|
@ -0,0 +1,786 @@
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let lnlLog = Logger(subsystem: "dev.smoll.KDChat", category: "LiteNetLib")
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
private enum LNL {
|
||||||
|
static let protocolId: Int32 = 13
|
||||||
|
static let maxSequence: UInt16 = 32768
|
||||||
|
static let halfMaxSequence: Int = 16384
|
||||||
|
static let windowSize: Int = 64
|
||||||
|
static let channeledHeaderSize: Int = 4
|
||||||
|
static let reliableOrderedChannelId: UInt8 = 2 // channel 0, ReliableOrdered (0*4+2)
|
||||||
|
static let pingIntervalMs: Int = 1000
|
||||||
|
static let disconnectTimeoutMs: Int = 5000
|
||||||
|
static let resendBaseMs: Double = 25
|
||||||
|
static let resendRttMultiplier: Double = 2.1
|
||||||
|
static let updateIntervalMs: Int = 50
|
||||||
|
|
||||||
|
// .NET epoch offset: ticks between 0001-01-01 and 1970-01-01
|
||||||
|
static let dotNetEpochOffset: Double = 62_135_596_800
|
||||||
|
|
||||||
|
static var dotNetTicks: Int64 {
|
||||||
|
Int64((Date.now.timeIntervalSince1970 + dotNetEpochOffset) * 10_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Packet Property
|
||||||
|
|
||||||
|
private enum PacketProperty: UInt8 {
|
||||||
|
case unreliable = 0
|
||||||
|
case channeled = 1
|
||||||
|
case ack = 2
|
||||||
|
case ping = 3
|
||||||
|
case pong = 4
|
||||||
|
case connectRequest = 5
|
||||||
|
case connectAccept = 6
|
||||||
|
case disconnect = 7
|
||||||
|
case unconnectedMessage = 8
|
||||||
|
case mtuCheck = 9
|
||||||
|
case mtuOk = 10
|
||||||
|
case broadcast = 11
|
||||||
|
case merged = 12
|
||||||
|
case shutdownOk = 13
|
||||||
|
case peerNotFound = 14
|
||||||
|
case invalidProtocol = 15
|
||||||
|
case natMessage = 16
|
||||||
|
case empty = 17
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection State
|
||||||
|
|
||||||
|
private enum ConnectionState {
|
||||||
|
case disconnected
|
||||||
|
case connecting
|
||||||
|
case connected
|
||||||
|
case shutdownRequested
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pending Packet
|
||||||
|
|
||||||
|
private struct PendingPacket {
|
||||||
|
var data: Data
|
||||||
|
var timestamp: UInt64
|
||||||
|
var isSent: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fragment Reassembly
|
||||||
|
|
||||||
|
private struct IncomingFragment {
|
||||||
|
let totalParts: Int
|
||||||
|
let channelId: UInt8
|
||||||
|
var parts: [Data?]
|
||||||
|
var receivedCount: Int
|
||||||
|
|
||||||
|
init(totalParts: Int, channelId: UInt8) {
|
||||||
|
self.totalParts = totalParts
|
||||||
|
self.channelId = channelId
|
||||||
|
self.parts = Array(repeating: nil, count: totalParts)
|
||||||
|
self.receivedCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Client Event
|
||||||
|
|
||||||
|
extension LNLClient {
|
||||||
|
enum Event: Sendable {
|
||||||
|
case connected
|
||||||
|
case disconnected(String?)
|
||||||
|
case received(Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - LNLClient
|
||||||
|
|
||||||
|
/// Minimal LiteNetLib client supporting ReliableOrdered delivery over UDP.
|
||||||
|
actor LNLClient {
|
||||||
|
// NWConnection requires a DispatchQueue for its callback-based API
|
||||||
|
private let nwQueue = DispatchQueue(label: "com.kdchat.litenetlib.nw")
|
||||||
|
private var connection: NWConnection?
|
||||||
|
private var updateTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
// Event stream for consumers
|
||||||
|
let events: AsyncStream<Event>
|
||||||
|
private let eventContinuation: AsyncStream<Event>.Continuation
|
||||||
|
|
||||||
|
// Connection state
|
||||||
|
private var state: ConnectionState = .disconnected
|
||||||
|
private var connectionTime: Int64 = 0
|
||||||
|
private var connectionNumber: UInt8 = 0
|
||||||
|
private var remotePeerId: Int32 = 0
|
||||||
|
private var localPeerId: Int32 = Int32.random(in: 1...Int32.max)
|
||||||
|
private var connectKey: String = ""
|
||||||
|
private var connectAttempts: Int = 0
|
||||||
|
private let maxConnectAttempts: Int = 10
|
||||||
|
private var lastConnectSendTime: UInt64 = 0
|
||||||
|
|
||||||
|
// Reliable ordered channel - send side
|
||||||
|
private var localSequence: UInt16 = 0
|
||||||
|
private var localWindowStart: UInt16 = 0
|
||||||
|
private var pendingPackets: [PendingPacket?]
|
||||||
|
private var outgoingQueue: [Data] = []
|
||||||
|
|
||||||
|
// Reliable ordered channel - receive side
|
||||||
|
private var remoteSequence: UInt16 = 0
|
||||||
|
private var remoteWindowStart: UInt16 = 0
|
||||||
|
private var receivedPackets: [Data?]
|
||||||
|
private var ackBitfield: [UInt8]
|
||||||
|
private var mustSendAcks: Bool = false
|
||||||
|
|
||||||
|
// Fragment reassembly
|
||||||
|
private var incomingFragments: [UInt16: IncomingFragment] = [:]
|
||||||
|
|
||||||
|
// Ping / keepalive
|
||||||
|
private var pingSequence: UInt16 = 0
|
||||||
|
private var lastPingSentTime: UInt64 = 0
|
||||||
|
private var lastPacketReceivedTime: UInt64 = 0
|
||||||
|
private var avgRtt: Double = 50
|
||||||
|
private var rttSum: Double = 0
|
||||||
|
private var rttCount: Int = 0
|
||||||
|
private var pingSentTimestamp: UInt64 = 0
|
||||||
|
|
||||||
|
// Resend delay (computed from RTT)
|
||||||
|
private var resendDelay: Double { LNL.resendBaseMs + avgRtt * LNL.resendRttMultiplier }
|
||||||
|
|
||||||
|
// Send stats
|
||||||
|
private var totalBytesSent: Int = 0
|
||||||
|
private var totalPacketsSent: Int = 0
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let ws = LNL.windowSize
|
||||||
|
self.pendingPackets = Array(repeating: nil, count: ws)
|
||||||
|
self.receivedPackets = Array(repeating: nil, count: ws)
|
||||||
|
let ackSize = (ws - 1) / 8 + 2
|
||||||
|
self.ackBitfield = Array(repeating: 0, count: ackSize)
|
||||||
|
|
||||||
|
let (stream, continuation) = AsyncStream.makeStream(of: Event.self)
|
||||||
|
self.events = stream
|
||||||
|
self.eventContinuation = continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
eventContinuation.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
func connect(host: String, port: UInt16, key: String) {
|
||||||
|
guard state == .disconnected else {
|
||||||
|
lnlLog.warning("connect() called but state is not disconnected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lnlLog.info("Connecting to \(host):\(port) with key '\(key)'")
|
||||||
|
connectKey = key
|
||||||
|
connectionTime = LNL.dotNetTicks
|
||||||
|
connectAttempts = 0
|
||||||
|
state = .connecting
|
||||||
|
|
||||||
|
let nwHost = NWEndpoint.Host(host)
|
||||||
|
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
|
||||||
|
lnlLog.error("Invalid port: \(port)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let conn = NWConnection(host: nwHost, port: nwPort, using: .udp)
|
||||||
|
connection = conn
|
||||||
|
|
||||||
|
conn.stateUpdateHandler = { [weak self] newState in
|
||||||
|
Task { [weak self] in await self?.handleNWState(newState) }
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.pathUpdateHandler = { path in
|
||||||
|
lnlLog.info("Path status: \(String(describing: path.status)), isExpensive: \(path.isExpensive), isConstrained: \(path.isConstrained)")
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.start(queue: nwQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(data: Data) {
|
||||||
|
guard state == .connected else { return }
|
||||||
|
outgoingQueue.append(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
performDisconnect(reason: "Client disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NWConnection State
|
||||||
|
|
||||||
|
private func handleNWState(_ newState: NWConnection.State) {
|
||||||
|
lnlLog.info("NWConnection state: \(String(describing: newState))")
|
||||||
|
switch newState {
|
||||||
|
case .ready:
|
||||||
|
if let path = connection?.currentPath {
|
||||||
|
lnlLog.info("UDP socket ready. Local: \(String(describing: path.localEndpoint)), remote: \(String(describing: path.remoteEndpoint))")
|
||||||
|
for iface in path.availableInterfaces {
|
||||||
|
lnlLog.info("Interface: \(iface.name) type: \(String(describing: iface.type))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startReceiving()
|
||||||
|
startUpdateLoop()
|
||||||
|
sendConnectRequest()
|
||||||
|
case .failed(let error):
|
||||||
|
lnlLog.error("NWConnection failed: \(error.localizedDescription)")
|
||||||
|
performDisconnect(reason: "Connection failed: \(error.localizedDescription)")
|
||||||
|
case .cancelled:
|
||||||
|
lnlLog.info("NWConnection cancelled")
|
||||||
|
performDisconnect(reason: nil)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Receive Loop
|
||||||
|
|
||||||
|
private func startReceiving() {
|
||||||
|
lnlLog.debug("Waiting for incoming UDP data...")
|
||||||
|
connection?.receiveMessage { [weak self] data, _, _, error in
|
||||||
|
Task { [weak self] in await self?.handleReceiveCallback(data: data, error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleReceiveCallback(data: Data?, error: NWError?) {
|
||||||
|
if let error {
|
||||||
|
lnlLog.error("Receive error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
if let data, !data.isEmpty {
|
||||||
|
lnlLog.info("Received \(data.count) bytes: \(data.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " "))\(data.count > 20 ? "..." : "")")
|
||||||
|
lastPacketReceivedTime = currentMs()
|
||||||
|
handleRawPacket(data)
|
||||||
|
} else if data == nil && error == nil {
|
||||||
|
lnlLog.warning("receiveMessage returned nil data with no error")
|
||||||
|
}
|
||||||
|
if error == nil && state != .disconnected {
|
||||||
|
startReceiving()
|
||||||
|
} else if state == .disconnected {
|
||||||
|
lnlLog.info("Not re-registering receive (disconnected)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Update Loop
|
||||||
|
|
||||||
|
private func startUpdateLoop() {
|
||||||
|
updateTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await self?.update()
|
||||||
|
try? await Task.sleep(for: .milliseconds(LNL.updateIntervalMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func update() {
|
||||||
|
let now = currentMs()
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case .connecting:
|
||||||
|
if now - lastConnectSendTime > 500 {
|
||||||
|
connectAttempts += 1
|
||||||
|
lnlLog.info("ConnectRequest attempt \(self.connectAttempts)/\(self.maxConnectAttempts)")
|
||||||
|
if connectAttempts > maxConnectAttempts {
|
||||||
|
lnlLog.error("Connection timed out after \(self.maxConnectAttempts) attempts")
|
||||||
|
performDisconnect(reason: "Connection timed out")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendConnectRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
case .connected:
|
||||||
|
if now - lastPacketReceivedTime > UInt64(LNL.disconnectTimeoutMs) {
|
||||||
|
performDisconnect(reason: "Connection timed out")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flushOutgoingQueue()
|
||||||
|
retransmitPending(now: now)
|
||||||
|
if mustSendAcks {
|
||||||
|
sendAck()
|
||||||
|
mustSendAcks = false
|
||||||
|
}
|
||||||
|
if now - lastPingSentTime > UInt64(LNL.pingIntervalMs) {
|
||||||
|
sendPing()
|
||||||
|
}
|
||||||
|
|
||||||
|
case .shutdownRequested, .disconnected:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Packet Handling
|
||||||
|
|
||||||
|
private func handleRawPacket(_ data: Data) {
|
||||||
|
guard !data.isEmpty else { return }
|
||||||
|
let byte0 = data[data.startIndex]
|
||||||
|
let property = byte0 & 0x1f
|
||||||
|
let connNum = (byte0 >> 5) & 0x03
|
||||||
|
|
||||||
|
guard let prop = PacketProperty(rawValue: property) else {
|
||||||
|
lnlLog.debug("Unknown packet property: \(property)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lnlLog.debug("Received packet: \(String(describing: prop)), \(data.count) bytes")
|
||||||
|
|
||||||
|
if state == .connected && prop != .connectAccept && prop != .connectRequest {
|
||||||
|
if connNum != connectionNumber { return }
|
||||||
|
}
|
||||||
|
|
||||||
|
switch prop {
|
||||||
|
case .connectAccept: handleConnectAccept(data)
|
||||||
|
case .channeled: handleChanneled(data)
|
||||||
|
case .ack: handleAck(data)
|
||||||
|
case .ping: handlePing(data)
|
||||||
|
case .pong: handlePong(data)
|
||||||
|
case .disconnect: handleServerDisconnect(data)
|
||||||
|
case .merged: handleMerged(data)
|
||||||
|
case .shutdownOk: performDisconnect(reason: nil)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connect Handshake
|
||||||
|
|
||||||
|
private func sendConnectRequest() {
|
||||||
|
lnlLog.debug("Sending ConnectRequest")
|
||||||
|
var w = DataWriter()
|
||||||
|
|
||||||
|
w.writeByte(PacketProperty.connectRequest.rawValue)
|
||||||
|
w.writeInt32LE(LNL.protocolId)
|
||||||
|
w.writeInt64LE(connectionTime)
|
||||||
|
w.writeInt32LE(localPeerId)
|
||||||
|
|
||||||
|
// Dummy IPv4 sockaddr (16 bytes, .NET SocketAddress format)
|
||||||
|
w.writeByte(16)
|
||||||
|
var addr = Data(count: 16)
|
||||||
|
addr[0] = 2 // AF_INET
|
||||||
|
w.writeData(addr)
|
||||||
|
|
||||||
|
// Connection key: ushort16 LE (byteCount + 1) + UTF-8
|
||||||
|
let keyBytes = Array(connectKey.utf8)
|
||||||
|
w.writeUInt16LE(UInt16(keyBytes.count + 1))
|
||||||
|
w.writeData(Data(keyBytes))
|
||||||
|
|
||||||
|
lastConnectSendTime = currentMs()
|
||||||
|
let packet = w.data
|
||||||
|
lnlLog.info("ConnectRequest packet (\(packet.count) bytes): \(packet.map { String(format: "%02x", $0) }.joined(separator: " "))")
|
||||||
|
sendRaw(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleConnectAccept(_ data: Data) {
|
||||||
|
guard state == .connecting else {
|
||||||
|
lnlLog.warning("ConnectAccept received but not in connecting state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard data.count >= 15 else {
|
||||||
|
lnlLog.error("ConnectAccept too short: \(data.count) bytes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var r = DataReader(data: data, offset: 1)
|
||||||
|
guard let echoedTime = r.readInt64LE(),
|
||||||
|
echoedTime == connectionTime else {
|
||||||
|
lnlLog.error("ConnectAccept connectionTime mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionNumber = r.readByte() ?? 0
|
||||||
|
_ = r.readByte() // isReused
|
||||||
|
remotePeerId = r.readInt32LE() ?? 0
|
||||||
|
|
||||||
|
lnlLog.info("Connected! connNum=\(self.connectionNumber), remotePeerId=\(self.remotePeerId)")
|
||||||
|
state = .connected
|
||||||
|
lastPacketReceivedTime = currentMs()
|
||||||
|
lastPingSentTime = currentMs()
|
||||||
|
|
||||||
|
eventContinuation.yield(.connected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Channeled (ReliableOrdered)
|
||||||
|
|
||||||
|
private func handleChanneled(_ data: Data) {
|
||||||
|
guard data.count >= LNL.channeledHeaderSize else { return }
|
||||||
|
|
||||||
|
var r = DataReader(data: data, offset: 1)
|
||||||
|
guard let seq = r.readUInt16LE() else { return }
|
||||||
|
guard let channelId = r.readByte() else { return }
|
||||||
|
guard channelId == LNL.reliableOrderedChannelId else { return }
|
||||||
|
|
||||||
|
// Pass the full packet (including fragment header if present) through
|
||||||
|
// the reliable ordered channel for sequencing and ACKs.
|
||||||
|
// Delivery/reassembly happens in deliverOrderedPacket.
|
||||||
|
let fullPayload = Data(data[data.startIndex ..< data.endIndex])
|
||||||
|
processReliableOrdered(sequence: seq, data: fullPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when a reliable ordered packet is delivered in-sequence.
|
||||||
|
/// Handles fragment reassembly before yielding to the consumer.
|
||||||
|
private func deliverOrderedPacket(_ data: Data) {
|
||||||
|
guard data.count >= LNL.channeledHeaderSize else { return }
|
||||||
|
let isFragmented = (data[data.startIndex] & 0x80) != 0
|
||||||
|
|
||||||
|
if isFragmented {
|
||||||
|
guard data.count >= 10 else { return }
|
||||||
|
var r = DataReader(data: data, offset: 4) // skip channeled header
|
||||||
|
guard let fragmentId = r.readUInt16LE() else { return }
|
||||||
|
guard let fragmentPart = r.readUInt16LE() else { return }
|
||||||
|
guard let fragmentsTotal = r.readUInt16LE() else { return }
|
||||||
|
guard fragmentsTotal > 0, fragmentPart < fragmentsTotal else { return }
|
||||||
|
|
||||||
|
let channelId = data[data.startIndex + 3]
|
||||||
|
let fragmentData = Data(data[data.startIndex + 10 ..< data.endIndex])
|
||||||
|
|
||||||
|
if incomingFragments[fragmentId] == nil {
|
||||||
|
incomingFragments[fragmentId] = IncomingFragment(
|
||||||
|
totalParts: Int(fragmentsTotal), channelId: channelId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard var fragment = incomingFragments[fragmentId],
|
||||||
|
fragment.channelId == channelId,
|
||||||
|
Int(fragmentPart) < fragment.totalParts,
|
||||||
|
fragment.parts[Int(fragmentPart)] == nil else { return }
|
||||||
|
|
||||||
|
fragment.parts[Int(fragmentPart)] = fragmentData
|
||||||
|
fragment.receivedCount += 1
|
||||||
|
incomingFragments[fragmentId] = fragment
|
||||||
|
|
||||||
|
if fragment.receivedCount == fragment.totalParts {
|
||||||
|
var assembled = Data()
|
||||||
|
for part in fragment.parts {
|
||||||
|
if let part { assembled.append(part) }
|
||||||
|
}
|
||||||
|
incomingFragments.removeValue(forKey: fragmentId)
|
||||||
|
lnlLog.info("Reassembled fragmented packet: \(fragment.totalParts) parts, \(assembled.count) bytes")
|
||||||
|
eventContinuation.yield(.received(assembled))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let userData = Data(data[data.startIndex + LNL.channeledHeaderSize ..< data.endIndex])
|
||||||
|
eventContinuation.yield(.received(userData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processReliableOrdered(sequence seq: UInt16, data: Data) {
|
||||||
|
// Reject already-delivered sequences
|
||||||
|
let relateToDelivered = relativeSequence(seq, to: remoteSequence)
|
||||||
|
if relateToDelivered < 0 { return }
|
||||||
|
|
||||||
|
let relate = relativeSequence(seq, to: remoteWindowStart)
|
||||||
|
|
||||||
|
if relate >= LNL.windowSize {
|
||||||
|
let diff = relate - LNL.windowSize + 1
|
||||||
|
for _ in 0..<diff {
|
||||||
|
let idx = Int(remoteWindowStart) % LNL.windowSize
|
||||||
|
receivedPackets[idx] = nil
|
||||||
|
let bitIndex = Int(remoteWindowStart) % LNL.windowSize
|
||||||
|
ackBitfield[bitIndex / 8] &= ~(1 << (bitIndex % 8))
|
||||||
|
remoteWindowStart = (remoteWindowStart &+ 1) % LNL.maxSequence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ackIdx = Int(seq) % LNL.windowSize
|
||||||
|
ackBitfield[ackIdx / 8] |= (1 << (ackIdx % 8))
|
||||||
|
mustSendAcks = true
|
||||||
|
|
||||||
|
if seq == remoteSequence {
|
||||||
|
deliverOrderedPacket(data)
|
||||||
|
remoteSequence = (remoteSequence &+ 1) % LNL.maxSequence
|
||||||
|
lnlLog.debug("Delivered seq \(seq), next expected: \(self.remoteSequence)")
|
||||||
|
|
||||||
|
while true {
|
||||||
|
let idx = Int(remoteSequence) % LNL.windowSize
|
||||||
|
guard let buffered = receivedPackets[idx] else { break }
|
||||||
|
receivedPackets[idx] = nil
|
||||||
|
deliverOrderedPacket(buffered)
|
||||||
|
remoteSequence = (remoteSequence &+ 1) % LNL.maxSequence
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let idx = Int(seq) % LNL.windowSize
|
||||||
|
receivedPackets[idx] = data
|
||||||
|
lnlLog.warning("Buffered out-of-order seq \(seq), expecting \(self.remoteSequence)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Send Reliable
|
||||||
|
|
||||||
|
private func flushOutgoingQueue() {
|
||||||
|
while !outgoingQueue.isEmpty {
|
||||||
|
let windowUsed = relativeSequence(localSequence, to: localWindowStart)
|
||||||
|
if windowUsed >= LNL.windowSize { break }
|
||||||
|
|
||||||
|
let userData = outgoingQueue.removeFirst()
|
||||||
|
let packet = buildChanneledPacket(sequence: localSequence, data: userData)
|
||||||
|
|
||||||
|
let idx = Int(localSequence) % LNL.windowSize
|
||||||
|
pendingPackets[idx] = PendingPacket(data: packet, timestamp: currentMs(), isSent: true)
|
||||||
|
localSequence = (localSequence &+ 1) % LNL.maxSequence
|
||||||
|
|
||||||
|
sendRaw(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retransmitPending(now: UInt64) {
|
||||||
|
var seq = localWindowStart
|
||||||
|
while seq != localSequence {
|
||||||
|
let idx = Int(seq) % LNL.windowSize
|
||||||
|
if let pending = pendingPackets[idx] {
|
||||||
|
if Double(now - pending.timestamp) >= resendDelay {
|
||||||
|
pendingPackets[idx]?.timestamp = now
|
||||||
|
sendRaw(pending.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seq = (seq &+ 1) % LNL.maxSequence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildChanneledPacket(sequence: UInt16, data: Data) -> Data {
|
||||||
|
var w = DataWriter()
|
||||||
|
w.writeByte((connectionNumber << 5) | PacketProperty.channeled.rawValue)
|
||||||
|
w.writeUInt16LE(sequence)
|
||||||
|
w.writeByte(LNL.reliableOrderedChannelId)
|
||||||
|
w.writeData(data)
|
||||||
|
return w.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ACK
|
||||||
|
|
||||||
|
private func sendAck() {
|
||||||
|
var w = DataWriter()
|
||||||
|
w.writeByte((connectionNumber << 5) | PacketProperty.ack.rawValue)
|
||||||
|
w.writeUInt16LE(remoteWindowStart)
|
||||||
|
w.writeByte(LNL.reliableOrderedChannelId)
|
||||||
|
w.writeData(Data(ackBitfield))
|
||||||
|
sendRaw(w.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAck(_ data: Data) {
|
||||||
|
guard data.count >= LNL.channeledHeaderSize else { return }
|
||||||
|
|
||||||
|
var r = DataReader(data: data, offset: 1)
|
||||||
|
guard let ackWindowStart = r.readUInt16LE() else { return }
|
||||||
|
guard let channelId = r.readByte(), channelId == LNL.reliableOrderedChannelId else { return }
|
||||||
|
|
||||||
|
let bitfieldStart = data.startIndex + LNL.channeledHeaderSize
|
||||||
|
let bitfieldData = data[bitfieldStart...]
|
||||||
|
|
||||||
|
var seq = localWindowStart
|
||||||
|
while seq != localSequence {
|
||||||
|
let relToAckStart = relativeSequence(seq, to: ackWindowStart)
|
||||||
|
if relToAckStart < 0 { break }
|
||||||
|
if relToAckStart >= LNL.windowSize { break }
|
||||||
|
|
||||||
|
let byteIdx = relToAckStart / 8
|
||||||
|
let bitIdx = relToAckStart % 8
|
||||||
|
if byteIdx < bitfieldData.count {
|
||||||
|
let acked = (bitfieldData[bitfieldStart + byteIdx] & (1 << bitIdx)) != 0
|
||||||
|
if acked {
|
||||||
|
let idx = Int(seq) % LNL.windowSize
|
||||||
|
pendingPackets[idx] = nil
|
||||||
|
if seq == localWindowStart {
|
||||||
|
localWindowStart = (localWindowStart &+ 1) % LNL.maxSequence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seq = (seq &+ 1) % LNL.maxSequence
|
||||||
|
}
|
||||||
|
|
||||||
|
while localWindowStart != localSequence {
|
||||||
|
let idx = Int(localWindowStart) % LNL.windowSize
|
||||||
|
if pendingPackets[idx] != nil { break }
|
||||||
|
localWindowStart = (localWindowStart &+ 1) % LNL.maxSequence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ping / Pong
|
||||||
|
|
||||||
|
private func sendPing() {
|
||||||
|
var w = DataWriter()
|
||||||
|
w.writeByte((connectionNumber << 5) | PacketProperty.ping.rawValue)
|
||||||
|
w.writeUInt16LE(pingSequence)
|
||||||
|
pingSequence = (pingSequence &+ 1) % LNL.maxSequence
|
||||||
|
pingSentTimestamp = currentMs()
|
||||||
|
lastPingSentTime = pingSentTimestamp
|
||||||
|
sendRaw(w.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePing(_ data: Data) {
|
||||||
|
guard data.count >= 3 else { return }
|
||||||
|
var r = DataReader(data: data, offset: 1)
|
||||||
|
guard let seq = r.readUInt16LE() else { return }
|
||||||
|
|
||||||
|
var w = DataWriter()
|
||||||
|
w.writeByte((connectionNumber << 5) | PacketProperty.pong.rawValue)
|
||||||
|
w.writeUInt16LE(seq)
|
||||||
|
w.writeInt64LE(LNL.dotNetTicks)
|
||||||
|
sendRaw(w.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePong(_ data: Data) {
|
||||||
|
guard data.count >= 3 else { return }
|
||||||
|
let elapsed = Double(currentMs() - pingSentTimestamp)
|
||||||
|
rttSum += elapsed
|
||||||
|
rttCount += 1
|
||||||
|
avgRtt = rttSum / Double(rttCount)
|
||||||
|
|
||||||
|
if rttCount > 10 {
|
||||||
|
rttSum = avgRtt
|
||||||
|
rttCount = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Disconnect
|
||||||
|
|
||||||
|
private func handleServerDisconnect(_ data: Data) {
|
||||||
|
guard data.count >= 9 else { return }
|
||||||
|
var w = DataWriter()
|
||||||
|
w.writeByte((connectionNumber << 5) | PacketProperty.shutdownOk.rawValue)
|
||||||
|
sendRaw(w.data)
|
||||||
|
performDisconnect(reason: "Server disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleMerged(_ data: Data) {
|
||||||
|
var offset = 1
|
||||||
|
while offset + 2 <= data.count {
|
||||||
|
let sizeBytes = data[data.startIndex + offset ..< data.startIndex + offset + 2]
|
||||||
|
let size = Int(UInt16(littleEndian: sizeBytes.withUnsafeBytes { $0.loadUnaligned(as: UInt16.self) }))
|
||||||
|
offset += 2
|
||||||
|
guard offset + size <= data.count else { break }
|
||||||
|
let subPacket = Data(data[data.startIndex + offset ..< data.startIndex + offset + size])
|
||||||
|
offset += size
|
||||||
|
handleRawPacket(subPacket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performDisconnect(reason: String?) {
|
||||||
|
guard state != .disconnected else { return }
|
||||||
|
lnlLog.info("Disconnecting: \(reason ?? "no reason")")
|
||||||
|
|
||||||
|
if state == .connected {
|
||||||
|
var w = DataWriter()
|
||||||
|
w.writeByte((connectionNumber << 5) | PacketProperty.disconnect.rawValue)
|
||||||
|
w.writeInt64LE(connectionTime)
|
||||||
|
sendRaw(w.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
state = .disconnected
|
||||||
|
updateTask?.cancel()
|
||||||
|
updateTask = nil
|
||||||
|
connection?.cancel()
|
||||||
|
connection = nil
|
||||||
|
|
||||||
|
// Reset channel state
|
||||||
|
localSequence = 0
|
||||||
|
localWindowStart = 0
|
||||||
|
remoteSequence = 0
|
||||||
|
remoteWindowStart = 0
|
||||||
|
outgoingQueue.removeAll()
|
||||||
|
for i in 0..<LNL.windowSize {
|
||||||
|
pendingPackets[i] = nil
|
||||||
|
receivedPackets[i] = nil
|
||||||
|
}
|
||||||
|
for i in 0..<ackBitfield.count {
|
||||||
|
ackBitfield[i] = 0
|
||||||
|
}
|
||||||
|
incomingFragments.removeAll()
|
||||||
|
|
||||||
|
eventContinuation.yield(.disconnected(reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Raw Send
|
||||||
|
|
||||||
|
private func sendRaw(_ data: Data) {
|
||||||
|
guard let conn = connection else {
|
||||||
|
lnlLog.error("sendRaw called but connection is nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if conn.state != .ready {
|
||||||
|
lnlLog.warning("sendRaw called but NWConnection state is \(String(describing: conn.state))")
|
||||||
|
}
|
||||||
|
totalPacketsSent += 1
|
||||||
|
totalBytesSent += data.count
|
||||||
|
let pktNum = totalPacketsSent
|
||||||
|
conn.send(content: data, completion: .contentProcessed { error in
|
||||||
|
if let error {
|
||||||
|
lnlLog.error("Send #\(pktNum) failed: \(error.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
lnlLog.debug("Send #\(pktNum) completed (\(data.count) bytes)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Utility
|
||||||
|
|
||||||
|
private func currentMs() -> UInt64 {
|
||||||
|
UInt64(DispatchTime.now().uptimeNanoseconds / 1_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func relativeSequence(_ number: UInt16, to expected: UInt16) -> Int {
|
||||||
|
let n = Int(number)
|
||||||
|
let e = Int(expected)
|
||||||
|
let max = Int(LNL.maxSequence)
|
||||||
|
return (n - e + max + LNL.halfMaxSequence) % max - LNL.halfMaxSequence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Binary Helpers
|
||||||
|
|
||||||
|
private struct DataWriter {
|
||||||
|
var data = Data()
|
||||||
|
|
||||||
|
mutating func writeByte(_ byte: UInt8) {
|
||||||
|
data.append(byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func writeUInt16LE(_ value: UInt16) {
|
||||||
|
withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func writeInt32LE(_ value: Int32) {
|
||||||
|
withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func writeInt64LE(_ value: Int64) {
|
||||||
|
withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func writeData(_ d: Data) {
|
||||||
|
data.append(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DataReader {
|
||||||
|
let data: Data
|
||||||
|
var offset: Int
|
||||||
|
|
||||||
|
mutating func readByte() -> UInt8? {
|
||||||
|
let idx = data.startIndex + offset
|
||||||
|
guard idx < data.endIndex else { return nil }
|
||||||
|
offset += 1
|
||||||
|
return data[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func readUInt16LE() -> UInt16? {
|
||||||
|
let start = data.startIndex + offset
|
||||||
|
guard start + 2 <= data.endIndex else { return nil }
|
||||||
|
offset += 2
|
||||||
|
return data[start..<start+2].withUnsafeBytes {
|
||||||
|
UInt16(littleEndian: $0.loadUnaligned(as: UInt16.self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func readInt32LE() -> Int32? {
|
||||||
|
let start = data.startIndex + offset
|
||||||
|
guard start + 4 <= data.endIndex else { return nil }
|
||||||
|
offset += 4
|
||||||
|
return data[start..<start+4].withUnsafeBytes {
|
||||||
|
Int32(littleEndian: $0.loadUnaligned(as: Int32.self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func readInt64LE() -> Int64? {
|
||||||
|
let start = data.startIndex + offset
|
||||||
|
guard start + 8 <= data.endIndex else { return nil }
|
||||||
|
offset += 8
|
||||||
|
return data[start..<start+8].withUnsafeBytes {
|
||||||
|
Int64(littleEndian: $0.loadUnaligned(as: Int64.self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
139
KDChat/Networking/MessagePack.swift
Normal file
139
KDChat/Networking/MessagePack.swift
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import Foundation
|
||||||
|
import MessagePack
|
||||||
|
|
||||||
|
// MARK: - Dynamic MessagePack Value
|
||||||
|
|
||||||
|
/// Represents any MessagePack value. Used for complex/polymorphic incoming data
|
||||||
|
/// (e.g. RemoteForm widgets) where the structure is too dynamic for static Codable types.
|
||||||
|
/// Decoded via the msgpack-swift library's `MessagePackDecoder`.
|
||||||
|
indirect enum MsgPackValue: Sendable {
|
||||||
|
case `nil`
|
||||||
|
case bool(Bool)
|
||||||
|
case int(Int64)
|
||||||
|
case uint(UInt64)
|
||||||
|
case float(Float)
|
||||||
|
case double(Double)
|
||||||
|
case string(String)
|
||||||
|
case binary(Data)
|
||||||
|
case array([MsgPackValue])
|
||||||
|
case map([(MsgPackValue, MsgPackValue)])
|
||||||
|
|
||||||
|
// MARK: Convenience Accessors
|
||||||
|
|
||||||
|
var isNil: Bool {
|
||||||
|
if case .nil = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var boolValue: Bool? {
|
||||||
|
if case .bool(let v) = self { return v }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var intValue: Int? {
|
||||||
|
switch self {
|
||||||
|
case .int(let v): return Int(exactly: v)
|
||||||
|
case .uint(let v): return Int(exactly: v)
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var int64Value: Int64? {
|
||||||
|
switch self {
|
||||||
|
case .int(let v): return v
|
||||||
|
case .uint(let v): return Int64(exactly: v)
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stringValue: String? {
|
||||||
|
if case .string(let v) = self { return v }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var arrayValue: [MsgPackValue]? {
|
||||||
|
if case .array(let v) = self { return v }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataValue: Data? {
|
||||||
|
if case .binary(let v) = self { return v }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up value by integer key in an array or map
|
||||||
|
subscript(key: Int) -> MsgPackValue? {
|
||||||
|
switch self {
|
||||||
|
case .array(let items):
|
||||||
|
guard key >= 0, key < items.count else { return nil }
|
||||||
|
return items[key]
|
||||||
|
case .map(let pairs):
|
||||||
|
for (k, v) in pairs {
|
||||||
|
if k.intValue == key { return v }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up value by string key in a map
|
||||||
|
subscript(key: String) -> MsgPackValue? {
|
||||||
|
guard case .map(let pairs) = self else { return nil }
|
||||||
|
for (k, v) in pairs {
|
||||||
|
if k.stringValue == key { return v }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Decodable
|
||||||
|
|
||||||
|
private struct DynamicCodingKey: CodingKey {
|
||||||
|
var stringValue: String
|
||||||
|
var intValue: Int?
|
||||||
|
init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }
|
||||||
|
init?(intValue: Int) { self.stringValue = "\(intValue)"; self.intValue = intValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MsgPackValue: Decodable {
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
// Try map (keyed container)
|
||||||
|
if let keyed = try? decoder.container(keyedBy: DynamicCodingKey.self) {
|
||||||
|
var pairs: [(MsgPackValue, MsgPackValue)] = []
|
||||||
|
for key in keyed.allKeys {
|
||||||
|
let val = try keyed.decode(MsgPackValue.self, forKey: key)
|
||||||
|
let k: MsgPackValue = key.intValue.map { .int(Int64($0)) } ?? .string(key.stringValue)
|
||||||
|
pairs.append((k, val))
|
||||||
|
}
|
||||||
|
self = .map(pairs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try array (unkeyed container)
|
||||||
|
if var unkeyed = try? decoder.unkeyedContainer() {
|
||||||
|
var items: [MsgPackValue] = []
|
||||||
|
while !unkeyed.isAtEnd {
|
||||||
|
items.append(try unkeyed.decode(MsgPackValue.self))
|
||||||
|
}
|
||||||
|
self = .array(items)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scalar value
|
||||||
|
let c = try decoder.singleValueContainer()
|
||||||
|
if c.decodeNil() { self = .nil; return }
|
||||||
|
if let v = try? c.decode(Bool.self) { self = .bool(v); return }
|
||||||
|
if let v = try? c.decode(String.self) { self = .string(v); return }
|
||||||
|
if let v = try? c.decode(UInt64.self) { self = .uint(v); return }
|
||||||
|
if let v = try? c.decode(Int64.self) { self = .int(v); return }
|
||||||
|
if let v = try? c.decode(Double.self) { self = .double(v); return }
|
||||||
|
if let v = try? c.decode(Float.self) { self = .float(v); return }
|
||||||
|
if let v = try? c.decode(Data.self) { self = .binary(v); return }
|
||||||
|
|
||||||
|
throw DecodingError.dataCorrupted(
|
||||||
|
DecodingError.Context(codingPath: decoder.codingPath,
|
||||||
|
debugDescription: "Cannot decode MsgPackValue")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
222
KDChat/Networking/ProtocolTypes.swift
Normal file
222
KDChat/Networking/ProtocolTypes.swift
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
import Foundation
|
||||||
|
import MessagePack
|
||||||
|
|
||||||
|
// MARK: - Server Methods (client -> server)
|
||||||
|
|
||||||
|
enum ServerMethod: Int32 {
|
||||||
|
case createAccount = 0
|
||||||
|
case requestToken = 1
|
||||||
|
case requestLogin = 2
|
||||||
|
case sendMessage = 3
|
||||||
|
case createCharacter = 4
|
||||||
|
case leaveGame = 5
|
||||||
|
// 6 = TimeSync, 7 = RequestProfile
|
||||||
|
case directMessage = 8
|
||||||
|
// 9..19 = game-specific methods
|
||||||
|
case remoteFormEvent = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Client Methods (server -> client)
|
||||||
|
|
||||||
|
enum ClientMethod: Int32 {
|
||||||
|
case accountCreationUpdate = 0
|
||||||
|
case tokenRequestUpdate = 1
|
||||||
|
case loginRequestUpdate = 2
|
||||||
|
case addBuffer = 3
|
||||||
|
case addMessage = 4
|
||||||
|
case requestGameplay = 5
|
||||||
|
case leaveGameUpdate = 6
|
||||||
|
// 7 = TimeSync (unhandled)
|
||||||
|
// 8 = Profile (unhandled)
|
||||||
|
case say = 9
|
||||||
|
// 10..32 = game-specific methods (unhandled)
|
||||||
|
case showRemoteForm = 33
|
||||||
|
case updateRemoteForm = 34
|
||||||
|
case closeRemoteForm = 35
|
||||||
|
case disconnect = 36
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Remote Form Event Types
|
||||||
|
|
||||||
|
enum RemoteFormEventType: Int, Sendable {
|
||||||
|
case buttonClicked = 0
|
||||||
|
case textChanged = 1
|
||||||
|
case textSubmitted = 2
|
||||||
|
case checkBoxValueChanged = 3
|
||||||
|
case listBoxSelectionChanged = 4
|
||||||
|
case listBoxItemClicked = 5
|
||||||
|
case multiSelectListSelectionChanged = 6
|
||||||
|
case sliderMoved = 7
|
||||||
|
case treeNodeSelected = 8
|
||||||
|
case treeNodeClicked = 9
|
||||||
|
case formClosed = 10
|
||||||
|
case formCloseRequested = 11
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Form Event Value (for outgoing form events)
|
||||||
|
|
||||||
|
enum FormEventValue: Sendable {
|
||||||
|
case none
|
||||||
|
case bool(Bool)
|
||||||
|
case string(String)
|
||||||
|
case uint(UInt64)
|
||||||
|
case uintArray([UInt64])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Outgoing Packet Types
|
||||||
|
|
||||||
|
private struct TwoStringPacket: Encodable {
|
||||||
|
let a: String, b: String
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var c = encoder.unkeyedContainer()
|
||||||
|
try c.encode(a); try c.encode(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OneStringPacket: Encodable {
|
||||||
|
let a: String
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var c = encoder.unkeyedContainer()
|
||||||
|
try c.encode(a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RemoteFormEventPacket: Encodable {
|
||||||
|
let formId: String
|
||||||
|
let widgetId: String
|
||||||
|
let eventType: Int
|
||||||
|
let value: FormEventValue
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var c = encoder.unkeyedContainer()
|
||||||
|
try c.encode(formId)
|
||||||
|
try c.encode(widgetId)
|
||||||
|
try c.encode(eventType)
|
||||||
|
switch value {
|
||||||
|
case .none: try c.encodeNil()
|
||||||
|
case .bool(let v): try c.encode(v)
|
||||||
|
case .string(let v): try c.encode(v)
|
||||||
|
case .uint(let v): try c.encode(v)
|
||||||
|
case .uintArray(let v): try c.encode(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Incoming Packet Types
|
||||||
|
|
||||||
|
struct GenericResponse: Decodable, Sendable {
|
||||||
|
let success: Bool
|
||||||
|
let message: String?
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
var c = try decoder.unkeyedContainer()
|
||||||
|
success = try c.decode(Bool.self)
|
||||||
|
if c.isAtEnd { message = nil }
|
||||||
|
else { message = try c.decodeNil() ? nil : c.decode(String.self) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GenericStringResponse: Decodable, Sendable {
|
||||||
|
let success: Bool
|
||||||
|
let message: String?
|
||||||
|
let data: String?
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
var c = try decoder.unkeyedContainer()
|
||||||
|
success = try c.decode(Bool.self)
|
||||||
|
if c.isAtEnd { message = nil; data = nil; return }
|
||||||
|
message = try c.decodeNil() ? nil : c.decode(String.self)
|
||||||
|
if c.isAtEnd { data = nil; return }
|
||||||
|
data = try c.decodeNil() ? nil : c.decode(String.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServerSay: Decodable, Sendable {
|
||||||
|
let message: String
|
||||||
|
let interrupt: Bool
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
var c = try decoder.unkeyedContainer()
|
||||||
|
message = try c.decode(String.self)
|
||||||
|
interrupt = c.isAtEnd ? false : try c.decode(Bool.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServerDisconnectInfo: Decodable, Sendable {
|
||||||
|
let characterId: String
|
||||||
|
let reason: String
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
var c = try decoder.unkeyedContainer()
|
||||||
|
characterId = try c.decode(String.self)
|
||||||
|
reason = c.isAtEnd ? "Unknown reason" : try c.decode(String.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Packet Encoding
|
||||||
|
|
||||||
|
enum PacketEncoder {
|
||||||
|
private static func encode<T: Encodable>(method: ServerMethod, payload: T) -> Data {
|
||||||
|
var data = Data()
|
||||||
|
withUnsafeBytes(of: method.rawValue.littleEndian) { data.append(contentsOf: $0) }
|
||||||
|
data.append(try! MessagePackEncoder().encode(payload))
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func tokenRequest(email: String, password: String) -> Data {
|
||||||
|
encode(method: .requestToken, payload: TwoStringPacket(a: email, b: password))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func createAccountRequest(email: String, password: String) -> Data {
|
||||||
|
encode(method: .createAccount, payload: TwoStringPacket(a: email, b: password))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loginRequest(token: String) -> Data {
|
||||||
|
encode(method: .requestLogin, payload: OneStringPacket(a: token))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sendMessage(buffer: String, content: String) -> Data {
|
||||||
|
encode(method: .sendMessage, payload: TwoStringPacket(a: buffer, b: content))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func directMessage(recipientId: String, message: String) -> Data {
|
||||||
|
encode(method: .directMessage, payload: TwoStringPacket(a: recipientId, b: message))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func leaveGame(characterId: String) -> Data {
|
||||||
|
encode(method: .leaveGame, payload: OneStringPacket(a: characterId))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func remoteFormEvent(
|
||||||
|
formId: String,
|
||||||
|
widgetId: String,
|
||||||
|
eventType: RemoteFormEventType,
|
||||||
|
value: FormEventValue = .none
|
||||||
|
) -> Data {
|
||||||
|
encode(method: .remoteFormEvent, payload: RemoteFormEventPacket(
|
||||||
|
formId: formId, widgetId: widgetId,
|
||||||
|
eventType: eventType.rawValue, value: value
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Packet Decoding
|
||||||
|
|
||||||
|
enum PacketDecoder {
|
||||||
|
static func extractMethod(from data: Data) -> (ClientMethod, Data)? {
|
||||||
|
guard data.count >= 4 else { return nil }
|
||||||
|
let methodRaw = data.withUnsafeBytes {
|
||||||
|
Int32(littleEndian: $0.loadUnaligned(as: Int32.self))
|
||||||
|
}
|
||||||
|
guard let method = ClientMethod(rawValue: methodRaw) else { return nil }
|
||||||
|
return (method, Data(data[data.startIndex + 4 ..< data.endIndex]))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode<T: Decodable>(_ type: T.Type, from data: Data) -> T? {
|
||||||
|
try? MessagePackDecoder().decode(type, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decodeDynamic(from data: Data) -> MsgPackValue? {
|
||||||
|
try? MessagePackDecoder().decode(MsgPackValue.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
201
KDChat/Networking/RemoteFormTypes.swift
Normal file
201
KDChat/Networking/RemoteFormTypes.swift
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Widget Type
|
||||||
|
|
||||||
|
enum WidgetType: Int, Sendable {
|
||||||
|
case button = 0
|
||||||
|
case textField = 1
|
||||||
|
case checkBox = 2
|
||||||
|
case listBox = 3
|
||||||
|
case slider = 4
|
||||||
|
case treeView = 5
|
||||||
|
case multiSelectList = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tree Node
|
||||||
|
|
||||||
|
struct RemoteTreeNode: Identifiable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let text: String
|
||||||
|
let children: [RemoteTreeNode]
|
||||||
|
let isExpanded: Bool
|
||||||
|
|
||||||
|
init?(from value: MsgPackValue) {
|
||||||
|
guard let id = value[0]?.stringValue,
|
||||||
|
let text = value[1]?.stringValue else { return nil }
|
||||||
|
self.id = id
|
||||||
|
self.text = text
|
||||||
|
self.children = value[2]?.arrayValue?.compactMap { RemoteTreeNode(from: $0) } ?? []
|
||||||
|
self.isExpanded = value[3]?.boolValue ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget State
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class WidgetState: Identifiable, @unchecked Sendable {
|
||||||
|
let id: String
|
||||||
|
var label: String
|
||||||
|
var enabled: Bool
|
||||||
|
var type: WidgetType
|
||||||
|
|
||||||
|
// TextField
|
||||||
|
var text: String = ""
|
||||||
|
var isPassword: Bool = false
|
||||||
|
var maxLength: Int = 1_000_000
|
||||||
|
var readOnly: Bool = false
|
||||||
|
|
||||||
|
// CheckBox
|
||||||
|
var isChecked: Bool = false
|
||||||
|
|
||||||
|
// ListBox
|
||||||
|
var items: [String] = []
|
||||||
|
var selectedIndex: Int = -1
|
||||||
|
|
||||||
|
// Slider
|
||||||
|
var sliderValue: Int = 0
|
||||||
|
var minValue: Int = 0
|
||||||
|
var maxValue: Int = 100
|
||||||
|
var increment: Int = 1
|
||||||
|
|
||||||
|
// TreeView
|
||||||
|
var nodes: [RemoteTreeNode] = []
|
||||||
|
var selectedNodeId: String = ""
|
||||||
|
|
||||||
|
// MultiSelectList
|
||||||
|
var selectedIndices: Set<Int> = []
|
||||||
|
|
||||||
|
init(id: String, label: String, enabled: Bool, type: WidgetType) {
|
||||||
|
self.id = id
|
||||||
|
self.label = label
|
||||||
|
self.enabled = enabled
|
||||||
|
self.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a widget from a MessagePack Union value: [typeId, objectMap]
|
||||||
|
static func fromUnion(_ value: MsgPackValue) -> WidgetState? {
|
||||||
|
guard let arr = value.arrayValue, arr.count == 2,
|
||||||
|
let typeId = arr[0].intValue,
|
||||||
|
let type = WidgetType(rawValue: typeId) else { return nil }
|
||||||
|
|
||||||
|
let obj = arr[1]
|
||||||
|
guard let id = obj[0]?.stringValue else { return nil }
|
||||||
|
let label = obj[1]?.stringValue ?? ""
|
||||||
|
let enabled = obj[2]?.boolValue ?? true
|
||||||
|
|
||||||
|
let widget = WidgetState(id: id, label: label, enabled: enabled, type: type)
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .button:
|
||||||
|
break
|
||||||
|
case .textField:
|
||||||
|
widget.text = obj[5]?.stringValue ?? ""
|
||||||
|
widget.isPassword = obj[6]?.boolValue ?? false
|
||||||
|
widget.maxLength = obj[7]?.intValue ?? 1_000_000
|
||||||
|
widget.readOnly = obj[8]?.boolValue ?? false
|
||||||
|
case .checkBox:
|
||||||
|
widget.isChecked = obj[5]?.boolValue ?? false
|
||||||
|
case .listBox:
|
||||||
|
widget.items = obj[5]?.arrayValue?.compactMap(\.stringValue) ?? []
|
||||||
|
widget.selectedIndex = obj[6]?.intValue ?? -1
|
||||||
|
case .slider:
|
||||||
|
widget.sliderValue = obj[5]?.intValue ?? 0
|
||||||
|
widget.minValue = obj[6]?.intValue ?? 0
|
||||||
|
widget.maxValue = obj[7]?.intValue ?? 100
|
||||||
|
widget.increment = obj[8]?.intValue ?? 1
|
||||||
|
case .treeView:
|
||||||
|
widget.nodes = obj[5]?.arrayValue?.compactMap { RemoteTreeNode(from: $0) } ?? []
|
||||||
|
widget.selectedNodeId = obj[6]?.stringValue ?? ""
|
||||||
|
case .multiSelectList:
|
||||||
|
widget.items = obj[5]?.arrayValue?.compactMap(\.stringValue) ?? []
|
||||||
|
let indices = obj[6]?.arrayValue?.compactMap(\.intValue) ?? []
|
||||||
|
widget.selectedIndices = Set(indices)
|
||||||
|
}
|
||||||
|
|
||||||
|
return widget
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update this widget from a new Union value (preserving identity)
|
||||||
|
func update(from value: MsgPackValue) {
|
||||||
|
guard let arr = value.arrayValue, arr.count == 2 else { return }
|
||||||
|
let obj = arr[1]
|
||||||
|
|
||||||
|
label = obj[1]?.stringValue ?? label
|
||||||
|
enabled = obj[2]?.boolValue ?? enabled
|
||||||
|
|
||||||
|
if let typeId = arr[0].intValue, let newType = WidgetType(rawValue: typeId) {
|
||||||
|
type = newType
|
||||||
|
}
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .button:
|
||||||
|
break
|
||||||
|
case .textField:
|
||||||
|
if let t = obj[5]?.stringValue { text = t }
|
||||||
|
if let p = obj[6]?.boolValue { isPassword = p }
|
||||||
|
if let m = obj[7]?.intValue { maxLength = m }
|
||||||
|
if let r = obj[8]?.boolValue { readOnly = r }
|
||||||
|
case .checkBox:
|
||||||
|
if let c = obj[5]?.boolValue { isChecked = c }
|
||||||
|
case .listBox:
|
||||||
|
if let i = obj[5]?.arrayValue { items = i.compactMap(\.stringValue) }
|
||||||
|
if let s = obj[6]?.intValue { selectedIndex = s }
|
||||||
|
case .slider:
|
||||||
|
if let v = obj[5]?.intValue { sliderValue = v }
|
||||||
|
if let mn = obj[6]?.intValue { minValue = mn }
|
||||||
|
if let mx = obj[7]?.intValue { maxValue = mx }
|
||||||
|
if let inc = obj[8]?.intValue { increment = inc }
|
||||||
|
case .treeView:
|
||||||
|
if let n = obj[5]?.arrayValue { nodes = n.compactMap { RemoteTreeNode(from: $0) } }
|
||||||
|
if let s = obj[6]?.stringValue { selectedNodeId = s }
|
||||||
|
case .multiSelectList:
|
||||||
|
if let i = obj[5]?.arrayValue { items = i.compactMap(\.stringValue) }
|
||||||
|
if let s = obj[6]?.arrayValue { selectedIndices = Set(s.compactMap(\.intValue)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Remote Form
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class RemoteFormData: Identifiable, @unchecked Sendable {
|
||||||
|
let id: String
|
||||||
|
var title: String
|
||||||
|
var widgets: [WidgetState]
|
||||||
|
|
||||||
|
init(id: String, title: String, widgets: [WidgetState]) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.widgets = widgets
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(_ value: MsgPackValue) -> RemoteFormData? {
|
||||||
|
guard let id = value[0]?.stringValue,
|
||||||
|
let title = value[1]?.stringValue else { return nil }
|
||||||
|
|
||||||
|
let widgets = value[2]?.arrayValue?.compactMap { WidgetState.fromUnion($0) } ?? []
|
||||||
|
return RemoteFormData(id: id, title: title, widgets: widgets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateWidgets(from value: MsgPackValue) {
|
||||||
|
// value[1] is UpdatedWidgets: Dictionary<string, RemoteWidget?>
|
||||||
|
guard let updates = value[1] else { return }
|
||||||
|
|
||||||
|
if case .map(let pairs) = updates {
|
||||||
|
for (key, val) in pairs {
|
||||||
|
guard let widgetId = key.stringValue else { continue }
|
||||||
|
|
||||||
|
if val.isNil {
|
||||||
|
// Remove widget
|
||||||
|
widgets.removeAll { $0.id == widgetId }
|
||||||
|
} else if let existing = widgets.first(where: { $0.id == widgetId }) {
|
||||||
|
// Update existing widget
|
||||||
|
existing.update(from: val)
|
||||||
|
} else if let newWidget = WidgetState.fromUnion(val) {
|
||||||
|
// Add new widget
|
||||||
|
widgets.append(newWidget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
390
KDChat/Services/ConnectionManager.swift
Normal file
390
KDChat/Services/ConnectionManager.swift
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "dev.smoll.KDChat", category: "Connection")
|
||||||
|
|
||||||
|
// MARK: - App Screen
|
||||||
|
|
||||||
|
enum AppScreen: Sendable, Equatable {
|
||||||
|
case login
|
||||||
|
case connecting
|
||||||
|
case forms
|
||||||
|
case chat
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Server Config
|
||||||
|
|
||||||
|
struct ServerConfig: Sendable {
|
||||||
|
var host: String
|
||||||
|
var port: UInt16
|
||||||
|
|
||||||
|
static let `default` = ServerConfig(host: "15.204.120.54", port: 4200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection Manager
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class ConnectionManager {
|
||||||
|
// Navigation state
|
||||||
|
var screen: AppScreen = .login
|
||||||
|
var errorMessage: String?
|
||||||
|
|
||||||
|
// Remote forms
|
||||||
|
var remoteForms: [RemoteFormData] = []
|
||||||
|
|
||||||
|
// Chat
|
||||||
|
var buffers: [ChatBuffer] = []
|
||||||
|
var messages: [String: [ChatMessage]] = [:]
|
||||||
|
var currentCharacterId: String?
|
||||||
|
|
||||||
|
// Feedback triggers (views observe these for sensoryFeedback / announcements)
|
||||||
|
var messageFeedbackTrigger: Int = 0
|
||||||
|
var systemFeedbackTrigger: Int = 0
|
||||||
|
var lastAnnouncement: String = ""
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
private(set) var isLoggedIn: Bool = false
|
||||||
|
private var cachedToken: String?
|
||||||
|
private var pendingEmail: String?
|
||||||
|
private var pendingPassword: String?
|
||||||
|
private var pendingCreateAccount: Bool = false
|
||||||
|
|
||||||
|
// Networking
|
||||||
|
private var client: LNLClient?
|
||||||
|
private var eventTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
// MARK: - Connect
|
||||||
|
|
||||||
|
func connect(host: String, port: UInt16, email: String, password: String, createAccount: Bool) {
|
||||||
|
screen = .connecting
|
||||||
|
errorMessage = nil
|
||||||
|
pendingEmail = email
|
||||||
|
pendingPassword = password
|
||||||
|
pendingCreateAccount = createAccount
|
||||||
|
|
||||||
|
startClient(host: host, port: port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconnect using a cached JWT token
|
||||||
|
func reconnectWithToken(host: String, port: UInt16) {
|
||||||
|
guard let token = KeychainHelper.load(key: "jwt") else {
|
||||||
|
screen = .login
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cachedToken = token
|
||||||
|
|
||||||
|
screen = .connecting
|
||||||
|
errorMessage = nil
|
||||||
|
pendingEmail = nil
|
||||||
|
pendingPassword = nil
|
||||||
|
|
||||||
|
startClient(host: host, port: port)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tearDownClient() {
|
||||||
|
eventTask?.cancel()
|
||||||
|
eventTask = nil
|
||||||
|
if let client {
|
||||||
|
Task { await client.disconnect() }
|
||||||
|
}
|
||||||
|
client = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startClient(host: String, port: UInt16) {
|
||||||
|
tearDownClient()
|
||||||
|
|
||||||
|
let client = LNLClient()
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
eventTask = Task { [weak self] in
|
||||||
|
await client.connect(host: host, port: port, key: "Placeholder")
|
||||||
|
for await event in client.events {
|
||||||
|
guard let self else { break }
|
||||||
|
switch event {
|
||||||
|
case .connected:
|
||||||
|
if self.cachedToken != nil {
|
||||||
|
self.loginWithToken(self.cachedToken!)
|
||||||
|
} else {
|
||||||
|
self.handleConnected()
|
||||||
|
}
|
||||||
|
case .received(let data):
|
||||||
|
self.handlePacket(data)
|
||||||
|
case .disconnected(let reason):
|
||||||
|
self.handleDisconnect(reason: reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chat Actions
|
||||||
|
|
||||||
|
func sendChatMessage(buffer: String, content: String) {
|
||||||
|
let packet = PacketEncoder.sendMessage(buffer: buffer, content: content)
|
||||||
|
Task { await client?.send(data: packet) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendDirectMessage(recipientId: String, message: String) {
|
||||||
|
let packet = PacketEncoder.directMessage(recipientId: recipientId, message: message)
|
||||||
|
Task { await client?.send(data: packet) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Form Actions
|
||||||
|
|
||||||
|
func sendFormEvent(
|
||||||
|
formId: String,
|
||||||
|
widgetId: String,
|
||||||
|
eventType: RemoteFormEventType,
|
||||||
|
value: FormEventValue = .none
|
||||||
|
) {
|
||||||
|
log.info("sendFormEvent: \(String(describing: eventType)) widget=\(widgetId) form=\(formId)")
|
||||||
|
let packet = PacketEncoder.remoteFormEvent(
|
||||||
|
formId: formId, widgetId: widgetId,
|
||||||
|
eventType: eventType, value: value
|
||||||
|
)
|
||||||
|
Task { await client?.send(data: packet) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Character Actions
|
||||||
|
|
||||||
|
func switchCharacter() {
|
||||||
|
guard let charId = currentCharacterId else { return }
|
||||||
|
let packet = PacketEncoder.leaveGame(characterId: charId)
|
||||||
|
Task { await client?.send(data: packet) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() {
|
||||||
|
KeychainHelper.delete(key: "jwt")
|
||||||
|
cachedToken = nil
|
||||||
|
isLoggedIn = false
|
||||||
|
tearDownClient()
|
||||||
|
resetState()
|
||||||
|
screen = .login
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection Callbacks
|
||||||
|
|
||||||
|
private func handleConnected() {
|
||||||
|
log.info("LiteNetLib connected, sending auth request")
|
||||||
|
if let email = pendingEmail, let password = pendingPassword {
|
||||||
|
if pendingCreateAccount {
|
||||||
|
log.info("Sending CreateAccount for \(email)")
|
||||||
|
let packet = PacketEncoder.createAccountRequest(email: email, password: password)
|
||||||
|
Task { await client?.send(data: packet) }
|
||||||
|
} else {
|
||||||
|
log.info("Sending TokenRequest for \(email)")
|
||||||
|
let packet = PacketEncoder.tokenRequest(email: email, password: password)
|
||||||
|
Task { await client?.send(data: packet) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warning("handleConnected called but no pending credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loginWithToken(_ token: String) {
|
||||||
|
let packet = PacketEncoder.loginRequest(token: token)
|
||||||
|
Task { await client?.send(data: packet) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDisconnect(reason: String?) {
|
||||||
|
log.info("Disconnected: \(reason ?? "unknown")")
|
||||||
|
if screen != .login {
|
||||||
|
errorMessage = reason ?? "Disconnected"
|
||||||
|
screen = .login
|
||||||
|
}
|
||||||
|
isLoggedIn = false
|
||||||
|
// Don't call tearDownClient here — this is already called from the event stream
|
||||||
|
// which means the client is already disconnecting. Just clear the reference.
|
||||||
|
client = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetState() {
|
||||||
|
buffers.removeAll()
|
||||||
|
messages.removeAll()
|
||||||
|
remoteForms.removeAll()
|
||||||
|
currentCharacterId = nil
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Packet Dispatch
|
||||||
|
|
||||||
|
private func handlePacket(_ data: Data) {
|
||||||
|
guard data.count >= 4 else {
|
||||||
|
log.warning("Packet too short (\(data.count) bytes)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let methodRaw = data.withUnsafeBytes { Int32(littleEndian: $0.loadUnaligned(as: Int32.self)) }
|
||||||
|
let payload = Data(data[data.startIndex + 4 ..< data.endIndex])
|
||||||
|
|
||||||
|
guard let method = ClientMethod(rawValue: methodRaw) else {
|
||||||
|
log.info("Ignoring unhandled method ID \(methodRaw) (\(payload.count) bytes)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.debug("Received \(String(describing: method))")
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case .accountCreationUpdate:
|
||||||
|
guard let response = PacketDecoder.decode(GenericResponse.self, from: payload) else { return }
|
||||||
|
handleAccountCreation(response)
|
||||||
|
case .tokenRequestUpdate:
|
||||||
|
guard let response = PacketDecoder.decode(GenericStringResponse.self, from: payload) else { return }
|
||||||
|
handleTokenResponse(response)
|
||||||
|
case .loginRequestUpdate:
|
||||||
|
guard let response = PacketDecoder.decode(GenericResponse.self, from: payload) else { return }
|
||||||
|
handleLoginResponse(response)
|
||||||
|
case .addBuffer:
|
||||||
|
guard let buffer = PacketDecoder.decode(ChatBuffer.self, from: payload) else { return }
|
||||||
|
handleAddBuffer(buffer)
|
||||||
|
case .addMessage:
|
||||||
|
guard let message = PacketDecoder.decode(ChatMessage.self, from: payload) else { return }
|
||||||
|
handleAddMessage(message)
|
||||||
|
case .requestGameplay:
|
||||||
|
handleRequestGameplay()
|
||||||
|
case .leaveGameUpdate:
|
||||||
|
guard let response = PacketDecoder.decode(GenericResponse.self, from: payload) else { return }
|
||||||
|
handleLeaveGameUpdate(response)
|
||||||
|
case .say:
|
||||||
|
guard let say = PacketDecoder.decode(ServerSay.self, from: payload) else { return }
|
||||||
|
handleSay(say)
|
||||||
|
case .showRemoteForm:
|
||||||
|
guard let value = PacketDecoder.decodeDynamic(from: payload) else {
|
||||||
|
log.error("Failed to decode showRemoteForm payload (\(payload.count) bytes)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleShowRemoteForm(value)
|
||||||
|
case .updateRemoteForm:
|
||||||
|
guard let value = PacketDecoder.decodeDynamic(from: payload) else { return }
|
||||||
|
handleUpdateRemoteForm(value)
|
||||||
|
case .closeRemoteForm:
|
||||||
|
guard let value = PacketDecoder.decodeDynamic(from: payload) else { return }
|
||||||
|
handleCloseRemoteForm(value)
|
||||||
|
case .disconnect:
|
||||||
|
guard let info = PacketDecoder.decode(ServerDisconnectInfo.self, from: payload) else { return }
|
||||||
|
handleServerDisconnect(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth Handlers
|
||||||
|
|
||||||
|
private func handleAccountCreation(_ response: GenericResponse) {
|
||||||
|
guard !isLoggedIn else { return }
|
||||||
|
if response.success {
|
||||||
|
// Account created, now request a token
|
||||||
|
if let email = pendingEmail, let password = pendingPassword {
|
||||||
|
pendingCreateAccount = false
|
||||||
|
let packet = PacketEncoder.tokenRequest(email: email, password: password)
|
||||||
|
Task { await client?.send(data: packet) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMessage = response.message ?? "Account creation failed"
|
||||||
|
tearDownClient()
|
||||||
|
screen = .login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleTokenResponse(_ response: GenericStringResponse) {
|
||||||
|
guard !isLoggedIn else { return }
|
||||||
|
if response.success, let token = response.data {
|
||||||
|
log.info("Token received, logging in")
|
||||||
|
cachedToken = token
|
||||||
|
KeychainHelper.save(key: "jwt", value: token)
|
||||||
|
loginWithToken(token)
|
||||||
|
} else {
|
||||||
|
log.error("Token request failed: \(response.message ?? "unknown")")
|
||||||
|
errorMessage = response.message ?? "Authentication failed"
|
||||||
|
tearDownClient()
|
||||||
|
screen = .login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleLoginResponse(_ response: GenericResponse) {
|
||||||
|
guard !isLoggedIn else { return }
|
||||||
|
if response.success {
|
||||||
|
log.info("Login successful, waiting for RemoteForms")
|
||||||
|
isLoggedIn = true
|
||||||
|
screen = .forms
|
||||||
|
} else {
|
||||||
|
log.error("Login failed: \(response.message ?? "unknown")")
|
||||||
|
KeychainHelper.delete(key: "jwt")
|
||||||
|
cachedToken = nil
|
||||||
|
errorMessage = response.message ?? "Login failed"
|
||||||
|
tearDownClient()
|
||||||
|
screen = .login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chat Handlers
|
||||||
|
|
||||||
|
private func handleAddBuffer(_ buffer: ChatBuffer) {
|
||||||
|
log.info("addBuffer: \(buffer.name), canSend=\(buffer.canSend)")
|
||||||
|
if !buffers.contains(where: { $0.name == buffer.name }) {
|
||||||
|
buffers.append(buffer)
|
||||||
|
messages[buffer.name] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAddMessage(_ message: ChatMessage) {
|
||||||
|
messages[message.buffer, default: []].append(message)
|
||||||
|
|
||||||
|
messageFeedbackTrigger += 1
|
||||||
|
lastAnnouncement = "\(message.senderName): \(message.content)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleSay(_ say: ServerSay) {
|
||||||
|
let sysMessage = ChatMessage(systemMessage: say.message)
|
||||||
|
|
||||||
|
for buffer in buffers {
|
||||||
|
messages[buffer.name, default: []].append(sysMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemFeedbackTrigger += 1
|
||||||
|
lastAnnouncement = say.message
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game State Handlers
|
||||||
|
|
||||||
|
private func handleRequestGameplay() {
|
||||||
|
// Game entry signal — switch to chat view
|
||||||
|
remoteForms.removeAll()
|
||||||
|
screen = .chat
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleLeaveGameUpdate(_ response: GenericResponse) {
|
||||||
|
if response.success {
|
||||||
|
// Clear chat state, wait for new RemoteForms
|
||||||
|
resetState()
|
||||||
|
screen = .forms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Remote Form Handlers
|
||||||
|
|
||||||
|
private func handleShowRemoteForm(_ payload: MsgPackValue) {
|
||||||
|
guard let form = RemoteFormData.from(payload) else { return }
|
||||||
|
remoteForms.append(form)
|
||||||
|
if screen != .chat {
|
||||||
|
screen = .forms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleUpdateRemoteForm(_ payload: MsgPackValue) {
|
||||||
|
guard let formId = payload[0]?.stringValue,
|
||||||
|
let form = remoteForms.first(where: { $0.id == formId }) else { return }
|
||||||
|
form.updateWidgets(from: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCloseRemoteForm(_ payload: MsgPackValue) {
|
||||||
|
guard let formId = payload[0]?.stringValue else { return }
|
||||||
|
remoteForms.removeAll { $0.id == formId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Server Disconnect
|
||||||
|
|
||||||
|
private func handleServerDisconnect(_ info: ServerDisconnectInfo) {
|
||||||
|
errorMessage = info.reason
|
||||||
|
tearDownClient()
|
||||||
|
isLoggedIn = false
|
||||||
|
screen = .login
|
||||||
|
}
|
||||||
|
}
|
||||||
48
KDChat/Services/KeychainHelper.swift
Normal file
48
KDChat/Services/KeychainHelper.swift
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum KeychainHelper {
|
||||||
|
private static let service = "dev.smoll.KDChat"
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func save(key: String, value: String) -> Bool {
|
||||||
|
let data = Data(value.utf8)
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
var add = query
|
||||||
|
add[kSecValueData as String] = data
|
||||||
|
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
let status = SecItemAdd(add as CFDictionary, nil)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
print("[Keychain] save failed: \(status)")
|
||||||
|
}
|
||||||
|
return status == errSecSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(key: String) -> String? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
var result: AnyObject?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||||
|
let data = result as? Data else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(key: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
84
KDChat/Views/ChatBufferView.swift
Normal file
84
KDChat/Views/ChatBufferView.swift
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ChatBufferView: View {
|
||||||
|
let bufferName: String
|
||||||
|
let canSend: Bool
|
||||||
|
var onDM: ((ChatMessage) -> Void)?
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
@State private var messageText = ""
|
||||||
|
|
||||||
|
private var messages: [ChatMessage] {
|
||||||
|
manager.messages[bufferName] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(messages) { message in
|
||||||
|
MessageRow(
|
||||||
|
message: message,
|
||||||
|
onTapSender: message.isInteractable ? { startDM(to: message) } : nil,
|
||||||
|
onDM: onDM,
|
||||||
|
onProfile: nil // TODO: profile viewing
|
||||||
|
)
|
||||||
|
.id(message.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.onChange(of: messages.count) {
|
||||||
|
if let lastId = messages.last?.id {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
proxy.scrollTo(lastId, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if canSend {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("Message...", text: $messageText, axis: .vertical)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(1...4)
|
||||||
|
.onSubmit { sendMessage() }
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
Button("/command", systemImage: "slash.circle") {
|
||||||
|
if !messageText.hasPrefix("/") {
|
||||||
|
messageText = "/" + messageText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Send", systemImage: "arrow.up.circle.fill", action: sendMessage)
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.font(.title)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.buttonBorderShape(.circle)
|
||||||
|
.disabled(messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.bar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendMessage() {
|
||||||
|
let text = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
|
||||||
|
manager.sendChatMessage(buffer: bufferName, content: text)
|
||||||
|
messageText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startDM(to message: ChatMessage) {
|
||||||
|
guard message.senderId != nil else { return }
|
||||||
|
// Full DM flow would use sendDirectMessage with a selected recipient
|
||||||
|
}
|
||||||
|
}
|
||||||
86
KDChat/Views/ChatView.swift
Normal file
86
KDChat/Views/ChatView.swift
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ChatView: View {
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
@State private var selectedBuffer: String = "global"
|
||||||
|
@State private var showSettings = false
|
||||||
|
@State private var selectedDMPartner: String?
|
||||||
|
@AppStorage("haptics_enabled") private var hapticsEnabled = true
|
||||||
|
@AppStorage("haptics_messages") private var messageHaptics = true
|
||||||
|
@AppStorage("haptics_status") private var statusHaptics = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
TabView(selection: $selectedBuffer) {
|
||||||
|
ForEach(manager.buffers) { buffer in
|
||||||
|
if buffer.name == "direct-messages" {
|
||||||
|
Tab(buffer.displayName, systemImage: iconForBuffer(buffer.name), value: buffer.name) {
|
||||||
|
DMListView(selectedConversation: $selectedDMPartner)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Tab(buffer.displayName, systemImage: iconForBuffer(buffer.name), value: buffer.name) {
|
||||||
|
ChatBufferView(bufferName: buffer.name, canSend: canSend(buffer)) { message in
|
||||||
|
navigateToDM(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Kirandur Chat")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu("More", systemImage: "ellipsis.circle") {
|
||||||
|
Button("Switch Character", systemImage: "person.2") {
|
||||||
|
manager.switchCharacter()
|
||||||
|
}
|
||||||
|
Button("Settings", systemImage: "gearshape") {
|
||||||
|
showSettings = true
|
||||||
|
}
|
||||||
|
Button("Log Out", systemImage: "rectangle.portrait.and.arrow.right", role: .destructive) {
|
||||||
|
manager.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(isPresented: $showSettings) {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
|
.navigationDestination(for: String.self) { partnerId in
|
||||||
|
let name = partnerName(for: partnerId)
|
||||||
|
DMConversationView(partnerId: partnerId, partnerName: name)
|
||||||
|
}
|
||||||
|
.sensoryFeedback(.impact(flexibility: .soft), trigger: hapticsEnabled && messageHaptics ? manager.messageFeedbackTrigger : 0)
|
||||||
|
.sensoryFeedback(.warning, trigger: hapticsEnabled && statusHaptics ? manager.systemFeedbackTrigger : 0)
|
||||||
|
.onChange(of: manager.lastAnnouncement) {
|
||||||
|
AccessibilityNotification.Announcement(manager.lastAnnouncement).post()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func canSend(_ buffer: ChatBuffer) -> Bool {
|
||||||
|
if buffer.name == "direct-messages" { return true }
|
||||||
|
return buffer.canSend
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iconForBuffer(_ name: String) -> String {
|
||||||
|
switch name {
|
||||||
|
case "global": "globe"
|
||||||
|
case "map": "map"
|
||||||
|
case "debug": "ladybug"
|
||||||
|
case "direct-messages": "envelope"
|
||||||
|
default: "bubble.left"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func partnerName(for partnerId: String) -> String {
|
||||||
|
let dmMessages = manager.messages["direct-messages"] ?? []
|
||||||
|
return dmMessages.first { $0.senderId == partnerId }?.senderName ?? partnerId
|
||||||
|
}
|
||||||
|
|
||||||
|
private func navigateToDM(_ message: ChatMessage) {
|
||||||
|
guard let senderId = message.senderId else { return }
|
||||||
|
selectedBuffer = "direct-messages"
|
||||||
|
selectedDMPartner = senderId
|
||||||
|
}
|
||||||
|
}
|
||||||
13
KDChat/Views/ConnectingView.swift
Normal file
13
KDChat/Views/ConnectingView.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ConnectingView: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.large)
|
||||||
|
Text("Connecting...")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
KDChat/Views/DMConversationView.swift
Normal file
63
KDChat/Views/DMConversationView.swift
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DMConversationView: View {
|
||||||
|
let partnerId: String
|
||||||
|
let partnerName: String
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
@State private var messageText = ""
|
||||||
|
|
||||||
|
private var messages: [ChatMessage] {
|
||||||
|
(manager.messages["direct-messages"] ?? []).filter { message in
|
||||||
|
message.senderId == partnerId || (message.senderIsRecipient && !message.isSystem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(messages) { message in
|
||||||
|
MessageRow(message: message)
|
||||||
|
.id(message.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.onChange(of: messages.count) {
|
||||||
|
if let lastId = messages.last?.id {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
proxy.scrollTo(lastId, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("Message \(partnerName)...", text: $messageText, axis: .vertical)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(1...4)
|
||||||
|
.onSubmit { sendMessage() }
|
||||||
|
|
||||||
|
Button("Send", systemImage: "arrow.up.circle.fill", action: sendMessage)
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.font(.title)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.buttonBorderShape(.circle)
|
||||||
|
.disabled(messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.bar)
|
||||||
|
}
|
||||||
|
.navigationTitle(partnerName)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendMessage() {
|
||||||
|
let text = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
manager.sendDirectMessage(recipientId: partnerId, message: text)
|
||||||
|
messageText = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
54
KDChat/Views/DMListView.swift
Normal file
54
KDChat/Views/DMListView.swift
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DMListView: View {
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
@Binding var selectedConversation: String?
|
||||||
|
|
||||||
|
private var conversations: [(id: String, name: String, lastMessage: ChatMessage)] {
|
||||||
|
let dmMessages = manager.messages["direct-messages"] ?? []
|
||||||
|
var latest: [String: (name: String, message: ChatMessage)] = [:]
|
||||||
|
|
||||||
|
for message in dmMessages where !message.isSystem {
|
||||||
|
// The conversation partner is the other person
|
||||||
|
guard let senderId = message.senderId, !message.senderIsRecipient else { continue }
|
||||||
|
if latest[senderId].map({ message.timestamp > $0.message.timestamp }) ?? true {
|
||||||
|
latest[senderId] = (message.senderName, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest
|
||||||
|
.map { (id: $0.key, name: $0.value.name, lastMessage: $0.value.message) }
|
||||||
|
.sorted { $0.lastMessage.timestamp > $1.lastMessage.timestamp }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if conversations.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Conversations",
|
||||||
|
systemImage: "envelope",
|
||||||
|
description: Text("Direct messages will appear here.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List(conversations, id: \.id, selection: $selectedConversation) { convo in
|
||||||
|
NavigationLink(value: convo.id) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(convo.name)
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Text(convo.lastMessage.timestamp, style: .time)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(convo.lastMessage.content)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
KDChat/Views/FormContentView.swift
Normal file
17
KDChat/Views/FormContentView.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FormContentView: View {
|
||||||
|
@Bindable var form: RemoteFormData
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
ForEach(form.widgets) { widget in
|
||||||
|
WidgetView(formId: form.id, widget: widget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle(form.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
KDChat/Views/ListBoxContent.swift
Normal file
32
KDChat/Views/ListBoxContent.swift
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ListBoxContent: View {
|
||||||
|
let formId: String
|
||||||
|
let widget: WidgetState
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker(widget.label, selection: Bindable(widget).selectedIndex) {
|
||||||
|
ForEach(widget.items.indices, id: \.self) { index in
|
||||||
|
Text(widget.items[index]).tag(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!widget.enabled)
|
||||||
|
.onChange(of: widget.selectedIndex) {
|
||||||
|
sendSelection()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if widget.items.count == 1 {
|
||||||
|
sendSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendSelection() {
|
||||||
|
manager.sendFormEvent(
|
||||||
|
formId: formId, widgetId: widget.id,
|
||||||
|
eventType: .listBoxItemClicked,
|
||||||
|
value: .uint(UInt64(widget.selectedIndex))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
108
KDChat/Views/LoginView.swift
Normal file
108
KDChat/Views/LoginView.swift
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoginView: View {
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
|
||||||
|
@AppStorage("login_email") private var email = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@AppStorage("save_password") private var savePassword = false
|
||||||
|
@AppStorage("use_custom_server") private var useCustomServer = false
|
||||||
|
@AppStorage("custom_host") private var customHost = ""
|
||||||
|
@AppStorage("custom_port") private var customPort = "4200"
|
||||||
|
@State private var isCreatingAccount = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Text("Enter your email and password to log in.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Credentials") {
|
||||||
|
TextField("Email", text: $email)
|
||||||
|
.textContentType(.emailAddress)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
|
||||||
|
SecureField("Password", text: $password)
|
||||||
|
.textContentType(.password)
|
||||||
|
|
||||||
|
Toggle("Save Password", isOn: $savePassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Server") {
|
||||||
|
Picker("Server", selection: $useCustomServer) {
|
||||||
|
Text("Default").tag(false)
|
||||||
|
Text("Custom").tag(true)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
if useCustomServer {
|
||||||
|
TextField("Host", text: $customHost)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
TextField("Port", text: $customPort)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("Create new account", isOn: $isCreatingAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
connect()
|
||||||
|
} label: {
|
||||||
|
Text(isCreatingAccount ? "Create Account & Connect" : "Connect")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.disabled(email.isEmpty || password.isEmpty || (useCustomServer && customHost.isEmpty))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Welcome to Kirandur Chat")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
NavigationLink {
|
||||||
|
SettingsView()
|
||||||
|
} label: {
|
||||||
|
Label("Settings", systemImage: "gearshape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: .init(
|
||||||
|
get: { manager.errorMessage != nil },
|
||||||
|
set: { if !$0 { manager.errorMessage = nil } }
|
||||||
|
)) {
|
||||||
|
} message: {
|
||||||
|
Text(manager.errorMessage ?? "")
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if savePassword {
|
||||||
|
password = KeychainHelper.load(key: "password") ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: savePassword) {
|
||||||
|
if !savePassword {
|
||||||
|
KeychainHelper.delete(key: "password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connect() {
|
||||||
|
if savePassword {
|
||||||
|
KeychainHelper.save(key: "password", value: password)
|
||||||
|
}
|
||||||
|
let host = useCustomServer ? customHost : ServerConfig.default.host
|
||||||
|
let port = UInt16(useCustomServer ? customPort : "\(ServerConfig.default.port)") ?? ServerConfig.default.port
|
||||||
|
manager.connect(
|
||||||
|
host: host, port: port,
|
||||||
|
email: email, password: password,
|
||||||
|
createAccount: isCreatingAccount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
63
KDChat/Views/MessageRow.swift
Normal file
63
KDChat/Views/MessageRow.swift
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MessageRow: View {
|
||||||
|
let message: ChatMessage
|
||||||
|
var onTapSender: (() -> Void)?
|
||||||
|
var onDM: ((ChatMessage) -> Void)?
|
||||||
|
var onProfile: ((ChatMessage) -> Void)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if message.isSystem {
|
||||||
|
Text(message.content)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.italic()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let tap = onTapSender {
|
||||||
|
Button(message.senderName, action: tap)
|
||||||
|
.font(.subheadline).bold()
|
||||||
|
.accessibilityHint("Tap to send a direct message")
|
||||||
|
} else {
|
||||||
|
Text(message.senderName)
|
||||||
|
.font(.subheadline).bold()
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.senderIsRecipient {
|
||||||
|
Text("(you)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(message.timestamp, style: .time)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(message.content)
|
||||||
|
.font(.body)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.clipShape(.rect(cornerRadius: 12))
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityActions {
|
||||||
|
if !message.senderIsRecipient, message.senderId != nil {
|
||||||
|
if let onDM {
|
||||||
|
Button("DM \(message.senderName)") { onDM(message) }
|
||||||
|
}
|
||||||
|
if let onProfile {
|
||||||
|
Button("Profile for \(message.senderName)") { onProfile(message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
KDChat/Views/MultiSelectContent.swift
Normal file
41
KDChat/Views/MultiSelectContent.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MultiSelectContent: View {
|
||||||
|
let formId: String
|
||||||
|
let widget: WidgetState
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !widget.label.isEmpty {
|
||||||
|
Text(widget.label)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
ForEach(widget.items.indices, id: \.self) { index in
|
||||||
|
Button {
|
||||||
|
toggleItem(index)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: widget.selectedIndices.contains(index) ? "checkmark.square.fill" : "square")
|
||||||
|
Text(widget.items[index])
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.disabled(!widget.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleItem(_ index: Int) {
|
||||||
|
if widget.selectedIndices.contains(index) {
|
||||||
|
widget.selectedIndices.remove(index)
|
||||||
|
} else {
|
||||||
|
widget.selectedIndices.insert(index)
|
||||||
|
}
|
||||||
|
let sorted = widget.selectedIndices.sorted().map { UInt64($0) }
|
||||||
|
manager.sendFormEvent(
|
||||||
|
formId: formId, widgetId: widget.id,
|
||||||
|
eventType: .multiSelectListSelectionChanged,
|
||||||
|
value: .uintArray(sorted)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
KDChat/Views/RemoteFormView.swift
Normal file
18
KDChat/Views/RemoteFormView.swift
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RemoteFormView: View {
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
if manager.remoteForms.isEmpty {
|
||||||
|
ProgressView("Waiting for server...")
|
||||||
|
.navigationTitle("Kirandur")
|
||||||
|
} else {
|
||||||
|
if let form = manager.remoteForms.last {
|
||||||
|
FormContentView(form: form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
KDChat/Views/SettingsView.swift
Normal file
26
KDChat/Views/SettingsView.swift
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@AppStorage("haptics_enabled") private var hapticsEnabled = true
|
||||||
|
@AppStorage("haptics_messages") private var messageHaptics = true
|
||||||
|
@AppStorage("haptics_status") private var statusHaptics = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Haptics") {
|
||||||
|
Toggle("Haptics", isOn: $hapticsEnabled)
|
||||||
|
Toggle("Message Haptics", isOn: $messageHaptics)
|
||||||
|
.disabled(!hapticsEnabled)
|
||||||
|
Toggle("Status Haptics", isOn: $statusHaptics)
|
||||||
|
.disabled(!hapticsEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Text("\(AppInfo.name) \(AppInfo.version) (\(AppInfo.build))")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
39
KDChat/Views/TreeNodeView.swift
Normal file
39
KDChat/Views/TreeNodeView.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TreeNodeView: View {
|
||||||
|
let node: RemoteTreeNode
|
||||||
|
let formId: String
|
||||||
|
let widgetId: String
|
||||||
|
let depth: Int
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Button {
|
||||||
|
manager.sendFormEvent(
|
||||||
|
formId: formId, widgetId: widgetId,
|
||||||
|
eventType: .treeNodeClicked,
|
||||||
|
value: .string(node.id)
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
if !node.children.isEmpty {
|
||||||
|
Image(systemName: node.isExpanded ? "chevron.down" : "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Text(node.text)
|
||||||
|
}
|
||||||
|
.padding(.leading, CGFloat(depth) * 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.isExpanded {
|
||||||
|
ForEach(node.children) { child in
|
||||||
|
TreeNodeView(
|
||||||
|
node: child, formId: formId,
|
||||||
|
widgetId: widgetId, depth: depth + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
KDChat/Views/WidgetView.swift
Normal file
163
KDChat/Views/WidgetView.swift
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WidgetView: View {
|
||||||
|
let formId: String
|
||||||
|
let widget: WidgetState
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
switch widget.type {
|
||||||
|
case .button:
|
||||||
|
Button {
|
||||||
|
manager.sendFormEvent(
|
||||||
|
formId: formId, widgetId: widget.id,
|
||||||
|
eventType: .buttonClicked
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Text(widget.label)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!widget.enabled)
|
||||||
|
|
||||||
|
case .textField:
|
||||||
|
TextFieldWidget(formId: formId, widget: widget)
|
||||||
|
|
||||||
|
case .checkBox:
|
||||||
|
Toggle(widget.label, isOn: Bindable(widget).isChecked)
|
||||||
|
.disabled(!widget.enabled)
|
||||||
|
.onChange(of: widget.isChecked) { _, newValue in
|
||||||
|
manager.sendFormEvent(
|
||||||
|
formId: formId, widgetId: widget.id,
|
||||||
|
eventType: .checkBoxValueChanged,
|
||||||
|
value: .bool(newValue)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .listBox:
|
||||||
|
ListBoxContent(formId: formId, widget: widget)
|
||||||
|
|
||||||
|
case .slider:
|
||||||
|
SliderWidget(formId: formId, widget: widget)
|
||||||
|
|
||||||
|
case .treeView:
|
||||||
|
TreeViewWidget(formId: formId, widget: widget)
|
||||||
|
|
||||||
|
case .multiSelectList:
|
||||||
|
MultiSelectContent(formId: formId, widget: widget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TextFieldWidget: View {
|
||||||
|
let formId: String
|
||||||
|
let widget: WidgetState
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !widget.label.isEmpty {
|
||||||
|
Text(widget.label)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
if widget.readOnly {
|
||||||
|
Text(widget.text)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(8)
|
||||||
|
.background(.quaternary)
|
||||||
|
.clipShape(.rect(cornerRadius: 8))
|
||||||
|
} else if widget.isPassword {
|
||||||
|
SecureField(widget.label, text: Bindable(widget).text)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.disabled(!widget.enabled)
|
||||||
|
.onChange(of: widget.text) { _, newValue in
|
||||||
|
enforceMaxLength(newValue)
|
||||||
|
sendTextChanged(newValue)
|
||||||
|
}
|
||||||
|
.onSubmit { sendTextSubmitted() }
|
||||||
|
} else {
|
||||||
|
TextField(widget.label, text: Bindable(widget).text)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.disabled(!widget.enabled)
|
||||||
|
.onChange(of: widget.text) { _, newValue in
|
||||||
|
enforceMaxLength(newValue)
|
||||||
|
sendTextChanged(newValue)
|
||||||
|
}
|
||||||
|
.onSubmit { sendTextSubmitted() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enforceMaxLength(_ text: String) {
|
||||||
|
if text.count > widget.maxLength {
|
||||||
|
widget.text = String(text.prefix(widget.maxLength))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendTextChanged(_ text: String) {
|
||||||
|
manager.sendFormEvent(
|
||||||
|
formId: formId, widgetId: widget.id,
|
||||||
|
eventType: .textChanged,
|
||||||
|
value: .string(text)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendTextSubmitted() {
|
||||||
|
manager.sendFormEvent(
|
||||||
|
formId: formId, widgetId: widget.id,
|
||||||
|
eventType: .textSubmitted,
|
||||||
|
value: .string(widget.text)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SliderWidget: View {
|
||||||
|
let formId: String
|
||||||
|
let widget: WidgetState
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !widget.label.isEmpty {
|
||||||
|
Text(widget.label)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
LabeledContent(widget.label) {
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { Double(widget.sliderValue) },
|
||||||
|
set: { widget.sliderValue = Int($0) }
|
||||||
|
),
|
||||||
|
in: Double(widget.minValue)...Double(widget.maxValue),
|
||||||
|
step: Double(widget.increment)
|
||||||
|
)
|
||||||
|
.disabled(!widget.enabled)
|
||||||
|
.onChange(of: widget.sliderValue) { _, newValue in
|
||||||
|
manager.sendFormEvent(
|
||||||
|
formId: formId, widgetId: widget.id,
|
||||||
|
eventType: .sliderMoved,
|
||||||
|
value: .uint(UInt64(newValue))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TreeViewWidget: View {
|
||||||
|
let formId: String
|
||||||
|
let widget: WidgetState
|
||||||
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !widget.label.isEmpty {
|
||||||
|
Text(widget.label)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
ForEach(widget.nodes) { node in
|
||||||
|
TreeNodeView(
|
||||||
|
node: node, formId: formId,
|
||||||
|
widgetId: widget.id, depth: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
KDChatTests/KDChatTests.swift
Normal file
17
KDChatTests/KDChatTests.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
//
|
||||||
|
// KDChatTests.swift
|
||||||
|
// KDChatTests
|
||||||
|
//
|
||||||
|
// Created by Blake Oliver on 3/21/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
@testable import KDChat
|
||||||
|
|
||||||
|
struct KDChatTests {
|
||||||
|
|
||||||
|
@Test func example() async throws {
|
||||||
|
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
41
KDChatUITests/KDChatUITests.swift
Normal file
41
KDChatUITests/KDChatUITests.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
//
|
||||||
|
// KDChatUITests.swift
|
||||||
|
// KDChatUITests
|
||||||
|
//
|
||||||
|
// Created by Blake Oliver on 3/21/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class KDChatUITests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||||
|
|
||||||
|
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||||
|
continueAfterFailure = false
|
||||||
|
|
||||||
|
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testExample() throws {
|
||||||
|
// UI tests must launch the application that they test.
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testLaunchPerformance() throws {
|
||||||
|
// This measures how long it takes to launch your application.
|
||||||
|
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||||
|
XCUIApplication().launch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
KDChatUITests/KDChatUITestsLaunchTests.swift
Normal file
33
KDChatUITests/KDChatUITestsLaunchTests.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// KDChatUITestsLaunchTests.swift
|
||||||
|
// KDChatUITests
|
||||||
|
//
|
||||||
|
// Created by Blake Oliver on 3/21/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class KDChatUITestsLaunchTests: XCTestCase {
|
||||||
|
|
||||||
|
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testLaunch() throws {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||||
|
// such as logging into a test account or navigating somewhere in the app
|
||||||
|
|
||||||
|
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||||
|
attachment.name = "Launch Screen"
|
||||||
|
attachment.lifetime = .keepAlways
|
||||||
|
add(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue