diff --git a/CLAUDE.md b/CLAUDE.md index ead9692..1899298 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,9 @@ Each module has its own `CLAUDE.md` with specific guidance. ## Adding a New Feature Module 1. Create the module directory under `feature/` -2. Apply the `librechat.mobile.feature` convention plugin in `build.gradle.kts` — this auto-applies Koin + Compose deps +2. Apply the convention plugin in `build.gradle.kts` — this auto-applies Koin + Compose deps: + - `librechat.kmp.feature` — for KMP modules with shared iOS + Android code (most features) + - `librechat.mobile.feature` — for Android-only modules 3. Create `di/Module.kt` with a Koin `module { }` containing `viewModelOf(::YourViewModel)` definitions 4. Register the module in `LibreChatApplication.kt`'s `startKoin { modules(...) }` list 5. Use `koinViewModel()` in screen composables to inject ViewModels diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6048c38..5fd65de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,22 @@ -# Contributing to LibreChat Native KMP +# Contributing to LibreChat Mobile ## Prerequisites -| Tool | Version | -|------|---------| -| JDK | 17+ | -| Android Studio | Latest stable | -| Xcode | 15+ (iOS development) | -| Gradle | 8.11.1 (via wrapper) | -| Kotlin | 2.1.20 | +| Tool | Version | Notes | +|------|---------|-------| +| JDK | 17+ | | +| Android Studio or IntelliJ IDEA | Latest stable | Recommended IDE for all code editing (Android + iOS) | +| Xcode | 15+ | Required for iOS SDK, simulators, and `xcodebuild` CLI. IDE not needed. | +| Apple Silicon Mac | | Intel Macs not supported (no `iosSimulatorX64` target) | +| iOS Deployment Target | 16.0+ | | +| Gradle | 9.4.1 (via wrapper) | | +| Kotlin | 2.3.20 | | + +## IDE Setup + +**Use Android Studio or IntelliJ IDEA for all development** — both Android and iOS. Nearly all code is Kotlin (shared business logic, Compose Multiplatform UI, `iosMain` platform implementations). The 4 remaining Swift files are a thin hosting layer that rarely changes. + +Xcode must be installed for the iOS toolchain (SDKs, simulators, `xcodebuild`), but you never need to open the Xcode IDE. All builds and runs are done via CLI. ## Getting Started @@ -20,24 +28,43 @@ cd Librechat-Mobile ### Android +Build and install on a connected device or emulator: + ```bash ./gradlew assembleDebug +adb install app/build/outputs/apk/debug/app-debug.apk ``` -Open the project in Android Studio and run on an emulator or device. Debug APK output: `app/build/outputs/apk/debug/app-debug.apk`. +Or run directly via Android Studio's run configuration. ### iOS +Build and run on the iOS Simulator from CLI: + +```bash +# Build the app (Xcode build phases handle the shared framework automatically) +xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -derivedDataPath iosApp/build build + +# Boot simulator, install, and launch +xcrun simctl boot "iPhone 16" +xcrun simctl install booted iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app +xcrun simctl launch booted com.garfiec.librechat.ios +``` + +> **Note:** The first build takes several minutes while the Kotlin/Native toolchain downloads. + +To build only the shared KMP framework (e.g., after changing shared Kotlin code): + ```bash -# Build the shared KMP framework ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 ``` -Open `iosApp/iosApp.xcodeproj` in Xcode and run on an iOS simulator. The shared framework must be rebuilt whenever shared code changes. - ## Project Structure -The project is a Kotlin Multiplatform (KMP) app with shared business logic and platform-specific UI layers. +This is a Kotlin Multiplatform (KMP) app. Business logic and UI are shared across Android and iOS via Compose Multiplatform. ### Modules @@ -66,7 +93,9 @@ Source sets: `commonMain` = shared code, `androidMain` / `iosMain` = platform-sp ## Adding a New Feature Module 1. Create the module directory under `feature/` -2. Apply the `librechat.kmp.feature` convention plugin in `build.gradle.kts` +2. Apply the convention plugin in `build.gradle.kts`: + - `librechat.kmp.feature` — for KMP modules with shared iOS + Android code (most features) + - `librechat.mobile.feature` — for Android-only modules 3. Create `di/Module.kt` with a Koin `module { }` containing `viewModelOf(::YourViewModel)` definitions 4. Register the module in the application startup Koin configuration 5. Use `koinViewModel()` in screen composables to inject ViewModels diff --git a/README.md b/README.md index 9b8916a..40a852a 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,11 @@ Without this setting, the server's violation system may accumulate ban points ag | Tool | Version | |------|---------| | JDK | 17+ | -| Android Studio | Latest stable | -| Xcode | 15+ (iOS only) | -| Gradle | 8.11.1 (via wrapper) | -| Kotlin | 2.1.20 | +| Android Studio or IntelliJ IDEA | Latest stable (recommended IDE for all code editing) | +| Xcode | 15+ (iOS only, Apple Silicon Mac required — IDE not needed, CLI only) | +| iOS Deployment Target | 16.0+ | +| Gradle | 9.4.1 (via wrapper) | +| Kotlin | 2.3.20 | ## Building from Source @@ -87,24 +88,26 @@ For a release build: ### iOS -Build the shared KMP framework and open in Xcode: +Build and run on the iOS Simulator: ```bash -./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 -open iosApp/iosApp.xcodeproj +xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -derivedDataPath iosApp/build build ``` -Run on an iOS simulator from Xcode. The shared framework must be rebuilt whenever shared code changes. +See [CONTRIBUTING.md](CONTRIBUTING.md) for full simulator install and launch commands. ### Tech Stack - Kotlin Multiplatform (KMP) with shared business logic -- Jetpack Compose (Android) + SwiftUI (iOS) +- Jetpack Compose (Android) + Compose Multiplatform (iOS) - Koin (dependency injection) -- Ktor Client with OkHttp engine +- Ktor Client (OkHttp on Android, Darwin on iOS) - Kotlinx Serialization -- Room (cache), DataStore (preferences), EncryptedSharedPreferences (tokens) -- Kotlin 2.1.20, compileSdk 35, minSdk 26 +- Room (cache), DataStore (preferences), EncryptedSharedPreferences / Keychain (tokens) +- Kotlin 2.3.20, compileSdk 36, minSdk 26 ## Contributing diff --git a/iosApp/README.md b/iosApp/README.md index 3ae85cd..5238857 100644 --- a/iosApp/README.md +++ b/iosApp/README.md @@ -1,59 +1,61 @@ # LibreChat iOS App -Compose Multiplatform iOS client for LibreChat, sharing business logic and UI with the Android app via Kotlin Multiplatform. - -## Project Structure - -``` -iosApp/ - iosApp/ - iOSApp.swift — SwiftUI App entry point (initializes Koin DI) - RootView.swift — Auth state router (login vs chat) - ComposeView.swift — CMP UIViewController wrapper - LoginView.swift — Server URL + email/password login screen - RegisterView.swift — Account registration screen - ForgotPasswordView.swift — Password reset screen - FilesView.swift — File management screen - ChatStreamView.swift — SSE streaming display with stream controls - KoinHelper.swift — Swift-side Koin dependency resolver - ContentView.swift — Placeholder view (for Previews) - SharedFrameworkTest.swift — Compile-time smoke test for KMP + SKIE bridging - Info.plist — Bundle config (ID: com.garfiec.librechat.ios, URL scheme: librechat) -``` +Compose Multiplatform iOS client for LibreChat. The iOS app is a thin SwiftUI wrapper around the full Compose Multiplatform UI — all screens, navigation, and business logic are shared with Android via KMP. ## Architecture -- **Shared Framework**: The `:shared` Gradle module exports `core:common`, `core:model`, `core:network`, and `core:data` as a single `Shared.framework` for iOS. All feature modules and `core:ui` are included for Compose Multiplatform screen sharing. -- **SKIE**: Enhances Kotlin→Swift interop automatically: - - `sealed interface StreamEvent` → Swift exhaustive enum via `onEnum(of:)` - - `Flow` → `AsyncSequence` (e.g., SSE streaming, connectivity observer) - - `suspend fun` → `async throws` (e.g., `sdk.login()`) +The entire UI is rendered by Compose Multiplatform. SwiftUI is only used as a hosting layer: + +``` +iOSApp.swift — App entry point: initializes Koin DI, renders LibreChatComposeView +ComposeView.swift — UIViewControllerRepresentable wrapping MainViewController() (CMP root) +KoinHelper.swift — Swift-side Koin dependency resolver (for debugging / Swift-native code) +SharedFrameworkTest.swift — Compile-time smoke test for KMP + SKIE bridging +``` + +The `:shared` Gradle module exports `core:common`, `core:model`, `core:network`, and `core:data` as a single `Shared.framework`. All feature modules and `core:ui` are included for Compose Multiplatform screen sharing. + +### Key components + +- **SKIE**: Enhances Kotlin-Swift interop automatically — sealed classes become Swift enums (`onEnum(of:)`), `Flow` becomes `AsyncSequence`, `suspend fun` becomes `async throws` - **DI**: Koin is initialized in `iOSApp.init()` via `IosKoinHelperKt.startIosKoin()` -- **Crash Reporting**: `installCrashReporting()` sets up a Kotlin/Native unhandled exception hook that logs via Kermit + NSLog and raises as NSException for readable iOS crash logs. +- **Crash Reporting**: `installCrashReporting()` sets up a Kotlin/Native unhandled exception hook that logs via Kermit + NSLog and raises as NSException - **Platform Impls**: - `IosTokenDataStore` (`core/data/src/iosMain/`) — Keychain-backed token storage via Security.framework - - `ServerDataStore` (`core/data/src/commonMain/`) — DataStore-backed server URL (shared with Android) - - `IosConnectivityObserver` (`core/common/src/iosMain/`) — NWPathMonitor via C-level `nw_path_monitor_*` APIs + - `IosConnectivityObserver` (`core/common/src/iosMain/`) — NWPathMonitor via `nw_path_monitor_*` APIs - `IosSharedModule` (`shared/src/iosMain/`) — Koin module wiring Darwin Ktor engine + all platform deps ## Building ### Prerequisites -- Xcode 15.0+ with iOS 16.0+ SDK -- JDK 17 (for Gradle/Kotlin compilation) +- Apple Silicon Mac (Intel Macs are not supported — no `iosSimulatorX64` target) +- Xcode 15.0+ with iOS 16.0+ SDK (for toolchain and simulators — IDE not needed) +- JDK 17+ (for Gradle/Kotlin compilation) +- Android Studio or IntelliJ IDEA (recommended IDE for all code editing) + +### Build and Run + +```bash +# Build the app (Xcode build phases handle the shared framework automatically) +xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -derivedDataPath iosApp/build build + +# Boot simulator, install, and launch +xcrun simctl boot "iPhone 16" +xcrun simctl install booted iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app +xcrun simctl launch booted com.garfiec.librechat.ios +``` + +> **Note:** The first build takes several minutes while the Kotlin/Native toolchain downloads. + +To rebuild only the shared KMP framework (e.g., after changing shared Kotlin code): -### Build the Shared Framework ```bash ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 ``` -### Open in Xcode -```bash -open iosApp/iosApp.xcodeproj -``` - -Build with scheme `iosApp` targeting an iOS Simulator. The Gradle build phase in the Xcode project automatically builds the shared framework. - ## Info.plist Permissions The following keys are configured in `Info.plist`: @@ -63,13 +65,6 @@ The following keys are configured in `Info.plist`: - `NSPhotoLibraryUsageDescription` — Photo library access - `CADisableMinimumFrameDurationOnPhone` — 120Hz ProMotion support -## CI - -iOS builds run on `macos-14` (Apple Silicon) via `.github/workflows/ios.yml`: -- Builds the shared KMP framework -- Builds the iOS app via `xcodebuild` (scheme: `iosApp`, no signing) -- Runs KMP unit tests on the iOS simulator target - ## URL Scheme The app registers the `librechat://` URL scheme for deep linking (conversations, OAuth callbacks), matching the Android app's behavior. diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 17fc8a3..b534583 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -8,15 +8,8 @@ /* Begin PBXBuildFile section */ AAA00001 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00001; }; - AAA00002 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00002; }; - AAA00003 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00003; }; - AAA00004 /* ChatStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00004; }; AAA00005 /* KoinHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00005; }; - AAA00006 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00006; }; AAA00007 /* SharedFrameworkTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00007; }; - AAA00008 /* RegisterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00008A; }; - AAA00009 /* ForgotPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00009; }; - AAA0000A /* FilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB0000A; }; AAA0000B /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB0000B; }; AAA0000C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BBB0000C; }; AAA00010 /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BBB00010; }; @@ -50,7 +43,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Aggregate and copy Compose Multiplatform resources into the app bundle.\n# Runs the Gradle aggregation task to ensure resources from all feature\n# modules are collected, then copies them into the .app bundle where\n# the Compose resource reader expects them at runtime.\n\nif [ \"$CONFIGURATION\" = \"Debug\" ]; then\n ARCH_DIR=\"iosSimulatorArm64\"\n GRADLE_TASK=\":shared:iosSimulatorArm64AggregateResources\"\nelse\n ARCH_DIR=\"iosArm64\"\n GRADLE_TASK=\":shared:iosArm64AggregateResources\"\nfi\n\nPROJECT_ROOT=\"$SRCROOT/..\"\nSRC=\"$PROJECT_ROOT/shared/build/kotlin-multiplatform-resources/aggregated-resources/$ARCH_DIR/composeResources\"\nDST=\"$BUILT_PRODUCTS_DIR/$PRODUCT_NAME.app/compose-resources/composeResources\"\n\n# Run Gradle aggregation to ensure resources are up to date\necho \"Running $GRADLE_TASK...\"\ncd \"$PROJECT_ROOT\" && ./gradlew \"$GRADLE_TASK\" --quiet 2>&1\nGRADLE_EXIT=$?\nif [ $GRADLE_EXIT -ne 0 ]; then\n echo \"error: Gradle task $GRADLE_TASK failed with exit code $GRADLE_EXIT\"\n exit $GRADLE_EXIT\nfi\n\n# Clean stale resources before copying fresh ones\nif [ -d \"$DST\" ]; then\n rm -rf \"$DST\"\nfi\n\nif [ -d \"$SRC\" ]; then\n mkdir -p \"$DST\"\n cp -R \"$SRC/\" \"$DST/\"\n echo \"Copied Compose resources from $SRC to $DST\"\nelse\n echo \"error: Compose resources not found at $SRC after Gradle aggregation\"\n exit 1\nfi\n"; + shellScript = "# Aggregate and copy Compose Multiplatform resources into the app bundle.\n# Runs the Gradle aggregation task to ensure resources from all feature\n# modules are collected, then copies them into the .app bundle where\n# the Compose resource reader expects them at runtime.\n\nif [ \"$CONFIGURATION\" = \"Debug\" ]; then\n ARCH_DIR=\"iosSimulatorArm64\"\n GRADLE_TASK=\":shared:iosSimulatorArm64AggregateResources\"\nelse\n ARCH_DIR=\"iosArm64\"\n GRADLE_TASK=\":shared:iosArm64AggregateResources\"\nfi\n\nPROJECT_ROOT=\"$SRCROOT/..\"\nSRC=\"$PROJECT_ROOT/shared/build/kotlin-multiplatform-resources/aggregated-resources/$ARCH_DIR/composeResources\"\nDST=\"$BUILT_PRODUCTS_DIR/$PRODUCT_NAME.app/compose-resources/composeResources\"\n\n# Run Gradle aggregation to ensure resources are up to date\necho \"Running $GRADLE_TASK...\"\ncd \"$PROJECT_ROOT\" && ./gradlew \"$GRADLE_TASK\" 2>&1\nGRADLE_EXIT=$?\nif [ $GRADLE_EXIT -ne 0 ]; then\n echo \"error: Gradle task $GRADLE_TASK failed with exit code $GRADLE_EXIT\"\n exit $GRADLE_EXIT\nfi\n\n# Clean stale resources before copying fresh ones\nif [ -d \"$DST\" ]; then\n rm -rf \"$DST\"\nfi\n\nif [ -d \"$SRC\" ]; then\n mkdir -p \"$DST\"\n cp -R \"$SRC/\" \"$DST/\"\n echo \"Copied Compose resources from $SRC to $DST\"\nelse\n echo \"error: Compose resources not found at $SRC after Gradle aggregation\"\n exit 1\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -67,16 +60,9 @@ /* Begin PBXFileReference section */ BBB00001 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; - BBB00002 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; - BBB00003 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; - BBB00004 /* ChatStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStreamView.swift; sourceTree = ""; }; BBB00005 /* KoinHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinHelper.swift; sourceTree = ""; }; - BBB00006 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; BBB00007 /* SharedFrameworkTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedFrameworkTest.swift; sourceTree = ""; }; BBB00008 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - BBB00008A /* RegisterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterView.swift; sourceTree = ""; }; - BBB00009 /* ForgotPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForgotPasswordView.swift; sourceTree = ""; }; - BBB0000A /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = ""; }; BBB0000B /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; BBB0000C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; BBB00010 /* Shared.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Shared.framework; path = "../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework"; sourceTree = ""; }; @@ -108,15 +94,8 @@ isa = PBXGroup; children = ( BBB00001 /* iOSApp.swift */, - BBB00002 /* RootView.swift */, - BBB00003 /* LoginView.swift */, - BBB00004 /* ChatStreamView.swift */, BBB00005 /* KoinHelper.swift */, - BBB00006 /* ContentView.swift */, BBB00007 /* SharedFrameworkTest.swift */, - BBB00008A /* RegisterView.swift */, - BBB00009 /* ForgotPasswordView.swift */, - BBB0000A /* FilesView.swift */, BBB0000B /* ComposeView.swift */, BBB0000C /* Assets.xcassets */, BBB00008 /* Info.plist */, @@ -196,15 +175,8 @@ buildActionMask = 2147483647; files = ( AAA00001 /* iOSApp.swift in Sources */, - AAA00002 /* RootView.swift in Sources */, - AAA00003 /* LoginView.swift in Sources */, - AAA00004 /* ChatStreamView.swift in Sources */, AAA00005 /* KoinHelper.swift in Sources */, - AAA00006 /* ContentView.swift in Sources */, AAA00007 /* SharedFrameworkTest.swift in Sources */, - AAA00008 /* RegisterView.swift in Sources */, - AAA00009 /* ForgotPasswordView.swift in Sources */, - AAA0000A /* FilesView.swift in Sources */, AAA0000B /* ComposeView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -302,7 +274,7 @@ ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/../shared/build/bin/iosSimulatorArm64/releaseFramework", + "$(PROJECT_DIR)/../shared/build/bin/iosArm64/releaseFramework", ); GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = iosApp/Info.plist; diff --git a/iosApp/iosApp/ChatStreamView.swift b/iosApp/iosApp/ChatStreamView.swift deleted file mode 100644 index f7a9276..0000000 --- a/iosApp/iosApp/ChatStreamView.swift +++ /dev/null @@ -1,266 +0,0 @@ -import SwiftUI -import Shared - -struct ChatStreamView: View { - @ObservedObject var authState: AuthState - - @State private var streamOutput = "" - @State private var isStreaming = false - @State private var statusText = "Ready" - @State private var streamTask: Task? - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - // Status bar - HStack { - Circle() - .fill(isStreaming ? .green : .gray) - .frame(width: 8, height: 8) - Text(statusText) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text("Connected to \(authState.serverUrl)") - .font(.caption2) - .foregroundColor(.secondary) - .lineLimit(1) - } - .padding(.horizontal) - .padding(.vertical, 8) - .background(Color(.systemGroupedBackground)) - - // Stream output - ScrollViewReader { proxy in - ScrollView { - Text(streamOutput.isEmpty ? "No stream data yet. Tap 'Test SSE Stream' to verify streaming works." : streamOutput) - .font(.system(.body, design: .monospaced)) - .foregroundColor(streamOutput.isEmpty ? .secondary : .primary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .id("bottom") - } - .onChange(of: streamOutput) { _ in - withAnimation { - proxy.scrollTo("bottom", anchor: .bottom) - } - } - } - - Divider() - - // Controls - HStack(spacing: 12) { - Button { - testSseStream() - } label: { - Label("Test SSE Stream", systemImage: "antenna.radiowaves.left.and.right") - .font(.subheadline) - .fontWeight(.medium) - } - .buttonStyle(.borderedProminent) - .disabled(isStreaming) - - if isStreaming { - Button { - stopStream() - } label: { - Label("Stop", systemImage: "stop.fill") - .font(.subheadline) - } - .buttonStyle(.bordered) - .tint(.red) - } - - Spacer() - - Button { - streamOutput = "" - statusText = "Ready" - } label: { - Image(systemName: "trash") - } - .disabled(streamOutput.isEmpty) - } - .padding() - } - .navigationTitle("LibreChat iOS") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Menu { - Text("Signed in as \(authState.userName)") - Button("Sign Out", role: .destructive) { - signOut() - } - } label: { - Image(systemName: "person.circle") - } - } - } - } - } - - private func testSseStream() { - isStreaming = true - statusText = "Connecting..." - streamOutput += "[SSE Test] Starting stream connection...\n" - - let sdk = KoinHelper.sdk - - // For the SSE test, we use the ChatApi to start a minimal chat, - // then connect to the stream. This proves the full pipeline works. - streamTask = Task { - do { - // Start a chat request to get a streamId - // ChatRequest constructor parameters match the Kotlin/Native export - let request = ChatRequest( - text: "Hello! This is a test from the iOS app.", - conversationId: nil, - parentMessageId: "00000000-0000-0000-0000-000000000000", - endpoint: EModelEndpoint.agents, - endpointType: nil, - model: nil, - agentId: nil, - isContinued: false, - isEdited: false, - isRegenerate: false, - overrideParentMessageId: nil, - responseMessageId: nil, - temperature: nil, - topP: nil, - maxOutputTokens: nil, - maxContextTokens: nil, - system: nil, - reasoningEffort: nil, - effort: nil, - thinkingLevel: nil, - stop: nil, - tools: nil, - iconURL: nil, - greeting: nil, - spec: nil, - modelLabel: nil, - maxTokens: nil, - promptPrefix: nil, - chatGptLabel: nil, - resendFiles: nil, - imageDetail: nil, - key: nil, - extra: nil, - webSearch: nil, - files: nil, - addedConvo: nil, - ephemeralAgent: nil - ) - - await MainActor.run { - statusText = "Sending chat request..." - streamOutput += "[SSE Test] Sending chat request to agents endpoint...\n" - } - - let startResponse = try await sdk.chatApi.startChat(endpoint: "agents", request: request) - let streamPath = "api/agents/chat/stream/\(startResponse.conversationId)" - - await MainActor.run { - statusText = "Streaming..." - streamOutput += "[SSE Test] Got streamId: \(startResponse.conversationId)\n" - streamOutput += "[SSE Test] Connecting to SSE stream...\n\n" - } - - // Connect to the SSE stream — SKIE converts Flow to AsyncSequence - // Note: SKIE's Flow→AsyncSequence is non-throwing. Any flow exceptions - // are caught by the outer try/catch in SseClient.connect() and emitted - // as StreamEvent.Error events. The Kotlin flow MUST NOT throw or - // SKIE's iterator will call fatalError. - let streamingClient = KoinHelper.streamingHttpClient - let eventFlow = sdk.sseClient.connect( - client: streamingClient, - streamPath: streamPath, - resume: false, - connectivityFlow: nil - ) - - // Iterate over the AsyncSequence (SKIE-bridged Flow) - for await event in eventFlow { - if Task.isCancelled { break } - - await MainActor.run { - handleStreamEvent(event) - } - } - - await MainActor.run { - isStreaming = false - statusText = "Stream complete" - streamOutput += "\n[SSE Test] Stream ended.\n" - } - } catch { - await MainActor.run { - isStreaming = false - statusText = "Error" - streamOutput += "\n[SSE Error] \(error.localizedDescription)\n" - } - } - } - } - - private func handleStreamEvent(_ event: StreamEvent) { - // SKIE converts sealed interface to Swift enum via onEnum(of:) - switch onEnum(of: event) { - case .contentDelta(let delta): - streamOutput += delta.chunk - - case .thinkingDelta(let thinking): - streamOutput += "[thinking] \(thinking.chunk)" - - case .toolCallStart(let toolCall): - streamOutput += "\n[tool: \(toolCall.toolName)] Starting...\n" - - case .toolCallComplete(let toolCall): - streamOutput += "[tool: \(toolCall.toolCallId)] Complete\n" - - case .created(let created): - streamOutput += "[created] conversationId: \(created.conversationId)\n" - - case .retrying(let retry): - statusText = "Retrying (\(retry.attempt)/\(retry.maxAttempts))..." - streamOutput += "[retry] Attempt \(retry.attempt) of \(retry.maxAttempts)\n" - - case .error(let error): - statusText = "Error" - streamOutput += "\n[error] \(error.message)\n" - - case .final(_): - statusText = "Complete" - streamOutput += "\n[final] Stream complete.\n" - - case .sync(let sync): - streamOutput += "[sync] Received \(sync.aggregatedContent.count) content parts\n" - - case .step(let step): - streamOutput += "[step: \(step.stepType)] \(step.stepData)\n" - - case .attachmentCreated(let attachment): - streamOutput += "[attachment] \(attachment.filename)\n" - } - } - - private func stopStream() { - streamTask?.cancel() - streamTask = nil - isStreaming = false - statusText = "Stopped" - streamOutput += "\n[SSE Test] Stream cancelled by user.\n" - } - - private func signOut() { - Task { - try? await KoinHelper.sdk.tokenManager.clearTokens() - await MainActor.run { - authState.isLoggedIn = false - authState.userName = "" - } - } - } -} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift deleted file mode 100644 index f51b2b5..0000000 --- a/iosApp/iosApp/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -import SwiftUI - -/// Placeholder content view — the actual root is RootView. -/// Kept for Preview support. -struct ContentView: View { - var body: some View { - VStack(spacing: 16) { - Image(systemName: "message.fill") - .font(.system(size: 64)) - .foregroundColor(.blue) - Text("LibreChat iOS") - .font(.largeTitle) - .fontWeight(.bold) - Text("KMP + SKIE Integrated") - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/iosApp/iosApp/FilesView.swift b/iosApp/iosApp/FilesView.swift deleted file mode 100644 index c900b9d..0000000 --- a/iosApp/iosApp/FilesView.swift +++ /dev/null @@ -1,366 +0,0 @@ -import SwiftUI -import Shared - -struct FilesView: View { - @ObservedObject var authState: AuthState - - @State private var files: [FileObject] = [] - @State private var isLoading = true - @State private var isRefreshing = false - @State private var isUploading = false - @State private var errorMessage: String? - @State private var selectedFile: FileObject? - @State private var showFilePicker = false - @State private var deleteConfirmFile: FileObject? - - var body: some View { - NavigationStack { - Group { - if isLoading && files.isEmpty { - ProgressView("Loading files...") - } else if let error = errorMessage, files.isEmpty { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 48)) - .foregroundColor(.orange) - Text(error) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - Button("Retry") { loadFiles() } - .buttonStyle(.borderedProminent) - } - .padding() - } else if files.isEmpty { - VStack(spacing: 16) { - Image(systemName: "folder") - .font(.system(size: 48)) - .foregroundColor(.secondary) - Text("No Files") - .font(.title3) - .fontWeight(.medium) - Text("Upload files to get started") - .foregroundColor(.secondary) - } - } else { - fileListView - } - } - .navigationTitle("Files") - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - showFilePicker = true - } label: { - Image(systemName: "plus") - } - .disabled(isUploading) - } - } - .refreshable { - await refreshFiles() - } - .fileImporter( - isPresented: $showFilePicker, - allowedContentTypes: [.data], - allowsMultipleSelection: false - ) { result in - handleFileImport(result) - } - .sheet(item: $selectedFile) { file in - FilePreviewSheet(file: file, serverUrl: authState.serverUrl) - } - .alert("Delete File?", isPresented: .init( - get: { deleteConfirmFile != nil }, - set: { if !$0 { deleteConfirmFile = nil } } - )) { - Button("Cancel", role: .cancel) { deleteConfirmFile = nil } - Button("Delete", role: .destructive) { - if let file = deleteConfirmFile { - deleteFile(file) - deleteConfirmFile = nil - } - } - } message: { - if let file = deleteConfirmFile { - Text("Are you sure you want to delete \"\(file.filename)\"? This cannot be undone.") - } - } - } - .task { loadFiles() } - } - - private var fileListView: some View { - List { - if isUploading { - HStack(spacing: 12) { - ProgressView() - Text("Uploading...") - .foregroundColor(.secondary) - } - } - - ForEach(files, id: \.fileId) { file in - FileRow(file: file) - .contentShape(Rectangle()) - .onTapGesture { - selectedFile = file - } - .swipeActions(edge: .trailing) { - Button(role: .destructive) { - deleteConfirmFile = file - } label: { - Label("Delete", systemImage: "trash") - } - } - } - } - .listStyle(.plain) - } - - private func loadFiles() { - isLoading = true - errorMessage = nil - - Task { - do { - let fileRepo = KoinHelper.fileRepository - let result = try await fileRepo.getFiles() - - await MainActor.run { - isLoading = false - if let success = result as? ResultSuccess { - files = (success.data as? [FileObject]) ?? [] - } else if let error = result as? ResultError { - errorMessage = error.message ?? "Failed to load files" - } - } - } catch { - await MainActor.run { - isLoading = false - errorMessage = error.localizedDescription - } - } - } - } - - private func refreshFiles() async { - do { - let fileRepo = KoinHelper.fileRepository - let result = try await fileRepo.getFiles() - - await MainActor.run { - if let success = result as? ResultSuccess { - files = (success.data as? [FileObject]) ?? [] - } - } - } catch { - // Silently fail on refresh - } - } - - private func handleFileImport(_ result: Swift.Result<[URL], Error>) { - switch result { - case .success(let urls): - guard let url = urls.first else { return } - uploadFile(url) - case .failure(let error): - errorMessage = error.localizedDescription - } - } - - private func uploadFile(_ url: URL) { - isUploading = true - - Task { - do { - guard url.startAccessingSecurityScopedResource() else { - await MainActor.run { - isUploading = false - errorMessage = "Could not access file" - } - return - } - defer { url.stopAccessingSecurityScopedResource() } - - let data = try Data(contentsOf: url) - let filename = url.lastPathComponent - let mimeType = "application/octet-stream" // Simplified; could use UTType - - let fileRepo = KoinHelper.fileRepository - let bytes = KotlinByteArray(size: Int32(data.count)) - data.withUnsafeBytes { rawBuffer in - guard let baseAddress = rawBuffer.baseAddress else { return } - for i in 0.. { - if let newFile = success.data { - files.insert(newFile, at: 0) - } - } else if let error = result as? ResultError { - errorMessage = error.message ?? "Upload failed" - } - } - } catch { - await MainActor.run { - isUploading = false - errorMessage = error.localizedDescription - } - } - } - } - - private func deleteFile(_ file: FileObject) { - Task { - do { - let entry = DeleteFileEntry(fileId: file.fileId, filepath: file.filepath) - let fileRepo = KoinHelper.fileRepository - let result = try await fileRepo.deleteFiles(files: [entry]) - - await MainActor.run { - if result is ResultSuccess { - files.removeAll { $0.fileId == file.fileId } - } else if let error = result as? ResultError { - errorMessage = error.message ?? "Failed to delete" - } - } - } catch { - await MainActor.run { - errorMessage = error.localizedDescription - } - } - } - } -} - -// MARK: - File Row - -struct FileRow: View { - let file: FileObject - - var body: some View { - HStack(spacing: 12) { - Image(systemName: iconName(for: file.type)) - .font(.title2) - .foregroundColor(.secondary) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 2) { - Text(file.filename) - .lineLimit(1) - - HStack(spacing: 4) { - Text(formatSize(file.bytes)) - if let date = file.createdAt { - Text("·") - Text(date.prefix(10)) - } - } - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding(.vertical, 4) - } - - private func iconName(for type: String) -> String { - if type.hasPrefix("image/") { return "photo" } - if type.hasPrefix("video/") { return "video" } - if type.hasPrefix("audio/") { return "music.note" } - if type == "application/pdf" { return "doc.richtext" } - return "doc" - } - - private func formatSize(_ bytes: Int64) -> String { - let formatter = ByteCountFormatter() - formatter.countStyle = .file - return formatter.string(fromByteCount: bytes) - } -} - -// MARK: - File Preview Sheet - -struct FilePreviewSheet: View { - let file: FileObject - let serverUrl: String - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - VStack(spacing: 16) { - Image(systemName: iconName(for: file.type)) - .font(.system(size: 64)) - .foregroundColor(.blue) - - Text(file.filename) - .font(.title3) - .fontWeight(.medium) - .multilineTextAlignment(.center) - - VStack(alignment: .leading, spacing: 8) { - infoRow("Type", value: file.type) - infoRow("Size", value: formatSize(file.bytes)) - if let date = file.createdAt { - infoRow("Created", value: String(date.prefix(10))) - } - if let source = file.source { - infoRow("Source", value: source) - } - } - .padding() - .background(Color(.systemGroupedBackground)) - .cornerRadius(12) - - Spacer() - } - .padding() - .navigationTitle("File Details") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { dismiss() } - } - } - } - } - - private func infoRow(_ label: String, value: String) -> some View { - HStack { - Text(label) - .foregroundColor(.secondary) - Spacer() - Text(value) - .lineLimit(1) - } - } - - private func iconName(for type: String) -> String { - if type.hasPrefix("image/") { return "photo" } - if type.hasPrefix("video/") { return "video" } - if type.hasPrefix("audio/") { return "music.note" } - if type == "application/pdf" { return "doc.richtext" } - return "doc" - } - - private func formatSize(_ bytes: Int64) -> String { - let formatter = ByteCountFormatter() - formatter.countStyle = .file - return formatter.string(fromByteCount: bytes) - } -} - -// MARK: - FileObject Identifiable conformance - -extension FileObject: @retroactive Identifiable { - public var id: String { fileId } -} diff --git a/iosApp/iosApp/ForgotPasswordView.swift b/iosApp/iosApp/ForgotPasswordView.swift deleted file mode 100644 index 8537f0f..0000000 --- a/iosApp/iosApp/ForgotPasswordView.swift +++ /dev/null @@ -1,131 +0,0 @@ -import SwiftUI -import Shared - -struct ForgotPasswordView: View { - @Environment(\.dismiss) private var dismiss - - @State private var email = "" - @State private var isLoading = false - @State private var errorMessage: String? - @State private var isSent = false - - var body: some View { - ScrollView { - VStack(spacing: 24) { - Image(systemName: "lock.rotation") - .font(.system(size: 56)) - .foregroundColor(.blue) - .padding(.top, 40) - - if isSent { - successView - } else { - formView - } - } - .padding(.horizontal, 32) - } - .navigationTitle("Reset Password") - .navigationBarTitleDisplayMode(.inline) - } - - private var successView: some View { - VStack(spacing: 16) { - Image(systemName: "envelope.badge.fill") - .font(.system(size: 48)) - .foregroundColor(.green) - - Text("Check Your Email") - .font(.title3) - .fontWeight(.semibold) - - Text("If an account exists for \(email), you'll receive a password reset link.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - - Button("Back to Sign In") { - dismiss() - } - .buttonStyle(.borderedProminent) - .padding(.top, 8) - } - } - - private var formView: some View { - VStack(spacing: 20) { - Text("Forgot your password?") - .font(.title3) - .fontWeight(.semibold) - - Text("Enter your email address and we'll send you a link to reset your password.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - - VStack(alignment: .leading, spacing: 8) { - Text("Email") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - - TextField("email@example.com", text: $email) - .textFieldStyle(.roundedBorder) - .autocapitalization(.none) - .disableAutocorrection(true) - .keyboardType(.emailAddress) - } - - if let errorMessage { - Text(errorMessage) - .font(.callout) - .foregroundColor(.red) - .multilineTextAlignment(.center) - } - - Button { - requestReset() - } label: { - if isLoading { - ProgressView() - .tint(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } else { - Text("Send Reset Link") - .fontWeight(.semibold) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - } - .buttonStyle(.borderedProminent) - .disabled(isLoading || email.isEmpty) - } - } - - private func requestReset() { - isLoading = true - errorMessage = nil - - Task { - do { - let authRepo = KoinHelper.authRepository - let result = try await authRepo.requestPasswordReset(email: email) - - await MainActor.run { - isLoading = false - if result is ResultSuccess { - isSent = true - } else if let error = result as? ResultError { - errorMessage = error.message ?? "Failed to send reset email" - } - } - } catch { - await MainActor.run { - isLoading = false - errorMessage = error.localizedDescription - } - } - } - } -} diff --git a/iosApp/iosApp/LoginView.swift b/iosApp/iosApp/LoginView.swift deleted file mode 100644 index ae51956..0000000 --- a/iosApp/iosApp/LoginView.swift +++ /dev/null @@ -1,155 +0,0 @@ -import SwiftUI -import Shared - -struct LoginView: View { - @ObservedObject var authState: AuthState - - @State private var serverUrl = "" - @State private var email = "" - @State private var password = "" - @State private var isLoading = false - @State private var errorMessage: String? - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 24) { - // Logo - Image(systemName: "message.fill") - .font(.system(size: 56)) - .foregroundColor(.blue) - .padding(.top, 40) - - Text("LibreChat") - .font(.largeTitle) - .fontWeight(.bold) - - // Server URL - VStack(alignment: .leading, spacing: 8) { - Text("Server URL") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - - TextField("https://your-server.com", text: $serverUrl) - .textFieldStyle(.roundedBorder) - .autocapitalization(.none) - .disableAutocorrection(true) - .keyboardType(.URL) - } - - // Email - VStack(alignment: .leading, spacing: 8) { - Text("Email") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - - TextField("email@example.com", text: $email) - .textFieldStyle(.roundedBorder) - .autocapitalization(.none) - .disableAutocorrection(true) - .keyboardType(.emailAddress) - } - - // Password - VStack(alignment: .leading, spacing: 8) { - Text("Password") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - - SecureField("Password", text: $password) - .textFieldStyle(.roundedBorder) - } - - // Error message - if let errorMessage { - Text(errorMessage) - .font(.callout) - .foregroundColor(.red) - .multilineTextAlignment(.center) - } - - // Login button - Button { - performLogin() - } label: { - if isLoading { - ProgressView() - .tint(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } else { - Text("Sign In") - .fontWeight(.semibold) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - } - .buttonStyle(.borderedProminent) - .disabled(isLoading || serverUrl.isEmpty || email.isEmpty || password.isEmpty) - - // Forgot password link - NavigationLink(destination: ForgotPasswordView()) { - Text("Forgot Password?") - .font(.subheadline) - .foregroundColor(.blue) - } - - // Register link - HStack { - Text("Don't have an account?") - .font(.subheadline) - .foregroundColor(.secondary) - NavigationLink(destination: RegisterView(authState: authState, serverUrl: $serverUrl)) { - Text("Sign Up") - .font(.subheadline) - .fontWeight(.semibold) - } - } - } - .padding(.horizontal, 32) - } - .navigationBarTitleDisplayMode(.inline) - } - } - - private func performLogin() { - guard !serverUrl.isEmpty, !email.isEmpty, !password.isEmpty else { return } - - isLoading = true - errorMessage = nil - - // Normalize server URL - var normalizedUrl = serverUrl - if !normalizedUrl.hasPrefix("http://") && !normalizedUrl.hasPrefix("https://") { - normalizedUrl = "https://\(normalizedUrl)" - } - if normalizedUrl.hasSuffix("/") { - normalizedUrl = String(normalizedUrl.dropLast()) - } - - let sdk = KoinHelper.sdk - - Task { - do { - // Set the server URL (async — DataStore persistence) - try await KoinHelper.serverDataStore.setServerUrl(url: normalizedUrl) - - let result = try await sdk.login(email: email, password: password) - await MainActor.run { - authState.userName = result.response.user?.name ?? email - authState.serverUrl = normalizedUrl - authState.isLoggedIn = true - isLoading = false - } - } catch { - await MainActor.run { - errorMessage = error.localizedDescription - isLoading = false - } - } - } - } -} diff --git a/iosApp/iosApp/RegisterView.swift b/iosApp/iosApp/RegisterView.swift deleted file mode 100644 index 6ba24b1..0000000 --- a/iosApp/iosApp/RegisterView.swift +++ /dev/null @@ -1,183 +0,0 @@ -import SwiftUI -import Shared - -struct RegisterView: View { - @ObservedObject var authState: AuthState - @Binding var serverUrl: String - @Environment(\.dismiss) private var dismiss - - @State private var name = "" - @State private var email = "" - @State private var username = "" - @State private var password = "" - @State private var confirmPassword = "" - @State private var isLoading = false - @State private var errorMessage: String? - @State private var isRegistered = false - - var body: some View { - ScrollView { - VStack(spacing: 20) { - Text("Create Account") - .font(.title2) - .fontWeight(.bold) - .padding(.top, 20) - - if isRegistered { - registrationSuccessView - } else { - registrationFormView - } - } - .padding(.horizontal, 32) - } - .navigationTitle("Register") - .navigationBarTitleDisplayMode(.inline) - } - - private var registrationSuccessView: some View { - VStack(spacing: 16) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 56)) - .foregroundColor(.green) - - Text("Registration Successful!") - .font(.title3) - .fontWeight(.semibold) - - Text("You can now sign in with your credentials.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - - Button("Back to Sign In") { - dismiss() - } - .buttonStyle(.borderedProminent) - } - .padding(.top, 20) - } - - private var registrationFormView: some View { - VStack(spacing: 16) { - formField("Name", text: $name, keyboardType: .default) - formField("Email", text: $email, keyboardType: .emailAddress) - formField("Username", text: $username, keyboardType: .default) - - VStack(alignment: .leading, spacing: 8) { - Text("Password") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - SecureField("Password", text: $password) - .textFieldStyle(.roundedBorder) - } - - VStack(alignment: .leading, spacing: 8) { - Text("Confirm Password") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - SecureField("Confirm Password", text: $confirmPassword) - .textFieldStyle(.roundedBorder) - } - - if let errorMessage { - Text(errorMessage) - .font(.callout) - .foregroundColor(.red) - .multilineTextAlignment(.center) - } - - Button { - performRegistration() - } label: { - if isLoading { - ProgressView() - .tint(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } else { - Text("Create Account") - .fontWeight(.semibold) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - } - .buttonStyle(.borderedProminent) - .disabled(isLoading || !isFormValid) - } - } - - private func formField(_ label: String, text: Binding, keyboardType: UIKeyboardType) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(label) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - TextField(label, text: text) - .textFieldStyle(.roundedBorder) - .autocapitalization(.none) - .disableAutocorrection(true) - .keyboardType(keyboardType) - } - } - - private var isFormValid: Bool { - !name.isEmpty && !email.isEmpty && !username.isEmpty && - !password.isEmpty && !confirmPassword.isEmpty - } - - private func performRegistration() { - guard password == confirmPassword else { - errorMessage = "Passwords do not match" - return - } - guard password.count >= 8 else { - errorMessage = "Password must be at least 8 characters" - return - } - - isLoading = true - errorMessage = nil - - Task { - do { - // Ensure server URL is set - if !serverUrl.isEmpty { - var normalizedUrl = serverUrl - if !normalizedUrl.hasPrefix("http://") && !normalizedUrl.hasPrefix("https://") { - normalizedUrl = "https://\(normalizedUrl)" - } - if normalizedUrl.hasSuffix("/") { - normalizedUrl = String(normalizedUrl.dropLast()) - } - try await KoinHelper.serverDataStore.setServerUrl(url: normalizedUrl) - } - - let authRepo = KoinHelper.authRepository - let result = try await authRepo.register( - name: name, - email: email, - username: username, - password: password - ) - - await MainActor.run { - isLoading = false - // Check if registration succeeded - if result is ResultSuccess { - isRegistered = true - } else if let error = result as? ResultError { - errorMessage = error.message ?? "Registration failed" - } - } - } catch { - await MainActor.run { - isLoading = false - errorMessage = error.localizedDescription - } - } - } - } -} diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift deleted file mode 100644 index c23ae0b..0000000 --- a/iosApp/iosApp/RootView.swift +++ /dev/null @@ -1,41 +0,0 @@ -import SwiftUI -import Shared - -struct RootView: View { - @StateObject private var authState = AuthState() - - var body: some View { - Group { - if authState.isLoggedIn { - MainTabView(authState: authState) - } else { - LoginView(authState: authState) - } - } - } -} - -struct MainTabView: View { - @ObservedObject var authState: AuthState - - var body: some View { - TabView { - ChatStreamView(authState: authState) - .tabItem { - Label("Chat", systemImage: "message") - } - - FilesView(authState: authState) - .tabItem { - Label("Files", systemImage: "folder") - } - } - } -} - -@MainActor -class AuthState: ObservableObject { - @Published var isLoggedIn = false - @Published var userName: String = "" - @Published var serverUrl: String = "" -} diff --git a/shared/CLAUDE.md b/shared/CLAUDE.md new file mode 100644 index 0000000..dbf6372 --- /dev/null +++ b/shared/CLAUDE.md @@ -0,0 +1,42 @@ +# Shared Module + +KMP umbrella module that exports all core and feature modules as a single `Shared.framework` for iOS. On Android, this module is a dependency of `:app` but the framework export is iOS-only. + +## Framework Export + +`shared/build.gradle.kts` configures iOS targets (`iosArm64`, `iosSimulatorArm64`) and exports: +- `core:common`, `core:model`, `core:network`, `core:data` — exported via `api()` so iOS sees all public types +- `core:ui` + all `feature:*` modules — included via `implementation()` for Compose Multiplatform screen sharing + +The framework is static (`isStatic = true`) and named `Shared`. + +## iOS Platform Files (`src/iosMain/`) + +| File | Purpose | +|------|---------| +| `IosKoinHelper.kt` | `startIosKoin()` — called from Swift `iOSApp.init()`. Sets up Kermit logging via NSLog, installs crash reporting, starts Koin with iOS modules. | +| `IosSharedModule.kt` | Koin module wiring Darwin Ktor engine, three HttpClient instances (main, streaming, refresh), all 16 API services, SSE client, and `LibreChatSDK`. | +| `IosKoinAccessor.kt` | Swift-accessible Koin resolver. `KoinHelper.swift` calls these to get SDK, repos, etc. | +| `IosCrashReporting.kt` | Unhandled exception hook — logs via Kermit + raises NSException for readable iOS crash logs. | +| `MainViewController.kt` | `MainViewController()` — the Compose Multiplatform entry point wrapped by `ComposeView.swift`. | + +## Common Files (`src/commonMain/`) + +- `LibreChatSDK.kt` — Facade class aggregating all API services, token manager, and SSE client +- `navigation/` — Nav 3 route definitions and entry providers shared across platforms +- `app/` — Shared app-level composables (root navigation host) + +## SKIE + +The SKIE Gradle plugin is applied here. All features are enabled by default: +- Sealed classes → Swift exhaustive enums (`onEnum(of:)`) +- `Flow` → `AsyncSequence` +- `suspend fun` → `async throws` + +No explicit SKIE configuration is needed unless disabling a specific feature. + +## Adding Platform-Specific Code + +- iOS implementations go in `src/iosMain/` with `actual` declarations matching `expect` in `commonMain` +- For feature-level platform code, prefer adding `iosMain` source sets in the feature module itself rather than here +- This module should only contain app-level iOS wiring (DI bootstrap, framework entry point)