udisks2: mount/unmount without sudo, auto-mount before sync

- m key toggles mount/unmount of the selected device via udisksctl
  (resolves the block device from mount point or lsblk label)
- rsync-style device sync auto-mounts the target first
- mlove finalize unmounts via udisksctl, falling back to sudo -n umount
- keybar/help/README updated
This commit is contained in:
Sebastian
2026-08-22 10:12:13 +02:00
parent d1cf5af7fa
commit 4568080453
7 changed files with 139 additions and 10 deletions
+8
View File
@@ -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
+29
View File
@@ -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));
+21 -2
View File
@@ -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,16 +663,22 @@ 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()));
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!(
"sudo -n umount failed (exit {code}) {detail} — run manually: sudo umount {mount_display}",
"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}…"));
let (code, detail) = capture("sudo", &["-n", "fatsort", &target])?;
+1
View File
@@ -3,6 +3,7 @@ mod config;
mod disk;
mod engine;
mod progress;
mod udisks;
mod ui;
use anyhow::Result;
+70
View File
@@ -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<String> {
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<String, String> {
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<String, String> {
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}"))
}
}
+1
View File
@@ -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"),
+1
View File
@@ -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",