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:
@@ -39,6 +39,7 @@ cargo build --release
|
|||||||
| `↑`/`↓` `j`/`k` | move selection |
|
| `↑`/`↓` `j`/`k` | move selection |
|
||||||
| `s` / `Enter`| sync selected device |
|
| `s` / `Enter`| sync selected device |
|
||||||
| `a` | abort running sync |
|
| `a` | abort running sync |
|
||||||
|
| `m` | mount / unmount device (udisks2, no sudo) |
|
||||||
| `Tab` / `1-3`| switch tab |
|
| `Tab` / `1-3`| switch tab |
|
||||||
| `r` | refresh device mounts |
|
| `r` | refresh device mounts |
|
||||||
| `f` | toggle log follow |
|
| `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
|
`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.
|
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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+29
@@ -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) {
|
pub fn on_key(&mut self, key: KeyEvent) {
|
||||||
if key.kind != KeyEventKind::Press {
|
if key.kind != KeyEventKind::Press {
|
||||||
return;
|
return;
|
||||||
@@ -174,6 +202,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
KeyCode::Char('s') | KeyCode::Enter => self.start_sync(),
|
KeyCode::Char('s') | KeyCode::Enter => self.start_sync(),
|
||||||
KeyCode::Char('a') => self.abort_sync(),
|
KeyCode::Char('a') => self.abort_sync(),
|
||||||
|
KeyCode::Char('m') => self.toggle_mount(),
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
self.refresh_mounts();
|
self.refresh_mounts();
|
||||||
self.status_msg = Some(("devices refreshed".to_string(), LogLevel::Info));
|
self.status_msg = Some(("devices refreshed".to_string(), LogLevel::Info));
|
||||||
|
|||||||
+29
-10
@@ -479,9 +479,22 @@ fn run_rsync_device(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result
|
|||||||
.mount_point
|
.mount_point
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "mount_point is not set for this device".to_string())?;
|
.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() {
|
if !mount.exists() {
|
||||||
return Err(format!(
|
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()
|
mount.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -650,15 +663,21 @@ fn finalize_fat(device: &Device, ctx: &WorkerCtx) -> Result<(), String> {
|
|||||||
let target = resolve_fatsort_target(mount)?;
|
let target = resolve_fatsort_target(mount)?;
|
||||||
|
|
||||||
ctx.log(LogLevel::Info, format!("Unmounting {mount_display}", mount_display = mount.display()));
|
ctx.log(LogLevel::Info, format!("Unmounting {mount_display}", mount_display = mount.display()));
|
||||||
let (code, detail) = capture("sudo", &["-n", "umount", mount.to_str().unwrap_or("")])?;
|
match crate::udisks::unmount_device(mount) {
|
||||||
if code != 0 {
|
Ok(msg) => ctx.log(LogLevel::Info, format!("unmounted: {msg}")),
|
||||||
ctx.log(
|
Err(e) => {
|
||||||
LogLevel::Warn,
|
// fall back to sudo for setups without udisks
|
||||||
format!(
|
let (code, detail) = capture("sudo", &["-n", "umount", mount.to_str().unwrap_or("")])?;
|
||||||
"sudo -n umount failed (exit {code}) {detail} — run manually: sudo umount {mount_display}",
|
if code != 0 {
|
||||||
mount_display = mount.display()
|
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}…"));
|
ctx.log(LogLevel::Info, format!("Running fatsort on {target}…"));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod config;
|
|||||||
mod disk;
|
mod disk;
|
||||||
mod engine;
|
mod engine;
|
||||||
mod progress;
|
mod progress;
|
||||||
|
mod udisks;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|||||||
@@ -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}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -353,6 +353,7 @@ pub fn draw_keys(f: &mut Frame, area: Rect, _app: &App) {
|
|||||||
("↑↓/jk", "nav"),
|
("↑↓/jk", "nav"),
|
||||||
("s", "sync"),
|
("s", "sync"),
|
||||||
("a", "abort"),
|
("a", "abort"),
|
||||||
|
("m", "mount"),
|
||||||
("Tab/1-3", "tab"),
|
("Tab/1-3", "tab"),
|
||||||
("r", "refresh"),
|
("r", "refresh"),
|
||||||
("f", "follow"),
|
("f", "follow"),
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ fn draw_help(f: &mut Frame) {
|
|||||||
" ↑↓ / j k move selection",
|
" ↑↓ / j k move selection",
|
||||||
" s / Enter sync selected device",
|
" s / Enter sync selected device",
|
||||||
" a abort running sync",
|
" a abort running sync",
|
||||||
|
" m mount / unmount selected device (udisks)",
|
||||||
" Tab / 1-3 switch tab",
|
" Tab / 1-3 switch tab",
|
||||||
" r refresh devices",
|
" r refresh devices",
|
||||||
" f toggle log follow",
|
" f toggle log follow",
|
||||||
|
|||||||
Reference in New Issue
Block a user