Download
939 lines 27.9 KiBRustModified 2026-10-16 raw
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);
}