refactor: collapse micro-modules, add AUR packaging
- domain/ (7 files) → domain.rs + error.rs + window.rs
- protocols/sftp/ (4 files) → sftp.rs, ftp/ (3 files) → ftp.rs
- storage/db/ (6 files) → storage.rs + settings.rs
- transfer/ (5 files) → transfers/{queue,worker}.rs
- add dist/PKGBUILD, .SRCINFO, wherry.desktop for AUR
- fix all import paths, 53 tests pass
This commit is contained in:
parent
e188fbef36
commit
d543ca4755
42 changed files with 1620 additions and 1639 deletions
5
src/transfers/mod.rs
Normal file
5
src/transfers/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod queue;
|
||||
pub mod worker;
|
||||
|
||||
pub use queue::TransferQueue;
|
||||
pub use worker::spawn_worker;
|
||||
183
src/transfers/queue.rs
Normal file
183
src/transfers/queue.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
use crate::domain::{TaskState, TransferKind, TransferTask};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub fn new_task(
|
||||
kind: TransferKind,
|
||||
connection_id: impl Into<String>,
|
||||
local_path: impl Into<String>,
|
||||
remote_path: impl Into<String>,
|
||||
file_name: impl Into<String>,
|
||||
total_bytes: u64,
|
||||
) -> TransferTask {
|
||||
TransferTask::new(
|
||||
kind,
|
||||
connection_id.into(),
|
||||
local_path.into(),
|
||||
remote_path.into(),
|
||||
file_name.into(),
|
||||
total_bytes,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct TransferQueue {
|
||||
inner: Arc<Mutex<VecDeque<TransferTask>>>,
|
||||
}
|
||||
|
||||
impl TransferQueue {
|
||||
pub fn push(&self, task: TransferTask) {
|
||||
self.inner.lock().unwrap().push_back(task);
|
||||
}
|
||||
|
||||
pub fn pop(&self) -> Option<TransferTask> {
|
||||
self.inner.lock().unwrap().pop_front()
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<TransferTask> {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn all(&self) -> Vec<TransferTask> {
|
||||
self.inner.lock().unwrap().iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn update_state(&self, id: &str, state: TaskState) {
|
||||
let mut q = self.inner.lock().unwrap();
|
||||
if let Some(t) = q.iter_mut().find(|t| t.id == id) {
|
||||
t.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_progress(&self, id: &str, transferred: u64, speed: u64) {
|
||||
let mut q = self.inner.lock().unwrap();
|
||||
if let Some(t) = q.iter_mut().find(|t| t.id == id) {
|
||||
t.transferred_bytes = transferred;
|
||||
t.speed = Some(speed);
|
||||
let remaining = t.total_bytes.saturating_sub(transferred);
|
||||
t.eta_secs = remaining.checked_div(speed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &str) {
|
||||
let mut q = self.inner.lock().unwrap();
|
||||
q.retain(|t| t.id != id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{TaskState, TransferKind, TransferTask};
|
||||
|
||||
fn make_task(id: &str, total: u64) -> TransferTask {
|
||||
TransferTask {
|
||||
id: id.into(),
|
||||
kind: TransferKind::Download,
|
||||
connection_id: "conn-1".into(),
|
||||
local_path: "/local".into(),
|
||||
remote_path: "/remote".into(),
|
||||
file_name: "file.txt".into(),
|
||||
total_bytes: total,
|
||||
transferred_bytes: 0,
|
||||
state: TaskState::Queued,
|
||||
speed: None,
|
||||
eta_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_pop_all() {
|
||||
let q = TransferQueue::default();
|
||||
assert!(q.all().is_empty());
|
||||
|
||||
q.push(make_task("t1", 100));
|
||||
q.push(make_task("t2", 200));
|
||||
assert_eq!(q.all().len(), 2);
|
||||
|
||||
let t1 = q.pop().unwrap();
|
||||
assert_eq!(t1.id, "t1");
|
||||
assert_eq!(q.all().len(), 1);
|
||||
|
||||
let t2 = q.pop().unwrap();
|
||||
assert_eq!(t2.id, "t2");
|
||||
assert!(q.pop().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get() {
|
||||
let q = TransferQueue::default();
|
||||
q.push(make_task("t1", 100));
|
||||
q.push(make_task("t2", 200));
|
||||
|
||||
assert_eq!(q.get("t2").unwrap().total_bytes, 200);
|
||||
assert!(q.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_state() {
|
||||
let q = TransferQueue::default();
|
||||
q.push(make_task("t1", 100));
|
||||
|
||||
for state in [
|
||||
TaskState::Running,
|
||||
TaskState::Paused,
|
||||
TaskState::Completed,
|
||||
TaskState::Failed("err".into()),
|
||||
TaskState::Cancelled,
|
||||
TaskState::Retrying(5),
|
||||
] {
|
||||
q.update_state("t1", state.clone());
|
||||
assert_eq!(q.get("t1").unwrap().state, state);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_progress() {
|
||||
let q = TransferQueue::default();
|
||||
q.push(make_task("t1", 1000));
|
||||
|
||||
q.update_progress("t1", 250, 50);
|
||||
let t = q.get("t1").unwrap();
|
||||
assert_eq!(t.transferred_bytes, 250);
|
||||
assert_eq!(t.speed, Some(50));
|
||||
assert_eq!(t.eta_secs, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_progress_zero_speed() {
|
||||
let q = TransferQueue::default();
|
||||
q.push(make_task("t1", 1000));
|
||||
|
||||
q.update_progress("t1", 100, 0);
|
||||
let t = q.get("t1").unwrap();
|
||||
assert_eq!(t.transferred_bytes, 100);
|
||||
assert_eq!(t.speed, Some(0));
|
||||
assert_eq!(t.eta_secs, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove() {
|
||||
let q = TransferQueue::default();
|
||||
q.push(make_task("t1", 100));
|
||||
q.push(make_task("t2", 200));
|
||||
q.push(make_task("t3", 300));
|
||||
|
||||
q.remove("t2");
|
||||
let ids: Vec<String> = q.all().into_iter().map(|t| t.id).collect();
|
||||
assert_eq!(ids, vec!["t1", "t3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_nonexistent() {
|
||||
let q = TransferQueue::default();
|
||||
q.push(make_task("t1", 100));
|
||||
q.remove("does-not-exist");
|
||||
assert_eq!(q.all().len(), 1);
|
||||
}
|
||||
}
|
||||
302
src/transfers/worker.rs
Normal file
302
src/transfers/worker.rs
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::domain::{TaskState, TransferKind, TransferTask};
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::protocols::ProgressAction;
|
||||
use crate::transfers::queue::TransferQueue;
|
||||
|
||||
const POLL_INTERVAL_MS: u64 = 200;
|
||||
|
||||
pub struct ProgressThrottle {
|
||||
last_emit: Instant,
|
||||
interval: Duration,
|
||||
}
|
||||
|
||||
impl Default for ProgressThrottle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_emit: Instant::now() - Duration::from_secs(1),
|
||||
interval: Duration::from_millis(100),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgressThrottle {
|
||||
pub fn should_emit(&mut self) -> bool {
|
||||
let now = Instant::now();
|
||||
if now.duration_since(self.last_emit) >= self.interval {
|
||||
self.last_emit = now;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn force(&mut self) -> bool {
|
||||
self.last_emit = Instant::now() - self.interval;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TransferManager {
|
||||
pub queue: TransferQueue,
|
||||
pub registry: Arc<RemoteRegistry>,
|
||||
}
|
||||
|
||||
impl TransferManager {
|
||||
pub fn new(registry: Arc<RemoteRegistry>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
queue: TransferQueue::default(),
|
||||
registry,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn registry(&self) -> &RemoteRegistry {
|
||||
&self.registry
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct ProgressPayload {
|
||||
id: String,
|
||||
transferred_bytes: u64,
|
||||
speed: u64,
|
||||
eta_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct StatePayload {
|
||||
id: String,
|
||||
state: TaskState,
|
||||
}
|
||||
|
||||
fn emit_progress(app: &AppHandle, queue: &TransferQueue, id: &str) {
|
||||
if let Some(t) = queue.get(id) {
|
||||
let _ = app.emit(
|
||||
"transfer-progress",
|
||||
ProgressPayload {
|
||||
id: t.id,
|
||||
transferred_bytes: t.transferred_bytes,
|
||||
speed: t.speed.unwrap_or(0),
|
||||
eta_secs: t.eta_secs,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_state(app: &AppHandle, id: &str, state: TaskState) {
|
||||
let _ = app.emit(
|
||||
"transfer-state-changed",
|
||||
StatePayload {
|
||||
id: id.to_string(),
|
||||
state,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
struct InFlightGuard(Arc<AtomicUsize>);
|
||||
impl Drop for InFlightGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_worker(
|
||||
queue: TransferQueue,
|
||||
registry: Arc<RemoteRegistry>,
|
||||
rt_handle: tokio::runtime::Handle,
|
||||
max_concurrent: Arc<AtomicU32>,
|
||||
auto_clear_secs: Arc<AtomicU32>,
|
||||
app: AppHandle,
|
||||
) {
|
||||
let in_flight = Arc::new(AtomicUsize::new(0));
|
||||
let completed_at: Arc<Mutex<HashMap<String, Instant>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
let completed_at_clone = completed_at.clone();
|
||||
rt_handle.spawn(async move {
|
||||
loop {
|
||||
let limit = max_concurrent.load(Ordering::Relaxed).max(1) as usize;
|
||||
while in_flight.load(Ordering::Relaxed) < limit {
|
||||
let Some(task) = queue
|
||||
.all()
|
||||
.into_iter()
|
||||
.find(|t| t.state == TaskState::Queued)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
queue.update_state(&task.id, TaskState::Running);
|
||||
emit_state(&app, &task.id, TaskState::Running);
|
||||
in_flight.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
let queue = queue.clone();
|
||||
let registry = registry.clone();
|
||||
let guard_counter = in_flight.clone();
|
||||
let app = app.clone();
|
||||
let completed_at = completed_at.clone();
|
||||
tokio::spawn(async move {
|
||||
let _guard = InFlightGuard(guard_counter);
|
||||
run_transfer(task, queue, registry, app, completed_at).await;
|
||||
});
|
||||
}
|
||||
|
||||
let clear_after = auto_clear_secs.load(Ordering::Relaxed);
|
||||
if clear_after > 0 {
|
||||
let mut completed = completed_at_clone.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
completed.retain(|id, completion_time| {
|
||||
if now.duration_since(*completion_time).as_secs() >= clear_after as u64 {
|
||||
queue.remove(id);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_transfer(
|
||||
task: TransferTask,
|
||||
queue: TransferQueue,
|
||||
registry: Arc<RemoteRegistry>,
|
||||
app: AppHandle,
|
||||
completed_at: Arc<Mutex<HashMap<String, Instant>>>,
|
||||
) {
|
||||
let fs = match registry.get(&task.connection_id) {
|
||||
Some(fs) => fs,
|
||||
None => {
|
||||
queue.update_state(&task.id, TaskState::Failed("connection not found".into()));
|
||||
emit_state(
|
||||
&app,
|
||||
&task.id,
|
||||
TaskState::Failed("connection not found".into()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let queue_for_progress = queue.clone();
|
||||
let task_id_for_progress = task.id.clone();
|
||||
let app_for_progress = app.clone();
|
||||
let throttle = Arc::new(std::sync::Mutex::new(ProgressThrottle::default()));
|
||||
let last_sample = Arc::new(std::sync::Mutex::new((Instant::now(), 0u64)));
|
||||
let on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>> =
|
||||
Some(Box::new(move |transferred: u64| {
|
||||
if let Some(t) = queue_for_progress.get(&task_id_for_progress) {
|
||||
match t.state {
|
||||
TaskState::Cancelled => return ProgressAction::Cancel,
|
||||
TaskState::Paused => return ProgressAction::Pause,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let speed = {
|
||||
let mut guard = last_sample.lock().unwrap();
|
||||
let (last_time, last_bytes) = *guard;
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(last_time).as_secs_f64();
|
||||
let speed = if elapsed > 0.0 {
|
||||
((transferred.saturating_sub(last_bytes)) as f64 / elapsed) as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
*guard = (now, transferred);
|
||||
speed
|
||||
};
|
||||
if throttle.lock().unwrap().should_emit() {
|
||||
queue_for_progress.update_progress(&task_id_for_progress, transferred, speed);
|
||||
emit_progress(
|
||||
&app_for_progress,
|
||||
&queue_for_progress,
|
||||
&task_id_for_progress,
|
||||
);
|
||||
}
|
||||
ProgressAction::Continue
|
||||
}));
|
||||
|
||||
let result = match task.kind {
|
||||
TransferKind::Upload => {
|
||||
fs.upload_with_progress(&task.local_path, &task.remote_path, on_progress)
|
||||
.await
|
||||
}
|
||||
TransferKind::Download => {
|
||||
fs.download_with_progress(&task.remote_path, &task.local_path, on_progress)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
queue.update_state(&task.id, TaskState::Completed);
|
||||
queue.update_progress(&task.id, task.total_bytes, 0);
|
||||
completed_at
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(task.id.clone(), Instant::now());
|
||||
emit_progress(&app, &queue, &task.id);
|
||||
emit_state(&app, &task.id, TaskState::Completed);
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg == "cancelled" {
|
||||
queue.update_state(&task.id, TaskState::Cancelled);
|
||||
queue.remove(&task.id);
|
||||
emit_state(&app, &task.id, TaskState::Cancelled);
|
||||
} else if msg == "paused" {
|
||||
let transferred = queue
|
||||
.get(&task.id)
|
||||
.map(|t| t.transferred_bytes)
|
||||
.unwrap_or(0);
|
||||
queue.update_state(&task.id, TaskState::Paused);
|
||||
queue.update_progress(&task.id, transferred, 0);
|
||||
emit_state(&app, &task.id, TaskState::Paused);
|
||||
} else {
|
||||
queue.update_state(&task.id, TaskState::Failed(msg.clone()));
|
||||
emit_state(&app, &task.id, TaskState::Failed(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_first_call_emits() {
|
||||
let mut throttle = ProgressThrottle::default();
|
||||
assert!(throttle.should_emit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_too_soon_does_not_emit() {
|
||||
let mut throttle = ProgressThrottle::default();
|
||||
throttle.should_emit();
|
||||
assert!(!throttle.should_emit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_force_resets() {
|
||||
let mut throttle = ProgressThrottle::default();
|
||||
throttle.should_emit();
|
||||
assert!(throttle.force());
|
||||
assert!(throttle.should_emit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interval_elapsed_emits() {
|
||||
let mut throttle = ProgressThrottle {
|
||||
last_emit: Instant::now() - Duration::from_millis(200),
|
||||
interval: Duration::from_millis(100),
|
||||
};
|
||||
assert!(throttle.should_emit());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue