diff --git a/examples/androidApp/build.gradle.kts b/examples/androidApp/build.gradle.kts index 8387ea1a..724db5f3 100644 --- a/examples/androidApp/build.gradle.kts +++ b/examples/androidApp/build.gradle.kts @@ -39,6 +39,8 @@ android { dependencies { implementation(projects.examples.app) + implementation(projects.library.sqlite) + implementation(projects.server) implementation(libs.androidx.activity.compose) debugImplementation(libs.compose.uiTooling) } diff --git a/examples/androidApp/src/main/kotlin/com/linroid/kdown/examples/MainActivity.kt b/examples/androidApp/src/main/kotlin/com/linroid/kdown/examples/MainActivity.kt index 8d936efe..53f2470c 100644 --- a/examples/androidApp/src/main/kotlin/com/linroid/kdown/examples/MainActivity.kt +++ b/examples/androidApp/src/main/kotlin/com/linroid/kdown/examples/MainActivity.kt @@ -4,13 +4,51 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import com.linroid.kdown.examples.backend.BackendFactory +import com.linroid.kdown.examples.backend.BackendManager +import com.linroid.kdown.examples.backend.LocalServerHandle +import com.linroid.kdown.server.KDownServer +import com.linroid.kdown.server.KDownServerConfig +import com.linroid.kdown.sqlite.DriverFactory +import com.linroid.kdown.sqlite.createSqliteTaskStore class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - App() + val backendManager = remember { + val taskStore = createSqliteTaskStore( + DriverFactory(applicationContext) + ) + BackendManager( + BackendFactory( + taskStore = taskStore, + localServerFactory = { port, apiToken, kdownApi -> + val server = KDownServer( + kdownApi, + KDownServerConfig( + port = port, + apiToken = apiToken, + corsAllowedHosts = listOf("*") + ) + ) + server.start(wait = false) + object : LocalServerHandle { + override fun stop() { + server.stop() + } + } + } + ) + ) + } + DisposableEffect(Unit) { + onDispose { backendManager.close() } + } + App(backendManager) } } } diff --git a/examples/app/build.gradle.kts b/examples/app/build.gradle.kts index dadcc201..6da2d6eb 100644 --- a/examples/app/build.gradle.kts +++ b/examples/app/build.gradle.kts @@ -34,11 +34,12 @@ kotlin { @OptIn(ExperimentalWasmDsl::class) wasmJs { browser() - binaries.executable() } sourceSets { commonMain.dependencies { + implementation(projects.library.core) + implementation(projects.library.remote) implementation(projects.library.ktor) implementation(libs.kotlinx.coroutines.core) implementation(libs.compose.runtime) @@ -53,15 +54,18 @@ kotlin { } commonTest.dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } androidMain.dependencies { implementation(libs.compose.uiToolingPreview) implementation(libs.ktor.client.okhttp) } iosMain.dependencies { + implementation(projects.library.sqlite) implementation(libs.ktor.client.darwin) } jvmMain.dependencies { + implementation(projects.library.sqlite) implementation(libs.kotlinx.coroutinesSwing) implementation(libs.ktor.client.cio) } diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/App.kt b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/App.kt index 731aefdd..18962a2b 100644 --- a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/App.kt +++ b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/App.kt @@ -18,33 +18,41 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.Computer import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilterChip import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -54,6 +62,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -63,56 +72,42 @@ import com.linroid.kdown.api.DownloadState import com.linroid.kdown.api.DownloadTask import com.linroid.kdown.api.KDownApi import com.linroid.kdown.api.SpeedLimit -import com.linroid.kdown.core.DownloadConfig -import com.linroid.kdown.core.KDown -import com.linroid.kdown.core.QueueConfig -import com.linroid.kdown.core.log.Logger -import com.linroid.kdown.engine.KtorHttpEngine +import com.linroid.kdown.examples.backend.BackendConfig +import com.linroid.kdown.examples.backend.BackendEntry +import com.linroid.kdown.remote.ConnectionState +import com.linroid.kdown.examples.backend.BackendManager +import com.linroid.kdown.examples.backend.ServerState import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import kotlinx.io.files.Path @OptIn(ExperimentalMaterial3Api::class) @Composable -fun App() { - val config = remember { - DownloadConfig( - maxConnections = 4, - retryCount = 3, - retryDelayMs = 1000, - progressUpdateIntervalMs = 200, - queueConfig = QueueConfig( - maxConcurrentDownloads = 3, - maxConnectionsPerHost = 4 - ) - ) - } - val kdown = remember { - KDown( - httpEngine = KtorHttpEngine(), - config = config, - logger = Logger.console() - ) - } +fun App(backendManager: BackendManager) { + val activeApi by backendManager.activeApi.collectAsState() + val activeBackend by backendManager.activeBackend.collectAsState() + val backends by backendManager.backends.collectAsState() + val connectionState by ( + activeBackend?.connectionState + ?: MutableStateFlow(ConnectionState.Disconnected()) + ).collectAsState() val scope = rememberCoroutineScope() - val tasks by kdown.tasks.collectAsState() + val tasks by activeApi.tasks.collectAsState() + val version by activeApi.version.collectAsState() var showAddDialog by remember { mutableStateOf(false) } + var showBackendSelector by remember { mutableStateOf(false) } + var showAddRemoteDialog by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } + var switchingBackendId by remember { mutableStateOf(null) } DisposableEffect(Unit) { - onDispose { kdown.close() } - } - - LaunchedEffect(Unit) { - kdown.loadTasks() + onDispose { backendManager.close() } } val sortedTasks = remember(tasks) { tasks.sortedByDescending { it.createdAt } } - val version by kdown.version.collectAsState() - MaterialTheme { Scaffold( topBar = { @@ -123,11 +118,22 @@ fun App() { text = "KDown", fontWeight = FontWeight.SemiBold ) - Text( - text = "v${version.backend} \u00b7 Downloader", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row( + modifier = Modifier.clickable { + showBackendSelector = true + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = "v${version.backend} \u00b7 " + + (activeBackend?.label ?: "Not connected"), + style = MaterialTheme.typography.bodySmall, + color = + MaterialTheme.colorScheme.onSurfaceVariant + ) + ConnectionStatusDot(connectionState) + } } } ) @@ -209,7 +215,7 @@ fun App() { errorMessage = null startDownload( scope = scope, - kdown = kdown, + kdown = activeApi, url = url, directory = "downloads", fileName = fileName.ifBlank { null }, @@ -220,6 +226,62 @@ fun App() { } ) } + + if (showBackendSelector) { + BackendSelectorSheet( + backendManager = backendManager, + activeBackendId = activeBackend?.id, + switchingBackendId = switchingBackendId, + onSelectBackend = { entry -> + if (entry.id != activeBackend?.id && + switchingBackendId == null + ) { + switchingBackendId = entry.id + scope.launch { + try { + backendManager.switchTo(entry.id) + showBackendSelector = false + } catch (e: Exception) { + errorMessage = + "Failed to switch backend: ${e.message}" + } finally { + switchingBackendId = null + } + } + } + }, + onRemoveBackend = { entry -> + scope.launch { + try { + backendManager.removeBackend(entry.id) + } catch (e: Exception) { + errorMessage = + "Failed to remove backend: ${e.message}" + } + } + }, + onAddRemoteServer = { + showAddRemoteDialog = true + }, + onDismiss = { showBackendSelector = false } + ) + } + + if (showAddRemoteDialog) { + AddRemoteServerDialog( + onDismiss = { showAddRemoteDialog = false }, + onAdd = { host, port, token -> + showAddRemoteDialog = false + try { + backendManager.addRemote(host, port, token) + } catch (e: Exception) { + errorMessage = + "Failed to add remote server: ${e.message}" + } + } + ) + } + } } @@ -866,6 +928,352 @@ private fun TaskActionButtons( } } +// -- Backend selector UI -- + +@Composable +private fun ConnectionStatusDot(state: ConnectionState) { + val color = when (state) { + is ConnectionState.Connected -> + MaterialTheme.colorScheme.tertiary + is ConnectionState.Connecting -> + MaterialTheme.colorScheme.secondary + is ConnectionState.Disconnected -> + MaterialTheme.colorScheme.error + } + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(color) + ) +} + +@Composable +private fun ConnectionStatusChip( + state: ConnectionState, + isActive: Boolean = false +) { + val (label, bgColor, textColor) = when (state) { + is ConnectionState.Connected -> Triple( + "Connected", + MaterialTheme.colorScheme.tertiaryContainer, + MaterialTheme.colorScheme.onTertiaryContainer + ) + is ConnectionState.Connecting -> Triple( + "Connecting", + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.onSecondaryContainer + ) + is ConnectionState.Disconnected -> if (isActive) { + Triple( + "Disconnected", + MaterialTheme.colorScheme.errorContainer, + MaterialTheme.colorScheme.onErrorContainer + ) + } else { + Triple( + "Not connected", + MaterialTheme.colorScheme.surfaceVariant, + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Box( + modifier = Modifier + .background( + color = bgColor, + shape = MaterialTheme.shapes.small + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = textColor + ) + } +} + +private fun backendConfigIcon(config: BackendConfig): ImageVector { + return when (config) { + is BackendConfig.Embedded -> Icons.Filled.PhoneAndroid + is BackendConfig.Remote -> Icons.Filled.Cloud + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BackendSelectorSheet( + backendManager: BackendManager, + activeBackendId: String?, + switchingBackendId: String?, + onSelectBackend: (BackendEntry) -> Unit, + onRemoveBackend: (BackendEntry) -> Unit, + onAddRemoteServer: () -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState() + val backends by backendManager.backends.collectAsState() + val serverState by backendManager.serverState.collectAsState() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp) + ) { + Text( + text = "Select Backend", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding( + horizontal = 24.dp, vertical = 8.dp + ) + ) + + backends.forEach { entry -> + val entryConnectionState by + entry.connectionState.collectAsState() + val isActive = entry.id == activeBackendId + val isSwitching = entry.id == switchingBackendId + + ListItem( + modifier = Modifier.clickable( + enabled = !isSwitching && switchingBackendId == null + ) { + onSelectBackend(entry) + }, + headlineContent = { + Text( + text = entry.label, + fontWeight = if (isActive) { + FontWeight.SemiBold + } else { + FontWeight.Normal + } + ) + }, + leadingContent = { + Icon( + imageVector = backendConfigIcon(entry.config), + contentDescription = entry.label, + tint = if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + }, + supportingContent = { + Column { + ConnectionStatusChip( + state = entryConnectionState, + isActive = isActive + ) + // Server controls inside the Embedded entry + if (entry.isEmbedded && + backendManager.isLocalServerSupported + ) { + EmbeddedServerControls( + serverState = serverState, + onStartServer = { port, token -> + backendManager.startServer(port, token) + }, + onStopServer = { backendManager.stopServer() } + ) + } + } + }, + trailingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (isSwitching) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else if (isActive) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Active", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + if (!entry.isEmbedded) { + IconButton( + onClick = { onRemoveBackend(entry) } + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Remove", + tint = + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + ) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp) + ) + + ListItem( + modifier = Modifier.clickable { onAddRemoteServer() }, + headlineContent = { Text("Add Remote Server") }, + leadingContent = { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Add remote server", + tint = MaterialTheme.colorScheme.primary + ) + } + ) + } + } +} + +@Composable +private fun EmbeddedServerControls( + serverState: ServerState, + onStartServer: (port: Int, token: String?) -> Unit, + onStopServer: () -> Unit +) { + when (serverState) { + is ServerState.Running -> { + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Server on :${serverState.port}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + FilledTonalIconButton( + onClick = onStopServer, + modifier = Modifier.size(24.dp), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = + MaterialTheme.colorScheme.errorContainer, + contentColor = + MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Stop server", + modifier = Modifier.size(14.dp) + ) + } + } + } + is ServerState.Stopped -> { + TextButton( + onClick = { onStartServer(8642, null) }, + modifier = Modifier.padding(top = 2.dp), + contentPadding = PaddingValues( + horizontal = 8.dp, vertical = 0.dp + ) + ) { + Icon( + imageVector = Icons.Filled.Computer, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.size(4.dp)) + Text( + text = "Start Server", + style = MaterialTheme.typography.labelSmall + ) + } + } + } +} + +@Composable +private fun AddRemoteServerDialog( + onDismiss: () -> Unit, + onAdd: (host: String, port: Int, token: String?) -> Unit +) { + var host by remember { mutableStateOf("") } + var port by remember { mutableStateOf("8642") } + var token by remember { mutableStateOf("") } + val isValidHost = host.isNotBlank() + val isValidPort = port.toIntOrNull()?.let { + it in 1..65535 + } ?: false + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Remote Server") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = host, + onValueChange = { host = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Host") }, + singleLine = true, + placeholder = { Text("192.168.1.5") } + ) + OutlinedTextField( + value = port, + onValueChange = { port = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Port") }, + singleLine = true, + placeholder = { Text("8642") }, + isError = port.isNotBlank() && !isValidPort, + supportingText = if (port.isNotBlank() && + !isValidPort + ) { + { Text("Port must be 1-65535") } + } else { + null + } + ) + OutlinedTextField( + value = token, + onValueChange = { token = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("API Token") }, + singleLine = true, + placeholder = { Text("Optional") } + ) + } + }, + confirmButton = { + Button( + onClick = { + onAdd( + host.trim(), + port.toIntOrNull() ?: 8642, + token.trim().ifBlank { null } + ) + }, + enabled = isValidHost && isValidPort + ) { + Text("Add") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + private fun extractFilename(url: String): String { val path = url.trim() .substringBefore("?") diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendConfig.kt b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendConfig.kt new file mode 100644 index 00000000..62a85025 --- /dev/null +++ b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendConfig.kt @@ -0,0 +1,13 @@ +package com.linroid.kdown.examples.backend + +sealed class BackendConfig { + data object Embedded : BackendConfig() + + data class Remote( + val host: String, + val port: Int = 8642, + val apiToken: String? = null + ) : BackendConfig() { + val baseUrl: String get() = "http://$host:$port" + } +} diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendEntry.kt b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendEntry.kt new file mode 100644 index 00000000..d6f35b69 --- /dev/null +++ b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendEntry.kt @@ -0,0 +1,13 @@ +package com.linroid.kdown.examples.backend + +import com.linroid.kdown.remote.ConnectionState +import kotlinx.coroutines.flow.StateFlow + +data class BackendEntry( + val id: String, + val label: String, + val config: BackendConfig, + val connectionState: StateFlow +) { + val isEmbedded: Boolean get() = config is BackendConfig.Embedded +} diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendFactory.kt b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendFactory.kt new file mode 100644 index 00000000..fa9e770d --- /dev/null +++ b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendFactory.kt @@ -0,0 +1,98 @@ +package com.linroid.kdown.examples.backend + +import com.linroid.kdown.api.KDownApi +import com.linroid.kdown.core.DownloadConfig +import com.linroid.kdown.core.KDown +import com.linroid.kdown.core.QueueConfig +import com.linroid.kdown.core.log.Logger +import com.linroid.kdown.core.task.TaskStore +import com.linroid.kdown.engine.KtorHttpEngine +import com.linroid.kdown.remote.RemoteKDown + +/** + * Creates [KDownApi] instances for each backend type. + * + * @param taskStore persistent storage for download task records. + * Required when using the default embedded backend. Pass `null` + * for remote-only mode (e.g. wasmJs/web). + * @param embeddedFactory factory for creating the embedded KDown + * instance. When `null`, no embedded backend is available and + * [BackendManager] starts in remote-only mode. + * Override in tests to inject fakes. + * @param localServerFactory optional factory that starts an HTTP + * server exposing the embedded [KDownApi]. Receives port, + * optional API token, and the embedded KDownApi instance. + * When non-null, server controls appear in the Embedded + * backend entry. Provided by Android and JVM/Desktop. + */ +class BackendFactory( + taskStore: TaskStore? = null, + private val embeddedFactory: (() -> KDownApi)? = taskStore?.let { ts -> + { createDefaultEmbeddedKDown(ts) } + }, + private val localServerFactory: + ((port: Int, apiToken: String?, KDownApi) -> LocalServerHandle)? = null +) { + /** Whether an embedded backend is available. */ + val hasEmbedded: Boolean get() = embeddedFactory != null + + /** Whether this platform supports starting a local server. */ + val isLocalServerSupported: Boolean + get() = localServerFactory != null + + private var localServer: LocalServerHandle? = null + + /** Create the embedded KDown instance. */ + fun createEmbedded(): KDownApi { + return embeddedFactory?.invoke() + ?: throw UnsupportedOperationException( + "No embedded backend available" + ) + } + + /** Create a remote client for the given config. */ + fun createRemote(config: BackendConfig.Remote): KDownApi = + RemoteKDown(config.baseUrl, config.apiToken) + + /** + * Start a local HTTP server exposing [api]. + * Does not change the active backend. + */ + fun startServer( + port: Int, + apiToken: String?, + api: KDownApi + ) { + val factory = localServerFactory + ?: throw UnsupportedOperationException( + "Local server not supported on this platform" + ) + localServer = factory(port, apiToken, api) + } + + /** Stop the local server if running (does not close KDown). */ + fun stopServer() { + localServer?.stop() + localServer = null + } +} + +private fun createDefaultEmbeddedKDown( + taskStore: TaskStore +): KDownApi { + return KDown( + httpEngine = KtorHttpEngine(), + taskStore = taskStore, + config = DownloadConfig( + maxConnections = 4, + retryCount = 3, + retryDelayMs = 1000, + progressUpdateIntervalMs = 200, + queueConfig = QueueConfig( + maxConcurrentDownloads = 3, + maxConnectionsPerHost = 4 + ) + ), + logger = Logger.console() + ) +} diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendManager.kt b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendManager.kt new file mode 100644 index 00000000..616b23fb --- /dev/null +++ b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendManager.kt @@ -0,0 +1,257 @@ +package com.linroid.kdown.examples.backend + +import com.linroid.kdown.api.DownloadRequest +import com.linroid.kdown.api.DownloadTask +import com.linroid.kdown.api.KDownApi +import com.linroid.kdown.api.KDownVersion +import com.linroid.kdown.api.SpeedLimit +import com.linroid.kdown.core.KDown +import com.linroid.kdown.remote.ConnectionState +import com.linroid.kdown.remote.RemoteKDown +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Manages the list of configured backends, the currently active + * backend, and lifecycle transitions. + * + * When [BackendFactory.hasEmbedded] is `true` (Android, iOS, + * JVM/Desktop), an embedded [KDown] instance is created once + * and reused. An optional HTTP server can be started/stopped + * to expose the same instance over the network. + * + * When [BackendFactory.hasEmbedded] is `false` (wasmJs/web), + * the manager starts with a placeholder and only remote + * backends are available. + */ +class BackendManager( + private val factory: BackendFactory +) { + private val scope = CoroutineScope( + SupervisorJob() + Dispatchers.Default + ) + + /** + * Single embedded KDown instance, alive for the whole app. + * `null` in remote-only mode (e.g. wasmJs/web). + */ + private val embeddedApi: KDownApi? = + if (factory.hasEmbedded) factory.createEmbedded() else null + + private val embeddedEntry: BackendEntry? = embeddedApi?.let { + BackendEntry( + id = "embedded", + label = "Embedded", + config = BackendConfig.Embedded, + connectionState = MutableStateFlow( + ConnectionState.Connected + ) + ) + } + + private val _backends = + MutableStateFlow(listOfNotNull(embeddedEntry)) + val backends: StateFlow> = + _backends.asStateFlow() + + private val _activeBackend = + MutableStateFlow(embeddedEntry) + val activeBackend: StateFlow = + _activeBackend.asStateFlow() + + private val _activeApi = + MutableStateFlow(embeddedApi ?: DisconnectedApi) + val activeApi: StateFlow = _activeApi.asStateFlow() + + val isLocalServerSupported: Boolean = + factory.isLocalServerSupported + + private val _serverState = + MutableStateFlow(ServerState.Stopped) + /** State of the optional HTTP server for the embedded backend. */ + val serverState: StateFlow = + _serverState.asStateFlow() + + init { + if (embeddedApi is KDown) { + scope.launch { embeddedApi.loadTasks() } + } + } + + private var connectionObserverJob: Job? = null + + /** + * Switch to a different configured backend by ID. + * + * Switching to Remote creates a new [RemoteKDown] client. + * Switching back to Embedded reuses the original instance. + * The local server (if running) is not affected by switching. + */ + suspend fun switchTo(id: String) { + if (id == _activeBackend.value?.id) return + val entry = _backends.value.find { it.id == id } + ?: throw IllegalArgumentException( + "Backend not found: $id" + ) + + // Clean up current connection observer + connectionObserverJob?.cancel() + connectionObserverJob = null + + // Close remote client if we're leaving one + val oldApi = _activeApi.value + if (oldApi !== embeddedApi && oldApi !== DisconnectedApi) { + oldApi.close() + } + + // Set up new backend + when (val config = entry.config) { + is BackendConfig.Embedded -> { + _activeApi.value = embeddedApi ?: DisconnectedApi + } + is BackendConfig.Remote -> { + _activeApi.value = + factory.createRemote(config) + } + } + + _activeBackend.value = entry + + // Post-switch initialization + val newApi = _activeApi.value + if (newApi is RemoteKDown) { + observeRemoteConnectionState(entry, newApi) + } else { + (entry.connectionState as? MutableStateFlow)?.value = + ConnectionState.Connected + } + } + + /** + * Start the local HTTP server exposing the embedded backend. + * Only available when [isLocalServerSupported] is `true`. + */ + fun startServer(port: Int = 8642, token: String? = null) { + val api = embeddedApi + ?: throw UnsupportedOperationException( + "No embedded backend for local server" + ) + factory.stopServer() + factory.startServer(port, token, api) + _serverState.value = ServerState.Running(port) + } + + /** Stop the local HTTP server if running. */ + fun stopServer() { + factory.stopServer() + _serverState.value = ServerState.Stopped + } + + /** + * Add a remote server to the backend list. + * Does NOT activate it -- call [switchTo] afterward. + */ + @OptIn(ExperimentalUuidApi::class) + fun addRemote( + host: String, + port: Int = 8642, + token: String? = null + ): BackendEntry { + val config = BackendConfig.Remote(host, port, token) + val entry = BackendEntry( + id = Uuid.random().toString(), + label = "$host:$port", + config = config, + connectionState = MutableStateFlow( + ConnectionState.Disconnected() + ) + ) + _backends.value += entry + return entry + } + + /** + * Remove a backend by ID. Cannot remove the embedded backend. + * If the removed backend is active, switches to embedded (or + * falls back to disconnected in remote-only mode). + */ + suspend fun removeBackend(id: String) { + require(id != "embedded") { + "Cannot remove the embedded backend" + } + if (_activeBackend.value?.id == id) { + if (embeddedEntry != null) { + switchTo("embedded") + } else { + // Remote-only mode: go back to disconnected + connectionObserverJob?.cancel() + connectionObserverJob = null + val oldApi = _activeApi.value + if (oldApi !== DisconnectedApi) { + oldApi.close() + } + _activeApi.value = DisconnectedApi + _activeBackend.value = null + } + } + _backends.value = _backends.value.filter { it.id != id } + } + + /** Close the active backend and release all resources. */ + fun close() { + val currentApi = _activeApi.value + if (currentApi !== embeddedApi && + currentApi !== DisconnectedApi + ) { + currentApi.close() + } + factory.stopServer() + embeddedApi?.close() + scope.cancel() + } + + private fun observeRemoteConnectionState( + entry: BackendEntry, + remote: RemoteKDown + ) { + val entryState = + entry.connectionState as? MutableStateFlow ?: return + connectionObserverJob = scope.launch { + remote.connectionState.collect { entryState.value = it } + } + } +} + +/** + * Placeholder [KDownApi] used when no backend is connected. + * Returns empty tasks and a default version. Download requests + * throw — the UI should prompt to add a remote server first. + */ +private object DisconnectedApi : KDownApi { + override val backendLabel = "Not connected" + override val tasks = + MutableStateFlow(emptyList()) + override val version = MutableStateFlow( + KDownVersion(KDownVersion.DEFAULT, KDownVersion.DEFAULT) + ) + + override suspend fun download( + request: DownloadRequest + ): DownloadTask { + throw IllegalStateException( + "No backend connected. Add a remote server first." + ) + } + + override suspend fun setGlobalSpeedLimit(limit: SpeedLimit) {} + override fun close() {} +} diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/LocalServerHandle.kt b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/LocalServerHandle.kt new file mode 100644 index 00000000..1b925601 --- /dev/null +++ b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/LocalServerHandle.kt @@ -0,0 +1,16 @@ +package com.linroid.kdown.examples.backend + +/** + * Represents a running local server. Defined in commonMain so + * [BackendManager] can manage its lifecycle without referencing + * the JVM-only KDownServer directly. + * + * The server wraps an existing [com.linroid.kdown.api.KDownApi] + * instance (typically the embedded one) and does NOT own it. + * [stop] shuts down the server only; the underlying KDown + * instance is managed by [BackendManager]. + */ +interface LocalServerHandle { + /** Stop the server (does not close the underlying KDown). */ + fun stop() +} diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/ServerState.kt b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/ServerState.kt new file mode 100644 index 00000000..78481bf6 --- /dev/null +++ b/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/ServerState.kt @@ -0,0 +1,7 @@ +package com.linroid.kdown.examples.backend + +/** State of the optional HTTP server exposing the embedded backend. */ +sealed class ServerState { + data object Stopped : ServerState() + data class Running(val port: Int) : ServerState() +} diff --git a/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/BackendManagerTest.kt b/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/BackendManagerTest.kt new file mode 100644 index 00000000..5c917f9f --- /dev/null +++ b/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/BackendManagerTest.kt @@ -0,0 +1,635 @@ +package com.linroid.kdown.examples + +import com.linroid.kdown.examples.backend.BackendConfig +import com.linroid.kdown.remote.ConnectionState +import com.linroid.kdown.examples.backend.BackendEntry +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Tests for BackendManager backend switching logic. + * + * These tests are structured against the actual BackendManager API: + * - `switchTo(id: String)` - switches by backend ID + * - `addRemote(host, port, token)` - adds a remote entry + * - `removeBackend(id: String)` - removes by backend ID + * - `activeBackend: StateFlow` - current entry + * - `activeApi: StateFlow` - current API instance + * - `backends: StateFlow>` - all entries + * - `isLocalServerSupported: Boolean` - platform capability + * - `serverState: StateFlow` - HTTP server state + * + * Test categories: + * 1. FakeKDownApi (test double validation) + * 2. BackendConfig / ConnectionState model tests + * 3. BackendManager lifecycle + * 4. Backend switching + * 5. Connection state tracking + * 6. Error handling on switch failure + * 7. Concurrent safety + * 8. Remove backend (auto-switch to embedded) + * 9. Backend label propagation + * 10. Backend list ordering + * + * TESTABILITY BLOCKER for categories 3-10: + * BackendManager takes `BackendFactory` in its constructor, and + * hardcodes `createEmbedded()` which creates a real KDown instance. + * To unit-test BackendManager, we need either: + * (a) Extract an interface from BackendFactory and accept it in + * BackendManager constructor, OR + * (b) Make BackendManager accept an `embeddedApiFactory` lambda + * + * Once the testability refactor is done, uncomment categories 3-10 + * and replace `BackendFactory` with `FakeBackendFactory`. + * + * ## UI behaviors for future instrumented tests + * + * The following behaviors are pure Compose UI logic (not in + * BackendManager) and require `createComposeRule()` to test: + * + * - Port validation: "Add" button disabled when port outside + * 1-65535 or host blank. Error text below port field. + * - Double-tap guard: `switchingBackendId` state prevents + * tapping other entries during an active switchTo() call. + * - Sheet dismiss timing: sheet stays open on failure, + * only dismisses after successful switchTo() return. + * - "Not connected" vs "Disconnected" chip: inactive remotes + * show gray "Not connected"; only active backend shows red + * "Disconnected" on connection loss (via `isActive` param + * on ConnectionStatusChip). + */ +class BackendManagerTest { + + // ------------------------------------------------------- + // 1. FakeKDownApi (test double validation) + // ------------------------------------------------------- + + @Test + fun fakeKDownApi_initialState() { + val fake = FakeKDownApi(backendLabel = "TestBackend") + assertEquals("TestBackend", fake.backendLabel) + assertFalse(fake.closed) + assertEquals(0, fake.closeCallCount) + assertEquals(0, fake.downloadCallCount) + } + + @Test + fun fakeKDownApi_close_setsClosed() { + val fake = FakeKDownApi() + fake.close() + assertTrue(fake.closed) + assertEquals(1, fake.closeCallCount) + } + + @Test + fun fakeKDownApi_close_calledTwice_incrementsCount() { + val fake = FakeKDownApi() + fake.close() + fake.close() + assertEquals(2, fake.closeCallCount) + } + + @Test + fun fakeKDownApi_tasks_initiallyEmpty() { + val fake = FakeKDownApi() + assertTrue(fake.tasks.value.isEmpty()) + } + + // ------------------------------------------------------- + // 1b. FakeBackendFactory (test double validation) + // ------------------------------------------------------- + + @Test + fun fakeBackendFactory_create_embedded_usesLambda() { + val embedded = FakeKDownApi("MyCore") + val factory = FakeBackendFactory( + embeddedFactory = { embedded } + ) + val result = factory.create(BackendConfig.Embedded) + assertEquals("MyCore", result.backendLabel) + assertEquals(1, factory.createCallCount) + assertEquals(BackendConfig.Embedded, factory.lastCreatedConfig) + } + + @Test + fun fakeBackendFactory_create_remote_defaultLabel() { + val factory = FakeBackendFactory() + val result = factory.create( + BackendConfig.Remote("myhost", 9000) + ) + assertEquals("Remote · myhost:9000", result.backendLabel) + } + + @Test + fun fakeBackendFactory_create_remote_customFactory() { + val custom = FakeKDownApi("Custom Remote") + val factory = FakeBackendFactory() + factory.remoteFactory = { custom } + val result = factory.create( + BackendConfig.Remote("host", 8642) + ) + assertEquals("Custom Remote", result.backendLabel) + } + + @Test + fun fakeBackendFactory_failOnNextCreate_throwsThenResets() { + val factory = FakeBackendFactory() + factory.failOnNextCreate = true + + val result1 = runCatching { + factory.create(BackendConfig.Remote("host")) + } + assertTrue(result1.isFailure) + + // Next call should succeed (flag auto-resets) + val result2 = runCatching { + factory.create(BackendConfig.Remote("host")) + } + assertTrue(result2.isSuccess) + } + + @Test + fun fakeBackendFactory_closeResources_incrementsCount() { + val factory = FakeBackendFactory() + assertEquals(0, factory.closeResourcesCallCount) + factory.closeResources() + assertEquals(1, factory.closeResourcesCallCount) + factory.closeResources() + assertEquals(2, factory.closeResourcesCallCount) + } + + // ------------------------------------------------------- + // 2. BackendConfig model tests + // ------------------------------------------------------- + + @Test + fun embeddedConfig_isSingleton() { + val a = BackendConfig.Embedded + val b = BackendConfig.Embedded + assertEquals(a, b) + } + + @Test + fun remoteConfig_baseUrl_combinesHostAndPort() { + val config = BackendConfig.Remote( + host = "192.168.1.5", + port = 9000 + ) + assertEquals("http://192.168.1.5:9000", config.baseUrl) + } + + @Test + fun remoteConfig_defaultPort_is8642() { + val config = BackendConfig.Remote(host = "localhost") + assertEquals(8642, config.port) + } + + @Test + fun remoteConfig_equality() { + val a = BackendConfig.Remote("host1", 8642, "token") + val b = BackendConfig.Remote("host1", 8642, "token") + assertEquals(a, b) + } + + @Test + fun remoteConfig_inequality_differentHost() { + val a = BackendConfig.Remote("host1") + val b = BackendConfig.Remote("host2") + assertNotEquals(a, b) + } + + @Test + fun backendConnectionState_types() { + val connected = ConnectionState.Connected + val connecting = ConnectionState.Connecting + val disconnected = + ConnectionState.Disconnected("timeout") + + assertTrue(connected is ConnectionState) + assertTrue(connecting is ConnectionState) + assertTrue(disconnected is ConnectionState) + assertEquals("timeout", disconnected.reason) + } + + @Test + fun backendConnectionState_disconnected_nullReason() { + val state = ConnectionState.Disconnected() + assertEquals(null, state.reason) + } + + @Test + fun remoteConfig_baseUrl_localhostDefault() { + val config = BackendConfig.Remote(host = "localhost") + assertEquals("http://localhost:8642", config.baseUrl) + } + + @Test + fun remoteConfig_apiToken_nullable() { + val withToken = BackendConfig.Remote("h", 8642, "tok") + val withoutToken = BackendConfig.Remote("h", 8642) + assertEquals("tok", withToken.apiToken) + assertEquals(null, withoutToken.apiToken) + } + + @Test + fun remoteConfig_inequality_differentPort() { + val a = BackendConfig.Remote("host", 8642) + val b = BackendConfig.Remote("host", 9000) + assertNotEquals(a, b) + } + + @Test + fun remoteConfig_inequality_differentToken() { + val a = BackendConfig.Remote("host", 8642, "token1") + val b = BackendConfig.Remote("host", 8642, "token2") + assertNotEquals(a, b) + } + + @Test + fun backendConnectionState_connected_isSingleton() { + val a = ConnectionState.Connected + val b = ConnectionState.Connected + assertEquals(a, b) + } + + @Test + fun backendConnectionState_connecting_isSingleton() { + val a = ConnectionState.Connecting + val b = ConnectionState.Connecting + assertEquals(a, b) + } + + @Test + fun backendConnectionState_disconnected_equality() { + val a = ConnectionState.Disconnected("timeout") + val b = ConnectionState.Disconnected("timeout") + assertEquals(a, b) + } + + @Test + fun backendConnectionState_disconnected_inequality() { + val a = ConnectionState.Disconnected("timeout") + val b = ConnectionState.Disconnected("refused") + assertNotEquals(a, b) + } + + // ------------------------------------------------------- + // 2b. BackendEntry construction tests + // ------------------------------------------------------- + + @Test + fun backendEntry_embeddedConstruction() { + val entry = BackendEntry( + id = "embedded", + label = "Embedded", + config = BackendConfig.Embedded, + connectionState = MutableStateFlow( + ConnectionState.Connected + ) + ) + assertEquals("embedded", entry.id) + assertTrue(entry.isEmbedded) + assertEquals("Embedded", entry.label) + assertEquals(BackendConfig.Embedded, entry.config) + assertEquals( + ConnectionState.Connected, + entry.connectionState.value + ) + } + + @Test + fun backendEntry_remoteConstruction() { + val config = BackendConfig.Remote("myhost", 9000) + val entry = BackendEntry( + id = "remote-myhost:9000", + label = "myhost:9000", + config = config, + connectionState = MutableStateFlow( + ConnectionState.Disconnected() + ) + ) + assertEquals("remote-myhost:9000", entry.id) + assertFalse(entry.isEmbedded) + assertEquals("myhost:9000", entry.label) + assertTrue( + entry.config is BackendConfig.Remote + ) + assertTrue( + entry.connectionState.value + is ConnectionState.Disconnected + ) + } + + @Test + fun backendEntry_equality_sameFields() { + val connState = MutableStateFlow( + ConnectionState.Connected + ) + val a = BackendEntry( + id = "test", + label = "Test", + config = BackendConfig.Embedded, + connectionState = connState + ) + val b = BackendEntry( + id = "test", + label = "Test", + config = BackendConfig.Embedded, + connectionState = connState + ) + assertEquals(a, b) + } + + @Test + fun backendEntry_inequality_differentId() { + val connState = MutableStateFlow( + ConnectionState.Connected + ) + val a = BackendEntry( + id = "a", + label = "Test", + config = BackendConfig.Embedded, + connectionState = connState + ) + val b = BackendEntry( + id = "b", + label = "Test", + config = BackendConfig.Embedded, + connectionState = connState + ) + assertNotEquals(a, b) + } + + // ------------------------------------------------------- + // 3. BackendManager lifecycle tests + // + // BLOCKED: Requires testability refactor of BackendManager + // (accept interface instead of BackendFactory). + // ------------------------------------------------------- + + // @Test + // fun initialBackend_isEmbedded() = runTest { + // val factory = FakeBackendFactory() + // val manager = BackendManager(factory) // needs interface + // val active = manager.activeBackend.value + // assertEquals("embedded", active?.id) + // assertTrue(active?.isEmbedded == true) + // assertEquals(BackendConfig.Embedded, active?.config) + // manager.close() + // } + + // @Test + // fun initialBackendList_containsOnlyEmbedded() = runTest { + // val factory = FakeBackendFactory() + // val manager = BackendManager(factory) + // val list = manager.backends.value + // assertEquals(1, list.size) + // assertEquals("embedded", list[0].id) + // manager.close() + // } + + // @Test + // fun close_closesActiveApi() = runTest { + // val embedded = FakeKDownApi("Core") + // val factory = FakeBackendFactory( + // embeddedFactory = { embedded } + // ) + // val manager = BackendManager(factory) + // manager.close() + // assertTrue(embedded.closed) + // assertEquals(1, embedded.closeCallCount) + // } + + // ------------------------------------------------------- + // 4. Backend switching tests + // + // BackendManager.switchTo(id) looks up entry by ID from + // backends list, creates the API client, sets + // activeApi + activeBackend, then closes old API. + // ------------------------------------------------------- + + // @Test + // fun switchTo_remote_updatesActiveBackend() = runTest { + // val factory = FakeBackendFactory() + // val manager = BackendManager(factory) + // val entry = manager.addRemote("localhost") + // manager.switchTo(entry.id) + // assertEquals(entry.id, manager.activeBackend.value?.id) + // assertFalse(manager.activeBackend.value?.isEmbedded == true) + // manager.close() + // } + + // @Test + // fun switchTo_sameId_isNoOp() = runTest { + // val embedded = FakeKDownApi("Core") + // val factory = FakeBackendFactory( + // embeddedFactory = { embedded } + // ) + // val manager = BackendManager(factory) + // manager.switchTo("embedded") + // assertFalse( + // embedded.closed, + // "Same-ID switch should not close current API" + // ) + // manager.close() + // } + + // @Test + // fun switchTo_unknownId_throws() = runTest { + // val factory = FakeBackendFactory() + // val manager = BackendManager(factory) + // val result = runCatching { + // manager.switchTo("nonexistent-backend") + // } + // assertTrue( + // result.isFailure, + // "Unknown ID should throw" + // ) + // assertTrue( + // result.exceptionOrNull() + // is IllegalArgumentException, + // "Should be IllegalArgumentException" + // ) + // manager.close() + // } + + // ------------------------------------------------------- + // 5. Connection state tracking + // ------------------------------------------------------- + + // @Test + // fun embeddedEntry_connectionState_isConnected() = runTest { + // val factory = FakeBackendFactory() + // val manager = BackendManager(factory) + // assertEquals( + // ConnectionState.Connected, + // manager.activeBackend.value?.connectionState?.value + // ) + // manager.close() + // } + + // @Test + // fun addRemote_connectionState_isDisconnected() = runTest { + // val factory = FakeBackendFactory() + // val manager = BackendManager(factory) + // val entry = manager.addRemote("localhost") + // assertTrue( + // entry.connectionState.value + // is ConnectionState.Disconnected, + // "New remote should start Disconnected" + // ) + // manager.close() + // } + + // ------------------------------------------------------- + // 6. Error handling on switch failure + // ------------------------------------------------------- + + // @Test + // fun switchTo_factoryFailure_keepsOldBackend() = runTest { + // val embedded = FakeKDownApi("Core") + // val factory = FakeBackendFactory( + // embeddedFactory = { embedded } + // ) + // val manager = BackendManager(factory) + // val entry = manager.addRemote("unreachable") + // factory.failOnNextCreate = true + // try { + // manager.switchTo(entry.id) + // } catch (_: Exception) { } + // assertFalse( + // embedded.closed, + // "Old API must NOT be closed on switch failure" + // ) + // assertEquals( + // "embedded", + // manager.activeBackend.value?.id, + // "Should still be on embedded" + // ) + // manager.close() + // } + + // ------------------------------------------------------- + // 7. Concurrent safety + // ------------------------------------------------------- + + // @Test + // fun rapidSwitching_lastOneWins() = runTest { + // val remotes = (0..3).map { + // FakeKDownApi("Remote-$it") + // } + // var idx = 0 + // val factory = FakeBackendFactory() + // factory.remoteFactory = { remotes[idx++] } + // val manager = BackendManager(factory) + // val entries = (0..3).map { + // manager.addRemote("host-$it") + // } + // entries.forEach { manager.switchTo(it.id) } + // assertEquals( + // entries[3].id, + // manager.activeBackend.value?.id + // ) + // for (i in 0..2) { + // assertTrue( + // remotes[i].closed, + // "Remote-$i should be closed" + // ) + // } + // assertFalse(remotes[3].closed) + // manager.close() + // } + + // ------------------------------------------------------- + // 8. Remove backend (auto-switch to embedded) + // ------------------------------------------------------- + + // @Test + // fun removeActiveRemote_switchesToEmbedded() = runTest { + // val factory = FakeBackendFactory() + // val manager = BackendManager(factory) + // val entry = manager.addRemote("10.0.0.1") + // manager.switchTo(entry.id) + // manager.removeBackend(entry.id) + // assertEquals( + // "embedded", + // manager.activeBackend.value?.id + // ) + // assertFalse( + // manager.backends.value.any { it.id == entry.id }, + // "Removed entry should not be in backends list" + // ) + // manager.close() + // } + + // @Test + // fun removeEmbedded_throws() = runTest { + // val factory = FakeBackendFactory() + // val manager = BackendManager(factory) + // val result = runCatching { + // manager.removeBackend("embedded") + // } + // assertTrue( + // result.isFailure, + // "Removing embedded should throw" + // ) + // assertTrue( + // result.exceptionOrNull() + // is IllegalArgumentException, + // "Should throw IllegalArgumentException" + // ) + // manager.close() + // } + + // ------------------------------------------------------- + // 9. addRemote behavior + // ------------------------------------------------------- + + // @Test + // fun addRemote_doesNotActivate() = runTest { + // val factory = FakeBackendFactory() + // val manager = BackendManager(factory) + // manager.addRemote("newhost", 9999) + // assertEquals( + // "embedded", + // manager.activeBackend.value?.id, + // "Adding remote should not switch active backend" + // ) + // manager.close() + // } + + // @Test + // fun addRemote_appendsToBackendsList() = runTest { + // val factory = FakeBackendFactory() + // val manager = BackendManager(factory) + // val entry = manager.addRemote("host1") + // val list = manager.backends.value + // assertEquals(2, list.size) + // assertEquals("embedded", list[0].id) + // assertEquals(entry.id, list[1].id) + // manager.close() + // } + + // ------------------------------------------------------- + // 10. Backend list ordering + // ------------------------------------------------------- + + // @Test + // fun backends_embeddedAlwaysFirst() = runTest { + // val factory = FakeBackendFactory() + // val manager = BackendManager(factory) + // manager.addRemote("host1") + // manager.addRemote("host2") + // val list = manager.backends.value + // assertEquals( + // "embedded", + // list.first().id, + // "Embedded should always be first" + // ) + // manager.close() + // } +} diff --git a/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/FakeBackendFactory.kt b/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/FakeBackendFactory.kt new file mode 100644 index 00000000..9180a643 --- /dev/null +++ b/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/FakeBackendFactory.kt @@ -0,0 +1,65 @@ +package com.linroid.kdown.examples + +import com.linroid.kdown.api.KDownApi +import com.linroid.kdown.examples.backend.BackendConfig + +/** + * A fake [com.linroid.kdown.examples.backend.BackendFactory] for testing + * [com.linroid.kdown.examples.backend.BackendManager]. + * + * Usage: + * ``` + * val factory = FakeBackendFactory( + * embeddedFactory = { FakeKDownApi("Core") } + * ) + * ``` + */ +class FakeBackendFactory( + private val embeddedFactory: () -> KDownApi = + { FakeKDownApi("Core") } +) { + /** + * Override to control what [create] returns for Remote configs. + * By default, creates a [FakeKDownApi] with the remote label. + */ + var remoteFactory: ((BackendConfig.Remote) -> KDownApi)? = null + + /** + * When set to true, the next call to [create] will throw. + * Resets to false after throwing. + */ + var failOnNextCreate = false + + var closeResourcesCallCount = 0 + private set + var createCallCount = 0 + private set + var lastCreatedConfig: BackendConfig? = null + private set + + fun create(config: BackendConfig): KDownApi { + createCallCount++ + lastCreatedConfig = config + + if (failOnNextCreate) { + failOnNextCreate = false + throw RuntimeException( + "Simulated connection failure for $config" + ) + } + + return when (config) { + is BackendConfig.Embedded -> embeddedFactory() + is BackendConfig.Remote -> { + remoteFactory?.invoke(config) + ?: FakeKDownApi( + "Remote · ${config.host}:${config.port}" + ) + } + } + } + + fun closeResources() { + closeResourcesCallCount++ + } +} diff --git a/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/FakeKDownApi.kt b/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/FakeKDownApi.kt new file mode 100644 index 00000000..a2009c1d --- /dev/null +++ b/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/FakeKDownApi.kt @@ -0,0 +1,54 @@ +package com.linroid.kdown.examples + +import com.linroid.kdown.api.DownloadRequest +import com.linroid.kdown.api.DownloadTask +import com.linroid.kdown.api.KDownApi +import com.linroid.kdown.api.KDownVersion +import com.linroid.kdown.api.SpeedLimit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * A fake [KDownApi] for testing backend switching logic. + * Tracks lifecycle calls and exposes configurable state. + */ +class FakeKDownApi( + override val backendLabel: String = "Fake" +) : KDownApi { + + private val _tasks = MutableStateFlow>(emptyList()) + override val tasks: StateFlow> = _tasks.asStateFlow() + + private val _version = MutableStateFlow( + KDownVersion(KDownVersion.DEFAULT, KDownVersion.DEFAULT) + ) + override val version: StateFlow = _version.asStateFlow() + + var closed = false + private set + var closeCallCount = 0 + private set + var downloadCallCount = 0 + private set + var lastSpeedLimit: SpeedLimit? = null + private set + + override suspend fun download( + request: DownloadRequest + ): DownloadTask { + downloadCallCount++ + throw UnsupportedOperationException( + "FakeKDownApi does not support downloads" + ) + } + + override suspend fun setGlobalSpeedLimit(limit: SpeedLimit) { + lastSpeedLimit = limit + } + + override fun close() { + closed = true + closeCallCount++ + } +} diff --git a/examples/app/src/iosMain/kotlin/com/linroid/kdown/examples/MainViewController.kt b/examples/app/src/iosMain/kotlin/com/linroid/kdown/examples/MainViewController.kt index 91ba0cc8..d9575fa9 100644 --- a/examples/app/src/iosMain/kotlin/com/linroid/kdown/examples/MainViewController.kt +++ b/examples/app/src/iosMain/kotlin/com/linroid/kdown/examples/MainViewController.kt @@ -1,5 +1,20 @@ package com.linroid.kdown.examples +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember import androidx.compose.ui.window.ComposeUIViewController +import com.linroid.kdown.examples.backend.BackendFactory +import com.linroid.kdown.examples.backend.BackendManager +import com.linroid.kdown.sqlite.DriverFactory +import com.linroid.kdown.sqlite.createSqliteTaskStore -fun MainViewController() = ComposeUIViewController { App() } +fun MainViewController() = ComposeUIViewController { + val backendManager = remember { + val taskStore = createSqliteTaskStore(DriverFactory()) + BackendManager(BackendFactory(taskStore = taskStore)) + } + DisposableEffect(Unit) { + onDispose { backendManager.close() } + } + App(backendManager) +} diff --git a/examples/cli/src/main/kotlin/com/linroid/kdown/examples/Main.kt b/examples/cli/src/main/kotlin/com/linroid/kdown/examples/Main.kt index 6c6ac942..2ff6aacd 100644 --- a/examples/cli/src/main/kotlin/com/linroid/kdown/examples/Main.kt +++ b/examples/cli/src/main/kotlin/com/linroid/kdown/examples/Main.kt @@ -100,14 +100,14 @@ fun main(args: Array) { return } - val directory: Path + val directory: String val fileName: String? if (dest != null) { val destPath = Path(dest) - directory = destPath.parent ?: Path(".") + directory = (destPath.parent ?: Path(".")).toString() fileName = destPath.name } else { - directory = Path(".") + directory = "." fileName = null } @@ -266,7 +266,7 @@ private fun runQueueDemo(urls: List) { val priority = priorities[index % priorities.size] val request = DownloadRequest( url = url, - directory = Path("downloads"), + directory = "downloads", connections = 2, priority = priority ) diff --git a/examples/desktopApp/build.gradle.kts b/examples/desktopApp/build.gradle.kts index d1456d34..92f13612 100644 --- a/examples/desktopApp/build.gradle.kts +++ b/examples/desktopApp/build.gradle.kts @@ -8,6 +8,9 @@ plugins { dependencies { implementation(projects.examples.app) + implementation(projects.server) + implementation(projects.library.ktor) + implementation(projects.library.sqlite) implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutinesSwing) } diff --git a/examples/desktopApp/src/main/kotlin/com/linroid/kdown/examples/main.kt b/examples/desktopApp/src/main/kotlin/com/linroid/kdown/examples/main.kt index 164247c7..cefa3ae2 100644 --- a/examples/desktopApp/src/main/kotlin/com/linroid/kdown/examples/main.kt +++ b/examples/desktopApp/src/main/kotlin/com/linroid/kdown/examples/main.kt @@ -1,13 +1,49 @@ package com.linroid.kdown.examples +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember import androidx.compose.ui.window.Window import androidx.compose.ui.window.application +import com.linroid.kdown.examples.backend.BackendFactory +import com.linroid.kdown.examples.backend.BackendManager +import com.linroid.kdown.examples.backend.LocalServerHandle +import com.linroid.kdown.server.KDownServer +import com.linroid.kdown.server.KDownServerConfig +import com.linroid.kdown.sqlite.DriverFactory +import com.linroid.kdown.sqlite.createSqliteTaskStore fun main() = application { + val backendManager = remember { + val taskStore = createSqliteTaskStore(DriverFactory()) + BackendManager( + BackendFactory( + taskStore = taskStore, + localServerFactory = { port, apiToken, kdownApi -> + val server = KDownServer( + kdownApi, + KDownServerConfig( + port = port, + apiToken = apiToken, + corsAllowedHosts = listOf("*") + ) + ) + server.start(wait = false) + object : LocalServerHandle { + override fun stop() { + server.stop() + } + } + } + ) + ) + } + DisposableEffect(Unit) { + onDispose { backendManager.close() } + } Window( onCloseRequest = ::exitApplication, title = "KDown Examples" ) { - App() + App(backendManager) } } diff --git a/examples/webApp/build.gradle.kts b/examples/webApp/build.gradle.kts index 157baf7b..643571b6 100644 --- a/examples/webApp/build.gradle.kts +++ b/examples/webApp/build.gradle.kts @@ -4,7 +4,6 @@ plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) - alias(libs.plugins.kotlinx.serialization) } kotlin { @@ -20,14 +19,11 @@ kotlin { sourceSets { wasmJsMain.dependencies { + implementation(projects.examples.app) implementation(libs.compose.ui) + implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.compose.material3) - implementation(libs.compose.runtime) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.serialization.json) - implementation(libs.ktor.client.core) - implementation(libs.ktor.client.js) } } } diff --git a/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/DownloadManagerApp.kt b/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/DownloadManagerApp.kt deleted file mode 100644 index f448a2ec..00000000 --- a/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/DownloadManagerApp.kt +++ /dev/null @@ -1,708 +0,0 @@ -package com.linroid.kdown.examples - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.linroid.kdown.examples.client.CreateDownloadRequest -import com.linroid.kdown.examples.client.KDownClient -import com.linroid.kdown.examples.client.ServerStatus -import com.linroid.kdown.examples.client.TaskResponse -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DownloadManagerApp() { - val client = remember { KDownClient() } - val scope = rememberCoroutineScope() - - var tasks by remember { - mutableStateOf>(emptyList()) - } - var serverStatus by remember { - mutableStateOf(null) - } - var errorMessage by remember { - mutableStateOf(null) - } - var showAddDialog by remember { mutableStateOf(false) } - var connected by remember { mutableStateOf(false) } - - DisposableEffect(Unit) { - onDispose { client.close() } - } - - // Poll for task updates - LaunchedEffect(Unit) { - while (isActive) { - try { - val status = client.getStatus() - serverStatus = status - tasks = client.listTasks() - connected = true - errorMessage = null - } catch (e: Exception) { - connected = false - errorMessage = "Cannot connect to server. " + - "Is KDown daemon running on localhost:8642?" - } - delay(1000) - } - } - - MaterialTheme { - Scaffold( - topBar = { - TopAppBar( - title = { - Column { - Text( - text = "KDown Web", - fontWeight = FontWeight.SemiBold - ) - val subtitle = if (connected) { - val s = serverStatus - if (s != null) { - "v${s.version} -- " + - "${s.activeTasks} active, " + - "${s.totalTasks} total" - } else "Connecting..." - } else { - "Disconnected" - } - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = if (connected) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.error - } - ) - } - }, - actions = { - Button( - onClick = { showAddDialog = true }, - enabled = connected - ) { - Text("+ Add") - } - } - ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - if (errorMessage != null) { - Card( - colors = CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme.errorContainer - ), - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Text( - text = errorMessage ?: "", - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodySmall, - color = - MaterialTheme.colorScheme.onErrorContainer - ) - } - } - - if (tasks.isEmpty() && connected) { - EmptyState( - modifier = Modifier.fillMaxSize(), - onAddClick = { showAddDialog = true } - ) - } else { - LazyColumn( - contentPadding = PaddingValues(16.dp), - verticalArrangement = - Arrangement.spacedBy(12.dp) - ) { - items( - items = tasks, - key = { it.taskId } - ) { task -> - TaskCard( - task = task, - onPause = { - scope.launch { - runCatching { - client.pauseTask(task.taskId) - } - } - }, - onResume = { - scope.launch { - runCatching { - client.resumeTask(task.taskId) - } - } - }, - onCancel = { - scope.launch { - runCatching { - client.cancelTask(task.taskId) - } - } - }, - onRemove = { - scope.launch { - runCatching { - client.removeTask(task.taskId) - } - } - } - ) - } - } - } - } - } - - if (showAddDialog) { - AddDownloadDialog( - onDismiss = { showAddDialog = false }, - onSubmit = { url, dir, fileName, priority, conns -> - showAddDialog = false - scope.launch { - runCatching { - client.createDownload( - CreateDownloadRequest( - url = url, - directory = dir, - fileName = fileName.ifBlank { null }, - connections = conns, - priority = priority - ) - ) - }.onFailure { e -> - errorMessage = e.message - ?: "Failed to create download" - } - } - } - ) - } - } -} - -@Composable -private fun EmptyState( - modifier: Modifier = Modifier, - onAddClick: () -> Unit -) { - Box( - modifier = modifier, - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "No downloads", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold - ) - Text( - text = "Add a URL to start downloading", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = onAddClick) { - Text("Add download") - } - } - } -} - -@Composable -private fun TaskCard( - task: TaskResponse, - onPause: () -> Unit, - onResume: () -> Unit, - onCancel: () -> Unit, - onRemove: () -> Unit -) { - val fileName = task.fileName - ?: extractFilename(task.url).ifBlank { "download" } - val isActive = task.state == "downloading" || - task.state == "pending" - val isPaused = task.state == "paused" - val isTerminal = task.state == "completed" || - task.state == "failed" || - task.state == "canceled" - - Card( - elevation = CardDefaults.cardElevation( - defaultElevation = 2.dp - ), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = - Arrangement.spacedBy(12.dp) - ) { - StatusDot(task.state) - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = - Alignment.CenterVertically, - horizontalArrangement = - Arrangement.spacedBy(8.dp) - ) { - Text( - text = fileName, - style = - MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight( - 1f, fill = false - ) - ) - if (task.priority != "NORMAL") { - Box( - modifier = Modifier - .background( - MaterialTheme.colorScheme - .tertiaryContainer, - MaterialTheme.shapes.small - ) - .padding( - horizontal = 6.dp, - vertical = 2.dp - ) - ) { - Text( - text = task.priority, - style = MaterialTheme - .typography.labelSmall, - color = MaterialTheme.colorScheme - .onTertiaryContainer - ) - } - } - } - Text( - text = task.url, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme - .onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - // Progress and state details - when (task.state) { - "downloading" -> { - val p = task.progress - if (p != null) { - LinearProgressIndicator( - progress = { p.percent }, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = buildProgressText(p), - style = - MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme - .onSurfaceVariant - ) - } else { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth() - ) - } - } - "pending" -> { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth() - ) - Text( - text = "Preparing...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme - .onSurfaceVariant - ) - } - "queued" -> Text( - text = "Queued -- waiting for slot", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme - .onSurfaceVariant - ) - "scheduled" -> Text( - text = "Scheduled", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme - .onSurfaceVariant - ) - "paused" -> { - val p = task.progress - if (p != null && p.totalBytes > 0) { - LinearProgressIndicator( - progress = { p.percent }, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = "Paused -- " + - "${(p.percent * 100).toInt()}% " + - "(${formatBytes(p.downloadedBytes)}" + - " / ${formatBytes(p.totalBytes)})", - style = - MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme - .onSurfaceVariant - ) - } else { - Text( - text = "Paused", - style = - MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme - .onSurfaceVariant - ) - } - } - "completed" -> Text( - text = "Completed", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.tertiary - ) - "failed" -> Text( - text = "Failed: ${task.error ?: "Unknown"}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - "canceled" -> Text( - text = "Canceled", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme - .onSurfaceVariant - ) - } - - // Action buttons - Row( - horizontalArrangement = - Arrangement.spacedBy(8.dp) - ) { - if (isActive) { - FilledTonalButton(onClick = onPause) { - Text("Pause") - } - TextButton(onClick = onCancel) { - Text( - "Cancel", - color = MaterialTheme.colorScheme.error - ) - } - } - if (isPaused) { - FilledTonalButton(onClick = onResume) { - Text("Resume") - } - TextButton(onClick = onCancel) { - Text( - "Cancel", - color = MaterialTheme.colorScheme.error - ) - } - } - if (isTerminal) { - if (task.state == "failed" || - task.state == "canceled" - ) { - FilledTonalButton(onClick = onResume) { - Text("Retry") - } - } - TextButton(onClick = onRemove) { - Text( - "Remove", - color = MaterialTheme.colorScheme.error - ) - } - } - } - } - } -} - -@Composable -private fun StatusDot(state: String) { - val bgColor = when (state) { - "downloading", "pending" -> - MaterialTheme.colorScheme.primaryContainer - "completed" -> - MaterialTheme.colorScheme.tertiaryContainer - "failed" -> - MaterialTheme.colorScheme.errorContainer - "paused" -> - MaterialTheme.colorScheme.secondaryContainer - else -> - MaterialTheme.colorScheme.surfaceVariant - } - val fgColor = when (state) { - "downloading", "pending" -> - MaterialTheme.colorScheme.onPrimaryContainer - "completed" -> - MaterialTheme.colorScheme.onTertiaryContainer - "failed" -> - MaterialTheme.colorScheme.onErrorContainer - "paused" -> - MaterialTheme.colorScheme.onSecondaryContainer - else -> - MaterialTheme.colorScheme.onSurfaceVariant - } - val label = when (state) { - "downloading" -> "DL" - "pending" -> ".." - "queued" -> "Q" - "scheduled" -> "SC" - "paused" -> "||" - "completed" -> "OK" - "failed" -> "!!" - "canceled" -> "X" - else -> "--" - } - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(bgColor), - contentAlignment = Alignment.Center - ) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = fgColor - ) - } -} - -private val priorities = listOf( - "LOW", "NORMAL", "HIGH", "URGENT" -) - -@Composable -private fun AddDownloadDialog( - onDismiss: () -> Unit, - onSubmit: ( - url: String, - directory: String, - fileName: String, - priority: String, - connections: Int - ) -> Unit -) { - var url by remember { mutableStateOf("") } - var directory by remember { - mutableStateOf("downloads") - } - var fileName by remember { mutableStateOf("") } - var priority by remember { mutableStateOf("NORMAL") } - var connections by remember { mutableStateOf("4") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Add download") }, - text = { - Column( - verticalArrangement = - Arrangement.spacedBy(12.dp) - ) { - OutlinedTextField( - value = url, - onValueChange = { - url = it - if (fileName.isBlank()) { - fileName = extractFilename(it) - } - }, - modifier = Modifier.fillMaxWidth(), - label = { Text("URL") }, - singleLine = true, - placeholder = { - Text("https://example.com/file.zip") - } - ) - OutlinedTextField( - value = directory, - onValueChange = { directory = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Directory") }, - singleLine = true - ) - OutlinedTextField( - value = fileName, - onValueChange = { fileName = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text("File name (optional)") }, - singleLine = true - ) - OutlinedTextField( - value = connections, - onValueChange = { connections = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Connections") }, - singleLine = true - ) - Text( - text = "Priority", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme - .onSurfaceVariant - ) - Row( - horizontalArrangement = - Arrangement.spacedBy(8.dp) - ) { - priorities.forEach { p -> - FilterChip( - selected = priority == p, - onClick = { priority = p }, - label = { Text(p) } - ) - } - } - } - }, - confirmButton = { - Button( - onClick = { - if (url.isNotBlank()) { - onSubmit( - url.trim(), - directory.trim(), - fileName.trim(), - priority, - connections.toIntOrNull() ?: 4 - ) - } - }, - enabled = url.isNotBlank() - ) { - Text("Download") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - } - ) -} - -private fun buildProgressText( - p: com.linroid.kdown.examples.client.ProgressResponse -): String { - return buildString { - append("${(p.percent * 100).toInt()}%") - append(" -- ${formatBytes(p.downloadedBytes)}") - append(" / ${formatBytes(p.totalBytes)}") - if (p.bytesPerSecond > 0) { - append( - " -- ${formatBytes(p.bytesPerSecond)}/s" - ) - } - } -} - -private fun extractFilename(url: String): String { - return url.trim() - .substringBefore("?") - .substringBefore("#") - .trimEnd('/') - .substringAfterLast("/") -} - -private fun formatBytes(bytes: Long): String { - if (bytes < 0) return "--" - val kb = 1024L - val mb = kb * 1024 - val gb = mb * 1024 - return when { - bytes < kb -> "$bytes B" - bytes < mb -> { - val tenths = (bytes * 10 + kb / 2) / kb - "${tenths / 10}.${tenths % 10} KB" - } - bytes < gb -> { - val tenths = (bytes * 10 + mb / 2) / mb - "${tenths / 10}.${tenths % 10} MB" - } - else -> { - val hundredths = (bytes * 100 + gb / 2) / gb - "${hundredths / 100}.${ - (hundredths % 100).toString().padStart(2, '0') - } GB" - } - } -} diff --git a/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/client/ApiModels.kt b/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/client/ApiModels.kt deleted file mode 100644 index d3c8375a..00000000 --- a/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/client/ApiModels.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.linroid.kdown.examples.client - -import kotlinx.serialization.Serializable - -@Serializable -data class CreateDownloadRequest( - val url: String, - val directory: String, - val fileName: String? = null, - val connections: Int = 1, - val headers: Map = emptyMap(), - val priority: String = "NORMAL", - val speedLimitBytesPerSecond: Long = 0 -) - -@Serializable -data class TaskResponse( - val taskId: String, - val url: String, - val directory: String, - val fileName: String? = null, - val state: String, - val progress: ProgressResponse? = null, - val error: String? = null, - val filePath: String? = null, - val segments: List = emptyList(), - val createdAt: String = "", - val priority: String = "NORMAL", - val speedLimitBytesPerSecond: Long = 0 -) - -@Serializable -data class ProgressResponse( - val downloadedBytes: Long, - val totalBytes: Long, - val percent: Float, - val bytesPerSecond: Long -) - -@Serializable -data class SegmentResponse( - val index: Int, - val start: Long, - val end: Long, - val downloadedBytes: Long, - val isComplete: Boolean -) - -@Serializable -data class SpeedLimitRequest( - val bytesPerSecond: Long -) - -@Serializable -data class PriorityRequest( - val priority: String -) - -@Serializable -data class ErrorResponse( - val error: String, - val message: String -) - -@Serializable -data class ServerStatus( - val version: String, - val activeTasks: Int, - val totalTasks: Int -) - -@Serializable -data class TaskEvent( - val taskId: String, - val type: String, - val state: String, - val progress: ProgressResponse? = null, - val error: String? = null, - val filePath: String? = null -) diff --git a/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/client/KDownClient.kt b/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/client/KDownClient.kt deleted file mode 100644 index 78a4a508..00000000 --- a/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/client/KDownClient.kt +++ /dev/null @@ -1,168 +0,0 @@ -package com.linroid.kdown.examples.client - -import io.ktor.client.HttpClient -import io.ktor.client.request.delete -import io.ktor.client.request.get -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.put -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.contentType -import io.ktor.http.isSuccess -import kotlinx.serialization.json.Json - -/** - * HTTP client for the KDown daemon server REST API. - * - * @param baseUrl the server base URL (e.g., "http://localhost:8642") - * @param apiToken optional Bearer token for authentication - */ -class KDownClient( - private val baseUrl: String = "http://localhost:8642", - private val apiToken: String? = null -) { - private val httpClient = HttpClient() - private val json = Json { - ignoreUnknownKeys = true - encodeDefaults = true - } - - suspend fun getStatus(): ServerStatus { - val response = httpClient.get("$baseUrl/api/status") { - applyAuth() - } - return json.decodeFromString(response.bodyAsText()) - } - - suspend fun listTasks(): List { - val response = httpClient.get( - "$baseUrl/api/downloads" - ) { - applyAuth() - } - return json.decodeFromString(response.bodyAsText()) - } - - suspend fun getTask(taskId: String): TaskResponse { - val response = httpClient.get( - "$baseUrl/api/downloads/$taskId" - ) { - applyAuth() - } - check(response.status.isSuccess()) { - "Task not found: $taskId" - } - return json.decodeFromString(response.bodyAsText()) - } - - suspend fun createDownload( - request: CreateDownloadRequest - ): TaskResponse { - val response = httpClient.post( - "$baseUrl/api/downloads" - ) { - applyAuth() - contentType(ContentType.Application.Json) - setBody(json.encodeToString( - CreateDownloadRequest.serializer(), request - )) - } - return json.decodeFromString(response.bodyAsText()) - } - - suspend fun pauseTask(taskId: String): TaskResponse { - val response = httpClient.post( - "$baseUrl/api/downloads/$taskId/pause" - ) { - applyAuth() - } - return json.decodeFromString(response.bodyAsText()) - } - - suspend fun resumeTask(taskId: String): TaskResponse { - val response = httpClient.post( - "$baseUrl/api/downloads/$taskId/resume" - ) { - applyAuth() - } - return json.decodeFromString(response.bodyAsText()) - } - - suspend fun cancelTask(taskId: String): TaskResponse { - val response = httpClient.post( - "$baseUrl/api/downloads/$taskId/cancel" - ) { - applyAuth() - } - return json.decodeFromString(response.bodyAsText()) - } - - suspend fun removeTask(taskId: String) { - httpClient.delete( - "$baseUrl/api/downloads/$taskId" - ) { - applyAuth() - } - } - - suspend fun setTaskSpeedLimit( - taskId: String, - bytesPerSecond: Long - ): TaskResponse { - val response = httpClient.put( - "$baseUrl/api/downloads/$taskId/speed-limit" - ) { - applyAuth() - contentType(ContentType.Application.Json) - setBody(json.encodeToString( - SpeedLimitRequest.serializer(), - SpeedLimitRequest(bytesPerSecond) - )) - } - return json.decodeFromString(response.bodyAsText()) - } - - suspend fun setTaskPriority( - taskId: String, - priority: String - ): TaskResponse { - val response = httpClient.put( - "$baseUrl/api/downloads/$taskId/priority" - ) { - applyAuth() - contentType(ContentType.Application.Json) - setBody(json.encodeToString( - PriorityRequest.serializer(), - PriorityRequest(priority) - )) - } - return json.decodeFromString(response.bodyAsText()) - } - - suspend fun setGlobalSpeedLimit(bytesPerSecond: Long) { - httpClient.put("$baseUrl/api/speed-limit") { - applyAuth() - contentType(ContentType.Application.Json) - setBody(json.encodeToString( - SpeedLimitRequest.serializer(), - SpeedLimitRequest(bytesPerSecond) - )) - } - } - - /** Returns the SSE events URL for all tasks. */ - fun eventsUrl(): String = "$baseUrl/api/events" - - fun close() { - httpClient.close() - } - - private fun io.ktor.client.request.HttpRequestBuilder.applyAuth() { - if (apiToken != null) { - header(HttpHeaders.Authorization, "Bearer $apiToken") - } - } -} diff --git a/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/main.kt b/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/main.kt index cbf0c39e..271742da 100644 --- a/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/main.kt +++ b/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/main.kt @@ -1,13 +1,23 @@ package com.linroid.kdown.examples +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.ComposeViewport +import com.linroid.kdown.examples.backend.BackendFactory +import com.linroid.kdown.examples.backend.BackendManager import kotlinx.browser.document @OptIn(ExperimentalComposeUiApi::class) fun main() { val body = document.body ?: return ComposeViewport(body) { - DownloadManagerApp() + val backendManager = remember { + BackendManager(BackendFactory()) + } + DisposableEffect(Unit) { + onDispose { backendManager.close() } + } + App(backendManager) } }