Initial commit of KDChat

This is an iOS chat client for Kirandur, a multiplayer RPG. Reimplements the LiteNetLib UDP protocol in Swift for reliable ordered messaging. Renders server-driven UI via a RemoteForms widget system for login, character creation and more inside the client.

Uses msgpack-swift for serialization, Keychain for credentials, and targets iOS 18.6+/Xcode 26+.

No message persistence yet. No auto-reconnect. Game-specific packets (movement, combat, etc.) are silently ignored.
No profile viewing yet.

Assisted by Claude Code (Opus 4.6)
This commit is contained in:
Blake Oliver 2026-04-05 07:11:07 -06:00
commit 2d3fefa013
No known key found for this signature in database
34 changed files with 3504 additions and 0 deletions

View file

@ -0,0 +1,617 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
6A3445BA2F82695300B35186 /* DMMessagePack in Frameworks */ = {isa = PBXBuildFile; productRef = 6A3445962F8266BD00B35186 /* DMMessagePack */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
6AE33C3C2F6F4C5200AA7CA1 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 6AE33C262F6F4C5100AA7CA1 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 6AE33C2D2F6F4C5100AA7CA1;
remoteInfo = KDChat;
};
6AE33C462F6F4C5200AA7CA1 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 6AE33C262F6F4C5100AA7CA1 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 6AE33C2D2F6F4C5100AA7CA1;
remoteInfo = KDChat;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
6AE33C2E2F6F4C5100AA7CA1 /* KDChat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KDChat.app; sourceTree = BUILT_PRODUCTS_DIR; };
6AE33C3B2F6F4C5200AA7CA1 /* KDChatTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KDChatTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
6AE33C452F6F4C5200AA7CA1 /* KDChatUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KDChatUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
6AE33C302F6F4C5100AA7CA1 /* KDChat */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = KDChat;
sourceTree = "<group>";
};
6AE33C3E2F6F4C5200AA7CA1 /* KDChatTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = KDChatTests;
sourceTree = "<group>";
};
6AE33C482F6F4C5200AA7CA1 /* KDChatUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = KDChatUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
6AE33C2B2F6F4C5100AA7CA1 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6A3445BA2F82695300B35186 /* DMMessagePack in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
6AE33C382F6F4C5200AA7CA1 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
6AE33C422F6F4C5200AA7CA1 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
6AE33C252F6F4C5100AA7CA1 = {
isa = PBXGroup;
children = (
6AE33C302F6F4C5100AA7CA1 /* KDChat */,
6AE33C3E2F6F4C5200AA7CA1 /* KDChatTests */,
6AE33C482F6F4C5200AA7CA1 /* KDChatUITests */,
6AE33C2F2F6F4C5100AA7CA1 /* Products */,
);
sourceTree = "<group>";
};
6AE33C2F2F6F4C5100AA7CA1 /* Products */ = {
isa = PBXGroup;
children = (
6AE33C2E2F6F4C5100AA7CA1 /* KDChat.app */,
6AE33C3B2F6F4C5200AA7CA1 /* KDChatTests.xctest */,
6AE33C452F6F4C5200AA7CA1 /* KDChatUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
6AE33C2D2F6F4C5100AA7CA1 /* KDChat */ = {
isa = PBXNativeTarget;
buildConfigurationList = 6AE33C4F2F6F4C5200AA7CA1 /* Build configuration list for PBXNativeTarget "KDChat" */;
buildPhases = (
6AE33C2A2F6F4C5100AA7CA1 /* Sources */,
6AE33C2B2F6F4C5100AA7CA1 /* Frameworks */,
6AE33C2C2F6F4C5100AA7CA1 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
6AE33C302F6F4C5100AA7CA1 /* KDChat */,
);
name = KDChat;
packageProductDependencies = (
6A3445962F8266BD00B35186 /* DMMessagePack */,
);
productName = KDChat;
productReference = 6AE33C2E2F6F4C5100AA7CA1 /* KDChat.app */;
productType = "com.apple.product-type.application";
};
6AE33C3A2F6F4C5200AA7CA1 /* KDChatTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 6AE33C522F6F4C5200AA7CA1 /* Build configuration list for PBXNativeTarget "KDChatTests" */;
buildPhases = (
6AE33C372F6F4C5200AA7CA1 /* Sources */,
6AE33C382F6F4C5200AA7CA1 /* Frameworks */,
6AE33C392F6F4C5200AA7CA1 /* Resources */,
);
buildRules = (
);
dependencies = (
6AE33C3D2F6F4C5200AA7CA1 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
6AE33C3E2F6F4C5200AA7CA1 /* KDChatTests */,
);
name = KDChatTests;
packageProductDependencies = (
);
productName = KDChatTests;
productReference = 6AE33C3B2F6F4C5200AA7CA1 /* KDChatTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
6AE33C442F6F4C5200AA7CA1 /* KDChatUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 6AE33C552F6F4C5200AA7CA1 /* Build configuration list for PBXNativeTarget "KDChatUITests" */;
buildPhases = (
6AE33C412F6F4C5200AA7CA1 /* Sources */,
6AE33C422F6F4C5200AA7CA1 /* Frameworks */,
6AE33C432F6F4C5200AA7CA1 /* Resources */,
);
buildRules = (
);
dependencies = (
6AE33C472F6F4C5200AA7CA1 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
6AE33C482F6F4C5200AA7CA1 /* KDChatUITests */,
);
name = KDChatUITests;
packageProductDependencies = (
);
productName = KDChatUITests;
productReference = 6AE33C452F6F4C5200AA7CA1 /* KDChatUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
6AE33C262F6F4C5100AA7CA1 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2630;
LastUpgradeCheck = 2640;
TargetAttributes = {
6AE33C2D2F6F4C5100AA7CA1 = {
CreatedOnToolsVersion = 26.3;
};
6AE33C3A2F6F4C5200AA7CA1 = {
CreatedOnToolsVersion = 26.3;
TestTargetID = 6AE33C2D2F6F4C5100AA7CA1;
};
6AE33C442F6F4C5200AA7CA1 = {
CreatedOnToolsVersion = 26.3;
TestTargetID = 6AE33C2D2F6F4C5100AA7CA1;
};
};
};
buildConfigurationList = 6AE33C292F6F4C5100AA7CA1 /* Build configuration list for PBXProject "KDChat" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 6AE33C252F6F4C5100AA7CA1;
minimizedProjectReferenceProxies = 1;
packageReferences = (
6A3445952F8266BD00B35186 /* XCRemoteSwiftPackageReference "msgpack-swift" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 6AE33C2F2F6F4C5100AA7CA1 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
6AE33C2D2F6F4C5100AA7CA1 /* KDChat */,
6AE33C3A2F6F4C5200AA7CA1 /* KDChatTests */,
6AE33C442F6F4C5200AA7CA1 /* KDChatUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
6AE33C2C2F6F4C5100AA7CA1 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
6AE33C392F6F4C5200AA7CA1 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
6AE33C432F6F4C5200AA7CA1 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
6AE33C2A2F6F4C5100AA7CA1 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
6AE33C372F6F4C5200AA7CA1 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
6AE33C412F6F4C5200AA7CA1 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
6AE33C3D2F6F4C5200AA7CA1 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 6AE33C2D2F6F4C5100AA7CA1 /* KDChat */;
targetProxy = 6AE33C3C2F6F4C5200AA7CA1 /* PBXContainerItemProxy */;
};
6AE33C472F6F4C5200AA7CA1 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 6AE33C2D2F6F4C5100AA7CA1 /* KDChat */;
targetProxy = 6AE33C462F6F4C5200AA7CA1 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
6AE33C4D2F6F4C5200AA7CA1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = F3AZTWNYRT;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
6AE33C4E2F6F4C5200AA7CA1 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = F3AZTWNYRT;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
6AE33C502F6F4C5200AA7CA1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F3AZTWNYRT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.smoll.KDChat;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
6AE33C512F6F4C5200AA7CA1 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F3AZTWNYRT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.smoll.KDChat;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
6AE33C532F6F4C5200AA7CA1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F3AZTWNYRT;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.smoll.KDChatTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KDChat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/KDChat";
};
name = Debug;
};
6AE33C542F6F4C5200AA7CA1 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F3AZTWNYRT;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.smoll.KDChatTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KDChat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/KDChat";
};
name = Release;
};
6AE33C562F6F4C5200AA7CA1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F3AZTWNYRT;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.smoll.KDChatUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = KDChat;
};
name = Debug;
};
6AE33C572F6F4C5200AA7CA1 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F3AZTWNYRT;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.smoll.KDChatUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = KDChat;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
6AE33C292F6F4C5100AA7CA1 /* Build configuration list for PBXProject "KDChat" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6AE33C4D2F6F4C5200AA7CA1 /* Debug */,
6AE33C4E2F6F4C5200AA7CA1 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
6AE33C4F2F6F4C5200AA7CA1 /* Build configuration list for PBXNativeTarget "KDChat" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6AE33C502F6F4C5200AA7CA1 /* Debug */,
6AE33C512F6F4C5200AA7CA1 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
6AE33C522F6F4C5200AA7CA1 /* Build configuration list for PBXNativeTarget "KDChatTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6AE33C532F6F4C5200AA7CA1 /* Debug */,
6AE33C542F6F4C5200AA7CA1 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
6AE33C552F6F4C5200AA7CA1 /* Build configuration list for PBXNativeTarget "KDChatUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6AE33C562F6F4C5200AA7CA1 /* Debug */,
6AE33C572F6F4C5200AA7CA1 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
6A3445952F8266BD00B35186 /* XCRemoteSwiftPackageReference "msgpack-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/fumoboy007/msgpack-swift";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.0.6;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
6A3445962F8266BD00B35186 /* DMMessagePack */ = {
isa = XCSwiftPackageProductDependency;
package = 6A3445952F8266BD00B35186 /* XCRemoteSwiftPackageReference "msgpack-swift" */;
productName = DMMessagePack;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 6AE33C262F6F4C5100AA7CA1 /* Project object */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -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
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>KDChat.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
</dict>
</plist>

7
KDChat/AppInfo.swift Normal file
View file

@ -0,0 +1,7 @@
import Foundation
enum AppInfo {
static let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "KDChat"
static let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
static let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? ""
}

View file

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

21
KDChat/ContentView.swift Normal file
View file

@ -0,0 +1,21 @@
import SwiftUI
struct ContentView: View {
@Environment(ConnectionManager.self) private var manager
var body: some View {
Group {
switch manager.screen {
case .login:
LoginView()
case .connecting:
ConnectingView()
case .forms:
RemoteFormView()
case .chat:
ChatView()
}
}
.animation(.default, value: manager.screen)
}
}

13
KDChat/KDChatApp.swift Normal file
View file

@ -0,0 +1,13 @@
import SwiftUI
@main
struct KDChatApp: App {
@State private var connectionManager = ConnectionManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(connectionManager)
}
}
}

