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
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,5 +1,9 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/*.tar.gz
|
||||||
|
dist/*.zip
|
||||||
|
dist/*.deb
|
||||||
|
dist/*.rpm
|
||||||
|
dist/pkg/
|
||||||
target/
|
target/
|
||||||
.tauri/
|
.tauri/
|
||||||
src/target/
|
src/target/
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ FTP/SFTP клиент на Tauri 2: бэкенд на Rust, фронтенд —
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/img/start_screen.png" alt="Wherry main screen" width="80%" style="max-width:800px;border-radius:10px">
|
<img src="docs/img/start_screen.png" alt="Wherry main screen" width="80%" style="max-width:800px;border-radius:10px">
|
||||||
<br><em>Welcome-экран с сохранёнными сайтами и историей подключений</em>
|
<br><em>Welcome-экран с сохранёнными сайтами и историей подключений (тема Midnight, нестандартная — в настройках доступно 6 тем)</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
|
||||||
22
dist/.SRCINFO
vendored
Normal file
22
dist/.SRCINFO
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
pkgbase = wherry
|
||||||
|
pkgdesc = A modern dual-pane file manager with SFTP/FTP/FTPS support
|
||||||
|
pkgver = 0.1.0
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/loki5512344/Wherry
|
||||||
|
install =
|
||||||
|
arch = x86_64
|
||||||
|
license = GPL3
|
||||||
|
makedepends = cargo
|
||||||
|
makedepends = rust
|
||||||
|
makedepends = patchelf
|
||||||
|
depends = webkit2gtk-4.1
|
||||||
|
depends = gtk3
|
||||||
|
depends = libsoup-3.0
|
||||||
|
depends = librsvg
|
||||||
|
depends = libappindicator-gtk3
|
||||||
|
depends = libdbus-1
|
||||||
|
depends = openssl
|
||||||
|
source = wherry-0.1.0.tar.gz::https://github.com/loki5512344/Wherry/archive/refs/tags/v0.1.0.tar.gz
|
||||||
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
pkgname = wherry
|
||||||
43
dist/PKGBUILD
vendored
Normal file
43
dist/PKGBUILD
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Maintainer: loki5512344 <dev14dbq@gmail.com>
|
||||||
|
# Contributor: Your name <your@email.com>
|
||||||
|
|
||||||
|
pkgname=wherry
|
||||||
|
pkgver=0.1.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="A modern dual-pane file manager with SFTP/FTP/FTPS support"
|
||||||
|
arch=('x86_64')
|
||||||
|
url='https://github.com/loki5512344/Wherry'
|
||||||
|
license=('GPL3')
|
||||||
|
depends=(
|
||||||
|
'webkit2gtk-4.1'
|
||||||
|
'gtk3'
|
||||||
|
'libsoup-3.0'
|
||||||
|
'librsvg'
|
||||||
|
'libappindicator-gtk3'
|
||||||
|
'libdbus-1'
|
||||||
|
'openssl'
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'cargo'
|
||||||
|
'rust'
|
||||||
|
'patchelf'
|
||||||
|
)
|
||||||
|
source=("$pkgname-$pkgver.tar.gz::https://github.com/loki5512344/Wherry/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
cd "$srcdir/$pkgname-$pkgver/src"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$srcdir/$pkgname-$pkgver/src"
|
||||||
|
export CARGO_TARGET_DIR="$srcdir/target"
|
||||||
|
cargo build --release --frozen
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$srcdir/$pkgname-$pkgver/src"
|
||||||
|
install -Dm755 target/release/wherry "$pkgdir/usr/bin/wherry"
|
||||||
|
install -Dm644 icons/128x128.png "$pkgdir/usr/share/pixmaps/wherry.png"
|
||||||
|
install -Dm644 dist/wherry.desktop "$pkgdir/usr/share/applications/wherry.desktop"
|
||||||
|
}
|
||||||
9
dist/wherry.desktop
vendored
Normal file
9
dist/wherry.desktop
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Wherry
|
||||||
|
Comment=FTP/SFTP client with dual-pane file manager
|
||||||
|
Exec=wherry
|
||||||
|
Icon=wherry
|
||||||
|
Categories=Network;FileTransfer;Utility;
|
||||||
|
Terminal=false
|
||||||
|
StartupNotify=true
|
||||||
|
|
@ -3,23 +3,23 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
use tauri::State;
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
use crate::domain::connection::{ConnectionParams, Protocol};
|
use crate::domain::{
|
||||||
use crate::domain::error::AppError;
|
ConnectionParams, FileEntry, Protocol, Site, TaskState, TransferKind, TransferTask,
|
||||||
use crate::domain::file_entry::FileEntry;
|
};
|
||||||
use crate::domain::site::Site;
|
use crate::error::AppError;
|
||||||
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
|
|
||||||
use crate::domain::window_state::WindowState;
|
|
||||||
use crate::fs::remote::RemoteRegistry;
|
use crate::fs::remote::RemoteRegistry;
|
||||||
use crate::protocols::{
|
use crate::protocols::{
|
||||||
RemoteFs,
|
RemoteFs,
|
||||||
ftp::{FtpClient, FtpsClient},
|
ftp::{FtpClient, FtpsClient},
|
||||||
sftp::SftpClient,
|
sftp::SftpClient,
|
||||||
};
|
};
|
||||||
use crate::storage::db::{self, HistoryRow};
|
use crate::settings;
|
||||||
use crate::transfer::queue::TransferQueue;
|
use crate::storage::{self, HistoryRow};
|
||||||
|
use crate::transfers::queue::TransferQueue;
|
||||||
|
use crate::window::WindowState;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Arc<std::sync::Mutex<rusqlite::Connection>>,
|
pub db: Arc<std::sync::Mutex<rusqlite::Connection>>,
|
||||||
|
|
@ -46,7 +46,7 @@ pub async fn connect(
|
||||||
.lock()
|
.lock()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|conn| {
|
.and_then(|conn| {
|
||||||
db::find_history_conn_id(&conn, ¶ms.host, params.port, ¶ms.username)
|
storage::find_history_conn_id(&conn, ¶ms.host, params.port, ¶ms.username)
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
})
|
})
|
||||||
|
|
@ -98,7 +98,7 @@ pub async fn connect(
|
||||||
registry.insert(params.id.clone(), fs);
|
registry.insert(params.id.clone(), fs);
|
||||||
|
|
||||||
if let Ok(conn) = db.lock() {
|
if let Ok(conn) = db.lock() {
|
||||||
let _ = db::add_history_entry(
|
let _ = storage::add_history_entry(
|
||||||
&conn,
|
&conn,
|
||||||
¶ms.host,
|
¶ms.host,
|
||||||
params.port,
|
params.port,
|
||||||
|
|
@ -266,7 +266,7 @@ pub fn remove_task(state: State<'_, AppState>, id: String) {
|
||||||
pub fn set_max_concurrent(state: State<'_, AppState>, n: u32) {
|
pub fn set_max_concurrent(state: State<'_, AppState>, n: u32) {
|
||||||
state.max_concurrent.store(n.max(1), Ordering::Relaxed);
|
state.max_concurrent.store(n.max(1), Ordering::Relaxed);
|
||||||
if let Ok(conn) = state.db.lock() {
|
if let Ok(conn) = state.db.lock() {
|
||||||
db::set_u32(&conn, "max_concurrent_transfers", n.max(1));
|
settings::set_u32(&conn, "max_concurrent_transfers", n.max(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,7 +278,7 @@ pub fn list_sites(state: State<'_, AppState>) -> Result<Vec<Site>, AppError> {
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
Ok(db::get_sites(&conn)?)
|
Ok(storage::get_sites(&conn)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -287,7 +287,7 @@ pub fn save_site(state: State<'_, AppState>, site: Site) -> Result<(), AppError>
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
Ok(db::save_site(&conn, &site)?)
|
Ok(storage::save_site(&conn, &site)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -296,7 +296,7 @@ pub fn delete_site(state: State<'_, AppState>, id: String) -> Result<(), AppErro
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
Ok(db::delete_site(&conn, &id)?)
|
Ok(storage::delete_site(&conn, &id)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -305,7 +305,7 @@ pub fn list_bookmarks(state: State<'_, AppState>) -> Result<Vec<(i64, String, St
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
Ok(db::get_bookmarks(&conn)?)
|
Ok(settings::get_bookmarks(&conn)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -318,7 +318,7 @@ pub fn add_bookmark(
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
Ok(db::add_bookmark(&conn, &name, &path)?)
|
Ok(settings::add_bookmark(&conn, &name, &path)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -327,7 +327,7 @@ pub fn remove_bookmark(state: State<'_, AppState>, id: i64) -> Result<(), AppErr
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
Ok(db::remove_bookmark(&conn, id)?)
|
Ok(settings::remove_bookmark(&conn, id)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -336,7 +336,7 @@ pub fn list_history(state: State<'_, AppState>) -> Result<Vec<HistoryRow>, AppEr
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
Ok(db::get_history(&conn)?)
|
Ok(storage::get_history(&conn)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -345,7 +345,7 @@ pub fn clear_history(state: State<'_, AppState>) -> Result<(), AppError> {
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
Ok(db::clear_history(&conn)?)
|
Ok(storage::clear_history(&conn)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -359,7 +359,9 @@ pub fn find_history_conn_id(
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
Ok(db::find_history_conn_id(&conn, &host, port, &username)?)
|
Ok(storage::find_history_conn_id(
|
||||||
|
&conn, &host, port, &username,
|
||||||
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -368,19 +370,21 @@ pub fn get_pref(state: State<'_, AppState>, key: String) -> Result<Option<String
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
Ok(db::get_setting(&conn, &key))
|
Ok(settings::get_setting(&conn, &key))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_pref(state: State<'_, AppState>, key: String, value: String) -> Result<(), AppError> {
|
pub fn set_pref(state: State<'_, AppState>, key: String, value: String) -> Result<(), AppError> {
|
||||||
if key == "auto_clear_completed_secs" && let Ok(secs) = value.parse::<u32>() {
|
if key == "auto_clear_completed_secs"
|
||||||
|
&& let Ok(secs) = value.parse::<u32>()
|
||||||
|
{
|
||||||
state.auto_clear_secs.store(secs, Ordering::Relaxed);
|
state.auto_clear_secs.store(secs, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
let conn = state
|
let conn = state
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
Ok(db::set_setting(&conn, &key, &value)?)
|
Ok(settings::set_setting(&conn, &key, &value)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Удалить пароль из сохранённого сайта (очистить поле password в БД).
|
/// Удалить пароль из сохранённого сайта (очистить поле password в БД).
|
||||||
|
|
@ -454,7 +458,7 @@ pub fn save_window_state(
|
||||||
let key = format!("window_state_{}", window_state.label);
|
let key = format!("window_state_{}", window_state.label);
|
||||||
let json =
|
let json =
|
||||||
serde_json::to_string(&window_state).map_err(|e| AppError::Internal(e.to_string()))?;
|
serde_json::to_string(&window_state).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
db::set_setting(&conn, &key, &json)?;
|
settings::set_setting(&conn, &key, &json)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -468,7 +472,7 @@ pub fn load_window_state(
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
let key = format!("window_state_{}", label);
|
let key = format!("window_state_{}", label);
|
||||||
match db::get_setting(&conn, &key) {
|
match settings::get_setting(&conn, &key) {
|
||||||
Some(json) => {
|
Some(json) => {
|
||||||
let ws: WindowState =
|
let ws: WindowState =
|
||||||
serde_json::from_str(&json).map_err(|e| AppError::Internal(e.to_string()))?;
|
serde_json::from_str(&json).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
|
@ -484,11 +488,8 @@ pub async fn new_window(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let label = format!("browser-{}", uuid::Uuid::new_v4());
|
let label = format!("browser-{}", uuid::Uuid::new_v4());
|
||||||
let window = tauri::WebviewWindowBuilder::new(
|
let window =
|
||||||
&app_handle,
|
tauri::WebviewWindowBuilder::new(&app_handle, &label, tauri::WebviewUrl::App("/".into()))
|
||||||
&label,
|
|
||||||
tauri::WebviewUrl::App("/".into()),
|
|
||||||
)
|
|
||||||
.title("Wherry")
|
.title("Wherry")
|
||||||
.inner_size(1280.0, 800.0)
|
.inner_size(1280.0, 800.0)
|
||||||
.min_inner_size(900.0, 600.0)
|
.min_inner_size(900.0, 600.0)
|
||||||
|
|
@ -516,9 +517,7 @@ pub(crate) fn save_window_state_internal(
|
||||||
) {
|
) {
|
||||||
let label = window.label().to_string();
|
let label = window.label().to_string();
|
||||||
let pos = window.outer_position().ok();
|
let pos = window.outer_position().ok();
|
||||||
let size = window
|
let size = window.outer_size().unwrap_or(tauri::PhysicalSize {
|
||||||
.outer_size()
|
|
||||||
.unwrap_or(tauri::PhysicalSize {
|
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 800,
|
height: 800,
|
||||||
});
|
});
|
||||||
|
|
@ -536,7 +535,7 @@ pub(crate) fn save_window_state_internal(
|
||||||
if let Ok(conn) = db.lock() {
|
if let Ok(conn) = db.lock() {
|
||||||
let key = format!("window_state_{}", label);
|
let key = format!("window_state_{}", label);
|
||||||
if let Ok(json) = serde_json::to_string(&ws) {
|
if let Ok(json) = serde_json::to_string(&ws) {
|
||||||
let _ = db::set_setting(&conn, &key, &json);
|
let _ = settings::set_setting(&conn, &key, &json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
376
src/domain.rs
Normal file
376
src/domain.rs
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ── Protocol & ConnectionParams ──
|
||||||
|
#[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),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EntryKind & FileEntry ──
|
||||||
|
#[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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Site ──
|
||||||
|
#[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 password: Option<String>,
|
||||||
|
pub key_path: Option<String>,
|
||||||
|
pub folder: Option<String>,
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TransferKind, TaskState, TransferTask ──
|
||||||
|
#[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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// from connection.rs
|
||||||
|
#[test]
|
||||||
|
fn test_protocol_serde() {
|
||||||
|
let sftp = Protocol::Sftp;
|
||||||
|
let json = serde_json::to_string(&sftp).unwrap();
|
||||||
|
assert_eq!(json, "\"sftp\"");
|
||||||
|
let deserialized: Protocol = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized, Protocol::Sftp);
|
||||||
|
|
||||||
|
let ftp = Protocol::Ftp;
|
||||||
|
let json = serde_json::to_string(&ftp).unwrap();
|
||||||
|
assert_eq!(json, "\"ftp\"");
|
||||||
|
|
||||||
|
let ftps = Protocol::Ftps;
|
||||||
|
let json = serde_json::to_string(&ftps).unwrap();
|
||||||
|
assert_eq!(json, "\"ftps\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connection_params() {
|
||||||
|
let params = ConnectionParams {
|
||||||
|
id: "test-id".into(),
|
||||||
|
label: "Test".into(),
|
||||||
|
protocol: Protocol::Sftp,
|
||||||
|
host: "example.com".into(),
|
||||||
|
port: 22,
|
||||||
|
username: "user".into(),
|
||||||
|
password: Some("pass".into()),
|
||||||
|
key_path: None,
|
||||||
|
};
|
||||||
|
assert_eq!(params.id, "test-id");
|
||||||
|
assert_eq!(params.protocol, Protocol::Sftp);
|
||||||
|
assert_eq!(params.port, 22);
|
||||||
|
assert!(params.password.is_some());
|
||||||
|
assert!(params.key_path.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connection_status_serde() {
|
||||||
|
let json = serde_json::to_string(&ConnectionStatus::Connected).unwrap();
|
||||||
|
assert_eq!(json, "\"connected\"");
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&ConnectionStatus::Error("timeout".into())).unwrap();
|
||||||
|
assert_eq!(json, "{\"error\":\"timeout\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// from file_entry.rs
|
||||||
|
#[test]
|
||||||
|
fn test_entry_kind_serde() {
|
||||||
|
assert_eq!(serde_json::to_string(&EntryKind::File).unwrap(), "\"file\"");
|
||||||
|
assert_eq!(serde_json::to_string(&EntryKind::Dir).unwrap(), "\"dir\"");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&EntryKind::Symlink).unwrap(),
|
||||||
|
"\"symlink\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_entry() {
|
||||||
|
let entry = FileEntry {
|
||||||
|
name: "test.txt".into(),
|
||||||
|
path: "/tmp/test.txt".into(),
|
||||||
|
kind: EntryKind::File,
|
||||||
|
size: Some(1024),
|
||||||
|
modified: Some(1234567890),
|
||||||
|
permissions: Some("rw-r--r--".into()),
|
||||||
|
};
|
||||||
|
assert_eq!(entry.name, "test.txt");
|
||||||
|
assert_eq!(entry.kind, EntryKind::File);
|
||||||
|
assert_eq!(entry.size, Some(1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_entry_serde() {
|
||||||
|
let entry = FileEntry {
|
||||||
|
name: "f".into(),
|
||||||
|
path: "/f".into(),
|
||||||
|
kind: EntryKind::Dir,
|
||||||
|
size: None,
|
||||||
|
modified: None,
|
||||||
|
permissions: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&entry).unwrap();
|
||||||
|
let deserialized: FileEntry = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.name, "f");
|
||||||
|
assert_eq!(deserialized.kind, EntryKind::Dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// from site.rs
|
||||||
|
#[test]
|
||||||
|
fn test_site_serde() {
|
||||||
|
let site = Site {
|
||||||
|
id: "site-1".into(),
|
||||||
|
name: "My Server".into(),
|
||||||
|
protocol: Protocol::Sftp,
|
||||||
|
host: "example.com".into(),
|
||||||
|
port: 22,
|
||||||
|
username: "admin".into(),
|
||||||
|
password: Some("secret".into()),
|
||||||
|
key_path: None,
|
||||||
|
folder: Some("/remote".into()),
|
||||||
|
note: Some("my note".into()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&site).unwrap();
|
||||||
|
let deserialized: Site = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.id, site.id);
|
||||||
|
assert_eq!(deserialized.name, site.name);
|
||||||
|
assert_eq!(deserialized.protocol, site.protocol);
|
||||||
|
assert_eq!(deserialized.host, site.host);
|
||||||
|
assert_eq!(deserialized.port, site.port);
|
||||||
|
assert_eq!(deserialized.username, site.username);
|
||||||
|
assert_eq!(deserialized.password, site.password);
|
||||||
|
assert_eq!(deserialized.folder, site.folder);
|
||||||
|
assert_eq!(deserialized.note, site.note);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_site_minimal() {
|
||||||
|
let site = Site {
|
||||||
|
id: "site-2".into(),
|
||||||
|
name: "Minimal".into(),
|
||||||
|
protocol: Protocol::Ftp,
|
||||||
|
host: "ftp.example.com".into(),
|
||||||
|
port: 21,
|
||||||
|
username: "user".into(),
|
||||||
|
password: None,
|
||||||
|
key_path: None,
|
||||||
|
folder: None,
|
||||||
|
note: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&site).unwrap();
|
||||||
|
let deserialized: Site = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.protocol, Protocol::Ftp);
|
||||||
|
assert!(deserialized.password.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// from transfer.rs
|
||||||
|
#[test]
|
||||||
|
fn test_transfer_task_new() {
|
||||||
|
let task = TransferTask::new(
|
||||||
|
TransferKind::Download,
|
||||||
|
"conn-1".into(),
|
||||||
|
"/local".into(),
|
||||||
|
"/remote".into(),
|
||||||
|
"file.txt".into(),
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
assert!(!task.id.is_empty());
|
||||||
|
assert_eq!(task.kind, TransferKind::Download);
|
||||||
|
assert_eq!(task.total_bytes, 1000);
|
||||||
|
assert_eq!(task.transferred_bytes, 0);
|
||||||
|
assert_eq!(task.state, TaskState::Queued);
|
||||||
|
assert!(task.speed.is_none());
|
||||||
|
assert!(task.eta_secs.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_progress_pct() {
|
||||||
|
let mut task = TransferTask::new(
|
||||||
|
TransferKind::Upload,
|
||||||
|
"conn-1".into(),
|
||||||
|
"/local".into(),
|
||||||
|
"/remote".into(),
|
||||||
|
"file.txt".into(),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert_eq!(task.progress_pct(), 0.0);
|
||||||
|
task.transferred_bytes = 50;
|
||||||
|
assert!((task.progress_pct() - 25.0).abs() < f64::EPSILON);
|
||||||
|
task.transferred_bytes = 200;
|
||||||
|
assert!((task.progress_pct() - 100.0).abs() < f64::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_progress_pct_zero_total() {
|
||||||
|
let task = TransferTask::new(
|
||||||
|
TransferKind::Upload,
|
||||||
|
"conn-1".into(),
|
||||||
|
"/local".into(),
|
||||||
|
"/remote".into(),
|
||||||
|
"file.txt".into(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(task.progress_pct(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_task_state_display() {
|
||||||
|
assert_eq!(TaskState::Queued.to_string(), "queued");
|
||||||
|
assert_eq!(TaskState::Running.to_string(), "running");
|
||||||
|
assert_eq!(TaskState::Paused.to_string(), "paused");
|
||||||
|
assert_eq!(TaskState::Cancelled.to_string(), "cancelled");
|
||||||
|
assert_eq!(TaskState::Completed.to_string(), "completed");
|
||||||
|
assert_eq!(TaskState::Failed("err".into()).to_string(), "failed: err");
|
||||||
|
assert_eq!(TaskState::Retrying(3).to_string(), "retrying(3)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_transfer_kind_serde() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&TransferKind::Upload).unwrap(),
|
||||||
|
"\"upload\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&TransferKind::Download).unwrap(),
|
||||||
|
"\"download\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_task_state_serde() {
|
||||||
|
let json = serde_json::to_string(&TaskState::Retrying(2)).unwrap();
|
||||||
|
assert_eq!(json, "{\"retrying\":2}");
|
||||||
|
let deserialized: TaskState = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized, TaskState::Retrying(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_protocol_serde() {
|
|
||||||
let sftp = Protocol::Sftp;
|
|
||||||
let json = serde_json::to_string(&sftp).unwrap();
|
|
||||||
assert_eq!(json, "\"sftp\"");
|
|
||||||
let deserialized: Protocol = serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(deserialized, Protocol::Sftp);
|
|
||||||
|
|
||||||
let ftp = Protocol::Ftp;
|
|
||||||
let json = serde_json::to_string(&ftp).unwrap();
|
|
||||||
assert_eq!(json, "\"ftp\"");
|
|
||||||
|
|
||||||
let ftps = Protocol::Ftps;
|
|
||||||
let json = serde_json::to_string(&ftps).unwrap();
|
|
||||||
assert_eq!(json, "\"ftps\"");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_connection_params() {
|
|
||||||
let params = ConnectionParams {
|
|
||||||
id: "test-id".into(),
|
|
||||||
label: "Test".into(),
|
|
||||||
protocol: Protocol::Sftp,
|
|
||||||
host: "example.com".into(),
|
|
||||||
port: 22,
|
|
||||||
username: "user".into(),
|
|
||||||
password: Some("pass".into()),
|
|
||||||
key_path: None,
|
|
||||||
};
|
|
||||||
assert_eq!(params.id, "test-id");
|
|
||||||
assert_eq!(params.protocol, Protocol::Sftp);
|
|
||||||
assert_eq!(params.port, 22);
|
|
||||||
assert!(params.password.is_some());
|
|
||||||
assert!(params.key_path.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_connection_status_serde() {
|
|
||||||
let json = serde_json::to_string(&ConnectionStatus::Connected).unwrap();
|
|
||||||
assert_eq!(json, "\"connected\"");
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&ConnectionStatus::Error("timeout".into())).unwrap();
|
|
||||||
assert_eq!(json, "{\"error\":\"timeout\"}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_entry_kind_serde() {
|
|
||||||
assert_eq!(serde_json::to_string(&EntryKind::File).unwrap(), "\"file\"");
|
|
||||||
assert_eq!(serde_json::to_string(&EntryKind::Dir).unwrap(), "\"dir\"");
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&EntryKind::Symlink).unwrap(),
|
|
||||||
"\"symlink\""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_file_entry() {
|
|
||||||
let entry = FileEntry {
|
|
||||||
name: "test.txt".into(),
|
|
||||||
path: "/tmp/test.txt".into(),
|
|
||||||
kind: EntryKind::File,
|
|
||||||
size: Some(1024),
|
|
||||||
modified: Some(1234567890),
|
|
||||||
permissions: Some("rw-r--r--".into()),
|
|
||||||
};
|
|
||||||
assert_eq!(entry.name, "test.txt");
|
|
||||||
assert_eq!(entry.kind, EntryKind::File);
|
|
||||||
assert_eq!(entry.size, Some(1024));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_file_entry_serde() {
|
|
||||||
let entry = FileEntry {
|
|
||||||
name: "f".into(),
|
|
||||||
path: "/f".into(),
|
|
||||||
kind: EntryKind::Dir,
|
|
||||||
size: None,
|
|
||||||
modified: None,
|
|
||||||
permissions: None,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&entry).unwrap();
|
|
||||||
let deserialized: FileEntry = serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(deserialized.name, "f");
|
|
||||||
assert_eq!(deserialized.kind, EntryKind::Dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
pub mod connection;
|
|
||||||
pub mod error;
|
|
||||||
pub mod file_entry;
|
|
||||||
pub mod site;
|
|
||||||
pub mod transfer;
|
|
||||||
pub mod window_state;
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
use crate::domain::connection::Protocol;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[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 password: Option<String>,
|
|
||||||
pub key_path: Option<String>,
|
|
||||||
pub folder: Option<String>,
|
|
||||||
pub note: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::domain::connection::Protocol;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_site_serde() {
|
|
||||||
let site = Site {
|
|
||||||
id: "site-1".into(),
|
|
||||||
name: "My Server".into(),
|
|
||||||
protocol: Protocol::Sftp,
|
|
||||||
host: "example.com".into(),
|
|
||||||
port: 22,
|
|
||||||
username: "admin".into(),
|
|
||||||
password: Some("secret".into()),
|
|
||||||
key_path: None,
|
|
||||||
folder: Some("/remote".into()),
|
|
||||||
note: Some("my note".into()),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&site).unwrap();
|
|
||||||
let deserialized: Site = serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(deserialized.id, site.id);
|
|
||||||
assert_eq!(deserialized.name, site.name);
|
|
||||||
assert_eq!(deserialized.protocol, site.protocol);
|
|
||||||
assert_eq!(deserialized.host, site.host);
|
|
||||||
assert_eq!(deserialized.port, site.port);
|
|
||||||
assert_eq!(deserialized.username, site.username);
|
|
||||||
assert_eq!(deserialized.password, site.password);
|
|
||||||
assert_eq!(deserialized.folder, site.folder);
|
|
||||||
assert_eq!(deserialized.note, site.note);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_site_minimal() {
|
|
||||||
let site = Site {
|
|
||||||
id: "site-2".into(),
|
|
||||||
name: "Minimal".into(),
|
|
||||||
protocol: Protocol::Ftp,
|
|
||||||
host: "ftp.example.com".into(),
|
|
||||||
port: 21,
|
|
||||||
username: "user".into(),
|
|
||||||
password: None,
|
|
||||||
key_path: None,
|
|
||||||
folder: None,
|
|
||||||
note: None,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&site).unwrap();
|
|
||||||
let deserialized: Site = serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(deserialized.protocol, Protocol::Ftp);
|
|
||||||
assert!(deserialized.password.is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fmt;
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_transfer_task_new() {
|
|
||||||
let task = TransferTask::new(
|
|
||||||
TransferKind::Download,
|
|
||||||
"conn-1".into(),
|
|
||||||
"/local".into(),
|
|
||||||
"/remote".into(),
|
|
||||||
"file.txt".into(),
|
|
||||||
1000,
|
|
||||||
);
|
|
||||||
assert!(!task.id.is_empty());
|
|
||||||
assert_eq!(task.kind, TransferKind::Download);
|
|
||||||
assert_eq!(task.total_bytes, 1000);
|
|
||||||
assert_eq!(task.transferred_bytes, 0);
|
|
||||||
assert_eq!(task.state, TaskState::Queued);
|
|
||||||
assert!(task.speed.is_none());
|
|
||||||
assert!(task.eta_secs.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_progress_pct() {
|
|
||||||
let mut task = TransferTask::new(
|
|
||||||
TransferKind::Upload,
|
|
||||||
"conn-1".into(),
|
|
||||||
"/local".into(),
|
|
||||||
"/remote".into(),
|
|
||||||
"file.txt".into(),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
assert_eq!(task.progress_pct(), 0.0);
|
|
||||||
task.transferred_bytes = 50;
|
|
||||||
assert!((task.progress_pct() - 25.0).abs() < f64::EPSILON);
|
|
||||||
task.transferred_bytes = 200;
|
|
||||||
assert!((task.progress_pct() - 100.0).abs() < f64::EPSILON);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_progress_pct_zero_total() {
|
|
||||||
let task = TransferTask::new(
|
|
||||||
TransferKind::Upload,
|
|
||||||
"conn-1".into(),
|
|
||||||
"/local".into(),
|
|
||||||
"/remote".into(),
|
|
||||||
"file.txt".into(),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert_eq!(task.progress_pct(), 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_task_state_display() {
|
|
||||||
assert_eq!(TaskState::Queued.to_string(), "queued");
|
|
||||||
assert_eq!(TaskState::Running.to_string(), "running");
|
|
||||||
assert_eq!(TaskState::Paused.to_string(), "paused");
|
|
||||||
assert_eq!(TaskState::Cancelled.to_string(), "cancelled");
|
|
||||||
assert_eq!(TaskState::Completed.to_string(), "completed");
|
|
||||||
assert_eq!(TaskState::Failed("err".into()).to_string(), "failed: err");
|
|
||||||
assert_eq!(TaskState::Retrying(3).to_string(), "retrying(3)");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_transfer_kind_serde() {
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&TransferKind::Upload).unwrap(),
|
|
||||||
"\"upload\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&TransferKind::Download).unwrap(),
|
|
||||||
"\"download\""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_task_state_serde() {
|
|
||||||
let json = serde_json::to_string(&TaskState::Retrying(2)).unwrap();
|
|
||||||
assert_eq!(json, "{\"retrying\":2}");
|
|
||||||
let deserialized: TaskState = serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(deserialized, TaskState::Retrying(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
use crate::domain::{EntryKind, FileEntry};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ impl RemoteRegistry {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
use crate::domain::{EntryKind, FileEntry};
|
||||||
use crate::protocols::ProgressAction;
|
use crate::protocols::ProgressAction;
|
||||||
|
|
||||||
struct MockFs;
|
struct MockFs;
|
||||||
|
|
|
||||||
25
src/lib.rs
25
src/lib.rs
|
|
@ -1,15 +1,18 @@
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
|
pub mod error;
|
||||||
pub mod fs;
|
pub mod fs;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
pub mod protocols;
|
pub mod protocols;
|
||||||
|
pub mod settings;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod transfer;
|
pub mod transfers;
|
||||||
|
pub mod window;
|
||||||
|
|
||||||
use crate::commands::AppState;
|
use crate::commands::AppState;
|
||||||
use crate::domain::window_state::WindowState;
|
|
||||||
use crate::fs::remote::RemoteRegistry;
|
use crate::fs::remote::RemoteRegistry;
|
||||||
use crate::transfer::queue::TransferQueue;
|
use crate::transfers::queue::TransferQueue;
|
||||||
|
use crate::window::WindowState;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::AtomicU32;
|
use std::sync::atomic::AtomicU32;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
@ -27,12 +30,12 @@ pub fn run() {
|
||||||
std::fs::create_dir_all(parent).ok();
|
std::fs::create_dir_all(parent).ok();
|
||||||
}
|
}
|
||||||
let conn = rusqlite::Connection::open(&db_path).expect("failed to open wherry database");
|
let conn = rusqlite::Connection::open(&db_path).expect("failed to open wherry database");
|
||||||
storage::db::init_tables(&conn).expect("failed to init db tables");
|
storage::init_tables(&conn).expect("failed to init db tables");
|
||||||
let db = Arc::new(std::sync::Mutex::new(conn));
|
let db = Arc::new(std::sync::Mutex::new(conn));
|
||||||
|
|
||||||
let max_concurrent = {
|
let max_concurrent = {
|
||||||
let c = db.lock().unwrap();
|
let c = db.lock().unwrap();
|
||||||
Arc::new(AtomicU32::new(storage::db::get_u32(
|
Arc::new(AtomicU32::new(settings::get_u32(
|
||||||
&c,
|
&c,
|
||||||
"max_concurrent_transfers",
|
"max_concurrent_transfers",
|
||||||
DEFAULT_MAX_CONCURRENT,
|
DEFAULT_MAX_CONCURRENT,
|
||||||
|
|
@ -41,7 +44,7 @@ pub fn run() {
|
||||||
|
|
||||||
let auto_clear_secs = {
|
let auto_clear_secs = {
|
||||||
let c = db.lock().unwrap();
|
let c = db.lock().unwrap();
|
||||||
Arc::new(AtomicU32::new(storage::db::get_u32(
|
Arc::new(AtomicU32::new(settings::get_u32(
|
||||||
&c,
|
&c,
|
||||||
"auto_clear_completed_secs",
|
"auto_clear_completed_secs",
|
||||||
0,
|
0,
|
||||||
|
|
@ -62,7 +65,7 @@ pub fn run() {
|
||||||
})
|
})
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
transfer::worker::spawn_worker(
|
transfers::worker::spawn_worker(
|
||||||
queue.clone(),
|
queue.clone(),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
tauri::async_runtime::handle().inner().clone(),
|
tauri::async_runtime::handle().inner().clone(),
|
||||||
|
|
@ -78,16 +81,14 @@ pub fn run() {
|
||||||
let label = window.label().to_string();
|
let label = window.label().to_string();
|
||||||
let key = format!("window_state_{}", label);
|
let key = format!("window_state_{}", label);
|
||||||
if let Ok(conn) = db.lock()
|
if let Ok(conn) = db.lock()
|
||||||
&& let Some(json) = storage::db::get_setting(&conn, &key)
|
&& let Some(json) = settings::get_setting(&conn, &key)
|
||||||
&& let Ok(ws) = serde_json::from_str::<WindowState>(&json)
|
&& let Ok(ws) = serde_json::from_str::<WindowState>(&json)
|
||||||
{
|
{
|
||||||
drop(conn);
|
drop(conn);
|
||||||
if let (Some(x), Some(y)) = (ws.x, ws.y) {
|
if let (Some(x), Some(y)) = (ws.x, ws.y) {
|
||||||
let _ = window
|
let _ = window.set_position(tauri::PhysicalPosition::new(x, y));
|
||||||
.set_position(tauri::PhysicalPosition::new(x, y));
|
|
||||||
}
|
}
|
||||||
let _ = window
|
let _ = window.set_size(tauri::PhysicalSize::new(ws.width, ws.height));
|
||||||
.set_size(tauri::PhysicalSize::new(ws.width, ws.height));
|
|
||||||
if ws.maximized {
|
if ws.maximized {
|
||||||
let _ = window.maximize();
|
let _ = window.maximize();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
311
src/protocols/ftp.rs
Normal file
311
src/protocols/ftp.rs
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use futures_lite::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
use suppaftp::async_native_tls::TlsConnector;
|
||||||
|
use suppaftp::{
|
||||||
|
AsyncFtpStream, AsyncNativeTlsConnector, AsyncNativeTlsFtpStream, list::File as FtpListFile,
|
||||||
|
};
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio::io::{AsyncReadExt as TokioReadExt, AsyncWriteExt as TokioWriteExt};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::domain::{EntryKind, FileEntry};
|
||||||
|
use crate::protocols::{ProgressAction, RemoteFs};
|
||||||
|
|
||||||
|
fn parse_list_entry(line: &str) -> Option<FileEntry> {
|
||||||
|
let file = FtpListFile::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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FtpClient {
|
||||||
|
pub conn: Mutex<Option<AsyncFtpStream>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FtpsClient {
|
||||||
|
pub conn: Mutex<Option<AsyncNativeTlsFtpStream>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FtpsClient {
|
||||||
|
pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result<Self> {
|
||||||
|
let addr = format!("{}:{}", host, port);
|
||||||
|
let stream = AsyncNativeTlsFtpStream::connect(&addr)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("FTPS connect failed: {}", e))?;
|
||||||
|
let mut stream = stream
|
||||||
|
.into_secure(AsyncNativeTlsConnector::from(TlsConnector::new()), host)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("FTPS TLS upgrade failed: {}", e))?;
|
||||||
|
stream
|
||||||
|
.login(user, pass)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("FTPS login failed: {}", e))?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(Some(stream)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHUNK_SIZE: usize = 64 * 1024;
|
||||||
|
|
||||||
|
type ProgressCb = Option<Box<dyn Fn(u64) -> ProgressAction + Send>>;
|
||||||
|
|
||||||
|
fn check(cb: &ProgressCb, total: u64) -> Result<()> {
|
||||||
|
if let Some(cb) = cb {
|
||||||
|
match cb(total) {
|
||||||
|
ProgressAction::Continue => {}
|
||||||
|
ProgressAction::Cancel => return Err(anyhow::anyhow!("cancelled")),
|
||||||
|
ProgressAction::Pause => return Err(anyhow::anyhow!("paused")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! gen_transfer {
|
||||||
|
($upload:ident, $download:ident, $stream:ty) => {
|
||||||
|
async fn $upload(
|
||||||
|
conn: &mut $stream,
|
||||||
|
local: &str,
|
||||||
|
remote: &str,
|
||||||
|
on_progress: ProgressCb,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut file = File::open(local)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("cannot open local file: {}", e))?;
|
||||||
|
|
||||||
|
let mut dstream = conn
|
||||||
|
.put_with_stream(remote)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("FTP upload stream failed: {}", e))?;
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; CHUNK_SIZE];
|
||||||
|
let mut total = 0u64;
|
||||||
|
loop {
|
||||||
|
let n = file
|
||||||
|
.read(&mut buf)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("FTP upload read failed: {}", e))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
dstream
|
||||||
|
.write_all(&buf[..n])
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("FTP upload write failed: {}", e))?;
|
||||||
|
total += n as u64;
|
||||||
|
check(&on_progress, total)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.finalize_put_stream(dstream)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("FTP upload finalize failed: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn $download(
|
||||||
|
conn: &mut $stream,
|
||||||
|
remote: &str,
|
||||||
|
local: &str,
|
||||||
|
on_progress: ProgressCb,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut dstream = conn
|
||||||
|
.retr_as_stream(remote)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("FTP download stream failed: {}", e))?;
|
||||||
|
|
||||||
|
let mut file = File::create(local)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("cannot create local file: {}", e))?;
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; CHUNK_SIZE];
|
||||||
|
let mut total = 0u64;
|
||||||
|
loop {
|
||||||
|
let n = dstream
|
||||||
|
.read(&mut buf)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("FTP download read failed: {}", e))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
file.write_all(&buf[..n])
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("FTP download write failed: {}", e))?;
|
||||||
|
total += n as u64;
|
||||||
|
check(&on_progress, total)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.finalize_retr_stream(dstream)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("FTP download finalize failed: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
gen_transfer!(upload_ftp, download_ftp, AsyncFtpStream);
|
||||||
|
gen_transfer!(upload_ftps, download_ftps, AsyncNativeTlsFtpStream);
|
||||||
|
|
||||||
|
macro_rules! impl_remote_fs {
|
||||||
|
($client:ty, $label:literal, $upload:ident, $download:ident) => {
|
||||||
|
#[async_trait]
|
||||||
|
impl RemoteFs for $client {
|
||||||
|
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!(concat!($label, " list failed: {}"), e))?;
|
||||||
|
|
||||||
|
let mut entries: Vec<FileEntry> = lines
|
||||||
|
.iter()
|
||||||
|
.filter_map(|line| 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_with_progress(
|
||||||
|
&self,
|
||||||
|
local: &str,
|
||||||
|
remote: &str,
|
||||||
|
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut guard = self.conn.lock().await;
|
||||||
|
let conn = guard
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||||
|
$upload(conn, local, remote, on_progress).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_with_progress(
|
||||||
|
&self,
|
||||||
|
remote: &str,
|
||||||
|
local: &str,
|
||||||
|
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut guard = self.conn.lock().await;
|
||||||
|
let conn = guard
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||||
|
$download(conn, remote, local, on_progress).await
|
||||||
|
}
|
||||||
|
|
||||||
|
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!(concat!($label, " 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!(concat!($label, " 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!(concat!($label, " 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!(concat!($label, " stat failed: {}"), e))?;
|
||||||
|
let modified = conn
|
||||||
|
.mdtm(path)
|
||||||
|
.await
|
||||||
|
.map(|dt| dt.and_utc().timestamp())
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let name = 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_remote_fs!(FtpClient, "FTP", upload_ftp, download_ftp);
|
||||||
|
impl_remote_fs!(FtpsClient, "FTPS", upload_ftps, download_ftps);
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
use anyhow::Result;
|
|
||||||
use std::str::FromStr;
|
|
||||||
use std::time::SystemTime;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
use suppaftp::async_native_tls::TlsConnector;
|
|
||||||
use suppaftp::{
|
|
||||||
AsyncFtpStream, AsyncNativeTlsConnector, AsyncNativeTlsFtpStream, list::File as FtpListFile,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
|
||||||
|
|
||||||
mod ops;
|
|
||||||
mod transfer;
|
|
||||||
|
|
||||||
pub struct FtpClient {
|
|
||||||
pub conn: Mutex<Option<AsyncFtpStream>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct FtpsClient {
|
|
||||||
pub conn: Mutex<Option<AsyncNativeTlsFtpStream>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn parse_list_entry(line: &str) -> Option<FileEntry> {
|
|
||||||
let file = FtpListFile::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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FtpsClient {
|
|
||||||
pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result<Self> {
|
|
||||||
let addr = format!("{}:{}", host, port);
|
|
||||||
let stream = AsyncNativeTlsFtpStream::connect(&addr)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("FTPS connect failed: {}", e))?;
|
|
||||||
let mut stream = stream
|
|
||||||
.into_secure(AsyncNativeTlsConnector::from(TlsConnector::new()), host)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("FTPS TLS upgrade failed: {}", e))?;
|
|
||||||
stream
|
|
||||||
.login(user, pass)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("FTPS login failed: {}", e))?;
|
|
||||||
Ok(Self {
|
|
||||||
conn: Mutex::new(Some(stream)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
//! Реализация `RemoteFs` для FTP. Чанкованные upload/download — в [`super::transfer`].
|
|
||||||
use anyhow::Result;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
use super::{FtpClient, FtpsClient, parse_list_entry, transfer};
|
|
||||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
|
||||||
use crate::protocols::{ProgressAction, RemoteFs};
|
|
||||||
|
|
||||||
macro_rules! impl_remote_fs {
|
|
||||||
($client:ty, $label:literal, $upload:ident, $download:ident) => {
|
|
||||||
#[async_trait]
|
|
||||||
impl RemoteFs for $client {
|
|
||||||
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!(concat!($label, " list failed: {}"), e))?;
|
|
||||||
|
|
||||||
let mut entries: Vec<FileEntry> = lines
|
|
||||||
.iter()
|
|
||||||
.filter_map(|line| 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_with_progress(
|
|
||||||
&self,
|
|
||||||
local: &str,
|
|
||||||
remote: &str,
|
|
||||||
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut guard = self.conn.lock().await;
|
|
||||||
let conn = guard
|
|
||||||
.as_mut()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
|
||||||
transfer::$upload(conn, local, remote, on_progress).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn download_with_progress(
|
|
||||||
&self,
|
|
||||||
remote: &str,
|
|
||||||
local: &str,
|
|
||||||
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut guard = self.conn.lock().await;
|
|
||||||
let conn = guard
|
|
||||||
.as_mut()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
|
||||||
transfer::$download(conn, remote, local, on_progress).await
|
|
||||||
}
|
|
||||||
|
|
||||||
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!(concat!($label, " 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!(concat!($label, " 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!(concat!($label, " 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!(concat!($label, " 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
impl_remote_fs!(FtpClient, "FTP", upload_ftp, download_ftp);
|
|
||||||
impl_remote_fs!(FtpsClient, "FTPS", upload_ftps, download_ftps);
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
//! Чанкованная загрузка/выгрузка по FTP с колбэком прогресса (pause/cancel).
|
|
||||||
use anyhow::Result;
|
|
||||||
use futures_lite::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use suppaftp::{AsyncFtpStream, AsyncNativeTlsFtpStream};
|
|
||||||
use tokio::fs::File;
|
|
||||||
use tokio::io::{AsyncReadExt as TokioReadExt, AsyncWriteExt as TokioWriteExt};
|
|
||||||
|
|
||||||
use crate::protocols::ProgressAction;
|
|
||||||
|
|
||||||
const CHUNK_SIZE: usize = 64 * 1024;
|
|
||||||
|
|
||||||
type ProgressCb = Option<Box<dyn Fn(u64) -> ProgressAction + Send>>;
|
|
||||||
|
|
||||||
fn check(cb: &ProgressCb, total: u64) -> Result<()> {
|
|
||||||
if let Some(cb) = cb {
|
|
||||||
match cb(total) {
|
|
||||||
ProgressAction::Continue => {}
|
|
||||||
ProgressAction::Cancel => return Err(anyhow::anyhow!("cancelled")),
|
|
||||||
ProgressAction::Pause => return Err(anyhow::anyhow!("paused")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
macro_rules! gen_transfer {
|
|
||||||
($upload:ident, $download:ident, $stream:ty) => {
|
|
||||||
pub(super) async fn $upload(
|
|
||||||
conn: &mut $stream,
|
|
||||||
local: &str,
|
|
||||||
remote: &str,
|
|
||||||
on_progress: ProgressCb,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut file = File::open(local)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("cannot open local file: {}", e))?;
|
|
||||||
|
|
||||||
let mut dstream = conn
|
|
||||||
.put_with_stream(remote)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("FTP upload stream failed: {}", e))?;
|
|
||||||
|
|
||||||
let mut buf = vec![0u8; CHUNK_SIZE];
|
|
||||||
let mut total = 0u64;
|
|
||||||
loop {
|
|
||||||
let n = file
|
|
||||||
.read(&mut buf)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("FTP upload read failed: {}", e))?;
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
dstream
|
|
||||||
.write_all(&buf[..n])
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("FTP upload write failed: {}", e))?;
|
|
||||||
total += n as u64;
|
|
||||||
check(&on_progress, total)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.finalize_put_stream(dstream)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("FTP upload finalize failed: {}", e))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn $download(
|
|
||||||
conn: &mut $stream,
|
|
||||||
remote: &str,
|
|
||||||
local: &str,
|
|
||||||
on_progress: ProgressCb,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut dstream = conn
|
|
||||||
.retr_as_stream(remote)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("FTP download stream failed: {}", e))?;
|
|
||||||
|
|
||||||
let mut file = File::create(local)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("cannot create local file: {}", e))?;
|
|
||||||
|
|
||||||
let mut buf = vec![0u8; CHUNK_SIZE];
|
|
||||||
let mut total = 0u64;
|
|
||||||
loop {
|
|
||||||
let n = dstream
|
|
||||||
.read(&mut buf)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("FTP download read failed: {}", e))?;
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
file.write_all(&buf[..n])
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("FTP download write failed: {}", e))?;
|
|
||||||
total += n as u64;
|
|
||||||
check(&on_progress, total)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.finalize_retr_stream(dstream)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("FTP download finalize failed: {}", e))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
gen_transfer!(upload_ftp, download_ftp, AsyncFtpStream);
|
|
||||||
gen_transfer!(upload_ftps, download_ftps, AsyncNativeTlsFtpStream);
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
pub mod ftp;
|
pub mod ftp;
|
||||||
pub use ftp::FtpsClient;
|
|
||||||
pub mod sftp;
|
pub mod sftp;
|
||||||
|
|
||||||
use crate::domain::file_entry::FileEntry;
|
pub use ftp::{FtpClient, FtpsClient};
|
||||||
|
pub use sftp::SftpClient;
|
||||||
|
|
||||||
|
use crate::domain::FileEntry;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
|
|
||||||
398
src/protocols/sftp.rs
Normal file
398
src/protocols/sftp.rs
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use ssh2::{CheckResult, HashType, KnownHostFileKind, Session};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::domain::{EntryKind, FileEntry};
|
||||||
|
use crate::protocols::{ProgressAction, RemoteFs};
|
||||||
|
|
||||||
|
fn known_hosts_path() -> Option<PathBuf> {
|
||||||
|
dirs::home_dir().map(|p| p.join(".ssh/known_hosts"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fingerprint_hex(session: &Session) -> String {
|
||||||
|
session
|
||||||
|
.host_key_hash(HashType::Sha256)
|
||||||
|
.map(|h| {
|
||||||
|
h.iter()
|
||||||
|
.map(|b| format!("{:02x}", b))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(":")
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "unknown".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_host_key(session: &Session, host: &str, port: u16) -> Result<()> {
|
||||||
|
let mut known = session.known_hosts().context("known_hosts init failed")?;
|
||||||
|
|
||||||
|
if let Some(ref path) = known_hosts_path() {
|
||||||
|
let _ = known.read_file(path, KnownHostFileKind::OpenSSH);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (key, key_type) = session
|
||||||
|
.host_key()
|
||||||
|
.context("no host key received from server")?;
|
||||||
|
|
||||||
|
match known.check_port(host, port, key) {
|
||||||
|
CheckResult::Match => {}
|
||||||
|
CheckResult::Mismatch => {
|
||||||
|
bail!(
|
||||||
|
"SSH host key mismatch for {}!\n\
|
||||||
|
The server's host key has changed since the last connection.\n\
|
||||||
|
This could mean someone is intercepting the connection (MITM attack).\n\
|
||||||
|
Fingerprint (SHA256): {}",
|
||||||
|
host,
|
||||||
|
fingerprint_hex(session)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
CheckResult::NotFound => {
|
||||||
|
tracing::info!(
|
||||||
|
"Unknown host key for {}, adding to known_hosts (SHA256: {})",
|
||||||
|
host,
|
||||||
|
fingerprint_hex(session)
|
||||||
|
);
|
||||||
|
known
|
||||||
|
.add(host, key, "wherry", key_type.into())
|
||||||
|
.context("failed to add host key to known_hosts")?;
|
||||||
|
if let Some(ref path) = known_hosts_path()
|
||||||
|
&& let Err(e) = known.write_file(path, KnownHostFileKind::OpenSSH)
|
||||||
|
{
|
||||||
|
tracing::warn!("failed to write known_hosts: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CheckResult::Failure => {
|
||||||
|
bail!("known_hosts check failed for {}", host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SftpClient {
|
||||||
|
session: Arc<Mutex<Session>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SftpClient {
|
||||||
|
pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result<Self> {
|
||||||
|
let session = handshake(host, port)?;
|
||||||
|
session
|
||||||
|
.userauth_password(user, password)
|
||||||
|
.context("SSH password auth failed")?;
|
||||||
|
Ok(Self {
|
||||||
|
session: Arc::new(Mutex::new(session)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_key(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
user: &str,
|
||||||
|
key_path: &str,
|
||||||
|
passphrase: Option<&str>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let session = handshake(host, port)?;
|
||||||
|
session
|
||||||
|
.userauth_pubkey_file(user, None, Path::new(key_path), passphrase)
|
||||||
|
.with_context(|| format!("SSH key auth failed ({})", key_path))?;
|
||||||
|
Ok(Self {
|
||||||
|
session: Arc::new(Mutex::new(session)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_auto(host: &str, port: u16, user: &str) -> Result<Self> {
|
||||||
|
let session = handshake(host, port)?;
|
||||||
|
|
||||||
|
if session.userauth_agent(user).is_ok() && session.authenticated() {
|
||||||
|
return Ok(Self {
|
||||||
|
session: Arc::new(Mutex::new(session)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let ssh_dir = dirs::home_dir()
|
||||||
|
.context("Cannot resolve home directory")?
|
||||||
|
.join(".ssh");
|
||||||
|
let mut tried = vec!["ssh-agent".to_string()];
|
||||||
|
for name in ["id_ed25519", "id_ecdsa", "id_rsa"] {
|
||||||
|
let key = ssh_dir.join(name);
|
||||||
|
if !key.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if session.userauth_pubkey_file(user, None, &key, None).is_ok()
|
||||||
|
&& session.authenticated()
|
||||||
|
{
|
||||||
|
return Ok(Self {
|
||||||
|
session: Arc::new(Mutex::new(session)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tried.push(format!("~/.ssh/{}", name));
|
||||||
|
}
|
||||||
|
|
||||||
|
anyhow::bail!(
|
||||||
|
"SSH auth failed: no password given, and none of [{}] were accepted. \
|
||||||
|
Pick a key file manually or enter a password.",
|
||||||
|
tried.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handshake(host: &str, port: u16) -> Result<Session> {
|
||||||
|
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")?;
|
||||||
|
verify_host_key(&session, host, port)?;
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHUNK_SIZE: usize = 64 * 1024;
|
||||||
|
|
||||||
|
type ProgressCb = Option<Box<dyn Fn(u64) -> ProgressAction + Send>>;
|
||||||
|
|
||||||
|
fn check(cb: &ProgressCb, total: u64) -> Result<()> {
|
||||||
|
if let Some(cb) = cb {
|
||||||
|
match cb(total) {
|
||||||
|
ProgressAction::Continue => {}
|
||||||
|
ProgressAction::Cancel => return Err(anyhow::anyhow!("cancelled")),
|
||||||
|
ProgressAction::Pause => return Err(anyhow::anyhow!("paused")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload(
|
||||||
|
session: Arc<Mutex<Session>>,
|
||||||
|
local: String,
|
||||||
|
remote: String,
|
||||||
|
on_progress: ProgressCb,
|
||||||
|
) -> Result<()> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let sftp = session
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.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")?;
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; CHUNK_SIZE];
|
||||||
|
let mut total = 0u64;
|
||||||
|
loop {
|
||||||
|
let n = local_file
|
||||||
|
.read(&mut buf)
|
||||||
|
.context("read local file failed")?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
remote_file
|
||||||
|
.write_all(&buf[..n])
|
||||||
|
.context("write remote file failed")?;
|
||||||
|
total += n as u64;
|
||||||
|
check(&on_progress, total)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("SFTP upload task failed: {}", e))?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download(
|
||||||
|
session: Arc<Mutex<Session>>,
|
||||||
|
remote: String,
|
||||||
|
local: String,
|
||||||
|
on_progress: ProgressCb,
|
||||||
|
) -> Result<()> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let sftp = session
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.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")?;
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; CHUNK_SIZE];
|
||||||
|
let mut total = 0u64;
|
||||||
|
loop {
|
||||||
|
let n = remote_file
|
||||||
|
.read(&mut buf)
|
||||||
|
.context("read remote file failed")?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
local_file
|
||||||
|
.write_all(&buf[..n])
|
||||||
|
.context("write local file failed")?;
|
||||||
|
total += n as u64;
|
||||||
|
check(&on_progress, total)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("SFTP download task failed: {}", e))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RemoteFs for SftpClient {
|
||||||
|
async fn list(&self, path: &str) -> Result<Vec<FileEntry>> {
|
||||||
|
let session = self.session.clone();
|
||||||
|
let path = path.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let sftp = session
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.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)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("SFTP list task failed: {}", e))?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_with_progress(
|
||||||
|
&self,
|
||||||
|
local: &str,
|
||||||
|
remote: &str,
|
||||||
|
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
upload(
|
||||||
|
self.session.clone(),
|
||||||
|
local.to_string(),
|
||||||
|
remote.to_string(),
|
||||||
|
on_progress,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_with_progress(
|
||||||
|
&self,
|
||||||
|
remote: &str,
|
||||||
|
local: &str,
|
||||||
|
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
download(
|
||||||
|
self.session.clone(),
|
||||||
|
remote.to_string(),
|
||||||
|
local.to_string(),
|
||||||
|
on_progress,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||||
|
let session = self.session.clone();
|
||||||
|
let path = path.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let sftp = session
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.sftp()
|
||||||
|
.context("SFTP subsystem failed")?;
|
||||||
|
sftp.mkdir(Path::new(&path), 0o755)
|
||||||
|
.context("mkdir failed")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("SFTP mkdir task failed: {}", e))?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||||
|
let session = self.session.clone();
|
||||||
|
let from = from.to_string();
|
||||||
|
let to = to.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let sftp = session
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.sftp()
|
||||||
|
.context("SFTP subsystem failed")?;
|
||||||
|
sftp.rename(Path::new(&from), Path::new(&to), None)
|
||||||
|
.context("rename failed")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("SFTP rename task failed: {}", e))?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, path: &str) -> Result<()> {
|
||||||
|
let session = self.session.clone();
|
||||||
|
let path = path.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let sftp = session
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.sftp()
|
||||||
|
.context("SFTP subsystem failed")?;
|
||||||
|
if sftp.unlink(Path::new(&path)).is_err() {
|
||||||
|
sftp.rmdir(Path::new(&path)).context("delete failed")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("SFTP delete task failed: {}", e))?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stat(&self, path: &str) -> Result<FileEntry> {
|
||||||
|
let session = self.session.clone();
|
||||||
|
let path = path.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let sftp = session
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("SFTP stat task failed: {}", e))?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
//! Проверка SSH host key через ~/.ssh/known_hosts (TOFU: неизвестный ключ добавляется).
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use ssh2::{CheckResult, HashType, KnownHostFileKind, Session};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn known_hosts_path() -> Option<PathBuf> {
|
|
||||||
dirs::home_dir().map(|p| p.join(".ssh/known_hosts"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fingerprint_hex(session: &Session) -> String {
|
|
||||||
session
|
|
||||||
.host_key_hash(HashType::Sha256)
|
|
||||||
.map(|h| {
|
|
||||||
h.iter()
|
|
||||||
.map(|b| format!("{:02x}", b))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(":")
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "unknown".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn verify_host_key(session: &Session, host: &str, port: u16) -> Result<()> {
|
|
||||||
let mut known = session.known_hosts().context("known_hosts init failed")?;
|
|
||||||
|
|
||||||
if let Some(ref path) = known_hosts_path() {
|
|
||||||
let _ = known.read_file(path, KnownHostFileKind::OpenSSH);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (key, key_type) = session
|
|
||||||
.host_key()
|
|
||||||
.context("no host key received from server")?;
|
|
||||||
|
|
||||||
match known.check_port(host, port, key) {
|
|
||||||
CheckResult::Match => {} // known and verified
|
|
||||||
CheckResult::Mismatch => {
|
|
||||||
bail!(
|
|
||||||
"SSH host key mismatch for {}!\n\
|
|
||||||
The server's host key has changed since the last connection.\n\
|
|
||||||
This could mean someone is intercepting the connection (MITM attack).\n\
|
|
||||||
Fingerprint (SHA256): {}",
|
|
||||||
host,
|
|
||||||
fingerprint_hex(session)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
CheckResult::NotFound => {
|
|
||||||
tracing::info!(
|
|
||||||
"Unknown host key for {}, adding to known_hosts (SHA256: {})",
|
|
||||||
host,
|
|
||||||
fingerprint_hex(session)
|
|
||||||
);
|
|
||||||
known
|
|
||||||
.add(host, key, "wherry", key_type.into())
|
|
||||||
.context("failed to add host key to known_hosts")?;
|
|
||||||
if let Some(ref path) = known_hosts_path()
|
|
||||||
&& let Err(e) = known.write_file(path, KnownHostFileKind::OpenSSH)
|
|
||||||
{
|
|
||||||
tracing::warn!("failed to write known_hosts: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CheckResult::Failure => {
|
|
||||||
bail!("known_hosts check failed for {}", host);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use ssh2::Session;
|
|
||||||
use std::net::TcpStream;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
mod hostkey;
|
|
||||||
mod ops;
|
|
||||||
mod transfer;
|
|
||||||
|
|
||||||
pub struct SftpClient {
|
|
||||||
session: Arc<Mutex<Session>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SftpClient {
|
|
||||||
/// Подключение по паролю
|
|
||||||
pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result<Self> {
|
|
||||||
let session = handshake(host, port)?;
|
|
||||||
session
|
|
||||||
.userauth_password(user, password)
|
|
||||||
.context("SSH password auth failed")?;
|
|
||||||
Ok(Self {
|
|
||||||
session: Arc::new(Mutex::new(session)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Подключение по явно выбранному ключу. `passphrase` — пароль из формы
|
|
||||||
/// (если задан): им расшифровывается защищённый паролем ключ.
|
|
||||||
pub fn connect_key(
|
|
||||||
host: &str,
|
|
||||||
port: u16,
|
|
||||||
user: &str,
|
|
||||||
key_path: &str,
|
|
||||||
passphrase: Option<&str>,
|
|
||||||
) -> Result<Self> {
|
|
||||||
let session = handshake(host, port)?;
|
|
||||||
session
|
|
||||||
.userauth_pubkey_file(user, None, Path::new(key_path), passphrase)
|
|
||||||
.with_context(|| format!("SSH key auth failed ({})", key_path))?;
|
|
||||||
Ok(Self {
|
|
||||||
session: Arc::new(Mutex::new(session)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Автоматический подбор SSH-аутентификации, когда пароль не задан и ключ
|
|
||||||
/// не выбран: сперва ssh-agent, затем стандартные ключи из ~/.ssh.
|
|
||||||
pub fn connect_auto(host: &str, port: u16, user: &str) -> Result<Self> {
|
|
||||||
let session = handshake(host, port)?;
|
|
||||||
|
|
||||||
// 1) ssh-agent — покрывает ключи с passphrase, добавленные в агент.
|
|
||||||
if session.userauth_agent(user).is_ok() && session.authenticated() {
|
|
||||||
return Ok(Self {
|
|
||||||
session: Arc::new(Mutex::new(session)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) стандартные ключи в ~/.ssh (без passphrase).
|
|
||||||
let ssh_dir = dirs::home_dir()
|
|
||||||
.context("Cannot resolve home directory")?
|
|
||||||
.join(".ssh");
|
|
||||||
let mut tried = vec!["ssh-agent".to_string()];
|
|
||||||
for name in ["id_ed25519", "id_ecdsa", "id_rsa"] {
|
|
||||||
let key = ssh_dir.join(name);
|
|
||||||
if !key.is_file() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if session.userauth_pubkey_file(user, None, &key, None).is_ok()
|
|
||||||
&& session.authenticated()
|
|
||||||
{
|
|
||||||
return Ok(Self {
|
|
||||||
session: Arc::new(Mutex::new(session)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
tried.push(format!("~/.ssh/{}", name));
|
|
||||||
}
|
|
||||||
|
|
||||||
anyhow::bail!(
|
|
||||||
"SSH auth failed: no password given, and none of [{}] were accepted. \
|
|
||||||
Pick a key file manually or enter a password.",
|
|
||||||
tried.join(", ")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TCP + SSH handshake + проверка host key (общий пролог обоих способов аутентификации).
|
|
||||||
fn handshake(host: &str, port: u16) -> Result<Session> {
|
|
||||||
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")?;
|
|
||||||
hostkey::verify_host_key(&session, host, port)?;
|
|
||||||
Ok(session)
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
//! Реализация `RemoteFs` для SFTP: всё через `spawn_blocking` (ssh2 синхронный).
|
|
||||||
//! Чанкованные upload/download вынесены в [`super::transfer`].
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use super::{SftpClient, transfer};
|
|
||||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
|
||||||
use crate::protocols::{ProgressAction, RemoteFs};
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl RemoteFs for SftpClient {
|
|
||||||
async fn list(&self, path: &str) -> Result<Vec<FileEntry>> {
|
|
||||||
let session = self.session.clone();
|
|
||||||
let path = path.to_string();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let sftp = session
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.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)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("SFTP list task failed: {}", e))?
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn upload_with_progress(
|
|
||||||
&self,
|
|
||||||
local: &str,
|
|
||||||
remote: &str,
|
|
||||||
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
|
|
||||||
) -> Result<()> {
|
|
||||||
transfer::upload(
|
|
||||||
self.session.clone(),
|
|
||||||
local.to_string(),
|
|
||||||
remote.to_string(),
|
|
||||||
on_progress,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn download_with_progress(
|
|
||||||
&self,
|
|
||||||
remote: &str,
|
|
||||||
local: &str,
|
|
||||||
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
|
|
||||||
) -> Result<()> {
|
|
||||||
transfer::download(
|
|
||||||
self.session.clone(),
|
|
||||||
remote.to_string(),
|
|
||||||
local.to_string(),
|
|
||||||
on_progress,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
|
||||||
let session = self.session.clone();
|
|
||||||
let path = path.to_string();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let sftp = session
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.sftp()
|
|
||||||
.context("SFTP subsystem failed")?;
|
|
||||||
sftp.mkdir(Path::new(&path), 0o755)
|
|
||||||
.context("mkdir failed")?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("SFTP mkdir task failed: {}", e))?
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
|
||||||
let session = self.session.clone();
|
|
||||||
let from = from.to_string();
|
|
||||||
let to = to.to_string();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let sftp = session
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.sftp()
|
|
||||||
.context("SFTP subsystem failed")?;
|
|
||||||
sftp.rename(Path::new(&from), Path::new(&to), None)
|
|
||||||
.context("rename failed")?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("SFTP rename task failed: {}", e))?
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, path: &str) -> Result<()> {
|
|
||||||
let session = self.session.clone();
|
|
||||||
let path = path.to_string();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let sftp = session
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.sftp()
|
|
||||||
.context("SFTP subsystem failed")?;
|
|
||||||
// пробуем как файл, потом как директорию
|
|
||||||
if sftp.unlink(Path::new(&path)).is_err() {
|
|
||||||
sftp.rmdir(Path::new(&path)).context("delete failed")?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("SFTP delete task failed: {}", e))?
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stat(&self, path: &str) -> Result<FileEntry> {
|
|
||||||
let session = self.session.clone();
|
|
||||||
let path = path.to_string();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let sftp = session
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("SFTP stat task failed: {}", e))?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
//! Чанкованная загрузка/выгрузка по SFTP с колбэком прогресса (pause/cancel).
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use ssh2::Session;
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use crate::protocols::ProgressAction;
|
|
||||||
|
|
||||||
const CHUNK_SIZE: usize = 64 * 1024;
|
|
||||||
|
|
||||||
type ProgressCb = Option<Box<dyn Fn(u64) -> ProgressAction + Send>>;
|
|
||||||
|
|
||||||
/// Проверяет действие прогресса; возвращает Err для pause/cancel (прерывает цикл).
|
|
||||||
fn check(cb: &ProgressCb, total: u64) -> Result<()> {
|
|
||||||
if let Some(cb) = cb {
|
|
||||||
match cb(total) {
|
|
||||||
ProgressAction::Continue => {}
|
|
||||||
ProgressAction::Cancel => return Err(anyhow::anyhow!("cancelled")),
|
|
||||||
ProgressAction::Pause => return Err(anyhow::anyhow!("paused")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn upload(
|
|
||||||
session: Arc<Mutex<Session>>,
|
|
||||||
local: String,
|
|
||||||
remote: String,
|
|
||||||
on_progress: ProgressCb,
|
|
||||||
) -> Result<()> {
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let sftp = session
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.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")?;
|
|
||||||
|
|
||||||
let mut buf = vec![0u8; CHUNK_SIZE];
|
|
||||||
let mut total = 0u64;
|
|
||||||
loop {
|
|
||||||
let n = local_file
|
|
||||||
.read(&mut buf)
|
|
||||||
.context("read local file failed")?;
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
remote_file
|
|
||||||
.write_all(&buf[..n])
|
|
||||||
.context("write remote file failed")?;
|
|
||||||
total += n as u64;
|
|
||||||
check(&on_progress, total)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("SFTP upload task failed: {}", e))?
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn download(
|
|
||||||
session: Arc<Mutex<Session>>,
|
|
||||||
remote: String,
|
|
||||||
local: String,
|
|
||||||
on_progress: ProgressCb,
|
|
||||||
) -> Result<()> {
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let sftp = session
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.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")?;
|
|
||||||
|
|
||||||
let mut buf = vec![0u8; CHUNK_SIZE];
|
|
||||||
let mut total = 0u64;
|
|
||||||
loop {
|
|
||||||
let n = remote_file
|
|
||||||
.read(&mut buf)
|
|
||||||
.context("read remote file failed")?;
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
local_file
|
|
||||||
.write_all(&buf[..n])
|
|
||||||
.context("write local file failed")?;
|
|
||||||
total += n as u64;
|
|
||||||
check(&on_progress, total)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("SFTP download task failed: {}", e))?
|
|
||||||
}
|
|
||||||
119
src/settings.rs
Normal file
119
src/settings.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
|
// ── App settings ──
|
||||||
|
|
||||||
|
pub fn get_setting(conn: &Connection, key: &str) -> Option<String> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT value FROM app_settings WHERE key = ?1",
|
||||||
|
params![key],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_setting(conn: &Connection, key: &str, value: &str) -> Result<()> {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO app_settings (key, value) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
params![key, value],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_bool(conn: &Connection, key: &str, default: bool) -> bool {
|
||||||
|
get_setting(conn, key)
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_u32(conn: &Connection, key: &str, default: u32) -> u32 {
|
||||||
|
get_setting(conn, key)
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_bool(conn: &Connection, key: &str, value: bool) {
|
||||||
|
let _ = set_setting(conn, key, &value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_u32(conn: &Connection, key: &str, value: u32) {
|
||||||
|
let _ = set_setting(conn, key, &value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bookmarks ──
|
||||||
|
|
||||||
|
pub fn get_bookmarks(conn: &Connection) -> Result<Vec<(i64, String, String)>> {
|
||||||
|
let mut stmt = conn.prepare("SELECT id, name, path FROM bookmarks ORDER BY id")?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_bookmark(conn: &Connection, name: &str, path: &str) -> Result<i64> {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO bookmarks (name, path) VALUES (?1, ?2)",
|
||||||
|
params![name, path],
|
||||||
|
)?;
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_bookmark(conn: &Connection, id: i64) -> Result<()> {
|
||||||
|
conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_db() -> rusqlite::Connection {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
crate::storage::init_tables(&conn).unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_get_remove_bookmark() {
|
||||||
|
let conn = test_db();
|
||||||
|
let id = add_bookmark(&conn, "Projects", "/home/user/projects").unwrap();
|
||||||
|
assert!(id > 0);
|
||||||
|
|
||||||
|
let bookmarks = get_bookmarks(&conn).unwrap();
|
||||||
|
assert_eq!(bookmarks.len(), 1);
|
||||||
|
assert_eq!(bookmarks[0].1, "Projects");
|
||||||
|
assert_eq!(bookmarks[0].2, "/home/user/projects");
|
||||||
|
|
||||||
|
remove_bookmark(&conn, id).unwrap();
|
||||||
|
assert!(get_bookmarks(&conn).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_string() {
|
||||||
|
let conn = test_db();
|
||||||
|
assert!(get_setting(&conn, "theme").is_none());
|
||||||
|
set_setting(&conn, "theme", "dark").unwrap();
|
||||||
|
assert_eq!(get_setting(&conn, "theme").unwrap(), "dark");
|
||||||
|
set_setting(&conn, "theme", "light").unwrap();
|
||||||
|
assert_eq!(get_setting(&conn, "theme").unwrap(), "light");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_bool() {
|
||||||
|
let conn = test_db();
|
||||||
|
assert!(!get_bool(&conn, "show_hidden", false));
|
||||||
|
assert!(get_bool(&conn, "show_hidden", true));
|
||||||
|
set_bool(&conn, "show_hidden", true);
|
||||||
|
assert!(get_bool(&conn, "show_hidden", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_u32() {
|
||||||
|
let conn = test_db();
|
||||||
|
assert_eq!(get_u32(&conn, "timeout", 30), 30);
|
||||||
|
set_u32(&conn, "timeout", 60);
|
||||||
|
assert_eq!(get_u32(&conn, "timeout", 30), 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,8 @@
|
||||||
//! SQLite-хранилище: схема + операции по таблицам (bookmarks/history/sites).
|
use crate::domain::{Protocol, Site};
|
||||||
use crate::domain::connection::Protocol;
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use rusqlite::Connection;
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
mod bookmarks;
|
fn protocol_to_str(protocol: &Protocol) -> &'static str {
|
||||||
mod history;
|
|
||||||
mod settings;
|
|
||||||
mod sites;
|
|
||||||
|
|
||||||
pub use bookmarks::{add_bookmark, get_bookmarks, remove_bookmark};
|
|
||||||
pub use history::{
|
|
||||||
add_history_entry, clear_history, find_history_conn_id, get_history, HistoryRow,
|
|
||||||
};
|
|
||||||
pub use settings::{get_bool, get_setting, get_u32, set_bool, set_setting, set_u32};
|
|
||||||
pub use sites::{delete_site, get_sites, save_site};
|
|
||||||
|
|
||||||
pub(super) fn protocol_to_str(protocol: &Protocol) -> &'static str {
|
|
||||||
match protocol {
|
match protocol {
|
||||||
Protocol::Sftp => "sftp",
|
Protocol::Sftp => "sftp",
|
||||||
Protocol::Ftp => "ftp",
|
Protocol::Ftp => "ftp",
|
||||||
|
|
@ -23,7 +10,7 @@ pub(super) fn protocol_to_str(protocol: &Protocol) -> &'static str {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn protocol_from_str(s: &str) -> Protocol {
|
fn protocol_from_str(s: &str) -> Protocol {
|
||||||
match s {
|
match s {
|
||||||
"sftp" => Protocol::Sftp,
|
"sftp" => Protocol::Sftp,
|
||||||
"ftps" => Protocol::Ftps,
|
"ftps" => Protocol::Ftps,
|
||||||
|
|
@ -70,7 +57,6 @@ pub fn init_tables(conn: &Connection) -> Result<()> {
|
||||||
",
|
",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Миграции
|
|
||||||
let _ = conn.execute("ALTER TABLE connection_history ADD COLUMN conn_id TEXT", []);
|
let _ = conn.execute("ALTER TABLE connection_history ADD COLUMN conn_id TEXT", []);
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"ALTER TABLE connection_history ADD COLUMN protocol TEXT NOT NULL DEFAULT 'sftp'",
|
"ALTER TABLE connection_history ADD COLUMN protocol TEXT NOT NULL DEFAULT 'sftp'",
|
||||||
|
|
@ -81,8 +67,6 @@ pub fn init_tables(conn: &Connection) -> Result<()> {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
let _ = conn.execute("ALTER TABLE sites ADD COLUMN password TEXT", []);
|
let _ = conn.execute("ALTER TABLE sites ADD COLUMN password TEXT", []);
|
||||||
// На случай старых записей без уникальности (host,port,username) — оставляем
|
|
||||||
// только самую свежую строку на каждую цель перед созданием индекса.
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM connection_history WHERE id NOT IN (
|
"DELETE FROM connection_history WHERE id NOT IN (
|
||||||
SELECT MAX(id) FROM connection_history GROUP BY host, port, username
|
SELECT MAX(id) FROM connection_history GROUP BY host, port, username
|
||||||
|
|
@ -98,10 +82,152 @@ pub fn init_tables(conn: &Connection) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sites ──
|
||||||
|
|
||||||
|
pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, name, protocol, host, port, username, password, key_path, folder, note FROM sites ORDER BY name",
|
||||||
|
)?;
|
||||||
|
let sites = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
let proto_str: String = row.get(2)?;
|
||||||
|
let protocol = protocol_from_str(&proto_str);
|
||||||
|
Ok(Site {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
protocol,
|
||||||
|
host: row.get(3)?,
|
||||||
|
port: row.get(4)?,
|
||||||
|
username: row.get(5)?,
|
||||||
|
password: row.get(6)?,
|
||||||
|
key_path: row.get(7)?,
|
||||||
|
folder: row.get(8)?,
|
||||||
|
note: row.get(9)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
Ok(sites)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_site(conn: &Connection, site: &Site) -> Result<()> {
|
||||||
|
let proto = protocol_to_str(&site.protocol);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO sites
|
||||||
|
(id, name, protocol, host, port, username, password, key_path, folder, note)
|
||||||
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
|
||||||
|
params![
|
||||||
|
site.id,
|
||||||
|
site.name,
|
||||||
|
proto,
|
||||||
|
site.host,
|
||||||
|
site.port,
|
||||||
|
site.username,
|
||||||
|
site.password,
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── History ──
|
||||||
|
|
||||||
|
pub type HistoryRow = (
|
||||||
|
String,
|
||||||
|
u16,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Protocol,
|
||||||
|
Option<String>,
|
||||||
|
);
|
||||||
|
|
||||||
|
pub fn find_history_conn_id(
|
||||||
|
conn: &Connection,
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
let id: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT conn_id FROM connection_history WHERE host = ?1 AND port = ?2 AND username = ?3",
|
||||||
|
params![host, port, username],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn add_history_entry(
|
||||||
|
conn: &Connection,
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
username: &str,
|
||||||
|
conn_id: &str,
|
||||||
|
protocol: &Protocol,
|
||||||
|
key_path: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use chrono::Local;
|
||||||
|
let connected_at = Local::now().format("%H:%M %d.%m").to_string();
|
||||||
|
let proto = protocol_to_str(protocol);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO connection_history (host, port, username, connected_at, conn_id, protocol, key_path)
|
||||||
|
VALUES (?1,?2,?3,?4,?5,?6,?7)
|
||||||
|
ON CONFLICT(host, port, username) DO UPDATE SET
|
||||||
|
connected_at = excluded.connected_at,
|
||||||
|
protocol = excluded.protocol,
|
||||||
|
key_path = excluded.key_path,
|
||||||
|
conn_id = COALESCE(connection_history.conn_id, excluded.conn_id)",
|
||||||
|
params![host, port, username, connected_at, conn_id, proto, key_path],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM connection_history WHERE id NOT IN (
|
||||||
|
SELECT id FROM connection_history ORDER BY id DESC LIMIT 20
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_history(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute("DELETE FROM connection_history", [])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_history(conn: &Connection) -> Result<Vec<HistoryRow>> {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT host, port, username, connected_at, conn_id, protocol, key_path
|
||||||
|
FROM connection_history ORDER BY id DESC LIMIT 20",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
let proto_str: String = row.get(5)?;
|
||||||
|
Ok((
|
||||||
|
row.get(0)?,
|
||||||
|
row.get(1)?,
|
||||||
|
row.get(2)?,
|
||||||
|
row.get(3)?,
|
||||||
|
row.get(4)?,
|
||||||
|
protocol_from_str(&proto_str),
|
||||||
|
row.get(6)?,
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::domain::site::Site;
|
|
||||||
|
|
||||||
fn test_db() -> rusqlite::Connection {
|
fn test_db() -> rusqlite::Connection {
|
||||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
|
@ -204,51 +330,8 @@ mod tests {
|
||||||
let protocol = Protocol::Sftp;
|
let protocol = Protocol::Sftp;
|
||||||
add_history_entry(&conn, "example.com", 22, "admin", "conn-1", &protocol, None).unwrap();
|
add_history_entry(&conn, "example.com", 22, "admin", "conn-1", &protocol, None).unwrap();
|
||||||
add_history_entry(&conn, "example.com", 22, "admin", "conn-2", &protocol, None).unwrap();
|
add_history_entry(&conn, "example.com", 22, "admin", "conn-2", &protocol, None).unwrap();
|
||||||
// same target keeps only the latest; conn_id preserved from first insert
|
|
||||||
let history = get_history(&conn).unwrap();
|
let history = get_history(&conn).unwrap();
|
||||||
assert_eq!(history.len(), 1);
|
assert_eq!(history.len(), 1);
|
||||||
assert_eq!(history[0].4, "conn-1");
|
assert_eq!(history[0].4, "conn-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_add_get_remove_bookmark() {
|
|
||||||
let conn = test_db();
|
|
||||||
let id = add_bookmark(&conn, "Projects", "/home/user/projects").unwrap();
|
|
||||||
assert!(id > 0);
|
|
||||||
|
|
||||||
let bookmarks = get_bookmarks(&conn).unwrap();
|
|
||||||
assert_eq!(bookmarks.len(), 1);
|
|
||||||
assert_eq!(bookmarks[0].1, "Projects");
|
|
||||||
assert_eq!(bookmarks[0].2, "/home/user/projects");
|
|
||||||
|
|
||||||
remove_bookmark(&conn, id).unwrap();
|
|
||||||
assert!(get_bookmarks(&conn).unwrap().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_settings_string() {
|
|
||||||
let conn = test_db();
|
|
||||||
assert!(get_setting(&conn, "theme").is_none());
|
|
||||||
set_setting(&conn, "theme", "dark").unwrap();
|
|
||||||
assert_eq!(get_setting(&conn, "theme").unwrap(), "dark");
|
|
||||||
set_setting(&conn, "theme", "light").unwrap();
|
|
||||||
assert_eq!(get_setting(&conn, "theme").unwrap(), "light");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_settings_bool() {
|
|
||||||
let conn = test_db();
|
|
||||||
assert!(!get_bool(&conn, "show_hidden", false));
|
|
||||||
assert!(get_bool(&conn, "show_hidden", true));
|
|
||||||
set_bool(&conn, "show_hidden", true);
|
|
||||||
assert!(get_bool(&conn, "show_hidden", false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_settings_u32() {
|
|
||||||
let conn = test_db();
|
|
||||||
assert_eq!(get_u32(&conn, "timeout", 30), 30);
|
|
||||||
set_u32(&conn, "timeout", 60);
|
|
||||||
assert_eq!(get_u32(&conn, "timeout", 30), 60);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
//! Таблица закладок локальных папок.
|
|
||||||
use anyhow::Result;
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
|
|
||||||
pub fn get_bookmarks(conn: &Connection) -> Result<Vec<(i64, String, String)>> {
|
|
||||||
let mut stmt = conn.prepare("SELECT id, name, path FROM bookmarks ORDER BY id")?;
|
|
||||||
let rows = stmt
|
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
|
|
||||||
.filter_map(|r| r.ok())
|
|
||||||
.collect();
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_bookmark(conn: &Connection, name: &str, path: &str) -> Result<i64> {
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO bookmarks (name, path) VALUES (?1, ?2)",
|
|
||||||
params![name, path],
|
|
||||||
)?;
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_bookmark(conn: &Connection, id: i64) -> Result<()> {
|
|
||||||
conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
//! Таблица истории подключений (последние 20 целей, уникальных по host/port/user).
|
|
||||||
use anyhow::Result;
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
|
|
||||||
use super::{protocol_from_str, protocol_to_str};
|
|
||||||
use crate::domain::connection::Protocol;
|
|
||||||
|
|
||||||
/// Одна запись истории: host, port, username, время, conn_id (стабильный id —
|
|
||||||
/// под ним же лежит пароль в keychain), протокол, key_path.
|
|
||||||
pub type HistoryRow = (
|
|
||||||
String,
|
|
||||||
u16,
|
|
||||||
String,
|
|
||||||
String,
|
|
||||||
String,
|
|
||||||
Protocol,
|
|
||||||
Option<String>,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Ищет уже существующий conn_id для этой цели (host,port,username), чтобы
|
|
||||||
/// повторные подключения писали пароль в keychain под тем же id, а не заводили новый.
|
|
||||||
pub fn find_history_conn_id(
|
|
||||||
conn: &Connection,
|
|
||||||
host: &str,
|
|
||||||
port: u16,
|
|
||||||
username: &str,
|
|
||||||
) -> Result<Option<String>> {
|
|
||||||
let id: Option<String> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT conn_id FROM connection_history WHERE host = ?1 AND port = ?2 AND username = ?3",
|
|
||||||
params![host, port, username],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn add_history_entry(
|
|
||||||
conn: &Connection,
|
|
||||||
host: &str,
|
|
||||||
port: u16,
|
|
||||||
username: &str,
|
|
||||||
conn_id: &str,
|
|
||||||
protocol: &Protocol,
|
|
||||||
key_path: Option<&str>,
|
|
||||||
) -> Result<()> {
|
|
||||||
use chrono::Local;
|
|
||||||
let connected_at = Local::now().format("%H:%M %d.%m").to_string();
|
|
||||||
let proto = protocol_to_str(protocol);
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO connection_history (host, port, username, connected_at, conn_id, protocol, key_path)
|
|
||||||
VALUES (?1,?2,?3,?4,?5,?6,?7)
|
|
||||||
ON CONFLICT(host, port, username) DO UPDATE SET
|
|
||||||
connected_at = excluded.connected_at,
|
|
||||||
protocol = excluded.protocol,
|
|
||||||
key_path = excluded.key_path,
|
|
||||||
conn_id = COALESCE(connection_history.conn_id, excluded.conn_id)",
|
|
||||||
params![host, port, username, connected_at, conn_id, proto, key_path],
|
|
||||||
)?;
|
|
||||||
// Храним только последние 20 записей
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM connection_history WHERE id NOT IN (
|
|
||||||
SELECT id FROM connection_history ORDER BY id DESC LIMIT 20
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Полностью очищает историю подключений (Settings → History → Clear All).
|
|
||||||
pub fn clear_history(conn: &Connection) -> Result<()> {
|
|
||||||
conn.execute("DELETE FROM connection_history", [])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_history(conn: &Connection) -> Result<Vec<HistoryRow>> {
|
|
||||||
let mut stmt = conn.prepare(
|
|
||||||
"SELECT host, port, username, connected_at, conn_id, protocol, key_path
|
|
||||||
FROM connection_history ORDER BY id DESC LIMIT 20",
|
|
||||||
)?;
|
|
||||||
let rows = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
let proto_str: String = row.get(5)?;
|
|
||||||
Ok((
|
|
||||||
row.get(0)?,
|
|
||||||
row.get(1)?,
|
|
||||||
row.get(2)?,
|
|
||||||
row.get(3)?,
|
|
||||||
row.get(4)?,
|
|
||||||
protocol_from_str(&proto_str),
|
|
||||||
row.get(6)?,
|
|
||||||
))
|
|
||||||
})?
|
|
||||||
.filter_map(|r| r.ok())
|
|
||||||
.collect();
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
//! Таблица произвольных настроек приложения (ключ-значение) — используется
|
|
||||||
//! диалогом Settings для того, что должно переживать перезапуск (в отличие
|
|
||||||
//! от видимости панелей, которая остаётся только на время сессии).
|
|
||||||
use anyhow::Result;
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
|
|
||||||
pub fn get_setting(conn: &Connection, key: &str) -> Option<String> {
|
|
||||||
conn.query_row(
|
|
||||||
"SELECT value FROM app_settings WHERE key = ?1",
|
|
||||||
params![key],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_setting(conn: &Connection, key: &str, value: &str) -> Result<()> {
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO app_settings (key, value) VALUES (?1, ?2)
|
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
||||||
params![key, value],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_bool(conn: &Connection, key: &str, default: bool) -> bool {
|
|
||||||
get_setting(conn, key)
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(default)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_u32(conn: &Connection, key: &str, default: u32) -> u32 {
|
|
||||||
get_setting(conn, key)
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(default)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_bool(conn: &Connection, key: &str, value: bool) {
|
|
||||||
let _ = set_setting(conn, key, &value.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_u32(conn: &Connection, key: &str, value: u32) {
|
|
||||||
let _ = set_setting(conn, key, &value.to_string());
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
//! Таблица сохранённых серверов (Sites).
|
|
||||||
use anyhow::Result;
|
|
||||||
use rusqlite::{params, Connection};
|
|
||||||
|
|
||||||
use super::{protocol_from_str, protocol_to_str};
|
|
||||||
use crate::domain::site::Site;
|
|
||||||
|
|
||||||
pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> {
|
|
||||||
let mut stmt = conn.prepare(
|
|
||||||
"SELECT id, name, protocol, host, port, username, password, key_path, folder, note FROM sites ORDER BY name",
|
|
||||||
)?;
|
|
||||||
let sites = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
let proto_str: String = row.get(2)?;
|
|
||||||
let protocol = protocol_from_str(&proto_str);
|
|
||||||
Ok(Site {
|
|
||||||
id: row.get(0)?,
|
|
||||||
name: row.get(1)?,
|
|
||||||
protocol,
|
|
||||||
host: row.get(3)?,
|
|
||||||
port: row.get(4)?,
|
|
||||||
username: row.get(5)?,
|
|
||||||
password: row.get(6)?,
|
|
||||||
key_path: row.get(7)?,
|
|
||||||
folder: row.get(8)?,
|
|
||||||
note: row.get(9)?,
|
|
||||||
})
|
|
||||||
})?
|
|
||||||
.filter_map(|r| r.ok())
|
|
||||||
.collect();
|
|
||||||
Ok(sites)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save_site(conn: &Connection, site: &Site) -> Result<()> {
|
|
||||||
let proto = protocol_to_str(&site.protocol);
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO sites
|
|
||||||
(id, name, protocol, host, port, username, password, key_path, folder, note)
|
|
||||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
|
|
||||||
params![
|
|
||||||
site.id,
|
|
||||||
site.name,
|
|
||||||
proto,
|
|
||||||
site.host,
|
|
||||||
site.port,
|
|
||||||
site.username,
|
|
||||||
site.password,
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
pub mod db;
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
//! Integration-level smoke tests: verify module imports and command function
|
//! Integration-level smoke tests: verify module imports and command function
|
||||||
//! signatures compile correctly. Does not exercise Tauri runtime.
|
//! signatures compile correctly. Does not exercise Tauri runtime.
|
||||||
use wherry_lib::domain::connection::{ConnectionParams, Protocol};
|
use wherry_lib::domain::{
|
||||||
use wherry_lib::domain::error::AppError;
|
ConnectionParams, EntryKind, FileEntry, Protocol, Site, TaskState, TransferKind, TransferTask,
|
||||||
use wherry_lib::domain::file_entry::{EntryKind, FileEntry};
|
};
|
||||||
use wherry_lib::domain::site::Site;
|
use wherry_lib::error::AppError;
|
||||||
use wherry_lib::domain::transfer::{TaskState, TransferKind, TransferTask};
|
use wherry_lib::settings::{
|
||||||
use wherry_lib::storage::db::{
|
add_bookmark, get_bookmarks, get_bool, get_setting, get_u32, remove_bookmark, set_bool,
|
||||||
add_bookmark, add_history_entry, clear_history, delete_site, find_history_conn_id,
|
set_setting, set_u32,
|
||||||
get_bookmarks, get_bool, get_history, get_setting, get_sites, get_u32, init_tables,
|
};
|
||||||
remove_bookmark, save_site, set_bool, set_setting, set_u32, HistoryRow,
|
use wherry_lib::storage::{
|
||||||
|
HistoryRow, add_history_entry, clear_history, delete_site, find_history_conn_id, get_history,
|
||||||
|
get_sites, init_tables, save_site,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
use crate::fs::remote::RemoteRegistry;
|
|
||||||
use crate::transfer::queue::TransferQueue;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
pub mod manager;
|
|
||||||
pub mod progress;
|
|
||||||
pub mod queue;
|
|
||||||
pub mod task;
|
|
||||||
pub mod worker;
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
use crate::domain::transfer::{TransferKind, TransferTask};
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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;
|
||||||
|
|
@ -1,7 +1,25 @@
|
||||||
use crate::domain::transfer::{TaskState, TransferTask};
|
use crate::domain::{TaskState, TransferKind, TransferTask};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::{Arc, Mutex};
|
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)]
|
#[derive(Clone, Default)]
|
||||||
pub struct TransferQueue {
|
pub struct TransferQueue {
|
||||||
inner: Arc<Mutex<VecDeque<TransferTask>>>,
|
inner: Arc<Mutex<VecDeque<TransferTask>>>,
|
||||||
|
|
@ -55,7 +73,7 @@ impl TransferQueue {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
|
use crate::domain::{TaskState, TransferKind, TransferTask};
|
||||||
|
|
||||||
fn make_task(id: &str, total: u64) -> TransferTask {
|
fn make_task(id: &str, total: u64) -> TransferTask {
|
||||||
TransferTask {
|
TransferTask {
|
||||||
|
|
@ -1,19 +1,67 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
||||||
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
|
use crate::domain::{TaskState, TransferKind, TransferTask};
|
||||||
use crate::fs::remote::RemoteRegistry;
|
use crate::fs::remote::RemoteRegistry;
|
||||||
use crate::protocols::ProgressAction;
|
use crate::protocols::ProgressAction;
|
||||||
use crate::transfer::progress::ProgressThrottle;
|
use crate::transfers::queue::TransferQueue;
|
||||||
use crate::transfer::queue::TransferQueue;
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS: u64 = 200;
|
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)]
|
#[derive(Clone, serde::Serialize)]
|
||||||
struct ProgressPayload {
|
struct ProgressPayload {
|
||||||
id: String,
|
id: String,
|
||||||
|
|
@ -52,9 +100,6 @@ fn emit_state(app: &AppHandle, id: &str, state: TaskState) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Уменьшает счётчик активных передач при выходе из скоупа — в том числе при
|
|
||||||
/// панике внутри задачи (Drop отрабатывает и во время размотки стека), иначе
|
|
||||||
/// упавшая задача навсегда съедала бы один слот параллелизма.
|
|
||||||
struct InFlightGuard(Arc<AtomicUsize>);
|
struct InFlightGuard(Arc<AtomicUsize>);
|
||||||
impl Drop for InFlightGuard {
|
impl Drop for InFlightGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
|
@ -62,10 +107,6 @@ impl Drop for InFlightGuard {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Запускает диспетчер передач: раз в тик подбирает из очереди задачи со
|
|
||||||
/// статусом `Queued` и запускает их параллельно, пока число активных передач
|
|
||||||
/// меньше `max_concurrent` — значение читается заново на каждой итерации, так
|
|
||||||
/// что Settings → Transfers меняет параллелизм на лету, без перезапуска.
|
|
||||||
pub fn spawn_worker(
|
pub fn spawn_worker(
|
||||||
queue: TransferQueue,
|
queue: TransferQueue,
|
||||||
registry: Arc<RemoteRegistry>,
|
registry: Arc<RemoteRegistry>,
|
||||||
|
|
@ -122,7 +163,6 @@ pub fn spawn_worker(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Выполняет одну задачу передачи целиком и записывает итог в очередь.
|
|
||||||
async fn run_transfer(
|
async fn run_transfer(
|
||||||
task: TransferTask,
|
task: TransferTask,
|
||||||
queue: TransferQueue,
|
queue: TransferQueue,
|
||||||
|
|
@ -143,8 +183,6 @@ async fn run_transfer(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Progress callback: троттлит обновления очереди до ~10 FPS, считает
|
|
||||||
// текущую скорость и реагирует на отмену/паузу, выставленные пользователем.
|
|
||||||
let queue_for_progress = queue.clone();
|
let queue_for_progress = queue.clone();
|
||||||
let task_id_for_progress = task.id.clone();
|
let task_id_for_progress = task.id.clone();
|
||||||
let app_for_progress = app.clone();
|
let app_for_progress = app.clone();
|
||||||
|
|
@ -199,7 +237,10 @@ async fn run_transfer(
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
queue.update_state(&task.id, TaskState::Completed);
|
queue.update_state(&task.id, TaskState::Completed);
|
||||||
queue.update_progress(&task.id, task.total_bytes, 0);
|
queue.update_progress(&task.id, task.total_bytes, 0);
|
||||||
completed_at.lock().unwrap().insert(task.id.clone(), Instant::now());
|
completed_at
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(task.id.clone(), Instant::now());
|
||||||
emit_progress(&app, &queue, &task.id);
|
emit_progress(&app, &queue, &task.id);
|
||||||
emit_state(&app, &task.id, TaskState::Completed);
|
emit_state(&app, &task.id, TaskState::Completed);
|
||||||
}
|
}
|
||||||
|
|
@ -224,3 +265,38 @@ async fn run_transfer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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