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

View file

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

View file

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

View file

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

View file

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

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)