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