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:
loki5512344 2026-07-11 00:24:18 +02:00
parent e188fbef36
commit d543ca4755
Signed by: boba
GPG key ID: 253067914055423B
42 changed files with 1620 additions and 1639 deletions

5
src/transfers/mod.rs Normal file
View 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
View 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
View 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());
}
}