View file

@ -0,0 +1,74 @@
import Foundation
import MessagePack
// MARK: - Chat Buffer
struct ChatBuffer: Identifiable, Sendable, Decodable {
let name: String
let canSend: Bool
var id: String { name }
var displayName: String {
switch name {
case "global": "Global"
case "map": "Map"
case "debug": "Debug"
case "direct-messages": "DMs"
default: name.capitalized
}
}
init(name: String, canSend: Bool) {
self.name = name
self.canSend = canSend
}
init(from decoder: Decoder) throws {
var c = try decoder.unkeyedContainer()
name = try c.decode(String.self)
canSend = c.isAtEnd ? false : try c.decode(Bool.self)
}
}
// MARK: - Chat Message
struct ChatMessage: Identifiable, Sendable, Decodable {
let id: String
let buffer: String
let senderId: String?
let senderName: String
let content: String
let isInteractable: Bool
let senderIsRecipient: Bool
let isSystem: Bool
let timestamp: Date
init(from decoder: Decoder) throws {
var c = try decoder.unkeyedContainer()
buffer = try c.decode(String.self) // [0]
let rawId: String? = try c.decodeNil() ? nil : c.decode(String.self) // [1]
id = rawId ?? UUID().uuidString
senderId = try c.decodeNil() ? nil : c.decode(String.self) // [2]
senderName = try c.decode(String.self) // [3]
isInteractable = try c.decode(Bool.self) // [4]
content = try c.decode(String.self) // [5]
_ = c.isAtEnd ? nil : try (c.decodeNil() ? nil : c.decode(String.self)) // [6] sound
senderIsRecipient = c.isAtEnd ? false : try c.decode(Bool.self) // [7]
isSystem = false
timestamp = Date()
}
/// Create a system message (from Say packets)
init(systemMessage: String) {
self.id = UUID().uuidString
self.buffer = "system"
self.senderId = nil
self.senderName = "System"
self.content = systemMessage
self.isInteractable = false
self.senderIsRecipient = false
self.isSystem = true
self.timestamp = Date()
}
}

View file

@ -0,0 +1,786 @@
import Foundation
import Network
import os
private let lnlLog = Logger(subsystem: "dev.smoll.KDChat", category: "LiteNetLib")
// MARK: - Constants
private enum LNL {
static let protocolId: Int32 = 13
static let maxSequence: UInt16 = 32768
static let halfMaxSequence: Int = 16384
static let windowSize: Int = 64
static let channeledHeaderSize: Int = 4
static let reliableOrderedChannelId: UInt8 = 2 // channel 0, ReliableOrdered (0*4+2)
static let pingIntervalMs: Int = 1000
static let disconnectTimeoutMs: Int = 5000
static let resendBaseMs: Double = 25
static let resendRttMultiplier: Double = 2.1
static let updateIntervalMs: Int = 50
// .NET epoch offset: ticks between 0001-01-01 and 1970-01-01
static let dotNetEpochOffset: Double = 62_135_596_800
static var dotNetTicks: Int64 {
Int64((Date.now.timeIntervalSince1970 + dotNetEpochOffset) * 10_000_000)
}
}
// MARK: - Packet Property
private enum PacketProperty: UInt8 {
case unreliable = 0
case channeled = 1
case ack = 2
case ping = 3
case pong = 4
case connectRequest = 5
case connectAccept = 6
case disconnect = 7
case unconnectedMessage = 8
case mtuCheck = 9
case mtuOk = 10
case broadcast = 11
case merged = 12
case shutdownOk = 13
case peerNotFound = 14
case invalidProtocol = 15
case natMessage = 16
case empty = 17
}
// MARK: - Connection State
private enum ConnectionState {
case disconnected
case connecting
case connected
case shutdownRequested
}
// MARK: - Pending Packet
private struct PendingPacket {
var data: Data
var timestamp: UInt64
var isSent: Bool
}
// MARK: - Fragment Reassembly
private struct IncomingFragment {
let totalParts: Int
let channelId: UInt8
var parts: [Data?]
var receivedCount: Int
init(totalParts: Int, channelId: UInt8) {
self.totalParts = totalParts
self.channelId = channelId
self.parts = Array(repeating: nil, count: totalParts)
self.receivedCount = 0
}
}
// MARK: - Client Event
extension LNLClient {
enum Event: Sendable {
case connected
case disconnected(String?)
case received(Data)
}
}
// MARK: - LNLClient
/// Minimal LiteNetLib client supporting ReliableOrdered delivery over UDP.
actor LNLClient {
// NWConnection requires a DispatchQueue for its callback-based API
private let nwQueue = DispatchQueue(label: "com.kdchat.litenetlib.nw")
private var connection: NWConnection?
private var updateTask: Task<Void, Never>?
// Event stream for consumers
let events: AsyncStream<Event>
private let eventContinuation: AsyncStream<Event>.Continuation
// Connection state
private var state: ConnectionState = .disconnected
private var connectionTime: Int64 = 0
private var connectionNumber: UInt8 = 0
private var remotePeerId: Int32 = 0
private var localPeerId: Int32 = Int32.random(in: 1...Int32.max)
private var connectKey: String = ""
private var connectAttempts: Int = 0
private let maxConnectAttempts: Int = 10
private var lastConnectSendTime: UInt64 = 0
// Reliable ordered channel - send side
private var localSequence: UInt16 = 0
private var localWindowStart: UInt16 = 0
private var pendingPackets: [PendingPacket?]
private var outgoingQueue: [Data] = []
// Reliable ordered channel - receive side
private var remoteSequence: UInt16 = 0
private var remoteWindowStart: UInt16 = 0
private var receivedPackets: [Data?]
private var ackBitfield: [UInt8]
private var mustSendAcks: Bool = false
// Fragment reassembly
private var incomingFragments: [UInt16: IncomingFragment] = [:]
// Ping / keepalive
private var pingSequence: UInt16 = 0
private var lastPingSentTime: UInt64 = 0
private var lastPacketReceivedTime: UInt64 = 0
private var avgRtt: Double = 50
private var rttSum: Double = 0
private var rttCount: Int = 0
private var pingSentTimestamp: UInt64 = 0
// Resend delay (computed from RTT)
private var resendDelay: Double { LNL.resendBaseMs + avgRtt * LNL.resendRttMultiplier }
// Send stats
private var totalBytesSent: Int = 0
private var totalPacketsSent: Int = 0
init() {
let ws = LNL.windowSize
self.pendingPackets = Array(repeating: nil, count: ws)
self.receivedPackets = Array(repeating: nil, count: ws)
let ackSize = (ws - 1) / 8 + 2
self.ackBitfield = Array(repeating: 0, count: ackSize)
let (stream, continuation) = AsyncStream.makeStream(of: Event.self)
self.events = stream
self.eventContinuation = continuation
}
deinit {
eventContinuation.finish()
}
// MARK: - Public API
func connect(host: String, port: UInt16, key: String) {
guard state == .disconnected else {
lnlLog.warning("connect() called but state is not disconnected")
return
}
lnlLog.info("Connecting to \(host):\(port) with key '\(key)'")
connectKey = key
connectionTime = LNL.dotNetTicks
connectAttempts = 0
state = .connecting
let nwHost = NWEndpoint.Host(host)
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
lnlLog.error("Invalid port: \(port)")
return
}
let conn = NWConnection(host: nwHost, port: nwPort, using: .udp)
connection = conn
conn.stateUpdateHandler = { [weak self] newState in
Task { [weak self] in await self?.handleNWState(newState) }
}
conn.pathUpdateHandler = { path in
lnlLog.info("Path status: \(String(describing: path.status)), isExpensive: \(path.isExpensive), isConstrained: \(path.isConstrained)")
}
conn.start(queue: nwQueue)
}
func send(data: Data) {
guard state == .connected else { return }
outgoingQueue.append(data)
}
func disconnect() {
performDisconnect(reason: "Client disconnected")
}
// MARK: - NWConnection State
private func handleNWState(_ newState: NWConnection.State) {
lnlLog.info("NWConnection state: \(String(describing: newState))")
switch newState {
case .ready:
if let path = connection?.currentPath {
lnlLog.info("UDP socket ready. Local: \(String(describing: path.localEndpoint)), remote: \(String(describing: path.remoteEndpoint))")
for iface in path.availableInterfaces {
lnlLog.info("Interface: \(iface.name) type: \(String(describing: iface.type))")
}
}
startReceiving()
startUpdateLoop()
sendConnectRequest()
case .failed(let error):
lnlLog.error("NWConnection failed: \(error.localizedDescription)")
performDisconnect(reason: "Connection failed: \(error.localizedDescription)")
case .cancelled:
lnlLog.info("NWConnection cancelled")
performDisconnect(reason: nil)
default:
break
}
}
// MARK: - Receive Loop
private func startReceiving() {
lnlLog.debug("Waiting for incoming UDP data...")
connection?.receiveMessage { [weak self] data, _, _, error in
Task { [weak self] in await self?.handleReceiveCallback(data: data, error: error) }
}
}
private func handleReceiveCallback(data: Data?, error: NWError?) {
if let error {
lnlLog.error("Receive error: \(error.localizedDescription)")
}
if let data, !data.isEmpty {
lnlLog.info("Received \(data.count) bytes: \(data.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " "))\(data.count > 20 ? "..." : "")")
lastPacketReceivedTime = currentMs()
handleRawPacket(data)
} else if data == nil && error == nil {
lnlLog.warning("receiveMessage returned nil data with no error")
}
if error == nil && state != .disconnected {
startReceiving()
} else if state == .disconnected {
lnlLog.info("Not re-registering receive (disconnected)")
}
}
// MARK: - Update Loop
private func startUpdateLoop() {
updateTask = Task { [weak self] in
while !Task.isCancelled {
await self?.update()
try? await Task.sleep(for: .milliseconds(LNL.updateIntervalMs))
}
}
}
private func update() {
let now = currentMs()
switch state {
case .connecting:
if now - lastConnectSendTime > 500 {
connectAttempts += 1
lnlLog.info("ConnectRequest attempt \(self.connectAttempts)/\(self.maxConnectAttempts)")
if connectAttempts > maxConnectAttempts {
lnlLog.error("Connection timed out after \(self.maxConnectAttempts) attempts")
performDisconnect(reason: "Connection timed out")
return
}
sendConnectRequest()
}
case .connected:
if now - lastPacketReceivedTime > UInt64(LNL.disconnectTimeoutMs) {
performDisconnect(reason: "Connection timed out")
return
}
flushOutgoingQueue()
retransmitPending(now: now)
if mustSendAcks {
sendAck()
mustSendAcks = false
}
if now - lastPingSentTime > UInt64(LNL.pingIntervalMs) {
sendPing()
}
case .shutdownRequested, .disconnected:
break
}
}
// MARK: - Packet Handling
private func handleRawPacket(_ data: Data) {
guard !data.isEmpty else { return }
let byte0 = data[data.startIndex]
let property = byte0 & 0x1f
let connNum = (byte0 >> 5) & 0x03
guard let prop = PacketProperty(rawValue: property) else {
lnlLog.debug("Unknown packet property: \(property)")
return
}
lnlLog.debug("Received packet: \(String(describing: prop)), \(data.count) bytes")
if state == .connected && prop != .connectAccept && prop != .connectRequest {
if connNum != connectionNumber { return }
}
switch prop {
case .connectAccept: handleConnectAccept(data)
case .channeled: handleChanneled(data)
case .ack: handleAck(data)
case .ping: handlePing(data)
case .pong: handlePong(data)
case .disconnect: handleServerDisconnect(data)
case .merged: handleMerged(data)
case .shutdownOk: performDisconnect(reason: nil)
default: break
}
}
// MARK: - Connect Handshake
private func sendConnectRequest() {
lnlLog.debug("Sending ConnectRequest")
var w = DataWriter()
w.writeByte(PacketProperty.connectRequest.rawValue)
w.writeInt32LE(LNL.protocolId)
w.writeInt64LE(connectionTime)
w.writeInt32LE(localPeerId)
// Dummy IPv4 sockaddr (16 bytes, .NET SocketAddress format)
w.writeByte(16)
var addr = Data(count: 16)
addr[0] = 2 // AF_INET
w.writeData(addr)
// Connection key: ushort16 LE (byteCount + 1) + UTF-8
let keyBytes = Array(connectKey.utf8)
w.writeUInt16LE(UInt16(keyBytes.count + 1))
w.writeData(Data(keyBytes))
lastConnectSendTime = currentMs()
let packet = w.data
lnlLog.info("ConnectRequest packet (\(packet.count) bytes): \(packet.map { String(format: "%02x", $0) }.joined(separator: " "))")
sendRaw(packet)
}
private func handleConnectAccept(_ data: Data) {
guard state == .connecting else {
lnlLog.warning("ConnectAccept received but not in connecting state")
return
}
guard data.count >= 15 else {
lnlLog.error("ConnectAccept too short: \(data.count) bytes")
return
}
var r = DataReader(data: data, offset: 1)
guard let echoedTime = r.readInt64LE(),
echoedTime == connectionTime else {
lnlLog.error("ConnectAccept connectionTime mismatch")
return
}
connectionNumber = r.readByte() ?? 0
_ = r.readByte() // isReused
remotePeerId = r.readInt32LE() ?? 0
lnlLog.info("Connected! connNum=\(self.connectionNumber), remotePeerId=\(self.remotePeerId)")
state = .connected
lastPacketReceivedTime = currentMs()
lastPingSentTime = currentMs()
eventContinuation.yield(.connected)
}
// MARK: - Channeled (ReliableOrdered)
private func handleChanneled(_ data: Data) {
guard data.count >= LNL.channeledHeaderSize else { return }
var r = DataReader(data: data, offset: 1)
guard let seq = r.readUInt16LE() else { return }
guard let channelId = r.readByte() else { return }
guard channelId == LNL.reliableOrderedChannelId else { return }
// Pass the full packet (including fragment header if present) through
// the reliable ordered channel for sequencing and ACKs.
// Delivery/reassembly happens in deliverOrderedPacket.
let fullPayload = Data(data[data.startIndex ..< data.endIndex])
processReliableOrdered(sequence: seq, data: fullPayload)
}
/// Called when a reliable ordered packet is delivered in-sequence.
/// Handles fragment reassembly before yielding to the consumer.
private func deliverOrderedPacket(_ data: Data) {
guard data.count >= LNL.channeledHeaderSize else { return }
let isFragmented = (data[data.startIndex] & 0x80) != 0
if isFragmented {
guard data.count >= 10 else { return }
var r = DataReader(data: data, offset: 4) // skip channeled header
guard let fragmentId = r.readUInt16LE() else { return }
guard let fragmentPart = r.readUInt16LE() else { return }
guard let fragmentsTotal = r.readUInt16LE() else { return }
guard fragmentsTotal > 0, fragmentPart < fragmentsTotal else { return }
let channelId = data[data.startIndex + 3]
let fragmentData = Data(data[data.startIndex + 10 ..< data.endIndex])
if incomingFragments[fragmentId] == nil {
incomingFragments[fragmentId] = IncomingFragment(
totalParts: Int(fragmentsTotal), channelId: channelId
)
}
guard var fragment = incomingFragments[fragmentId],
fragment.channelId == channelId,
Int(fragmentPart) < fragment.totalParts,
fragment.parts[Int(fragmentPart)] == nil else { return }
fragment.parts[Int(fragmentPart)] = fragmentData
fragment.receivedCount += 1
incomingFragments[fragmentId] = fragment
if fragment.receivedCount == fragment.totalParts {
var assembled = Data()
for part in fragment.parts {
if let part { assembled.append(part) }
}
incomingFragments.removeValue(forKey: fragmentId)
lnlLog.info("Reassembled fragmented packet: \(fragment.totalParts) parts, \(assembled.count) bytes")
eventContinuation.yield(.received(assembled))
}
} else {
let userData = Data(data[data.startIndex + LNL.channeledHeaderSize ..< data.endIndex])
eventContinuation.yield(.received(userData))
}
}
private func processReliableOrdered(sequence seq: UInt16, data: Data) {
// Reject already-delivered sequences
let relateToDelivered = relativeSequence(seq, to: remoteSequence)
if relateToDelivered < 0 { return }
let relate = relativeSequence(seq, to: remoteWindowStart)
if relate >= LNL.windowSize {
let diff = relate - LNL.windowSize + 1
for _ in 0..<diff {
let idx = Int(remoteWindowStart) % LNL.windowSize
receivedPackets[idx] = nil
let bitIndex = Int(remoteWindowStart) % LNL.windowSize
ackBitfield[bitIndex / 8] &= ~(1 << (bitIndex % 8))
remoteWindowStart = (remoteWindowStart &+ 1) % LNL.maxSequence
}
}
let ackIdx = Int(seq) % LNL.windowSize
ackBitfield[ackIdx / 8] |= (1 << (ackIdx % 8))
mustSendAcks = true
if seq == remoteSequence {
deliverOrderedPacket(data)
remoteSequence = (remoteSequence &+ 1) % LNL.maxSequence
lnlLog.debug("Delivered seq \(seq), next expected: \(self.remoteSequence)")
while true {
let idx = Int(remoteSequence) % LNL.windowSize
guard let buffered = receivedPackets[idx] else { break }
receivedPackets[idx] = nil
deliverOrderedPacket(buffered)
remoteSequence = (remoteSequence &+ 1) % LNL.maxSequence
}
} else {
let idx = Int(seq) % LNL.windowSize
receivedPackets[idx] = data
lnlLog.warning("Buffered out-of-order seq \(seq), expecting \(self.remoteSequence)")
}
}
// MARK: - Send Reliable
private func flushOutgoingQueue() {
while !outgoingQueue.isEmpty {
let windowUsed = relativeSequence(localSequence, to: localWindowStart)
if windowUsed >= LNL.windowSize { break }
let userData = outgoingQueue.removeFirst()
let packet = buildChanneledPacket(sequence: localSequence, data: userData)
let idx = Int(localSequence) % LNL.windowSize
pendingPackets[idx] = PendingPacket(data: packet, timestamp: currentMs(), isSent: true)
localSequence = (localSequence &+ 1) % LNL.maxSequence
sendRaw(packet)
}
}
private func retransmitPending(now: UInt64) {
var seq = localWindowStart
while seq != localSequence {
let idx = Int(seq) % LNL.windowSize
if let pending = pendingPackets[idx] {
if Double(now - pending.timestamp) >= resendDelay {
pendingPackets[idx]?.timestamp = now
sendRaw(pending.data)
}
}
seq = (seq &+ 1) % LNL.maxSequence
}
}
private func buildChanneledPacket(sequence: UInt16, data: Data) -> Data {
var w = DataWriter()
w.writeByte((connectionNumber << 5) | PacketProperty.channeled.rawValue)
w.writeUInt16LE(sequence)
w.writeByte(LNL.reliableOrderedChannelId)
w.writeData(data)
return w.data
}
// MARK: - ACK
private func sendAck() {
var w = DataWriter()
w.writeByte((connectionNumber << 5) | PacketProperty.ack.rawValue)
w.writeUInt16LE(remoteWindowStart)
w.writeByte(LNL.reliableOrderedChannelId)
w.writeData(Data(ackBitfield))
sendRaw(w.data)
}
private func handleAck(_ data: Data) {
guard data.count >= LNL.channeledHeaderSize else { return }
var r = DataReader(data: data, offset: 1)
guard let ackWindowStart = r.readUInt16LE() else { return }
guard let channelId = r.readByte(), channelId == LNL.reliableOrderedChannelId else { return }
let bitfieldStart = data.startIndex + LNL.channeledHeaderSize
let bitfieldData = data[bitfieldStart...]
var seq = localWindowStart
while seq != localSequence {
let relToAckStart = relativeSequence(seq, to: ackWindowStart)
if relToAckStart < 0 { break }
if relToAckStart >= LNL.windowSize { break }
let byteIdx = relToAckStart / 8
let bitIdx = relToAckStart % 8
if byteIdx < bitfieldData.count {
let acked = (bitfieldData[bitfieldStart + byteIdx] & (1 << bitIdx)) != 0
if acked {
let idx = Int(seq) % LNL.windowSize
pendingPackets[idx] = nil
if seq == localWindowStart {
localWindowStart = (localWindowStart &+ 1) % LNL.maxSequence
}
}
}
seq = (seq &+ 1) % LNL.maxSequence
}
while localWindowStart != localSequence {
let idx = Int(localWindowStart) % LNL.windowSize
if pendingPackets[idx] != nil { break }
localWindowStart = (localWindowStart &+ 1) % LNL.maxSequence
}
}
// MARK: - Ping / Pong
private func sendPing() {
var w = DataWriter()
w.writeByte((connectionNumber << 5) | PacketProperty.ping.rawValue)
w.writeUInt16LE(pingSequence)
pingSequence = (pingSequence &+ 1) % LNL.maxSequence
pingSentTimestamp = currentMs()
lastPingSentTime = pingSentTimestamp
sendRaw(w.data)
}
private func handlePing(_ data: Data) {
guard data.count >= 3 else { return }
var r = DataReader(data: data, offset: 1)
guard let seq = r.readUInt16LE() else { return }
var w = DataWriter()
w.writeByte((connectionNumber << 5) | PacketProperty.pong.rawValue)
w.writeUInt16LE(seq)
w.writeInt64LE(LNL.dotNetTicks)
sendRaw(w.data)
}
private func handlePong(_ data: Data) {
guard data.count >= 3 else { return }
let elapsed = Double(currentMs() - pingSentTimestamp)
rttSum += elapsed
rttCount += 1
avgRtt = rttSum / Double(rttCount)
if rttCount > 10 {
rttSum = avgRtt
rttCount = 1
}
}
// MARK: - Disconnect
private func handleServerDisconnect(_ data: Data) {
guard data.count >= 9 else { return }
var w = DataWriter()
w.writeByte((connectionNumber << 5) | PacketProperty.shutdownOk.rawValue)
sendRaw(w.data)
performDisconnect(reason: "Server disconnected")
}
private func handleMerged(_ data: Data) {
var offset = 1
while offset + 2 <= data.count {
let sizeBytes = data[data.startIndex + offset ..< data.startIndex + offset + 2]
let size = Int(UInt16(littleEndian: sizeBytes.withUnsafeBytes { $0.loadUnaligned(as: UInt16.self) }))
offset += 2
guard offset + size <= data.count else { break }
let subPacket = Data(data[data.startIndex + offset ..< data.startIndex + offset + size])
offset += size
handleRawPacket(subPacket)
}
}
private func performDisconnect(reason: String?) {
guard state != .disconnected else { return }
lnlLog.info("Disconnecting: \(reason ?? "no reason")")
if state == .connected {
var w = DataWriter()
w.writeByte((connectionNumber << 5) | PacketProperty.disconnect.rawValue)
w.writeInt64LE(connectionTime)
sendRaw(w.data)
}
state = .disconnected
updateTask?.cancel()
updateTask = nil
connection?.cancel()
connection = nil
// Reset channel state
localSequence = 0
localWindowStart = 0
remoteSequence = 0
remoteWindowStart = 0
outgoingQueue.removeAll()
for i in 0..<LNL.windowSize {
pendingPackets[i] = nil
receivedPackets[i] = nil
}
for i in 0..<ackBitfield.count {
ackBitfield[i] = 0
}
incomingFragments.removeAll()
eventContinuation.yield(.disconnected(reason))
}
// MARK: - Raw Send
private func sendRaw(_ data: Data) {
guard let conn = connection else {
lnlLog.error("sendRaw called but connection is nil")
return
}
if conn.state != .ready {
lnlLog.warning("sendRaw called but NWConnection state is \(String(describing: conn.state))")
}
totalPacketsSent += 1
totalBytesSent += data.count
let pktNum = totalPacketsSent
conn.send(content: data, completion: .contentProcessed { error in
if let error {
lnlLog.error("Send #\(pktNum) failed: \(error.localizedDescription)")
} else {
lnlLog.debug("Send #\(pktNum) completed (\(data.count) bytes)")
}
})
}
// MARK: - Utility
private func currentMs() -> UInt64 {
UInt64(DispatchTime.now().uptimeNanoseconds / 1_000_000)
}
private func relativeSequence(_ number: UInt16, to expected: UInt16) -> Int {
let n = Int(number)
let e = Int(expected)
let max = Int(LNL.maxSequence)
return (n - e + max + LNL.halfMaxSequence) % max - LNL.halfMaxSequence
}
}
// MARK: - Binary Helpers
private struct DataWriter {
var data = Data()
mutating func writeByte(_ byte: UInt8) {
data.append(byte)
}
mutating func writeUInt16LE(_ value: UInt16) {
withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
}
mutating func writeInt32LE(_ value: Int32) {
withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
}
mutating func writeInt64LE(_ value: Int64) {
withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
}
mutating func writeData(_ d: Data) {
data.append(d)
}
}
private struct DataReader {
let data: Data
var offset: Int
mutating func readByte() -> UInt8? {
let idx = data.startIndex + offset
guard idx < data.endIndex else { return nil }
offset += 1
return data[idx]
}
mutating func readUInt16LE() -> UInt16? {
let start = data.startIndex + offset
guard start + 2 <= data.endIndex else { return nil }
offset += 2
return data[start..<start+2].withUnsafeBytes {
UInt16(littleEndian: $0.loadUnaligned(as: UInt16.self))
}
}
mutating func readInt32LE() -> Int32? {
let start = data.startIndex + offset
guard start + 4 <= data.endIndex else { return nil }
offset += 4
return data[start..<start+4].withUnsafeBytes {
Int32(littleEndian: $0.loadUnaligned(as: Int32.self))
}
}
mutating func readInt64LE() -> Int64? {
let start = data.startIndex + offset
guard start + 8 <= data.endIndex else { return nil }
offset += 8
return data[start..<start+8].withUnsafeBytes {
Int64(littleEndian: $0.loadUnaligned(as: Int64.self))
}
}
}

