diff --git a/README.md b/README.md index db40ab9..cb9d517 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ cargo build --release | `↑`/`↓` `j`/`k` | move selection | | `s` / `Enter`| sync selected device | | `a` | abort running sync | +| `m` | mount / unmount device (udisks2, no sudo) | | `Tab` / `1-3`| switch tab | | `r` | refresh device mounts | | `f` | toggle log follow | @@ -83,6 +84,13 @@ Firmware types: `apple` (podkit command), `rockbox`, `android`, `sony` `sudo -n` to keep FAT directory order stable. If passwordless sudo is not available the step is reported as a warning and the commands are printed. +## Mounting + +Devices are mounted/unmounted without sudo via **udisks2**: +`m` in the TUI mounts/unmounts the selected device, and a sync automatically +mounts the target first (resolved by mount-point label via `lsblk`). Only the +mlove `fatsort` step needs root (raw block device). + ## Development ```bash diff --git a/src/app.rs b/src/app.rs index 273039b..98f8c69 100644 --- a/src/app.rs +++ b/src/app.rs @@ -145,6 +145,34 @@ impl App { } } + fn toggle_mount(&mut self) { + let Some(device) = self.selected_device().cloned() else { + self.status_msg = Some(("no device selected".to_string(), LogLevel::Warn)); + return; + }; + let Some(mp) = device.mount_point.clone() else { + self.status_msg = Some(("device has no mount_point".to_string(), LogLevel::Warn)); + return; + }; + if self.is_syncing() { + return; + } + let (action, res) = if device.mounted { + ("Unmounting", crate::udisks::unmount_device(&mp)) + } else { + ("Mounting", crate::udisks::mount_device(&mp)) + }; + match res { + Ok(msg) => { + self.status_msg = Some((format!("{action}: {msg}"), LogLevel::Success)); + } + Err(e) => { + self.status_msg = Some((format!("{action} failed — {e}"), LogLevel::Error)); + } + } + self.refresh_mounts(); + } + pub fn on_key(&mut self, key: KeyEvent) { if key.kind != KeyEventKind::Press { return; @@ -174,6 +202,7 @@ impl App { } KeyCode::Char('s') | KeyCode::Enter => self.start_sync(), KeyCode::Char('a') => self.abort_sync(), + KeyCode::Char('m') => self.toggle_mount(), KeyCode::Char('r') => { self.refresh_mounts(); self.status_msg = Some(("devices refreshed".to_string(), LogLevel::Info)); diff --git a/src/engine.rs b/src/engine.rs index 4944ceb..8583ae5 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -479,9 +479,22 @@ fn run_rsync_device(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result .mount_point .as_ref() .ok_or_else(|| "mount_point is not set for this device".to_string())?; + + if !mount.exists() { + ctx.log(LogLevel::Info, format!("device not mounted at {} — trying udisksctl…", mount.display())); + match crate::udisks::mount_device(mount) { + Ok(msg) => ctx.log(LogLevel::Info, format!("auto-mounted: {msg}")), + Err(e) => { + return Err(format!( + "device not found at {} — connect & mount it (m) and retry. {e}", + mount.display() + )); + } + } + } if !mount.exists() { return Err(format!( - "device not found at {} — connect & mount it, then try again", + "device not found at {} — connect & mount it (m) and retry", mount.display() )); } @@ -650,15 +663,21 @@ fn finalize_fat(device: &Device, ctx: &WorkerCtx) -> Result<(), String> { let target = resolve_fatsort_target(mount)?; ctx.log(LogLevel::Info, format!("Unmounting {mount_display}", mount_display = mount.display())); - let (code, detail) = capture("sudo", &["-n", "umount", mount.to_str().unwrap_or("")])?; - if code != 0 { - ctx.log( - LogLevel::Warn, - format!( - "sudo -n umount failed (exit {code}) {detail} — run manually: sudo umount {mount_display}", - mount_display = mount.display() - ), - ); + match crate::udisks::unmount_device(mount) { + Ok(msg) => ctx.log(LogLevel::Info, format!("unmounted: {msg}")), + Err(e) => { + // fall back to sudo for setups without udisks + let (code, detail) = capture("sudo", &["-n", "umount", mount.to_str().unwrap_or("")])?; + if code != 0 { + ctx.log( + LogLevel::Warn, + format!( + "udisks failed ({e}) and sudo -n umount failed (exit {code}) {detail} — run manually: sudo umount {mount_display}", + mount_display = mount.display() + ), + ); + } + } } ctx.log(LogLevel::Info, format!("Running fatsort on {target}…")); diff --git a/src/main.rs b/src/main.rs index 4959bfd..04c1826 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod config; mod disk; mod engine; mod progress; +mod udisks; mod ui; use anyhow::Result; diff --git a/src/udisks.rs b/src/udisks.rs new file mode 100644 index 0000000..8c128b3 --- /dev/null +++ b/src/udisks.rs @@ -0,0 +1,70 @@ +use std::path::Path; +use std::process::Command; + +/// Mount/unmount devices without sudo via udisks2 (available to the active +/// desktop user). The block device is resolved from the mount point: either +/// the currently-mounted source, or a partition whose label matches the mount +/// point's basename (e.g. `/media/sebastian/IPOD` → label `IPOD`). + +fn run(cmd: &str, args: &[&str]) -> (bool, String) { + match Command::new(cmd).args(args).output() { + Ok(o) => { + let out = String::from_utf8_lossy(&o.stdout).trim().to_string(); + let err = String::from_utf8_lossy(&o.stderr).trim().to_string(); + let detail = if !err.is_empty() { err } else { out }; + (o.status.success(), detail) + } + Err(e) => (false, format!("cannot run {cmd}: {e}")), + } +} + +fn resolve_block_device(path: &Path) -> Option { + if path.is_dir() { + let (ok, out) = run("findmnt", &["-n", "-o", "SOURCE", "-T", path.to_str()?]); + if ok { + let src = out.trim(); + if src.starts_with("/dev/") { + return Some(src.to_string()); + } + } + } + let wanted = path.file_name()?.to_string_lossy().to_lowercase(); + let (ok, out) = run("lsblk", &["-o", "PATH,LABEL", "-n", "-l"]); + if !ok { + return None; + } + for line in out.lines() { + let mut parts = line.split_whitespace(); + let Some(dev) = parts.next() else { continue }; + if let Some(label) = parts.next() { + if !label.is_empty() && label.to_lowercase() == wanted { + return Some(dev.to_string()); + } + } + } + None +} + +pub fn mount_device(path: &Path) -> Result { + let Some(dev) = resolve_block_device(path) else { + return Err(format!("no block device found for {}", path.display())); + }; + let (ok, detail) = run("udisksctl", &["mount", "-b", dev.as_str()]); + if ok { + Ok(detail) + } else { + Err(format!("udisksctl mount {dev} failed: {detail}")) + } +} + +pub fn unmount_device(path: &Path) -> Result { + let Some(dev) = resolve_block_device(path) else { + return Err(format!("no block device found for {}", path.display())); + }; + let (ok, detail) = run("udisksctl", &["unmount", "-b", dev.as_str()]); + if ok { + Ok(detail) + } else { + Err(format!("udisksctl unmount {dev} failed: {detail}")) + } +} diff --git a/src/ui/components.rs b/src/ui/components.rs index 749bc42..c4c6009 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -353,6 +353,7 @@ pub fn draw_keys(f: &mut Frame, area: Rect, _app: &App) { ("↑↓/jk", "nav"), ("s", "sync"), ("a", "abort"), + ("m", "mount"), ("Tab/1-3", "tab"), ("r", "refresh"), ("f", "follow"), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f346f46..41ebd46 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -81,6 +81,7 @@ fn draw_help(f: &mut Frame) { " ↑↓ / j k move selection", " s / Enter sync selected device", " a abort running sync", + " m mount / unmount selected device (udisks)", " Tab / 1-3 switch tab", " r refresh devices", " f toggle log follow",