feat: external capture queue and modal-first add flow (v0.1.1)

This commit is contained in:
2026-02-25 01:40:52 +09:00
parent 552f27c002
commit e9f332171e
24 changed files with 2772 additions and 110 deletions

115
src-tauri/Cargo.lock generated
View File

@@ -86,6 +86,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-log",
]
@@ -431,6 +432,26 @@ dependencies = [
"memchr",
]
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -520,6 +541,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -708,6 +735,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "dpi"
version = "0.1.2"
@@ -1296,6 +1332,12 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -2247,6 +2289,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3025,6 +3077,16 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rust_decimal"
version = "1.40.0"
@@ -3787,6 +3849,27 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"tracing",
"url",
"windows-registry",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
@@ -4034,6 +4117,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -4244,9 +4336,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -4867,6 +4971,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-result"
version = "0.3.4"

View File

@@ -1,6 +1,6 @@
[package]
name = "app"
version = "0.1.0"
version = "0.1.1"
description = "A Tauri App"
authors = ["you"]
license = ""
@@ -24,5 +24,6 @@ log = "0.4"
tauri = { version = "2.10.0", features = [] }
tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
tauri-plugin-deep-link = "2"
reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls"] }
base64 = "0.22"

View File

@@ -7,6 +7,7 @@
],
"permissions": [
"core:default",
"dialog:allow-open"
"dialog:allow-open",
"deep-link:default"
]
}

View File