View file

@ -0,0 +1,139 @@
import Foundation
import MessagePack
// MARK: - Dynamic MessagePack Value
/// Represents any MessagePack value. Used for complex/polymorphic incoming data
/// (e.g. RemoteForm widgets) where the structure is too dynamic for static Codable types.
/// Decoded via the msgpack-swift library's `MessagePackDecoder`.
indirect enum MsgPackValue: Sendable {
case `nil`
case bool(Bool)
case int(Int64)
case uint(UInt64)
case float(Float)
case double(Double)
case string(String)
case binary(Data)
case array([MsgPackValue])
case map([(MsgPackValue, MsgPackValue)])
// MARK: Convenience Accessors
var isNil: Bool {
if case .nil = self { return true }
return false
}
var boolValue: Bool? {
if case .bool(let v) = self { return v }
return nil
}
var intValue: Int? {
switch self {
case .int(let v): return Int(exactly: v)
case .uint(let v): return Int(exactly: v)
default: return nil
}
}
var int64Value: Int64? {
switch self {
case .int(let v): return v
case .uint(let v): return Int64(exactly: v)
default: return nil
}
}
var stringValue: String? {
if case .string(let v) = self { return v }
return nil
}
var arrayValue: [MsgPackValue]? {
if case .array(let v) = self { return v }
return nil
}
var dataValue: Data? {
if case .binary(let v) = self { return v }
return nil
}
/// Look up value by integer key in an array or map
subscript(key: Int) -> MsgPackValue? {
switch self {
case .array(let items):
guard key >= 0, key < items.count else { return nil }
return items[key]
case .map(let pairs):
for (k, v) in pairs {
if k.intValue == key { return v }
}
return nil
default:
return nil
}
}
/// Look up value by string key in a map
subscript(key: String) -> MsgPackValue? {
guard case .map(let pairs) = self else { return nil }
for (k, v) in pairs {
if k.stringValue == key { return v }
}
return nil
}
}
// MARK: - Decodable
private struct DynamicCodingKey: CodingKey {
var stringValue: String
var intValue: Int?
init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }
init?(intValue: Int) { self.stringValue = "\(intValue)"; self.intValue = intValue }
}
extension MsgPackValue: Decodable {
init(from decoder: Decoder) throws {
// Try map (keyed container)
if let keyed = try? decoder.container(keyedBy: DynamicCodingKey.self) {
var pairs: [(MsgPackValue, MsgPackValue)] = []
for key in keyed.allKeys {
let val = try keyed.decode(MsgPackValue.self, forKey: key)
let k: MsgPackValue = key.intValue.map { .int(Int64($0)) } ?? .string(key.stringValue)
pairs.append((k, val))
}
self = .map(pairs)
return
}
// Try array (unkeyed container)
if var unkeyed = try? decoder.unkeyedContainer() {
var items: [MsgPackValue] = []
while !unkeyed.isAtEnd {
items.append(try unkeyed.decode(MsgPackValue.self))
}
self = .array(items)
return
}
// Scalar value
let c = try decoder.singleValueContainer()
if c.decodeNil() { self = .nil; return }
if let v = try? c.decode(Bool.self) { self = .bool(v); return }
if let v = try? c.decode(String.self) { self = .string(v); return }
if let v = try? c.decode(UInt64.self) { self = .uint(v); return }
if let v = try? c.decode(Int64.self) { self = .int(v); return }
if let v = try? c.decode(Double.self) { self = .double(v); return }
if let v = try? c.decode(Float.self) { self = .float(v); return }
if let v = try? c.decode(Data.self) { self = .binary(v); return }
throw DecodingError.dataCorrupted(
DecodingError.Context(codingPath: decoder.codingPath,
debugDescription: "Cannot decode MsgPackValue")
)
}
}

View file

