Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,9 +25,14 @@ class MainActivity : ComponentActivity() {
val taskStore = createSqliteTaskStore(
DriverFactory(applicationContext)
)
val downloadsDir = Environment
.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS
Comment on lines +28 to +30

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use app-scoped storage for Android default downloads

Setting the default directory to Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) routes writes to shared external storage, but this app only declares INTERNET in app/android/src/main/AndroidManifest.xml and writes files via RandomAccessFile. On modern Android (targetSdk 36), direct writes to /storage/emulated/0/Download are blocked for normal apps, so downloads started with the default settings can fail with permission/disk errors.

Useful? React with 👍 / 👎.

).absolutePath
BackendManager(
BackendFactory(
taskStore = taskStore,
defaultDirectory = downloadsDir,
localServerFactory = { port, apiToken, kdownApi ->
val server = KDownServer(
kdownApi,
Expand Down
27 changes: 26 additions & 1 deletion app/desktop/src/main/kotlin/com/linroid/kdown/desktop/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ class AppState(
}
val request = DownloadRequest(
url = url,
directory = "downloads",
directory = null,
fileName = fileName.ifBlank { null },
connections = connections,
speedLimit = speedLimit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>).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<String>
val dir = "${paths.first()}/kdown"
NSFileManager.defaultManager.createDirectoryAtPath(
dir, withIntermediateDirectories = true, attributes = null,
)
return "$dir/kdown.db"
}
35 changes: 31 additions & 4 deletions cli/src/main/kotlin/com/linroid/kdown/cli/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,8 @@ private fun runServer(args: Array<String>) {
var port = 8642
var token: String? = null
var corsOrigins: List<String> = emptyList()
var downloadDir = "downloads"
var downloadDir = System.getProperty("user.home") +
File.separator + "Downloads"
var speedLimit = SpeedLimit.Unlimited

var i = 0
Expand Down Expand Up @@ -307,10 +308,13 @@ private fun runServer(args: Array<String>) {

File(downloadDir).mkdirs()

val dbPath = File(downloadDir, "kdown.db").absolutePath
val dbPath = defaultDbPath()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve legacy CLI database when changing default DB path

Switching the server DB location to defaultDbPath() removes the previous <downloadDir>/kdown.db default with no fallback, so users upgrading from older versions can appear to lose all persisted tasks/history because the server opens a new empty DB instead of the existing one. Add a migration or legacy-path fallback when the new path is absent.

Useful? React with 👍 / 👎.

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(),
Expand Down Expand Up @@ -529,7 +533,7 @@ private fun printServerUsage() {
println(" --cors <origins> CORS allowed origins,")
println(" comma-separated (optional)")
println(" --dir <path> Download directory")
println(" (default: ./downloads)")
println(" (default: ~/Downloads)")
println(" --speed-limit <value> Global speed limit")
println(" (e.g., 10m, 500k)")
println(" --help, -h Show this help message")
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ internal class DownloadCoordinator(
segmentsFlow: MutableStateFlow<List<Segment>>,
) {
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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently NativeSqliteDriver only accepts a filename, not a full path?

Uncaught Kotlin exception: kotlin.IllegalArgumentException: File /Users/mt/Library/Developer/CoreSimulator/Devices/40ED1A06-7801-431B-9D84-F9FE5EC630A0/data/Containers/Data/Application/C5D6C87E-FB93-4D18-9712-C34F18E385DF/Library/Application Support/kdown/kdown.db contains a path separator  
    at 0   KDown.debug.dylib                   0x10a8c3b43        kfun:kotlin.Throwable#<init>(kotlin.String?){} + 99   
    at 1   KDown.debug.dylib                   0x10a8be113        kfun:kotlin.Exception#<init>(kotlin.String?){} + 95   
    at 2   KDown.debug.dylib                   0x10a8be2d3        kfun:kotlin.RuntimeException#<init>(kotlin.String?){} + 95

Copy link
Owner Author

@linroid linroid Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @markst for your feedback!

I'm curious how did you find my project, I'm developing this project to learn using Claude Code effectively, it's just started a few days ago.

@claude Help me address the crash.

Copy link

@claude claude bot Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @linroid's task in 2m 27s —— View job


Fixing iOS NativeSqliteDriver crash

Issue: NativeSqliteDriver throws IllegalArgumentException because it only accepts a filename, not a full path with separators.

Root Cause: The iOS app was passing Library/Application Support/kdown/kdown.db as the full path, but NativeSqliteDriver only accepts a database name (filename without path separators). The database is always created in the application's default database directory.

Solution:

1. Updated library/sqlite/src/iosMain/kotlin/com/linroid/kdown/sqlite/DriverFactory.ios.kt:

actual class DriverFactory(private val dbPath: String) {
  actual fun createDriver(): SqlDriver {
    // NativeSqliteDriver only accepts a database name (filename without path separators)
    // The database will be created in the application's default database directory.
    // We extract just the filename from the provided path.
    val fileName = dbPath.substringAfterLast('/')
    return NativeSqliteDriver(KDownDatabase.Schema, fileName)
  }
}

2. Updated app/shared/src/iosMain/kotlin/com/linroid/kdown/app/MainViewController.kt:

fun MainViewController() = ComposeUIViewController {
  val backendManager = remember {
    // NativeSqliteDriver only accepts a filename, not a full path.
    // The database will be stored in the application's default database directory.
    val taskStore = createSqliteTaskStore(DriverFactory("kdown.db"))
    // ... rest of the code
  }
}

Removed the appSupportDbPath() function and unused imports (NSApplicationSupportDirectory, NSFileManager).

Todo List:

  • Read iOS DriverFactory implementation
  • Read iOS app entry point to understand path construction
  • Implement fix to handle directory creation and use filename only
  • Verify compilation
  • Commit and push changes
  • Provide PR link

Note: I need your approval to commit these changes. The commit is staged and ready. Once you approve the git commit command in your terminal, I'll push the changes and provide the PR link.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude Use the dbName as the DriverFactory constructor parameter

Copy link

@claude claude bot Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @linroid's task in 6m 14s —— View job


Refactoring iOS DriverFactory to use dbName parameter

Todo List:

  • Read current iOS DriverFactory implementation
  • Read iOS app entry point call site
  • Change constructor parameter from dbPath to dbName
  • Update implementation to use dbName directly
  • Update call sites to pass database name only
  • Stage changes for commit
  • Waiting for commit approval
  • Push to remote
  • Provide PR creation link

Changes Made:

  1. library/sqlite/src/iosMain/kotlin/com/linroid/kdown/sqlite/DriverFactory.ios.kt:

    • Renamed constructor parameter from dbPath to dbName to clarify that only a database name (not a path) should be passed
    • Updated usage to pass dbName directly to NativeSqliteDriver
  2. app/shared/src/iosMain/kotlin/com/linroid/kdown/app/MainViewController.kt:

    • Simplified call site to pass just "kdown.db" instead of constructing a full path
    • Removed unused appSupportDbPath() function
    • Removed unused imports: NSApplicationSupportDirectory, NSFileManager
    • Added clarifying comment about NativeSqliteDriver behavior

Next Steps:
The changes have been staged. Please approve the commit command in your terminal, then I'll push the changes and provide the PR link.


Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude Last try: fix it and push to create PR, don't wait for my approval, I already approved. If your environment can't do that let me know I'll do it in terminal. Or can you send the session link, I can't find from claude.ai/code or github thread

Copy link

@claude claude bot Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @linroid's task in 1m 54s —— View job


Unable to commit without permission

I've implemented the iOS NativeSqliteDriver fix, but I cannot commit without permission approval in the workflow. The GitHub Actions environment requires approval for git commit commands.

Changes made:

  1. library/sqlite/src/iosMain/kotlin/com/linroid/kdown/sqlite/DriverFactory.ios.kt: Renamed parameter from dbPath to dbName with clarifying comment
  2. app/shared/src/iosMain/kotlin/com/linroid/kdown/app/MainViewController.kt: Pass "kdown.db" directly, removed unused imports and helper function

Files are staged and ready to commit.

Since the environment blocks git commits, you'll need to run this in your terminal:

git commit -m "fix: use dbName parameter for iOS NativeSqliteDriver

NativeSqliteDriver only accepts a database filename (no path separators).
Renamed DriverFactory parameter from dbPath to dbName and updated iOS app
to pass 'kdown.db' directly instead of constructing a full path.

Fixes crash: File ... contains a path separator

Co-authored-by: Lin Zhang <linroid@users.noreply.github.com>"

git push origin claude/pr-58-20260213-0942

Then create a PR with this URL:
Create PR →


Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems claude bot can't push in a comment thread.

Fixed in #66

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious how did you find my project

I just searched in github: https://github.com/search?q=Kotlin%20Multiplatform%20download%20manager&type=repositories

I'm surprised there isn't more of a community of multiplatform libraries.
Take a look at my repo here: http://github.com/markst/radioplayer-kt

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your information!

This library is still under active development, so I wouldn’t recommend using it i production until it becomes more stable.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading