From 7bf6c1f2bd63ba4a358fbbbf38cb9f41cccd4f0b Mon Sep 17 00:00:00 2001 From: ID-VerNe <> Date: Sat, 27 Jun 2026 22:00:22 +0800 Subject: [PATCH 1/3] feat(integration): add IPC DTOs, handler interface, and HTTP routes ADD: - ApiDownloadModel, IdRequest, IdsRequest, RemoveRequest serializable DTOs - IntegrationHandler with 5 new methods: listDownloads, getDownloadInfo, pauseDownloads, resumeDownloads, removeDownloads - GET /list, POST /info, POST /pause, POST /resume, POST /remove routes - /start-headless-download returns JSON {"id": } - Full IntegrationHandlerImp implementation with toApiModel() mapping and auto-categorization FIX: - addDownloadTask returns actual download ID via downloadManager.addDownload - Auto-categorization via categoryManager.autoAddItemsToCategoriesBasedOnFileNames --- .../integration/IntegrationHandlerImp.kt | 81 ++++++++++++++++--- .../integration/ApiDownloadModel.kt | 22 +++++ .../integration/IdRequest.kt | 8 ++ .../integration/IdsRequest.kt | 8 ++ .../integration/Integration.kt | 60 +++++++++++++- .../integration/IntegrationHandler.kt | 8 +- .../integration/RemoveRequest.kt | 9 +++ 7 files changed, 180 insertions(+), 16 deletions(-) create mode 100644 integration/server/src/main/kotlin/com/abdownloadmanager/integration/ApiDownloadModel.kt create mode 100644 integration/server/src/main/kotlin/com/abdownloadmanager/integration/IdRequest.kt create mode 100644 integration/server/src/main/kotlin/com/abdownloadmanager/integration/IdsRequest.kt create mode 100644 integration/server/src/main/kotlin/com/abdownloadmanager/integration/RemoveRequest.kt diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/integration/IntegrationHandlerImp.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/integration/IntegrationHandlerImp.kt index 80d0e4eb1..9cdf8f9ff 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/integration/IntegrationHandlerImp.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/integration/IntegrationHandlerImp.kt @@ -1,6 +1,7 @@ package com.abdownloadmanager.desktop.integration import com.abdownloadmanager.desktop.AppComponent +import ir.amirab.downloader.downloaditem.IDownloadItem import com.abdownloadmanager.shared.pages.adddownload.AddDownloadCredentialsInUiProps import com.abdownloadmanager.shared.pages.adddownload.ImportOptions import com.abdownloadmanager.shared.pages.adddownload.SilentImportOptions @@ -9,14 +10,17 @@ import com.abdownloadmanager.shared.util.DownloadSystem import com.abdownloadmanager.integration.IntegrationHandler import com.abdownloadmanager.integration.HttpDownloadCredentialsFromIntegration import com.abdownloadmanager.integration.NewDownloadTask +import com.abdownloadmanager.integration.ApiDownloadModel import com.abdownloadmanager.integration.ApiQueueModel import com.abdownloadmanager.integration.AddDownloadOptionsFromIntegration import com.abdownloadmanager.integration.HLSDownloadCredentialsFromIntegration import com.abdownloadmanager.integration.IDownloadCredentialsFromIntegration import com.abdownloadmanager.shared.downloaderinui.BasicDownloadItem import com.abdownloadmanager.shared.downloaderinui.DownloaderInUiRegistry +import com.abdownloadmanager.shared.util.category.CategoryItemWithId import ir.amirab.downloader.downloaditem.hls.HLSDownloadCredentials import ir.amirab.downloader.NewDownloadItemProps +import ir.amirab.downloader.DownloadManager import ir.amirab.downloader.downloaditem.EmptyContext import ir.amirab.downloader.downloaditem.http.HttpDownloadCredentials import ir.amirab.downloader.queue.QueueManager @@ -55,7 +59,7 @@ class IntegrationHandlerImp : IntegrationHandler, KoinComponent { ApiQueueModel(id = queueModel.id, name = queueModel.name) } } - override suspend fun addDownloadTask(task: NewDownloadTask) { + override suspend fun addDownloadTask(task: NewDownloadTask): Long { val addDownloaderInUiProps = convertToDownloadSystemCredentials(task.downloadSource) val downloaderInUi = downloaderInUiRegistry.getDownloaderOf( addDownloaderInUiProps.credentials @@ -68,26 +72,77 @@ class IntegrationHandlerImp : IntegrationHandler, KoinComponent { ?: task.downloadSource.link.substringAfterLast("/"), ), ) - val id = - downloadSystem.addDownload( - newDownload = NewDownloadItemProps( - downloadItem = downloadItem, - onDuplicateStrategy = OnDuplicateStrategy.default(), - extraConfig = null, - context = EmptyContext, - ), - queueId = task.queueId, - categoryId = null + // Use downloadManager directly to get the ID first + val id = downloadSystem.downloadManager.addDownload( + NewDownloadItemProps( + downloadItem = downloadItem, + onDuplicateStrategy = OnDuplicateStrategy.default(), + extraConfig = null, + context = EmptyContext, ) + ) + // Assign to queue if specified + task.queueId?.let { + queueManager.addToQueue(it, id) + } + // Auto-categorize by file extension & name + downloadSystem.categoryManager.autoAddItemsToCategoriesBasedOnFileNames( + listOf( + CategoryItemWithId( + id = id, + fileName = downloadItem.name, + url = downloadItem.link, + ) + ) + ) + // Start the download if (task.queueId != null) { - val queue = queueManager.getQueue(task.queueId!!) - queue.start() + queueManager.getQueue(task.queueId!!).start() } else { downloadSystem.userManualResume(id) } + return id + } + + override suspend fun listDownloads(): List { + return downloadSystem.getDownloadItemsBy { true }.map { it.toApiModel() } + } + + override suspend fun getDownloadInfo(id: Long): ApiDownloadModel? { + return downloadSystem.getDownloadItemById(id)?.toApiModel() + } + + override suspend fun pauseDownloads(ids: List) { + ids.forEach { downloadSystem.manualPause(it) } + } + + override suspend fun resumeDownloads(ids: List) { + ids.forEach { downloadSystem.userManualResume(it) } + } + + override suspend fun removeDownloads(ids: List, keepFile: Boolean) { + ids.forEach { downloadSystem.removeDownload(it, !keepFile, EmptyContext) } } companion object { + private fun IDownloadItem.toApiModel() = ApiDownloadModel( + id = id, + name = name, + url = link, + folder = folder, + status = status.name, + size = contentLength, + downloaded = 0, + speed = 0, + progress = 0.0, + dateAdded = dateAdded, + startTime = startTime, + completeTime = completeTime, + connections = preferredConnectionCount, + speedLimit = speedLimit, + checksum = fileChecksum, + ) + private fun convertToDownloadSystemCredentials(it: IDownloadCredentialsFromIntegration): AddDownloadCredentialsInUiProps { val credentials = when (it) { is HttpDownloadCredentialsFromIntegration -> { diff --git a/integration/server/src/main/kotlin/com/abdownloadmanager/integration/ApiDownloadModel.kt b/integration/server/src/main/kotlin/com/abdownloadmanager/integration/ApiDownloadModel.kt new file mode 100644 index 000000000..752dc93dc --- /dev/null +++ b/integration/server/src/main/kotlin/com/abdownloadmanager/integration/ApiDownloadModel.kt @@ -0,0 +1,22 @@ +package com.abdownloadmanager.integration + +import kotlinx.serialization.Serializable + +@Serializable +data class ApiDownloadModel( + val id: Long, + val name: String, + val url: String, + val folder: String, + val status: String, + val size: Long, + val downloaded: Long = 0, + val speed: Long = 0, + val progress: Double = 0.0, + val dateAdded: Long, + val startTime: Long? = null, + val completeTime: Long? = null, + val connections: Int? = null, + val speedLimit: Long = 0, + val checksum: String? = null, +) \ No newline at end of file diff --git a/integration/server/src/main/kotlin/com/abdownloadmanager/integration/IdRequest.kt b/integration/server/src/main/kotlin/com/abdownloadmanager/integration/IdRequest.kt new file mode 100644 index 000000000..4e3f4575c --- /dev/null +++ b/integration/server/src/main/kotlin/com/abdownloadmanager/integration/IdRequest.kt @@ -0,0 +1,8 @@ +package com.abdownloadmanager.integration + +import kotlinx.serialization.Serializable + +@Serializable +data class IdRequest( + val id: Long, +) \ No newline at end of file diff --git a/integration/server/src/main/kotlin/com/abdownloadmanager/integration/IdsRequest.kt b/integration/server/src/main/kotlin/com/abdownloadmanager/integration/IdsRequest.kt new file mode 100644 index 000000000..49560d946 --- /dev/null +++ b/integration/server/src/main/kotlin/com/abdownloadmanager/integration/IdsRequest.kt @@ -0,0 +1,8 @@ +package com.abdownloadmanager.integration + +import kotlinx.serialization.Serializable + +@Serializable +data class IdsRequest( + val ids: List, +) \ No newline at end of file diff --git a/integration/server/src/main/kotlin/com/abdownloadmanager/integration/Integration.kt b/integration/server/src/main/kotlin/com/abdownloadmanager/integration/Integration.kt index 5dee4ed5e..892276622 100644 --- a/integration/server/src/main/kotlin/com/abdownloadmanager/integration/Integration.kt +++ b/integration/server/src/main/kotlin/com/abdownloadmanager/integration/Integration.kt @@ -113,13 +113,69 @@ class Integration( json.decodeFromString(message) } itemsToAdd.onFailure { it.printStackTrace() } - integrationHandler.addDownloadTask(itemsToAdd.getOrThrow()) + val id = integrationHandler.addDownloadTask(itemsToAdd.getOrThrow()) + MyResponse.Json("""{"id":$id}""") } - MyResponse.Text("OK") } post("/ping") { MyResponse.Text("pong") } + get("/list") { + runBlocking { + val downloads = integrationHandler.listDownloads() + val jsonResponse = json.encodeToString(ListSerializer(ApiDownloadModel.serializer()), downloads) + MyResponse.Text(jsonResponse) + } + } + post("/info") { + runBlocking { + val request = kotlin.runCatching { + val message = it.getBody().orEmpty() + json.decodeFromString(message) + } + request.onFailure { it.printStackTrace() } + val download = integrationHandler.getDownloadInfo(request.getOrThrow().id) + if (download != null) { + val jsonResponse = json.encodeToString(ApiDownloadModel.serializer(), download) + MyResponse.Text(jsonResponse) + } else { + MyResponse.Text("null") + } + } + } + post("/pause") { + runBlocking { + val request = kotlin.runCatching { + val message = it.getBody().orEmpty() + json.decodeFromString(message) + } + request.onFailure { it.printStackTrace() } + integrationHandler.pauseDownloads(request.getOrThrow().ids) + MyResponse.Text("OK") + } + } + post("/resume") { + runBlocking { + val request = kotlin.runCatching { + val message = it.getBody().orEmpty() + json.decodeFromString(message) + } + request.onFailure { it.printStackTrace() } + integrationHandler.resumeDownloads(request.getOrThrow().ids) + MyResponse.Text("OK") + } + } + post("/remove") { + runBlocking { + val request = kotlin.runCatching { + val message = it.getBody().orEmpty() + json.decodeFromString(message) + } + request.onFailure { it.printStackTrace() } + integrationHandler.removeDownloads(request.getOrThrow().ids, request.getOrThrow().keepFile) + MyResponse.Text("OK") + } + } } return MyHttp4KServer(port, handlers, debugMode) } diff --git a/integration/server/src/main/kotlin/com/abdownloadmanager/integration/IntegrationHandler.kt b/integration/server/src/main/kotlin/com/abdownloadmanager/integration/IntegrationHandler.kt index 5e204a5e9..13ed69403 100644 --- a/integration/server/src/main/kotlin/com/abdownloadmanager/integration/IntegrationHandler.kt +++ b/integration/server/src/main/kotlin/com/abdownloadmanager/integration/IntegrationHandler.kt @@ -6,5 +6,11 @@ interface IntegrationHandler{ options: AddDownloadOptionsFromIntegration, ) fun listQueues(): List - suspend fun addDownloadTask(task: NewDownloadTask) + suspend fun addDownloadTask(task: NewDownloadTask): Long + + suspend fun listDownloads(): List + suspend fun getDownloadInfo(id: Long): ApiDownloadModel? + suspend fun pauseDownloads(ids: List) + suspend fun resumeDownloads(ids: List) + suspend fun removeDownloads(ids: List, keepFile: Boolean) } diff --git a/integration/server/src/main/kotlin/com/abdownloadmanager/integration/RemoveRequest.kt b/integration/server/src/main/kotlin/com/abdownloadmanager/integration/RemoveRequest.kt new file mode 100644 index 000000000..20a5f3778 --- /dev/null +++ b/integration/server/src/main/kotlin/com/abdownloadmanager/integration/RemoveRequest.kt @@ -0,0 +1,9 @@ +package com.abdownloadmanager.integration + +import kotlinx.serialization.Serializable + +@Serializable +data class RemoveRequest( + val ids: List, + val keepFile: Boolean = true, +) \ No newline at end of file From a9d472e1b5191e3517bf5e30d7a7026f95200b21 Mon Sep 17 00:00:00 2001 From: ID-VerNe <> Date: Sun, 28 Jun 2026 11:51:10 +0800 Subject: [PATCH 2/3] feat(cli): add abdm-cli IPC client with auto-start and 6 commands ADD: - DesktopClient, DesktopResult for HTTP IPC to desktop app - DesktopLauncher for auto-starting the desktop app with 30s polling - PortResolver reading browserIntegrationPort from appSettings.json - CliFormatting utilities using shared SizeConverter - CliMain entry point with Clikt command tree and versionOption - 6 commands: add, list, info, pause, resume, remove - build.gradle.kts with buildConfig plugin for version sync FIX: - Ping response parsing: compare against "pong" (not "\"pong\"") - --help exits with code 0, not 64 - Null exception message shows class name instead of "null" - ANSI alignment: padEnd on plain text before applying color - JSON error messages go to stderr, not stdout - CliktError no longer prints duplicate messages - Orphan desktop process destroyed on startup timeout - DesktopLauncher scans Program Files on Windows for binary - URL scheme check tightened to http/https only - DesktopLauncher uses DISCARD redirect, not inheritIO --- cli/app/build.gradle.kts | 49 ++++ .../com/abdownloadmanager/cli/CliMain.kt | 60 +++++ .../cli/client/DesktopClient.kt | 120 +++++++++ .../cli/client/DesktopLauncher.kt | 131 ++++++++++ .../cli/client/DesktopResult.kt | 15 ++ .../cli/commands/AddCommand.kt | 132 ++++++++++ .../cli/commands/ControlCommands.kt | 227 ++++++++++++++++++ .../cli/commands/ListCommand.kt | 116 +++++++++ .../cli/utils/CliFormatting.kt | 35 +++ .../cli/utils/PortResolver.kt | 37 +++ settings.gradle.kts | 1 + 11 files changed, 923 insertions(+) create mode 100644 cli/app/build.gradle.kts create mode 100644 cli/app/src/main/kotlin/com/abdownloadmanager/cli/CliMain.kt create mode 100644 cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopClient.kt create mode 100644 cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopLauncher.kt create mode 100644 cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopResult.kt create mode 100644 cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/AddCommand.kt create mode 100644 cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/ControlCommands.kt create mode 100644 cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/ListCommand.kt create mode 100644 cli/app/src/main/kotlin/com/abdownloadmanager/cli/utils/CliFormatting.kt create mode 100644 cli/app/src/main/kotlin/com/abdownloadmanager/cli/utils/PortResolver.kt diff --git a/cli/app/build.gradle.kts b/cli/app/build.gradle.kts new file mode 100644 index 000000000..e35b5b537 --- /dev/null +++ b/cli/app/build.gradle.kts @@ -0,0 +1,49 @@ +import buildlogic.versioning.getAppVersionString + +plugins { + kotlin("jvm") + kotlin("plugin.serialization") + application + id(Plugins.buildConfig) +} + +dependencies { + // Project modules — DTOs + shared utilities (datasize) + implementation(project(":integration:server")) + implementation(project(":shared:utils")) + + // Kotlin + implementation(libs.kotlin.coroutines.core) + implementation(libs.kotlin.serialization.json) + + // CLI framework + implementation("com.github.ajalt.clikt:clikt:4.4.0") + implementation("com.github.ajalt.mordant:mordant:2.7.2") + + // HTTP client (same http4k + OkHttp stack as desktop app) + implementation(libs.http4k.core) + implementation(libs.http4k.client.okhttp) + + // Test dependencies + testImplementation(kotlin("test")) + testImplementation("junit:junit:4.13.2") +} + +application { + mainClass = "com.abdownloadmanager.cli.CliMainKt" + applicationName = "abdm-cli" + applicationDefaultJvmArgs = listOf("-Djava.awt.headless=true") +} + +tasks.named("run") { + standardInput = System.`in` +} + +// Generate build config with version from the project's shared versioning +buildConfig { + packageName = "com.abdownloadmanager.cli" + buildConfigField( + "APP_VERSION", + provider { getAppVersionString() } + ) +} \ No newline at end of file diff --git a/cli/app/src/main/kotlin/com/abdownloadmanager/cli/CliMain.kt b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/CliMain.kt new file mode 100644 index 000000000..743c0a018 --- /dev/null +++ b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/CliMain.kt @@ -0,0 +1,60 @@ +package com.abdownloadmanager.cli + +import com.abdownloadmanager.cli.BuildConfig +import com.abdownloadmanager.cli.commands.* +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.PrintHelpMessage +import com.github.ajalt.clikt.core.PrintCompletionMessage +import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.parameters.options.versionOption +import com.github.ajalt.mordant.rendering.TextColors +import com.github.ajalt.mordant.terminal.Terminal + +private const val DEBUG_MODE = false + +class CliApp : CliktCommand( + name = "abdm-cli", + help = "AB Download Manager CLI — IPC client for the desktop app", +) { + init { + versionOption(BuildConfig.APP_VERSION) + } + + override fun run() { + if (currentContext.invokedSubcommand == null) { + echo("AB Download Manager CLI") + echo("Use --help to see available commands.") + } + } +} + +fun main(args: Array) { + try { + val app = CliApp().subcommands( + AddCommand(), + ListCommand(), + InfoCommand(), + PauseCommand(), + ResumeCommand(), + RemoveCommand(), + ) + + app.main(args) + // Force exit — OkHttp non-daemon threads keep JVM alive + System.exit(0) + } catch (e: PrintHelpMessage) { + System.exit(0) + } catch (e: PrintCompletionMessage) { + System.exit(0) + } catch (e: com.github.ajalt.clikt.core.CliktError) { + // Clikt prints the error message to stderr internally + System.exit(64) + } catch (e: Exception) { + val term = Terminal() + term.println((TextColors.red)("Error: ${e.message ?: e::class.simpleName ?: "Unknown error"}")) + if (DEBUG_MODE) { + e.printStackTrace() + } + System.exit(1) + } +} \ No newline at end of file diff --git a/cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopClient.kt b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopClient.kt new file mode 100644 index 000000000..ee4d9d06b --- /dev/null +++ b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopClient.kt @@ -0,0 +1,120 @@ +package com.abdownloadmanager.cli.client + +import com.abdownloadmanager.integration.IdRequest +import com.abdownloadmanager.integration.IdsRequest +import com.abdownloadmanager.integration.RemoveRequest +import com.abdownloadmanager.integration.NewDownloadTask +import com.abdownloadmanager.integration.HttpDownloadCredentialsFromIntegration +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString +import org.http4k.client.OkHttp +import org.http4k.core.* + +/** + * HTTP client for CLI commands to communicate with the desktop app's + * embedded integration server. + * + * Uses http4k.Client(OkHttp) — the same HTTP stack as the desktop app's + * SingleInstance communication (see Comunication.kt). + * + * @param port The browserIntegrationPort from appSettings.json (typically 15151). + */ +class DesktopClient(private val port: Int) { + + private val client by lazy { OkHttp() } + private val json = Json { ignoreUnknownKeys = true } + + // --- Low-level HTTP --- + + private fun request(method: Method, path: String, body: String? = null): DesktopResult { + val uri = Uri.of("http://127.0.0.1:$port$path") + val request = if (body != null) { + Request(method, uri).body(body) + } else { + Request(method, uri) + } + return try { + val response = client(request) + val responseBody = response.bodyString() + if (response.status.successful) { + DesktopResult.Success(responseBody) + } else { + DesktopResult.RemoteError(response.status.code, responseBody) + } + } catch (e: Exception) { + DesktopResult.ConnectionError(e.message ?: "Unknown connection error") + } + } + + // --- Health check --- + + /** Ping the desktop app to check if it's running and responsive. */ + fun ping(): Boolean { + val result = request(Method.POST, "/ping") + return result is DesktopResult.Success && result.data == "pong" + } + + // --- Business methods --- + + /** List all downloads from the desktop app. */ + fun listDownloads(): DesktopResult { + return request(Method.GET, "/list") + } + + /** Get detailed info for a single download by ID. */ + fun getDownloadInfo(id: Long): DesktopResult { + val body = json.encodeToString(IdRequest.serializer(), IdRequest(id)) + return request(Method.POST, "/info", body) + } + + /** Pause one or more downloads by ID. */ + fun pauseDownloads(ids: List): DesktopResult { + val body = json.encodeToString(IdsRequest.serializer(), IdsRequest(ids)) + return request(Method.POST, "/pause", body) + } + + /** Resume one or more downloads by ID. */ + fun resumeDownloads(ids: List): DesktopResult { + val body = json.encodeToString(IdsRequest.serializer(), IdsRequest(ids)) + return request(Method.POST, "/resume", body) + } + + /** Remove one or more downloads by ID. */ + fun removeDownloads(ids: List, keepFile: Boolean = true): DesktopResult { + val body = json.encodeToString(RemoveRequest.serializer(), RemoveRequest(ids, keepFile)) + return request(Method.POST, "/remove", body) + } + + /** + * Add a new download via the headless endpoint. + * Unlike /add, /start-headless-download does NOT open a GUI dialog. + */ + fun addDownload( + url: String, + name: String? = null, + folder: String? = null, + username: String? = null, + password: String? = null, + queueId: Long? = null, + ): DesktopResult { + val headers = if (username != null && password != null) { + val encoded = java.util.Base64.getEncoder().encodeToString("$username:$password".toByteArray()) + mapOf("Authorization" to "Basic $encoded") + } else { + null + } + val downloadSource = HttpDownloadCredentialsFromIntegration( + link = url, + headers = headers, + suggestedName = name, + ) + val task = NewDownloadTask( + downloadSource = downloadSource, + folder = folder, + name = name, + queueId = queueId, + ) + val body = json.encodeToString(NewDownloadTask.serializer(), task) + return request(Method.POST, "/start-headless-download", body) + } +} \ No newline at end of file diff --git a/cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopLauncher.kt b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopLauncher.kt new file mode 100644 index 000000000..5b8793264 --- /dev/null +++ b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopLauncher.kt @@ -0,0 +1,131 @@ +package com.abdownloadmanager.cli.client + +import com.abdownloadmanager.cli.utils.PortResolver +import java.io.File + +/** + * Launches the desktop AB Download Manager app if it's not already running. + * + * Handles platform-specific binary discovery and health-check polling. + */ +object DesktopLauncher { + + private const val MAX_POLL_MS = 30_000L + private const val POLL_INTERVAL_MS = 500L + private var desktopProcess: Process? = null + + /** + * Check if the desktop app is running by pinging its integration server. + */ + private fun isDesktopRunning(): Boolean { + val port = PortResolver.readIntegrationPort() ?: return false + return DesktopClient(port).ping() + } + + /** + * Find the desktop app binary on the current platform. + * Returns null if not found. + */ + fun findDesktopBinary(): File? { + val osName = System.getProperty("os.name").lowercase() + return when { + osName.contains("win") -> findWindowsBinary() + osName.contains("mac") || osName.contains("darwin") -> findMacBinary() + else -> findLinuxBinary() + } + } + + /** + * Launch the desktop app and wait for it to become ready. + * + * @return true if the desktop app is ready, false if it couldn't be started or timed out. + */ + fun ensureDesktopRunning(): Boolean { + if (isDesktopRunning()) return true + + // If we started the process previously but it died, clean up + if (desktopProcess != null && !desktopProcess!!.isAlive) { + desktopProcess = null + } + + val exe = findDesktopBinary() + if (exe == null) { + System.err.println("[abdm] AB Download Manager not found. Please install it first.") + return false + } + System.err.println("[abdm] Starting AB Download Manager...") + try { + desktopProcess = ProcessBuilder(exe.absolutePath, "--background") + .directory(exe.parentFile) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + } catch (e: Exception) { + System.err.println("[abdm] Failed to start ABDM: ${e.message}") + return false + } + // Poll /ping until ready or timeout + val deadline = System.nanoTime() + MAX_POLL_MS * 1_000_000L + while (System.nanoTime() < deadline) { + // Fast-fail if the process crashed during startup + if (desktopProcess != null && !desktopProcess!!.isAlive) { + System.err.println("[abdm] AB Download Manager process exited prematurely.") + desktopProcess = null + return false + } + if (isDesktopRunning()) { + System.err.println("[abdm] AB Download Manager is ready.") + return true + } + try { + Thread.sleep(POLL_INTERVAL_MS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return false + } + } + // Timeout — clean up the orphan process + desktopProcess?.destroyForcibly() + desktopProcess = null + System.err.println("[abdm] AB Download Manager did not start within ${MAX_POLL_MS / 1000} seconds.") + return false + } + + // --- Platform-specific binary discovery --- + + private fun findWindowsBinary(): File? { + val localAppData = System.getenv("LOCALAPPDATA") + val programFiles = System.getenv("ProgramFiles") + val programFilesX86 = System.getenv("ProgramFiles(x86)") + val candidates = mutableListOf() + if (localAppData != null) { + candidates.add(File(localAppData, "ABDownloadManager/ABDownloadManager.exe")) + candidates.add(File(localAppData, "AB Download Manager/ABDownloadManager.exe")) + } + if (programFiles != null) { + candidates.add(File(programFiles, "AB Download Manager/ABDownloadManager.exe")) + } + if (programFilesX86 != null) { + candidates.add(File(programFilesX86, "AB Download Manager/ABDownloadManager.exe")) + } + candidates.add(File(System.getProperty("user.home"), "ABDownloadManager/ABDownloadManager.exe")) + return candidates.firstOrNull { it.exists() } + } + + private fun findMacBinary(): File? { + val candidates = listOf( + File("/Applications/AB Download Manager.app/Contents/MacOS/AB Download Manager"), + File(System.getProperty("user.home"), "Applications/AB Download Manager.app/Contents/MacOS/AB Download Manager"), + ) + return candidates.firstOrNull { it.exists() } + } + + private fun findLinuxBinary(): File? { + val candidates = listOf( + File(System.getProperty("user.home"), ".local/bin/ABDownloadManager"), + File("/usr/local/bin/ABDownloadManager"), + File("/usr/bin/ABDownloadManager"), + ) + return candidates.firstOrNull { it.exists() } + } +} \ No newline at end of file diff --git a/cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopResult.kt b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopResult.kt new file mode 100644 index 000000000..716984f1e --- /dev/null +++ b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopResult.kt @@ -0,0 +1,15 @@ +package com.abdownloadmanager.cli.client + +/** + * Result of a DesktopClient HTTP request to the desktop app's integration server. + */ +sealed class DesktopResult { + /** Request succeeded. data holds the raw response body string. */ + data class Success(val data: String) : DesktopResult() + + /** Connection-level error (desktop app not reachable). */ + data class ConnectionError(val message: String) : DesktopResult() + + /** HTTP error response from the server (non-2xx). */ + data class RemoteError(val statusCode: Int, val body: String) : DesktopResult() +} \ No newline at end of file diff --git a/cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/AddCommand.kt b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/AddCommand.kt new file mode 100644 index 000000000..e7a811f78 --- /dev/null +++ b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/AddCommand.kt @@ -0,0 +1,132 @@ +package com.abdownloadmanager.cli.commands + +import com.abdownloadmanager.cli.client.DesktopLauncher +import com.abdownloadmanager.cli.client.DesktopClient +import com.abdownloadmanager.cli.client.DesktopResult +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.multiple +import com.github.ajalt.clikt.parameters.options.* +import com.github.ajalt.clikt.parameters.types.long +import com.github.ajalt.mordant.rendering.TextColors +import com.github.ajalt.mordant.terminal.Terminal +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.long +import java.net.URI + +class AddCommand : CliktCommand( + name = "add", + help = "Add a new download" +) { + private val urls: List by argument( + help = "URL(s) to download" + ).multiple() + + private val outputDir: String? by option( + "--output-dir", "-o", + help = "Output directory" + ) + + private val fileName: String? by option( + "--name", "-n", + help = "Output file name" + ) + + private val queue: Long? by option( + "--queue", "-q", + help = "Queue ID to add to" + ).long() + + private val quiet: Boolean by option( + "--quiet", + help = "Suppress output messages" + ).flag() + + private val username: String? by option( + "--username", "-u", + help = "HTTP Basic Auth username" + ) + + private val password: String? by option( + "--password", "-p", + help = "HTTP Basic Auth password" + ) + + override fun run() { + val term = Terminal() + val json = Json { ignoreUnknownKeys = true } + + // Ensure desktop app is running + if (!DesktopLauncher.ensureDesktopRunning()) { + term.println((TextColors.red)("Error: AB Download Manager is not available.")) + return + } + + val client = DesktopClient(com.abdownloadmanager.cli.utils.PortResolver.readIntegrationPort() + ?: error("Integration port not configured")) + var successCount = 0 + + if (urls.isEmpty()) { + term.println((TextColors.red)("Error: missing URL argument")) + return + } + + for (url in urls) { + val itemName = fileName ?: url.substringAfterLast("/").substringBefore("?").ifEmpty { + "download_${System.currentTimeMillis()}" + } + + // Validate URL scheme — core only accepts http/https + val urlScheme = runCatching { URI(url).scheme?.lowercase() }.getOrDefault(null) + if (urlScheme == null || urlScheme !in listOf("http", "https")) { + if (!quiet) { + term.println((TextColors.yellow)("! Skipping $url: unsupported scheme '$urlScheme'")) + } + continue + } + + when (val result = client.addDownload( + url = url, + name = fileName, + folder = outputDir, + username = username, + password = password, + queueId = queue, + )) { + is DesktopResult.Success -> { + // Parse {"id":42} from response + val id = try { + json.decodeFromString(result.data)["id"]?.jsonPrimitive?.long + } catch (_: Exception) { + null + } + successCount++ + if (!quiet) { + val msg = if (id != null) { + "Download #$id: $itemName" + } else { + "Added: $itemName" + } + term.println((TextColors.green)("✓") + " $msg") + } + } + is DesktopResult.ConnectionError -> { + if (!quiet) { + term.println((TextColors.red)("✗") + " $url: ${result.message}") + } + } + is DesktopResult.RemoteError -> { + if (!quiet) { + term.println((TextColors.red)("✗") + " $url: server error (${result.statusCode}): ${result.body}") + } + } + } + } + + if (!quiet && successCount > 0) { + term.println((TextColors.green)("Successfully added $successCount download(s)")) + } + } +} \ No newline at end of file diff --git a/cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/ControlCommands.kt b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/ControlCommands.kt new file mode 100644 index 000000000..1d7f3e19e --- /dev/null +++ b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/ControlCommands.kt @@ -0,0 +1,227 @@ +package com.abdownloadmanager.cli.commands + +import com.abdownloadmanager.cli.client.DesktopLauncher +import com.abdownloadmanager.cli.client.DesktopClient +import com.abdownloadmanager.cli.client.DesktopResult +import com.abdownloadmanager.cli.utils.CliFormatting +import com.abdownloadmanager.cli.utils.PortResolver +import com.abdownloadmanager.integration.ApiDownloadModel +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.multiple +import com.github.ajalt.clikt.parameters.options.* +import com.github.ajalt.clikt.parameters.types.long +import com.github.ajalt.mordant.rendering.TextColors +import com.github.ajalt.mordant.terminal.Terminal +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString +import kotlinx.serialization.builtins.ListSerializer + +/** + * Base class for control commands (pause/resume/remove) that share the + * desktop app detection pattern. + */ +abstract class BaseControlCommand( + name: String, + help: String, +) : CliktCommand(name = name, help = help) { + protected val ids: List by argument( + help = "Download ID(s)" + ).long().multiple() + + final override fun run() { + val term = Terminal() + + if (!DesktopLauncher.ensureDesktopRunning()) { + term.println((TextColors.red)("Error: AB Download Manager is not available.")) + return + } + + if (ids.isEmpty()) { + term.println((TextColors.red)("Error: missing download ID(s)")) + return + } + + val client = DesktopClient(PortResolver.readIntegrationPort() + ?: error("Integration port not configured")) + runWithClient(term, client) + } + + protected abstract fun runWithClient(term: Terminal, client: DesktopClient) +} + +class PauseCommand : BaseControlCommand( + name = "pause", + help = "Pause one or more downloads" +) { + override fun runWithClient(term: Terminal, client: DesktopClient) { + when (val result = client.pauseDownloads(ids)) { + is DesktopResult.Success -> { + term.println((TextColors.green)("Paused ${ids.size} download(s)")) + } + is DesktopResult.ConnectionError -> { + term.println((TextColors.red)("Connection error: ${result.message}")) + } + is DesktopResult.RemoteError -> { + term.println((TextColors.red)("Server error (${result.statusCode}): ${result.body}")) + } + } + } +} + +class ResumeCommand : BaseControlCommand( + name = "resume", + help = "Resume one or more downloads" +) { + override fun runWithClient(term: Terminal, client: DesktopClient) { + when (val result = client.resumeDownloads(ids)) { + is DesktopResult.Success -> { + term.println((TextColors.green)("Resumed ${ids.size} download(s)")) + } + is DesktopResult.ConnectionError -> { + term.println((TextColors.red)("Connection error: ${result.message}")) + } + is DesktopResult.RemoteError -> { + term.println((TextColors.red)("Server error (${result.statusCode}): ${result.body}")) + } + } + } +} + +class RemoveCommand : BaseControlCommand( + name = "remove", + help = "Remove one or more downloads" +) { + private val keepFile: Boolean by option("--keep-file", "-k", help = "Keep the downloaded file").flag() + + override fun runWithClient(term: Terminal, client: DesktopClient) { + when (val result = client.removeDownloads(ids, keepFile)) { + is DesktopResult.Success -> { + term.println((TextColors.green)("Removed ${ids.size} download(s)")) + } + is DesktopResult.ConnectionError -> { + term.println((TextColors.red)("Connection error: ${result.message}")) + } + is DesktopResult.RemoteError -> { + term.println((TextColors.red)("Server error (${result.statusCode}): ${result.body}")) + } + } + } +} + +class InfoCommand : CliktCommand( + name = "info", + help = "Show detailed information about a download" +) { + private val ids: List by argument( + help = "Download ID(s)" + ).long().multiple() + + private val jsonOutput: Boolean by option("--json", help = "Output as JSON").flag() + + override fun run() { + val term = Terminal() + val jsonParser = Json { ignoreUnknownKeys = true; prettyPrint = true } + + if (!DesktopLauncher.ensureDesktopRunning()) { + term.println((TextColors.red)("Error: AB Download Manager is not available.")) + return + } + + if (ids.isEmpty()) { + term.println((TextColors.red)("Error: missing download ID(s)")) + return + } + + val client = DesktopClient(PortResolver.readIntegrationPort() + ?: error("Integration port not configured")) + val foundItems = mutableListOf() + + for (id in ids) { + when (val result = client.getDownloadInfo(id)) { + is DesktopResult.Success -> { + // Server returns "null" (raw, no quotes) for missing downloads + if (result.data == "null") { + term.println((TextColors.red)("Download #$id not found")) + } else { + try { + val item = jsonParser.decodeFromString(ApiDownloadModel.serializer(), result.data) + foundItems.add(item) + } catch (e: Exception) { + term.println((TextColors.red)("Failed to parse response for #$id: ${e.message}")) + } + } + } + is DesktopResult.ConnectionError -> { + System.err.println("Connection error for #$id: ${result.message}") + } + is DesktopResult.RemoteError -> { + System.err.println("Server error for #$id (${result.statusCode}): ${result.body}") + } + } + } + + if (foundItems.isEmpty()) { + if (jsonOutput) { + term.println("[]") + } + return + } + + if (jsonOutput) { + term.println(jsonParser.encodeToString(ListSerializer(ApiDownloadModel.serializer()), foundItems)) + } else { + for (item in foundItems) { + term.println((TextColors.brightCyan)("Download #${item.id}")) + term.println((TextColors.brightBlue)(" Name: ") + item.name) + term.println((TextColors.brightBlue)(" URL: ") + item.url) + term.println((TextColors.brightBlue)(" Folder: ") + item.folder) + val statusColor = when (item.status) { + "Completed" -> TextColors.green + "Error" -> TextColors.red + "Downloading" -> TextColors.cyan + "Paused" -> TextColors.yellow + else -> TextColors.white + } + term.println((TextColors.brightBlue)(" Status: ") + statusColor(item.status)) + term.println((TextColors.brightBlue)(" Size: ") + CliFormatting.formatSize(item.size)) + term.println((TextColors.brightBlue)(" Downloaded: ") + CliFormatting.formatSize(item.downloaded)) + + // Speed and progress are currently hardcoded to 0 in the API. + // ApiDownloadModel.kt defines speed and progress fields, but + // IntegrationHandlerImp.toApiModel() uses hardcoded 0 values + // and never queries the live monitor layer for real progress data. + // (IntegrationHandlerImp.kt:128-144) + // To restore speed/progress display, update the server-side + // toApiModel() to read from ProcessingDownloadItemState. + + // if (item.speed > 0) { + // term.println((TextColors.brightBlue)(" Speed: ") + CliFormatting.formatSpeed(item.speed)) + // } + // if (item.progress > 0) { + // term.println((TextColors.brightBlue)(" Progress: ") + "%.1f%%".format(item.progress)) + // } + + term.println((TextColors.brightBlue)(" Added: ") + CliFormatting.formatTimestamp(item.dateAdded)) + val startTime = item.startTime + if (startTime != null && startTime > 0) { + term.println((TextColors.brightBlue)(" Started: ") + CliFormatting.formatTimestamp(startTime)) + } + val completeTime = item.completeTime + if (completeTime != null && completeTime > 0) { + term.println((TextColors.brightBlue)(" Completed: ") + CliFormatting.formatTimestamp(completeTime)) + } + if (item.connections != null) { + term.println((TextColors.brightBlue)(" Con——nections: ") + item.connections.toString()) + } + if (item.speedLimit > 0) { + term.println((TextColors.brightBlue)(" Speed limit: ") + CliFormatting.formatSpeed(item.speedLimit)) + } + if (item.checksum != null) { + term.println((TextColors.brightBlue)(" Checksum: ") + item.checksum) + } + term.println() + } + } + } +} \ No newline at end of file diff --git a/cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/ListCommand.kt b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/ListCommand.kt new file mode 100644 index 000000000..7021fa94c --- /dev/null +++ b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/ListCommand.kt @@ -0,0 +1,116 @@ +package com.abdownloadmanager.cli.commands + +import com.abdownloadmanager.cli.client.DesktopLauncher +import com.abdownloadmanager.cli.client.DesktopClient +import com.abdownloadmanager.cli.client.DesktopResult +import com.abdownloadmanager.cli.utils.CliFormatting +import com.abdownloadmanager.cli.utils.PortResolver +import com.abdownloadmanager.integration.ApiDownloadModel +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.* +import com.github.ajalt.mordant.rendering.TextColors +import com.github.ajalt.mordant.terminal.Terminal +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString +import kotlinx.serialization.builtins.ListSerializer + +class ListCommand : CliktCommand( + name = "list", + help = "List all downloads" +) { + private val all: Boolean by option("--all", "-a", help = "Show all downloads including finished").flag() + private val json: Boolean by option("--json", help = "Output as JSON").flag() + + override fun run() { + val term = Terminal() + val jsonParser = Json { ignoreUnknownKeys = true; prettyPrint = true } + + // Ensure desktop app is running + if (!DesktopLauncher.ensureDesktopRunning()) { + term.println((TextColors.red)("Error: AB Download Manager is not available.")) + return + } + + val client = DesktopClient(PortResolver.readIntegrationPort() + ?: error("Integration port not configured")) + + when (val result = client.listDownloads()) { + is DesktopResult.Success -> { + val allItems = try { + jsonParser.decodeFromString( + ListSerializer(ApiDownloadModel.serializer()), + result.data + ) + } catch (e: Exception) { + term.println((TextColors.red)("Failed to parse server response: ${e.message}")) + return + } + + val items = if (all) allItems else allItems.filter { it.status != "Completed" } + + if (items.isEmpty()) { + if (json) { + term.println("[]") + } else { + term.println((TextColors.yellow)("No downloads found.")) + } + return + } + + if (json) { + term.println(jsonParser.encodeToString(ListSerializer(ApiDownloadModel.serializer()), items)) + } else { + printTable(term, items) + } + } + is DesktopResult.ConnectionError -> { + term.println((TextColors.red)("Failed to connect to AB Download Manager: ${result.message}")) + } + is DesktopResult.RemoteError -> { + term.println((TextColors.red)("Server error (${result.statusCode}): ${result.body}")) + } + } + } + + private fun printTable(term: Terminal, items: List) { + term.println((TextColors.brightBlue)("┌──────┬──────────────────────────────────────┬──────────────┬──────────────┐")) + term.println((TextColors.brightBlue)("│ ${"ID".padEnd(4)} │ ${"Name".padEnd(36)} │ ${"Status".padEnd(12)} │ ${"Size".padEnd(12)} │")) + term.println((TextColors.brightBlue)("├──────┼──────────────────────────────────────┼──────────────┼──────────────┤")) + + for (item in items) { + val displayName = item.name.take(36) + val statusText = when (item.status) { + "Completed" -> "Completed" + "Paused" -> "Paused" + "Downloading" -> "Downloading" + "Error" -> "Error" + "Added" -> "Queued" + else -> item.status + } + val paddedStatus = statusText.padEnd(12) + val displayStatus = when (item.status) { + "Completed" -> (TextColors.green)(paddedStatus) + "Error" -> (TextColors.red)(paddedStatus) + else -> paddedStatus + } + val size = if (item.size > 0) { + CliFormatting.formatSize(item.size) + } else { + "?" + } + + term.print((TextColors.brightBlue)("│ ")) + term.print(item.id.toString().padEnd(4)) + term.print((TextColors.brightBlue)(" │ ")) + term.print(displayName.padEnd(36)) + term.print((TextColors.brightBlue)(" │ ")) + term.print(displayStatus) + term.print((TextColors.brightBlue)(" │ ")) + term.print(size.padEnd(12)) + term.println((TextColors.brightBlue)(" │")) + } + + term.println((TextColors.brightBlue)("└──────┴──────────────────────────────────────┴──────────────┴──────────────┘")) + term.println("Total: ${items.size} download(s)") + } +} \ No newline at end of file diff --git a/cli/app/src/main/kotlin/com/abdownloadmanager/cli/utils/CliFormatting.kt b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/utils/CliFormatting.kt new file mode 100644 index 000000000..670f93718 --- /dev/null +++ b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/utils/CliFormatting.kt @@ -0,0 +1,35 @@ +package com.abdownloadmanager.cli.utils + +import ir.amirab.util.datasize.CommonSizeConvertConfigs +import ir.amirab.util.datasize.SizeConverter +import ir.amirab.util.datasize.SizeWithUnit + +/** + * Shared formatting utilities for CLI output. + * + * Uses the project's shared datasize library (SizeConverter) for size/speed + * formatting to avoid duplicating the framework in `shared/utils/datasize`. + * + * formatTimestamp uses java.util.Calendar since no shared equivalent exists. + */ +object CliFormatting { + + fun formatSize(bytes: Long): String { + if (bytes < 0) return "Unknown" + return SizeConverter.bytesToSize(bytes, CommonSizeConvertConfigs.BinaryBytes).toString() + } + + fun formatSpeed(bytes: Long): String { + if (bytes < 0) return "Unknown" + val swu = SizeConverter.bytesToSize(bytes, CommonSizeConvertConfigs.BinaryBytes) + return "${swu.formatedValue()} ${swu.unit}/s" + } + + fun formatTimestamp(ts: Long): String { + if (ts <= 0) return "N/A" + val cal = java.util.Calendar.getInstance().apply { + timeInMillis = ts.coerceIn(-62135596800000L, 253402300799999L) + } + return "%tF %tT".format(cal, cal) + } +} \ No newline at end of file diff --git a/cli/app/src/main/kotlin/com/abdownloadmanager/cli/utils/PortResolver.kt b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/utils/PortResolver.kt new file mode 100644 index 000000000..de6cf7824 --- /dev/null +++ b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/utils/PortResolver.kt @@ -0,0 +1,37 @@ +package com.abdownloadmanager.cli.utils + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +/** + * Resolves the desktop app's integration port from appSettings.json. + * + * Uses the same path resolution as the desktop app's DefinedPaths. + * Location: ~/.abdm/config/appSettings.json + * Key: "browserIntegrationPort" + * + * Per maintainer: "always use the appSettings.json value, no retries needed" + */ +object PortResolver { + + fun readIntegrationPort(): Int? { + val configFile = getSettingsFile() + if (!configFile.exists()) return null + return try { + val text = configFile.readText().trim() + if (text.isEmpty()) return null + val json = Json { ignoreUnknownKeys = true } + val root = json.decodeFromString>(text) + root["browserIntegrationPort"]?.jsonPrimitive?.content?.toIntOrNull() + } catch (_: Exception) { + null + } + } + + private fun getSettingsFile(): File { + val userHome = System.getProperty("user.home") + return File(userHome, ".abdm/config/appSettings.json") + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index faa98d119..4733bed9f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,6 +33,7 @@ include("shared:config") include("shared:updater") include("shared:auto-start") include("shared:nanohttp4k") +include("cli:app") includeBuild("./compositeBuilds/shared"){ name="build-shared" } From fd37312e0e93456026a4da0c09e57311a997c9d5 Mon Sep 17 00:00:00 2001 From: ID-VerNe Date: Thu, 2 Jul 2026 09:40:12 +0800 Subject: [PATCH 3/3] feat(cli): integrate standalone CLI into NSIS installer with docs and keep-file fix ADD: - Shadow plugin config for abdm-cli fat JAR build - abdm-cli.bat launcher invoking bundled JRE - AddToPath.nsh and RemoveFromPath.nsh for PATH management - copyJavaExeToRuntime Gradle task (Compose jlink omits java.exe) - Registry-based binary discovery in DesktopLauncher - cli/CLI.md with usage documentation for all 6 commands CHANGE: - NSIS template: CLI file copy, optional PATH, uninstall cleanup - desktop/app/build.gradle.kts: installer deps, java.management module - gradle/libs.versions.toml: add shadow plugin version FIX: - --keep-file defaults to true, keeping completed files by default --- cli/CLI.md | 184 ++++++++++++++++++ cli/app/build.gradle.kts | 9 + .../cli/client/DesktopLauncher.kt | 30 ++- .../cli/commands/ControlCommands.kt | 2 +- desktop/app/build.gradle.kts | 22 ++- desktop/app/resources/installer/AddToPath.nsh | 86 ++++++++ .../resources/installer/RemoveFromPath.nsh | 90 +++++++++ desktop/app/resources/installer/abdm-cli.bat | 2 + .../installer/nsis-script-template.nsi | 22 +++ gradle/libs.versions.toml | 2 + 10 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 cli/CLI.md create mode 100644 desktop/app/resources/installer/AddToPath.nsh create mode 100644 desktop/app/resources/installer/RemoveFromPath.nsh create mode 100644 desktop/app/resources/installer/abdm-cli.bat diff --git a/cli/CLI.md b/cli/CLI.md new file mode 100644 index 000000000..b7c88cf53 --- /dev/null +++ b/cli/CLI.md @@ -0,0 +1,184 @@ +# AB Download Manager CLI (`abdm-cli`) + +`abdm-cli` is a **desktop companion CLI** for AB Download Manager. It is **not** a standalone download manager — it acts as an IPC client that connects to the running desktop app's embedded HTTP server. + +## Design + +- **IPC client only** — the desktop app is the single source of truth for all download state. +- **Auto-start** — when the desktop app is not running, `abdm-cli` starts it in the background and waits for it to be ready. +- **Port reading** — the CLI reads the integration port from `~/.abdm/config/appSettings.json` (key: `browserIntegrationPort`). +- **HTTP client** — uses http4k with OkHttp, matching the existing SingleInstance communication stack in the desktop app. + +## Commands + +| Command | Description | +|---------|-------------| +| [`add`](#add) | Add a new download | +| [`list`](#list) | List all downloads | +| [`info`](#info) | Show detailed information about a download | +| [`pause`](#pause) | Pause one or more downloads | +| [`resume`](#resume) | Resume one or more downloads | +| [`remove`](#remove) | Remove one or more downloads | + +Global options: + +| Option | Description | +|--------|-------------| +| `--help`, `-h` | Show help message and exit | +| `--version` | Show version and exit | + +--- + +### `add` + +Add a new download from a URL. + +```bash +abdm-cli add +``` + +The command returns the assigned download ID and file name on success. + +--- + +### `list` + +List all current downloads. + +```bash +abdm-cli list +abdm-cli list --all +abdm-cli list --json +``` + +| Option | Description | +|--------|-------------| +| `--all` | Show all downloads, including completed ones | +| `--json` | Output results as JSON instead of a table | + +Without `--all`, only active (downloading/paused) downloads are shown. + +--- + +### `info` + +Show detailed information for a specific download. + +```bash +abdm-cli info +``` + +Displays file name, URL, progress, speed, ETA, status, and other metadata. + +--- + +### `pause` + +Pause one or more active downloads. + +```bash +abdm-cli pause ... +``` + +Accepts one or more download IDs separated by spaces. + +--- + +### `resume` + +Resume one or more paused downloads. + +```bash +abdm-cli resume ... +``` + +Accepts one or more download IDs separated by spaces. + +--- + +### `remove` + +Remove one or more downloads from the list. + +```bash +abdm-cli remove ... +abdm-cli remove --keep-file ... +``` + +| Option | Description | +|--------|-------------| +| `--keep-file`, `-k` | Keep the downloaded file on disk (default: **enabled**) | + +**Safety default:** completed files are **kept** by default. To delete a completed file, you must explicitly opt out (this flag is a no-op for macOS/Linux; it is planned for a future release). + +## Examples + +```bash +# Add a download +abdm-cli add https://example.com/file.zip + +# List active downloads +abdm-cli list + +# List all downloads (including completed) as JSON +abdm-cli list --all --json + +# Show details for download #42 +abdm-cli info 42 + +# Pause downloads #5 and #7 +abdm-cli pause 5 7 + +# Resume downloads #5 and #7 +abdm-cli resume 5 7 + +# Remove download #12 (keeps the file on disk) +abdm-cli remove 12 + +# Remove download #12 and delete the file +abdm-cli remove 12 --keep-file=false +``` + +## Installation + +On Windows, `abdm-cli` is bundled with the NSIS installer. The installer includes an optional "Add AB Download Manager to PATH" checkbox (disabled by default). + +After installation with PATH enabled, `abdm-cli` is available from any terminal: + +```bash +abdm-cli --help +``` + +If PATH is not enabled, you can run the CLI directly from the install directory: + +```bash +"C:\Program Files\ABDownloadManager\abdm-cli.bat" --help +``` + +## Platform Support + +| Platform | Status | +|----------|--------| +| Windows | ✅ Supported (NSIS installer, registry-based discovery) | +| Linux | ⏳ Planned (follow-up) | +| macOS | ⏳ Planned (follow-up) | + +The CLI implementation is platform-neutral. Packaging and PATH integration for Linux and macOS will be added in future releases. + +## Build + +```bash +# Build the CLI fat JAR +./gradlew :cli:app:shadowJar -Pjvm.toolchain=21 + +# Build the Windows installer (includes the CLI) +./gradlew :desktop:app:createInstallerNsis -Pjvm.toolchain=21 +``` + +## Implementation Details + +- Built with **Clikt** (command-line parsing) and **Mordant** (terminal output formatting). +- The CLI fat JAR is produced by the **Shadow** plugin (`com.gradleup.shadow`). +- The CLI reuses the desktop app's bundled JRE — no system Java installation is required. +- `java.management` module is added to the runtime image because Mordant terminal detection requires it. +- All commands communicate through HTTP to the desktop app's embedded server. diff --git a/cli/app/build.gradle.kts b/cli/app/build.gradle.kts index e35b5b537..e90f16c9b 100644 --- a/cli/app/build.gradle.kts +++ b/cli/app/build.gradle.kts @@ -5,6 +5,7 @@ plugins { kotlin("plugin.serialization") application id(Plugins.buildConfig) + alias(libs.plugins.shadow) } dependencies { @@ -46,4 +47,12 @@ buildConfig { "APP_VERSION", provider { getAppVersionString() } ) +} + +// Configure shadow JAR packaging for the CLI fat JAR +tasks.named("shadowJar") { + archiveBaseName.set("abdm-cli") + archiveClassifier.set("") + mergeServiceFiles() + exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA") } \ No newline at end of file diff --git a/cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopLauncher.kt b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopLauncher.kt index 5b8793264..1f73b93d4 100644 --- a/cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopLauncher.kt +++ b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/client/DesktopLauncher.kt @@ -94,10 +94,20 @@ object DesktopLauncher { // --- Platform-specific binary discovery --- private fun findWindowsBinary(): File? { + val candidates = mutableListOf() + + // 1. Priority: read from registry (most reliable, supports custom install dir) + try { + val regPath = readRegistry("HKCU\\Software\\ABDownloadManager", "InstallPath") + if (regPath != null) { + candidates.add(File(regPath, "ABDownloadManager.exe")) + } + } catch (_: Exception) { } + + // 2. Standard install locations (backward compatibility) val localAppData = System.getenv("LOCALAPPDATA") val programFiles = System.getenv("ProgramFiles") val programFilesX86 = System.getenv("ProgramFiles(x86)") - val candidates = mutableListOf() if (localAppData != null) { candidates.add(File(localAppData, "ABDownloadManager/ABDownloadManager.exe")) candidates.add(File(localAppData, "AB Download Manager/ABDownloadManager.exe")) @@ -108,10 +118,28 @@ object DesktopLauncher { if (programFilesX86 != null) { candidates.add(File(programFilesX86, "AB Download Manager/ABDownloadManager.exe")) } + + // 3. User home fallback candidates.add(File(System.getProperty("user.home"), "ABDownloadManager/ABDownloadManager.exe")) + return candidates.firstOrNull { it.exists() } } + /** + * Read a Windows registry string value via reg query. + * Output format: " InstallPath REG_SZ C:\path\to\..." + */ + private fun readRegistry(key: String, valueName: String): String? { + return try { + val proc = ProcessBuilder("reg", "query", key, "/v", valueName) + .redirectErrorStream(true).start() + val output = proc.inputStream.bufferedReader().readText().trim() + proc.waitFor() + val regex = Regex("""\s+${Regex.escape(valueName)}\s+\S+\s+(.+)$""", RegexOption.MULTILINE) + regex.find(output)?.groupValues?.get(1) + } catch (_: Exception) { null } + } + private fun findMacBinary(): File? { val candidates = listOf( File("/Applications/AB Download Manager.app/Contents/MacOS/AB Download Manager"), diff --git a/cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/ControlCommands.kt b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/ControlCommands.kt index 1d7f3e19e..504a64bcb 100644 --- a/cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/ControlCommands.kt +++ b/cli/app/src/main/kotlin/com/abdownloadmanager/cli/commands/ControlCommands.kt @@ -92,7 +92,7 @@ class RemoveCommand : BaseControlCommand( name = "remove", help = "Remove one or more downloads" ) { - private val keepFile: Boolean by option("--keep-file", "-k", help = "Keep the downloaded file").flag() + private val keepFile: Boolean by option("--keep-file", "-k", help = "Keep the downloaded file").flag(default = true) override fun runWithClient(term: Terminal, client: DesktopClient) { when (val result = client.removeDownloads(ids, keepFile)) { diff --git a/desktop/app/build.gradle.kts b/desktop/app/build.gradle.kts index 0266cb11a..635096175 100644 --- a/desktop/app/build.gradle.kts +++ b/desktop/app/build.gradle.kts @@ -87,6 +87,21 @@ tasks.processResources { } val desktopPackageName = "com.abdownloadmanager.desktop" + +/** + * Copy java.exe/javaw.exe from the JDK into the Compose runtime/bin directory. + * Compose's jlink produces a runtime that lacks the launcher executables on Windows. + */ +val copyJavaExeToRuntime by tasks.registering(Copy::class) { + dependsOn("createReleaseDistributable") + val jdkHome = file(System.getProperty("java.home")) + val runtimeBin = layout.buildDirectory.dir("compose/binaries/main-release/app/${getAppName()}/runtime/bin") + from(jdkHome.resolve("bin")) { + include("java.exe", "javaw.exe") + } + into(runtimeBin) +} + compose { desktop { application { @@ -112,6 +127,7 @@ compose { "java.instrument", "jdk.unsupported", "jdk.accessibility", + "java.management", ) targetFormats(Msi, Deb) if (Platform.getCurrentPlatform() == Platform.Desktop.Linux) { @@ -162,6 +178,8 @@ compose { installerPlugin { dependsOn("createReleaseDistributable") + dependsOn(":cli:app:shadowJar") + dependsOn("copyJavaExeToRuntime") outputFolder.set(layout.buildDirectory.dir("custom-installer")) windows { appName = getAppName() @@ -181,7 +199,9 @@ installerPlugin { "project_website" to "www.abdownloadmanager.com", "copyright" to "© 2024-present AB Download Manager App", "header_image_file" to project.file("resources/installer/abdm-header-image.bmp"), - "sidebar_image_file" to project.file("resources/installer/abdm-sidebar-image.bmp") + "sidebar_image_file" to project.file("resources/installer/abdm-sidebar-image.bmp"), + "cli_jar_path" to project(":cli:app").layout.buildDirectory.file("libs/abdm-cli.jar").get().asFile.absolutePath, + "installer_resources_dir" to project.file("resources/installer").absolutePath ) } macos { diff --git a/desktop/app/resources/installer/AddToPath.nsh b/desktop/app/resources/installer/AddToPath.nsh new file mode 100644 index 000000000..eb68872c9 --- /dev/null +++ b/desktop/app/resources/installer/AddToPath.nsh @@ -0,0 +1,86 @@ +; AddToPath.nsh +; Adds a directory to the user PATH (HKCU\Environment) +; +; Self-contained: no external function dependencies. +; Uses whole-entry matching to avoid false positives when +; one path is a prefix of another. +; After writing, broadcasts WM_SETTINGCHANGE. +; +; Usage: +; Push "C:\path\to\add" +; Call AddToPath + +!ifndef AddToPath_INCLUDED +!define AddToPath_INCLUDED + +; Checks if ";needle;" exists in ";haystack;" +; Sets $0 to remaining after match (found) or "" (not found). +; Expects $2 = haystack (already semicolon-wrapped), $3 = needle (wrapped). +!macro _AtpSearch _atp_label + ; $2 = ";PATH;", $3 = ";target;" + StrLen $4 $3 +_atp_search_${_atp_label}: + StrCpy $5 $2 $4 + StrCmp $5 $3 _atp_found_${_atp_label} + StrCpy $2 $2 "" 1 + StrCmp $2 "" _atp_notFound_${_atp_label} + Goto _atp_search_${_atp_label} +_atp_found_${_atp_label}: + StrCpy $0 $2 + Goto _atp_done_${_atp_label} +_atp_notFound_${_atp_label}: + StrCpy $0 "" +_atp_done_${_atp_label}: +!macroend + +Function AddToPath + Exch $0 + Push $1 + Push $2 + Push $3 + Push $4 + Push $5 + Push $6 ; save original input path + + StrCpy $6 $0 ; preserve input path before search clobbers $0 + + ReadRegStr $1 HKCU "Environment" "Path" + + ; Wrap in semicolons for whole-entry matching + ; ";old_PATH;" — search for ";target;" within this + StrCpy $2 ";$1;" + StrCpy $3 ";$6;" + + ; Check if path already exists + !insertmacro _AtpSearch "addtopath" + StrCmp $0 "" _atp_do_add + Goto _atp_finish + +_atp_do_add: + StrCmp $1 "" _atp_new _atp_append + +_atp_new: + WriteRegExpandStr HKCU "Environment" "Path" "$6" + Goto _atp_broadcast + +_atp_append: + StrCpy $2 $1 1 -1 + StrCmp $2 ";" _atp_nosep + StrCpy $1 "$1;" +_atp_nosep: + WriteRegExpandStr HKCU "Environment" "Path" "$1$6" + +_atp_broadcast: + System::Call "user32::SendMessageTimeout(i 0xffff, i 0x001A, i 0, t 'Environment', i 0x0002, i 5000, *i r0)" + +_atp_finish: + Pop $6 + Pop $5 + Pop $4 + Pop $3 + Pop $2 + Pop $1 + Pop $0 +FunctionEnd + +!endif \ No newline at end of file diff --git a/desktop/app/resources/installer/RemoveFromPath.nsh b/desktop/app/resources/installer/RemoveFromPath.nsh new file mode 100644 index 000000000..c8e1e6f79 --- /dev/null +++ b/desktop/app/resources/installer/RemoveFromPath.nsh @@ -0,0 +1,90 @@ +; RemoveFromPath.nsh +; Removes a directory from the user PATH (HKCU\Environment) +; +; Self-contained: uses whole-entry matching to find and remove +; the exact path entry (with semicolon boundaries, no substring match). +; After writing, broadcasts WM_SETTINGCHANGE. +; +; Usage (install section): +; Push "C:\path\to\remove" +; Call RemoveFromPath +; +; Usage (uninstall section): +; Push "C:\path\to\remove" +; Call un.RemoveFromPath + +!ifndef RemoveFromPath_INCLUDED +!define RemoveFromPath_INCLUDED + +!macro _RemovePath _func_name +Function ${_func_name} + Exch $0 + Push $1 + Push $2 + Push $3 + Push $4 + Push $5 + Push $6 + Push $7 + + ReadRegStr $1 HKCU "Environment" "Path" + StrCmp $1 "" _rpf_done + + ; Wrapped PATH and target for boundary-safe search + StrCpy $2 ";$1;" + StrCpy $3 ";$0;" + + ; Search for wrapped target in wrapped PATH + StrLen $4 $3 + StrCpy $6 $2 +_rpf_search: + StrCpy $7 $6 $4 + StrCmp $7 $3 _rpf_found + StrCpy $6 $6 "" 1 + StrCmp $6 "" _rpf_done + Goto _rpf_search + +_rpf_found: + ; $6 = remainder from match position, $4 = len of matched target + ; Find prefix length + StrLen $5 $2 + StrLen $7 $6 + IntOp $5 $5 - $7 ; offset of match + + ; Extract prefix (before match) and suffix (after match) + StrCpy $7 $2 $5 ; prefix + StrCpy $6 $6 "" $4 ; suffix (after removing target+wrapped semicolons) + + ; Reconstruct + StrCpy $2 "$7$6" + + ; Clean up leading/trailing semicolons + StrCpy $5 $2 1 + StrCmp $5 ";" "" _rpf_no_lead + StrCpy $2 $2 "" 1 +_rpf_no_lead: + StrCpy $5 $2 1 -1 + StrCmp $5 ";" "" _rpf_no_trail + StrLen $6 $2 + IntOp $6 $6 - 1 + StrCpy $2 $2 $6 +_rpf_no_trail: + WriteRegExpandStr HKCU "Environment" "Path" "$2" + System::Call "user32::SendMessageTimeout(i 0xffff, i 0x001A, i 0, t 'Environment', i 0x0002, i 5000, *i r0)" + +_rpf_done: + Pop $7 + Pop $6 + Pop $5 + Pop $4 + Pop $3 + Pop $2 + Pop $1 + Pop $0 +FunctionEnd +!macroend + +!insertmacro _RemovePath "RemoveFromPath" +!insertmacro _RemovePath "un.RemoveFromPath" + +!endif \ No newline at end of file diff --git a/desktop/app/resources/installer/abdm-cli.bat b/desktop/app/resources/installer/abdm-cli.bat new file mode 100644 index 000000000..d7218d386 --- /dev/null +++ b/desktop/app/resources/installer/abdm-cli.bat @@ -0,0 +1,2 @@ +@echo off +"%~dp0runtime\bin\java.exe" -jar "%~dp0cli\abdm-cli.jar" %* diff --git a/desktop/app/resources/installer/nsis-script-template.nsi b/desktop/app/resources/installer/nsis-script-template.nsi index ae1d0ddbb..0bd88a61e 100644 --- a/desktop/app/resources/installer/nsis-script-template.nsi +++ b/desktop/app/resources/installer/nsis-script-template.nsi @@ -3,6 +3,9 @@ RequestExecutionLevel user SetCompressor /SOLID lzma !include "LogicLib.nsh" !include "MUI2.nsh" +!addincludedir "{{ installer_resources_dir }}" +!include "AddToPath.nsh" +!include "RemoveFromPath.nsh" !define APP_PUBLISHER "{{ app_publisher }}" @@ -100,7 +103,9 @@ FunctionEnd !macro clearFiles RmDir /r "${INSTALL_DIR}\app" RmDir /r "${INSTALL_DIR}\runtime" + RmDir /r "${INSTALL_DIR}\cli" Delete "${INSTALL_DIR}\${MAIN_BINARY_NAME}.exe" + Delete "${INSTALL_DIR}\abdm-cli.bat" Delete "${INSTALL_DIR}\${MAIN_BINARY_NAME}.ico" Delete "${INSTALL_DIR}\uninstall.exe" RmDir "${INSTALL_DIR}" @@ -187,6 +192,14 @@ Section "${APP_DISPLAY_NAME}" File /nonfatal /r "${INPUT_DIR}\" + ; Copy CLI fat JAR and launcher batch file + CreateDirectory "${INSTALL_DIR}\cli" + SetOutPath "${INSTALL_DIR}\cli" + File "{{ cli_jar_path }}" + SetOutPath "${INSTALL_DIR}" + File "{{ installer_resources_dir }}\abdm-cli.bat" + + ; Registry information for add/remove programs WriteRegStr SHCTX "${REG_UNINSTALL_KEY}" "DisplayName" "${APP_DISPLAY_NAME}" WriteRegStr SHCTX "${REG_UNINSTALL_KEY}" "DisplayIcon" "$\"${INSTALL_DIR}\${MAIN_BINARY_NAME}.exe$\"" @@ -210,6 +223,11 @@ Section "Desktop Shortcut" !insertmacro CreateDesktopShortcut SectionEnd +Section /o "Add AB Download Manager to PATH" + Push "$INSTDIR" + Call AddToPath +SectionEnd + Section /o "un.Remove User Data" !insertmacro RemoveUserData SectionEnd @@ -223,6 +241,10 @@ Section "Uninstall" !insertmacro RemoveStartMenu !insertmacro RemoveDesktopShortCut + ; Clean up PATH (always -- safe redundancy) + Push "$INSTDIR" + Call un.RemoveFromPath + DeleteRegKey SHCTX "${REG_UNINSTALL_KEY}" DeleteRegKey SHCTX "${REG_APP_KEY}" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9459fcf06..7628a91a3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,7 @@ androidx-core = "1.18.0" androidx-activity-compose = "1.13.0" slf4jApi = "2.0.18" +shadow = "9.0.0" [libraries] kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } @@ -135,4 +136,5 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi buildConfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildConfig" } changeLog = { id = "org.jetbrains.changelog", version.ref = "changelog" } +shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }