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)