feat: external capture queue and modal-first add flow (v0.1.1)
This commit is contained in:
115
src-tauri/Cargo.lock
generated
115
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:allow-open"
|
||||
"dialog:allow-open",
|
||||
"deep-link:default"
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user