mod buffer;
mod device;
mod error;
mod fs;
mod hints;
mod inode;
mod reconnect;
mod shutdown;
mod sparse_cache;
mod spool;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use clap::Parser;
use crate::device::{UnplugSwitch, UsbOpener};
use crate::fs::{MtpFs, MtpFsConfig};
use crate::hints::{indent, open_failure_hint, BUSY_HINT, PERMISSION_HINT};
use crate::reconnect::ReconnectPolicy;
#[derive(Parser, Debug)]
#[command(
version,
about,
after_long_help = long_help()
)]
struct Cli {
mountpoint: Option<String>,
#[arg(short, long, value_name = "SERIAL")]
device: Option<String>,
#[arg(short, long, default_value_t = true)]
foreground: bool,
#[arg(short, long)]
read_only: bool,
#[arg(short, long)]
list: bool,
#[arg(long, value_name = "PATH")]
spool_dir: Option<PathBuf>,
#[arg(long, value_name = "SECONDS", default_value_t = ReconnectPolicy::DEFAULT_TIMEOUT_SECS)]
reconnect_timeout: u64,
}
fn long_help() -> String {
format!(
"\
EXAMPLES:
Mount the first available device:
mtp-mount /mnt/phone
List connected devices (shows serial numbers for -d):
mtp-mount --list
Mount a specific device:
mtp-mount -d ABC123 /mnt/phone
Mount read-only (safer for browsing, no accidental deletes):
mtp-mount -r /mnt/phone
Unmount:
umount /mnt/phone
Give a worn-out cable a minute to recover (0 unmounts on the first drop):
mtp-mount --reconnect-timeout 60 /mnt/phone
Show debug output (handy for troubleshooting):
RUST_LOG=debug mtp-mount /mnt/phone
TROUBLESHOOTING:
\"No MTP device found\"
Make sure the phone is unlocked, USB mode is set to \"File Transfer\"
(not \"Charging only\"), and the USB debugging prompt is accepted.
\"interface is busy\"
{busy}
\"Permission denied\" on /dev/bus/usb
{permission}
NOTES:
Files are uploaded to the device when you close them, not on each write.
While a file is open for writing it's spooled to disk under your cache
directory (--spool-dir overrides it), so uploads bigger than RAM work.
MTP doesn't support partial writes, hardlinks, symlinks, or chmod.
If the device disconnects, the mount is taken down. Pass
--reconnect-timeout SECONDS to wait for it to come back instead: the mount
then picks up where it left off, including files you have open. Useful with
a flaky cable that keeps dropping the link. It's off by default because
commands WAIT during that window rather than failing, so anything walking
the mount point (a file manager, a backup job) freezes until the device
returns or the window ends.",
busy = indent(BUSY_HINT, " "),
permission = indent(PERMISSION_HINT, " "),
)
}
fn list_devices() {
match mtp_rs::MtpDevice::list_devices() {
Ok(devices) if devices.is_empty() => {
println!("No MTP devices found.");
println!();
println!("Make sure your device is unlocked, USB mode is set to");
println!("\"File Transfer\", and the USB debugging prompt is accepted.");
}
Ok(devices) => {
println!("Found {} MTP device(s):\n", devices.len());
for (i, dev) in devices.iter().enumerate() {
let mfr = dev.manufacturer.as_deref().unwrap_or("Unknown");
let product = dev.product.as_deref().unwrap_or("Unknown");
let serial = dev.serial_number.as_deref().unwrap_or("(no serial)");
println!(
" [{}] {} {} (serial: {}, USB {:04x}:{:04x})",
i, mfr, product, serial, dev.vendor_id, dev.product_id
);
}
println!();
println!("Use -d SERIAL to mount a specific device.");
}
Err(e) => {
eprintln!("Failed to list devices: {e}");
std::process::exit(1);
}
}
}
fn main() {
env_logger::init();
let cli = Cli::parse();
if cli.list {
list_devices();
return;
}
let mountpoint = match &cli.mountpoint {
Some(m) => m,
None => {
eprintln!("Error: <MOUNTPOINT> is required (or use --list to see devices)");
std::process::exit(1);
}
};
let spool_dir = match spool::spool_dir_from_env(cli.spool_dir.as_deref())
.and_then(|dir| spool::prepare_spool_dir(&dir).map(|()| dir))
{
Ok(dir) => dir,
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
};
let device_label = cli.device.as_deref().unwrap_or("first available device");
println!("Mounting {device_label} at {mountpoint}...");
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
let handle = rt.handle().clone();
let device = rt.block_on(async {
if let Some(serial) = &cli.device {
mtp_rs::MtpDevice::open_by_serial(serial).await
} else {
mtp_rs::MtpDevice::open_first().await
}
});
let device = match device {
Ok(d) => d,
Err(e) => {
eprintln!("Failed to open MTP device: {e}");
if let Some(hint) = open_failure_hint(&e) {
eprintln!();
eprintln!("{hint}");
}
std::process::exit(1);
}
};
let serial = match device.device_info().serial_number.trim() {
"" => {
eprintln!(
"Note: this device reports no serial number, so a reconnect \
opens whichever MTP device is available."
);
None
}
serial => Some(serial.to_string()),
};
let unplug = UnplugSwitch::default();
let opener = Arc::new(UsbOpener::new(serial, unplug.clone()));
let mtp_fs = MtpFs::new(
device,
opener,
handle,
MtpFsConfig {
read_only: cli.read_only,
spool_dir,
reconnect: ReconnectPolicy::from_secs(cli.reconnect_timeout),
unplug,
},
);
let shutdown = mtp_fs.shutdown();
let mount_options = mtp_fs.mount_options();
let mut config = fuser::Config::default();
config.mount_options = mount_options;
let session = match fuser::spawn_mount2(mtp_fs, mountpoint, &config) {
Ok(session) => session,
Err(e) => {
eprintln!("Mount failed: {e}");
std::process::exit(1);
}
};
println!("Mounted. Press Ctrl+C to unmount.");
let gave_up = loop {
if shutdown.wait_timeout(Duration::from_millis(250)).is_some() {
break true;
}
if session.guard.is_finished() {
break false;
}
};
let ended = if gave_up {
session.umount_and_join()
} else {
session.join()
};
if let Err(e) = ended {
eprintln!("Mount ended with an error: {e}");
std::process::exit(1);
}
if gave_up {
std::process::exit(1);
}
}