chore: fix iOS setup docs, remove dead Swift files, fix Xcode release build config
This commit is contained in:
parent
d8bdfa843b
commit
2d06b7e2f6
13 changed files with 145 additions and 1268 deletions
|
|
@ -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/<Feature>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
|
||||
|
|
|
|||
|
|
@ -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/<Feature>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
|
||||
|
|
|
|||
27
README.md
27
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T>` → `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<T>` 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.
|
||||
|
|
|
|||
|
|
@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
42
shared/CLAUDE.md
Normal 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)
|
||||
Loading…
Reference in a new issue