feat: align motrix-style download UI/actions and stabilize aria2 ops
This commit is contained in:
@@ -2,11 +2,13 @@ use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
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 tauri::State;
|
||||
use tauri::{Manager, State};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -46,6 +48,13 @@ pub struct Aria2AddTorrentRequest {
|
||||
pub split: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Aria2TaskCommandRequest {
|
||||
pub rpc: Aria2RpcConfig,
|
||||
pub gid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EngineStatusResponse {
|
||||
@@ -66,6 +75,7 @@ pub struct Aria2TaskSummary {
|
||||
pub download_speed: String,
|
||||
pub dir: String,
|
||||
pub file_name: String,
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -84,9 +94,20 @@ pub struct TorrentFilePayload {
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Aria2BinaryProbeResponse {
|
||||
pub found: bool,
|
||||
pub binary_path: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub candidates: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct EngineRuntime {
|
||||
child: Option<Child>,
|
||||
external_reuse: bool,
|
||||
rpc_port: Option<u16>,
|
||||
binary_path: Option<String>,
|
||||
args: Vec<String>,
|
||||
started_at: Option<u64>,
|
||||
@@ -96,6 +117,8 @@ impl Default for EngineRuntime {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
child: None,
|
||||
external_reuse: false,
|
||||
rpc_port: None,
|
||||
binary_path: None,
|
||||
args: vec![],
|
||||
started_at: None,
|
||||
@@ -111,7 +134,7 @@ pub struct EngineState {
|
||||
impl EngineState {
|
||||
fn status(runtime: &EngineRuntime) -> EngineStatusResponse {
|
||||
EngineStatusResponse {
|
||||
running: runtime.child.is_some(),
|
||||
running: runtime.child.is_some() || runtime.external_reuse,
|
||||
pid: runtime.child.as_ref().map(std::process::Child::id),
|
||||
binary_path: runtime.binary_path.clone(),
|
||||
args: runtime.args.clone(),
|
||||
@@ -153,6 +176,133 @@ fn default_aria2_binary() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_binary_hint(binary_hint: Option<&str>) -> String {
|
||||
let value = binary_hint.unwrap_or("").trim();
|
||||
if value.is_empty() {
|
||||
default_aria2_binary()
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_push(path: &Path, out: &mut Vec<String>) {
|
||||
if let Some(s) = path.to_str() {
|
||||
let v = s.trim();
|
||||
if !v.is_empty() {
|
||||
out.push(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_aliases() -> Vec<&'static str> {
|
||||
match env::consts::OS {
|
||||
"macos" => vec!["macos", "darwin"],
|
||||
"windows" => vec!["windows", "win32"],
|
||||
"linux" => vec!["linux"],
|
||||
_ => vec![env::consts::OS],
|
||||
}
|
||||
}
|
||||
|
||||
fn arch_aliases() -> Vec<&'static str> {
|
||||
match env::consts::ARCH {
|
||||
"aarch64" => vec!["aarch64", "arm64"],
|
||||
"x86_64" => vec!["x86_64", "x64"],
|
||||
"x86" => vec!["x86", "ia32"],
|
||||
"arm" => vec!["arm", "armv7l"],
|
||||
other => vec![other],
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_binary_candidates(app: &tauri::AppHandle, binary_hint: Option<&str>) -> Vec<String> {
|
||||
let binary_name = normalize_binary_hint(binary_hint);
|
||||
let mut candidates: Vec<String> = vec![];
|
||||
|
||||
if let Some(raw) = binary_hint.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
candidates.push(raw.to_string());
|
||||
}
|
||||
|
||||
if let Ok(env_path) = env::var("ARIA2C_BIN") {
|
||||
let trimmed = env_path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
candidates.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
if let Ok(env_path) = env::var("ARIA2C_PATH") {
|
||||
let trimmed = env_path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
candidates.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path_var) = env::var_os("PATH") {
|
||||
for dir in env::split_paths(&path_var) {
|
||||
candidate_push(&dir.join(&binary_name), &mut candidates);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let lower = binary_name.to_ascii_lowercase();
|
||||
if !lower.ends_with(".exe") {
|
||||
candidate_push(&dir.join(format!("{binary_name}.exe")), &mut candidates);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let source_engine_base = Path::new(env!("CARGO_MANIFEST_DIR")).join("resources").join("engine");
|
||||
let mut engine_bases = vec![source_engine_base];
|
||||
if let Ok(resource_dir) = app.path().resource_dir() {
|
||||
engine_bases.push(resource_dir.join("engine"));
|
||||
}
|
||||
|
||||
let platforms = platform_aliases();
|
||||
let arches = arch_aliases();
|
||||
for base in engine_bases {
|
||||
for platform in &platforms {
|
||||
for arch in &arches {
|
||||
candidate_push(&base.join(platform).join(arch).join(&binary_name), &mut candidates);
|
||||
}
|
||||
candidate_push(&base.join(platform).join(&binary_name), &mut candidates);
|
||||
}
|
||||
candidate_push(&base.join(&binary_name), &mut candidates);
|
||||
}
|
||||
|
||||
let mut deduped = Vec::with_capacity(candidates.len());
|
||||
for path in candidates {
|
||||
if !deduped.contains(&path) {
|
||||
deduped.push(path);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
fn resolve_binary_from_candidates(candidates: &[String]) -> Option<(String, String)> {
|
||||
for (idx, candidate) in candidates.iter().enumerate() {
|
||||
let path = Path::new(candidate);
|
||||
if path.is_file() {
|
||||
let source = if idx == 0 { "user_or_default" } else { "auto_detected" };
|
||||
return Some((candidate.clone(), source.to_string()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn classify_engine_spawn_error(binary: &str, err: &std::io::Error) -> String {
|
||||
use std::io::ErrorKind;
|
||||
match err.kind() {
|
||||
ErrorKind::NotFound => format!(
|
||||
"aria2 binary not found: '{binary}'. Binary Path를 지정하거나 PATH/resources/engine 경로를 확인하세요."
|
||||
),
|
||||
ErrorKind::PermissionDenied => {
|
||||
format!("aria2 binary is not executable: '{binary}'. 실행 권한(chmod +x)을 확인하세요.")
|
||||
}
|
||||
_ => format!("failed to start aria2 engine with '{binary}': {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_local_port_open(port: u16) -> bool {
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(220)).is_ok()
|
||||
}
|
||||
|
||||
fn rpc_endpoint(config: &Aria2RpcConfig) -> String {
|
||||
format!(
|
||||
"http://127.0.0.1:{}/jsonrpc",
|
||||
@@ -249,6 +399,17 @@ fn map_task(task: &Value) -> Aria2TaskSummary {
|
||||
download_speed: value_to_string(task.get("downloadSpeed")),
|
||||
dir: value_to_string(task.get("dir")),
|
||||
file_name: pick_file_name(file_path),
|
||||
uri: task
|
||||
.get("files")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|files| files.first())
|
||||
.and_then(|file| file.get("uris"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|uris| uris.first())
|
||||
.and_then(|uri| uri.get("uri"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,16 +438,33 @@ fn build_rpc_options(out: Option<&String>, dir: Option<&String>, split: Option<u
|
||||
Value::Object(options)
|
||||
}
|
||||
|
||||
fn is_gid_not_found_error(message: &str) -> bool {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
lower.contains("not found for gid#")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn engine_start(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, EngineState>,
|
||||
request: EngineStartRequest,
|
||||
) -> Result<EngineStatusResponse, String> {
|
||||
let rpc_port = request.rpc_listen_port.unwrap_or(6800);
|
||||
let mut runtime = state
|
||||
.runtime
|
||||
.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(_)) => {
|
||||
@@ -302,7 +480,31 @@ pub fn engine_start(
|
||||
}
|
||||
|
||||
let args = build_engine_args(&request);
|
||||
let binary = request.binary_path.unwrap_or_else(default_aria2_binary);
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
let candidates = collect_binary_candidates(&app, request.binary_path.as_deref());
|
||||
let (binary, _) = resolve_binary_from_candidates(&candidates).ok_or_else(|| {
|
||||
let sample = candidates.into_iter().take(6).collect::<Vec<String>>().join(", ");
|
||||
format!(
|
||||
"aria2 binary를 찾지 못했습니다. Binary Path를 직접 지정하세요. (검색 후보: {sample})"
|
||||
)
|
||||
})?;
|
||||
|
||||
let child = Command::new(&binary)
|
||||
.args(&args)
|
||||
@@ -310,9 +512,25 @@ pub fn engine_start(
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|err| format!("failed to start aria2 engine with '{binary}': {err}"))?;
|
||||
.map_err(|err| classify_engine_spawn_error(&binary, &err))?;
|
||||
|
||||
let mut child = child;
|
||||
std::thread::sleep(std::time::Duration::from_millis(220));
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
return Err(format!(
|
||||
"aria2 engine exited immediately (status={status}). 포트 충돌 또는 잘못된 옵션일 수 있습니다."
|
||||
));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
return Err(format!("failed to inspect aria2 engine process: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
runtime.child = Some(child);
|
||||
runtime.external_reuse = false;
|
||||
runtime.rpc_port = Some(rpc_port);
|
||||
runtime.binary_path = Some(binary);
|
||||
runtime.args = args;
|
||||
runtime.started_at = Some(
|
||||
@@ -325,6 +543,21 @@ pub fn engine_start(
|
||||
Ok(EngineState::status(&runtime))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn detect_aria2_binary(
|
||||
app: tauri::AppHandle,
|
||||
binary_path: Option<String>,
|
||||
) -> Aria2BinaryProbeResponse {
|
||||
let candidates = collect_binary_candidates(&app, binary_path.as_deref());
|
||||
let resolved = resolve_binary_from_candidates(&candidates);
|
||||
Aria2BinaryProbeResponse {
|
||||
found: resolved.is_some(),
|
||||
binary_path: resolved.as_ref().map(|v| v.0.clone()),
|
||||
source: resolved.map(|v| v.1),
|
||||
candidates,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn engine_stop(state: State<'_, EngineState>) -> Result<EngineStatusResponse, String> {
|
||||
let mut runtime = state
|
||||
@@ -339,6 +572,8 @@ pub fn engine_stop(state: State<'_, EngineState>) -> Result<EngineStatusResponse
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
runtime.external_reuse = false;
|
||||
runtime.rpc_port = None;
|
||||
runtime.started_at = None;
|
||||
Ok(EngineState::status(&runtime))
|
||||
}
|
||||
@@ -363,6 +598,15 @@ pub fn engine_status(state: State<'_, EngineState>) -> Result<EngineStatusRespon
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.external_reuse {
|
||||
let alive = runtime.rpc_port.map(is_local_port_open).unwrap_or(false);
|
||||
if !alive {
|
||||
runtime.external_reuse = false;
|
||||
runtime.rpc_port = None;
|
||||
runtime.started_at = None;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(EngineState::status(&runtime))
|
||||
}
|
||||
|
||||
@@ -426,6 +670,105 @@ pub async fn aria2_list_tasks(config: Aria2RpcConfig) -> Result<Aria2TaskSnapsho
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aria2_pause_task(request: Aria2TaskCommandRequest) -> Result<String, String> {
|
||||
let gid = request.gid.trim();
|
||||
if gid.is_empty() {
|
||||
return Err("gid is required".to_string());
|
||||
}
|
||||
let client = Client::new();
|
||||
let result = call_aria2_rpc(&client, &request.rpc, "aria2.pause", vec![json!(gid)]).await?;
|
||||
result
|
||||
.as_str()
|
||||
.map(|value| value.to_string())
|
||||
.ok_or_else(|| format!("aria2.pause returned unexpected result: {result}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aria2_resume_task(request: Aria2TaskCommandRequest) -> Result<String, String> {
|
||||
let gid = request.gid.trim();
|
||||
if gid.is_empty() {
|
||||
return Err("gid is required".to_string());
|
||||
}
|
||||
let client = Client::new();
|
||||
let result = call_aria2_rpc(&client, &request.rpc, "aria2.unpause", vec![json!(gid)]).await?;
|
||||
result
|
||||
.as_str()
|
||||
.map(|value| value.to_string())
|
||||
.ok_or_else(|| format!("aria2.unpause returned unexpected result: {result}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aria2_remove_task(request: Aria2TaskCommandRequest) -> Result<String, String> {
|
||||
let gid = request.gid.trim();
|
||||
if gid.is_empty() {
|
||||
return Err("gid is required".to_string());
|
||||
}
|
||||
let client = Client::new();
|
||||
match call_aria2_rpc(&client, &request.rpc, "aria2.remove", 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),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aria2_remove_task_record(request: Aria2TaskCommandRequest) -> Result<String, String> {
|
||||
let gid = request.gid.trim();
|
||||
if gid.is_empty() {
|
||||
return Err("gid is required".to_string());
|
||||
}
|
||||
let client = Client::new();
|
||||
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(err) if is_gid_not_found_error(&err) => Ok(gid.to_string()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aria2_pause_all(config: Aria2RpcConfig) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
let result = call_aria2_rpc(&client, &config, "aria2.pauseAll", vec![]).await?;
|
||||
result
|
||||
.as_str()
|
||||
.map(|value| value.to_string())
|
||||
.ok_or_else(|| format!("aria2.pauseAll returned unexpected result: {result}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aria2_resume_all(config: Aria2RpcConfig) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
let result = call_aria2_rpc(&client, &config, "aria2.unpauseAll", vec![]).await?;
|
||||
result
|
||||
.as_str()
|
||||
.map(|value| value.to_string())
|
||||
.ok_or_else(|| format!("aria2.unpauseAll returned unexpected result: {result}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_torrent_file(path: String) -> Result<TorrentFilePayload, String> {
|
||||
if !path.to_ascii_lowercase().ends_with(".torrent") {
|
||||
@@ -445,3 +788,34 @@ pub fn load_torrent_file(path: String) -> Result<TorrentFilePayload, String> {
|
||||
size: bytes.len() as u64,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_path_in_file_manager(path: String) -> Result<(), String> {
|
||||
let target = path.trim();
|
||||
if target.is_empty() {
|
||||
return Err("path is required".to_string());
|
||||
}
|
||||
|
||||
let mut command = if cfg!(target_os = "macos") {
|
||||
let mut cmd = Command::new("open");
|
||||
cmd.arg(target);
|
||||
cmd
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let mut cmd = Command::new("explorer");
|
||||
cmd.arg(target);
|
||||
cmd
|
||||
} else {
|
||||
let mut cmd = Command::new("xdg-open");
|
||||
cmd.arg(target);
|
||||
cmd
|
||||
};
|
||||
|
||||
let status = command
|
||||
.status()
|
||||
.map_err(|err| format!("failed to open path in file manager: {err}"))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(format!("file manager command exited with status: {status}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
mod engine;
|
||||
|
||||
use engine::{
|
||||
aria2_add_torrent, aria2_add_uri, aria2_list_tasks, engine_start, engine_status, engine_stop,
|
||||
load_torrent_file, EngineState,
|
||||
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,
|
||||
};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(EngineState::default())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
@@ -23,10 +26,18 @@ pub fn run() {
|
||||
engine_start,
|
||||
engine_stop,
|
||||
engine_status,
|
||||
detect_aria2_binary,
|
||||
aria2_add_torrent,
|
||||
aria2_add_uri,
|
||||
aria2_list_tasks,
|
||||
load_torrent_file
|
||||
aria2_pause_task,
|
||||
aria2_resume_task,
|
||||
aria2_remove_task,
|
||||
aria2_remove_task_record,
|
||||
aria2_pause_all,
|
||||
aria2_resume_all,
|
||||
load_torrent_file,
|
||||
open_path_in_file_manager
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
Reference in New Issue
Block a user