chore: fix iOS setup docs, remove dead Swift files, fix Xcode release build config

This commit is contained in:
Garfie 2026-04-05 16:49:23 -05:00
parent d8bdfa843b
commit 2d06b7e2f6
13 changed files with 145 additions and 1268 deletions

View file

@ -43,7 +43,9 @@ Each module has its own `CLAUDE.md` with specific guidance.
## Adding a New Feature Module ## Adding a New Feature Module
1. Create the module directory under `feature/` 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/<Feature>Module.kt` with a Koin `module { }` containing `viewModelOf(::YourViewModel)` definitions 3. Create `di/<Feature>Module.kt` with a Koin `module { }` containing `viewModelOf(::YourViewModel)` definitions
4. Register the module in `LibreChatApplication.kt`'s `startKoin { modules(...) }` list 4. Register the module in `LibreChatApplication.kt`'s `startKoin { modules(...) }` list
5. Use `koinViewModel()` in screen composables to inject ViewModels 5. Use `koinViewModel()` in screen composables to inject ViewModels

View file

@ -1,14 +1,22 @@
# Contributing to LibreChat Native KMP # Contributing to LibreChat Mobile
## Prerequisites ## Prerequisites
| Tool | Version | | Tool | Version | Notes |
|------|---------| |------|---------|-------|
| JDK | 17+ | | JDK | 17+ | |
| Android Studio | Latest stable | | Android Studio or IntelliJ IDEA | Latest stable | Recommended IDE for all code editing (Android + iOS) |
| Xcode | 15+ (iOS development) | | Xcode | 15+ | Required for iOS SDK, simulators, and `xcodebuild` CLI. IDE not needed. |
| Gradle | 8.11.1 (via wrapper) | | Apple Silicon Mac | | Intel Macs not supported (no `iosSimulatorX64` target) |
| Kotlin | 2.1.20 | | 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 ## Getting Started
@ -20,24 +28,43 @@ cd Librechat-Mobile
### Android ### Android
Build and install on a connected device or emulator:
```bash ```bash
./gradlew assembleDebug ./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 ### 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 ```bash
# Build the shared KMP framework
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 ./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 ## 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 ### Modules
@ -66,7 +93,9 @@ Source sets: `commonMain` = shared code, `androidMain` / `iosMain` = platform-sp
## Adding a New Feature Module ## Adding a New Feature Module
1. Create the module directory under `feature/` 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/<Feature>Module.kt` with a Koin `module { }` containing `viewModelOf(::YourViewModel)` definitions 3. Create `di/<Feature>Module.kt` with a Koin `module { }` containing `viewModelOf(::YourViewModel)` definitions
4. Register the module in the application startup Koin configuration 4. Register the module in the application startup Koin configuration
5. Use `koinViewModel()` in screen composables to inject ViewModels 5. Use `koinViewModel()` in screen composables to inject ViewModels

View file

@ -64,10 +64,11 @@ Without this setting, the server's violation system may accumulate ban points ag
| Tool | Version | | Tool | Version |
|------|---------| |------|---------|
| JDK | 17+ | | JDK | 17+ |
| Android Studio | Latest stable | | Android Studio or IntelliJ IDEA | Latest stable (recommended IDE for all code editing) |
| Xcode | 15+ (iOS only) | | Xcode | 15+ (iOS only, Apple Silicon Mac required — IDE not needed, CLI only) |
| Gradle | 8.11.1 (via wrapper) | | iOS Deployment Target | 16.0+ |
| Kotlin | 2.1.20 | | Gradle | 9.4.1 (via wrapper) |
| Kotlin | 2.3.20 |
## Building from Source ## Building from Source
@ -87,24 +88,26 @@ For a release build:
### iOS ### iOS
Build the shared KMP framework and open in Xcode: Build and run on the iOS Simulator:
```bash ```bash
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \
open iosApp/iosApp.xcodeproj -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 ### Tech Stack
- Kotlin Multiplatform (KMP) with shared business logic - Kotlin Multiplatform (KMP) with shared business logic
- Jetpack Compose (Android) + SwiftUI (iOS) - Jetpack Compose (Android) + Compose Multiplatform (iOS)
- Koin (dependency injection) - Koin (dependency injection)
- Ktor Client with OkHttp engine - Ktor Client (OkHttp on Android, Darwin on iOS)
- Kotlinx Serialization - Kotlinx Serialization
- Room (cache), DataStore (preferences), EncryptedSharedPreferences (tokens) - Room (cache), DataStore (preferences), EncryptedSharedPreferences / Keychain (tokens)
- Kotlin 2.1.20, compileSdk 35, minSdk 26 - Kotlin 2.3.20, compileSdk 36, minSdk 26
## Contributing ## Contributing

View file

@ -1,59 +1,61 @@
# LibreChat iOS App # LibreChat iOS App
Compose Multiplatform iOS client for LibreChat, sharing business logic and UI with the Android app via Kotlin Multiplatform. 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.
## 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)
```
## Architecture ## 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. The entire UI is rendered by Compose Multiplatform. SwiftUI is only used as a hosting layer:
- **SKIE**: Enhances Kotlin→Swift interop automatically:
- `sealed interface StreamEvent` → Swift exhaustive enum via `onEnum(of:)` ```
- `Flow<T>``AsyncSequence` (e.g., SSE streaming, connectivity observer) iOSApp.swift — App entry point: initializes Koin DI, renders LibreChatComposeView
- `suspend fun``async throws` (e.g., `sdk.login()`) 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<T>` becomes `AsyncSequence`, `suspend fun` becomes `async throws`
- **DI**: Koin is initialized in `iOSApp.init()` via `IosKoinHelperKt.startIosKoin()` - **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**: - **Platform Impls**:
- `IosTokenDataStore` (`core/data/src/iosMain/`) — Keychain-backed token storage via Security.framework - `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 `nw_path_monitor_*` APIs
- `IosConnectivityObserver` (`core/common/src/iosMain/`) — NWPathMonitor via C-level `nw_path_monitor_*` APIs
- `IosSharedModule` (`shared/src/iosMain/`) — Koin module wiring Darwin Ktor engine + all platform deps - `IosSharedModule` (`shared/src/iosMain/`) — Koin module wiring Darwin Ktor engine + all platform deps
## Building ## Building
### Prerequisites ### Prerequisites
- Xcode 15.0+ with iOS 16.0+ SDK - Apple Silicon Mac (Intel Macs are not supported — no `iosSimulatorX64` target)
- JDK 17 (for Gradle/Kotlin compilation) - 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 ```bash
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 ./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 ## Info.plist Permissions
The following keys are configured in `Info.plist`: The following keys are configured in `Info.plist`:
@ -63,13 +65,6 @@ The following keys are configured in `Info.plist`:
- `NSPhotoLibraryUsageDescription` — Photo library access - `NSPhotoLibraryUsageDescription` — Photo library access
- `CADisableMinimumFrameDurationOnPhone` — 120Hz ProMotion support - `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 ## URL Scheme
The app registers the `librechat://` URL scheme for deep linking (conversations, OAuth callbacks), matching the Android app's behavior. The app registers the `librechat://` URL scheme for deep linking (conversations, OAuth callbacks), matching the Android app's behavior.

View file

@ -8,15 +8,8 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
AAA00001 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00001; }; 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; }; 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; }; 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; }; AAA0000B /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB0000B; };
AAA0000C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BBB0000C; }; AAA0000C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BBB0000C; };
AAA00010 /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BBB00010; }; AAA00010 /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BBB00010; };
@ -50,7 +43,7 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; 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 */ /* End PBXShellScriptBuildPhase section */
@ -67,16 +60,9 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
BBB00001 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; }; BBB00001 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
BBB00002 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
BBB00003 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
BBB00004 /* ChatStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStreamView.swift; sourceTree = "<group>"; };
BBB00005 /* KoinHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinHelper.swift; sourceTree = "<group>"; }; BBB00005 /* KoinHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinHelper.swift; sourceTree = "<group>"; };
BBB00006 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
BBB00007 /* SharedFrameworkTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedFrameworkTest.swift; sourceTree = "<group>"; }; BBB00007 /* SharedFrameworkTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedFrameworkTest.swift; sourceTree = "<group>"; };
BBB00008 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; BBB00008 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
BBB00008A /* RegisterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterView.swift; sourceTree = "<group>"; };
BBB00009 /* ForgotPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForgotPasswordView.swift; sourceTree = "<group>"; };
BBB0000A /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = "<group>"; };
BBB0000B /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; }; BBB0000B /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
BBB0000C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; BBB0000C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
BBB00010 /* Shared.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Shared.framework; path = "../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework"; sourceTree = "<group>"; }; BBB00010 /* Shared.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Shared.framework; path = "../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework"; sourceTree = "<group>"; };
@ -108,15 +94,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
BBB00001 /* iOSApp.swift */, BBB00001 /* iOSApp.swift */,
BBB00002 /* RootView.swift */,
BBB00003 /* LoginView.swift */,
BBB00004 /* ChatStreamView.swift */,
BBB00005 /* KoinHelper.swift */, BBB00005 /* KoinHelper.swift */,
BBB00006 /* ContentView.swift */,
BBB00007 /* SharedFrameworkTest.swift */, BBB00007 /* SharedFrameworkTest.swift */,
BBB00008A /* RegisterView.swift */,
BBB00009 /* ForgotPasswordView.swift */,
BBB0000A /* FilesView.swift */,
BBB0000B /* ComposeView.swift */, BBB0000B /* ComposeView.swift */,
BBB0000C /* Assets.xcassets */, BBB0000C /* Assets.xcassets */,
BBB00008 /* Info.plist */, BBB00008 /* Info.plist */,
@ -196,15 +175,8 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
AAA00001 /* iOSApp.swift in Sources */, 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 */, AAA00005 /* KoinHelper.swift in Sources */,
AAA00006 /* ContentView.swift in Sources */,
AAA00007 /* SharedFrameworkTest.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 */, AAA0000B /* ComposeView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -302,7 +274,7 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/../shared/build/bin/iosSimulatorArm64/releaseFramework", "$(PROJECT_DIR)/../shared/build/bin/iosArm64/releaseFramework",
); );
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = iosApp/Info.plist; INFOPLIST_FILE = iosApp/Info.plist;

View file

@ -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<Void, Never>?
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<StreamEvent> to AsyncSequence
// Note: SKIE's FlowAsyncSequence 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 = ""
}
}
}
}

View file

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

View file

@ -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<NSArray> {
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<NSArray> {
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..<data.count {
bytes.set(index: Int32(i), value: baseAddress.advanced(by: i).load(as: Int8.self))
}
}
let result = try await fileRepo.uploadFile(
bytes: bytes,
filename: filename,
type: mimeType
)
await MainActor.run {
isUploading = false
if let success = result as? ResultSuccess<FileObject> {
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<AnyObject> {
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 }
}

View file

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

View file

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

View file

@ -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<String>, 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<AnyObject> {
isRegistered = true
} else if let error = result as? ResultError {
errorMessage = error.message ?? "Registration failed"
}
}
} catch {
await MainActor.run {
isLoading = false
errorMessage = error.localizedDescription
}
}
}
}
}

View file

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

42
shared/CLAUDE.md Normal file
View file

@ -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<T>``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)