From d1d1de10e7b46726b05f71ea6c03c0fc4934fb9f Mon Sep 17 00:00:00 2001 From: Lin Zhang Date: Thu, 12 Feb 2026 23:45:22 +0800 Subject: [PATCH] feat: use platform-specific default directories for downloads and database Add defaultDirectory field to DownloadConfig so the app layer provides platform-appropriate download paths instead of hardcoded "downloads". DownloadCoordinator falls back to this when DownloadRequest.directory is null. DriverFactory now requires an explicit dbPath on JVM and iOS, with each app entry point providing the correct platform location (Application Support on macOS/iOS, XDG on Linux, %APPDATA% on Windows). Co-Authored-By: Claude Opus 4.6 --- .../com/linroid/kdown/android/MainActivity.kt | 6 ++++ .../kotlin/com/linroid/kdown/desktop/main.kt | 27 +++++++++++++- .../kdown/app/backend/BackendFactory.kt | 5 ++- .../com/linroid/kdown/app/state/AppState.kt | 2 +- .../linroid/kdown/app/MainViewController.kt | 31 ++++++++++++++-- .../main/kotlin/com/linroid/kdown/cli/Main.kt | 35 ++++++++++++++++--- .../com/linroid/kdown/core/DownloadConfig.kt | 4 +++ .../kdown/core/engine/DownloadCoordinator.kt | 4 +-- .../linroid/kdown/sqlite/DriverFactory.ios.kt | 4 +-- .../linroid/kdown/sqlite/DriverFactory.jvm.kt | 4 ++- 10 files changed, 108 insertions(+), 14 deletions(-) diff --git a/app/android/src/main/kotlin/com/linroid/kdown/android/MainActivity.kt b/app/android/src/main/kotlin/com/linroid/kdown/android/MainActivity.kt index e739f770..2074c152 100644 --- a/app/android/src/main/kotlin/com/linroid/kdown/android/MainActivity.kt +++ b/app/android/src/main/kotlin/com/linroid/kdown/android/MainActivity.kt @@ -1,6 +1,7 @@ package com.linroid.kdown.android import android.os.Bundle +import android.os.Environment import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -24,9 +25,14 @@ class MainActivity : ComponentActivity() { val taskStore = createSqliteTaskStore( DriverFactory(applicationContext) ) + val downloadsDir = Environment + .getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + ).absolutePath BackendManager( BackendFactory( taskStore = taskStore, + defaultDirectory = downloadsDir, localServerFactory = { port, apiToken, kdownApi -> val server = KDownServer( kdownApi, diff --git a/app/desktop/src/main/kotlin/com/linroid/kdown/desktop/main.kt b/app/desktop/src/main/kotlin/com/linroid/kdown/desktop/main.kt index 4db6c485..985ca2db 100644 --- a/app/desktop/src/main/kotlin/com/linroid/kdown/desktop/main.kt +++ b/app/desktop/src/main/kotlin/com/linroid/kdown/desktop/main.kt @@ -12,13 +12,18 @@ import com.linroid.kdown.server.KDownServer import com.linroid.kdown.server.KDownServerConfig import com.linroid.kdown.sqlite.DriverFactory import com.linroid.kdown.sqlite.createSqliteTaskStore +import java.io.File fun main() = application { val backendManager = remember { - val taskStore = createSqliteTaskStore(DriverFactory()) + val dbPath = appConfigDir() + File.separator + "kdown.db" + val taskStore = createSqliteTaskStore(DriverFactory(dbPath)) + val downloadsDir = System.getProperty("user.home") + + File.separator + "Downloads" BackendManager( BackendFactory( taskStore = taskStore, + defaultDirectory = downloadsDir, localServerFactory = { port, apiToken, kdownApi -> val server = KDownServer( kdownApi, @@ -48,3 +53,23 @@ fun main() = application { App(backendManager) } } + +private fun appConfigDir(): String { + val os = System.getProperty("os.name", "").lowercase() + val home = System.getProperty("user.home") + return when { + os.contains("mac") -> + "$home${File.separator}Library${File.separator}" + + "Application Support${File.separator}kdown" + os.contains("win") -> { + val appData = System.getenv("APPDATA") + ?: "$home${File.separator}AppData${File.separator}Roaming" + "$appData${File.separator}kdown" + } + else -> { + val xdg = System.getenv("XDG_CONFIG_HOME") + ?: "$home${File.separator}.config" + "$xdg${File.separator}kdown" + } + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendFactory.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendFactory.kt index 8713d72e..63fc841c 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendFactory.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendFactory.kt @@ -27,8 +27,9 @@ import com.linroid.kdown.remote.RemoteKDown */ class BackendFactory( taskStore: TaskStore? = null, + defaultDirectory: String = "downloads", private val embeddedFactory: (() -> KDownApi)? = taskStore?.let { ts -> - { createDefaultEmbeddedKDown(ts) } + { createDefaultEmbeddedKDown(ts, defaultDirectory) } }, private val localServerFactory: ((port: Int, apiToken: String?, KDownApi) -> LocalServerHandle)? = null, @@ -79,11 +80,13 @@ class BackendFactory( private fun createDefaultEmbeddedKDown( taskStore: TaskStore, + defaultDirectory: String, ): KDownApi { return KDown( httpEngine = KtorHttpEngine(), taskStore = taskStore, config = DownloadConfig( + defaultDirectory = defaultDirectory, maxConnections = 4, retryCount = 3, retryDelayMs = 1000, diff --git a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/state/AppState.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/state/AppState.kt index 8857a9f1..0661ee12 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/state/AppState.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/state/AppState.kt @@ -138,7 +138,7 @@ class AppState( } val request = DownloadRequest( url = url, - directory = "downloads", + directory = null, fileName = fileName.ifBlank { null }, connections = connections, speedLimit = speedLimit, diff --git a/app/shared/src/iosMain/kotlin/com/linroid/kdown/app/MainViewController.kt b/app/shared/src/iosMain/kotlin/com/linroid/kdown/app/MainViewController.kt index e74a3a95..50cf07da 100644 --- a/app/shared/src/iosMain/kotlin/com/linroid/kdown/app/MainViewController.kt +++ b/app/shared/src/iosMain/kotlin/com/linroid/kdown/app/MainViewController.kt @@ -7,15 +7,42 @@ import com.linroid.kdown.app.backend.BackendFactory import com.linroid.kdown.app.backend.BackendManager import com.linroid.kdown.sqlite.DriverFactory import com.linroid.kdown.sqlite.createSqliteTaskStore +import platform.Foundation.NSApplicationSupportDirectory +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSUserDomainMask @Suppress("unused", "FunctionName") fun MainViewController() = ComposeUIViewController { val backendManager = remember { - val taskStore = createSqliteTaskStore(DriverFactory()) - BackendManager(BackendFactory(taskStore = taskStore)) + val dbPath = appSupportDbPath() + val taskStore = createSqliteTaskStore(DriverFactory(dbPath)) + @Suppress("UNCHECKED_CAST") + val downloadsDir = (NSSearchPathForDirectoriesInDomains( + NSDocumentDirectory, NSUserDomainMask, true, + ) as List).first() + BackendManager( + BackendFactory( + taskStore = taskStore, + defaultDirectory = downloadsDir, + ) + ) } DisposableEffect(Unit) { onDispose { backendManager.close() } } App(backendManager) } + +@Suppress("UNCHECKED_CAST") +private fun appSupportDbPath(): String { + val paths = NSSearchPathForDirectoriesInDomains( + NSApplicationSupportDirectory, NSUserDomainMask, true, + ) as List + val dir = "${paths.first()}/kdown" + NSFileManager.defaultManager.createDirectoryAtPath( + dir, withIntermediateDirectories = true, attributes = null, + ) + return "$dir/kdown.db" +} diff --git a/cli/src/main/kotlin/com/linroid/kdown/cli/Main.kt b/cli/src/main/kotlin/com/linroid/kdown/cli/Main.kt index 1db6d220..87ecf572 100644 --- a/cli/src/main/kotlin/com/linroid/kdown/cli/Main.kt +++ b/cli/src/main/kotlin/com/linroid/kdown/cli/Main.kt @@ -229,7 +229,8 @@ private fun runServer(args: Array) { var port = 8642 var token: String? = null var corsOrigins: List = emptyList() - var downloadDir = "downloads" + var downloadDir = System.getProperty("user.home") + + File.separator + "Downloads" var speedLimit = SpeedLimit.Unlimited var i = 0 @@ -307,10 +308,13 @@ private fun runServer(args: Array) { File(downloadDir).mkdirs() - val dbPath = File(downloadDir, "kdown.db").absolutePath + val dbPath = defaultDbPath() val driver = DriverFactory(dbPath).createDriver() val taskStore = SqliteTaskStore(driver) - val config = DownloadConfig(speedLimit = speedLimit) + val config = DownloadConfig( + defaultDirectory = downloadDir, + speedLimit = speedLimit, + ) val kdown = KDown( httpEngine = KtorHttpEngine(), @@ -529,7 +533,7 @@ private fun printServerUsage() { println(" --cors CORS allowed origins,") println(" comma-separated (optional)") println(" --dir Download directory") - println(" (default: ./downloads)") + println(" (default: ~/Downloads)") println(" --speed-limit Global speed limit") println(" (e.g., 10m, 500k)") println(" --help, -h Show this help message") @@ -541,6 +545,29 @@ private fun printServerUsage() { println(" kdown-cli server --speed-limit 10m") } +private fun defaultDbPath(): String { + val os = System.getProperty("os.name", "").lowercase() + val home = System.getProperty("user.home") + val configDir = when { + os.contains("mac") -> + "$home${File.separator}Library${File.separator}" + + "Application Support${File.separator}kdown" + os.contains("win") -> { + val appData = System.getenv("APPDATA") + ?: "$home${File.separator}AppData${File.separator}Roaming" + "$appData${File.separator}kdown" + } + else -> { + val xdg = System.getenv("XDG_CONFIG_HOME") + ?: "$home${File.separator}.config" + "$xdg${File.separator}kdown" + } + } + val dir = File(configDir) + dir.mkdirs() + return File(dir, "kdown.db").absolutePath +} + private fun parsePriority(value: String): DownloadPriority? { return when (value.trim().lowercase()) { "low" -> DownloadPriority.LOW diff --git a/library/core/src/commonMain/kotlin/com/linroid/kdown/core/DownloadConfig.kt b/library/core/src/commonMain/kotlin/com/linroid/kdown/core/DownloadConfig.kt index ec83b7f9..fe75397c 100644 --- a/library/core/src/commonMain/kotlin/com/linroid/kdown/core/DownloadConfig.kt +++ b/library/core/src/commonMain/kotlin/com/linroid/kdown/core/DownloadConfig.kt @@ -5,6 +5,9 @@ import com.linroid.kdown.api.SpeedLimit /** * Download configuration. * + * @property defaultDirectory Default directory for saving downloaded files when + * [DownloadRequest.directory][com.linroid.kdown.api.DownloadRequest.directory] is `null`. + * Should be set to a platform-appropriate path by the app layer. * @property maxConnections Maximum number of concurrent segment downloads * @property retryCount Maximum number of retry attempts for failed requests * @property retryDelayMs Base delay in milliseconds between retry attempts (uses exponential backoff) @@ -15,6 +18,7 @@ import com.linroid.kdown.api.SpeedLimit * @property queueConfig Configuration for the download queue */ data class DownloadConfig( + val defaultDirectory: String = "downloads", val maxConnections: Int = 4, val retryCount: Int = 3, val retryDelayMs: Long = 1000, diff --git a/library/core/src/commonMain/kotlin/com/linroid/kdown/core/engine/DownloadCoordinator.kt b/library/core/src/commonMain/kotlin/com/linroid/kdown/core/engine/DownloadCoordinator.kt index 6e336d63..4fcd3e24 100644 --- a/library/core/src/commonMain/kotlin/com/linroid/kdown/core/engine/DownloadCoordinator.kt +++ b/library/core/src/commonMain/kotlin/com/linroid/kdown/core/engine/DownloadCoordinator.kt @@ -58,7 +58,7 @@ internal class DownloadCoordinator( segmentsFlow: MutableStateFlow>, ) { val directory = request.directory?.let { Path(it) } - ?: error("directory is required for download") + ?: Path(config.defaultDirectory) val initialDestPath = request.fileName?.let { Path(directory, it) } ?: directory @@ -206,7 +206,7 @@ internal class DownloadCoordinator( request, toServerInfo(resolvedUrl), ) val dir = request.directory?.let { Path(it) } - ?: error("directory is required for download") + ?: Path(config.defaultDirectory) val destPath = deduplicatePath(dir, fileName) val now = Clock.System.now() diff --git a/library/sqlite/src/iosMain/kotlin/com/linroid/kdown/sqlite/DriverFactory.ios.kt b/library/sqlite/src/iosMain/kotlin/com/linroid/kdown/sqlite/DriverFactory.ios.kt index 686e2068..b0c4424c 100644 --- a/library/sqlite/src/iosMain/kotlin/com/linroid/kdown/sqlite/DriverFactory.ios.kt +++ b/library/sqlite/src/iosMain/kotlin/com/linroid/kdown/sqlite/DriverFactory.ios.kt @@ -3,8 +3,8 @@ package com.linroid.kdown.sqlite import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver -actual class DriverFactory { +actual class DriverFactory(private val dbPath: String) { actual fun createDriver(): SqlDriver { - return NativeSqliteDriver(KDownDatabase.Schema, "kdown.db") + return NativeSqliteDriver(KDownDatabase.Schema, dbPath) } } diff --git a/library/sqlite/src/jvmMain/kotlin/com/linroid/kdown/sqlite/DriverFactory.jvm.kt b/library/sqlite/src/jvmMain/kotlin/com/linroid/kdown/sqlite/DriverFactory.jvm.kt index 0b8ede98..a7db85b7 100644 --- a/library/sqlite/src/jvmMain/kotlin/com/linroid/kdown/sqlite/DriverFactory.jvm.kt +++ b/library/sqlite/src/jvmMain/kotlin/com/linroid/kdown/sqlite/DriverFactory.jvm.kt @@ -2,12 +2,14 @@ package com.linroid.kdown.sqlite import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import java.io.File -actual class DriverFactory(private val dbPath: String = "kdown.db") { +actual class DriverFactory(private val dbPath: String) { actual fun createDriver(): SqlDriver { val url = if (dbPath == ":memory:") { JdbcSqliteDriver.IN_MEMORY } else { + File(dbPath).parentFile?.mkdirs() "jdbc:sqlite:$dbPath" } val driver = JdbcSqliteDriver(url)