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,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) = &params.password {
p.clone()
} else {
keychain::get_password(&params.id).map_err(|e| e.to_string())?
};
let fs: Arc<dyn crate::protocols::RemoteFs> = match params.protocol {
Protocol::Sftp => {
if let Some(key_path) = &params.key_path {
Arc::new(
SftpClient::connect_key(&params.host, params.port, &params.username, key_path)
.map_err(|e| e.to_string())?
)
} else {
Arc::new(
SftpClient::connect_password(&params.host, params.port, &params.username, &password)
.map_err(|e| e.to_string())?
)
}
}
Protocol::Ftp => {
Arc::new(
FtpClient::connect(&params.host, params.port, &params.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(())
}

View 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())
}

View file

@ -0,0 +1,4 @@
pub mod connection;
pub mod fs;
pub mod sites;
pub mod transfer;

View 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())
}

View 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(())
}

View 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),
}

View 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>,
}

View file

@ -0,0 +1,4 @@
pub mod connection;
pub mod file_entry;
pub mod site;
pub mod transfer;

View 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>,
}

View 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
View 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
View file

@ -0,0 +1,2 @@
pub mod local;
pub mod remote;

View 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
View 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
View 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();
}

View 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,
})
}
}

View 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>;
}

View 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,
})
}
}

View 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(())
}

View 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(())
}

View file

@ -0,0 +1,2 @@
pub mod db;
pub mod keychain;

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()),
});
}