@ -0,0 +1,222 @@
import Foundation
import MessagePack
// MARK: - Server Methods (client -> server)
enum ServerMethod: Int32 {
case createAccount = 0
case requestToken = 1
case requestLogin = 2
case sendMessage = 3
case createCharacter = 4
case leaveGame = 5
// 6 = TimeSync, 7 = RequestProfile
case directMessage = 8
// 9..19 = game-specific methods
case remoteFormEvent = 20
}
// MARK: - Client Methods (server -> client)
enum ClientMethod: Int32 {
case accountCreationUpdate = 0
case tokenRequestUpdate = 1
case loginRequestUpdate = 2
case addBuffer = 3
case addMessage = 4
case requestGameplay = 5
case leaveGameUpdate = 6
// 7 = TimeSync (unhandled)
// 8 = Profile (unhandled)
case say = 9
// 10..32 = game-specific methods (unhandled)
case showRemoteForm = 33
case updateRemoteForm = 34
case closeRemoteForm = 35
case disconnect = 36
}
// MARK: - Remote Form Event Types
enum RemoteFormEventType: Int, Sendable {
case buttonClicked = 0
case textChanged = 1
case textSubmitted = 2
case checkBoxValueChanged = 3
case listBoxSelectionChanged = 4
case listBoxItemClicked = 5
case multiSelectListSelectionChanged = 6
case sliderMoved = 7
case treeNodeSelected = 8
case treeNodeClicked = 9
case formClosed = 10
case formCloseRequested = 11
}
// MARK: - Form Event Value (for outgoing form events)
enum FormEventValue: Sendable {
case none
case bool(Bool)
case string(String)
case uint(UInt64)
case uintArray([UInt64])
}
// MARK: - Outgoing Packet Types
private struct TwoStringPacket: Encodable {
let a: String, b: String
func encode(to encoder: Encoder) throws {
var c = encoder.unkeyedContainer()
try c.encode(a); try c.encode(b)
}
}
private struct OneStringPacket: Encodable {
let a: String
func encode(to encoder: Encoder) throws {
var c = encoder.unkeyedContainer()
try c.encode(a)
}
}
private struct RemoteFormEventPacket: Encodable {
let formId: String
let widgetId: String
let eventType: Int
let value: FormEventValue
func encode(to encoder: Encoder) throws {
var c = encoder.unkeyedContainer()
try c.encode(formId)
try c.encode(widgetId)
try c.encode(eventType)
switch value {
case .none: try c.encodeNil()
case .bool(let v): try c.encode(v)
case .string(let v): try c.encode(v)
case .uint(let v): try c.encode(v)
case .uintArray(let v): try c.encode(v)
}
}
}
// MARK: - Incoming Packet Types
struct GenericResponse: Decodable, Sendable {
let success: Bool
let message: String?
init(from decoder: Decoder) throws {
var c = try decoder.unkeyedContainer()
success = try c.decode(Bool.self)
if c.isAtEnd { message = nil }
else { message = try c.decodeNil() ? nil : c.decode(String.self) }
}
}
struct GenericStringResponse: Decodable, Sendable {
let success: Bool
let message: String?
let data: String?
init(from decoder: Decoder) throws {
var c = try decoder.unkeyedContainer()
success = try c.decode(Bool.self)
if c.isAtEnd { message = nil; data = nil; return }
message = try c.decodeNil() ? nil : c.decode(String.self)
if c.isAtEnd { data = nil; return }
data = try c.decodeNil() ? nil : c.decode(String.self)
}
}
struct ServerSay: Decodable, Sendable {
let message: String
let interrupt: Bool
init(from decoder: Decoder) throws {
var c = try decoder.unkeyedContainer()
message = try c.decode(String.self)
interrupt = c.isAtEnd ? false : try c.decode(Bool.self)
}
}
struct ServerDisconnectInfo: Decodable, Sendable {
let characterId: String
let reason: String
init(from decoder: Decoder) throws {
var c = try decoder.unkeyedContainer()
characterId = try c.decode(String.self)
reason = c.isAtEnd ? "Unknown reason" : try c.decode(String.self)
}
}
// MARK: - Packet Encoding
enum PacketEncoder {
private static func encode<T: Encodable>(method: ServerMethod, payload: T) -> Data {
var data = Data()
withUnsafeBytes(of: method.rawValue.littleEndian) { data.append(contentsOf: $0) }
data.append(try! MessagePackEncoder().encode(payload))
return data
}
static func tokenRequest(email: String, password: String) -> Data {
encode(method: .requestToken, payload: TwoStringPacket(a: email, b: password))
}
static func createAccountRequest(email: String, password: String) -> Data {
encode(method: .createAccount, payload: TwoStringPacket(a: email, b: password))
}
static func loginRequest(token: String) -> Data {
encode(method: .requestLogin, payload: OneStringPacket(a: token))
}
static func sendMessage(buffer: String, content: String) -> Data {
encode(method: .sendMessage, payload: TwoStringPacket(a: buffer, b: content))
}
static func directMessage(recipientId: String, message: String) -> Data {
encode(method: .directMessage, payload: TwoStringPacket(a: recipientId, b: message))
}
static func leaveGame(characterId: String) -> Data {
encode(method: .leaveGame, payload: OneStringPacket(a: characterId))
}
static func remoteFormEvent(
formId: String,
widgetId: String,
eventType: RemoteFormEventType,
value: FormEventValue = .none
) -> Data {
encode(method: .remoteFormEvent, payload: RemoteFormEventPacket(
formId: formId, widgetId: widgetId,
eventType: eventType.rawValue, value: value
))
}
}
// MARK: - Packet Decoding
enum PacketDecoder {
static func extractMethod(from data: Data) -> (ClientMethod, Data)? {
guard data.count >= 4 else { return nil }
let methodRaw = data.withUnsafeBytes {
Int32(littleEndian: $0.loadUnaligned(as: Int32.self))
}
guard let method = ClientMethod(rawValue: methodRaw) else { return nil }
return (method, Data(data[data.startIndex + 4 ..< data.endIndex]))
}
static func decode<T: Decodable>(_ type: T.Type, from data: Data) -> T? {
try? MessagePackDecoder().decode(type, from: data)
}
static func decodeDynamic(from data: Data) -> MsgPackValue? {
try? MessagePackDecoder().decode(MsgPackValue.self, from: data)
}
}

View file

@ -0,0 +1,201 @@
import Foundation
// MARK: - Widget Type
enum WidgetType: Int, Sendable {
case button = 0
case textField = 1
case checkBox = 2
case listBox = 3
case slider = 4
case treeView = 5
case multiSelectList = 6
}
// MARK: - Tree Node
struct RemoteTreeNode: Identifiable, Sendable {
let id: String
let text: String
let children: [RemoteTreeNode]
let isExpanded: Bool
init?(from value: MsgPackValue) {
guard let id = value[0]?.stringValue,
let text = value[1]?.stringValue else { return nil }
self.id = id
self.text = text
self.children = value[2]?.arrayValue?.compactMap { RemoteTreeNode(from: $0) } ?? []
self.isExpanded = value[3]?.boolValue ?? false
}
}
// MARK: - Widget State
@Observable
final class WidgetState: Identifiable, @unchecked Sendable {
let id: String
var label: String
var enabled: Bool
var type: WidgetType
// TextField
var text: String = ""
var isPassword: Bool = false
var maxLength: Int = 1_000_000
var readOnly: Bool = false
// CheckBox
var isChecked: Bool = false
// ListBox
var items: [String] = []
var selectedIndex: Int = -1
// Slider
var sliderValue: Int = 0
var minValue: Int = 0
var maxValue: Int = 100
var increment: Int = 1
// TreeView
var nodes: [RemoteTreeNode] = []
var selectedNodeId: String = ""
// MultiSelectList
var selectedIndices: Set<Int> = []
init(id: String, label: String, enabled: Bool, type: WidgetType) {
self.id = id
self.label = label
self.enabled = enabled
self.type = type
}
/// Parse a widget from a MessagePack Union value: [typeId, objectMap]
static func fromUnion(_ value: MsgPackValue) -> WidgetState? {
guard let arr = value.arrayValue, arr.count == 2,
let typeId = arr[0].intValue,
let type = WidgetType(rawValue: typeId) else { return nil }
let obj = arr[1]
guard let id = obj[0]?.stringValue else { return nil }
let label = obj[1]?.stringValue ?? ""
let enabled = obj[2]?.boolValue ?? true
let widget = WidgetState(id: id, label: label, enabled: enabled, type: type)
switch type {
case .button:
break
case .textField:
widget.text = obj[5]?.stringValue ?? ""
widget.isPassword = obj[6]?.boolValue ?? false
widget.maxLength = obj[7]?.intValue ?? 1_000_000
widget.readOnly = obj[8]?.boolValue ?? false
case .checkBox:
widget.isChecked = obj[5]?.boolValue ?? false
case .listBox:
widget.items = obj[5]?.arrayValue?.compactMap(\.stringValue) ?? []
widget.selectedIndex = obj[6]?.intValue ?? -1
case .slider:
widget.sliderValue = obj[5]?.intValue ?? 0
widget.minValue = obj[6]?.intValue ?? 0
widget.maxValue = obj[7]?.intValue ?? 100
widget.increment = obj[8]?.intValue ?? 1
case .treeView:
widget.nodes = obj[5]?.arrayValue?.compactMap { RemoteTreeNode(from: $0) } ?? []
widget.selectedNodeId = obj[6]?.stringValue ?? ""
case .multiSelectList:
widget.items = obj[5]?.arrayValue?.compactMap(\.stringValue) ?? []
let indices = obj[6]?.arrayValue?.compactMap(\.intValue) ?? []
widget.selectedIndices = Set(indices)
}
return widget
}
/// Update this widget from a new Union value (preserving identity)
func update(from value: MsgPackValue) {
guard let arr = value.arrayValue, arr.count == 2 else { return }
let obj = arr[1]
label = obj[1]?.stringValue ?? label
enabled = obj[2]?.boolValue ?? enabled
if let typeId = arr[0].intValue, let newType = WidgetType(rawValue: typeId) {
type = newType
}
switch type {
case .button:
break
case .textField:
if let t = obj[5]?.stringValue { text = t }
if let p = obj[6]?.boolValue { isPassword = p }
if let m = obj[7]?.intValue { maxLength = m }
if let r = obj[8]?.boolValue { readOnly = r }
case .checkBox:
if let c = obj[5]?.boolValue { isChecked = c }
case .listBox:
if let i = obj[5]?.arrayValue { items = i.compactMap(\.stringValue) }
if let s = obj[6]?.intValue { selectedIndex = s }
case .slider:
if let v = obj[5]?.intValue { sliderValue = v }
if let mn = obj[6]?.intValue { minValue = mn }
if let mx = obj[7]?.intValue { maxValue = mx }
if let inc = obj[8]?.intValue { increment = inc }
case .treeView:
if let n = obj[5]?.arrayValue { nodes = n.compactMap { RemoteTreeNode(from: $0) } }
if let s = obj[6]?.stringValue { selectedNodeId = s }
case .multiSelectList:
if let i = obj[5]?.arrayValue { items = i.compactMap(\.stringValue) }
if let s = obj[6]?.arrayValue { selectedIndices = Set(s.compactMap(\.intValue)) }
}
}
}
// MARK: - Remote Form
@Observable
final class RemoteFormData: Identifiable, @unchecked Sendable {
let id: String
var title: String
var widgets: [WidgetState]
init(id: String, title: String, widgets: [WidgetState]) {
self.id = id
self.title = title
self.widgets = widgets
}
static func from(_ value: MsgPackValue) -> RemoteFormData? {
guard let id = value[0]?.stringValue,
let title = value[1]?.stringValue else { return nil }
let widgets = value[2]?.arrayValue?.compactMap { WidgetState.fromUnion($0) } ?? []
return RemoteFormData(id: id, title: title, widgets: widgets)
}
func updateWidgets(from value: MsgPackValue) {
// value[1] is UpdatedWidgets: Dictionary<string, RemoteWidget?>
guard let updates = value[1] else { return }
if case .map(let pairs) = updates {
for (key, val) in pairs {
guard let widgetId = key.stringValue else { continue }
if val.isNil {
// Remove widget
widgets.removeAll { $0.id == widgetId }
} else if let existing = widgets.first(where: { $0.id == widgetId }) {
// Update existing widget
existing.update(from: val)
} else if let newWidget = WidgetState.fromUnion(val) {
// Add new widget
widgets.append(newWidget)
}
}
}
}
}

