commit 2d3fefa013dfce1971d92b21366640483a730110 Author: Blake Oliver Date: Sun Apr 5 07:11:07 2026 -0600 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) diff --git a/KDChat.xcodeproj/project.pbxproj b/KDChat.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b0fe807 --- /dev/null +++ b/KDChat.xcodeproj/project.pbxproj @@ -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 = ""; + }; + 6AE33C3E2F6F4C5200AA7CA1 /* KDChatTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = KDChatTests; + sourceTree = ""; + }; + 6AE33C482F6F4C5200AA7CA1 /* KDChatUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = KDChatUITests; + sourceTree = ""; + }; +/* 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 = ""; + }; + 6AE33C2F2F6F4C5100AA7CA1 /* Products */ = { + isa = PBXGroup; + children = ( + 6AE33C2E2F6F4C5100AA7CA1 /* KDChat.app */, + 6AE33C3B2F6F4C5200AA7CA1 /* KDChatTests.xctest */, + 6AE33C452F6F4C5200AA7CA1 /* KDChatUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/KDChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/KDChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/KDChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/KDChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/KDChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..c3d9659 --- /dev/null +++ b/KDChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/KDChat.xcodeproj/xcuserdata/blakeoliver.xcuserdatad/xcschemes/xcschememanagement.plist b/KDChat.xcodeproj/xcuserdata/blakeoliver.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..eb76ab2 --- /dev/null +++ b/KDChat.xcodeproj/xcuserdata/blakeoliver.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + KDChat.xcscheme_^#shared#^_ + + orderHint + 1 + + + + diff --git a/KDChat/AppInfo.swift b/KDChat/AppInfo.swift new file mode 100644 index 0000000..d52c6e8 --- /dev/null +++ b/KDChat/AppInfo.swift @@ -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 ?? "–" +} diff --git a/KDChat/Assets.xcassets/AccentColor.colorset/Contents.json b/KDChat/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/KDChat/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KDChat/Assets.xcassets/AppIcon.appiconset/Contents.json b/KDChat/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/KDChat/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/KDChat/Assets.xcassets/Contents.json b/KDChat/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/KDChat/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KDChat/ContentView.swift b/KDChat/ContentView.swift new file mode 100644 index 0000000..82f1337 --- /dev/null +++ b/KDChat/ContentView.swift @@ -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) + } +} diff --git a/KDChat/KDChatApp.swift b/KDChat/KDChatApp.swift new file mode 100644 index 0000000..419feba --- /dev/null +++ b/KDChat/KDChatApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct KDChatApp: App { + @State private var connectionManager = ConnectionManager() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(connectionManager) + } + } +} diff --git a/KDChat/Models/ChatModels.swift b/KDChat/Models/ChatModels.swift new file mode 100644 index 0000000..bed910e --- /dev/null +++ b/KDChat/Models/ChatModels.swift @@ -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() + } +} diff --git a/KDChat/Networking/LiteNetLib.swift b/KDChat/Networking/LiteNetLib.swift new file mode 100644 index 0000000..87936fa --- /dev/null +++ b/KDChat/Networking/LiteNetLib.swift @@ -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? + + // Event stream for consumers + let events: AsyncStream + private let eventContinuation: AsyncStream.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..= 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.. 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.. Int32? { + let start = data.startIndex + offset + guard start + 4 <= data.endIndex else { return nil } + offset += 4 + return data[start.. Int64? { + let start = data.startIndex + offset + guard start + 8 <= data.endIndex else { return nil } + offset += 8 + return data[start.. 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") + ) + } +} diff --git a/KDChat/Networking/ProtocolTypes.swift b/KDChat/Networking/ProtocolTypes.swift new file mode 100644 index 0000000..63f93f8 --- /dev/null +++ b/KDChat/Networking/ProtocolTypes.swift @@ -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(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(_ 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) + } +} diff --git a/KDChat/Networking/RemoteFormTypes.swift b/KDChat/Networking/RemoteFormTypes.swift new file mode 100644 index 0000000..6e3ecec --- /dev/null +++ b/KDChat/Networking/RemoteFormTypes.swift @@ -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 = [] + + 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 + 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) + } + } + } + } +} diff --git a/KDChat/Services/ConnectionManager.swift b/KDChat/Services/ConnectionManager.swift new file mode 100644 index 0000000..6ac719a --- /dev/null +++ b/KDChat/Services/ConnectionManager.swift @@ -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? + + // 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 + } +} diff --git a/KDChat/Services/KeychainHelper.swift b/KDChat/Services/KeychainHelper.swift new file mode 100644 index 0000000..3ecd823 --- /dev/null +++ b/KDChat/Services/KeychainHelper.swift @@ -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) + } +} diff --git a/KDChat/Views/ChatBufferView.swift b/KDChat/Views/ChatBufferView.swift new file mode 100644 index 0000000..3467625 --- /dev/null +++ b/KDChat/Views/ChatBufferView.swift @@ -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 + } +} diff --git a/KDChat/Views/ChatView.swift b/KDChat/Views/ChatView.swift new file mode 100644 index 0000000..e591f0c --- /dev/null +++ b/KDChat/Views/ChatView.swift @@ -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 + } +} diff --git a/KDChat/Views/ConnectingView.swift b/KDChat/Views/ConnectingView.swift new file mode 100644 index 0000000..e7a3793 --- /dev/null +++ b/KDChat/Views/ConnectingView.swift @@ -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) + } + } +} diff --git a/KDChat/Views/DMConversationView.swift b/KDChat/Views/DMConversationView.swift new file mode 100644 index 0000000..d8aad3b --- /dev/null +++ b/KDChat/Views/DMConversationView.swift @@ -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 = "" + } +} diff --git a/KDChat/Views/DMListView.swift b/KDChat/Views/DMListView.swift new file mode 100644 index 0000000..70e2ff8 --- /dev/null +++ b/KDChat/Views/DMListView.swift @@ -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) + } + } +} diff --git a/KDChat/Views/FormContentView.swift b/KDChat/Views/FormContentView.swift new file mode 100644 index 0000000..432d192 --- /dev/null +++ b/KDChat/Views/FormContentView.swift @@ -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) + } +} diff --git a/KDChat/Views/ListBoxContent.swift b/KDChat/Views/ListBoxContent.swift new file mode 100644 index 0000000..2925cce --- /dev/null +++ b/KDChat/Views/ListBoxContent.swift @@ -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)) + ) + } +} diff --git a/KDChat/Views/LoginView.swift b/KDChat/Views/LoginView.swift new file mode 100644 index 0000000..04ecf41 --- /dev/null +++ b/KDChat/Views/LoginView.swift @@ -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 + ) + } +} diff --git a/KDChat/Views/MessageRow.swift b/KDChat/Views/MessageRow.swift new file mode 100644 index 0000000..a5015b6 --- /dev/null +++ b/KDChat/Views/MessageRow.swift @@ -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) } + } + } + } + } + } +} diff --git a/KDChat/Views/MultiSelectContent.swift b/KDChat/Views/MultiSelectContent.swift new file mode 100644 index 0000000..8d2280c --- /dev/null +++ b/KDChat/Views/MultiSelectContent.swift @@ -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) + ) + } +} diff --git a/KDChat/Views/RemoteFormView.swift b/KDChat/Views/RemoteFormView.swift new file mode 100644 index 0000000..298396b --- /dev/null +++ b/KDChat/Views/RemoteFormView.swift @@ -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) + } + } + } + } +} diff --git a/KDChat/Views/SettingsView.swift b/KDChat/Views/SettingsView.swift new file mode 100644 index 0000000..195a142 --- /dev/null +++ b/KDChat/Views/SettingsView.swift @@ -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") + } +} diff --git a/KDChat/Views/TreeNodeView.swift b/KDChat/Views/TreeNodeView.swift new file mode 100644 index 0000000..bae84c6 --- /dev/null +++ b/KDChat/Views/TreeNodeView.swift @@ -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 + ) + } + } + } + } +} diff --git a/KDChat/Views/WidgetView.swift b/KDChat/Views/WidgetView.swift new file mode 100644 index 0000000..7134868 --- /dev/null +++ b/KDChat/Views/WidgetView.swift @@ -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 + ) + } + } +} diff --git a/KDChatTests/KDChatTests.swift b/KDChatTests/KDChatTests.swift new file mode 100644 index 0000000..6a2ed8b --- /dev/null +++ b/KDChatTests/KDChatTests.swift @@ -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. + } + +} diff --git a/KDChatUITests/KDChatUITests.swift b/KDChatUITests/KDChatUITests.swift new file mode 100644 index 0000000..3e66b30 --- /dev/null +++ b/KDChatUITests/KDChatUITests.swift @@ -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() + } + } +} diff --git a/KDChatUITests/KDChatUITestsLaunchTests.swift b/KDChatUITests/KDChatUITestsLaunchTests.swift new file mode 100644 index 0000000..f56cd45 --- /dev/null +++ b/KDChatUITests/KDChatUITestsLaunchTests.swift @@ -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) + } +}