TL;DR
Android file transfer on macOS has been a broken experience for a decade — Google's official app is buggy, and most alternatives either wrap Electron (heavy) or bolt a Go MTP library onto a native Swift UI via CGO (fast UI, but a fragile bridge underneath). When I set out to build my own alternative, HiyokoMTP, I picked pure Rust + Tauri: one language from the USB layer to the transfer engine, no GC pauses, no cross-runtime signal handling headaches. This post walks through the real macOS/Android edge cases that made that choice non-negotiable — device claim conflicts with macOS's own background processes, MediaStore stalls on large batch transfers, and why "it connects" and "it transfers reliably" are two very different engineering problems.
The problem nobody really solves
If you've ever plugged an Android phone into a Mac, you know the experience: Google's official Android File Transfer app is functionally frozen in time — 4GB file size limits, random disconnects, no rename support. So a small ecosystem of alternatives has grown up around this gap, mostly falling into two camps:
- Electron-based tools — cross-platform, but heavy (200-400MB+ binaries) and never quite feel native on macOS.
- Native Swift UI + Go MTP backend — lightweight, native-feeling UI, but the actual device communication runs in a Go library compiled to a C archive and called through CGO.
I went a third way: Rust end-to-end, using nusb for USB access, wrapped in a Tauri shell with a React frontend. No CGO, no separate garbage-collected runtime living inside a native app, no C bridge translating between memory models.
Here's what actually drove that decision — not language fandom, but specific failure modes I ran into while building this.
Failure mode #1: macOS already has its hands on your device
The most common — and most confusing — connection failure isn't a USB issue at all. macOS ships with its own background processes for handling imaging devices (the same machinery behind Image Capture and Preview's "import from device" feature). When an Android phone connects in MTP mode, these processes can grab the device's PTP/MTP interface before your app gets a chance to.
The result, from the user's side: "Device busy" or a silent connection failure that looks like a bug in your app, when it's actually a resource contention issue with macOS itself.
I didn't figure this out from documentation — I found it by comparing system_profiler/IORegistry output and lsof-style process inspection at the exact moment a connection failed, and noticing the same handful of Apple process names showing an open handle on the device every time. Once you know to look, the pattern is obvious; before that, it just looks like your USB code is broken.
Handling this properly means your app has to actively check for and release these conflicting processes before attempting to claim the interface — and retry with backoff, because the timing of when macOS's own daemon grabs the device is not deterministic. This is the kind of edge case you only find by testing against real devices repeatedly, not by reading the MTP spec.
Failure mode #2: the Android side doesn't keep up
The other class of failures happens after a successful connection, during bulk transfers. Android's MediaStore (the system database that indexes media files) does write-side bookkeeping every time a file lands on the device. If you fire off writes faster than MediaStore can index them — which is easy to do once you're using a modern high-speed USB connection — the device-side MTP stack can stall waiting on the database, and the whole transfer session hangs.
This one took longer to isolate — my first instinct was to blame USB bandwidth or cable quality, and chasing that dead end wasted a few days before I looked in the right place. It doesn't fail consistently either, because it depends on the phone model, how full its MediaStore index already is, and how many small files are in the batch. What eventually made it click was watching transfer speed graphs over long batch jobs: instead of a flat line, I'd see the throughput trail off and then flatline entirely partway through, always around the same file count on a given device. That "it always stalls around file N, not randomly" pattern is what pointed at a device-side bottleneck rather than a USB or driver issue.
The fix isn't more raw throughput — it's pacing. Deliberately inserting small breathing gaps at intervals during large batch transfers, and being conservative about how often you refresh UI state (since a naive "re-list the folder after every file" approach re-triggers MTP traffic that competes with the actual transfer and makes things worse, not better).
None of this is exotic. It's the unglamorous, accumulated knowledge of "what actually breaks when real Android phones talk to a real Mac over real USB," and it only comes from hammering the thing with many devices over time.
Why the language choice actually matters here
None of the above is language-specific in principle — you could handle process conflicts and write-pacing in Go just as well as Rust. But the architecture the language choice forces you into matters a lot in practice. Laid out side by side:
| Electron | Native Swift + Go core | Rust end-to-end | |
|---|---|---|---|
| Binary size | Large (200–400MB+) | Small | Small–medium |
| GC pauses in the transfer path | Yes (JS) | Yes (Go) | None |
| Cross-runtime FFI boundary | No (single JS runtime) | Yes (Swift ↔ C ↔ Go) | None |
| Signal-handling conflicts | N/A | Yes — Go's runtime installs its own handlers (SIGPIPE, SIGURG collisions when embedded via CGO) | N/A |
| Adapting to a new device quirk | Easy (single runtime, but heavier at rest) | Touches 2–3 layers | One layer, one language |
The row that matters most day-to-day is the last one. Splitting the work across 2–3 layers doesn't just mean more surface area to touch — the boundary itself becomes a source of bugs. The SIGPIPE/SIGURG collision wasn't caused by writing new feature code; it was caused by the act of wiring two runtimes together in the first place. Rust end-to-end removes that boundary entirely: the USB layer, the transfer engine, and the pacing/retry logic all live in one memory model, with no FFI boundary to reason about and no second garbage collector running inside your process.
The Tauri/React frontend is a tradeoff in the other direction — you give up a bit of native "feel" and binary size compared to pure SwiftUI. But for the part of the app that actually has to be correct under real-world device weirdness — the USB and MTP layer — a single-language, GC-free stack removed an entire category of bugs before I even had to think about them.
The takeaway
The MTP spec itself was never the hard part. The hard part was the accumulated pile of "here's what actually happens with real devices" — and I didn't want to be debugging that pile through a C shim while also hoping Go's signal handlers stayed out of Swift's way. Keeping the whole USB-facing side of HiyokoMTP in one language, with no FFI boundary between me and the bug, was less about picking a "better" language and more about making sure that when something inevitably breaks in the field, I have one stack to look at instead of three.
HiyokoMTP is a macOS Android file transfer app built with Rust + Tauri. If you've fought with MTP on macOS before, I'd love to hear your war stories in the comments.
Top comments (0)