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:
parent
0f182ab76f
commit
79855585bd
60 changed files with 14532 additions and 18 deletions
57
src-tauri/src/commands/connection.rs
Normal file
57
src-tauri/src/commands/connection.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
use tauri::State;
|
||||
use std::sync::Arc;
|
||||
use crate::domain::connection::{ConnectionParams, Protocol};
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::protocols::sftp::SftpClient;
|
||||
use crate::protocols::ftp::FtpClient;
|
||||
use crate::storage::keychain;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect(
|
||||
registry: State<'_, Arc<RemoteRegistry>>,
|
||||
params: ConnectionParams,
|
||||
) -> Result<(), String> {
|
||||
let password = if let Some(p) = ¶ms.password {
|
||||
p.clone()
|
||||
} else {
|
||||
keychain::get_password(¶ms.id).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
let fs: Arc<dyn crate::protocols::RemoteFs> = match params.protocol {
|
||||
Protocol::Sftp => {
|
||||
if let Some(key_path) = ¶ms.key_path {
|
||||
Arc::new(
|
||||
SftpClient::connect_key(¶ms.host, params.port, ¶ms.username, key_path)
|
||||
.map_err(|e| e.to_string())?
|
||||
)
|
||||
} else {
|
||||
Arc::new(
|
||||
SftpClient::connect_password(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.map_err(|e| e.to_string())?
|
||||
)
|
||||
}
|
||||
}
|
||||
Protocol::Ftp => {
|
||||
Arc::new(
|
||||
FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
)
|
||||
}
|
||||
Protocol::Ftps => {
|
||||
return Err("FTPS not yet implemented (suppaftp API compat)".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
registry.insert(params.id, fs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(
|
||||
registry: State<'_, Arc<RemoteRegistry>>,
|
||||
connection_id: String,
|
||||
) -> Result<(), String> {
|
||||
registry.remove(&connection_id);
|
||||
Ok(())
|
||||
}
|
||||
54
src-tauri/src/commands/fs.rs
Normal file
54
src-tauri/src/commands/fs.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
use crate::fs::{local, remote::RemoteRegistry};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_local(path: String) -> Result<Vec<FileEntry>, String> {
|
||||
local::list(&path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_remote(
|
||||
registry: State<'_, Arc<RemoteRegistry>>,
|
||||
connection_id: String,
|
||||
path: String,
|
||||
) -> Result<Vec<FileEntry>, String> {
|
||||
let fs = registry.get(&connection_id)
|
||||
.ok_or_else(|| format!("no connection: {}", connection_id))?;
|
||||
fs.list(&path).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_mkdir(
|
||||
registry: State<'_, Arc<RemoteRegistry>>,
|
||||
connection_id: String,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
let fs = registry.get(&connection_id)
|
||||
.ok_or_else(|| format!("no connection: {}", connection_id))?;
|
||||
fs.mkdir(&path).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_rename(
|
||||
registry: State<'_, Arc<RemoteRegistry>>,
|
||||
connection_id: String,
|
||||
from: String,
|
||||
to: String,
|
||||
) -> Result<(), String> {
|
||||
let fs = registry.get(&connection_id)
|
||||
.ok_or_else(|| format!("no connection: {}", connection_id))?;
|
||||
fs.rename(&from, &to).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_delete(
|
||||
registry: State<'_, Arc<RemoteRegistry>>,
|
||||
connection_id: String,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
let fs = registry.get(&connection_id)
|
||||
.ok_or_else(|| format!("no connection: {}", connection_id))?;
|
||||
fs.delete(&path).await.map_err(|e| e.to_string())
|
||||
}
|
||||
4
src-tauri/src/commands/mod.rs
Normal file
4
src-tauri/src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod connection;
|
||||
pub mod fs;
|
||||
pub mod sites;
|
||||
pub mod transfer;
|
||||
21
src-tauri/src/commands/sites.rs
Normal file
21
src-tauri/src/commands/sites.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
use tauri::State;
|
||||
use crate::domain::site::Site;
|
||||
use crate::storage::db::Db;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_sites(db: State<'_, Db>) -> Result<Vec<Site>, String> {
|
||||
let conn = db.0.lock().unwrap();
|
||||
crate::storage::db::get_sites(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_site(db: State<'_, Db>, site: Site) -> Result<(), String> {
|
||||
let conn = db.0.lock().unwrap();
|
||||
crate::storage::db::save_site(&conn, &site).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_site(db: State<'_, Db>, id: String) -> Result<(), String> {
|
||||
let conn = db.0.lock().unwrap();
|
||||
crate::storage::db::delete_site(&conn, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
103
src-tauri/src/commands/transfer.rs
Normal file
103
src-tauri/src/commands/transfer.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
use tauri::State;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::domain::transfer::{TransferTask, TransferKind, TaskState};
|
||||
use crate::transfer::manager::TransferManager;
|
||||
use crate::domain::file_entry::EntryKind;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload(
|
||||
manager: State<'_, Arc<TransferManager>>,
|
||||
connection_id: String,
|
||||
local_path: String,
|
||||
remote_path: String,
|
||||
) -> Result<String, String> {
|
||||
let meta = std::fs::metadata(&local_path).map_err(|e| format!("cannot stat local file: {}", e))?;
|
||||
if !meta.is_file() {
|
||||
return Err("local path is not a file".into());
|
||||
}
|
||||
|
||||
let file_name = std::path::Path::new(&local_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Upload,
|
||||
connection_id,
|
||||
local_path,
|
||||
remote_path,
|
||||
file_name,
|
||||
meta.len(),
|
||||
);
|
||||
let task_id = task.id.clone();
|
||||
|
||||
// Проверяем, что соединение живо
|
||||
if manager.registry().get(&task.connection_id).is_none() {
|
||||
return Err(format!("no connection: {}", task.connection_id));
|
||||
}
|
||||
|
||||
manager.queue.push(task);
|
||||
Ok(task_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download(
|
||||
manager: State<'_, Arc<TransferManager>>,
|
||||
connection_id: String,
|
||||
remote_path: String,
|
||||
local_path: String,
|
||||
) -> Result<String, String> {
|
||||
// Узнаём размер удалённого файла через stat
|
||||
let registry = manager.registry();
|
||||
let fs = registry.get(&connection_id)
|
||||
.ok_or_else(|| format!("no connection: {}", connection_id))?;
|
||||
let stat = fs.stat(&remote_path).await.map_err(|e| format!("stat failed: {}", e))?;
|
||||
|
||||
let file_name = std::path::Path::new(&remote_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let total = match stat.kind {
|
||||
EntryKind::File => stat.size.unwrap_or(0),
|
||||
_ => return Err("remote path is not a file".into()),
|
||||
};
|
||||
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Download,
|
||||
connection_id,
|
||||
local_path,
|
||||
remote_path,
|
||||
file_name,
|
||||
total,
|
||||
);
|
||||
let task_id = task.id.clone();
|
||||
manager.queue.push(task);
|
||||
Ok(task_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_queue(
|
||||
manager: State<'_, Arc<TransferManager>>,
|
||||
) -> Result<Vec<TransferTask>, String> {
|
||||
Ok(manager.queue.all())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pause_task(
|
||||
manager: State<'_, Arc<TransferManager>>,
|
||||
task_id: String,
|
||||
) -> Result<(), String> {
|
||||
manager.queue.update_state(&task_id, TaskState::Paused);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_task(
|
||||
manager: State<'_, Arc<TransferManager>>,
|
||||
task_id: String,
|
||||
) -> Result<(), String> {
|
||||
manager.queue.update_state(&task_id, TaskState::Cancelled);
|
||||
Ok(())
|
||||
}
|
||||
32
src-tauri/src/domain/connection.rs
Normal file
32
src-tauri/src/domain/connection.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Protocol {
|
||||
Sftp,
|
||||
Ftp,
|
||||
Ftps,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConnectionParams {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub protocol: Protocol,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
/// None = use keychain
|
||||
pub password: Option<String>,
|
||||
pub key_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConnectionStatus {
|
||||
Connected,
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Error(String),
|
||||
}
|
||||
20
src-tauri/src/domain/file_entry.rs
Normal file
20
src-tauri/src/domain/file_entry.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EntryKind {
|
||||
File,
|
||||
Dir,
|
||||
Symlink,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub kind: EntryKind,
|
||||
pub size: Option<u64>,
|
||||
pub modified: Option<i64>, // unix timestamp
|
||||
pub permissions: Option<String>,
|
||||
}
|
||||
4
src-tauri/src/domain/mod.rs
Normal file
4
src-tauri/src/domain/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod connection;
|
||||
pub mod file_entry;
|
||||
pub mod site;
|
||||
pub mod transfer;
|
||||
16
src-tauri/src/domain/site.rs
Normal file
16
src-tauri/src/domain/site.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::connection::Protocol;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Site {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub protocol: Protocol,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub key_path: Option<String>,
|
||||
pub folder: Option<String>,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
85
src-tauri/src/domain/transfer.rs
Normal file
85
src-tauri/src/domain/transfer.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
use std::fmt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TransferKind {
|
||||
Upload,
|
||||
Download,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TaskState {
|
||||
Queued,
|
||||
Running,
|
||||
Paused,
|
||||
Cancelled,
|
||||
Completed,
|
||||
Failed(String),
|
||||
Retrying(u32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TransferTask {
|
||||
pub id: String,
|
||||
pub kind: TransferKind,
|
||||
pub connection_id: String,
|
||||
pub local_path: String,
|
||||
pub remote_path: String,
|
||||
pub file_name: String,
|
||||
pub total_bytes: u64,
|
||||
pub transferred_bytes: u64,
|
||||
pub state: TaskState,
|
||||
/// bytes/sec, updated with throttle
|
||||
pub speed: Option<u64>,
|
||||
pub eta_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl fmt::Display for TaskState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
TaskState::Queued => write!(f, "queued"),
|
||||
TaskState::Running => write!(f, "running"),
|
||||
TaskState::Paused => write!(f, "paused"),
|
||||
TaskState::Cancelled => write!(f, "cancelled"),
|
||||
TaskState::Completed => write!(f, "completed"),
|
||||
TaskState::Failed(e) => write!(f, "failed: {}", e),
|
||||
TaskState::Retrying(n) => write!(f, "retrying({})", n),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TransferTask {
|
||||
pub fn new(
|
||||
kind: TransferKind,
|
||||
connection_id: String,
|
||||
local_path: String,
|
||||
remote_path: String,
|
||||
file_name: String,
|
||||
total_bytes: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
kind,
|
||||
connection_id,
|
||||
local_path,
|
||||
remote_path,
|
||||
file_name,
|
||||
total_bytes,
|
||||
transferred_bytes: 0,
|
||||
state: TaskState::Queued,
|
||||
speed: None,
|
||||
eta_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn progress_pct(&self) -> f64 {
|
||||
if self.total_bytes == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.transferred_bytes as f64 / self.total_bytes as f64) * 100.0
|
||||
}
|
||||
}
|
||||
54
src-tauri/src/fs/local.rs
Normal file
54
src-tauri/src/fs/local.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use anyhow::Result;
|
||||
use std::fs;
|
||||
use crate::domain::file_entry::{FileEntry, EntryKind};
|
||||
|
||||
pub fn list(path: &str) -> Result<Vec<FileEntry>> {
|
||||
let mut entries: Vec<FileEntry> = fs::read_dir(path)?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| {
|
||||
let meta = e.metadata().ok();
|
||||
let kind = meta.as_ref().map(|m| {
|
||||
if m.is_dir() { EntryKind::Dir }
|
||||
else if m.is_symlink() { EntryKind::Symlink }
|
||||
else { EntryKind::File }
|
||||
}).unwrap_or(EntryKind::File);
|
||||
|
||||
let size = meta.as_ref().and_then(|m| {
|
||||
if m.is_file() { Some(m.len()) } else { None }
|
||||
});
|
||||
|
||||
let modified = meta.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64);
|
||||
|
||||
FileEntry {
|
||||
name: e.file_name().to_string_lossy().to_string(),
|
||||
path: e.path().to_string_lossy().to_string(),
|
||||
kind,
|
||||
size,
|
||||
modified,
|
||||
permissions: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// папки сначала, потом файлы, по имени
|
||||
entries.sort_by(|a, b| {
|
||||
match (&a.kind, &b.kind) {
|
||||
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
|
||||
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
|
||||
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn home_dir() -> String {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
2
src-tauri/src/fs/mod.rs
Normal file
2
src-tauri/src/fs/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod local;
|
||||
pub mod remote;
|
||||
25
src-tauri/src/fs/remote.rs
Normal file
25
src-tauri/src/fs/remote.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Абстракция над RemoteFs для хранения активных соединений
|
||||
// TODO: HashMap<connection_id, Arc<dyn RemoteFs>>
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use crate::protocols::RemoteFs;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RemoteRegistry {
|
||||
connections: Mutex<HashMap<String, Arc<dyn RemoteFs>>>,
|
||||
}
|
||||
|
||||
impl RemoteRegistry {
|
||||
pub fn insert(&self, id: String, fs: Arc<dyn RemoteFs>) {
|
||||
self.connections.lock().unwrap().insert(id, fs);
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<Arc<dyn RemoteFs>> {
|
||||
self.connections.lock().unwrap().get(id).cloned()
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &str) {
|
||||
self.connections.lock().unwrap().remove(id);
|
||||
}
|
||||
}
|
||||
47
src-tauri/src/lib.rs
Normal file
47
src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
pub mod commands;
|
||||
pub mod domain;
|
||||
pub mod fs;
|
||||
pub mod protocols;
|
||||
pub mod storage;
|
||||
pub mod transfer;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
use crate::transfer::manager::TransferManager;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::fs::list_local,
|
||||
commands::fs::list_remote,
|
||||
commands::fs::remote_mkdir,
|
||||
commands::fs::remote_rename,
|
||||
commands::fs::remote_delete,
|
||||
commands::connection::connect,
|
||||
commands::connection::disconnect,
|
||||
commands::transfer::upload,
|
||||
commands::transfer::download,
|
||||
commands::transfer::get_queue,
|
||||
commands::transfer::pause_task,
|
||||
commands::transfer::cancel_task,
|
||||
commands::sites::get_sites,
|
||||
commands::sites::save_site,
|
||||
commands::sites::delete_site,
|
||||
])
|
||||
.setup(|app| {
|
||||
let registry = Arc::new(crate::fs::remote::RemoteRegistry::default());
|
||||
let manager = TransferManager::new(registry.clone(), app.handle().clone());
|
||||
|
||||
app.manage(registry);
|
||||
app.manage(manager);
|
||||
|
||||
storage::db::init(app.handle())?;
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running loflum");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
6
src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
loflum_lib::run();
|
||||
}
|
||||
155
src-tauri/src/protocols/ftp.rs
Normal file
155
src-tauri/src/protocols/ftp.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
use async_trait::async_trait;
|
||||
use anyhow::Result;
|
||||
use std::str::FromStr;
|
||||
use std::time::SystemTime;
|
||||
use tokio::sync::Mutex;
|
||||
use async_std::io::{ReadExt, WriteExt};
|
||||
|
||||
use suppaftp::{AsyncFtpStream, list::File};
|
||||
|
||||
use crate::domain::file_entry::{FileEntry, EntryKind};
|
||||
use super::RemoteFs;
|
||||
|
||||
pub struct FtpClient {
|
||||
pub conn: Mutex<Option<AsyncFtpStream>>,
|
||||
}
|
||||
|
||||
impl FtpClient {
|
||||
pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result<Self> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let mut stream = AsyncFtpStream::connect(&addr).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP connect failed: {}", e))?;
|
||||
stream.login(user, pass).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP login failed: {}", e))?;
|
||||
Ok(Self { conn: Mutex::new(Some(stream)) })
|
||||
}
|
||||
|
||||
fn parse_list_entry(line: &str) -> Option<FileEntry> {
|
||||
let file = File::from_str(line).ok()?;
|
||||
let kind = if file.is_directory() {
|
||||
EntryKind::Dir
|
||||
} else if file.is_symlink() {
|
||||
EntryKind::Symlink
|
||||
} else {
|
||||
EntryKind::File
|
||||
};
|
||||
|
||||
let modified = file.modified()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|d| d.as_secs() as i64);
|
||||
|
||||
Some(FileEntry {
|
||||
name: file.name().to_string(),
|
||||
path: file.name().to_string(),
|
||||
kind,
|
||||
size: Some(file.size() as u64),
|
||||
modified,
|
||||
permissions: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteFs for FtpClient {
|
||||
async fn list(&self, path: &str) -> Result<Vec<FileEntry>> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let lines = conn.list(Some(path)).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP list failed: {}", e))?;
|
||||
|
||||
let mut entries: Vec<FileEntry> = lines.iter()
|
||||
.filter_map(|line| Self::parse_list_entry(line))
|
||||
.collect();
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
match (&a.kind, &b.kind) {
|
||||
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
|
||||
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
|
||||
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn upload(&self, local: &str, remote: &str) -> Result<()> {
|
||||
let data = std::fs::read(local)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read local file: {}", e))?;
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let mut stream = conn.put_with_stream(remote).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP upload stream failed: {}", e))?;
|
||||
stream.write_all(&data).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP upload write failed: {}", e))?;
|
||||
conn.finalize_put_stream(stream).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP upload finalize failed: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download(&self, remote: &str, local: &str) -> Result<()> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let mut stream = conn.retr_as_stream(remote).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP download stream failed: {}", e))?;
|
||||
let mut buf = Vec::new();
|
||||
stream.read_to_end(&mut buf).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP download read failed: {}", e))?;
|
||||
conn.finalize_retr_stream(stream).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP download finalize failed: {}", e))?;
|
||||
std::fs::write(local, &buf)
|
||||
.map_err(|e| anyhow::anyhow!("cannot write local file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
conn.mkdir(path).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP mkdir failed: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
conn.rename(from, to).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP rename failed: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
if conn.rm(path).await.is_err() {
|
||||
conn.rmdir(path).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP delete failed: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<FileEntry> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let size = conn.size(path).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP stat failed: {}", e))?;
|
||||
let modified = conn.mdtm(path).await
|
||||
.map(|dt| dt.and_utc().timestamp())
|
||||
.ok();
|
||||
|
||||
let name = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path.to_string());
|
||||
|
||||
Ok(FileEntry {
|
||||
name,
|
||||
path: path.to_string(),
|
||||
kind: EntryKind::File,
|
||||
size: Some(size as u64),
|
||||
modified,
|
||||
permissions: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
19
src-tauri/src/protocols/mod.rs
Normal file
19
src-tauri/src/protocols/mod.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
pub mod ftp;
|
||||
pub mod sftp;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Единый контракт для всех протоколов.
|
||||
/// Очередь передач не знает — FTP это или SFTP.
|
||||
#[async_trait]
|
||||
pub trait RemoteFs: Send + Sync {
|
||||
async fn list(&self, path: &str) -> Result<Vec<FileEntry>>;
|
||||
async fn upload(&self, local: &str, remote: &str) -> Result<()>;
|
||||
async fn download(&self, remote: &str, local: &str) -> Result<()>;
|
||||
async fn mkdir(&self, path: &str) -> Result<()>;
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()>;
|
||||
async fn delete(&self, path: &str) -> Result<()>;
|
||||
async fn stat(&self, path: &str) -> Result<FileEntry>;
|
||||
}
|
||||
131
src-tauri/src/protocols/sftp.rs
Normal file
131
src-tauri/src/protocols/sftp.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
use async_trait::async_trait;
|
||||
use anyhow::{Result, Context};
|
||||
use ssh2::Session;
|
||||
use std::net::TcpStream;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::file_entry::{FileEntry, EntryKind};
|
||||
use super::RemoteFs;
|
||||
|
||||
pub struct SftpClient {
|
||||
session: Session,
|
||||
}
|
||||
|
||||
impl SftpClient {
|
||||
/// Подключение по паролю
|
||||
pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result<Self> {
|
||||
let tcp = TcpStream::connect(format!("{}:{}", host, port))
|
||||
.context("TCP connect failed")?;
|
||||
let mut session = Session::new().context("SSH session init failed")?;
|
||||
session.set_tcp_stream(tcp);
|
||||
session.handshake().context("SSH handshake failed")?;
|
||||
session.userauth_password(user, password)
|
||||
.context("SSH password auth failed")?;
|
||||
Ok(Self { session })
|
||||
}
|
||||
|
||||
/// Подключение по ключу
|
||||
pub fn connect_key(host: &str, port: u16, user: &str, key_path: &str) -> Result<Self> {
|
||||
let tcp = TcpStream::connect(format!("{}:{}", host, port))
|
||||
.context("TCP connect failed")?;
|
||||
let mut session = Session::new().context("SSH session init failed")?;
|
||||
session.set_tcp_stream(tcp);
|
||||
session.handshake().context("SSH handshake failed")?;
|
||||
session.userauth_pubkey_file(user, None, Path::new(key_path), None)
|
||||
.context("SSH key auth failed")?;
|
||||
Ok(Self { session })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteFs for SftpClient {
|
||||
async fn list(&self, path: &str) -> Result<Vec<FileEntry>> {
|
||||
// TODO: перенести в spawn_blocking когда будет Arc<Session>
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
let entries = sftp.readdir(Path::new(path)).context("readdir failed")?;
|
||||
|
||||
let result = entries.into_iter().map(|(pb, stat)| {
|
||||
let kind = if stat.is_dir() {
|
||||
EntryKind::Dir
|
||||
} else if stat.file_type().is_symlink() {
|
||||
EntryKind::Symlink
|
||||
} else {
|
||||
EntryKind::File
|
||||
};
|
||||
FileEntry {
|
||||
name: pb.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
path: pb.to_string_lossy().to_string(),
|
||||
kind,
|
||||
size: stat.size,
|
||||
modified: stat.mtime.map(|t| t as i64),
|
||||
permissions: None,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn upload(&self, local: &str, remote: &str) -> Result<()> {
|
||||
// TODO: chunked upload с прогресс-событиями
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
let mut local_file = std::fs::File::open(local)
|
||||
.context("open local file failed")?;
|
||||
let mut remote_file = sftp.create(Path::new(remote))
|
||||
.context("create remote file failed")?;
|
||||
std::io::copy(&mut local_file, &mut remote_file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download(&self, remote: &str, local: &str) -> Result<()> {
|
||||
// TODO: chunked download с прогресс-событиями
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
let mut remote_file = sftp.open(Path::new(remote))
|
||||
.context("open remote file failed")?;
|
||||
let mut local_file = std::fs::File::create(local)
|
||||
.context("create local file failed")?;
|
||||
std::io::copy(&mut remote_file, &mut local_file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
sftp.mkdir(Path::new(path), 0o755).context("mkdir failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
sftp.rename(Path::new(from), Path::new(to), None)
|
||||
.context("rename failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
// пробуем как файл, потом как директорию
|
||||
if sftp.unlink(Path::new(path)).is_err() {
|
||||
sftp.rmdir(Path::new(path)).context("delete failed")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<FileEntry> {
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
let stat = sftp.stat(Path::new(path)).context("stat failed")?;
|
||||
Ok(FileEntry {
|
||||
name: Path::new(path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
path: path.to_string(),
|
||||
kind: if stat.is_dir() { EntryKind::Dir } else { EntryKind::File },
|
||||
size: stat.size,
|
||||
modified: stat.mtime.map(|t| t as i64),
|
||||
permissions: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
85
src-tauri/src/storage/db.rs
Normal file
85
src-tauri/src/storage/db.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
use anyhow::Result;
|
||||
use rusqlite::{Connection, params};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use std::sync::Mutex;
|
||||
use crate::domain::site::Site;
|
||||
use crate::domain::connection::Protocol;
|
||||
|
||||
pub struct Db(pub Mutex<Connection>);
|
||||
|
||||
pub fn init(app: &AppHandle) -> Result<()> {
|
||||
let path = app.path().app_data_dir()
|
||||
.expect("no app data dir")
|
||||
.join("loflum.db");
|
||||
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let conn = Connection::open(&path)?;
|
||||
|
||||
conn.execute_batch("
|
||||
PRAGMA journal_mode=WAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
key_path TEXT,
|
||||
folder TEXT,
|
||||
note TEXT
|
||||
);
|
||||
")?;
|
||||
|
||||
app.manage(Db(Mutex::new(conn)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, protocol, host, port, username, key_path, folder, note FROM sites ORDER BY name"
|
||||
)?;
|
||||
let sites = stmt.query_map([], |row| {
|
||||
let proto_str: String = row.get(2)?;
|
||||
let protocol = match proto_str.as_str() {
|
||||
"sftp" => Protocol::Sftp,
|
||||
"ftps" => Protocol::Ftps,
|
||||
_ => Protocol::Ftp,
|
||||
};
|
||||
Ok(Site {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
protocol,
|
||||
host: row.get(3)?,
|
||||
port: row.get(4)?,
|
||||
username: row.get(5)?,
|
||||
key_path: row.get(6)?,
|
||||
folder: row.get(7)?,
|
||||
note: row.get(8)?,
|
||||
})
|
||||
})?.filter_map(|r| r.ok()).collect();
|
||||
Ok(sites)
|
||||
}
|
||||
|
||||
pub fn save_site(conn: &Connection, site: &Site) -> Result<()> {
|
||||
let proto = match site.protocol {
|
||||
Protocol::Sftp => "sftp",
|
||||
Protocol::Ftp => "ftp",
|
||||
Protocol::Ftps => "ftps",
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sites
|
||||
(id, name, protocol, host, port, username, key_path, folder, note)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
|
||||
params![
|
||||
site.id, site.name, proto, site.host, site.port,
|
||||
site.username, site.key_path, site.folder, site.note
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_site(conn: &Connection, id: &str) -> Result<()> {
|
||||
conn.execute("DELETE FROM sites WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
21
src-tauri/src/storage/keychain.rs
Normal file
21
src-tauri/src/storage/keychain.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
use anyhow::Result;
|
||||
use keyring::Entry;
|
||||
|
||||
const SERVICE: &str = "loflum-ftp-client";
|
||||
|
||||
pub fn store_password(site_id: &str, password: &str) -> Result<()> {
|
||||
let entry = Entry::new(SERVICE, site_id)?;
|
||||
entry.set_password(password)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_password(site_id: &str) -> Result<String> {
|
||||
let entry = Entry::new(SERVICE, site_id)?;
|
||||
Ok(entry.get_password()?)
|
||||
}
|
||||
|
||||
pub fn delete_password(site_id: &str) -> Result<()> {
|
||||
let entry = Entry::new(SERVICE, site_id)?;
|
||||
entry.delete_password()?;
|
||||
Ok(())
|
||||
}
|
||||
2
src-tauri/src/storage/mod.rs
Normal file
2
src-tauri/src/storage/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod db;
|
||||
pub mod keychain;
|
||||
32
src-tauri/src/transfer/manager.rs
Normal file
32
src-tauri/src/transfer/manager.rs
Normal 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
|
||||
}
|
||||
}
|
||||
5
src-tauri/src/transfer/mod.rs
Normal file
5
src-tauri/src/transfer/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod manager;
|
||||
pub mod progress;
|
||||
pub mod queue;
|
||||
pub mod task;
|
||||
pub mod worker;
|
||||
35
src-tauri/src/transfer/progress.rs
Normal file
35
src-tauri/src/transfer/progress.rs
Normal 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
|
||||
}
|
||||
}
|
||||
46
src-tauri/src/transfer/queue.rs
Normal file
46
src-tauri/src/transfer/queue.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
19
src-tauri/src/transfer/task.rs
Normal file
19
src-tauri/src/transfer/task.rs
Normal 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,
|
||||
)
|
||||
}
|
||||
99
src-tauri/src/transfer/worker.rs
Normal file
99
src-tauri/src/transfer/worker.rs
Normal 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()),
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue