From ffa874cf0e740254387f52f851abc226aa428303 Mon Sep 17 00:00:00 2001 From: Toto Tvalavadze Date: Thu, 7 May 2026 15:40:36 +0400 Subject: [PATCH 1/5] feat: add Finder mount commands --- Cargo.lock | 674 +++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 6 + README.md | 22 +- docs/spec.md | 44 +++- src/cli.rs | 24 ++ src/lib.rs | 15 ++ src/mount.rs | 385 ++++++++++++++++++++++++++++ src/output.rs | 35 +++ 8 files changed, 1186 insertions(+), 19 deletions(-) create mode 100644 src/mount.rs diff --git a/Cargo.lock b/Cargo.lock index 1662cd3..8ece471 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,9 +58,15 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "async-trait" version = "0.1.89" @@ -72,6 +78,12 @@ dependencies = [ "syn", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.11.1" @@ -90,6 +102,18 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.6.1" @@ -209,7 +233,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "fuser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80a5eca878900c2e39e9e52fd797954b7fc39eeefc8558257114bfea6a698fcf" +dependencies = [ + "bitflags", + "libc", + "log", + "memchr", + "nix", + "num_enum", + "page_size", + "parking_lot", + "pkg-config", + "ref-cast", + "smallvec", + "zerocopy", ] [[package]] @@ -306,6 +371,28 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.0" @@ -318,6 +405,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.14.0" @@ -325,7 +418,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", ] [[package]] @@ -374,6 +489,32 @@ dependencies = [ "syn", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" @@ -386,6 +527,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -407,6 +557,46 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mtp-mount" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490e05ede6bf5ae1a3ede376f24cc40dcef45531104b09c214b96294ff773738" +dependencies = [ + "bytes", + "clap", + "env_logger", + "fuser", + "futures", + "libc", + "log", + "mtp-rs", + "tempfile", + "thiserror", + "tokio", +] + [[package]] name = "mtp-rs" version = "0.13.3" @@ -417,11 +607,52 @@ dependencies = [ "bytes", "futures", "futures-timer", + "notify", "num_enum", "nusb", "thiserror", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -459,7 +690,7 @@ dependencies = [ "once_cell", "rustix", "slab", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -474,12 +705,51 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -495,6 +765,16 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -522,6 +802,41 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -561,7 +876,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -570,6 +885,27 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -619,6 +955,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "strsim" version = "0.11.1" @@ -636,6 +978,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -663,6 +1018,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" dependencies = [ "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -703,8 +1070,10 @@ dependencies = [ "clap", "coremidi", "env_logger", + "fuser", "futures", "log", + "mtp-mount", "mtp-rs", "nusb", "serde", @@ -719,18 +1088,132 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -740,6 +1223,71 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.2" @@ -749,6 +1297,120 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index fb05693..b7cf7e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,8 +16,10 @@ categories = ["command-line-utilities"] bytes = "1.5" clap = { version = "4.6.1", features = ["derive"] } env_logger = "0.11.10" +fuser = { version = "0.17", optional = true } futures = "0.3" log = "0.4.29" +mtp-mount = { version = "0.3.1", optional = true } mtp-rs = "0.13.3" nusb = "0.2.3" serde = { version = "1.0.228", features = ["derive"] } @@ -25,6 +27,10 @@ serde_json = "1.0.149" thiserror = "2.0.18" tokio = { version = "1.52.2", features = ["rt"] } +[features] +default = [] +finder-mount = ["dep:fuser", "dep:mtp-mount", "tokio/rt-multi-thread", "tokio/time"] + [target.'cfg(target_os = "macos")'.dependencies] coremidi = "0.9.0" diff --git a/README.md b/README.md index 12ace47..d4b418b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TP-7 CLI -`tp7` is a macOS command-line tool for browsing and moving files on a Teenage Engineering TP-7 field recorder without relying on FieldKit, Android File Transfer, or Finder mounting. +`tp7` is a macOS command-line tool for browsing and moving files on a Teenage Engineering TP-7 field recorder without relying on FieldKit or Android File Transfer. Direct file commands need no Finder mount; optional Finder mounting is available for source builds with FUSE support enabled. The TP-7 normally appears as a USB audio/MIDI device. `tp7` can send the device-specific MIDI mode switch, wait for the recorder to re-enumerate as MTP, open a direct MTP session, perform the file operation, and close the session again. @@ -21,11 +21,14 @@ tp7 -a pull /recordings ./recordings --recursive --skip-existing tp7 -a push ./clip.wav /memo/clip.wav --dry-run tp7 -a push ./clip.wav /memo/clip.wav --overwrite tp7 -a rm /memo/clip.wav --dry-run +mkdir -p ~/TP-7 +tp7 -a mount ~/TP-7 +tp7 unmount ~/TP-7 ``` For normal use, prefer `-a` / `--auto-connect`. Each command then handles the full TP-7 lifecycle: detect the recorder, switch to MTP if needed, open MTP, do the operation, and close cleanly. -`tp7 connect` and `tp7 eject` are diagnostic/manual-control commands. They are useful for checking whether MTP can be opened and released, but they are not a "mount once, run many commands, eject later" workflow. +`tp7 connect` and `tp7 eject` are diagnostic/manual-control commands. They are useful for checking whether MTP can be opened and released. For Finder access, use `tp7 mount`; it keeps the MTP session open until Finder, `diskutil`, `umount`, or `tp7 unmount` unmounts the volume. ## Command surface @@ -42,6 +45,8 @@ push Upload a file or directory to the TP-7 mkdir Create a remote folder rm Delete a remote file or folder rename Rename a remote object without moving it +mount Mount the TP-7 as a read-only Finder filesystem +unmount Unmount a mounted TP-7 filesystem eject Open and close an MTP session cleanly ``` @@ -91,15 +96,16 @@ The TP-7 firmware tested here (`1.1.9`) accepts file upload, rename, delete, and - `push --recursive` uploads into an existing remote folder tree only. - Missing remote folders are detected before any recursive upload starts. -This is a direct MTP CLI, not a Finder mount. A future FUSE mount is documented as a separate research track in `docs/spec.md`. +Finder mounting is read-only in the first implementation. It requires a binary built with the `finder-mount` feature plus macFUSE or Fuse-T development files at build time and a working FUSE runtime at mount time. ## Local requirements - macOS - A Teenage Engineering TP-7 connected over USB - Rust 1.88 or newer for source builds and development +- Optional for Finder mounting: macFUSE or Fuse-T with FUSE `pkg-config` metadata available -No Android File Transfer, FieldKit, libmtp, or kernel extension is required for the direct CLI workflow. +No Android File Transfer, FieldKit, libmtp, or kernel extension is required for the direct CLI workflow. Finder mounting uses FUSE and is separate from the direct MTP commands. ## Install @@ -117,10 +123,17 @@ From this repository: cargo install --path . --locked ``` +To build Finder mount support from source after installing a FUSE runtime: + +```sh +cargo install --path . --locked --features finder-mount +``` + Or during development: ```sh cargo run -- -a ls -lah / +cargo run --features finder-mount -- -a mount ~/TP-7 ``` ## Development @@ -133,6 +146,7 @@ cargo check cargo clippy -- -D warnings cargo test cargo run -- --help +cargo check --features finder-mount,fuser/macos-no-mount ``` When the TP-7 is connected and write behavior changes, run the hardware smoke script. It creates only tiny temporary text files under `/memo` by default: diff --git a/docs/spec.md b/docs/spec.md index be442fd..32cda7d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -356,15 +356,37 @@ Open and close an MTP session cleanly. This validates that the CLI can release the device, but it does not force the TP-7 back to audio/MIDI mode. If the reverse mode-switch command is discovered later, this can optionally use it. -### Future Commands - `tp7 mount ` -Mount the TP-7 as a read-only filesystem. Requires macFUSE or Fuse-T. +Mount the TP-7 as a read-only Finder-visible filesystem. The command keeps +running as the userspace filesystem server. If the user unmounts the volume +from Finder, `diskutil`, or `umount`, the command exits cleanly and does not +depend on stored CLI state. + +Implementation notes: + +- The mount point must already exist and be a directory. +- Mount support is gated behind the `finder-mount` Cargo feature because the + Rust FUSE stack requires macFUSE/Fuse-T development metadata on macOS. +- The implementation uses the same TP-7 MTP mode switch and open-session path + as the direct file commands, then hands the opened MTP device to a FUSE + filesystem for the lifetime of the mount. +- The mounted filesystem is read-only for the initial implementation. + +`tp7 unmount ` + +Stateless OS unmount wrapper. It does not read or write PID files or mount +registries. It inspects the current mount table and runs `diskutil unmount`, +falling back to `umount`; if the path is already unmounted, it reports that +without treating it as a CLI state error. + +### Future Commands `tp7 mount --read-write` -Read-write mount with local write staging and upload-on-close semantics. +Read-write mount with local write staging and upload-on-close semantics. This +remains future work until TP-7 folder creation, Finder write patterns, and +large-file replacement behavior are validated carefully. `tp7 sync ` @@ -400,7 +422,7 @@ Current choices: - Retry transient CoreMIDI endpoint discovery during `--auto-connect` for the same 12-second window used for MTP visibility because the USB device can reappear before its MIDI endpoints are ready. -- Keep v1 mount-free. +- Keep Finder mounting read-only until write semantics are validated. ## Implementation Plan @@ -483,14 +505,15 @@ Deliverable: - `tp7 eject` (implemented as MTP open/close validation) - dry-run behavior -### Phase 7: Mount Research +### Phase 7: Mount Prototype a read-only FUSE mount after the direct MTP CLI is stable. Deliverable: -- decision between macFUSE and Fuse-T -- read-only `tp7 mount ` prototype +- `tp7 mount ` source-build feature using `fuser`/`mtp-mount` +- Finder-visible read-only volume +- stateless `tp7 unmount ` wrapper ## Risks @@ -516,7 +539,10 @@ Library risk: Mount risk: -- Finder-style mounting is a separate product surface with caching, partial writes, metadata, and macOS FUSE distribution concerns. +- Finder-style mounting is a separate product surface with caching, metadata, + macOS FUSE distribution concerns, and a build-time FUSE metadata dependency. +- Read-write Finder mounting remains risky because Finder write patterns and + MTP object-write semantics do not match normal block filesystem behavior. ## References diff --git a/src/cli.rs b/src/cli.rs index 1d4d91b..64a846e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -87,6 +87,12 @@ pub enum Command { #[command(about = "Rename a remote object without moving it")] Rename(RenameArgs), + #[command(about = "Mount the TP-7 as a read-only Finder filesystem")] + Mount(MountArgs), + + #[command(about = "Unmount a mounted TP-7 filesystem")] + Unmount(UnmountArgs), + #[command(about = "Open and close an MTP session cleanly")] Eject, } @@ -221,3 +227,21 @@ pub struct RenameArgs { #[arg(help = "New name in the same remote folder")] pub new_name: String, } + +#[derive(Debug, Args)] +pub struct MountArgs { + #[arg(help = "Existing local directory to use as the mount point")] + pub mountpoint: String, + + #[arg(long = "no-open", help = "Do not open the mounted volume in Finder")] + pub no_open: bool, +} + +#[derive(Debug, Args)] +pub struct UnmountArgs { + #[arg(help = "Local mount point to unmount")] + pub mountpoint: String, + + #[arg(short, long, help = "Force the OS unmount")] + pub force: bool, +} diff --git a/src/lib.rs b/src/lib.rs index eda111c..229a6b3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ mod doctor; mod eject; mod ls; mod midi; +mod mount; mod mtp_session; mod output; mod pull; @@ -153,6 +154,20 @@ pub fn run() -> Result<(), AppError> { )?; output::write_rename(&report, cli.json) } + Command::Mount(args) => { + let report = mount::run_mount( + cli.device.as_deref(), + cli.auto_connect, + args.mountpoint.as_str(), + !args.no_open, + !cli.json, + )?; + output::write_mount(&report, cli.json) + } + Command::Unmount(args) => { + let report = mount::run_unmount(args.mountpoint.as_str(), args.force)?; + output::write_unmount(&report, cli.json) + } Command::Eject => { let report = eject::run_eject(cli.device.as_deref(), cli.auto_connect)?; output::write_eject(&report, cli.json) diff --git a/src/mount.rs b/src/mount.rs new file mode 100644 index 0000000..e5a9c43 --- /dev/null +++ b/src/mount.rs @@ -0,0 +1,385 @@ +#[cfg(any(feature = "finder-mount", test))] +use std::io; +#[cfg(feature = "finder-mount")] +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[cfg(feature = "finder-mount")] +use fuser::{Config, MountOption}; +#[cfg(feature = "finder-mount")] +use mtp_mount::fs::MtpFs; +use serde::Serialize; + +#[cfg(not(feature = "finder-mount"))] +use crate::device::UsbMode; +#[cfg(feature = "finder-mount")] +use crate::device::{Tp7Device, UsbMode}; +#[cfg(feature = "finder-mount")] +use crate::mtp_session::{MtpOpenPolicy, open_mtp_session}; +use crate::output::AppError; + +#[derive(Debug, Clone, Serialize)] +pub struct MountReport { + pub mountpoint: String, + pub serial_number: Option, + pub initial_mode: UsbMode, + pub final_mode: UsbMode, + pub read_only: bool, + pub opened_finder: bool, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct UnmountReport { + pub mountpoint: String, + pub force: bool, + pub unmounted: bool, + pub message: String, +} + +pub fn run_mount( + serial: Option<&str>, + auto_connect: bool, + mountpoint: &str, + open_finder: bool, + human_status: bool, +) -> Result { + run_mount_impl(serial, auto_connect, mountpoint, open_finder, human_status) +} + +#[cfg(feature = "finder-mount")] +fn run_mount_impl( + serial: Option<&str>, + auto_connect: bool, + mountpoint: &str, + open_finder: bool, + human_status: bool, +) -> Result { + let mountpoint = existing_mountpoint_dir(mountpoint)?; + let policy = if auto_connect { + MtpOpenPolicy::AutoSwitch + } else { + MtpOpenPolicy::RequireAutoConnectFlag + }; + + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .map_err(|error| AppError::Runtime { + message: error.to_string(), + })?; + + let session = runtime.block_on(open_mtp_session(serial, policy))?; + let prepared = session.prepared; + let device = session.device; + let mountpoint_label = path_to_string(&mountpoint); + let serial_number = prepared.usb.serial_number.clone(); + let initial_mode = prepared.initial_usb.mode.clone(); + let final_mode = prepared.usb.mode.clone(); + + let mtp_fs = MtpFs::new(device, true, runtime.handle().clone()); + let mut config = Config::default(); + config.mount_options = mount_options(&mtp_fs, &prepared.usb); + + let background = fuser::spawn_mount2(mtp_fs, &mountpoint, &config).map_err(|error| { + AppError::Mount { + message: format!( + "failed to mount {mountpoint_label}: {error}. Install macFUSE or Fuse-T if no FUSE runtime is available." + ), + } + })?; + + let opened_finder = if open_finder { + open_mountpoint_in_finder(&mountpoint, human_status) + } else { + false + }; + + if human_status { + println!("mounted TP-7 at {mountpoint_label} (read-only)"); + println!("unmount from Finder or run: tp7 unmount {mountpoint_label}"); + let _ = io::stdout().flush(); + } + + let join_result = background.join(); + let message = match join_result { + Ok(()) => "Unmounted by the OS.".to_string(), + Err(error) if is_graceful_mount_end(&error) => "Unmounted by the OS.".to_string(), + Err(error) => { + return Err(AppError::Mount { + message: format!("mounted filesystem ended unexpectedly: {error}"), + }); + } + }; + + Ok(MountReport { + mountpoint: mountpoint_label, + serial_number, + initial_mode, + final_mode, + read_only: true, + opened_finder, + message, + }) +} + +#[cfg(not(feature = "finder-mount"))] +fn run_mount_impl( + _serial: Option<&str>, + _auto_connect: bool, + mountpoint: &str, + _open_finder: bool, + _human_status: bool, +) -> Result { + let _ = existing_mountpoint_dir(mountpoint)?; + Err(AppError::Mount { + message: "this binary was built without Finder mount support; rebuild with `--features finder-mount` after installing macFUSE or Fuse-T development files".to_string(), + }) +} + +pub fn run_unmount(mountpoint: &str, force: bool) -> Result { + let mountpoint = existing_path(mountpoint)?; + let mountpoint_label = path_to_string(&mountpoint); + + if !is_mounted_at(&mountpoint)? { + return Ok(UnmountReport { + mountpoint: mountpoint_label, + force, + unmounted: false, + message: "Mount point is not currently mounted.".to_string(), + }); + } + + match run_diskutil_unmount(&mountpoint, force) { + Ok(()) => Ok(UnmountReport { + mountpoint: mountpoint_label, + force, + unmounted: true, + message: "Unmounted.".to_string(), + }), + Err(diskutil_error) => { + if !is_mounted_at(&mountpoint)? { + return Ok(UnmountReport { + mountpoint: mountpoint_label, + force, + unmounted: true, + message: "Unmounted.".to_string(), + }); + } + + match run_umount(&mountpoint, force) { + Ok(()) => Ok(UnmountReport { + mountpoint: mountpoint_label, + force, + unmounted: true, + message: "Unmounted.".to_string(), + }), + Err(umount_error) => Err(AppError::Unmount { + message: format!( + "failed to unmount {mountpoint_label} with diskutil ({diskutil_error}) or umount ({umount_error})" + ), + }), + } + } + } +} + +#[cfg(feature = "finder-mount")] +fn mount_options(fs: &MtpFs, device: &Tp7Device) -> Vec { + let mut options = fs.mount_options(); + options.retain(|option| !matches!(option, MountOption::FSName(_) | MountOption::Subtype(_))); + + let fs_name = match device.serial_number.as_deref() { + Some(serial) => format!("tp7:{serial}"), + None => "tp7".to_string(), + }; + + options.push(MountOption::FSName(fs_name)); + options.push(MountOption::Subtype("mtp".to_string())); + options.push(MountOption::CUSTOM("volname=TP-7".to_string())); + options +} + +fn existing_mountpoint_dir(path: &str) -> Result { + let path = existing_path(path)?; + if !path.is_dir() { + return Err(AppError::FileSystem { + path: path_to_string(&path), + message: "mount point must be a directory".to_string(), + }); + } + + Ok(path) +} + +fn existing_path(path: &str) -> Result { + let path = absolute_path(path)?; + if !path.exists() { + return Err(AppError::FileSystem { + path: path_to_string(&path), + message: "path does not exist".to_string(), + }); + } + + path.canonicalize().map_err(|error| AppError::FileSystem { + path: path_to_string(&path), + message: error.to_string(), + }) +} + +fn absolute_path(path: &str) -> Result { + let path = PathBuf::from(path); + if path.is_absolute() { + return Ok(path); + } + + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .map_err(|error| AppError::FileSystem { + path: ".".to_string(), + message: error.to_string(), + }) +} + +#[cfg(feature = "finder-mount")] +fn open_mountpoint_in_finder(path: &Path, human_status: bool) -> bool { + #[cfg(target_os = "macos")] + { + match Command::new("open").arg(path).status() { + Ok(status) if status.success() => true, + Ok(status) => { + if human_status { + eprintln!( + "warning: could not open Finder for {}: {status}", + path.display() + ); + } + false + } + Err(error) => { + if human_status { + eprintln!( + "warning: could not open Finder for {}: {error}", + path.display() + ); + } + false + } + } + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (path, human_status); + false + } +} + +fn run_diskutil_unmount(path: &Path, force: bool) -> Result<(), String> { + let mut command = Command::new("diskutil"); + command.arg("unmount"); + if force { + command.arg("force"); + } + command.arg(path); + run_unmount_command(command) +} + +fn run_umount(path: &Path, force: bool) -> Result<(), String> { + let mut command = Command::new("umount"); + if force { + command.arg("-f"); + } + command.arg(path); + run_unmount_command(command) +} + +fn run_unmount_command(mut command: Command) -> Result<(), String> { + let output = command.output().map_err(|error| error.to_string())?; + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + format!("exited with {}", output.status) + }; + + Err(detail) +} + +fn is_mounted_at(path: &Path) -> Result { + let output = Command::new("mount") + .output() + .map_err(|error| AppError::Unmount { + message: format!("failed to inspect mounted filesystems: {error}"), + })?; + + if !output.status.success() { + return Err(AppError::Unmount { + message: format!("mount exited with {}", output.status), + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(mount_output_has_mountpoint(&stdout, path)) +} + +fn mount_output_has_mountpoint(output: &str, path: &Path) -> bool { + let needle = format!(" on {} (", path.display()); + output.lines().any(|line| line.contains(&needle)) +} + +#[cfg(any(feature = "finder-mount", test))] +fn is_graceful_mount_end(error: &io::Error) -> bool { + let message = error.to_string().to_lowercase(); + message.contains("not mounted") + || message.contains("unmounted") + || message.contains("transport endpoint is not connected") + || message.contains("device not configured") + || message.contains("no such file or directory") + || message.contains("invalid argument") +} + +fn path_to_string(path: &Path) -> String { + path.to_string_lossy().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_mountpoint_in_mount_output() { + let output = "/dev/disk3s1 on /System/Volumes/Data (apfs, local)\ntp7:F1RTL11C on /Users/toto/TP-7 (mtp, nodev, nosuid, read-only)\n"; + + assert!(mount_output_has_mountpoint( + output, + Path::new("/Users/toto/TP-7") + )); + } + + #[test] + fn ignores_prefix_mountpoint_matches() { + let output = "tp7:F1RTL11C on /Users/toto/TP-7-backup (mtp, nodev, nosuid, read-only)\n"; + + assert!(!mount_output_has_mountpoint( + output, + Path::new("/Users/toto/TP-7") + )); + } + + #[test] + fn treats_common_unmount_errors_as_graceful() { + let error = io::Error::other("mount point is not mounted"); + + assert!(is_graceful_mount_end(&error)); + } +} diff --git a/src/output.rs b/src/output.rs index c494f7b..c6048fc 100644 --- a/src/output.rs +++ b/src/output.rs @@ -6,6 +6,7 @@ use crate::device::{Tp7Device, interface_summary}; use crate::doctor::{DoctorReport, ProcessConflict}; use crate::eject::EjectReport; use crate::ls::{LsEntry, LsReport}; +use crate::mount::{MountReport, UnmountReport}; use crate::pull::{PullReport, PullStatus}; use crate::push::{PushReport, PushStatus}; use crate::remote::ObjectKind; @@ -114,6 +115,12 @@ pub enum AppError { #[error("MTP device is busy or owned by another process: {message}")] MtpExclusiveAccess { message: String }, + #[error("mount failed: {message}")] + Mount { message: String }, + + #[error("unmount failed: {message}")] + Unmount { message: String }, + #[error("MIDI operation failed: {message}")] Midi { message: String }, @@ -173,6 +180,8 @@ impl AppError { | AppError::TransferVerification { .. } | AppError::Mtp { .. } | AppError::MtpUnsupported { .. } + | AppError::Mount { .. } + | AppError::Unmount { .. } | AppError::Runtime { .. } | AppError::Json { .. } => 1, } @@ -517,6 +526,32 @@ pub fn write_eject(report: &EjectReport, json: bool) -> Result<(), AppError> { Ok(()) } +pub fn write_mount(report: &MountReport, json: bool) -> Result<(), AppError> { + if json { + write_json(report)?; + return Ok(()); + } + + println!("unmounted {} ({})", report.mountpoint, report.message); + + Ok(()) +} + +pub fn write_unmount(report: &UnmountReport, json: bool) -> Result<(), AppError> { + if json { + write_json(report)?; + return Ok(()); + } + + if report.unmounted { + println!("unmounted {}", report.mountpoint); + } else { + println!("not mounted {}", report.mountpoint); + } + + Ok(()) +} + pub fn write_doctor(report: &DoctorReport, json: bool) -> Result<(), AppError> { if json { write_json(report)?; From fa71e401d5d534ac8c66a5b7bed38cf26f9f7c38 Mon Sep 17 00:00:00 2001 From: Toto Tvalavadze Date: Thu, 7 May 2026 15:53:37 +0400 Subject: [PATCH 2/5] feat: make Finder mount default --- .github/workflows/ci.yml | 7 + .github/workflows/release.yml | 12 ++ AGENTS.md | 4 + CONTRIBUTING.md | 2 + Cargo.toml | 10 +- README.md | 24 ++-- docs/spec.md | 24 ++-- src/cli.rs | 8 +- src/lib.rs | 4 +- src/mount.rs | 255 +++++++++++++++++++++++++--------- 10 files changed, 250 insertions(+), 100 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1892924..07f7e24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,8 @@ on: env: CARGO_TERM_COLOR: always RUST_VERSION: 1.88.0 + PKG_CONFIG_PATH: /usr/local/lib/pkgconfig:/opt/homebrew/lib/pkgconfig + PKG_CONFIG_ALLOW_CROSS: "1" jobs: build: @@ -25,6 +27,11 @@ jobs: override: true components: rustfmt, clippy + - name: Install FUSE build dependencies + run: | + brew install pkgconf + brew install --cask macfuse + - name: Cache cargo registry uses: actions/cache@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5fae70..0a1aae2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,8 @@ env: CARGO_TERM_COLOR: always RUST_VERSION: 1.88.0 BIN_NAME: tp7 + PKG_CONFIG_PATH: /usr/local/lib/pkgconfig:/opt/homebrew/lib/pkgconfig + PKG_CONFIG_ALLOW_CROSS: "1" jobs: verify: @@ -25,6 +27,11 @@ jobs: override: true components: rustfmt, clippy + - name: Install FUSE build dependencies + run: | + brew install pkgconf + brew install --cask macfuse + - name: Cache cargo registry uses: actions/cache@v4 with: @@ -77,6 +84,11 @@ jobs: - name: Add compilation targets run: rustup target add aarch64-apple-darwin x86_64-apple-darwin + - name: Install FUSE build dependencies + run: | + brew install pkgconf + brew install --cask macfuse + - name: Cache cargo registry uses: actions/cache@v4 with: diff --git a/AGENTS.md b/AGENTS.md index a81350b..f527286 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,10 @@ - `cargo clippy -- -D warnings` - `cargo test` - at least one relevant `cargo run -- ...` command against the current feature. +- Finder mounting is part of the default binary. On macOS, the default Rust + build requires macFUSE or Fuse-T development metadata (`fuse.pc`). If that is + unavailable, report the blocker and use `--features fuser/macos-no-mount` + only as a compile-only fallback; it does not validate real mounting. - When the TP-7 is connected and write-path behavior changes, prefer `scripts/hardware-smoke.sh` for the device smoke. It uses `/memo` by default; override with `TP7_SMOKE_REMOTE_DIR=/existing/folder` if needed. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f198f01..0a3b777 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,8 @@ Thanks for considering a contribution. `tp7` is a small hardware-facing CLI, so - Read the top-level `README.md` for user-facing behavior and install instructions. - Review `docs/spec.md` and `docs/tp7-handshake.md` before changing protocol behavior. - Use the pinned Rust toolchain from `rust-toolchain.toml`. +- Install macFUSE or Fuse-T development metadata before building the default + macOS target, because Finder mounting is part of the normal binary. ## Development Workflow diff --git a/Cargo.toml b/Cargo.toml index b7cf7e6..fb2ecbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,20 +16,16 @@ categories = ["command-line-utilities"] bytes = "1.5" clap = { version = "4.6.1", features = ["derive"] } env_logger = "0.11.10" -fuser = { version = "0.17", optional = true } +fuser = "0.17" futures = "0.3" log = "0.4.29" -mtp-mount = { version = "0.3.1", optional = true } +mtp-mount = "0.3.1" mtp-rs = "0.13.3" nusb = "0.2.3" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" thiserror = "2.0.18" -tokio = { version = "1.52.2", features = ["rt"] } - -[features] -default = [] -finder-mount = ["dep:fuser", "dep:mtp-mount", "tokio/rt-multi-thread", "tokio/time"] +tokio = { version = "1.52.2", features = ["rt", "rt-multi-thread", "time"] } [target.'cfg(target_os = "macos")'.dependencies] coremidi = "0.9.0" diff --git a/README.md b/README.md index d4b418b..6ad1d4b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TP-7 CLI -`tp7` is a macOS command-line tool for browsing and moving files on a Teenage Engineering TP-7 field recorder without relying on FieldKit or Android File Transfer. Direct file commands need no Finder mount; optional Finder mounting is available for source builds with FUSE support enabled. +`tp7` is a macOS command-line tool for browsing and moving files on a Teenage Engineering TP-7 field recorder without relying on FieldKit or Android File Transfer. Direct file commands need no Finder mount; Finder mounting is available when macFUSE or Fuse-T is installed. The TP-7 normally appears as a USB audio/MIDI device. `tp7` can send the device-specific MIDI mode switch, wait for the recorder to re-enumerate as MTP, open a direct MTP session, perform the file operation, and close the session again. @@ -21,9 +21,8 @@ tp7 -a pull /recordings ./recordings --recursive --skip-existing tp7 -a push ./clip.wav /memo/clip.wav --dry-run tp7 -a push ./clip.wav /memo/clip.wav --overwrite tp7 -a rm /memo/clip.wav --dry-run -mkdir -p ~/TP-7 -tp7 -a mount ~/TP-7 -tp7 unmount ~/TP-7 +tp7 -a mount +tp7 unmount ``` For normal use, prefer `-a` / `--auto-connect`. Each command then handles the full TP-7 lifecycle: detect the recorder, switch to MTP if needed, open MTP, do the operation, and close cleanly. @@ -96,14 +95,14 @@ The TP-7 firmware tested here (`1.1.9`) accepts file upload, rename, delete, and - `push --recursive` uploads into an existing remote folder tree only. - Missing remote folders are detected before any recursive upload starts. -Finder mounting is read-only in the first implementation. It requires a binary built with the `finder-mount` feature plus macFUSE or Fuse-T development files at build time and a working FUSE runtime at mount time. +Finder mounting is read-only in the first implementation. By default `tp7 mount` uses `/Volumes/TP-7`, creating it when permissions allow. If `/Volumes/TP-7` is already in use, it tries `/Volumes/TP-7-2`, `/Volumes/TP-7-3`, and so on. You can also pass your own empty directory. ## Local requirements - macOS - A Teenage Engineering TP-7 connected over USB - Rust 1.88 or newer for source builds and development -- Optional for Finder mounting: macFUSE or Fuse-T with FUSE `pkg-config` metadata available +- For Finder mounting: macFUSE or Fuse-T with FUSE `pkg-config` metadata available at build time and a working FUSE runtime at mount time No Android File Transfer, FieldKit, libmtp, or kernel extension is required for the direct CLI workflow. Finder mounting uses FUSE and is separate from the direct MTP commands. @@ -123,17 +122,11 @@ From this repository: cargo install --path . --locked ``` -To build Finder mount support from source after installing a FUSE runtime: - -```sh -cargo install --path . --locked --features finder-mount -``` - Or during development: ```sh cargo run -- -a ls -lah / -cargo run --features finder-mount -- -a mount ~/TP-7 +cargo run -- -a mount ``` ## Development @@ -146,9 +139,12 @@ cargo check cargo clippy -- -D warnings cargo test cargo run -- --help -cargo check --features finder-mount,fuser/macos-no-mount ``` +On macOS machines without FUSE development metadata, Rust builds that compile +the mount code will fail until macFUSE or Fuse-T exposes `fuse.pc` to +`pkg-config`. + When the TP-7 is connected and write behavior changes, run the hardware smoke script. It creates only tiny temporary text files under `/memo` by default: ```sh diff --git a/docs/spec.md b/docs/spec.md index 32cda7d..ff87531 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -356,7 +356,7 @@ Open and close an MTP session cleanly. This validates that the CLI can release the device, but it does not force the TP-7 back to audio/MIDI mode. If the reverse mode-switch command is discovered later, this can optionally use it. -`tp7 mount ` +`tp7 mount [mountpoint]` Mount the TP-7 as a read-only Finder-visible filesystem. The command keeps running as the userspace filesystem server. If the user unmounts the volume @@ -365,20 +365,24 @@ depend on stored CLI state. Implementation notes: -- The mount point must already exist and be a directory. -- Mount support is gated behind the `finder-mount` Cargo feature because the - Rust FUSE stack requires macFUSE/Fuse-T development metadata on macOS. +- With no mount point, the CLI uses `/Volumes/TP-7`. If that path is already + mounted or unavailable, it tries numbered siblings such as `/Volumes/TP-7-2`. +- An explicit mount point is created when missing, but must be an empty + directory before mounting. +- The Rust FUSE stack requires macFUSE/Fuse-T development metadata at build + time on macOS and a working FUSE runtime at mount time. - The implementation uses the same TP-7 MTP mode switch and open-session path as the direct file commands, then hands the opened MTP device to a FUSE filesystem for the lifetime of the mount. - The mounted filesystem is read-only for the initial implementation. -`tp7 unmount ` +`tp7 unmount [mountpoint]` Stateless OS unmount wrapper. It does not read or write PID files or mount -registries. It inspects the current mount table and runs `diskutil unmount`, -falling back to `umount`; if the path is already unmounted, it reports that -without treating it as a CLI state error. +registries. With no mount point, it inspects the current mount table and +unmounts the single mounted TP-7 volume. With an explicit path, it runs +`diskutil unmount`, falling back to `umount`; if the path is already unmounted, +it reports that without treating it as a CLI state error. ### Future Commands @@ -511,9 +515,9 @@ Prototype a read-only FUSE mount after the direct MTP CLI is stable. Deliverable: -- `tp7 mount ` source-build feature using `fuser`/`mtp-mount` +- `tp7 mount [mountpoint]` using `fuser`/`mtp-mount` - Finder-visible read-only volume -- stateless `tp7 unmount ` wrapper +- stateless `tp7 unmount [mountpoint]` wrapper ## Risks diff --git a/src/cli.rs b/src/cli.rs index 64a846e..004414b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -230,8 +230,8 @@ pub struct RenameArgs { #[derive(Debug, Args)] pub struct MountArgs { - #[arg(help = "Existing local directory to use as the mount point")] - pub mountpoint: String, + #[arg(help = "Local mount point; defaults to /Volumes/TP-7")] + pub mountpoint: Option, #[arg(long = "no-open", help = "Do not open the mounted volume in Finder")] pub no_open: bool, @@ -239,8 +239,8 @@ pub struct MountArgs { #[derive(Debug, Args)] pub struct UnmountArgs { - #[arg(help = "Local mount point to unmount")] - pub mountpoint: String, + #[arg(help = "Local mount point to unmount; defaults to the mounted TP-7 volume")] + pub mountpoint: Option, #[arg(short, long, help = "Force the OS unmount")] pub force: bool, diff --git a/src/lib.rs b/src/lib.rs index 229a6b3..fd48e02 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -158,14 +158,14 @@ pub fn run() -> Result<(), AppError> { let report = mount::run_mount( cli.device.as_deref(), cli.auto_connect, - args.mountpoint.as_str(), + args.mountpoint.as_deref(), !args.no_open, !cli.json, )?; output::write_mount(&report, cli.json) } Command::Unmount(args) => { - let report = mount::run_unmount(args.mountpoint.as_str(), args.force)?; + let report = mount::run_unmount(args.mountpoint.as_deref(), args.force)?; output::write_unmount(&report, cli.json) } Command::Eject => { diff --git a/src/mount.rs b/src/mount.rs index e5a9c43..247ced7 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -1,24 +1,20 @@ -#[cfg(any(feature = "finder-mount", test))] -use std::io; -#[cfg(feature = "finder-mount")] -use std::io::Write; +use std::fs; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::Command; -#[cfg(feature = "finder-mount")] use fuser::{Config, MountOption}; -#[cfg(feature = "finder-mount")] use mtp_mount::fs::MtpFs; use serde::Serialize; -#[cfg(not(feature = "finder-mount"))] -use crate::device::UsbMode; -#[cfg(feature = "finder-mount")] use crate::device::{Tp7Device, UsbMode}; -#[cfg(feature = "finder-mount")] use crate::mtp_session::{MtpOpenPolicy, open_mtp_session}; use crate::output::AppError; +const DEFAULT_MOUNTPOINT: &str = "/Volumes/TP-7"; +const DEFAULT_MOUNTPOINT_PREFIX: &str = "/Volumes/TP-7"; +const MAX_DEFAULT_MOUNTPOINT_ATTEMPTS: usize = 99; + #[derive(Debug, Clone, Serialize)] pub struct MountReport { pub mountpoint: String, @@ -41,22 +37,11 @@ pub struct UnmountReport { pub fn run_mount( serial: Option<&str>, auto_connect: bool, - mountpoint: &str, + mountpoint: Option<&str>, open_finder: bool, human_status: bool, ) -> Result { - run_mount_impl(serial, auto_connect, mountpoint, open_finder, human_status) -} - -#[cfg(feature = "finder-mount")] -fn run_mount_impl( - serial: Option<&str>, - auto_connect: bool, - mountpoint: &str, - open_finder: bool, - human_status: bool, -) -> Result { - let mountpoint = existing_mountpoint_dir(mountpoint)?; + let mountpoint = prepare_mountpoint(mountpoint)?; let policy = if auto_connect { MtpOpenPolicy::AutoSwitch } else { @@ -125,25 +110,11 @@ fn run_mount_impl( }) } -#[cfg(not(feature = "finder-mount"))] -fn run_mount_impl( - _serial: Option<&str>, - _auto_connect: bool, - mountpoint: &str, - _open_finder: bool, - _human_status: bool, -) -> Result { - let _ = existing_mountpoint_dir(mountpoint)?; - Err(AppError::Mount { - message: "this binary was built without Finder mount support; rebuild with `--features finder-mount` after installing macFUSE or Fuse-T development files".to_string(), - }) -} - -pub fn run_unmount(mountpoint: &str, force: bool) -> Result { - let mountpoint = existing_path(mountpoint)?; +pub fn run_unmount(mountpoint: Option<&str>, force: bool) -> Result { + let mountpoint = resolve_unmount_mountpoint(mountpoint)?; let mountpoint_label = path_to_string(&mountpoint); - if !is_mounted_at(&mountpoint)? { + if !is_mounted_at(&mountpoint).map_err(|message| AppError::Unmount { message })? { return Ok(UnmountReport { mountpoint: mountpoint_label, force, @@ -160,7 +131,7 @@ pub fn run_unmount(mountpoint: &str, force: bool) -> Result { - if !is_mounted_at(&mountpoint)? { + if !is_mounted_at(&mountpoint).map_err(|message| AppError::Unmount { message })? { return Ok(UnmountReport { mountpoint: mountpoint_label, force, @@ -186,7 +157,6 @@ pub fn run_unmount(mountpoint: &str, force: bool) -> Result Vec { let mut options = fs.mount_options(); options.retain(|option| !matches!(option, MountOption::FSName(_) | MountOption::Subtype(_))); @@ -202,29 +172,147 @@ fn mount_options(fs: &MtpFs, device: &Tp7Device) -> Vec { options } -fn existing_mountpoint_dir(path: &str) -> Result { - let path = existing_path(path)?; +fn prepare_mountpoint(path: Option<&str>) -> Result { + match path { + Some(path) => prepare_explicit_mountpoint(path), + None => prepare_default_mountpoint(), + } +} + +fn prepare_explicit_mountpoint(path: &str) -> Result { + let path = absolute_path(path)?; + if !path.exists() { + return create_mountpoint_dir(&path, false); + } + + let path = canonicalize_path(&path)?; + validate_available_mountpoint(&path, false)?; + Ok(path) +} + +fn prepare_default_mountpoint() -> Result { + for index in 1..=MAX_DEFAULT_MOUNTPOINT_ATTEMPTS { + let candidate = default_mountpoint_candidate(index); + if !candidate.exists() { + return create_mountpoint_dir(&candidate, true); + } + + let Ok(candidate) = canonicalize_path(&candidate) else { + continue; + }; + if validate_available_mountpoint(&candidate, true).is_ok() { + return Ok(candidate); + } + } + + Err(AppError::Mount { + message: format!( + "no available default mount point found under {DEFAULT_MOUNTPOINT_PREFIX}; pass an explicit mount point" + ), + }) +} + +fn default_mountpoint_candidate(index: usize) -> PathBuf { + if index == 1 { + PathBuf::from(DEFAULT_MOUNTPOINT) + } else { + PathBuf::from(format!("{DEFAULT_MOUNTPOINT_PREFIX}-{index}")) + } +} + +fn create_mountpoint_dir(path: &Path, default_mountpoint: bool) -> Result { + fs::create_dir_all(path).map_err(|error| AppError::FileSystem { + path: path_to_string(path), + message: if default_mountpoint { + format!( + "could not create default mount point: {error}. Create it with administrator privileges or pass a directory you own" + ) + } else { + error.to_string() + }, + })?; + + let path = canonicalize_path(path)?; + validate_available_mountpoint(&path, default_mountpoint)?; + Ok(path) +} + +fn validate_available_mountpoint(path: &Path, default_mountpoint: bool) -> Result<(), AppError> { if !path.is_dir() { return Err(AppError::FileSystem { - path: path_to_string(&path), + path: path_to_string(path), message: "mount point must be a directory".to_string(), }); } - Ok(path) + if is_mounted_at(path).map_err(|message| AppError::Mount { message })? { + return Err(AppError::Mount { + message: format!("mount point is already mounted: {}", path.display()), + }); + } + + if !dir_is_empty(path)? { + let message = if default_mountpoint { + "default mount point is not empty; trying the next default candidate".to_string() + } else { + "mount point must be empty".to_string() + }; + return Err(AppError::FileSystem { + path: path_to_string(path), + message, + }); + } + + Ok(()) +} + +fn dir_is_empty(path: &Path) -> Result { + let mut entries = fs::read_dir(path).map_err(|error| AppError::FileSystem { + path: path_to_string(path), + message: error.to_string(), + })?; + + Ok(entries.next().is_none()) +} + +fn resolve_unmount_mountpoint(path: Option<&str>) -> Result { + match path { + Some(path) => resolve_explicit_unmount_mountpoint(path), + None => resolve_default_unmount_mountpoint(), + } } -fn existing_path(path: &str) -> Result { +fn resolve_explicit_unmount_mountpoint(path: &str) -> Result { let path = absolute_path(path)?; if !path.exists() { - return Err(AppError::FileSystem { - path: path_to_string(&path), - message: "path does not exist".to_string(), - }); + return Ok(path); } + canonicalize_path(&path) +} + +fn resolve_default_unmount_mountpoint() -> Result { + let mountpoints = find_tp7_mountpoints().map_err(|message| AppError::Unmount { message })?; + + match mountpoints.as_slice() { + [] => Ok(PathBuf::from(DEFAULT_MOUNTPOINT)), + [mountpoint] => Ok(mountpoint.clone()), + _ => Err(AppError::Unmount { + message: format!( + "multiple TP-7 mounts found: {}; pass the mount point explicitly", + mountpoints + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + ), + }), + } +} + +fn canonicalize_path(path: &Path) -> Result { path.canonicalize().map_err(|error| AppError::FileSystem { - path: path_to_string(&path), + path: path_to_string(path), message: error.to_string(), }) } @@ -243,7 +331,6 @@ fn absolute_path(path: &str) -> Result { }) } -#[cfg(feature = "finder-mount")] fn open_mountpoint_in_finder(path: &Path, human_status: bool) -> bool { #[cfg(target_os = "macos")] { @@ -315,21 +402,26 @@ fn run_unmount_command(mut command: Command) -> Result<(), String> { Err(detail) } -fn is_mounted_at(path: &Path) -> Result { +fn is_mounted_at(path: &Path) -> Result { + let output = read_mount_output()?; + Ok(mount_output_has_mountpoint(&output, path)) +} + +fn find_tp7_mountpoints() -> Result, String> { + let output = read_mount_output()?; + Ok(mount_output_tp7_mountpoints(&output)) +} + +fn read_mount_output() -> Result { let output = Command::new("mount") .output() - .map_err(|error| AppError::Unmount { - message: format!("failed to inspect mounted filesystems: {error}"), - })?; + .map_err(|error| format!("failed to inspect mounted filesystems: {error}"))?; if !output.status.success() { - return Err(AppError::Unmount { - message: format!("mount exited with {}", output.status), - }); + return Err(format!("mount exited with {}", output.status)); } - let stdout = String::from_utf8_lossy(&output.stdout); - Ok(mount_output_has_mountpoint(&stdout, path)) + Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn mount_output_has_mountpoint(output: &str, path: &Path) -> bool { @@ -337,7 +429,31 @@ fn mount_output_has_mountpoint(output: &str, path: &Path) -> bool { output.lines().any(|line| line.contains(&needle)) } -#[cfg(any(feature = "finder-mount", test))] +fn mount_output_tp7_mountpoints(output: &str) -> Vec { + output + .lines() + .filter_map(parse_tp7_mountpoint) + .collect::>() +} + +fn parse_tp7_mountpoint(line: &str) -> Option { + let on_index = line.find(" on ")?; + let options_index = line.rfind(" (")?; + if options_index <= on_index { + return None; + } + + let source = &line[..on_index]; + let mountpoint = &line[on_index + 4..options_index]; + let options = &line[options_index + 2..]; + + if source.starts_with("tp7") && options.contains("mtp") { + Some(PathBuf::from(mountpoint)) + } else { + None + } +} + fn is_graceful_mount_end(error: &io::Error) -> bool { let message = error.to_string().to_lowercase(); message.contains("not mounted") @@ -382,4 +498,17 @@ mod tests { assert!(is_graceful_mount_end(&error)); } + + #[test] + fn finds_tp7_mountpoints_in_mount_output() { + let output = "/dev/disk3s1 on /System/Volumes/Data (apfs, local)\ntp7:F1RTL11C on /Volumes/TP-7 (mtp, nodev, nosuid, read-only)\ntp7:F2RTL11C on /Volumes/TP-7-2 (mtp, nodev, nosuid, read-only)\n"; + + assert_eq!( + mount_output_tp7_mountpoints(output), + vec![ + PathBuf::from("/Volumes/TP-7"), + PathBuf::from("/Volumes/TP-7-2") + ] + ); + } } From cf0ae76b73bb8be572c8bde9f19e815c070656c8 Mon Sep 17 00:00:00 2001 From: Toto Tvalavadze Date: Thu, 7 May 2026 16:01:03 +0400 Subject: [PATCH 3/5] feat: make Finder mount read-write --- README.md | 6 ++++-- docs/spec.md | 21 ++++++++++----------- src/cli.rs | 5 ++++- src/lib.rs | 1 + src/mount.rs | 8 +++++--- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6ad1d4b..725bfb0 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ push Upload a file or directory to the TP-7 mkdir Create a remote folder rm Delete a remote file or folder rename Rename a remote object without moving it -mount Mount the TP-7 as a read-only Finder filesystem +mount Mount the TP-7 as a Finder filesystem unmount Unmount a mounted TP-7 filesystem eject Open and close an MTP session cleanly ``` @@ -95,7 +95,9 @@ The TP-7 firmware tested here (`1.1.9`) accepts file upload, rename, delete, and - `push --recursive` uploads into an existing remote folder tree only. - Missing remote folders are detected before any recursive upload starts. -Finder mounting is read-only in the first implementation. By default `tp7 mount` uses `/Volumes/TP-7`, creating it when permissions allow. If `/Volumes/TP-7` is already in use, it tries `/Volumes/TP-7-2`, `/Volumes/TP-7-3`, and so on. You can also pass your own empty directory. +Finder mounting is read-write by default. By default `tp7 mount` uses `/Volumes/TP-7`, creating it when permissions allow. If `/Volumes/TP-7` is already in use, it tries `/Volumes/TP-7-2`, `/Volumes/TP-7-3`, and so on. You can also pass your own empty directory or use `--read-only` for an inspection-only mount. + +Because TP-7 firmware `1.1.9` rejects MTP folder creation, creating folders from Finder may fail even though file copy, overwrite, rename, and delete use writable MTP operations. ## Local requirements diff --git a/docs/spec.md b/docs/spec.md index ff87531..77cc2ed 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -358,7 +358,7 @@ reverse mode-switch command is discovered later, this can optionally use it. `tp7 mount [mountpoint]` -Mount the TP-7 as a read-only Finder-visible filesystem. The command keeps +Mount the TP-7 as a read-write Finder-visible filesystem. The command keeps running as the userspace filesystem server. If the user unmounts the volume from Finder, `diskutil`, or `umount`, the command exits cleanly and does not depend on stored CLI state. @@ -374,7 +374,11 @@ Implementation notes: - The implementation uses the same TP-7 MTP mode switch and open-session path as the direct file commands, then hands the opened MTP device to a FUSE filesystem for the lifetime of the mount. -- The mounted filesystem is read-only for the initial implementation. +- The mounted filesystem is read-write by default. `--read-only` keeps the mount + inspection-only. +- TP-7 firmware `1.1.9` rejected MTP folder creation in direct CLI smoke tests, + so Finder folder creation can fail even while file copy, overwrite, rename, + and delete are writable. `tp7 unmount [mountpoint]` @@ -386,12 +390,6 @@ it reports that without treating it as a CLI state error. ### Future Commands -`tp7 mount --read-write` - -Read-write mount with local write staging and upload-on-close semantics. This -remains future work until TP-7 folder creation, Finder write patterns, and -large-file replacement behavior are validated carefully. - `tp7 sync ` One-way sync from TP-7 to Mac. This is likely the most useful workflow command for recordings. @@ -426,7 +424,8 @@ Current choices: - Retry transient CoreMIDI endpoint discovery during `--auto-connect` for the same 12-second window used for MTP visibility because the USB device can reappear before its MIDI endpoints are ready. -- Keep Finder mounting read-only until write semantics are validated. +- Make Finder mounting read-write by default, with `--read-only` available for + inspection-only sessions. ## Implementation Plan @@ -511,12 +510,12 @@ Deliverable: ### Phase 7: Mount -Prototype a read-only FUSE mount after the direct MTP CLI is stable. +Prototype a FUSE mount after the direct MTP CLI is stable. Deliverable: - `tp7 mount [mountpoint]` using `fuser`/`mtp-mount` -- Finder-visible read-only volume +- Finder-visible read-write volume with `--read-only` support - stateless `tp7 unmount [mountpoint]` wrapper ## Risks diff --git a/src/cli.rs b/src/cli.rs index 004414b..22d8703 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -87,7 +87,7 @@ pub enum Command { #[command(about = "Rename a remote object without moving it")] Rename(RenameArgs), - #[command(about = "Mount the TP-7 as a read-only Finder filesystem")] + #[command(about = "Mount the TP-7 as a Finder filesystem")] Mount(MountArgs), #[command(about = "Unmount a mounted TP-7 filesystem")] @@ -233,6 +233,9 @@ pub struct MountArgs { #[arg(help = "Local mount point; defaults to /Volumes/TP-7")] pub mountpoint: Option, + #[arg(long, help = "Mount without allowing Finder writes")] + pub read_only: bool, + #[arg(long = "no-open", help = "Do not open the mounted volume in Finder")] pub no_open: bool, } diff --git a/src/lib.rs b/src/lib.rs index fd48e02..db902dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -159,6 +159,7 @@ pub fn run() -> Result<(), AppError> { cli.device.as_deref(), cli.auto_connect, args.mountpoint.as_deref(), + args.read_only, !args.no_open, !cli.json, )?; diff --git a/src/mount.rs b/src/mount.rs index 247ced7..d435a0c 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -38,6 +38,7 @@ pub fn run_mount( serial: Option<&str>, auto_connect: bool, mountpoint: Option<&str>, + read_only: bool, open_finder: bool, human_status: bool, ) -> Result { @@ -64,7 +65,7 @@ pub fn run_mount( let initial_mode = prepared.initial_usb.mode.clone(); let final_mode = prepared.usb.mode.clone(); - let mtp_fs = MtpFs::new(device, true, runtime.handle().clone()); + let mtp_fs = MtpFs::new(device, read_only, runtime.handle().clone()); let mut config = Config::default(); config.mount_options = mount_options(&mtp_fs, &prepared.usb); @@ -83,7 +84,8 @@ pub fn run_mount( }; if human_status { - println!("mounted TP-7 at {mountpoint_label} (read-only)"); + let access_mode = if read_only { "read-only" } else { "read-write" }; + println!("mounted TP-7 at {mountpoint_label} ({access_mode})"); println!("unmount from Finder or run: tp7 unmount {mountpoint_label}"); let _ = io::stdout().flush(); } @@ -104,7 +106,7 @@ pub fn run_mount( serial_number, initial_mode, final_mode, - read_only: true, + read_only, opened_finder, message, }) From 444de9fe5fbcdf5ce2066c2b6158fd970a5f02ac Mon Sep 17 00:00:00 2001 From: Toto Tvalavadze Date: Thu, 7 May 2026 16:23:24 +0400 Subject: [PATCH 4/5] feat: publish tp7 as a Homebrew cask --- .github/workflows/release.yml | 2 +- README.md | 11 +++-- docs/spec.md | 11 +++-- scripts/update-homebrew-tap.sh | 75 +++++++++++++++++++++------------- scripts/write-release-notes.sh | 6 ++- src/doctor.rs | 27 ++++++++++++ src/mount.rs | 2 +- 7 files changed, 96 insertions(+), 38 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a1aae2..4618b34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -274,7 +274,7 @@ jobs: if git status --porcelain | grep .; then git config user.name "tp7-bot" git config user.email "tp7-bot@users.noreply.github.com" - git add Formula/tp7.rb + git add -A git commit -m "chore: release tp7 v${VERSION}" git push origin HEAD:main else diff --git a/README.md b/README.md index 725bfb0..877962d 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,8 @@ Because TP-7 firmware `1.1.9` rejects MTP folder creation, creating folders from - macOS - A Teenage Engineering TP-7 connected over USB - Rust 1.88 or newer for source builds and development -- For Finder mounting: macFUSE or Fuse-T with FUSE `pkg-config` metadata available at build time and a working FUSE runtime at mount time +- For Finder mounting: macFUSE at runtime; source builds also need FUSE + `pkg-config` metadata available at build time No Android File Transfer, FieldKit, libmtp, or kernel extension is required for the direct CLI workflow. Finder mounting uses FUSE and is separate from the direct MTP commands. @@ -114,10 +115,14 @@ With Homebrew: ```sh brew tap totocaster/tap -brew install totocaster/tap/tp7 +brew install --cask totocaster/tap/tp7 tp7 --version ``` +The Homebrew cask installs macFUSE as a dependency for Finder mounting. If +macOS asks you to approve macFUSE in System Settings -> Privacy & Security, +approve it and rerun `tp7 doctor` or `tp7 -a mount`. + From this repository: ```sh @@ -169,7 +174,7 @@ Releases are tag-driven and update the Homebrew tap automatically: 3. Tag the commit as `vX.Y.Z`. 4. Push the tag. -The GitHub `Release` workflow verifies formatting, check, clippy, tests, and a CLI smoke test, then builds `aarch64-apple-darwin` and `x86_64-apple-darwin` release archives. It publishes the GitHub release with install instructions, artifact checksums, and conventional-commit changelog notes, then rewrites `Formula/tp7.rb` in `totocaster/homebrew-tap` with the new artifact URLs and SHA256 sums. The `HOMEBREW_TAP_TOKEN` repository secret must be configured for the tap push. +The GitHub `Release` workflow verifies formatting, check, clippy, tests, and a CLI smoke test, then builds `aarch64-apple-darwin` and `x86_64-apple-darwin` release archives. It publishes the GitHub release with install instructions, artifact checksums, and conventional-commit changelog notes, then rewrites `Casks/tp7.rb` in `totocaster/homebrew-tap` with the new artifact URLs and SHA256 sums. The `HOMEBREW_TAP_TOKEN` repository secret must be configured for the tap push. ## License diff --git a/docs/spec.md b/docs/spec.md index 77cc2ed..fe83a05 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -4,7 +4,11 @@ Build a macOS CLI for Teenage Engineering TP-7 file access. -The first implementation target is a robust Rust-based MTP file manager that can detect the TP-7, switch or validate MTP mode, list files, and transfer recordings. Finder-style mounting is valuable, but should be treated as a later layer because MTP is object-based and macOS mounting requires FUSE infrastructure. +The implementation target is a robust Rust-based TP-7 file manager that can +detect the recorder, switch or validate MTP mode, list files, transfer +recordings, and optionally expose the MTP object store through Finder. Finder +mounting uses FUSE infrastructure because MTP is object-based and macOS has no +native Finder-level MTP support. ## Current Decision @@ -16,7 +20,7 @@ Recommended initial stack: - `nusb` indirectly through `mtp-rs` for USB access. - `clap` for CLI parsing. - `tracing` or `log` for diagnostics. -- Later: `fuser` plus macFUSE or Fuse-T for filesystem mounting. +- `fuser` plus macFUSE for Finder filesystem mounting. Avoid `libmtp` in the first prototype unless `mtp-rs` cannot handle the TP-7 reliably. FieldKit uses `libmtp`, so it remains a proven fallback. @@ -192,7 +196,8 @@ For mounting, macOS requires a FUSE-compatible runtime: - macFUSE: mature, but requires kernel extension approval or newer FSKit paths depending on version/macOS. - Fuse-T: kext-less, attractive for distribution, but needs validation with Rust FUSE tooling. -V1 should not require FUSE. +The Homebrew cask should install macFUSE automatically for normal users. +Source builds still need FUSE development metadata available at build time. ## Rust Tooling diff --git a/scripts/update-homebrew-tap.sh b/scripts/update-homebrew-tap.sh index a75d9bd..d4dfd76 100755 --- a/scripts/update-homebrew-tap.sh +++ b/scripts/update-homebrew-tap.sh @@ -14,38 +14,55 @@ INTEL_URL="$5" INTEL_SHA="$6" FORMULA_PATH="${TAP_DIR}/Formula/tp7.rb" +CASK_PATH="${TAP_DIR}/Casks/tp7.rb" +ARM_SUFFIX="aarch64-apple-darwin.tar.gz" +INTEL_SUFFIX="x86_64-apple-darwin.tar.gz" -mkdir -p "$(dirname "${FORMULA_PATH}")" +if [[ "${ARM_URL}" != *"${ARM_SUFFIX}" ]]; then + echo "Apple Silicon URL does not end with ${ARM_SUFFIX}: ${ARM_URL}" >&2 + exit 1 +fi + +if [[ "${INTEL_URL}" != *"${INTEL_SUFFIX}" ]]; then + echo "Intel URL does not end with ${INTEL_SUFFIX}: ${INTEL_URL}" >&2 + exit 1 +fi + +URL_PREFIX="${ARM_URL%${ARM_SUFFIX}}" +EXPECTED_INTEL_URL="${URL_PREFIX}${INTEL_SUFFIX}" +if [[ "${INTEL_URL}" != "${EXPECTED_INTEL_URL}" ]]; then + echo "Artifact URLs do not share an architecture-only suffix:" >&2 + echo " arm: ${ARM_URL}" >&2 + echo " intel: ${INTEL_URL}" >&2 + exit 1 +fi + +rm -f "${FORMULA_PATH}" +mkdir -p "$(dirname "${CASK_PATH}")" -cat >"${FORMULA_PATH}" <"${CASK_PATH}" < Privacy & Security, then retry: + + tp7 doctor + tp7 -a mount + EOS end EOF diff --git a/scripts/write-release-notes.sh b/scripts/write-release-notes.sh index 187192d..d8c321d 100755 --- a/scripts/write-release-notes.sh +++ b/scripts/write-release-notes.sh @@ -82,10 +82,14 @@ cat > "${OUTPUT_FILE}" < Privacy & Security, approve it and rerun +\`tp7 doctor\`. + ## Highlights \`tp7\` is an unofficial macOS CLI for browsing and moving files on a Teenage Engineering TP-7 over direct MTP. A TP-7 is only required for device operations; \`--help\` and \`--version\` work without hardware. diff --git a/src/doctor.rs b/src/doctor.rs index 41e9d10..8273058 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -1,4 +1,5 @@ use serde::Serialize; +use std::path::Path; use std::process::Command; use crate::device::{Tp7Device, UsbMode, filter_by_serial, list_tp7_devices}; @@ -131,6 +132,7 @@ pub fn run_doctor(serial: Option<&str>) -> Result { name: "implementation-dependency".to_string(), message: "This CLI uses direct USB enumeration; companion apps are not implementation dependencies.".to_string(), }); + checks.push(finder_mount_runtime_check()); Ok(DoctorReport { devices, @@ -170,6 +172,31 @@ fn mode_check(device: &Tp7Device) -> DoctorCheck { } } +fn finder_mount_runtime_check() -> DoctorCheck { + if fuse_runtime_installed() { + DoctorCheck { + status: CheckStatus::Ok, + name: "finder-mount-runtime".to_string(), + message: "A FUSE runtime appears installed for Finder mounting. If mounting is still blocked, approve macFUSE in System Settings -> Privacy & Security.".to_string(), + } + } else { + DoctorCheck { + status: CheckStatus::Warn, + name: "finder-mount-runtime".to_string(), + message: "Finder mounting requires macFUSE. Install with `brew install --cask macfuse`; if macOS prompts, approve it in System Settings -> Privacy & Security.".to_string(), + } + } +} + +fn fuse_runtime_installed() -> bool { + [ + "/Library/Filesystems/macfuse.fs", + "/Library/Filesystems/fuse-t.fs", + ] + .iter() + .any(|path| Path::new(path).exists()) +} + fn find_process_conflicts() -> Result, AppError> { let output = Command::new("ps") .args(["-axo", "pid=,comm=,args="]) diff --git a/src/mount.rs b/src/mount.rs index d435a0c..81fa043 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -72,7 +72,7 @@ pub fn run_mount( let background = fuser::spawn_mount2(mtp_fs, &mountpoint, &config).map_err(|error| { AppError::Mount { message: format!( - "failed to mount {mountpoint_label}: {error}. Install macFUSE or Fuse-T if no FUSE runtime is available." + "failed to mount {mountpoint_label}: {error}. Install macFUSE with `brew install --cask macfuse`; if macOS prompts, approve it in System Settings -> Privacy & Security." ), } })?; From 146814fcde0bff21050acd01355fcc318c1c5120 Mon Sep 17 00:00:00 2001 From: Toto Tvalavadze Date: Thu, 7 May 2026 16:40:25 +0400 Subject: [PATCH 5/5] fix: default Finder mount to user directory --- README.md | 2 +- docs/spec.md | 4 ++-- src/cli.rs | 2 +- src/mount.rs | 46 +++++++++++++++++++++++++++++++++++++--------- 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 877962d..18bd1f6 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ The TP-7 firmware tested here (`1.1.9`) accepts file upload, rename, delete, and - `push --recursive` uploads into an existing remote folder tree only. - Missing remote folders are detected before any recursive upload starts. -Finder mounting is read-write by default. By default `tp7 mount` uses `/Volumes/TP-7`, creating it when permissions allow. If `/Volumes/TP-7` is already in use, it tries `/Volumes/TP-7-2`, `/Volumes/TP-7-3`, and so on. You can also pass your own empty directory or use `--read-only` for an inspection-only mount. +Finder mounting is read-write by default. By default `tp7 mount` uses `~/TP-7`, creating it when needed. If `~/TP-7` is already in use, it tries `~/TP-7-2`, `~/TP-7-3`, and so on. You can also pass your own empty directory or use `--read-only` for an inspection-only mount. Because TP-7 firmware `1.1.9` rejects MTP folder creation, creating folders from Finder may fail even though file copy, overwrite, rename, and delete use writable MTP operations. diff --git a/docs/spec.md b/docs/spec.md index fe83a05..d9f4763 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -370,8 +370,8 @@ depend on stored CLI state. Implementation notes: -- With no mount point, the CLI uses `/Volumes/TP-7`. If that path is already - mounted or unavailable, it tries numbered siblings such as `/Volumes/TP-7-2`. +- With no mount point, the CLI uses `~/TP-7`. If that path is already mounted + or unavailable, it tries numbered siblings such as `~/TP-7-2`. - An explicit mount point is created when missing, but must be an empty directory before mounting. - The Rust FUSE stack requires macFUSE/Fuse-T development metadata at build diff --git a/src/cli.rs b/src/cli.rs index 22d8703..43a22e5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -230,7 +230,7 @@ pub struct RenameArgs { #[derive(Debug, Args)] pub struct MountArgs { - #[arg(help = "Local mount point; defaults to /Volumes/TP-7")] + #[arg(help = "Local mount point; defaults to ~/TP-7")] pub mountpoint: Option, #[arg(long, help = "Mount without allowing Finder writes")] diff --git a/src/mount.rs b/src/mount.rs index 81fa043..d6564b9 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -11,8 +11,7 @@ use crate::device::{Tp7Device, UsbMode}; use crate::mtp_session::{MtpOpenPolicy, open_mtp_session}; use crate::output::AppError; -const DEFAULT_MOUNTPOINT: &str = "/Volumes/TP-7"; -const DEFAULT_MOUNTPOINT_PREFIX: &str = "/Volumes/TP-7"; +const DEFAULT_MOUNTPOINT_NAME: &str = "TP-7"; const MAX_DEFAULT_MOUNTPOINT_ATTEMPTS: usize = 99; #[derive(Debug, Clone, Serialize)] @@ -209,16 +208,47 @@ fn prepare_default_mountpoint() -> Result { Err(AppError::Mount { message: format!( - "no available default mount point found under {DEFAULT_MOUNTPOINT_PREFIX}; pass an explicit mount point" + "no available default mount point found under {}; pass an explicit mount point", + default_mountpoint_base_label() ), }) } fn default_mountpoint_candidate(index: usize) -> PathBuf { + let base = default_mountpoint_base(); if index == 1 { - PathBuf::from(DEFAULT_MOUNTPOINT) + base } else { - PathBuf::from(format!("{DEFAULT_MOUNTPOINT_PREFIX}-{index}")) + numbered_default_mountpoint_base(base, index) + } +} + +fn default_mountpoint_base() -> PathBuf { + match std::env::var_os("HOME") { + Some(home) if !home.is_empty() => PathBuf::from(home).join(DEFAULT_MOUNTPOINT_NAME), + _ => PathBuf::from(DEFAULT_MOUNTPOINT_NAME), + } +} + +fn numbered_default_mountpoint_base(base: PathBuf, index: usize) -> PathBuf { + let mut name = base + .file_name() + .map(|name| name.to_os_string()) + .unwrap_or_else(|| DEFAULT_MOUNTPOINT_NAME.into()); + name.push(format!("-{index}")); + + match base.parent() { + Some(parent) => parent.join(name), + None => PathBuf::from(name), + } +} + +fn default_mountpoint_base_label() -> String { + match std::env::var_os("HOME") { + Some(home) if !home.is_empty() => { + format!("{}/{}", Path::new(&home).display(), DEFAULT_MOUNTPOINT_NAME) + } + _ => DEFAULT_MOUNTPOINT_NAME.to_string(), } } @@ -226,9 +256,7 @@ fn create_mountpoint_dir(path: &Path, default_mountpoint: bool) -> Result Result { let mountpoints = find_tp7_mountpoints().map_err(|message| AppError::Unmount { message })?; match mountpoints.as_slice() { - [] => Ok(PathBuf::from(DEFAULT_MOUNTPOINT)), + [] => Ok(default_mountpoint_base()), [mountpoint] => Ok(mountpoint.clone()), _ => Err(AppError::Unmount { message: format!(