DEV Community

Cover image for MTP File Transfer in Rust on macOS — Why I Wrote My Own Stack
hiyoyo
hiyoyo

Posted on

MTP File Transfer in Rust on macOS — Why I Wrote My Own Stack

All tests run on an 8-year-old MacBook Air. All results from shipping 7 Mac apps as a solo developer. No sponsored opinion.

HiyokoMTP transfers files between Android and Mac via MTP. The obvious approach — use libmtp — doesn't work well on macOS. So I wrote a custom MTP implementation. Here's why, and what that looks like.


The libmtp problem on macOS

libmtp is the standard C library for MTP. On Linux, it works well. On macOS, it conflicts with IOKit's USB ownership model.

macOS claims USB devices at the system level. libmtp tries to claim them again at the library level. The result: connection failures, device not found errors, crashes. Unreliable enough to be unusable for a shipping product.


The alternative: nusb

nusb is a pure Rust USB library that works with macOS's IOKit properly. Instead of using libmtp, I implement the MTP protocol directly over USB using nusb.

[dependencies]
nusb = "0.1"
Enter fullscreen mode Exit fullscreen mode

MTP basics in Rust

MTP runs over USB bulk transfer endpoints. The protocol is request-response: send an operation request, receive a response, transfer data.

use nusb::transfer::RequestBuffer;

async fn send_mtp_operation(
    interface: &nusb::Interface,
    op_code: u16,
    params: &[u32],
) -> Result<Vec<u8>, AppError> {
    // Build MTP container
    let container = build_operation_container(op_code, params);

    // Send on bulk-out endpoint
    interface.bulk_out(BULK_OUT_EP, container).await?;

    // Receive response on bulk-in endpoint
    let response = interface
        .bulk_in(BULK_IN_EP, RequestBuffer::new(512))
        .await?;

    parse_mtp_response(&response)
}
Enter fullscreen mode Exit fullscreen mode

File transfer

Large file transfers use chunked bulk reads:

async fn download_file(
    interface: &nusb::Interface,
    object_handle: u32,
    output: &mut impl Write,
) -> Result<(), AppError> {
    // Request file data
    send_mtp_operation(interface, MTP_OP_GET_OBJECT, &[object_handle]).await?;

    // Read data container header
    let header = read_data_container_header(interface).await?;
    let total_size = header.data_length;
    let mut received = 0usize;

    // Stream data in chunks
    while received < total_size {
        let chunk = interface
            .bulk_in(BULK_IN_EP, RequestBuffer::new(65536))
            .await?;
        output.write_all(&chunk)?;
        received += chunk.len();
    }

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

What this enables

A custom MTP stack means full control over the protocol. Smart resume (check partial transfers, skip completed files), parallel transfers (multiple MTP sessions), conflict detection — all buildable because the stack is yours.

libmtp gives you a C API. A custom stack gives you whatever you need.


Is it worth it?

For a Mac-focused app: yes. The libmtp reliability issues on macOS make it a non-starter. The custom stack took time to build but is stable and fast.

For a cross-platform app where Linux support matters: libmtp on Linux is fine. Use it there, custom on macOS.


TL;DR: libmtp conflicts with macOS's IOKit USB ownership model — don't use it on Mac. Instead, use nusb (pure Rust) and implement the MTP protocol directly over bulk USB transfers. More work upfront, but you get full control: smart resume, parallel sessions, conflict detection.


If this was useful, a ❤️ helps more than you'd think — thanks!

HiyokoMTP | X → @hiyoyok

Top comments (0)