@@ -2,12 +2,14 @@ use base64::{engine::general_purpose::STANDARD, Engine as _};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::env;
use std::io::ErrorKind;
use std::net::{SocketAddr, TcpStream};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::{Manager, State};
#[derive(Debug, Deserialize)]
@@ -36,6 +38,8 @@ pub struct Aria2AddUriRequest {
pub out: Option<String>,
pub dir: Option<String>,
pub split: Option<u16>,
pub options: Option<BTreeMap<String, Value>>,
pub position: Option<u32>,
}
#[derive(Debug, Deserialize)]
@@ -46,6 +50,8 @@ pub struct Aria2AddTorrentRequest {
pub out: Option<String>,
pub dir: Option<String>,
pub split: Option<u16>,
pub options: Option<BTreeMap<String, Value>>,
pub position: Option<u32>,
}
#[derive(Debug, Deserialize)]
@@ -55,6 +61,20 @@ pub struct Aria2TaskCommandRequest {
pub gid: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2TaskDetailRequest {
pub rpc: Aria2RpcConfig,
pub gid: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2ChangeGlobalOptionRequest {
pub rpc: Aria2RpcConfig,
pub options: BTreeMap<String, String>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct EngineStatusResponse {
@@ -86,6 +106,63 @@ pub struct Aria2TaskSnapshot {
pub stopped: Vec<Aria2TaskSummary>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2TaskFile {
pub path: String,
pub length: String,
pub completed_length: String,
pub selected: String,
pub uris: Vec<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2TaskPeer {
pub peer_id: String,
pub ip: String,
pub port: String,
pub bitfield: String,
pub am_choking: String,
pub peer_choking: String,
pub download_speed: String,
pub upload_speed: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2TaskServer {
pub uri: String,
pub current_uri: String,
pub download_speed: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2TaskDetail {
pub gid: String,
pub status: String,
pub total_length: String,
pub completed_length: String,
pub upload_length: String,
pub download_speed: String,
pub upload_speed: String,
pub num_seeders: String,
pub connections: String,
pub piece_length: String,
pub num_pieces: String,
pub dir: String,
pub info_hash: String,
pub creation_date: String,
pub comment: String,
pub error_code: String,
pub error_message: String,
pub trackers: Vec<String>,
pub files: Vec<Aria2TaskFile>,
pub peers: Vec<Aria2TaskPeer>,
pub servers: Vec<Aria2TaskServer>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TorrentFilePayload {
@@ -143,6 +220,162 @@ impl EngineState {
}
}
fn reset_runtime_after_stop(runtime: &mut EngineRuntime) {
runtime.child = None;
runtime.external_reuse = false;
runtime.rpc_port = None;
runtime.started_at = None;
}
fn force_kill_pid(pid: u32) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let status = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F", "/T"])
.status()
.map_err(|err| format!("failed to execute taskkill for pid {pid}: {err}"))?;
if !status.success() {
return Err(format!("taskkill failed for pid {pid} with status {status}"));
}
return Ok(());
}
#[cfg(not(target_os = "windows"))]
{
let status = Command::new("kill")
.args(["-9", &pid.to_string()])
.status()
.map_err(|err| format!("failed to execute kill -9 for pid {pid}: {err}"))?;
if !status.success() {
return Err(format!("kill -9 failed for pid {pid} with status {status}"));
}
Ok(())
}
}
fn list_listening_pids_on_port(port: u16) -> Result<Vec<u32>, String> {
#[cfg(target_os = "windows")]
{
let _ = port;
Ok(vec![])
}
#[cfg(not(target_os = "windows"))]
{
let target = format!(":{port}");
let output = Command::new("lsof")
.args(["-nP", "-iTCP", target.as_str(), "-sTCP:LISTEN", "-t"])
.output()
.map_err(|err| format!("failed to execute lsof for port {port}: {err}"))?;
if !output.status.success() && output.stdout.is_empty() {
return Ok(vec![]);
}
let text = String::from_utf8_lossy(&output.stdout);
let mut pids: Vec<u32> = text
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.collect();
pids.sort_unstable();
pids.dedup();
Ok(pids)
}
}
fn try_release_local_port(port: u16) -> Result<(), String> {
if !is_local_port_open(port) {
return Ok(());
}
let pids = list_listening_pids_on_port(port)?;
for pid in &pids {
#[cfg(target_os = "windows")]
{
let status = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/T"])
.status()
.map_err(|err| format!("failed to execute taskkill for pid {pid}: {err}"))?;
if !status.success() {
let _ = force_kill_pid(*pid);
}
}
#[cfg(not(target_os = "windows"))]
{
let status = Command::new("kill")
.args(["-15", &pid.to_string()])
.status()
.map_err(|err| format!("failed to execute kill -15 for pid {pid}: {err}"))?;
if !status.success() {
let _ = force_kill_pid(*pid);
}
}
}
std::thread::sleep(Duration::from_millis(350));
if is_local_port_open(port) {
for pid in list_listening_pids_on_port(port)? {
let _ = force_kill_pid(pid);
}
std::thread::sleep(Duration::from_millis(200));
}
Ok(())
}
fn terminate_child_process(child: &mut Child) -> Result<(), String> {
match child.try_wait() {
Ok(Some(_)) => return Ok(()),
Ok(None) => {}
Err(err) => return Err(format!("failed to inspect aria2 process before stop: {err}")),
}
let pid = child.id();
if let Err(err) = child.kill() {
if err.kind() != ErrorKind::NotFound {
return Err(format!("failed to stop aria2 engine: {err}"));
}
}
let deadline = Instant::now() + Duration::from_millis(900);
while Instant::now() < deadline {
match child.try_wait() {
Ok(Some(_)) => return Ok(()),
Ok(None) => std::thread::sleep(Duration::from_millis(40)),
Err(err) => {
if err.kind() == ErrorKind::InvalidInput || err.kind() == ErrorKind::NotFound {
return Ok(());
}
return Err(format!("failed while waiting aria2 stop: {err}"));
}
}
}
let _ = force_kill_pid(pid);
let _ = child.wait();
Ok(())
}
fn stop_engine_runtime(runtime: &mut EngineRuntime) -> Result<(), String> {
let mut error: Option<String> = None;
if let Some(mut child) = runtime.child.take() {
if let Err(err) = terminate_child_process(&mut child) {
error = Some(err);
}
}
reset_runtime_after_stop(runtime);
if let Some(err) = error {
return Err(err);
}
Ok(())
}
pub fn stop_engine_for_exit(state: &EngineState) {
if let Ok(mut runtime) = state.runtime.lock() {
let _ = stop_engine_runtime(&mut runtime);
}
}
fn build_engine_args(request: &EngineStartRequest) -> Vec<String> {
let mut args = vec![
"--enable-rpc=true".to_string(),
@@ -152,6 +385,7 @@ fn build_engine_args(request: &EngineStartRequest) -> Vec<String> {
"--max-concurrent-downloads=".to_string() + &request.max_concurrent_downloads.unwrap_or(5).to_string(),
"--split=".to_string() + &request.split.unwrap_or(8).to_string(),
"--continue=true".to_string(),
"--check-certificate=false".to_string(),
];
if let Some(secret) = &request.rpc_secret {
@@ -420,7 +654,124 @@ fn map_tasks(result: Value) -> Vec<Aria2TaskSummary> {
.unwrap_or_default()
}
fn build_rpc_options(out: Option<&String>, dir: Option<&String>, split: Option<u16>) -> Value {
fn map_task_files(status_result: &Value) -> Vec<Aria2TaskFile> {
status_result
.get("files")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|file| Aria2TaskFile {
path: value_to_string(file.get("path")),
length: value_to_string(file.get("length")),
completed_length: value_to_string(file.get("completedLength")),
selected: value_to_string(file.get("selected")),
uris: file
.get("uris")
.and_then(Value::as_array)
.map(|uris| {
uris
.iter()
.filter_map(|u| u.get("uri").and_then(Value::as_str).map(str::to_string))
.collect::<Vec<String>>()
})
.unwrap_or_default(),
})
.collect::<Vec<Aria2TaskFile>>()
})
.unwrap_or_default()
}
fn map_task_peers(result: &Value) -> Vec<Aria2TaskPeer> {
result
.as_array()
.map(|items| {
items
.iter()
.map(|peer| Aria2TaskPeer {
peer_id: value_to_string(peer.get("peerId")),
ip: value_to_string(peer.get("ip")),
port: value_to_string(peer.get("port")),
bitfield: value_to_string(peer.get("bitfield")),
am_choking: value_to_string(peer.get("amChoking")),
peer_choking: value_to_string(peer.get("peerChoking")),
download_speed: value_to_string(peer.get("downloadSpeed")),
upload_speed: value_to_string(peer.get("uploadSpeed")),
})
.collect::<Vec<Aria2TaskPeer>>()
})
.unwrap_or_default()
}
fn map_task_servers(result: &Value) -> Vec<Aria2TaskServer> {
result
.as_array()
.map(|items| {
items
.iter()
.map(|server| {
let current_uri = server
.get("currentUri")
.and_then(Value::as_object)
.and_then(|obj| obj.get("uri"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
Aria2TaskServer {
uri: value_to_string(server.get("uri")),
current_uri,
download_speed: value_to_string(server.get("downloadSpeed")),
}
})
.collect::<Vec<Aria2TaskServer>>()
})
.unwrap_or_default()
}
fn map_task_trackers(status_result: &Value) -> Vec<String> {
let mut trackers: Vec<String> = vec![];
if let Some(announce_list) = status_result
.get("bittorrent")
.and_then(|v| v.get("announceList"))
.and_then(Value::as_array)
{
for tier in announce_list {
if let Some(items) = tier.as_array() {
for tracker in items {
if let Some(url) = tracker.as_str() {
let trimmed = url.trim();
if !trimmed.is_empty() {
trackers.push(trimmed.to_string());
}
}
}
}
}
}
if trackers.is_empty() {
if let Some(primary) = status_result
.get("bittorrent")
.and_then(|v| v.get("announce"))
.and_then(Value::as_str)
{
let trimmed = primary.trim();
if !trimmed.is_empty() {
trackers.push(trimmed.to_string());
}
}
}
trackers.sort();
trackers.dedup();
trackers
}
fn build_rpc_options(
out: Option<&String>,
dir: Option<&String>,
split: Option<u16>,
extra: Option<&BTreeMap<String, Value>>,
) -> Value {
let mut options = serde_json::Map::new();
if let Some(value) = out {
if !value.trim().is_empty() {
@@ -435,6 +786,11 @@ fn build_rpc_options(out: Option<&String>, dir: Option<&String>, split: Option<u
if let Some(value) = split {
options.insert("split".to_string(), json!(value.to_string()));
}
if let Some(extra_options) = extra {
for (key, value) in extra_options {
options.insert(key.to_string(), value.clone());
}
}
Value::Object(options)
}
@@ -455,16 +811,6 @@ pub fn engine_start(
.lock()
.map_err(|err| format!("failed to lock engine state: {err}"))?;
if runtime.external_reuse {
if let Some(port) = runtime.rpc_port {
if is_local_port_open(port) {
return Ok(EngineState::status(&runtime));
}
}
runtime.external_reuse = false;
runtime.rpc_port = None;
}
if let Some(child) = runtime.child.as_mut() {
match child.try_wait() {
Ok(Some(_)) => {
@@ -481,21 +827,13 @@ pub fn engine_start(
let args = build_engine_args(&request);
// Reuse existing engine when the target RPC port is already occupied.
// This mirrors Motrix-style behavior where an already-running aria2 instance is reused.
if is_local_port_open(rpc_port) {
runtime.child = None;
runtime.external_reuse = true;
runtime.rpc_port = Some(rpc_port);
runtime.binary_path = Some(format!("external://127.0.0.1:{rpc_port}"));
runtime.args = args;
runtime.started_at = Some(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| format!("failed to get system time: {err}"))?
.as_secs(),
);
return Ok(EngineState::status(&runtime));
try_release_local_port(rpc_port)?;
if is_local_port_open(rpc_port) {
return Err(format!(
"RPC 포트 {rpc_port} 가 이미 사용 중입니다. 점유 프로세스 정리 후 다시 시도하세요."
));
}
}
let candidates = collect_binary_candidates(&app, request.binary_path.as_deref());
@@ -565,16 +903,7 @@ pub fn engine_stop(state: State<'_, EngineState>) -> Result<EngineStatusResponse
.lock()
.map_err(|err| format!("failed to lock engine state: {err}"))?;
if let Some(mut child) = runtime.child.take() {
child
.kill()
.map_err(|err| format!("failed to stop aria2 engine: {err}"))?;
let _ = child.wait();
}
runtime.external_reuse = false;
runtime.rpc_port = None;
runtime.started_at = None;
stop_engine_runtime(&mut runtime)?;
Ok(EngineState::status(&runtime))
}
@@ -619,10 +948,18 @@ pub async fn aria2_add_uri(request: Aria2AddUriRequest) -> Result<String, String
let client = Client::new();
let mut params = vec![json!([request.uri.trim()])];
let options = build_rpc_options(request.out.as_ref(), request.dir.as_ref(), request.split);
let options = build_rpc_options(
request.out.as_ref(),
request.dir.as_ref(),
request.split,
request.options.as_ref(),
);
if options.as_object().map(|obj| !obj.is_empty()).unwrap_or(false) {
params.push(options);
}
if let Some(position) = request.position {
params.push(json!(position));
}
let result = call_aria2_rpc(&client, &request.rpc, "aria2.addUri", params).await?;
result
@@ -638,12 +975,24 @@ pub async fn aria2_add_torrent(request: Aria2AddTorrentRequest) -> Result<String
}
let client = Client::new();
let options = build_rpc_options(request.out.as_ref(), request.dir.as_ref(), request.split);
let options = build_rpc_options(
request.out.as_ref(),
request.dir.as_ref(),
request.split,
request.options.as_ref(),
);
let mut params = vec![json!(request.torrent_base64.trim())];
if options.as_object().map(|obj| !obj.is_empty()).unwrap_or(false) {
params.push(json!([]));
params.push(options);
if let Some(position) = request.position {
params.push(json!(position));
}
} else if let Some(position) = request.position {
params.push(json!([]));
params.push(json!({}));
params.push(json!(position));
}
let result = call_aria2_rpc(&client, &request.rpc, "aria2.addTorrent", params).await?;
@@ -670,6 +1019,52 @@ pub async fn aria2_list_tasks(config: Aria2RpcConfig) -> Result<Aria2TaskSnapsho
})
}
#[tauri::command]
pub async fn aria2_get_task_detail(request: Aria2TaskDetailRequest) -> Result<Aria2TaskDetail, String> {
let gid = request.gid.trim();
if gid.is_empty() {
return Err("gid is required".to_string());
}
let client = Client::new();
let status_result =
call_aria2_rpc(&client, &request.rpc, "aria2.tellStatus", vec![json!(gid)]).await?;
let peers_result = call_aria2_rpc(&client, &request.rpc, "aria2.getPeers", vec![json!(gid)])
.await
.unwrap_or_else(|_| json!([]));
let servers_result = call_aria2_rpc(&client, &request.rpc, "aria2.getServers", vec![json!(gid)])
.await
.unwrap_or_else(|_| json!([]));
Ok(Aria2TaskDetail {
gid: value_to_string(status_result.get("gid")),
status: value_to_string(status_result.get("status")),
total_length: value_to_string(status_result.get("totalLength")),
completed_length: value_to_string(status_result.get("completedLength")),
upload_length: value_to_string(status_result.get("uploadLength")),
download_speed: value_to_string(status_result.get("downloadSpeed")),
upload_speed: value_to_string(status_result.get("uploadSpeed")),
num_seeders: value_to_string(status_result.get("numSeeders")),
connections: value_to_string(status_result.get("connections")),
piece_length: value_to_string(status_result.get("pieceLength")),
num_pieces: value_to_string(status_result.get("numPieces")),
dir: value_to_string(status_result.get("dir")),
info_hash: value_to_string(status_result.get("infoHash")),
creation_date: value_to_string(
status_result
.get("bittorrent")
.and_then(|v| v.get("creationDate")),
),
comment: value_to_string(status_result.get("bittorrent").and_then(|v| v.get("comment"))),
error_code: value_to_string(status_result.get("errorCode")),
error_message: value_to_string(status_result.get("errorMessage")),
trackers: map_task_trackers(&status_result),
files: map_task_files(&status_result),
peers: map_task_peers(&peers_result),
servers: map_task_servers(&servers_result),
})
}
#[tauri::command]
pub async fn aria2_pause_task(request: Aria2TaskCommandRequest) -> Result<String, String> {
let gid = request.gid.trim();
@@ -705,29 +1100,43 @@ pub async fn aria2_remove_task(request: Aria2TaskCommandRequest) -> Result<Strin
return Err("gid is required".to_string());
}
let client = Client::new();
// Download 중 항목이 즉시 지워지지 않는 케이스를 줄이기 위해 forceRemove를 우선 시도한다.
match call_aria2_rpc(&client, &request.rpc, "aria2.forceRemove", vec![json!(gid)]).await {
Ok(result) => {
return result
.as_str()
.map(|value| value.to_string())
.ok_or_else(|| format!("aria2.forceRemove returned unexpected result: {result}"))
}
Err(err) if !is_gid_not_found_error(&err) => return Err(err),
Err(_) => {}
};
match call_aria2_rpc(&client, &request.rpc, "aria2.remove", vec![json!(gid)]).await {
Ok(result) => {
return result
.as_str()
.map(|value| value.to_string())
.ok_or_else(|| format!("aria2.remove returned unexpected result: {result}"))
}
Err(err) if !is_gid_not_found_error(&err) => return Err(err),
Err(_) => {}
};
match call_aria2_rpc(
&client,
&request.rpc,
"aria2.removeDownloadResult",
vec![json!(gid)],
)
.await
{
Ok(result) => result
.as_str()
.map(|value| value.to_string())
.ok_or_else(|| format!("aria2.remove returned unexpected result: {result}")),
Err(err) if is_gid_not_found_error(&err) => {
match call_aria2_rpc(
&client,
&request.rpc,
"aria2.removeDownloadResult",
vec![json!(gid)],
)
.await
{
Ok(result) => result
.as_str()
.map(|value| value.to_string())
.ok_or_else(|| format!("aria2.removeDownloadResult returned unexpected result: {result}")),
Err(fallback_err) if is_gid_not_found_error(&fallback_err) => Ok(gid.to_string()),
Err(fallback_err) => Err(fallback_err),
}
}
Err(err) => Err(err),
.ok_or_else(|| format!("aria2.removeDownloadResult returned unexpected result: {result}")),
Err(fallback_err) if is_gid_not_found_error(&fallback_err) => Ok(gid.to_string()),
Err(fallback_err) => Err(fallback_err),
}
}
@@ -769,6 +1178,27 @@ pub async fn aria2_resume_all(config: Aria2RpcConfig) -> Result<String, String>
.ok_or_else(|| format!("aria2.unpauseAll returned unexpected result: {result}"))
}
#[tauri::command]
pub async fn aria2_change_global_option(
request: Aria2ChangeGlobalOptionRequest,
) -> Result<String, String> {
let client = Client::new();
let options = serde_json::to_value(request.options)
.map_err(|err| format!("global option 직렬화 실패: {err}"))?;
let result = call_aria2_rpc(
&client,
&request.rpc,
"aria2.changeGlobalOption",
vec![options],
)
.await?;
result
.as_str()
.map(|value| value.to_string())
.ok_or_else(|| format!("aria2.changeGlobalOption returned unexpected result: {result}"))
}
#[tauri::command]
pub fn load_torrent_file(path: String) -> Result<TorrentFilePayload, String> {
if !path.to_ascii_lowercase().ends_with(".torrent") {

View File

@@ -1,17 +1,106 @@
mod engine;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use tauri::Manager;
use engine::{
aria2_add_torrent, aria2_add_uri, aria2_list_tasks, aria2_pause_all, aria2_pause_task,
aria2_remove_task, aria2_remove_task_record, aria2_resume_all, aria2_resume_task,
detect_aria2_binary, engine_start, engine_status, engine_stop, load_torrent_file,
open_path_in_file_manager, EngineState,
aria2_add_torrent, aria2_add_uri, aria2_change_global_option, aria2_get_task_detail,
aria2_list_tasks, aria2_pause_all, aria2_pause_task, aria2_remove_task,
aria2_remove_task_record, aria2_resume_all, aria2_resume_task, detect_aria2_binary,
engine_start, engine_status, engine_stop,
load_torrent_file,
open_path_in_file_manager, stop_engine_for_exit, EngineState,
};
#[tauri::command]
async fn focus_main_window(app: tauri::AppHandle) -> Result<(), String> {
let window = app
.get_webview_window("main")
.ok_or_else(|| "메인 창을 찾을 수 없습니다.".to_string())?;
window
.show()
.map_err(|error| format!("창 표시 실패: {error}"))?;
window
.unminimize()
.map_err(|error| format!("창 복원 실패: {error}"))?;
window
.set_focus()
.map_err(|error| format!("창 포커스 실패: {error}"))?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ExternalAddRequest {
url: String,
out: Option<String>,
dir: Option<String>,
referer: Option<String>,
user_agent: Option<String>,
authorization: Option<String>,
cookie: Option<String>,
proxy: Option<String>,
split: Option<u32>,
}
fn external_add_queue_path() -> Result<PathBuf, String> {
let home = std::env::var("HOME").map_err(|err| format!("HOME 경로 확인 실패: {err}"))?;
Ok(PathBuf::from(home).join(".gdown").join("external_add_queue.jsonl"))
}
#[tauri::command]
fn take_external_add_requests() -> Result<Vec<ExternalAddRequest>, String> {
let path = external_add_queue_path()?;
let parent = path
.parent()
.ok_or_else(|| "큐 디렉터리 경로를 계산할 수 없습니다.".to_string())?;
fs::create_dir_all(parent).map_err(|err| format!("큐 디렉터리 생성 실패: {err}"))?;
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path).map_err(|err| format!("큐 읽기 실패: {err}"))?;
let mut requests: Vec<ExternalAddRequest> = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(item) = serde_json::from_str::<ExternalAddRequest>(trimmed) {
requests.push(item);
}
}
let mut file = fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&path)
.map_err(|err| format!("큐 초기화 실패: {err}"))?;
file
.write_all(b"")
.map_err(|err| format!("큐 파일 쓰기 실패: {err}"))?;
Ok(requests)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(EngineState::default())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_deep_link::init())
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
if window.label() == "main" {
let state = window.state::<EngineState>();
stop_engine_for_exit(&state);
}
}
})
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -29,6 +118,8 @@ pub fn run() {
detect_aria2_binary,
aria2_add_torrent,
aria2_add_uri,
aria2_change_global_option,
aria2_get_task_detail,
aria2_list_tasks,
aria2_pause_task,
aria2_resume_task,
@@ -37,7 +128,9 @@ pub fn run() {
aria2_pause_all,
aria2_resume_all,
load_torrent_file,
open_path_in_file_manager
open_path_in_file_manager,
focus_main_window,
take_external_add_requests
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "gdown",
"version": "0.1.0",
"version": "0.1.1",
"identifier": "com.tauri.dev",
"build": {
"frontendDist": "../dist",
@@ -39,5 +39,14 @@
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": [
"gdown"
]
}
}
}
}