diff --git a/Cargo.lock b/Cargo.lock index 1662cd3..0938c35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,12 +84,24 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" 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 = "clap" version = "4.6.1" @@ -374,6 +386,17 @@ dependencies = [ "syn", ] +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.186" @@ -409,9 +432,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mtp-rs" -version = "0.13.3" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7c57cd0f4a0371556db22a3a532eb6ab82b90979e53d7ba119d4a1156faf3f" +checksum = "5512a899d3ad31f804bd0f85108ad7256e6f5414030ee3c247c4ba2dffa116b0" dependencies = [ "async-trait", "bytes", @@ -420,6 +443,8 @@ dependencies = [ "num_enum", "nusb", "thiserror", + "windows", + "windows-core", ] [[package]] @@ -446,19 +471,23 @@ dependencies = [ [[package]] name = "nusb" -version = "0.2.3" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a330b3bc7f8b4fc729a4c63164b3927eeeaced198222a3ce6b8b6e034851b7a" +checksum = "18ef13beb3b3a8fc16fd7aea912ebd3d45dde00a9a5b968d0742297468065845" dependencies = [ "core-foundation", "core-foundation-sys", "futures-core", "io-kit-sys", + "js-sys", "linux-raw-sys", "log", "once_cell", "rustix", "slab", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", "windows-sys", ] @@ -697,7 +726,7 @@ dependencies = [ [[package]] name = "tp7" -version = "0.1.0" +version = "0.1.1" dependencies = [ "bytes", "clap", @@ -725,12 +754,172 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -740,6 +929,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index fb05693..1a2a3bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tp7" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version = "1.88" authors = ["Toto Tvalavadze"] @@ -18,7 +18,7 @@ clap = { version = "4.6.1", features = ["derive"] } env_logger = "0.11.10" futures = "0.3" log = "0.4.29" -mtp-rs = "0.13.3" +mtp-rs = "0.32.0" nusb = "0.2.3" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" diff --git a/README.md b/README.md index 12ace47..cd4bae9 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ tp7 -a push ./clip.wav /memo/clip.wav --overwrite tp7 -a rm /memo/clip.wav --dry-run ``` -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. +For normal use, prefer `-a` / `--auto-connect`. Each command detects the +recorder, switches to MTP if needed, opens or reuses the MTP session, performs +the operation, and leaves the recorder ready for the next command. `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. @@ -63,6 +65,20 @@ tp7 -a ls -sS --human-readable / tp7 -a ls -ltr /recordings ``` +TP-7 file commands leave the recorder in MTP mode after they exit, so repeated +commands work without reconnecting or a resident helper process. `tp7 eject` +is the explicit way to close the device-side MTP session. On macOS, +`ptpcamerad` may claim the interface before `tp7`; when that happens, +`--take-over` can reclaim it and retry: + +```sh +tp7 -a ls --take-over -lah / +``` + +`--take-over` only terminates a process when macOS reports `ptpcamerad` as the +exclusive owner of the TP-7 MTP interface. It does not terminate FieldKit, +OpenMTP, or unrecognized owners. + ## Agent-friendly by design `tp7` follows the same Project Humane conventions as the other local tools in this workspace: concise text output for people, deterministic `--json` for scripts and agents, non-zero exit codes on errors, explicit destructive commands, and dry-run support where a command can change files. diff --git a/docs/spec.md b/docs/spec.md index be442fd..3657746 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -29,7 +29,10 @@ Device identity: - Product: `TP-7` - Vendor string: `teenage engineering` - Vendor ID: `0x2367` -- Product ID: `0x0019` +- Product IDs: `0x0019` in the original observation; `0x8019` for the + audio/MIDI personality on another observed unit. The latter re-enumerates as + `0x0019` in MTP mode, so interface descriptors remain the authoritative mode + signal. - Serial: `F1RTL11C` - USB speed: high speed, 480 Mbps - USB version: 2.0 @@ -209,7 +212,7 @@ Open validation tasks: - Confirmed: `mtp-rs` can open the TP-7 once it is in MTP mode. - Confirm object listing and transfers work with TP-7 recordings. -- Confirm whether `mtp-rs` exposes all required operations or whether we need small extensions. +- Confirmed: `mtp-rs` 0.32.0 includes the TP-7 session-reuse API upstream, so no vendored fork is required. - Confirm whether `nusb` can handle any macOS device ownership edge cases cleanly. Validated MTP status from `mtp-rs`: @@ -224,6 +227,11 @@ Validated MTP status from `mtp-rs`: - Capacity: `120244404224` bytes - Free space observed: `112986914816` bytes +`mtp-rs` 0.32.0 moved its high-level API to backend-neutral device and storage +metadata. The CLI keeps the existing JSON keys for vendor-extension text and +free object counts, but reports them as `null` because those protocol-specific +values are no longer exposed by that API. + ## Go Tooling Considered Go is feasible but less attractive for this project. @@ -280,6 +288,9 @@ Useful flags: - `-t, --sort-time`: sort by modified time, newest first. - `-r, --reverse`: reverse listing order. - `-i, --ids`: show MTP object IDs. +- `--take-over`: after an exclusive-access failure, terminate and retry only + when macOS reports `ptpcamerad` as the exclusive owner of the TP-7 MTP + interface. Other owners remain untouched. - `--json` `tp7 tree [remote-path]` @@ -511,7 +522,7 @@ MTP semantics: Library risk: -- `mtp-rs` may need fixes or extensions for TP-7 quirks. +- Future TP-7 quirks may still require upstream `mtp-rs` fixes or extensions. - `libmtp` fallback is likely reliable but adds C dependency and licensing considerations. Mount risk: @@ -525,7 +536,7 @@ Mount risk: - Teenage Engineering TP-7 product page: - Teenage Engineering TP-7 downloads/release notes: - Microsoft MTP extensions overview: -- `mtp-rs` crate docs: +- `mtp-rs` crate docs: - `fuser` crate docs: - macFUSE: - Fuse-T: diff --git a/docs/tp7-handshake.md b/docs/tp7-handshake.md index 99e1a91..8c6f0f1 100644 --- a/docs/tp7-handshake.md +++ b/docs/tp7-handshake.md @@ -8,7 +8,8 @@ mode into MTP mode, and how we reverse-engineered the sequence. The TP-7 does not expose MTP as a normal macOS mountable device when first plugged in. It starts as a USB audio/MIDI device. To access files, the CLI must: -1. Find the TP-7 over USB and CoreMIDI. +1. Find the TP-7 over USB and CoreMIDI. The observed audio/MIDI personality + uses product ID `0x8019`; MTP uses `0x0019`. 2. Send a Teenage Engineering SysEx `greet` command over MIDI. 3. Send a Teenage Engineering SysEx `mode` command over MIDI. 4. Wait for the TP-7 to re-enumerate as `TP-7 MTP Device`. @@ -28,7 +29,7 @@ sequenceDiagram participant TP7 as TP-7 participant MTP as MTP session - CLI->>USB: Detect vendor 0x2367, product 0x0019 + CLI->>USB: Detect vendor 0x2367, product 0x8019 or 0x0019 USB-->>CLI: TP-7 in audio/MIDI mode CLI->>MIDI: Find TP-7 MIDI source and destination CLI->>TP7: Universal MIDI identity request @@ -136,16 +137,36 @@ What each step taught us: ## CLI Implications -- File commands switch to MTP when `--auto-connect` is set, open a session, - perform the operation, and close cleanly in one flow. +- File commands switch to MTP when `--auto-connect` is set, open or reuse the + durable device-side session, perform the operation, and release host ownership + without sending the TP-7 back to audio/MIDI mode. +- A TP-7 observed in August 2026 acknowledged MTP `CloseSession` and then left + USB enumeration. Omitting `CloseSession` allowed an immediate second process + to win a short race, but a timestamped trace showed the device still returned + to its `0x8019` audio/MIDI personality within two seconds of the host releasing + the USB interface. A traced FieldKit 2.0.1 session sent no periodic MTP + traffic while idle. Its stable pre-list initialization reads the battery + descriptor, refreshes storage metadata twice, reads battery level twice and + device time once, then refreshes storage metadata before listing objects. + A smaller prefix remained stable only until object listing began. The CLI + uses FieldKit's stable session ID through `mtp-rs` 0.32.0, reuses + `SessionAlreadyOpen` without sending `CloseSession`, primes storage metadata, + and leaves USB cleanup to process exit. Ordinary commands therefore leave + the TP-7 in MTP without polling or a resident process; `tp7 eject` remains + the explicit protocol-level close. The FieldKit property reads remain useful + reverse-engineering evidence, but are not exposed by the backend-neutral + high-level API in `mtp-rs` 0.32.0. +- `ls --take-over` automates that recovery narrowly: it acts only after an + exclusive-access failure and only when IORegistry identifies `ptpcamerad` as + the exclusive TP-7 MTP interface owner. It leaves every other owner alone. - `tp7 status` cannot assume a previous `tp7 connect` left the TP-7 in MTP mode. - The CLI should warn when FieldKit or other processes own TP-7 interfaces, but it should not require those apps. - Newer firmware accepts mode payload `[0x01, 0x03]`. FieldKit suggests `[0x01, 0x02]` may be a fallback for older firmware. - MTP support lives behind our own session layer. `ls`, `tree`, `stat`, and - `pull`, `push`, `rename`, and `rm` now use that same switch/open/work/close - flow. + `pull`, `push`, `rename`, and `rm` now use the same switch/open/work/release + flow, backed by the upstream `mtp-rs` 0.32.0 session-reuse API. - TP-7 firmware `1.1.9` accepted file upload, rename, and delete in smoke tests, but rejected folder creation with MTP `GeneralError`. - `push --overwrite` stages a replacement under a temporary remote name before diff --git a/src/cli.rs b/src/cli.rs index 1d4d91b..5aa159c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -118,6 +118,12 @@ pub struct LsArgs { #[arg(short = 'r', long, help = "Reverse the listing order")] pub reverse: bool, + #[arg( + long, + help = "Terminate ptpcamerad only when it owns the MTP interface, then retry (macOS)" + )] + pub take_over: bool, + #[arg( short = 'h', long, @@ -129,6 +135,22 @@ pub struct LsArgs { pub help: Option, } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ls_take_over() { + let cli = Cli::try_parse_from(["tp7", "ls", "--take-over", "/"]).unwrap(); + + let Command::Ls(args) = cli.command else { + panic!("expected ls command"); + }; + assert!(args.take_over); + assert_eq!(args.remote_path, "/"); + } +} + #[derive(Debug, Args)] pub struct TreeArgs { #[arg(default_value = "/", help = "Remote folder or file path")] diff --git a/src/connect.rs b/src/connect.rs index a42a264..82fd1ca 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -4,6 +4,7 @@ use crate::device::{Tp7Device, UsbMode}; use crate::midi::MidiSwitchReport; use crate::mtp_session::{ MtpOpenPolicy, PreparedMtpDevice, block_on, open_mtp_device, prepare_mtp_device, + release_mtp_device, }; use crate::output::AppError; @@ -83,10 +84,7 @@ fn validate_mtp_session(serial: Option<&str>) -> MtpSessionCheck { .await .map_err(crate::mtp_session::map_mtp_error)?; let storage_count = storages.len(); - device - .close() - .await - .map_err(crate::mtp_session::map_mtp_error)?; + release_mtp_device(device); Ok(storage_count) }) { Ok(storage_count) => MtpSessionCheck { diff --git a/src/device.rs b/src/device.rs index 3c19e2a..f67b63e 100644 --- a/src/device.rs +++ b/src/device.rs @@ -4,7 +4,12 @@ use serde::Serialize; use crate::output::AppError; pub const TP7_VENDOR_ID: u16 = 0x2367; -pub const TP7_PRODUCT_ID: u16 = 0x0019; +pub const TP7_MTP_PRODUCT_ID: u16 = 0x0019; +pub const TP7_AUDIO_MIDI_PRODUCT_ID: u16 = 0x8019; + +pub fn is_tp7_product_id(product_id: u16) -> bool { + matches!(product_id, TP7_MTP_PRODUCT_ID | TP7_AUDIO_MIDI_PRODUCT_ID) +} #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] @@ -72,7 +77,7 @@ pub fn list_tp7_devices() -> Result, AppError> { Ok(devices .filter(|device| { - device.vendor_id() == TP7_VENDOR_ID && device.product_id() == TP7_PRODUCT_ID + device.vendor_id() == TP7_VENDOR_ID && is_tp7_product_id(device.product_id()) }) .map(Tp7Device::from) .collect()) @@ -330,9 +335,9 @@ mod tests { fn tp7_device(serial_number: Option<&str>) -> Tp7Device { Tp7Device { vendor_id: TP7_VENDOR_ID, - product_id: TP7_PRODUCT_ID, + product_id: TP7_AUDIO_MIDI_PRODUCT_ID, vendor_id_hex: "0x2367".to_string(), - product_id_hex: "0x0019".to_string(), + product_id_hex: "0x8019".to_string(), manufacturer: Some("teenage engineering".to_string()), product: Some("TP-7".to_string()), serial_number: serial_number.map(str::to_string), @@ -359,6 +364,13 @@ mod tests { assert_eq!(infer_usb_mode(&interfaces), UsbMode::AudioMidi); } + #[test] + fn recognizes_audio_midi_and_mtp_product_ids() { + assert!(is_tp7_product_id(TP7_AUDIO_MIDI_PRODUCT_ID)); + assert!(is_tp7_product_id(TP7_MTP_PRODUCT_ID)); + assert!(!is_tp7_product_id(0x1234)); + } + #[test] fn infers_mtp_mode() { let interfaces = vec![interface(0x06, 0x01, 0x01)]; diff --git a/src/eject.rs b/src/eject.rs index 87981b5..7b55287 100644 --- a/src/eject.rs +++ b/src/eject.rs @@ -32,7 +32,7 @@ pub fn run_eject(serial: Option<&str>, auto_connect: bool) -> Result Result<(), AppError> { cli.device.as_deref(), cli.auto_connect, args.remote_path.as_str(), + args.take_over, )?; let sort = if args.sort_size { output::LsSort::Size diff --git a/src/ls.rs b/src/ls.rs index b8d4a42..af88da6 100644 --- a/src/ls.rs +++ b/src/ls.rs @@ -1,13 +1,13 @@ use serde::{Deserialize, Serialize}; -use crate::mtp_session::{MtpOpenPolicy, block_on, open_mtp_session}; +use crate::mtp_session::{MtpOpenPolicy, block_on, open_mtp_session_with_takeover}; use crate::output::AppError; use crate::remote::{RemoteObject, RemoteTarget, first_storage, list_remote_objects, resolve_path}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LsReport { pub path: String, - pub storage_id: u32, + pub storage_id: u64, pub entries: Vec, } @@ -17,6 +17,7 @@ pub fn run_ls( serial: Option<&str>, auto_connect: bool, remote_path: &str, + take_over: bool, ) -> Result { let policy = if auto_connect { MtpOpenPolicy::AutoSwitch @@ -25,14 +26,16 @@ pub fn run_ls( }; block_on(async { - let session = open_mtp_session(serial, policy).await?; - let result = read_ls(&session.device, remote_path).await; - let close_result = session.close().await; - - match (result, close_result) { - (Ok(report), Ok(())) => Ok(report), - (Err(error), _) => Err(error), - (Ok(_), Err(error)) => Err(error), + let session = open_mtp_session_with_takeover(serial, policy, take_over).await?; + match read_ls(&session.device, remote_path).await { + Ok(report) => { + session.release().await?; + Ok(report) + } + Err(error) => { + let _ = session.release().await; + Err(error) + } } }) } diff --git a/src/mtp_session.rs b/src/mtp_session.rs index 7055ca2..2a71aa0 100644 --- a/src/mtp_session.rs +++ b/src/mtp_session.rs @@ -1,16 +1,19 @@ use std::future::Future; +use std::process::Command; use std::thread; use std::time::{Duration, Instant}; use mtp_rs::MtpDevice; use crate::device::{ - TP7_PRODUCT_ID, TP7_VENDOR_ID, Tp7Device, UsbMode, list_tp7_devices, select_one_device, + TP7_MTP_PRODUCT_ID, TP7_VENDOR_ID, Tp7Device, UsbMode, list_tp7_devices, select_one_device, }; use crate::midi::{MidiSwitchReport, switch_tp7_to_mtp}; use crate::output::AppError; +use crate::usb_owner::{inspect_tp7_usb_owners, ptpcamerad_exclusive_owner_pids}; -pub const KNOWN_TP7: &[(u16, u16)] = &[(TP7_VENDOR_ID, TP7_PRODUCT_ID)]; +pub const KNOWN_TP7: &[(u16, u16)] = &[(TP7_VENDOR_ID, TP7_MTP_PRODUCT_ID)]; +const TP7_SESSION_ID: u32 = 0xBAAA_AAAD; #[derive(Debug, Clone, Copy)] pub enum MtpOpenPolicy { @@ -33,11 +36,23 @@ pub struct MtpSession { } impl MtpSession { - pub async fn close(self) -> Result<(), AppError> { + /// Leave the device-side session open and let CLI process exit reclaim USB handles. + pub async fn release(self) -> Result<(), AppError> { + std::mem::forget(self); + Ok(()) + } + + /// Explicitly close the device-side session for `tp7 eject`. + pub async fn eject(self) -> Result<(), AppError> { self.device.close().await.map_err(map_mtp_error) } } +/// Release a validation connection without sending the TP-7 `CloseSession`. +pub fn release_mtp_device(device: MtpDevice) { + std::mem::forget(device); +} + pub fn block_on(future: F) -> Result where F: Future>, @@ -123,6 +138,69 @@ pub async fn open_mtp_session( open_prepared_mtp_session(prepared).await } +pub async fn open_mtp_session_with_takeover( + serial: Option<&str>, + policy: MtpOpenPolicy, + take_over: bool, +) -> Result { + let prepared = prepare_mtp_device(serial, policy)?; + let first_open = open_prepared_mtp_session(prepared.clone()).await; + + match first_open { + Err(error @ AppError::MtpExclusiveAccess { .. }) if take_over => { + if !take_over_from_ptpcamerad()? { + return Err(error); + } + + retry_open_prepared_mtp_session(prepared, Duration::from_secs(2)).await + } + result => result, + } +} + +async fn retry_open_prepared_mtp_session( + prepared: PreparedMtpDevice, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + + loop { + match open_prepared_mtp_session(prepared.clone()).await { + Err(AppError::MtpExclusiveAccess { .. }) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(100)); + } + result => return result, + } + } +} + +fn take_over_from_ptpcamerad() -> Result { + let owners = inspect_tp7_usb_owners()?; + let pids = ptpcamerad_exclusive_owner_pids(&owners); + + if pids.is_empty() { + return Ok(false); + } + + for pid in pids { + log::warn!("terminating ptpcamerad process {pid} to claim the TP-7 MTP interface"); + let status = Command::new("/bin/kill") + .args(["-KILL", &pid.to_string()]) + .status() + .map_err(|error| AppError::MtpTakeover { + message: format!("could not terminate ptpcamerad process {pid}: {error}"), + })?; + + if !status.success() { + return Err(AppError::MtpTakeover { + message: format!("could not terminate ptpcamerad process {pid}: {status}"), + }); + } + } + + Ok(true) +} + pub async fn open_prepared_mtp_session( prepared: PreparedMtpDevice, ) -> Result { @@ -132,21 +210,33 @@ pub async fn open_prepared_mtp_session( } pub async fn open_mtp_device(serial: Option<&str>) -> Result { - match serial { + let device = match serial { Some(serial) => { MtpDevice::builder() .known_devices(KNOWN_TP7) + .reuse_existing_session(TP7_SESSION_ID) .open_by_serial(serial) .await } None => { MtpDevice::builder() .known_devices(KNOWN_TP7) + .reuse_existing_session(TP7_SESSION_ID) .open_first() .await } } - .map_err(map_mtp_error) + .map_err(map_mtp_error)?; + + initialize_tp7_session(&device).await?; + + Ok(device) +} + +async fn initialize_tp7_session(device: &MtpDevice) -> Result<(), AppError> { + device.storages().await.map_err(map_mtp_error)?; + + Ok(()) } pub fn map_mtp_error(error: mtp_rs::Error) -> AppError { diff --git a/src/output.rs b/src/output.rs index c494f7b..4259095 100644 --- a/src/output.rs +++ b/src/output.rs @@ -108,6 +108,9 @@ pub enum AppError { #[error("MTP operation failed: {message}")] Mtp { message: String }, + #[error("could not take over the TP-7 MTP interface: {message}")] + MtpTakeover { message: String }, + #[error("MTP operation is not supported: {message}")] MtpUnsupported { message: String }, @@ -157,7 +160,7 @@ impl AppError { | AppError::RemotePathExists { .. } | AppError::InvalidArguments { .. } => 2, AppError::MtpNotVisible { .. } | AppError::AutoConnectRequired { .. } => 3, - AppError::MtpExclusiveAccess { .. } => 4, + AppError::MtpExclusiveAccess { .. } | AppError::MtpTakeover { .. } => 4, AppError::Midi { .. } | AppError::MidiTimeout { .. } | AppError::MidiCommandRejected { .. } => 5, diff --git a/src/pull.rs b/src/pull.rs index 7907b1a..dae9b6a 100644 --- a/src/pull.rs +++ b/src/pull.rs @@ -2,9 +2,7 @@ use std::fs::{self, File, OpenOptions}; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -use mtp_rs::Storage; -use mtp_rs::ptp::ObjectInfo; -use mtp_rs::{DEFAULT_CANCEL_TIMEOUT, ObjectHandle}; +use mtp_rs::{ByteRange, DEFAULT_CANCEL_TIMEOUT, ObjectHandle, ObjectInfo, Storage}; use serde::{Deserialize, Serialize}; use crate::mtp_session::{MtpOpenPolicy, block_on, map_mtp_error, open_mtp_session}; @@ -66,9 +64,9 @@ pub fn run_pull( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = read_pull(&session.device, remote_path, local_path, options).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), @@ -249,7 +247,7 @@ async fn download_to_path( options: PullOptions, ) -> Result<(), AppError> { let mut download = storage - .download_stream(handle) + .download(handle, ByteRange::Full) .await .map_err(map_mtp_error)?; let mut file = create_file(local_path)?; diff --git a/src/push.rs b/src/push.rs index 0e47f03..d5f3639 100644 --- a/src/push.rs +++ b/src/push.rs @@ -8,8 +8,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use bytes::Bytes; use futures::Stream; -use mtp_rs::ptp::{ObjectHandle, ObjectInfo}; -use mtp_rs::{NewObjectInfo, Storage}; +use mtp_rs::{NewObjectInfo, ObjectHandle, ObjectInfo, Storage}; use serde::{Deserialize, Serialize}; use crate::mtp_session::{MtpOpenPolicy, block_on, map_mtp_error, open_mtp_session}; @@ -84,9 +83,9 @@ pub fn run_push( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = write_push(&session.device, &local_path, remote_path, options).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), @@ -346,7 +345,7 @@ async fn upload_file( ControlFlow::Continue(()) }) .await - .map_err(map_mtp_error); + .map_err(|error| map_mtp_error(error.into())); finish_progress(options.progress); result @@ -689,7 +688,7 @@ impl Stream for FileChunkStream { #[cfg(test)] mod tests { - use mtp_rs::ptp::ObjectHandle; + use mtp_rs::ObjectHandle; use super::*; diff --git a/src/remote.rs b/src/remote.rs index 93ed11c..26ade99 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1,5 +1,4 @@ -use mtp_rs::ptp::{ObjectHandle, ObjectInfo}; -use mtp_rs::{MtpDevice, Storage}; +use mtp_rs::{DateTime, MtpDevice, ObjectHandle, ObjectInfo, Storage}; use serde::{Deserialize, Serialize}; use crate::mtp_session::map_mtp_error; @@ -7,9 +6,9 @@ use crate::output::AppError; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RemoteObject { - pub id: u32, - pub parent_id: u32, - pub storage_id: u32, + pub id: u64, + pub parent_id: u64, + pub storage_id: u64, pub kind: ObjectKind, pub name: String, pub size: u64, @@ -46,11 +45,23 @@ impl RemoteObject { }, name: object.filename, size: object.size, - modified: object.modified.and_then(|modified| modified.format()), + modified: object.modified.map(format_datetime), } } } +pub fn format_datetime(datetime: DateTime) -> String { + format!( + "{:04}{:02}{:02}T{:02}{:02}{:02}", + datetime.year, + datetime.month, + datetime.day, + datetime.hour, + datetime.minute, + datetime.second + ) +} + pub async fn first_storage(device: &MtpDevice) -> Result { let mut storages = device.storages().await.map_err(map_mtp_error)?; @@ -214,7 +225,7 @@ fn kind_sort_key(kind: &ObjectKind) -> u8 { #[cfg(test)] mod tests { - use mtp_rs::ptp::{DateTime, ObjectFormatCode, ObjectHandle, ObjectInfo, StorageId}; + use mtp_rs::{DateTime, ObjectFormat, ObjectHandle, ObjectInfo, StorageId}; use super::*; @@ -241,15 +252,20 @@ mod tests { #[test] fn converts_object_info_to_remote_object() { - let object = ObjectInfo { - handle: ObjectHandle(42), - storage_id: StorageId(65_537), - parent: ObjectHandle(7), - filename: "take.wav".to_string(), - size: 1024, - modified: Some(DateTime::new(2026, 5, 7, 12, 30, 45).unwrap()), - ..Default::default() - }; + let mut object = ObjectInfo::default(); + object.handle = ObjectHandle(42); + object.storage_id = StorageId(65_537); + object.parent = ObjectHandle(7); + object.filename = "take.wav".to_string(); + object.size = 1024; + object.modified = Some(DateTime { + year: 2026, + month: 5, + day: 7, + hour: 12, + minute: 30, + second: 45, + }); let entry = RemoteObject::from_object(object); @@ -282,12 +298,8 @@ mod tests { } #[test] - fn sorts_object_infos_folders_before_files_by_name() { - let mut objects = vec![ - object("z.wav", ObjectKind::File), - object("Recordings", ObjectKind::Folder), - object("a.wav", ObjectKind::File), - ]; + fn sorts_object_infos_by_name() { + let mut objects = vec![object("z.wav"), object("Recordings"), object("a.wav")]; sort_object_infos(&mut objects); @@ -296,7 +308,7 @@ mod tests { .into_iter() .map(|object| object.filename) .collect::>(), - vec!["Recordings", "a.wav", "z.wav"] + vec!["a.wav", "Recordings", "z.wav"] ); } @@ -314,18 +326,21 @@ mod tests { } fn entry(name: &str, kind: ObjectKind) -> RemoteObject { - RemoteObject::from_object(object(name, kind)) + RemoteObject { + id: 0, + parent_id: 0, + storage_id: 0, + kind, + name: name.to_string(), + size: 0, + modified: None, + } } - fn object(name: &str, kind: ObjectKind) -> ObjectInfo { - let format = match kind { - ObjectKind::File => ObjectFormatCode::Wav, - ObjectKind::Folder => ObjectFormatCode::Association, - }; - ObjectInfo { - filename: name.to_string(), - format, - ..Default::default() - } + fn object(name: &str) -> ObjectInfo { + let mut object = ObjectInfo::default(); + object.filename = name.to_string(); + object.format = ObjectFormat(0x3008); + object } } diff --git a/src/stat.rs b/src/stat.rs index 7eb609e..e09ca02 100644 --- a/src/stat.rs +++ b/src/stat.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::mtp_session::{MtpOpenPolicy, block_on, open_mtp_session}; use crate::output::AppError; -use crate::remote::{RemoteObject, RemoteTarget, first_storage, resolve_path}; +use crate::remote::{RemoteObject, RemoteTarget, first_storage, format_datetime, resolve_path}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StatReport { @@ -27,9 +27,9 @@ pub fn run_stat( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = read_stat(&session.device, remote_path).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), @@ -44,9 +44,12 @@ async fn read_stat(device: &mtp_rs::MtpDevice, remote_path: &str) -> Result Result, pub supports_rename: bool, pub storage_count: usize, pub storages: Vec, @@ -26,12 +26,12 @@ pub struct MtpStatus { #[derive(Debug, Clone, Serialize)] pub struct StorageStatus { - pub id: u32, + pub id: u64, pub description: String, pub volume_identifier: String, pub max_capacity_bytes: u64, pub free_space_bytes: u64, - pub free_space_objects: u32, + pub free_space_objects: Option, } pub fn run_status(serial: Option<&str>) -> Result { @@ -39,7 +39,7 @@ pub fn run_status(serial: Option<&str>) -> Result { let session = open_mtp_session(serial, MtpOpenPolicy::MtpOnly).await?; let usb = session.prepared.usb.clone(); let mtp = read_mtp_status(&session.device).await?; - session.close().await?; + session.release().await?; Ok(StatusReport { usb, mtp }) }) @@ -57,9 +57,9 @@ pub async fn read_mtp_status(device: &MtpDevice) -> Result id: storage.id().0, description: info.description.clone(), volume_identifier: info.volume_identifier.clone(), - max_capacity_bytes: info.max_capacity, - free_space_bytes: info.free_space_bytes, - free_space_objects: info.free_space_objects, + max_capacity_bytes: info.total_capacity, + free_space_bytes: info.free_space, + free_space_objects: None, } }) .collect(); @@ -69,7 +69,7 @@ pub async fn read_mtp_status(device: &MtpDevice) -> Result model: device_info.model, device_version: device_info.device_version, serial_number: device_info.serial_number, - vendor_extension: device_info.vendor_extension_desc, + vendor_extension: None, supports_rename: device.supports_rename(), storage_count, storages, diff --git a/src/tree.rs b/src/tree.rs index c5d9655..88e2f80 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -1,5 +1,4 @@ -use mtp_rs::Storage; -use mtp_rs::ptp::{ObjectHandle, ObjectInfo}; +use mtp_rs::{ObjectHandle, ObjectInfo, Storage}; use serde::{Deserialize, Serialize}; use crate::mtp_session::{MtpOpenPolicy, block_on, open_mtp_session}; @@ -10,7 +9,7 @@ use crate::remote::{RemoteObject, RemoteTarget, first_storage, list_object_infos #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TreeReport { pub path: String, - pub storage_id: u32, + pub storage_id: u64, pub entries: Vec, } @@ -36,9 +35,9 @@ pub fn run_tree( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = read_tree(&session.device, remote_path, depth).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), @@ -135,7 +134,7 @@ impl TreeEntry { #[cfg(test)] mod tests { - use mtp_rs::ptp::{ObjectFormatCode, ObjectHandle, ObjectInfo}; + use mtp_rs::{ObjectFormat, ObjectHandle, ObjectInfo}; use crate::remote::ObjectKind; @@ -143,13 +142,11 @@ mod tests { #[test] fn builds_tree_entry_from_object() { - let object = ObjectInfo { - handle: ObjectHandle(16), - format: ObjectFormatCode::Wav, - filename: "take.wav".to_string(), - size: 1024, - ..Default::default() - }; + let mut object = ObjectInfo::default(); + object.handle = ObjectHandle(16); + object.format = ObjectFormat(0x3008); + object.filename = "take.wav".to_string(); + object.size = 1024; let entry = TreeEntry::from_object("/recordings/take.wav".to_string(), 1, object); diff --git a/src/usb_owner.rs b/src/usb_owner.rs index ab28562..f287e82 100644 --- a/src/usb_owner.rs +++ b/src/usb_owner.rs @@ -89,6 +89,22 @@ pub fn is_conflicting_owner(owner: &UsbOwner) -> bool { || process.contains("ptpcamerad") } +pub fn ptpcamerad_exclusive_owner_pids(owners: &[UsbOwner]) -> Vec { + let mut pids = owners + .iter() + .filter(|owner| { + owner.kind == UsbOwnerKind::ExclusiveOwner + && owner.scope == UsbOwnerScope::Interface + && owner.process.eq_ignore_ascii_case("ptpcamerad") + }) + .filter_map(|owner| owner.pid) + .collect::>(); + + pids.sort_unstable(); + pids.dedup(); + pids +} + fn parse_ioreg_usb_owners(output: &str) -> Vec { let entries = parse_ioreg_entries(output); let mut owners = Vec::new(); @@ -267,7 +283,9 @@ fn owner_belongs_to_tp7_device(entries: &[IoregEntry], index: usize) -> bool { fn is_tp7_device_entry(entry: &IoregEntry) -> bool { entry.node_class == "IOUSBHostDevice" && entry.id_vendor == Some(crate::device::TP7_VENDOR_ID) - && entry.id_product == Some(crate::device::TP7_PRODUCT_ID) + && entry + .id_product + .is_some_and(crate::device::is_tp7_product_id) } fn nearest_scope_entry(entries: &[IoregEntry], index: usize) -> usize { @@ -432,4 +450,39 @@ mod tests { assert_eq!(owners[0].raw, "pid 92001, other process"); assert_eq!(owners[0].owner_node_name, "other process"); } + + #[test] + fn selects_only_exclusive_ptpcamerad_interface_owners() { + let output = r#" ++-o TP-7 MTP Device@01100000 + | { + | "idVendor" = 9063 + | "idProduct" = 25 + | } + | + +-o MTP Data Intf@0 + | | { + | | "bInterfaceProtocol" = 1 + | | "bInterfaceClass" = 6 + | | "bInterfaceSubClass" = 1 + | | "UsbExclusiveOwner" = "pid 28379, ptpcamerad" + | | "kUSBString" = "MTP Data Intf" + | | "bInterfaceNumber" = 0 + | | } + | | + | +-o ptpcamerad + | { + | "IOUserClientCreator" = "pid 28379, ptpcamerad" + | } + | + +-o field kit + { + "IOUserClientCreator" = "pid 19731, field kit" + } +"#; + + let owners = parse_ioreg_usb_owners(output); + + assert_eq!(ptpcamerad_exclusive_owner_pids(&owners), vec![28379]); + } } diff --git a/src/write_ops.rs b/src/write_ops.rs index 902107d..cf05067 100644 --- a/src/write_ops.rs +++ b/src/write_ops.rs @@ -1,6 +1,4 @@ -use mtp_rs::ResponseCode; -use mtp_rs::Storage; -use mtp_rs::ptp::ObjectHandle; +use mtp_rs::{ObjectHandle, ObjectInfo, Storage}; use serde::{Deserialize, Serialize}; use crate::mtp_session::{MtpOpenPolicy, block_on, map_mtp_error, open_mtp_session}; @@ -41,9 +39,9 @@ pub fn run_mkdir( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = write_mkdir(&session.device, remote_path, parents).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), @@ -63,9 +61,9 @@ pub fn run_rename( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = write_rename(&session.device, remote_path, new_name).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), @@ -86,9 +84,9 @@ pub fn run_rm( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = write_rm(&session.device, remote_path, recursive, force, dry_run).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), @@ -227,7 +225,7 @@ async fn find_child( storage: &Storage, parent: Option, name: &str, -) -> Result, AppError> { +) -> Result, AppError> { Ok(list_object_infos(storage, parent) .await? .into_iter() @@ -270,15 +268,12 @@ fn renamed_path(old_path: &str, new_name: &str) -> String { } fn map_create_folder_error(error: mtp_rs::Error) -> AppError { - match error.response_code() { - Some( - ResponseCode::GeneralError - | ResponseCode::OperationNotSupported - | ResponseCode::StoreReadOnly - | ResponseCode::AccessDenied, - ) => AppError::MtpUnsupported { - message: format!("TP-7 rejected folder creation ({error})"), - }, + match error { + mtp_rs::Error::Unsupported | mtp_rs::Error::AccessDenied | mtp_rs::Error::Other { .. } => { + AppError::MtpUnsupported { + message: format!("TP-7 rejected folder creation ({error})"), + } + } _ => map_mtp_error(error), } } @@ -293,6 +288,18 @@ mod tests { assert!(validate_remote_name("").is_err()); } + #[test] + fn maps_tp7_general_folder_error_to_unsupported() { + let error = mtp_rs::Error::Other { + detail: "GeneralError".to_string(), + }; + + assert!(matches!( + map_create_folder_error(error), + AppError::MtpUnsupported { .. } + )); + } + #[test] fn builds_renamed_root_child_path() { assert_eq!(renamed_path("/old.txt", "new.txt"), "/new.txt");