View file

@ -0,0 +1,390 @@
import Foundation
import SwiftUI
import os
private let log = Logger(subsystem: "dev.smoll.KDChat", category: "Connection")
// MARK: - App Screen
enum AppScreen: Sendable, Equatable {
case login
case connecting
case forms
case chat
}
// MARK: - Server Config
struct ServerConfig: Sendable {
var host: String
var port: UInt16
static let `default` = ServerConfig(host: "15.204.120.54", port: 4200)
}
// MARK: - Connection Manager
@Observable
@MainActor
final class ConnectionManager {
// Navigation state
var screen: AppScreen = .login
var errorMessage: String?
// Remote forms
var remoteForms: [RemoteFormData] = []
// Chat
var buffers: [ChatBuffer] = []
var messages: [String: [ChatMessage]] = [:]
var currentCharacterId: String?
// Feedback triggers (views observe these for sensoryFeedback / announcements)
var messageFeedbackTrigger: Int = 0
var systemFeedbackTrigger: Int = 0
var lastAnnouncement: String = ""
// Auth
private(set) var isLoggedIn: Bool = false
private var cachedToken: String?
private var pendingEmail: String?
private var pendingPassword: String?
private var pendingCreateAccount: Bool = false
// Networking
private var client: LNLClient?
private var eventTask: Task<Void, Never>?
// MARK: - Connect
func connect(host: String, port: UInt16, email: String, password: String, createAccount: Bool) {
screen = .connecting
errorMessage = nil
pendingEmail = email
pendingPassword = password
pendingCreateAccount = createAccount
startClient(host: host, port: port)
}
/// Reconnect using a cached JWT token
func reconnectWithToken(host: String, port: UInt16) {
guard let token = KeychainHelper.load(key: "jwt") else {
screen = .login
return
}
cachedToken = token
screen = .connecting
errorMessage = nil
pendingEmail = nil
pendingPassword = nil
startClient(host: host, port: port)
}
private func tearDownClient() {
eventTask?.cancel()
eventTask = nil
if let client {
Task { await client.disconnect() }
}
client = nil
}
private func startClient(host: String, port: UInt16) {
tearDownClient()
let client = LNLClient()
self.client = client
eventTask = Task { [weak self] in
await client.connect(host: host, port: port, key: "Placeholder")
for await event in client.events {
guard let self else { break }
switch event {
case .connected:
if self.cachedToken != nil {
self.loginWithToken(self.cachedToken!)
} else {
self.handleConnected()
}
case .received(let data):
self.handlePacket(data)
case .disconnected(let reason):
self.handleDisconnect(reason: reason)
}
}
}
}
// MARK: - Chat Actions
func sendChatMessage(buffer: String, content: String) {
let packet = PacketEncoder.sendMessage(buffer: buffer, content: content)
Task { await client?.send(data: packet) }
}
func sendDirectMessage(recipientId: String, message: String) {
let packet = PacketEncoder.directMessage(recipientId: recipientId, message: message)
Task { await client?.send(data: packet) }
}
// MARK: - Form Actions
func sendFormEvent(
formId: String,
widgetId: String,
eventType: RemoteFormEventType,
value: FormEventValue = .none
) {
log.info("sendFormEvent: \(String(describing: eventType)) widget=\(widgetId) form=\(formId)")
let packet = PacketEncoder.remoteFormEvent(
formId: formId, widgetId: widgetId,
eventType: eventType, value: value
)
Task { await client?.send(data: packet) }
}
// MARK: - Character Actions
func switchCharacter() {
guard let charId = currentCharacterId else { return }
let packet = PacketEncoder.leaveGame(characterId: charId)
Task { await client?.send(data: packet) }
}
func logout() {
KeychainHelper.delete(key: "jwt")
cachedToken = nil
isLoggedIn = false
tearDownClient()
resetState()
screen = .login
}
// MARK: - Connection Callbacks
private func handleConnected() {
log.info("LiteNetLib connected, sending auth request")
if let email = pendingEmail, let password = pendingPassword {
if pendingCreateAccount {
log.info("Sending CreateAccount for \(email)")
let packet = PacketEncoder.createAccountRequest(email: email, password: password)
Task { await client?.send(data: packet) }
} else {
log.info("Sending TokenRequest for \(email)")
let packet = PacketEncoder.tokenRequest(email: email, password: password)
Task { await client?.send(data: packet) }
}
} else {
log.warning("handleConnected called but no pending credentials")
}
}
private func loginWithToken(_ token: String) {
let packet = PacketEncoder.loginRequest(token: token)
Task { await client?.send(data: packet) }
}
private func handleDisconnect(reason: String?) {
log.info("Disconnected: \(reason ?? "unknown")")
if screen != .login {
errorMessage = reason ?? "Disconnected"
screen = .login
}
isLoggedIn = false
// Don't call tearDownClient here this is already called from the event stream
// which means the client is already disconnecting. Just clear the reference.
client = nil
}
private func resetState() {
buffers.removeAll()
messages.removeAll()
remoteForms.removeAll()
currentCharacterId = nil
errorMessage = nil
}
// MARK: - Packet Dispatch
private func handlePacket(_ data: Data) {
guard data.count >= 4 else {
log.warning("Packet too short (\(data.count) bytes)")
return
}
let methodRaw = data.withUnsafeBytes { Int32(littleEndian: $0.loadUnaligned(as: Int32.self)) }
let payload = Data(data[data.startIndex + 4 ..< data.endIndex])
guard let method = ClientMethod(rawValue: methodRaw) else {
log.info("Ignoring unhandled method ID \(methodRaw) (\(payload.count) bytes)")
return
}
log.debug("Received \(String(describing: method))")
switch method {
case .accountCreationUpdate:
guard let response = PacketDecoder.decode(GenericResponse.self, from: payload) else { return }
handleAccountCreation(response)
case .tokenRequestUpdate:
guard let response = PacketDecoder.decode(GenericStringResponse.self, from: payload) else { return }
handleTokenResponse(response)
case .loginRequestUpdate:
guard let response = PacketDecoder.decode(GenericResponse.self, from: payload) else { return }
handleLoginResponse(response)
case .addBuffer:
guard let buffer = PacketDecoder.decode(ChatBuffer.self, from: payload) else { return }
handleAddBuffer(buffer)
case .addMessage:
guard let message = PacketDecoder.decode(ChatMessage.self, from: payload) else { return }
handleAddMessage(message)
case .requestGameplay:
handleRequestGameplay()
case .leaveGameUpdate:
guard let response = PacketDecoder.decode(GenericResponse.self, from: payload) else { return }
handleLeaveGameUpdate(response)
case .say:
guard let say = PacketDecoder.decode(ServerSay.self, from: payload) else { return }
handleSay(say)
case .showRemoteForm:
guard let value = PacketDecoder.decodeDynamic(from: payload) else {
log.error("Failed to decode showRemoteForm payload (\(payload.count) bytes)")
return
}
handleShowRemoteForm(value)
case .updateRemoteForm:
guard let value = PacketDecoder.decodeDynamic(from: payload) else { return }
handleUpdateRemoteForm(value)
case .closeRemoteForm:
guard let value = PacketDecoder.decodeDynamic(from: payload) else { return }
handleCloseRemoteForm(value)
case .disconnect:
guard let info = PacketDecoder.decode(ServerDisconnectInfo.self, from: payload) else { return }
handleServerDisconnect(info)
}
}
// MARK: - Auth Handlers
private func handleAccountCreation(_ response: GenericResponse) {
guard !isLoggedIn else { return }
if response.success {
// Account created, now request a token
if let email = pendingEmail, let password = pendingPassword {
pendingCreateAccount = false
let packet = PacketEncoder.tokenRequest(email: email, password: password)
Task { await client?.send(data: packet) }
}
} else {
errorMessage = response.message ?? "Account creation failed"
tearDownClient()
screen = .login
}
}
private func handleTokenResponse(_ response: GenericStringResponse) {
guard !isLoggedIn else { return }
if response.success, let token = response.data {
log.info("Token received, logging in")
cachedToken = token
KeychainHelper.save(key: "jwt", value: token)
loginWithToken(token)
} else {
log.error("Token request failed: \(response.message ?? "unknown")")
errorMessage = response.message ?? "Authentication failed"
tearDownClient()
screen = .login
}
}
private func handleLoginResponse(_ response: GenericResponse) {
guard !isLoggedIn else { return }
if response.success {
log.info("Login successful, waiting for RemoteForms")
isLoggedIn = true
screen = .forms
} else {
log.error("Login failed: \(response.message ?? "unknown")")
KeychainHelper.delete(key: "jwt")
cachedToken = nil
errorMessage = response.message ?? "Login failed"
tearDownClient()
screen = .login
}
}
// MARK: - Chat Handlers
private func handleAddBuffer(_ buffer: ChatBuffer) {
log.info("addBuffer: \(buffer.name), canSend=\(buffer.canSend)")
if !buffers.contains(where: { $0.name == buffer.name }) {
buffers.append(buffer)
messages[buffer.name] = []
}
}
private func handleAddMessage(_ message: ChatMessage) {
messages[message.buffer, default: []].append(message)
messageFeedbackTrigger += 1
lastAnnouncement = "\(message.senderName): \(message.content)"
}
private func handleSay(_ say: ServerSay) {
let sysMessage = ChatMessage(systemMessage: say.message)
for buffer in buffers {
messages[buffer.name, default: []].append(sysMessage)
}
systemFeedbackTrigger += 1
lastAnnouncement = say.message
}
// MARK: - Game State Handlers
private func handleRequestGameplay() {
// Game entry signal switch to chat view
remoteForms.removeAll()
screen = .chat
}
private func handleLeaveGameUpdate(_ response: GenericResponse) {
if response.success {
// Clear chat state, wait for new RemoteForms
resetState()
screen = .forms
}
}
// MARK: - Remote Form Handlers
private func handleShowRemoteForm(_ payload: MsgPackValue) {
guard let form = RemoteFormData.from(payload) else { return }
remoteForms.append(form)
if screen != .chat {
screen = .forms
}
}
private func handleUpdateRemoteForm(_ payload: MsgPackValue) {
guard let formId = payload[0]?.stringValue,
let form = remoteForms.first(where: { $0.id == formId }) else { return }
form.updateWidgets(from: payload)
}
private func handleCloseRemoteForm(_ payload: MsgPackValue) {
guard let formId = payload[0]?.stringValue else { return }
remoteForms.removeAll { $0.id == formId }
}
// MARK: - Server Disconnect
private func handleServerDisconnect(_ info: ServerDisconnectInfo) {
errorMessage = info.reason
tearDownClient()
isLoggedIn = false
screen = .login
}
}

