From 857a2484edfacb47a42d5cc405e746b7f94243fa Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Tue, 16 Jun 2026 09:57:55 -0400 Subject: [PATCH] feat: init --- .gitignore | 32 ++ crushing/crushing.xcodeproj/project.pbxproj | 399 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../crushing/Assets.xcassets/Contents.json | 6 + crushing/crushing/ContentView.swift | 7 + crushing/crushing/Info.plist | 13 + crushing/crushing/Models/Message.swift | 172 ++++++++ crushing/crushing/Models/Proto.swift | 159 +++++++ crushing/crushing/Models/SSEEvent.swift | 70 +++ crushing/crushing/Models/Session.swift | 51 +++ crushing/crushing/Services/APIClient.swift | 167 ++++++++ crushing/crushing/Services/SSEClient.swift | 225 ++++++++++ crushing/crushing/ViewModels/AppState.swift | 262 ++++++++++++ crushing/crushing/Views/ChatView.swift | 156 +++++++ crushing/crushing/Views/ConnectView.swift | 57 +++ crushing/crushing/Views/MessageBubble.swift | 153 +++++++ crushing/crushing/Views/SessionListView.swift | 70 +++ crushing/crushing/crushingApp.swift | 13 + crushing/project.yml | 31 ++ 21 files changed, 2096 insertions(+) create mode 100644 .gitignore create mode 100644 crushing/crushing.xcodeproj/project.pbxproj create mode 100644 crushing/crushing.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 crushing/crushing/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 crushing/crushing/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 crushing/crushing/Assets.xcassets/Contents.json create mode 100644 crushing/crushing/ContentView.swift create mode 100644 crushing/crushing/Info.plist create mode 100644 crushing/crushing/Models/Message.swift create mode 100644 crushing/crushing/Models/Proto.swift create mode 100644 crushing/crushing/Models/SSEEvent.swift create mode 100644 crushing/crushing/Models/Session.swift create mode 100644 crushing/crushing/Services/APIClient.swift create mode 100644 crushing/crushing/Services/SSEClient.swift create mode 100644 crushing/crushing/ViewModels/AppState.swift create mode 100644 crushing/crushing/Views/ChatView.swift create mode 100644 crushing/crushing/Views/ConnectView.swift create mode 100644 crushing/crushing/Views/MessageBubble.swift create mode 100644 crushing/crushing/Views/SessionListView.swift create mode 100644 crushing/crushing/crushingApp.swift create mode 100644 crushing/project.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b117ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Xcode +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +*.xcworkspace/contents.xcworkspacedata +DerivedData/ +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Swift Package Manager +.build/ +Package.resolved + +# macOS +.DS_Store +*.swp +*~ + +# Crush server +crush.sock diff --git a/crushing/crushing.xcodeproj/project.pbxproj b/crushing/crushing.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1cb7956 --- /dev/null +++ b/crushing/crushing.xcodeproj/project.pbxproj @@ -0,0 +1,399 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 05D60A2BC4A5468262ABFB6D /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBBDEB4761F93AA6744A964 /* Message.swift */; }; + 06249B2409690A7E27BA7311 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E6991F135D01FF06C3050E /* ContentView.swift */; }; + 164B61BA764325E7B125084E /* crushingApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59A351222D68D28E6EDD86F /* crushingApp.swift */; }; + 1A78F4972FFECBB91B82703F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DE27EC2F62320F5B8A119F86 /* Assets.xcassets */; }; + 1CD5AD71FF02113AF5A1DB39 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09C7D84558096C87A4E3CB21 /* APIClient.swift */; }; + 1DF75AF89DFA5880FD4CD166 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94505B949F020799E60E696F /* Session.swift */; }; + 37AB876DE9B92CA482C5687A /* MessageBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796D52650CAB4395CD7D98C8 /* MessageBubble.swift */; }; + 4EDE03DF4317CAED958E055A /* Proto.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC4EE7AAA1A0269798545314 /* Proto.swift */; }; + 4EE03552CF630BC9036E7369 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65B30680188912801E307E0 /* AppState.swift */; }; + A0B0E4B3441F7813ED88803B /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25BDF2DA4E914622CBC1948F /* SessionListView.swift */; }; + ABEE1A9B041CA3A7F8B01D71 /* SSEEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546CC46ABCE7DB50D814EC6C /* SSEEvent.swift */; }; + BFF9761EADA77FF9CA3C1A59 /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A482C6794E1E260E0089CF /* ConnectView.swift */; }; + E74D113007A48214A11C62D7 /* SSEClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D34F3AFF399646A924DC712E /* SSEClient.swift */; }; + F96CF840FDED8537A6924B81 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE1BA1460AD29886DB43BBA /* ChatView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 09C7D84558096C87A4E3CB21 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; + 1D4F5BB4E67950843F2CA1E5 /* crushing.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = crushing.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 25BDF2DA4E914622CBC1948F /* SessionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListView.swift; sourceTree = ""; }; + 546CC46ABCE7DB50D814EC6C /* SSEEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSEEvent.swift; sourceTree = ""; }; + 59E6991F135D01FF06C3050E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 70B6D44C1FDF504EEFD9EDA7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 796D52650CAB4395CD7D98C8 /* MessageBubble.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBubble.swift; sourceTree = ""; }; + 94505B949F020799E60E696F /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; + C7A482C6794E1E260E0089CF /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = ""; }; + CEE1BA1460AD29886DB43BBA /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + D34F3AFF399646A924DC712E /* SSEClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSEClient.swift; sourceTree = ""; }; + D59A351222D68D28E6EDD86F /* crushingApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = crushingApp.swift; sourceTree = ""; }; + DE27EC2F62320F5B8A119F86 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + E65B30680188912801E307E0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + EC4EE7AAA1A0269798545314 /* Proto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Proto.swift; sourceTree = ""; }; + ECBBDEB4761F93AA6744A964 /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 0AF55375048A5BD531C89F01 = { + isa = PBXGroup; + children = ( + 5A0D6E76BC59E444A36F0A44 /* crushing */, + 3536B0231A750D09C0EC4A24 /* Products */, + ); + sourceTree = ""; + }; + 29F7B271CF40FFF868E38E37 /* Services */ = { + isa = PBXGroup; + children = ( + 09C7D84558096C87A4E3CB21 /* APIClient.swift */, + D34F3AFF399646A924DC712E /* SSEClient.swift */, + ); + path = Services; + sourceTree = ""; + }; + 314A28C3972CF07972F81673 /* ViewModels */ = { + isa = PBXGroup; + children = ( + E65B30680188912801E307E0 /* AppState.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + 3536B0231A750D09C0EC4A24 /* Products */ = { + isa = PBXGroup; + children = ( + 1D4F5BB4E67950843F2CA1E5 /* crushing.app */, + ); + name = Products; + sourceTree = ""; + }; + 5A0D6E76BC59E444A36F0A44 /* crushing */ = { + isa = PBXGroup; + children = ( + DE27EC2F62320F5B8A119F86 /* Assets.xcassets */, + 59E6991F135D01FF06C3050E /* ContentView.swift */, + D59A351222D68D28E6EDD86F /* crushingApp.swift */, + 70B6D44C1FDF504EEFD9EDA7 /* Info.plist */, + AB73F1BEF66399789AC1D4E7 /* Models */, + 29F7B271CF40FFF868E38E37 /* Services */, + 314A28C3972CF07972F81673 /* ViewModels */, + EC0047C8973F59FEDF17BCFD /* Views */, + ); + path = crushing; + sourceTree = ""; + }; + AB73F1BEF66399789AC1D4E7 /* Models */ = { + isa = PBXGroup; + children = ( + ECBBDEB4761F93AA6744A964 /* Message.swift */, + EC4EE7AAA1A0269798545314 /* Proto.swift */, + 94505B949F020799E60E696F /* Session.swift */, + 546CC46ABCE7DB50D814EC6C /* SSEEvent.swift */, + ); + path = Models; + sourceTree = ""; + }; + EC0047C8973F59FEDF17BCFD /* Views */ = { + isa = PBXGroup; + children = ( + CEE1BA1460AD29886DB43BBA /* ChatView.swift */, + C7A482C6794E1E260E0089CF /* ConnectView.swift */, + 796D52650CAB4395CD7D98C8 /* MessageBubble.swift */, + 25BDF2DA4E914622CBC1948F /* SessionListView.swift */, + ); + path = Views; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 75250775F3D4AE811CAC2571 /* crushing */ = { + isa = PBXNativeTarget; + buildConfigurationList = 272CB25A068D9F7FD3B9671E /* Build configuration list for PBXNativeTarget "crushing" */; + buildPhases = ( + F2E371C23D9E5CE8DD109C72 /* Sources */, + D9EF9E58F8DC2DB9F5B4AA96 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = crushing; + packageProductDependencies = ( + ); + productName = crushing; + productReference = 1D4F5BB4E67950843F2CA1E5 /* crushing.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 8EBC8DF20A50A3002E9EE71C /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1600; + TargetAttributes = { + 75250775F3D4AE811CAC2571 = { + DevelopmentTeam = M67B42LX8D; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 6434556989E676E904B13B86 /* Build configuration list for PBXProject "crushing" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 0AF55375048A5BD531C89F01; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 3536B0231A750D09C0EC4A24 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 75250775F3D4AE811CAC2571 /* crushing */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + D9EF9E58F8DC2DB9F5B4AA96 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A78F4972FFECBB91B82703F /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F2E371C23D9E5CE8DD109C72 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1CD5AD71FF02113AF5A1DB39 /* APIClient.swift in Sources */, + 4EE03552CF630BC9036E7369 /* AppState.swift in Sources */, + F96CF840FDED8537A6924B81 /* ChatView.swift in Sources */, + BFF9761EADA77FF9CA3C1A59 /* ConnectView.swift in Sources */, + 06249B2409690A7E27BA7311 /* ContentView.swift in Sources */, + 05D60A2BC4A5468262ABFB6D /* Message.swift in Sources */, + 37AB876DE9B92CA482C5687A /* MessageBubble.swift in Sources */, + 4EDE03DF4317CAED958E055A /* Proto.swift in Sources */, + E74D113007A48214A11C62D7 /* SSEClient.swift in Sources */, + ABEE1A9B041CA3A7F8B01D71 /* SSEEvent.swift in Sources */, + 1DF75AF89DFA5880FD4CD166 /* Session.swift in Sources */, + A0B0E4B3441F7813ED88803B /* SessionListView.swift in Sources */, + 164B61BA764325E7B125084E /* crushingApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 15DD4EBE1FDB07FED677C405 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + 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.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 7544F6C6F5C8B0FB0EE24D1D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = M67B42LX8D; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = crushing/Info.plist; + INFOPLIST_GENERATION_MODE = GeneratedFile; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = sh.dunkirk.crushing; + SDKROOT = iphoneos; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + CF6F86576C94A3BD97CE311E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = M67B42LX8D; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = crushing/Info.plist; + INFOPLIST_GENERATION_MODE = GeneratedFile; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = sh.dunkirk.crushing; + SDKROOT = iphoneos; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F4A2995A69F288F9688427A6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 272CB25A068D9F7FD3B9671E /* Build configuration list for PBXNativeTarget "crushing" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CF6F86576C94A3BD97CE311E /* Debug */, + 7544F6C6F5C8B0FB0EE24D1D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 6434556989E676E904B13B86 /* Build configuration list for PBXProject "crushing" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 15DD4EBE1FDB07FED677C405 /* Debug */, + F4A2995A69F288F9688427A6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 8EBC8DF20A50A3002E9EE71C /* Project object */; +} diff --git a/crushing/crushing.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/crushing/crushing.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/crushing/crushing.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/crushing/crushing/Assets.xcassets/AccentColor.colorset/Contents.json b/crushing/crushing/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/crushing/crushing/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/crushing/crushing/Assets.xcassets/AppIcon.appiconset/Contents.json b/crushing/crushing/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/crushing/crushing/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/crushing/crushing/Assets.xcassets/Contents.json b/crushing/crushing/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/crushing/crushing/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/crushing/crushing/ContentView.swift b/crushing/crushing/ContentView.swift new file mode 100644 index 0000000..ad1f1b9 --- /dev/null +++ b/crushing/crushing/ContentView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct ContentView: View { + var body: some View { + Text("hello") + } +} diff --git a/crushing/crushing/Info.plist b/crushing/crushing/Info.plist new file mode 100644 index 0000000..6295856 --- /dev/null +++ b/crushing/crushing/Info.plist @@ -0,0 +1,13 @@ + + + + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSAllowsArbitraryLoads + + + + diff --git a/crushing/crushing/Models/Message.swift b/crushing/crushing/Models/Message.swift new file mode 100644 index 0000000..42216c0 --- /dev/null +++ b/crushing/crushing/Models/Message.swift @@ -0,0 +1,172 @@ +import Foundation + +// MARK: - Message Role + +enum MessageRole: String, Codable { + case assistant + case user + case system + case tool +} + +// MARK: - Finish Reason + +enum FinishReason: String, Codable { + case endTurn = "end_turn" + case maxTokens = "max_tokens" + case toolUse = "tool_use" + case canceled + case error + case unknown +} + +// MARK: - Content Parts (polymorphic) + +enum ContentPart: Codable, Identifiable { + case text(TextContent) + case reasoning(ReasoningContent) + case toolCall(ToolCall) + case toolResult(ToolResult) + case finish(Finish) + case imageURL(ImageURLContent) + + var id: String { + switch self { + case .text(let c): return "text-\(c.text.hashValue)" + case .reasoning(let c): return "reasoning-\(c.thinking.hashValue)" + case .toolCall(let c): return "toolcall-\(c.id)" + case .toolResult(let c): return "toolresult-\(c.toolCallID)" + case .finish(let c): return "finish-\(c.time)" + case .imageURL(let c): return "image-\(c.url.hashValue)" + } + } + + // Custom decoding: try each variant in order + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let v = try? container.decode(ToolCall.self), v.name != nil { + self = .toolCall(v); return + } + if let v = try? container.decode(ToolResult.self), v.toolCallID != nil { + self = .toolResult(v); return + } + if let v = try? container.decode(Finish.self), v.reason != nil { + self = .finish(v); return + } + if let v = try? container.decode(ReasoningContent.self), v.thinking != nil { + self = .reasoning(v); return + } + if let v = try? container.decode(ImageURLContent.self), v.url != nil { + self = .imageURL(v); return + } + if let v = try? container.decode(TextContent.self) { + self = .text(v); return + } + + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unable to decode ContentPart" + ) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .text(let v): try container.encode(v) + case .reasoning(let v): try container.encode(v) + case .toolCall(let v): try container.encode(v) + case .toolResult(let v): try container.encode(v) + case .finish(let v): try container.encode(v) + case .imageURL(let v): try container.encode(v) + } + } +} + +struct TextContent: Codable, Hashable { + let text: String +} + +struct ReasoningContent: Codable, Hashable { + let thinking: String? + let signature: String? + let startedAt: Int64? + let finishedAt: Int64? + + enum CodingKeys: String, CodingKey { + case thinking, signature + case startedAt = "started_at" + case finishedAt = "finished_at" + } +} + +struct ImageURLContent: Codable, Hashable { + let url: String? + let detail: String? +} + +struct ToolCall: Codable, Hashable { + let id: String + let name: String? + let input: String? + let type: String? + let finished: Bool? +} + +struct ToolResult: Codable, Hashable { + let toolCallID: String? + let name: String? + let content: String? + let data: String? + let mimeType: String? + let metadata: String? + let isError: Bool? + + enum CodingKeys: String, CodingKey { + case toolCallID = "tool_call_id" + case name, content, data + case mimeType = "mime_type" + case metadata + case isError = "is_error" + } +} + +struct Finish: Codable, Hashable { + let reason: FinishReason? + let time: Int64? + let message: String? + let details: String? +} + +// MARK: - Message + +struct Message: Codable, Identifiable { + let id: String + let role: MessageRole + let sessionId: String + let parts: [ContentPart] + let model: String + let provider: String + let createdAt: Int64 + let updatedAt: Int64 + + enum CodingKeys: String, CodingKey { + case id, role, parts, model, provider + case sessionId = "session_id" + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + /// Convenience: extract plain text from parts + var textContent: String { + parts.compactMap { part -> String? in + if case .text(let t) = part { return t.text } + return nil + }.joined(separator: "\n") + } + + /// Convenience: check if message contains a finish part + var isFinished: Bool { + parts.contains { if case .finish = $0 { return true }; return false } + } +} diff --git a/crushing/crushing/Models/Proto.swift b/crushing/crushing/Models/Proto.swift new file mode 100644 index 0000000..16fcdf6 --- /dev/null +++ b/crushing/crushing/Models/Proto.swift @@ -0,0 +1,159 @@ +import Foundation + +// MARK: - Permission Types + +struct PermissionRequest: Codable, Identifiable { + let id: String + let sessionId: String + let toolCallId: String + let toolName: String + let description: String + let action: String? + let params: AnyCodable? + let path: String? + + enum CodingKeys: String, CodingKey { + case id, description, action, params, path + case sessionId = "session_id" + case toolCallId = "tool_call_id" + case toolName = "tool_name" + } +} + +struct PermissionNotification: Codable { + let toolCallId: String + let granted: Bool + let denied: Bool + + enum CodingKeys: String, CodingKey { + case toolCallId = "tool_call_id" + case granted, denied + } +} + +enum PermissionAction: String, Codable { + case allow + case allowSession = "allow_session" + case deny +} + +struct PermissionGrant: Codable { + let permission: PermissionRequest + let action: PermissionAction +} + +struct PermissionGrantResponse: Codable { + let resolved: Bool +} + +// MARK: - Agent Types + +struct AgentMessage: Codable { + let sessionId: String + let runId: String? + let prompt: String + let attachments: [Attachment]? + + enum CodingKeys: String, CodingKey { + case prompt, attachments + case sessionId = "session_id" + case runId = "run_id" + } +} + +struct Attachment: Codable { + let name: String + let content: String + let mimeType: String? + + enum CodingKeys: String, CodingKey { + case name, content + case mimeType = "mime_type" + } +} + +struct RunComplete: Codable { + let sessionId: String + let runId: String? + let messageId: String + let text: String? + let error: String? + let cancelled: Bool? + + enum CodingKeys: String, CodingKey { + case text, error, cancelled + case sessionId = "session_id" + case runId = "run_id" + case messageId = "message_id" + } +} + +struct AgentEvent: Codable { + let type: String + let sessionId: String? + let sessionTitle: String? + let runId: String? + let error: String? + let progress: String? + let done: Bool? + + enum CodingKeys: String, CodingKey { + case type, error, progress, done + case sessionId = "session_id" + case sessionTitle = "session_title" + case runId = "run_id" + } +} + +// MARK: - Workspace + +struct Workspace: Codable, Identifiable { + let id: String + let path: String + let yolo: Bool? + let debug: Bool? + let version: String? + let clientId: String? + + enum CodingKeys: String, CodingKey { + case id, path, yolo, debug, version + case clientId = "client_id" + } +} + +// MARK: - Version + +struct VersionInfo: Codable { + let version: String + let commit: String? +} + +// MARK: - AnyCodable helper for untyped JSON fields + +struct AnyCodable: Codable { + let value: Any + + init(_ value: Any) { self.value = value } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let v = try? container.decode(Bool.self) { value = v } + else if let v = try? container.decode(Int.self) { value = v } + else if let v = try? container.decode(Double.self) { value = v } + else if let v = try? container.decode(String.self) { value = v } + else if let v = try? container.decode([AnyCodable].self) { value = v.map(\.value) } + else if let v = try? container.decode([String: AnyCodable].self) { value = v.mapValues(\.value) } + else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type") } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case let v as Bool: try container.encode(v) + case let v as Int: try container.encode(v) + case let v as Double: try container.encode(v) + case let v as String: try container.encode(v) + default: try container.encodeNil() + } + } +} diff --git a/crushing/crushing/Models/SSEEvent.swift b/crushing/crushing/Models/SSEEvent.swift new file mode 100644 index 0000000..41546c7 --- /dev/null +++ b/crushing/crushing/Models/SSEEvent.swift @@ -0,0 +1,70 @@ +import Foundation + +// MARK: - SSE Event Envelope + +/// The outer SSE payload. `type` discriminates the event kind, +/// `payload` is a nested Event with its own type + typed payload. +struct SSEEnvelope: Codable { + let type: String + let payload: JSONAny + + /// Decode the inner Event for a known payload type. + func decodePayload(as _: T.Type) -> T? { + guard let data = try? JSONSerialization.data(withJSONObject: payload.value) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } +} + +/// Inner event wrapper: every SSE payload contains {"type": "created|updated|deleted", "payload": {...}} +struct TypedEvent: Decodable { + let type: String // "created", "updated", "deleted" + let payload: T +} + +// MARK: - Payload Type Constants + +enum PayloadType { + static let message = "message" + static let session = "session" + static let permissionRequest = "permission_request" + static let permissionNotification = "permission_notification" + static let runComplete = "run_complete" + static let agentEvent = "agent_event" + static let configChanged = "config_changed" + static let lspEvent = "lsp_event" + static let mcpEvent = "mcp_event" + static let skillsEvent = "skills_event" + static let file = "file" +} + +// MARK: - JSONAny helper + +struct JSONAny: Codable { + let value: Any + + init(_ value: Any) { self.value = value } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let v = try? container.decode(Bool.self) { value = v } + else if let v = try? container.decode(Int.self) { value = v } + else if let v = try? container.decode(Double.self) { value = v } + else if let v = try? container.decode(String.self) { value = v } + else if let v = try? container.decode([JSONAny].self) { value = v.map(\.value) } + else if let v = try? container.decode([String: JSONAny].self) { value = v.mapValues(\.value) } + else if container.decodeNil() { value = NSNull() } + else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON") } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case let v as Bool: try container.encode(v) + case let v as Int: try container.encode(v) + case let v as Double: try container.encode(v) + case let v as String: try container.encode(v) + case is NSNull: try container.encodeNil() + default: try container.encodeNil() + } + } +} diff --git a/crushing/crushing/Models/Session.swift b/crushing/crushing/Models/Session.swift new file mode 100644 index 0000000..4e84c87 --- /dev/null +++ b/crushing/crushing/Models/Session.swift @@ -0,0 +1,51 @@ +import Foundation + +struct Session: Codable, Identifiable { + let id: String + let parentSessionId: String? + let title: String + let messageCount: Int64 + let promptTokens: Int64 + let completionTokens: Int64 + let summaryMessageId: String? + let cost: Double + let todos: [Todo]? + let createdAt: Int64 + let updatedAt: Int64 + var isBusy: Bool + var attachedClients: Int? + + enum CodingKeys: String, CodingKey { + case id, title, cost, todos + case parentSessionId = "parent_session_id" + case messageCount = "message_count" + case promptTokens = "prompt_tokens" + case completionTokens = "completion_tokens" + case summaryMessageId = "summary_message_id" + case createdAt = "created_at" + case updatedAt = "updated_at" + case isBusy = "is_busy" + case attachedClients = "attached_clients" + } + + var createdDate: Date { + Date(timeIntervalSince1970: TimeInterval(createdAt)) + } + + var updatedDate: Date { + Date(timeIntervalSince1970: TimeInterval(updatedAt)) + } +} + +struct Todo: Codable, Identifiable { + let content: String + let status: String + let activeForm: String + + enum CodingKeys: String, CodingKey { + case content, status + case activeForm = "active_form" + } + + var id: String { content } +} diff --git a/crushing/crushing/Services/APIClient.swift b/crushing/crushing/Services/APIClient.swift new file mode 100644 index 0000000..93300e8 --- /dev/null +++ b/crushing/crushing/Services/APIClient.swift @@ -0,0 +1,167 @@ +import Foundation + +/// REST client for the Crush server API. +/// All endpoints are under /v1/. +final class APIClient { + + private let baseURL: URL + private let session: URLSession + private let decoder: JSONDecoder + + init(baseURL: URL) { + self.baseURL = baseURL + self.session = URLSession(configuration: .default) + self.decoder = JSONDecoder() + } + + // MARK: - System + + func healthCheck() async throws -> Bool { + let (_, response) = try await get(path: "v1/health") + return (response as? HTTPURLResponse)?.statusCode == 200 + } + + func version() async throws -> VersionInfo { + try await getDecoded(path: "v1/version") + } + + // MARK: - Workspaces + + func listWorkspaces() async throws -> [Workspace] { + try await getDecoded(path: "v1/workspaces") + } + + func createWorkspace(path workspacePath: String) async throws -> Workspace { + struct CreateRequest: Codable { let path: String } + return try await postDecoded( + path: "v1/workspaces", + body: CreateRequest(path: workspacePath) + ) + } + + func getWorkspace(id: String) async throws -> Workspace { + try await getDecoded(path: "v1/workspaces/\(id)") + } + + func deleteWorkspace(id: String) async throws { + _ = try await delete(path: "v1/workspaces/\(id)") + } + + // MARK: - Sessions + + func listSessions(workspaceID: String) async throws -> [Session] { + try await getDecoded(path: "v1/workspaces/\(workspaceID)/sessions") + } + + func createSession(workspaceID: String, title: String? = nil) async throws -> Session { + struct CreateRequest: Codable { let title: String? } + return try await postDecoded( + path: "v1/workspaces/\(workspaceID)/sessions", + body: CreateRequest(title: title) + ) + } + + func getSession(workspaceID: String, sessionID: String) async throws -> Session { + try await getDecoded(path: "v1/workspaces/\(workspaceID)/sessions/\(sessionID)") + } + + func deleteSession(workspaceID: String, sessionID: String) async throws { + _ = try await delete(path: "v1/workspaces/\(workspaceID)/sessions/\(sessionID)") + } + + func getSessionMessages(workspaceID: String, sessionID: String) async throws -> [Message] { + try await getDecoded(path: "v1/workspaces/\(workspaceID)/sessions/\(sessionID)/messages") + } + + func getSessionHistory(workspaceID: String, sessionID: String) async throws -> [Message] { + try await getDecoded(path: "v1/workspaces/\(workspaceID)/sessions/\(sessionID)/history") + } + + // MARK: - Agent + + func sendMessage(workspaceID: String, message: AgentMessage) async throws { + _ = try await post(path: "v1/workspaces/\(workspaceID)/agent", body: message) + } + + func cancelAgent(workspaceID: String, sessionID: String) async throws { + _ = try await post( + path: "v1/workspaces/\(workspaceID)/agent/sessions/\(sessionID)/cancel" + ) + } + + func summarizeSession(workspaceID: String, sessionID: String) async throws { + _ = try await post( + path: "v1/workspaces/\(workspaceID)/agent/sessions/\(sessionID)/summarize" + ) + } + + func getAgentInfo(workspaceID: String) async throws -> AgentInfo { + try await getDecoded(path: "v1/workspaces/\(workspaceID)/agent") + } + + // MARK: - Permissions + + func grantPermission(workspaceID: String, grant: PermissionGrant) async throws -> PermissionGrantResponse { + try await postDecoded( + path: "v1/workspaces/\(workspaceID)/permissions/grant", + body: grant + ) + } + + func skipPermissions(workspaceID: String, skip: Bool) async throws { + struct SkipRequest: Codable { let skip: Bool } + _ = try await post( + path: "v1/workspaces/\(workspaceID)/permissions/skip", + body: SkipRequest(skip: skip) + ) + } + + // MARK: - HTTP Helpers + + private func get(path: String) async throws -> (Data, URLResponse) { + let url = baseURL.appendingPathComponent(path) + var request = URLRequest(url: url) + request.httpMethod = "GET" + return try await session.data(for: request) + } + + private func post(path: String, body: (any Encodable)? = nil) async throws -> (Data, URLResponse) { + let url = baseURL.appendingPathComponent(path) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let body { + request.httpBody = try JSONEncoder().encode(body) + } + return try await session.data(for: request) + } + + private func delete(path: String) async throws -> (Data, URLResponse) { + let url = baseURL.appendingPathComponent(path) + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + return try await session.data(for: request) + } + + private func getDecoded(path: String) async throws -> T { + let (data, _) = try await get(path: path) + return try decoder.decode(T.self, from: data) + } + + private func postDecoded(path: String, body: any Encodable) async throws -> T { + let (data, _) = try await post(path: path, body: body) + return try decoder.decode(T.self, from: data) + } +} + +// MARK: - AgentInfo (lightweight, separate from proto.AgentInfo which depends on catwalk) + +struct AgentInfo: Codable { + let isBusy: Bool + let isReady: Bool + + enum CodingKeys: String, CodingKey { + case isBusy = "is_busy" + case isReady = "is_ready" + } +} diff --git a/crushing/crushing/Services/SSEClient.swift b/crushing/crushing/Services/SSEClient.swift new file mode 100644 index 0000000..63f83c3 --- /dev/null +++ b/crushing/crushing/Services/SSEClient.swift @@ -0,0 +1,225 @@ +import Foundation + +/// Manages the SSE connection to a Crush server workspace event stream. +/// Uses URLSessionDataDelegate instead of async bytes to avoid +/// iOS 26 beta simulator crash in mach_msg dispatch. +@Observable +@MainActor +final class SSEClient { + + // MARK: - State + + var isConnected = false + var lastError: String? + + // MARK: - Event Callbacks + + var onMessage: ((TypedEvent) -> Void)? + var onSession: ((TypedEvent) -> Void)? + var onPermissionRequest: ((TypedEvent) -> Void)? + var onPermissionNotification: ((TypedEvent) -> Void)? + var onRunComplete: ((TypedEvent) -> Void)? + var onAgentEvent: ((TypedEvent) -> Void)? + var onConfigChanged: (() -> Void)? + + // MARK: - Private + + private var baseURL: URL + private var workspaceID: String + private var sessionTask: URLSessionDataTask? + private var urlSession: URLSession? + private var retryDelay: TimeInterval = 1 + private let maxRetryDelay: TimeInterval = 30 + private var shouldReconnect = true + private var buffer = "" + + init(baseURL: URL, workspaceID: String) { + self.baseURL = baseURL + self.workspaceID = workspaceID + } + + func updateConnection(baseURL: URL, workspaceID: String) { + self.baseURL = baseURL + self.workspaceID = workspaceID + } + + // MARK: - Connect / Disconnect + + func connect() { + disconnect() + shouldReconnect = true + startStream() + } + + func disconnect() { + shouldReconnect = false + sessionTask?.cancel() + sessionTask = nil + urlSession?.invalidateAndCancel() + urlSession = nil + isConnected = false + } + + // MARK: - Stream + + private func startStream() { + guard shouldReconnect else { return } + + let url = baseURL + .appendingPathComponent("v1/workspaces") + .appendingPathComponent(workspaceID) + .appendingPathComponent("events") + + var request = URLRequest(url: url) + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.timeoutInterval = .infinity + + let delegate = SSEDataDelegate(owner: self) + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + self.urlSession = session + + let task = session.dataTask(with: request) + self.sessionTask = task + task.resume() + } + + fileprivate func handleConnected() { + Task { @MainActor [weak self] in + self?.isConnected = true + self?.lastError = nil + self?.retryDelay = 1 + } + } + + fileprivate func handleData(_ data: Data) { + guard let str = String(data: data, encoding: .utf8) else { return } + buffer += str + processBuffer() + } + + fileprivate func handleError(_ error: Error?) { + guard shouldReconnect else { return } + + Task { @MainActor [weak self] in + guard let self else { return } + self.isConnected = false + self.lastError = error?.localizedDescription ?? "Connection lost" + } + + scheduleReconnect() + } + + fileprivate func handleCompleted() { + guard shouldReconnect else { return } + scheduleReconnect() + } + + private func scheduleReconnect() { + guard shouldReconnect else { return } + let delay = retryDelay + retryDelay = min(retryDelay * 2, maxRetryDelay) + DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in + Task { @MainActor [weak self] in + self?.startStream() + } + } + } + + // MARK: - Buffer Processing + + private func processBuffer() { + // SSE events are separated by \n\n + while let range = buffer.range(of: "\n\n") { + let eventBlock = String(buffer[buffer.startIndex...self) { + onMessage?(event) + } + case PayloadType.session: + if let event = envelope.decodePayload(as: TypedEvent.self) { + onSession?(event) + } + case PayloadType.permissionRequest: + if let event = envelope.decodePayload(as: TypedEvent.self) { + onPermissionRequest?(event) + } + case PayloadType.permissionNotification: + if let event = envelope.decodePayload(as: TypedEvent.self) { + onPermissionNotification?(event) + } + case PayloadType.runComplete: + if let event = envelope.decodePayload(as: TypedEvent.self) { + onRunComplete?(event) + } + case PayloadType.agentEvent: + if let event = envelope.decodePayload(as: TypedEvent.self) { + onAgentEvent?(event) + } + case PayloadType.configChanged: + onConfigChanged?() + default: + break + } + } +} + +// MARK: - URLSession Delegate + +/// Non-isolated delegate that forwards data back to the @MainActor SSEClient. +/// Kept as a separate class because URLSession delegates must not be actors. +private final class SSEDataDelegate: NSObject, URLSessionDataDelegate { + private weak var owner: SSEClient? + + init(owner: SSEClient) { + self.owner = owner + } + + nonisolated func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + if let http = response as? HTTPURLResponse, http.statusCode == 200 { + Task { @MainActor [weak owner] in + owner?.handleConnected() + } + completionHandler(.allow) + } else { + completionHandler(.cancel) + } + } + + nonisolated func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + Task { @MainActor [weak owner] in + owner?.handleData(data) + } + } + + nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + Task { @MainActor [weak owner] in + if let error { + owner?.handleError(error) + } else { + owner?.handleCompleted() + } + } + } +} diff --git a/crushing/crushing/ViewModels/AppState.swift b/crushing/crushing/ViewModels/AppState.swift new file mode 100644 index 0000000..9725b4f --- /dev/null +++ b/crushing/crushing/ViewModels/AppState.swift @@ -0,0 +1,262 @@ +import Foundation +import SwiftUI + +/// Central app state: manages connection, active workspace/session, and coordinates +/// the SSE client with the REST API client. +@Observable +@MainActor +final class AppState { + + // MARK: - Connection + + var serverURL: String = "" + var workspacePath: String = "" + var isConnected = false + var connectionError: String? + var isConnecting = false + + // MARK: - Data + + var workspace: Workspace? + var sessions: [Session] = [] + var activeSessionID: String? + var messages: [Message] = [] + var pendingPermission: PermissionRequest? + var agentBusy = false + + // MARK: - Services + + private(set) var api: APIClient? + private(set) var sse: SSEClient? + + // MARK: - Init + + init() { + // Defer UserDefaults reads to avoid blocking scene creation + Task { @MainActor [weak self] in + self?.serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? "" + self?.workspacePath = UserDefaults.standard.string(forKey: "workspacePath") ?? "" + } + } + + // MARK: - Connect + + func connect() async { + guard !serverURL.isEmpty else { + connectionError = "Server URL is required" + return + } + + isConnecting = true + connectionError = nil + + let baseURL: URL + do { + baseURL = try parseServerURL(serverURL) + } catch { + connectionError = "Invalid URL: \(error.localizedDescription)" + isConnecting = false + return + } + + let client = APIClient(baseURL: baseURL) + + // Health check + do { + let ok = try await client.healthCheck() + guard ok else { + connectionError = "Health check failed" + isConnecting = false + return + } + } catch { + connectionError = "Cannot reach server: \(error.localizedDescription)" + isConnecting = false + return + } + + self.api = client + + // Create or find workspace + do { + let ws = try await client.createWorkspace(path: workspacePath.isEmpty ? "." : workspacePath) + self.workspace = ws + + // Persist settings + UserDefaults.standard.set(serverURL, forKey: "serverURL") + UserDefaults.standard.set(workspacePath, forKey: "workspacePath") + + // Start SSE + let sseClient = SSEClient(baseURL: baseURL, workspaceID: ws.id) + setupSSECallbacks(sseClient) + sseClient.connect() + self.sse = sseClient + + // Load sessions + let loaded = try await client.listSessions(workspaceID: ws.id) + self.sessions = loaded.sorted { $0.updatedDate > $1.updatedDate } + + if let first = sessions.first { + activeSessionID = first.id + await loadMessages(for: first.id) + } + + isConnected = true + } catch { + connectionError = "Setup failed: \(error.localizedDescription)" + } + + isConnecting = false + } + + func disconnect() { + sse?.disconnect() + sse = nil + api = nil + workspace = nil + sessions = [] + messages = [] + activeSessionID = nil + pendingPermission = nil + isConnected = false + } + + // MARK: - Session Management + + func createNewSession() async { + guard let api, let ws = workspace else { return } + do { + let session = try await api.createSession(workspaceID: ws.id) + sessions.insert(session, at: 0) + activeSessionID = session.id + messages = [] + } catch { + connectionError = "Failed to create session: \(error.localizedDescription)" + } + } + + func selectSession(_ id: String) async { + activeSessionID = id + await loadMessages(for: id) + } + + func loadMessages(for sessionID: String) async { + guard let api, let ws = workspace else { return } + do { + messages = try await api.getSessionMessages(workspaceID: ws.id, sessionID: sessionID) + } catch { + // Non-fatal + } + } + + // MARK: - Send Message + + func sendPrompt(_ text: String) async { + guard let api, let ws = workspace, let sessionID = activeSessionID else { return } + let runID = UUID().uuidString + let msg = AgentMessage( + sessionId: sessionID, + runId: runID, + prompt: text, + attachments: nil + ) + agentBusy = true + do { + try await api.sendMessage(workspaceID: ws.id, message: msg) + } catch { + connectionError = "Send failed: \(error.localizedDescription)" + agentBusy = false + } + } + + func cancelAgent() async { + guard let api, let ws = workspace, let sessionID = activeSessionID else { return } + do { + try await api.cancelAgent(workspaceID: ws.id, sessionID: sessionID) + } catch { + connectionError = "Cancel failed: \(error.localizedDescription)" + } + } + + // MARK: - Permissions + + func respondToPermission(action: PermissionAction) async { + guard let api, let ws = workspace, let perm = pendingPermission else { return } + let grant = PermissionGrant(permission: perm, action: action) + do { + _ = try await api.grantPermission(workspaceID: ws.id, grant: grant) + } catch { + connectionError = "Permission response failed: \(error.localizedDescription)" + } + pendingPermission = nil + } + + // MARK: - SSE Callbacks + + private func setupSSECallbacks(_ sse: SSEClient) { + sse.onMessage = { [weak self] event in + Task { @MainActor [weak self] in + guard let self else { return } + switch event.type { + case "created": + self.messages.append(event.payload) + case "updated": + if let idx = self.messages.firstIndex(where: { $0.id == event.payload.id }) { + self.messages[idx] = event.payload + } + case "deleted": + self.messages.removeAll { $0.id == event.payload.id } + default: break + } + } + } + + sse.onSession = { [weak self] event in + Task { @MainActor [weak self] in + guard let self else { return } + switch event.type { + case "created": + self.sessions.insert(event.payload, at: 0) + case "updated": + if let idx = self.sessions.firstIndex(where: { $0.id == event.payload.id }) { + self.sessions[idx] = event.payload + } + case "deleted": + self.sessions.removeAll { $0.id == event.payload.id } + default: break + } + } + } + + sse.onPermissionRequest = { [weak self] event in + Task { @MainActor [weak self] in + self?.pendingPermission = event.payload + } + } + + sse.onRunComplete = { [weak self] event in + Task { @MainActor [weak self] in + self?.agentBusy = false + } + } + + sse.onAgentEvent = { [weak self] event in + Task { @MainActor [weak self] in + if event.payload.type == "error" { + self?.connectionError = event.payload.error + } + } + } + } + + // MARK: - Helpers + + private func parseServerURL(_ raw: String) throws -> URL { + var str = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !str.contains("://") { str = "http://" + str } + guard let url = URL(string: str) else { + throw URLError(.badURL) + } + return url + } +} \ No newline at end of file diff --git a/crushing/crushing/Views/ChatView.swift b/crushing/crushing/Views/ChatView.swift new file mode 100644 index 0000000..e7bdfd4 --- /dev/null +++ b/crushing/crushing/Views/ChatView.swift @@ -0,0 +1,156 @@ +import SwiftUI + +struct ChatView: View { + @Environment(AppState.self) private var appState + @State private var inputText = "" + @FocusState private var inputFocused: Bool + + var body: some View { + VStack(spacing: 0) { + messageList + + Divider() + + if let perm = appState.pendingPermission { + permissionBanner(perm) + } + + inputBar + } + .navigationTitle(appState.sessions.first(where: { $0.id == appState.activeSessionID })?.title ?? "Chat") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button("New Session") { + Task { await appState.createNewSession() } + } + Button("Disconnect", role: .destructive) { + appState.disconnect() + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + } + + // MARK: - Message List + + private var messageList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(appState.messages) { message in + MessageBubble(message: message) + .id(message.id) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } + .onChange(of: appState.messages.count) { _, _ in + if let last = appState.messages.last { + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + } + } + + // MARK: - Permission Banner + + private func permissionBanner(_ perm: PermissionRequest) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "shield.fill") + .foregroundStyle(.orange) + Text(perm.toolName) + .font(.headline) + .fontWeight(.semibold) + } + + Text(perm.description) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + Button("Allow") { + Task { await appState.respondToPermission(action: .allow) } + } + .buttonStyle(.borderedProminent) + .tint(.green) + .controlSize(.small) + + Button("Deny") { + Task { await appState.respondToPermission(action: .deny) } + } + .buttonStyle(.bordered) + .tint(.red) + .controlSize(.small) + + Button("Allow for Session") { + Task { await appState.respondToPermission(action: .allowSession) } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(12) + .background(Color.orange.opacity(0.1)) + .cornerRadius(10) + .padding(.horizontal) + .padding(.vertical, 4) + } + + // MARK: - Input Bar + + private var inputBar: some View { + HStack(alignment: .bottom, spacing: 8) { + TextField("Message...", text: $inputText, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...6) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.systemGray6)) + .cornerRadius(20) + .focused($inputFocused) + + if appState.agentBusy { + Button { + Task { await appState.cancelAgent() } + } label: { + Image(systemName: "stop.fill") + .foregroundStyle(.red) + .frame(width: 36, height: 36) + } + } else { + Button { + send() + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.title2) + .foregroundStyle(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : Color.accentColor) + } + .disabled(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } + + private func send() { + let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + inputText = "" + Task { await appState.sendPrompt(text) } + } +} + +#Preview { + NavigationStack { + ChatView() + .environment(AppState()) + } +} diff --git a/crushing/crushing/Views/ConnectView.swift b/crushing/crushing/Views/ConnectView.swift new file mode 100644 index 0000000..48f5b30 --- /dev/null +++ b/crushing/crushing/Views/ConnectView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct ConnectView: View { + @Environment(AppState.self) private var appState + + var body: some View { + @Bindable var appState = appState + + NavigationStack { + Form { + Section("Server") { + TextField("Host:port", text: $appState.serverURL) + .textContentType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .keyboardType(.URL) + } + + Section("Workspace") { + TextField("Project path (e.g. /Users/you/code/project)", text: $appState.workspacePath) + .autocapitalization(.none) + .disableAutocorrection(true) + } + + if let error = appState.connectionError { + Section { + Text(error) + .foregroundStyle(.red) + .font(.caption) + } + } + + Section { + Button { + Task { await appState.connect() } + } label: { + HStack { + if appState.isConnecting { + ProgressView() + .controlSize(.small) + } + Text(appState.isConnecting ? "Connecting..." : "Connect") + .frame(maxWidth: .infinity) + } + } + .disabled(appState.isConnecting || appState.serverURL.isEmpty) + } + } + .navigationTitle("Crush") + } + } +} + +#Preview { + ConnectView() + .environment(AppState()) +} diff --git a/crushing/crushing/Views/MessageBubble.swift b/crushing/crushing/Views/MessageBubble.swift new file mode 100644 index 0000000..63dd958 --- /dev/null +++ b/crushing/crushing/Views/MessageBubble.swift @@ -0,0 +1,153 @@ +import SwiftUI + +struct MessageBubble: View { + let message: Message + + var body: some View { + HStack(alignment: .top, spacing: 8) { + if message.role == .user { Spacer(minLength: 48) } + + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 4) { + // Role label + Text(message.role.rawValue.capitalized) + .font(.caption2) + .foregroundStyle(.secondary) + + // Content parts + ForEach(message.parts) { part in + contentPart(part) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(bubbleColor) + .cornerRadius(16) + + if message.role != .user { Spacer(minLength: 48) } + } + } + + @ViewBuilder + private func contentPart(_ part: ContentPart) -> some View { + switch part { + case .text(let t): + Text(t.text) + .textSelection(.enabled) + .font(.body) + + case .reasoning(let r): + DisclosureGroup("Thinking") { + Text(r.thinking ?? "") + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + .font(.caption) + + case .toolCall(let tc): + toolCallView(tc) + + case .toolResult(let tr): + toolResultView(tr) + + case .finish(let f): + if let reason = f.reason, reason != .endTurn { + HStack(spacing: 4) { + Image(systemName: "info.circle") + .font(.caption2) + Text(reason.rawValue) + .font(.caption2) + } + .foregroundStyle(.secondary) + } + + case .imageURL(let img): + AsyncImage(url: URL(string: img.url ?? "")) { image in + image.resizable().aspectRatio(contentMode: .fit) + } placeholder: { + ProgressView() + } + .frame(maxHeight: 200) + .cornerRadius(8) + } + } + + private func toolCallView(_ tc: ToolCall) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: "wrench.and.screwdriver") + .font(.caption2) + Text(tc.name ?? "tool") + .font(.caption) + .fontWeight(.medium) + if !(tc.finished ?? false) { + ProgressView() + .controlSize(.mini) + } + } + .foregroundStyle(.blue) + + if let input = tc.input, !input.isEmpty { + Text(input.prefix(300)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(5) + } + } + .padding(8) + .background(Color.blue.opacity(0.08)) + .cornerRadius(8) + } + + private func toolResultView(_ tr: ToolResult) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: tr.isError == true ? "xmark.circle" : "checkmark.circle") + .font(.caption2) + .foregroundStyle(tr.isError == true ? .red : .green) + Text(tr.name ?? "result") + .font(.caption) + .fontWeight(.medium) + } + + if let content = tr.content, !content.isEmpty { + Text(content.prefix(500)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(8) + .textSelection(.enabled) + } + } + .padding(8) + .background((tr.isError == true ? Color.red : Color.green).opacity(0.08)) + .cornerRadius(8) + } + + private var bubbleColor: Color { + switch message.role { + case .user: return Color.accentColor.opacity(0.15) + case .assistant: return Color(.systemGray6) + case .tool: return Color(.systemGray6) + case .system: return Color.yellow.opacity(0.1) + } + } +} + +#Preview { + VStack(spacing: 16) { + MessageBubble(message: Message( + id: "1", role: .user, sessionId: "s", + parts: [.text(TextContent(text: "Hello!"))], + model: "", provider: "", createdAt: 0, updatedAt: 0 + )) + MessageBubble(message: Message( + id: "2", role: .assistant, sessionId: "s", + parts: [ + .reasoning(ReasoningContent(thinking: "Let me think...", signature: nil, startedAt: nil, finishedAt: nil)), + .text(TextContent(text: "Hi there! How can I help?")), + ], + model: "", provider: "", createdAt: 0, updatedAt: 0 + )) + } + .padding() +} diff --git a/crushing/crushing/Views/SessionListView.swift b/crushing/crushing/Views/SessionListView.swift new file mode 100644 index 0000000..ff78a7e --- /dev/null +++ b/crushing/crushing/Views/SessionListView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct SessionListView: View { + @Environment(AppState.self) private var appState + + var body: some View { + List(appState.sessions) { session in + Button { + Task { await appState.selectSession(session.id) } + } label: { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(session.title.isEmpty ? "Untitled" : session.title) + .font(.headline) + .lineLimit(1) + + Spacer() + + if session.isBusy { + ProgressView() + .controlSize(.mini) + } + } + + HStack(spacing: 8) { + Text("\(session.messageCount) messages") + .font(.caption) + .foregroundStyle(.secondary) + + if session.cost > 0 { + Text(String(format: "$%.3f", session.cost)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Text(session.updatedDate, style: .relative) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 2) + } + .tint(.primary) + } + .listStyle(.plain) + .navigationTitle("Sessions") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await appState.createNewSession() } + } label: { + Image(systemName: "plus") + } + } + } + .refreshable { + guard let api = appState.api, let ws = appState.workspace else { return } + if let loaded = try? await api.listSessions(workspaceID: ws.id) { + appState.sessions = loaded.sorted { $0.updatedDate > $1.updatedDate } + } + } + } +} + +#Preview { + NavigationStack { + SessionListView() + .environment(AppState()) + } +} diff --git a/crushing/crushing/crushingApp.swift b/crushing/crushing/crushingApp.swift new file mode 100644 index 0000000..7200cca --- /dev/null +++ b/crushing/crushing/crushingApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct crushingApp: App { + @State private var appState = AppState() + + var body: some Scene { + WindowGroup { + ConnectView() + .environment(appState) + } + } +} diff --git a/crushing/project.yml b/crushing/project.yml new file mode 100644 index 0000000..97052f7 --- /dev/null +++ b/crushing/project.yml @@ -0,0 +1,31 @@ +name: crushing +options: + bundleIdPrefix: sh.dunkirk + deploymentTarget: + iOS: "26.0" + xcodeVersion: "16.0" + generateEmptyDirectories: true + +targets: + crushing: + type: application + platform: iOS + sources: + - path: crushing + excludes: + - "**/*.xcuserstate" + settings: + base: + DEVELOPMENT_TEAM: M67B42LX8D + CODE_SIGN_STYLE: Automatic + INFOPLIST_GENERATION_MODE: GeneratedFile + PRODUCT_BUNDLE_IDENTIFIER: sh.dunkirk.crushing + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + SWIFT_VERSION: "5.0" + TARGETED_DEVICE_FAMILY: "1,2" + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: true + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + GENERATE_INFOPLIST_FILE: true + INFOPLIST_FILE: crushing/Info.plist -- 2.51.2