TL;DR
macOSでのAndroidファイル転送は、ここ10年ずっと"壊れた体験"のままだ。Google公式アプリは実質メンテ停止状態だし、代替ツールも「Electronで作って重い」か「ネイティブSwift UI + Goの転送エンジンをCGO経由で繋ぐ」かの二択になりがち。自分で代替ツールを作ろうと決めたとき、選んだのはRust + Tauri。USB層から転送エンジンまで単一言語で、GCの一時停止もないし、異なるランタイムをまたぐシグナル制御の面倒もない。この記事では、その判断に至った実際のmacOS/Android特有のハマりどころ——macOS自身のバックグラウンドプロセスとのデバイス競合や、大量転送時のAndroid側のフリーズなど——を紹介する。「繋がる」ことと「安定して転送できる」ことは、実は全く別の技術課題だという話。
誰も本当には解決していない問題
AndroidスマホをMacに繋いだことがある人ならわかると思うけど、Google公式のAndroid File Transferは事実上時が止まってる。4GB制限、頻繁な切断、リネーム不可。この隙間を埋めようと生まれた代替ツールは、だいたい2つの陣営に分かれる。
- Electronベース — クロスプラットフォームだけど重い(200〜400MB超のバイナリ)し、macOSネイティブの質感が出ない
- ネイティブSwift UI + Goの転送バックエンド — 軽快なUIだけど、実際のデバイス通信はGoライブラリをCアーカイブにコンパイルしてCGO経由で呼ぶ構成
自分は3つ目の道、Rustで一気通貫にした。USBアクセスはnusb、それをTauriのシェルとReactフロントエンドで包む構成。CGOなし、ネイティブアプリの中に別のGC付きランタイムが同居することもなし、CブリッジでメモリモデルをTranslateする必要もなし。
この判断を後押ししたのは言語の好みじゃなくて、開発中にぶつかった具体的な失敗パターンだった。
ハマりどころ①: macOS自身がすでにデバイスを掴んでいる
一番よくある、そして一番わかりにくい接続失敗は、実はUSBの問題ですらない。macOSにはイメージングデバイスを扱うための独自のバックグラウンドプロセスが標準搭載されている(Image CaptureやPreviewの「デバイスから読み込む」機能を支えている仕組み)。AndroidをMTPモードで接続すると、こうしたプロセスが自分のアプリより先にPTP/MTPインターフェースを掴んでしまうことがある。
ユーザー視点だと「デバイスがビジー」とか、原因不明の接続失敗に見える。実際はアプリのバグじゃなくて、macOS自身とのリソース競合が起きている。
これに気づいたのはドキュメントを読んだからじゃなくて、接続が失敗した瞬間のsystem_profiler/IORegistryの出力と、lsof的なプロセス調査を突き合わせて、決まって同じ顔ぶれのAppleのプロセスがそのデバイスのハンドルを掴んでいることに気づいたから。一度パターンが見えれば当たり前なんだけど、気づく前は「自分のUSBコードがバグってる」としか見えない。
これをちゃんと処理するには、インターフェースをクレームする前に競合しているプロセスを検知・解放する処理と、タイミングが一定しない(macOS側のデーモンがいつデバイスを掴むか予測できない)ことを踏まえたバックオフ付きリトライが必要になる。これはMTP仕様書を読んでもわからない、実機を何度も繋いで初めて見えてくる類のハマりどころ。
ハマりどころ②: Android側が処理に追いつかない
もう一つの失敗パターンは、接続が成功した後、大量転送の最中に起きる。AndroidのMediaStore(メディアファイルをインデックスするシステムDB)は、ファイルが端末に書き込まれるたびに裏で更新処理を走らせている。現代の高速USB接続だと簡単に起きるんだけど、MediaStoreの処理速度を上回るペースで書き込みを飛ばすと、端末側のMTPスタックがDB待ちでスタックし、転送セッション全体がフリーズしてしまう。
最初はUSB側の帯域やケーブル品質を疑って色々試したけど改善せず、これは原因の切り分けに時間がかかった。常に同じ形で失敗するわけじゃなく、端末の機種やMediaStoreのインデックスの状態、バッチ内の小ファイルの数によって出方が変わるから。気づくきっかけになったのは、長時間の一括転送で転送速度のグラフを眺めていたとき。平坦な線ではなく、ある地点から徐々に速度が落ちていき、同じ端末なら毎回だいたい同じファイル数あたりで完全に止まる、というパターンが見えた。「ランダムじゃなく、いつも同じファイル数あたりで止まる」という規則性が、USBやドライバの問題ではなく端末側のボトルネックを示していた。
解決策は「もっと速く送る」ことじゃなくて「ペース配分」。大量転送の途中に意図的に小さな待ち時間を挟むこと、そしてUI更新の頻度を抑えること(素朴に「1ファイル送るたびにフォルダを再取得」みたいな実装は、それ自体が余計なMTP通信を発生させて実転送と競合し、むしろ状況を悪化させる)。
どれも派手な話じゃない。「実際のAndroid端末が実際のMac相手にUSB経由で何をやらかすか」という、地味に積み上げるしかない経験則。これは色んな端末で何度も叩いてみないとわからない。
なぜここで言語選択が効いてくるのか
上記のハマりどころ自体は、原理的には言語に依存しない。Goでもプロセス競合対策やペース配分は書けるはず。でも言語選択が強いるアーキテクチャは、実務上かなり効いてくる。並べるとこんな感じ:
| Electron | ネイティブSwift + Goコア | Rust一気通貫 | |
|---|---|---|---|
| バイナリサイズ | 大きい(200〜400MB超) | 小さい | 小〜中 |
| 転送経路上でのGC一時停止 | あり(JS) | あり(Go) | なし |
| ランタイムをまたぐFFI境界 | なし(単一JSランタイム) | あり(Swift↔C↔Go) | なし |
| シグナル制御の衝突リスク | 該当なし | あり — GoランタイムがSIGPIPE/SIGURGなどの独自シグナルハンドラを持ち、CGO経由の埋め込み時に衝突しうる | 該当なし |
| 新しい端末の癖への対応しやすさ | 楽(ただしランタイム自体は重い) | 2〜3層に手を入れる必要あり | 1層・1言語で完結 |
一番効いてくるのは最後の行だ。2〜3層に分かれているということは、単に「触る箇所が増える」だけじゃなく、その境界そのものが不具合の発生源になる——実際、SIGPIPE/SIGURGの衝突は「機能追加で新しいコードを書いた」せいじゃなく「2つのランタイムを繋いだ」こと自体が原因で起きた問題だった。Rustで一気通貫にすると、この境界そのものがなくなる。USB層・転送エンジン・ペース配分/リトライロジックが全部同じメモリモデルの中に収まり、FFI境界を気にする必要もないし、プロセス内で2つ目のガベージコレクタが動くこともない。
Tauri/Reactのフロントエンドは逆方向のトレードオフで、純SwiftUIと比べるとネイティブの質感やバイナリサイズでは分が悪い。でも、実際に現実のデバイスの癖に対して正しく動く必要があるパート——USB/MTP層——に関しては、単一言語・GCなしのスタックのおかげで、意識する前から一つの大きなバグの温床が消えていた。
まとめ
MTP仕様書通りに動かす部分は、実は最初から難しくなかった。難しかったのは「実機で本当に何が起きるか」という積み上げの部分で、それをCシム越しにデバッグしながら、同時にGoのシグナルハンドラがSwiftの邪魔をしないよう祈る、みたいなことはしたくなかった。HiyokoMTPのUSBまわりを全部一つの言語に収めて、自分とバグの間にFFI境界を挟まないようにしたのは、「より良い言語を選んだ」という話というより、現場で何かが壊れたときに見るべきスタックが1つで済むようにしておきたかった、というだけの話。
HiyokoMTPはRust + Tauriで作ったmacOS向けAndroidファイル転送アプリです。macOSでMTPと格闘した経験がある人、コメントで武勇伝聞かせてください🐣