View file

@ -0,0 +1,48 @@
import Foundation
import Security
enum KeychainHelper {
private static let service = "dev.smoll.KDChat"
@discardableResult
static func save(key: String, value: String) -> Bool {
let data = Data(value.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
var add = query
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(add as CFDictionary, nil)
if status != errSecSuccess {
print("[Keychain] save failed: \(status)")
}
return status == errSecSuccess
}
static func load(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func delete(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
}

View file

@ -0,0 +1,84 @@
import SwiftUI
struct ChatBufferView: View {
let bufferName: String
let canSend: Bool
var onDM: ((ChatMessage) -> Void)?
@Environment(ConnectionManager.self) private var manager
@State private var messageText = ""
private var messages: [ChatMessage] {
manager.messages[bufferName] ?? []
}
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(messages) { message in
MessageRow(
message: message,
onTapSender: message.isInteractable ? { startDM(to: message) } : nil,
onDM: onDM,
onProfile: nil // TODO: profile viewing
)
.id(message.id)
}
}
.padding()
}
.onChange(of: messages.count) {
if let lastId = messages.last?.id {
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(lastId, anchor: .bottom)
}
}
}
}
if canSend {
HStack(spacing: 8) {
TextField("Message...", text: $messageText, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
.onSubmit { sendMessage() }
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Button("/command", systemImage: "slash.circle") {
if !messageText.hasPrefix("/") {
messageText = "/" + messageText
}
}
.labelStyle(.titleAndIcon)
Spacer()
}
}
Button("Send", systemImage: "arrow.up.circle.fill", action: sendMessage)
.labelStyle(.iconOnly)
.font(.title)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.circle)
.disabled(messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
}
}
}
private func sendMessage() {
let text = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
manager.sendChatMessage(buffer: bufferName, content: text)
messageText = ""
}
private func startDM(to message: ChatMessage) {
guard message.senderId != nil else { return }
// Full DM flow would use sendDirectMessage with a selected recipient
}
}

View file

@ -0,0 +1,86 @@
import SwiftUI
struct ChatView: View {
@Environment(ConnectionManager.self) private var manager
@State private var selectedBuffer: String = "global"
@State private var showSettings = false
@State private var selectedDMPartner: String?
@AppStorage("haptics_enabled") private var hapticsEnabled = true
@AppStorage("haptics_messages") private var messageHaptics = true
@AppStorage("haptics_status") private var statusHaptics = true
var body: some View {
NavigationStack {
TabView(selection: $selectedBuffer) {
ForEach(manager.buffers) { buffer in
if buffer.name == "direct-messages" {
Tab(buffer.displayName, systemImage: iconForBuffer(buffer.name), value: buffer.name) {
DMListView(selectedConversation: $selectedDMPartner)
}
} else {
Tab(buffer.displayName, systemImage: iconForBuffer(buffer.name), value: buffer.name) {
ChatBufferView(bufferName: buffer.name, canSend: canSend(buffer)) { message in
navigateToDM(message)
}
}
}
}
}
.navigationTitle("Kirandur Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu("More", systemImage: "ellipsis.circle") {
Button("Switch Character", systemImage: "person.2") {
manager.switchCharacter()
}
Button("Settings", systemImage: "gearshape") {
showSettings = true
}
Button("Log Out", systemImage: "rectangle.portrait.and.arrow.right", role: .destructive) {
manager.logout()
}
}
}
}
.navigationDestination(isPresented: $showSettings) {
SettingsView()
}
.navigationDestination(for: String.self) { partnerId in
let name = partnerName(for: partnerId)
DMConversationView(partnerId: partnerId, partnerName: name)
}
.sensoryFeedback(.impact(flexibility: .soft), trigger: hapticsEnabled && messageHaptics ? manager.messageFeedbackTrigger : 0)
.sensoryFeedback(.warning, trigger: hapticsEnabled && statusHaptics ? manager.systemFeedbackTrigger : 0)
.onChange(of: manager.lastAnnouncement) {
AccessibilityNotification.Announcement(manager.lastAnnouncement).post()
}
}
}
private func canSend(_ buffer: ChatBuffer) -> Bool {
if buffer.name == "direct-messages" { return true }
return buffer.canSend
}
private func iconForBuffer(_ name: String) -> String {
switch name {
case "global": "globe"
case "map": "map"
case "debug": "ladybug"
case "direct-messages": "envelope"
default: "bubble.left"
}
}
private func partnerName(for partnerId: String) -> String {
let dmMessages = manager.messages["direct-messages"] ?? []
return dmMessages.first { $0.senderId == partnerId }?.senderName ?? partnerId
}
private func navigateToDM(_ message: ChatMessage) {
guard let senderId = message.senderId else { return }
selectedBuffer = "direct-messages"
selectedDMPartner = senderId
}
}

View file

@ -0,0 +1,13 @@
import SwiftUI
struct ConnectingView: View {
var body: some View {
VStack(spacing: 16) {
ProgressView()
.controlSize(.large)
Text("Connecting...")
.font(.headline)
.foregroundStyle(.secondary)
}
}
}

View file

@ -0,0 +1,63 @@
import SwiftUI
struct DMConversationView: View {
let partnerId: String
let partnerName: String
@Environment(ConnectionManager.self) private var manager
@State private var messageText = ""
private var messages: [ChatMessage] {
(manager.messages["direct-messages"] ?? []).filter { message in
message.senderId == partnerId || (message.senderIsRecipient && !message.isSystem)
}
}
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(messages) { message in
MessageRow(message: message)
.id(message.id)
}
}
.padding()
}
.onChange(of: messages.count) {
if let lastId = messages.last?.id {
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(lastId, anchor: .bottom)
}
}
}
}
HStack(spacing: 8) {
TextField("Message \(partnerName)...", text: $messageText, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
.onSubmit { sendMessage() }
Button("Send", systemImage: "arrow.up.circle.fill", action: sendMessage)
.labelStyle(.iconOnly)
.font(.title)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.circle)
.disabled(messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
}
.navigationTitle(partnerName)
.navigationBarTitleDisplayMode(.inline)
}
private func sendMessage() {
let text = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
manager.sendDirectMessage(recipientId: partnerId, message: text)
messageText = ""
}
}

View file

@ -0,0 +1,54 @@
import SwiftUI
struct DMListView: View {
@Environment(ConnectionManager.self) private var manager
@Binding var selectedConversation: String?
private var conversations: [(id: String, name: String, lastMessage: ChatMessage)] {
let dmMessages = manager.messages["direct-messages"] ?? []
var latest: [String: (name: String, message: ChatMessage)] = [:]
for message in dmMessages where !message.isSystem {
// The conversation partner is the other person
guard let senderId = message.senderId, !message.senderIsRecipient else { continue }
if latest[senderId].map({ message.timestamp > $0.message.timestamp }) ?? true {
latest[senderId] = (message.senderName, message)
}
}
return latest
.map { (id: $0.key, name: $0.value.name, lastMessage: $0.value.message) }
.sorted { $0.lastMessage.timestamp > $1.lastMessage.timestamp }
}
var body: some View {
if conversations.isEmpty {
ContentUnavailableView(
"No Conversations",
systemImage: "envelope",
description: Text("Direct messages will appear here.")
)
} else {
List(conversations, id: \.id, selection: $selectedConversation) { convo in
NavigationLink(value: convo.id) {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(convo.name)
.font(.headline)
Spacer()
Text(convo.lastMessage.timestamp, style: .time)
.font(.caption)
.foregroundStyle(.secondary)
}
Text(convo.lastMessage.content)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(.vertical, 4)
}
}
.listStyle(.plain)
}
}
}

View file

@ -0,0 +1,17 @@
import SwiftUI
struct FormContentView: View {
@Bindable var form: RemoteFormData
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
ForEach(form.widgets) { widget in
WidgetView(formId: form.id, widget: widget)
}
}
.padding()
}
.navigationTitle(form.title)
}
}

View file

@ -0,0 +1,32 @@
import SwiftUI
struct ListBoxContent: View {
let formId: String
let widget: WidgetState
@Environment(ConnectionManager.self) private var manager
var body: some View {
Picker(widget.label, selection: Bindable(widget).selectedIndex) {
ForEach(widget.items.indices, id: \.self) { index in
Text(widget.items[index]).tag(index)
}
}
.disabled(!widget.enabled)
.onChange(of: widget.selectedIndex) {
sendSelection()
}
.onAppear {
if widget.items.count == 1 {
sendSelection()
}
}
}
private func sendSelection() {
manager.sendFormEvent(
formId: formId, widgetId: widget.id,
eventType: .listBoxItemClicked,
value: .uint(UInt64(widget.selectedIndex))
)
}
}

View file

