123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939 | use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::Serialize;
use tauri::State;
use crate::backup::{
BackupProgress, format_duration, list_snapshot_names, rsync_command_string, rsync_restore_args,
run_backup, run_restore,
};
use crate::config::{Config, SyncJob};
use crate::drives::{self, Drive};
use crate::format::{DriveInfo, FormatProgress, probe_drive, run_format};
use crate::state::AppState;
use crate::wipe::{self, WipeProgress};
// ── Serializable response types ──────────────────────────────────────────────
#[derive(Serialize)]
pub struct DirEntry {
pub name: String,
pub is_dir: bool,
pub size: String,
}
#[derive(Serialize)]
pub struct DriveJson {
pub device: String,
pub label: Option<String>,
pub fstype: Option<String>,
pub size: Option<String>,
pub mountpoint: Option<String>,
pub is_encrypted: bool,
pub luks_parent: Option<String>,
pub model: Option<String>,
pub vendor: Option<String>,
pub tran: Option<String>,
pub dev_type: String,
pub display_name: String,
pub is_mounted: bool,
}
impl From<Drive> for DriveJson {
fn from(d: Drive) -> Self {
DriveJson {
display_name: d.display_name(),
is_mounted: d.is_mounted(),
device: d.device,
label: d.label,
fstype: d.fstype,
size: d.size,
mountpoint: d.mountpoint,
is_encrypted: d.is_encrypted,
luks_parent: d.luks_parent,
model: d.model,
vendor: d.vendor,
tran: d.tran,
dev_type: d.dev_type,
}
}
}
#[derive(Serialize)]
pub struct MountResult {
pub mount_point: String,
pub config: Config,
}
#[derive(Serialize)]
pub struct OpenResult {
pub needs_password: bool,
pub mounted: Option<MountResult>,
pub error: Option<String>,
}
#[derive(Serialize)]
pub struct PreviewCommand {
pub name: String,
pub cmd: String,
}
#[derive(Serialize)]
pub struct BackupProgressJson {
pub current_job: usize,
pub total_jobs: usize,
pub job_name: String,
pub current_file: String,
pub elapsed: String,
pub eta: String,
pub overall_fraction: f32,
pub files_transferred: u64,
pub files_total: u64,
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
pub paused: bool,
pub log_lines: Vec<String>,
pub finished_msg: Option<String>,
pub running: bool,
}
#[derive(Serialize)]
pub struct AppStatus {
pub mount_point: Option<String>,
pub config: Option<Config>,
pub config_dirty: bool,
pub status_msg: Option<String>,
pub backup_running: bool,
}
// ── Commands ──────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn list_drives() -> Result<Vec<DriveJson>, String> {
tauri::async_runtime::spawn_blocking(|| {
drives::list_removable_drives()
.map(|ds| ds.into_iter().map(DriveJson::from).collect())
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn open_drive(
state: State<'_, Mutex<AppState>>,
device: String,
) -> Result<OpenResult, String> {
let drive = match tauri::async_runtime::spawn_blocking({
let device = device.clone();
move || {
drives::list_removable_drives()
.map_err(|e| e.to_string())
.and_then(|ds| {
ds.into_iter()
.find(|d| d.device == device)
.ok_or_else(|| format!("Drive {device} not found"))
})
}
})
.await
{
Ok(Ok(d)) => d,
Ok(Err(e)) => {
return Ok(OpenResult {
needs_password: false,
mounted: None,
error: Some(e),
});
}
Err(e) => {
return Ok(OpenResult {
needs_password: false,
mounted: None,
error: Some(e.to_string()),
});
}
};
if drive.is_mounted() {
let mp = PathBuf::from(drive.mountpoint.as_deref().unwrap_or("/"));
let config = load_config(&mp);
let mut s = state.lock().unwrap();
s.mount_point = Some(mp.clone());
if let Some(luks_parent) = &drive.luks_parent {
s.mapper_name = Some(drive.device.clone());
s.mounted_device = Some(luks_parent.clone());
} else {
s.mounted_device = Some(drive.device.clone());
}
s.config = Some(config.clone());
s.config_dirty = false;
return Ok(OpenResult {
needs_password: false,
mounted: Some(MountResult {
mount_point: mp.display().to_string(),
config,
}),
error: None,
});
}
if drive.is_encrypted {
return Ok(OpenResult {
needs_password: true,
mounted: None,
error: None,
});
}
let dev = drive.device.clone();
match tauri::async_runtime::spawn_blocking(move || drives::mount_device(&dev)).await {
Ok(Ok(mp)) => {
let config = load_config(&mp);
let mut s = state.lock().unwrap();
s.mounted_device = Some(drive.device);
s.mount_point = Some(mp.clone());
s.config = Some(config.clone());
s.config_dirty = false;
Ok(OpenResult {
needs_password: false,
mounted: Some(MountResult {
mount_point: mp.display().to_string(),
config,
}),
error: None,
})
}
Ok(Err(e)) => Ok(OpenResult {
needs_password: false,
mounted: None,
error: Some(format!("Mount failed: {e}")),
}),
Err(e) => Ok(OpenResult {
needs_password: false,
mounted: None,
error: Some(e.to_string()),
}),
}
}
#[tauri::command]
pub async fn unlock_drive(
state: State<'_, Mutex<AppState>>,
device: String,
password: String,
) -> Result<MountResult, String> {
let luks_dev = device.clone();
let (dm_device, mp) = tauri::async_runtime::spawn_blocking(move || {
drives::unlock_and_mount(&device, &password).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())??;
let config = load_config(&mp);
let mut s = state.lock().unwrap();
s.mounted_device = Some(luks_dev);
s.mapper_name = Some(dm_device);
s.mount_point = Some(mp.clone());
s.config = Some(config.clone());
s.config_dirty = false;
Ok(MountResult {
mount_point: mp.display().to_string(),
config,
})
}
#[tauri::command]
pub fn get_config(state: State<'_, Mutex<AppState>>) -> Option<Config> {
state.lock().unwrap().config.clone()
}
#[tauri::command]
pub fn save_config(state: State<'_, Mutex<AppState>>) -> Result<(), String> {
let (cfg, mp) = {
let s = state.lock().unwrap();
match (&s.config, &s.mount_point) {
(Some(cfg), Some(mp)) => (cfg.clone(), mp.clone()),
_ => return Err("No config or mount point".to_owned()),
}
};
cfg.save(&mp).map_err(|e| e.to_string())?;
state.lock().unwrap().config_dirty = false;
Ok(())
}
#[tauri::command]
pub fn update_config(state: State<'_, Mutex<AppState>>, config: Config) -> Result<(), String> {
let mut s = state.lock().unwrap();
s.config = Some(config);
s.config_dirty = true;
Ok(())
}
#[tauri::command]
pub fn add_job(state: State<'_, Mutex<AppState>>) -> Result<Config, String> {
let mut s = state.lock().unwrap();
if let Some(cfg) = &mut s.config {
let idx = cfg.jobs.len();
cfg.jobs.push(SyncJob::new(
format!("Job {}", idx + 1),
std::env::var("HOME").unwrap_or_default(),
));
let result = cfg.clone();
s.config_dirty = true;
Ok(result)
} else {
Err("No config loaded".to_owned())
}
}
#[tauri::command]
pub fn delete_job(state: State<'_, Mutex<AppState>>, idx: usize) -> Result<Config, String> {
let mut s = state.lock().unwrap();
if let Some(cfg) = &mut s.config {
if idx < cfg.jobs.len() {
cfg.jobs.remove(idx);
let result = cfg.clone();
s.config_dirty = true;
Ok(result)
} else {
Err("Job index out of range".to_owned())
}
} else {
Err("No config loaded".to_owned())
}
}
#[tauri::command]
pub async fn eject(state: State<'_, Mutex<AppState>>) -> Result<(), String> {
let (mapper_name, mounted_device, mount_point, child_pid) = {
let s = state.lock().unwrap();
let pid = s.progress.lock().unwrap().child_pid;
(
s.mapper_name.clone(),
s.mounted_device.clone(),
s.mount_point.clone(),
pid,
)
};
// Stop any running backup/restore before unmounting.
if let Some(pid) = child_pid {
let s = state.lock().unwrap();
let mut p = s.progress.lock().unwrap();
p.cancelled = true;
p.finished = true;
drop(p);
drop(s);
let _ = std::process::Command::new("kill")
.args([&pid.to_string()])
.status();
}
// Cancel any running wipe so the thread stops writing before unmount.
{
let s = state.lock().unwrap();
let mut p = s.wipe_progress.lock().unwrap();
p.cancelled = true;
p.finished = true;
}
tauri::async_runtime::spawn_blocking(move || {
let result = match (&mapper_name, &mounted_device) {
(Some(cleartext_dev), Some(luks_dev)) => {
let unmount = if cleartext_dev.starts_with("/dev/mapper/") {
mount_point
.as_ref()
.map(|mp| drives::doas_unmount(mp))
.unwrap_or_else(|| drives::udisksctl_unmount(cleartext_dev))
} else {
drives::udisksctl_unmount(cleartext_dev)
};
unmount.and_then(|()| {
if cleartext_dev.starts_with("/dev/mapper/") {
drives::doas_luks_close()
} else {
drives::udisksctl_lock(luks_dev)
}
})
}
(None, Some(dev)) => drives::udisksctl_unmount(dev),
_ => Ok(()),
};
if result.is_ok() {
if let Some(dev) = &mounted_device {
let _ = drives::udisksctl_power_off(dev);
}
}
result.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())??;
let mut s = state.lock().unwrap();
s.mount_point = None;
s.mounted_device = None;
s.mapper_name = None;
s.config = None;
s.config_dirty = false;
s.backup_finished_msg = None;
s.backup_running = false;
s.wipe_running = false;
s.is_restore = false;
Ok(())
}
// Unmount a drive that was not mounted through the app (e.g. during format flow).
// Does not power off — the drive may be needed for subsequent formatting.
#[tauri::command]
pub async fn unmount_device(device: String, luks_parent: Option<String>) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let result = if let Some(ref luks_dev) = luks_parent {
drives::udisksctl_unmount(&device).and_then(|()| drives::udisksctl_lock(luks_dev))
} else {
drives::udisksctl_unmount(&device)
};
result.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub fn preview_commands(state: State<'_, Mutex<AppState>>) -> Vec<PreviewCommand> {
let s = state.lock().unwrap();
match (&s.config, &s.mount_point) {
(Some(cfg), Some(mp)) => cfg
.jobs
.iter()
.filter(|j| j.enabled)
.map(|j| PreviewCommand {
name: j.name.clone(),
cmd: rsync_command_string(j, mp),
})
.collect(),
_ => vec![],
}
}
#[tauri::command]
pub fn start_backup(state: State<'_, Mutex<AppState>>) -> Result<(), String> {
let (cfg, mp, progress) = {
let s = state.lock().unwrap();
if s.backup_running {
return Err("A backup or restore is already in progress".to_owned());
}
if s.wipe_running {
return Err("Cannot start backup while free space wipe is running".to_owned());
}
match (s.config.clone(), s.mount_point.clone()) {
(Some(c), Some(m)) => (c, m, std::sync::Arc::clone(&s.progress)),
_ => return Err("No config or mount point".to_owned()),
}
};
// Clean up any fill file left over from a crashed wipe (before the snapshot is taken).
wipe::cleanup_fill_file(&mp);
{
let mut p = progress.lock().unwrap();
*p = BackupProgress::default();
}
{
let mut s = state.lock().unwrap();
s.backup_running = true;
s.is_restore = false;
s.backup_finished_msg = None;
}
run_backup(&cfg, &mp, progress);
Ok(())
}
#[tauri::command]
pub fn start_wipe_free_space(state: State<'_, Mutex<AppState>>) -> Result<(), String> {
let (mp, progress) = {
let mut s = state.lock().unwrap();
if s.backup_running {
return Err("Cannot wipe free space while a backup is running".to_owned());
}
if s.wipe_running {
return Err("A wipe is already in progress".to_owned());
}
let mp = match &s.mount_point {
Some(mp) => mp.clone(),
None => return Err("No drive mounted".to_owned()),
};
let progress = std::sync::Arc::clone(&s.wipe_progress);
s.wipe_running = true;
(mp, progress)
};
wipe::run_wipe(mp, progress);
Ok(())
}
#[tauri::command]
pub fn get_wipe_progress(state: State<'_, Mutex<AppState>>) -> WipeProgress {
let mut s = state.lock().unwrap();
let p = s.wipe_progress.lock().unwrap().clone();
if s.wipe_running && p.finished {
s.wipe_running = false;
}
p
}
#[tauri::command]
pub fn cancel_wipe(state: State<'_, Mutex<AppState>>) {
let s = state.lock().unwrap();
let mut p = s.wipe_progress.lock().unwrap();
if !p.finished {
p.cancelled = true;
}
}
#[tauri::command]
pub fn get_backup_progress(state: State<'_, Mutex<AppState>>) -> BackupProgressJson {
let mut s = state.lock().unwrap();
let p = s.progress.lock().unwrap().clone();
let running = s.backup_running && !p.finished && p.error.is_none();
if s.backup_running && (p.finished || p.error.is_some()) {
s.backup_running = false;
let is_restore = s.is_restore;
let op = if is_restore { "Restore" } else { "Backup" };
if p.cancelled {
s.backup_finished_msg = Some(format!("{op} cancelled."));
} else if let Some(ref err) = p.error {
s.backup_finished_msg = Some(format!("{op} failed: {err}"));
} else {
let elapsed = format_duration(p.elapsed_secs as u64);
s.backup_finished_msg = Some(format!("{op} complete in {elapsed}!"));
if !is_restore {
if let Some(cfg) = &mut s.config {
cfg.last_backup = Some(chrono::Local::now());
}
if !s.config_dirty {
if let (Some(cfg), Some(mp)) = (&s.config, &s.mount_point) {
let _ = cfg.save(mp);
}
}
}
}
}
let finished_msg = s.backup_finished_msg.clone();
let eta = p.eta_string();
let elapsed = format_duration(p.elapsed_secs as u64);
let overall_fraction = p.overall_fraction();
BackupProgressJson {
current_job: p.current_job,
total_jobs: p.total_jobs,
job_name: p.job_name,
current_file: p.current_file,
elapsed,
eta,
overall_fraction,
files_transferred: p.files_transferred,
files_total: p.files_total,
finished: p.finished,
cancelled: p.cancelled,
error: p.error,
paused: p.paused,
log_lines: p.log_lines,
finished_msg,
running,
}
}
#[tauri::command]
pub fn pause_backup(state: State<'_, Mutex<AppState>>) {
let s = state.lock().unwrap();
let p = s.progress.lock().unwrap();
if let Some(pid) = p.child_pid {
drop(p);
drop(s);
let _ = std::process::Command::new("kill")
.args(["-STOP", &pid.to_string()])
.status();
state.lock().unwrap().progress.lock().unwrap().paused = true;
}
}
#[tauri::command]
pub fn resume_backup(state: State<'_, Mutex<AppState>>) {
let s = state.lock().unwrap();
let p = s.progress.lock().unwrap();
if let Some(pid) = p.child_pid {
drop(p);
drop(s);
let _ = std::process::Command::new("kill")
.args(["-CONT", &pid.to_string()])
.status();
state.lock().unwrap().progress.lock().unwrap().paused = false;
}
}
#[tauri::command]
pub fn cancel_backup(state: State<'_, Mutex<AppState>>) {
let s = state.lock().unwrap();
let mut p = s.progress.lock().unwrap();
p.cancelled = true;
p.finished = true;
if let Some(pid) = p.child_pid {
drop(p);
drop(s);
let _ = std::process::Command::new("kill")
.args([&pid.to_string()])
.status();
}
}
#[tauri::command]
pub fn start_probe_drive(
state: State<'_, Mutex<AppState>>,
device: String,
fstype: Option<String>,
) {
let shared = probe_drive(device, fstype);
state.lock().unwrap().format_drive_info = shared;
}
#[tauri::command]
pub fn get_drive_probe(state: State<'_, Mutex<AppState>>) -> DriveInfo {
state
.lock()
.unwrap()
.format_drive_info
.lock()
.unwrap()
.clone()
}
#[tauri::command]
pub fn format_command_preview(
device: String,
is_disk: bool,
label: String,
fstype: String,
encrypt: bool,
) -> Vec<String> {
crate::format::format_command_preview(&device, is_disk, &label, &fstype, encrypt)
}
#[tauri::command]
pub fn start_format(
state: State<'_, Mutex<AppState>>,
device: String,
is_disk: bool,
label: String,
fstype: String,
encrypt: bool,
passphrase: String,
) -> Result<(), String> {
let progress = {
let mut s = state.lock().unwrap();
s.format_running = true;
std::sync::Arc::clone(&s.format_progress)
};
run_format(
device, is_disk, label, fstype, encrypt, passphrase, progress,
);
Ok(())
}
#[tauri::command]
pub fn get_format_progress(state: State<'_, Mutex<AppState>>) -> FormatProgress {
let s = state.lock().unwrap();
let p = s.format_progress.lock().unwrap().clone();
if s.format_running && p.finished {
drop(s);
state.lock().unwrap().format_running = false;
}
p
}
#[tauri::command]
pub fn get_status(state: State<'_, Mutex<AppState>>) -> AppStatus {
let s = state.lock().unwrap();
AppStatus {
mount_point: s.mount_point.as_ref().map(|p| p.display().to_string()),
config: s.config.clone(),
config_dirty: s.config_dirty,
status_msg: s.status_msg.clone(),
backup_running: s.backup_running,
}
}
#[tauri::command]
pub async fn list_snapshots(state: State<'_, Mutex<AppState>>) -> Result<Vec<String>, String> {
let mp = {
let s = state.lock().unwrap();
s.mount_point.clone()
};
match mp {
Some(mp) => tauri::async_runtime::spawn_blocking(move || list_snapshot_names(&mp))
.await
.map_err(|e| e.to_string()),
None => Ok(vec![]),
}
}
#[tauri::command]
pub async fn preview_restore(
state: State<'_, Mutex<AppState>>,
snapshot: Option<String>,
job_indices: Vec<usize>,
subpaths: Vec<String>,
delete_extra: bool,
) -> Result<Vec<String>, String> {
let (cfg, mp) = {
let s = state.lock().unwrap();
if s.backup_running {
return Err("A backup or restore is already in progress".to_owned());
}
match (s.config.clone(), s.mount_point.clone()) {
(Some(c), Some(m)) => (c, m),
_ => return Err("No config or mount point".to_owned()),
}
};
if let Some(ref snap) = snapshot {
if snap.contains('/') {
return Err("Invalid snapshot name".to_owned());
}
}
for sub in &subpaths {
if !sub.is_empty() {
validate_subpath(sub)?;
}
}
let job_pairs: Vec<(SyncJob, Option<String>)> = job_indices
.iter()
.zip(subpaths.into_iter())
.filter_map(|(&i, sub)| {
cfg.jobs
.get(i)
.map(|j| (j.clone(), if sub.is_empty() { None } else { Some(sub) }))
})
.collect();
if job_pairs.is_empty() {
return Err("No jobs selected".to_owned());
}
tauri::async_runtime::spawn_blocking(move || {
if let Some(ref snap) = snapshot {
if !list_snapshot_names(&mp).contains(snap) {
return Err(format!("Snapshot not found: {snap}"));
}
}
let mut all_lines = Vec::new();
for (job, subpath) in &job_pairs {
let mut args = rsync_restore_args(
job,
&mp,
snapshot.as_deref(),
delete_extra,
subpath.as_deref(),
);
args.push("--dry-run".to_owned());
args.push("--stats".to_owned());
all_lines.push(format!(">>> [{}] dry-run", job.name));
let output = std::process::Command::new("rsync")
.args(&args)
.output()
.map_err(|e| e.to_string())?;
let stdout = String::from_utf8_lossy(&output.stdout);
all_lines.extend(stdout.lines().map(str::to_owned));
if !output.status.success() {
let code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim();
if !stderr.is_empty() {
all_lines.push(format!("(stderr: {stderr})"));
}
if code != 0 && code != 24 {
all_lines.push(format!("(rsync exited {code})"));
}
}
}
Ok(all_lines)
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub fn start_restore(
state: State<'_, Mutex<AppState>>,
snapshot: Option<String>,
job_indices: Vec<usize>,
subpaths: Vec<String>,
delete_extra: bool,
) -> Result<(), String> {
let (cfg, mp, progress) = {
let s = state.lock().unwrap();
if s.backup_running {
return Err("A backup or restore is already in progress".to_owned());
}
match (s.config.clone(), s.mount_point.clone()) {
(Some(c), Some(m)) => (c, m, std::sync::Arc::clone(&s.progress)),
_ => return Err("No config or mount point".to_owned()),
}
};
wipe::cleanup_fill_file(&mp);
// Validate snapshot name before using it in a path join.
// Slashes would allow escaping the snapshots directory; the whitelist
// check catches everything else (read_dir never yields ".." entries).
if let Some(ref snap) = snapshot {
if snap.contains('/') {
return Err("Invalid snapshot name".to_owned());
}
if !list_snapshot_names(&mp).contains(snap) {
return Err(format!("Snapshot not found: {snap}"));
}
}
for sub in &subpaths {
if !sub.is_empty() {
validate_subpath(sub)?;
}
}
let (jobs, job_subpaths): (Vec<SyncJob>, Vec<Option<String>>) = job_indices
.iter()
.zip(subpaths.into_iter())
.filter_map(|(&i, sub)| {
cfg.jobs
.get(i)
.map(|j| (j.clone(), if sub.is_empty() { None } else { Some(sub) }))
})
.unzip();
if jobs.is_empty() {
return Err("No jobs selected".to_owned());
}
{
let mut p = progress.lock().unwrap();
*p = BackupProgress::default();
}
{
let mut s = state.lock().unwrap();
s.backup_running = true;
s.is_restore = true;
s.backup_finished_msg = None;
}
run_restore(jobs, job_subpaths, mp, snapshot, delete_extra, progress);
Ok(())
}
fn load_config(mp: &Path) -> Config {
Config::load(mp).unwrap_or_default()
}
fn validate_subpath(sub: &str) -> Result<(), String> {
use std::path::Component;
for comp in Path::new(sub).components() {
match comp {
Component::Normal(_) => {}
_ => {
return Err(format!(
"Invalid subpath \"{sub}\": must use only plain directory names (no '..', '/', or leading '.')"
));
}
}
}
Ok(())
}
#[tauri::command]
pub async fn list_dir(path: String) -> Result<Vec<DirEntry>, String> {
tauri::async_runtime::spawn_blocking(move || {
let rd = std::fs::read_dir(&path).map_err(|e| format!("Cannot read {}: {}", path, e))?;
let mut entries: Vec<(String, bool)> = Vec::new();
for entry in rd {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy().into_owned();
let ft = entry.file_type().map_err(|e| e.to_string())?;
let is_dir = if ft.is_symlink() {
std::fs::metadata(entry.path())
.map(|m| m.is_dir())
.unwrap_or(false)
} else {
ft.is_dir()
};
entries.push((name, is_dir));
}
entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let dir_sizes: std::collections::HashMap<String, String> = {
let mut map = std::collections::HashMap::new();
if let Ok(out) = std::process::Command::new("du")
.args(["-h", "--max-depth=1", &path])
.output()
{
let path_trimmed = path.trim_end_matches('/');
for line in String::from_utf8_lossy(&out.stdout).lines() {
let mut parts = line.splitn(2, '\t');
if let (Some(size), Some(p)) = (parts.next(), parts.next()) {
let p = p.trim_end_matches('/');
if p != path_trimmed {
if let Some(name) = p.rsplit('/').next() {
map.insert(name.to_owned(), size.to_owned());
}
}
}
}
}
map
};
let result = entries
.into_iter()
.map(|(name, is_dir)| {
let size = if is_dir {
dir_sizes.get(&name).cloned().unwrap_or_default()
} else {
let full = format!("{}/{}", path, name);
std::fs::metadata(&full)
.map(|m| fmt_dir_entry_bytes(m.len()))
.unwrap_or_default()
};
DirEntry { name, is_dir, size }
})
.collect();
Ok(result)
})
.await
.map_err(|e| e.to_string())?
}
fn fmt_dir_entry_bytes(b: u64) -> String {
if b >= 1_073_741_824 {
format!("{:.1}G", b as f64 / 1_073_741_824.0)
} else if b >= 1_048_576 {
format!("{:.1}M", b as f64 / 1_048_576.0)
} else if b >= 1_024 {
format!("{:.1}K", b as f64 / 1_024.0)
} else {
format!("{}B", b)
}
}
#[tauri::command]
pub fn quit(app: tauri::AppHandle) {
app.exit(0);
}
|