Initial commit: LoFlum FTP/SFTP client

Tauri 2 + Rust + Svelte 5. SFTP via ssh2, FTP via suppaftp.
Transfer queue with async worker, SQLite site storage, keychain.
This commit is contained in:
loki5512344 2026-06-13 12:06:56 +02:00
parent 0f182ab76f
commit 79855585bd
60 changed files with 14532 additions and 18 deletions

View file

@ -0,0 +1,32 @@
use std::sync::Arc;
use tauri::AppHandle;
use crate::transfer::queue::TransferQueue;
use crate::transfer::worker::spawn_worker;
use crate::fs::remote::RemoteRegistry;
/// Хранит очередь передач и запускает воркер.
pub struct TransferManager {
pub queue: TransferQueue,
registry: Arc<RemoteRegistry>,
app: AppHandle,
}
impl TransferManager {
pub fn new(registry: Arc<RemoteRegistry>, app: AppHandle) -> Arc<Self> {
let manager = Arc::new(Self {
queue: TransferQueue::default(),
registry: registry.clone(),
app: app.clone(),
});
spawn_worker(manager.clone(), registry, app);
manager
}
pub fn registry(&self) -> &RemoteRegistry {
&self.registry
}
pub fn app(&self) -> &AppHandle {
&self.app
}
}

View file

@ -0,0 +1,5 @@
pub mod manager;
pub mod progress;
pub mod queue;
pub mod task;
pub mod worker;

View file

@ -0,0 +1,35 @@
use std::time::{Duration, Instant};
/// Throttle прогресс-событий — максимум 10 в секунду.
/// Пользователь разницы не увидит, а тысяч emit'ов не будет.
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), // 10 fps
}
}
}
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
}
}
/// Всегда emit при завершении/ошибке
pub fn force(&mut self) -> bool {
self.last_emit = Instant::now() - self.interval;
true
}
}

View file

@ -0,0 +1,46 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use crate::domain::transfer::{TransferTask, TaskState};
#[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 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);
if speed > 0 {
let remaining = t.total_bytes.saturating_sub(transferred);
t.eta_secs = Some(remaining / speed);
}
}
}
pub fn remove(&self, id: &str) {
let mut q = self.inner.lock().unwrap();
q.retain(|t| t.id != id);
}
}

View file

@ -0,0 +1,19 @@
use crate::domain::transfer::{TransferTask, TransferKind};
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,
)
}

View file

@ -0,0 +1,99 @@
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tokio::time::{sleep, Duration};
use crate::domain::transfer::{TaskState, TransferKind};
use crate::fs::remote::RemoteRegistry;
use crate::transfer::manager::TransferManager;
/// Событие для фронтенда
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct TransferProgress {
task_id: String,
state: String,
transferred_bytes: u64,
total_bytes: u64,
speed: Option<u64>,
eta_secs: Option<u64>,
error: Option<String>,
}
pub fn spawn_worker(
manager: Arc<TransferManager>,
registry: Arc<RemoteRegistry>,
app: AppHandle,
) {
tokio::spawn(async move {
loop {
let task = {
let task = manager.queue.pop();
if let Some(ref t) = task {
if t.state != TaskState::Queued {
// paused/cancelled — не трогаем, вернём
manager.queue.push(t.clone());
sleep(Duration::from_millis(500)).await;
continue;
}
}
task
};
let mut task = match task {
Some(t) => t,
None => {
sleep(Duration::from_millis(500)).await;
continue;
}
};
// Старт
task.state = TaskState::Running;
manager.queue.update_state(&task.id, TaskState::Running);
emit_progress(&app, &task, None);
let fs = match registry.get(&task.connection_id) {
Some(fs) => fs,
None => {
task.state = TaskState::Failed("connection not found".into());
manager.queue.update_state(&task.id, task.state.clone());
emit_progress(&app, &task, Some("connection not found"));
continue;
}
};
let result = match task.kind {
TransferKind::Upload => fs.upload(&task.local_path, &task.remote_path).await,
TransferKind::Download => fs.download(&task.remote_path, &task.local_path).await,
};
match result {
Ok(()) => {
task.state = TaskState::Completed;
task.transferred_bytes = task.total_bytes;
manager.queue.update_state(&task.id, TaskState::Completed);
manager.queue.update_progress(&task.id, task.total_bytes, 0);
emit_progress(&app, &task, None);
}
Err(e) => {
let msg = e.to_string();
task.state = TaskState::Failed(msg.clone());
manager.queue.update_state(&task.id, TaskState::Failed(msg.clone()));
emit_progress(&app, &task, Some(&msg));
}
}
}
});
}
fn emit_progress(app: &AppHandle, task: &crate::domain::transfer::TransferTask, error: Option<&str>) {
let _ = app.emit("transfer-progress", TransferProgress {
task_id: task.id.clone(),
state: task.state.to_string(),
transferred_bytes: task.transferred_bytes,
total_bytes: task.total_bytes,
speed: task.speed,
eta_secs: task.eta_secs,
error: error.map(|s| s.to_string()),
});
}