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..4618b34 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: @@ -262,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/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.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..fb2ecbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,14 +16,16 @@ categories = ["command-line-utilities"] bytes = "1.5" clap = { version = "4.6.1", features = ["derive"] } env_logger = "0.11.10" +fuser = "0.17" futures = "0.3" log = "0.4.29" +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"] } +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 12ace47..18bd1f6 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; 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,11 +21,13 @@ 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 +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. -`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 +44,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 Finder filesystem +unmount Unmount a mounted TP-7 filesystem eject Open and close an MTP session cleanly ``` @@ -91,15 +95,19 @@ 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-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. ## Local requirements - macOS - A Teenage Engineering TP-7 connected over USB - Rust 1.88 or newer for source builds and development +- 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. +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 @@ -107,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 @@ -121,6 +133,7 @@ Or during development: ```sh cargo run -- -a ls -lah / +cargo run -- -a mount ``` ## Development @@ -135,6 +148,10 @@ cargo test cargo run -- --help ``` +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 @@ -157,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 be442fd..d9f4763 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 @@ -356,15 +361,39 @@ 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. +`tp7 mount [mountpoint]` + +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. + +Implementation notes: + +- 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 + 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-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]` + +Stateless OS unmount wrapper. It does not read or write PID files or mount +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. -`tp7 mount --read-write` - -Read-write mount with local write staging and upload-on-close semantics. +### Future Commands `tp7 sync ` @@ -400,7 +429,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 v1 mount-free. +- Make Finder mounting read-write by default, with `--read-only` available for + inspection-only sessions. ## Implementation Plan @@ -483,14 +513,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. +Prototype a FUSE mount after the direct MTP CLI is stable. Deliverable: -- decision between macFUSE and Fuse-T -- read-only `tp7 mount ` prototype +- `tp7 mount [mountpoint]` using `fuser`/`mtp-mount` +- Finder-visible read-write volume with `--read-only` support +- stateless `tp7 unmount [mountpoint]` wrapper ## Risks @@ -516,7 +547,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/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/cli.rs b/src/cli.rs index 1d4d91b..43a22e5 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 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,24 @@ pub struct RenameArgs { #[arg(help = "New name in the same remote folder")] pub new_name: String, } + +#[derive(Debug, Args)] +pub struct MountArgs { + #[arg(help = "Local mount point; defaults to ~/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, +} + +#[derive(Debug, Args)] +pub struct UnmountArgs { + #[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/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/lib.rs b/src/lib.rs index eda111c..db902dd 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,21 @@ 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_deref(), + args.read_only, + !args.no_open, + !cli.json, + )?; + output::write_mount(&report, cli.json) + } + Command::Unmount(args) => { + let report = mount::run_unmount(args.mountpoint.as_deref(), 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..d6564b9 --- /dev/null +++ b/src/mount.rs @@ -0,0 +1,544 @@ +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use fuser::{Config, MountOption}; +use mtp_mount::fs::MtpFs; +use serde::Serialize; + +use crate::device::{Tp7Device, UsbMode}; +use crate::mtp_session::{MtpOpenPolicy, open_mtp_session}; +use crate::output::AppError; + +const DEFAULT_MOUNTPOINT_NAME: &str = "TP-7"; +const MAX_DEFAULT_MOUNTPOINT_ATTEMPTS: usize = 99; + +#[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: Option<&str>, + read_only: bool, + open_finder: bool, + human_status: bool, +) -> Result { + let mountpoint = prepare_mountpoint(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, read_only, 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 with `brew install --cask macfuse`; if macOS prompts, approve it in System Settings -> Privacy & Security." + ), + } + })?; + + let opened_finder = if open_finder { + open_mountpoint_in_finder(&mountpoint, human_status) + } else { + false + }; + + if human_status { + 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(); + } + + 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, + opened_finder, + message, + }) +} + +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).map_err(|message| AppError::Unmount { message })? { + 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).map_err(|message| AppError::Unmount { message })? { + 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})" + ), + }), + } + } + } +} + +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 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 {}; pass an explicit mount point", + default_mountpoint_base_label() + ), + }) +} + +fn default_mountpoint_candidate(index: usize) -> PathBuf { + let base = default_mountpoint_base(); + if index == 1 { + base + } else { + 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(), + } +} + +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}. Pass an explicit empty 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), + message: "mount point must be a directory".to_string(), + }); + } + + 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 resolve_explicit_unmount_mountpoint(path: &str) -> Result { + let path = absolute_path(path)?; + if !path.exists() { + 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(default_mountpoint_base()), + [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), + 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(), + }) +} + +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 = 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| format!("failed to inspect mounted filesystems: {error}"))?; + + if !output.status.success() { + return Err(format!("mount exited with {}", output.status)); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn mount_output_has_mountpoint(output: &str, path: &Path) -> bool { + let needle = format!(" on {} (", path.display()); + output.lines().any(|line| line.contains(&needle)) +} + +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") + || 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)); + } + + #[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") + ] + ); + } +} 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)?;