@ -0,0 +1,108 @@
import SwiftUI
struct LoginView: View {
@Environment(ConnectionManager.self) private var manager
@AppStorage("login_email") private var email = ""
@State private var password = ""
@AppStorage("save_password") private var savePassword = false
@AppStorage("use_custom_server") private var useCustomServer = false
@AppStorage("custom_host") private var customHost = ""
@AppStorage("custom_port") private var customPort = "4200"
@State private var isCreatingAccount = false
var body: some View {
NavigationStack {
Form {
Section {
Text("Enter your email and password to log in.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Section("Credentials") {
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
SecureField("Password", text: $password)
.textContentType(.password)
Toggle("Save Password", isOn: $savePassword)
}
Section("Server") {
Picker("Server", selection: $useCustomServer) {
Text("Default").tag(false)
Text("Custom").tag(true)
}
.pickerStyle(.segmented)
if useCustomServer {
TextField("Host", text: $customHost)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
TextField("Port", text: $customPort)
.keyboardType(.numberPad)
}
}
Section {
Toggle("Create new account", isOn: $isCreatingAccount)
}
Section {
Button {
connect()
} label: {
Text(isCreatingAccount ? "Create Account & Connect" : "Connect")
.frame(maxWidth: .infinity)
}
.disabled(email.isEmpty || password.isEmpty || (useCustomServer && customHost.isEmpty))
}
}
.navigationTitle("Welcome to Kirandur Chat")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
SettingsView()
} label: {
Label("Settings", systemImage: "gearshape")
}
}
}
.alert("Error", isPresented: .init(
get: { manager.errorMessage != nil },
set: { if !$0 { manager.errorMessage = nil } }
)) {
} message: {
Text(manager.errorMessage ?? "")
}
.onAppear {
if savePassword {
password = KeychainHelper.load(key: "password") ?? ""
}
}
.onChange(of: savePassword) {
if !savePassword {
KeychainHelper.delete(key: "password")
}
}
}
}
private func connect() {
if savePassword {
KeychainHelper.save(key: "password", value: password)
}
let host = useCustomServer ? customHost : ServerConfig.default.host
let port = UInt16(useCustomServer ? customPort : "\(ServerConfig.default.port)") ?? ServerConfig.default.port
manager.connect(
host: host, port: port,
email: email, password: password,
createAccount: isCreatingAccount
)
}
}

View file

@ -0,0 +1,63 @@
import SwiftUI
struct MessageRow: View {
let message: ChatMessage
var onTapSender: (() -> Void)?
var onDM: ((ChatMessage) -> Void)?
var onProfile: ((ChatMessage) -> Void)?
var body: some View {
if message.isSystem {
Text(message.content)
.font(.subheadline)
.foregroundStyle(.secondary)
.italic()
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 4)
} else {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
if let tap = onTapSender {
Button(message.senderName, action: tap)
.font(.subheadline).bold()
.accessibilityHint("Tap to send a direct message")
} else {
Text(message.senderName)
.font(.subheadline).bold()
.foregroundStyle(Color.accentColor)
}
if message.senderIsRecipient {
Text("(you)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(message.timestamp, style: .time)
.font(.caption)
.foregroundStyle(.secondary)
}
Text(message.content)
.font(.body)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(.regularMaterial)
.clipShape(.rect(cornerRadius: 12))
.accessibilityElement(children: .combine)
.accessibilityActions {
if !message.senderIsRecipient, message.senderId != nil {
if let onDM {
Button("DM \(message.senderName)") { onDM(message) }
}
if let onProfile {
Button("Profile for \(message.senderName)") { onProfile(message) }
}
}
}
}
}
}

View file

@ -0,0 +1,41 @@
import SwiftUI
struct MultiSelectContent: View {
let formId: String
let widget: WidgetState
@Environment(ConnectionManager.self) private var manager
var body: some View {
if !widget.label.isEmpty {
Text(widget.label)
.font(.headline)
}
ForEach(widget.items.indices, id: \.self) { index in
Button {
toggleItem(index)
} label: {
HStack {
Image(systemName: widget.selectedIndices.contains(index) ? "checkmark.square.fill" : "square")
Text(widget.items[index])
.foregroundStyle(.primary)
}
.padding(.vertical, 4)
}
.disabled(!widget.enabled)
}
}
private func toggleItem(_ index: Int) {
if widget.selectedIndices.contains(index) {
widget.selectedIndices.remove(index)
} else {
widget.selectedIndices.insert(index)
}
let sorted = widget.selectedIndices.sorted().map { UInt64($0) }
manager.sendFormEvent(
formId: formId, widgetId: widget.id,
eventType: .multiSelectListSelectionChanged,
value: .uintArray(sorted)
)
}
}

View file

@ -0,0 +1,18 @@
import SwiftUI
struct RemoteFormView: View {
@Environment(ConnectionManager.self) private var manager
var body: some View {
NavigationStack {
if manager.remoteForms.isEmpty {
ProgressView("Waiting for server...")
.navigationTitle("Kirandur")
} else {
if let form = manager.remoteForms.last {
FormContentView(form: form)
}
}
}
}
}

View file

@ -0,0 +1,26 @@
import SwiftUI
struct SettingsView: View {
@AppStorage("haptics_enabled") private var hapticsEnabled = true
@AppStorage("haptics_messages") private var messageHaptics = true
@AppStorage("haptics_status") private var statusHaptics = true
var body: some View {
Form {
Section("Haptics") {
Toggle("Haptics", isOn: $hapticsEnabled)
Toggle("Message Haptics", isOn: $messageHaptics)
.disabled(!hapticsEnabled)
Toggle("Status Haptics", isOn: $statusHaptics)
.disabled(!hapticsEnabled)
}
Section {
Text("\(AppInfo.name) \(AppInfo.version) (\(AppInfo.build))")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.navigationTitle("Settings")
}
}

View file

@ -0,0 +1,39 @@
import SwiftUI
struct TreeNodeView: View {
let node: RemoteTreeNode
let formId: String
let widgetId: String
let depth: Int
@Environment(ConnectionManager.self) private var manager
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Button {
manager.sendFormEvent(
formId: formId, widgetId: widgetId,
eventType: .treeNodeClicked,
value: .string(node.id)
)
} label: {
HStack {
if !node.children.isEmpty {
Image(systemName: node.isExpanded ? "chevron.down" : "chevron.right")
.font(.caption)
}
Text(node.text)
}
.padding(.leading, CGFloat(depth) * 16)
}
if node.isExpanded {
ForEach(node.children) { child in
TreeNodeView(
node: child, formId: formId,
widgetId: widgetId, depth: depth + 1
)
}
}
}
}
}

View file

@ -0,0 +1,163 @@
import SwiftUI
struct WidgetView: View {
let formId: String
let widget: WidgetState
@Environment(ConnectionManager.self) private var manager
var body: some View {
VStack(alignment: .leading, spacing: 6) {
switch widget.type {
case .button:
Button {
manager.sendFormEvent(
formId: formId, widgetId: widget.id,
eventType: .buttonClicked
)
} label: {
Text(widget.label)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(!widget.enabled)
case .textField:
TextFieldWidget(formId: formId, widget: widget)
case .checkBox:
Toggle(widget.label, isOn: Bindable(widget).isChecked)
.disabled(!widget.enabled)
.onChange(of: widget.isChecked) { _, newValue in
manager.sendFormEvent(
formId: formId, widgetId: widget.id,
eventType: .checkBoxValueChanged,
value: .bool(newValue)
)
}
case .listBox:
ListBoxContent(formId: formId, widget: widget)
case .slider:
SliderWidget(formId: formId, widget: widget)
case .treeView:
TreeViewWidget(formId: formId, widget: widget)
case .multiSelectList:
MultiSelectContent(formId: formId, widget: widget)
}
}
}
}
private struct TextFieldWidget: View {
let formId: String
let widget: WidgetState
@Environment(ConnectionManager.self) private var manager
var body: some View {
if !widget.label.isEmpty {
Text(widget.label)
.font(.headline)
}
if widget.readOnly {
Text(widget.text)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.quaternary)
.clipShape(.rect(cornerRadius: 8))
} else if widget.isPassword {
SecureField(widget.label, text: Bindable(widget).text)
.textFieldStyle(.roundedBorder)
.disabled(!widget.enabled)
.onChange(of: widget.text) { _, newValue in
enforceMaxLength(newValue)
sendTextChanged(newValue)
}
.onSubmit { sendTextSubmitted() }
} else {
TextField(widget.label, text: Bindable(widget).text)
.textFieldStyle(.roundedBorder)
.disabled(!widget.enabled)
.onChange(of: widget.text) { _, newValue in
enforceMaxLength(newValue)
sendTextChanged(newValue)
}
.onSubmit { sendTextSubmitted() }
}
}
private func enforceMaxLength(_ text: String) {
if text.count > widget.maxLength {
widget.text = String(text.prefix(widget.maxLength))
}
}
private func sendTextChanged(_ text: String) {
manager.sendFormEvent(
formId: formId, widgetId: widget.id,
eventType: .textChanged,
value: .string(text)
)
}
private func sendTextSubmitted() {
manager.sendFormEvent(
formId: formId, widgetId: widget.id,
eventType: .textSubmitted,
value: .string(widget.text)
)
}
}
private struct SliderWidget: View {
let formId: String
let widget: WidgetState
@Environment(ConnectionManager.self) private var manager
var body: some View {
if !widget.label.isEmpty {
Text(widget.label)
.font(.headline)
}
LabeledContent(widget.label) {
Slider(
value: Binding(
get: { Double(widget.sliderValue) },
set: { widget.sliderValue = Int($0) }
),
in: Double(widget.minValue)...Double(widget.maxValue),
step: Double(widget.increment)
)
.disabled(!widget.enabled)
.onChange(of: widget.sliderValue) { _, newValue in
manager.sendFormEvent(
formId: formId, widgetId: widget.id,
eventType: .sliderMoved,
value: .uint(UInt64(newValue))
)
}
}
}
}
private struct TreeViewWidget: View {
let formId: String
let widget: WidgetState
@Environment(ConnectionManager.self) private var manager
var body: some View {
if !widget.label.isEmpty {
Text(widget.label)
.font(.headline)
}
ForEach(widget.nodes) { node in
TreeNodeView(
node: node, formId: formId,
widgetId: widget.id, depth: 0
)
}
}
}

View file

@ -0,0 +1,17 @@
//
// KDChatTests.swift
// KDChatTests
//
// Created by Blake Oliver on 3/21/26.
//
import Testing
@testable import KDChat
struct KDChatTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}
}

View file

@ -0,0 +1,41 @@
//
// KDChatUITests.swift
// KDChatUITests
//
// Created by Blake Oliver on 3/21/26.
//
import XCTest
final class KDChatUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its 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()
}
}
}

View file

@ -0,0 +1,33 @@
//
// KDChatUITestsLaunchTests.swift
// KDChatUITests
//
// Created by Blake Oliver on 3/21/26.
//
import XCTest
final class KDChatUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}