macos(drive): discover empty-tray drives via IOKit registry-id selectors

Empty-tray optical drives couldn't be opened because the macOS backend
resolved drives by media-child `BSD Name`, which doesn't exist without a
disc loaded. Result: `list_drives()` returned nothing for a drive with no
media, and any attempt to `open("disc://")` on that state failed.

Fix: enumerate `IOBDServices` directly. With media present, the returned
selector is the familiar `/dev/diskN` path (unchanged). With no media,
the selector is an opaque `ioreg:<registry-id>` string that resolves to
the same IOBDServices entry — Disk Arbitration and unmount are skipped
because there's no media node.

- `src/drive/macos.rs`, `src/drive/mod.rs`, `src/scsi/mod.rs`: threads
  the selector through the transport so empty-tray drives are openable
  without punning it as a filesystem path.
- `src/scsi/macos.rs`, `src/scsi/macos_shim.c`: the C shim now accepts
  both `/dev/diskN` and `ioreg:<id>` in `shim_open_exclusive`, and
  `shim_list_drives` reports the correct selector for each drive.
- `tests/macos_drive_tests.rs` (new): covers empty-tray discovery.
- `docs/drive-access.md`, `docs/scsi-macos.md`: document the two-path
  selector model.

[skip-media]
This commit is contained in:
Matthew Jackson
2026-09-25 12:03:02 -07:00
parent f21ab37920
commit 8949b81dfc
8 changed files with 414 additions and 89 deletions
+156
View File
@@ -0,0 +1,156 @@
//! Opt-in checks against an attached macOS optical drive.
//!
//! Run with:
//! `cargo test --test macos_drive_tests -- --ignored --nocapture`
#![cfg(target_os = "macos")]
use libfreemkv::{Drive, DriveStatus, list_drives, scsi::drive_has_disc};
use std::path::Path;
use std::path::PathBuf;
fn drive_name(drive: &libfreemkv::DriveInfo) -> String {
format!("{} {}", drive.vendor.trim(), drive.model.trim())
.trim()
.to_string()
}
/// Exercises the empty-tray path end to end: IOKit enumeration must retain an
/// IOBDServices drive without IOMedia, and the opaque selector must still open
/// that exact service and issue real SCSI commands.
#[test]
#[ignore = "requires an attached optical drive with an empty tray"]
fn empty_tray_drive_is_enumerated_opened_and_reports_no_disc() {
let drives = list_drives();
assert!(!drives.is_empty(), "no optical drive enumerated");
let empty = drives
.iter()
.find(|drive| matches!(drive_has_disc(Path::new(&drive.path)), Ok(false)))
.unwrap_or_else(|| {
panic!(
"no empty optical drive found among {} drive(s)",
drives.len()
)
});
assert!(
empty.path.starts_with("ioreg:"),
"empty-tray path should use its IOKit service selector, got {:?}",
empty.path
);
let mut drive = Drive::open(Path::new(&empty.path)).unwrap_or_else(|e| {
panic!(
"failed to open {} ({}) with empty tray: {e}",
drive_name(empty),
empty.path
)
});
assert_eq!(
drive.drive_id.raw_inquiry.first().map(|byte| byte & 0x1f),
Some(0x05),
"opened service must identify as an MMC optical drive"
);
assert_eq!(
drive.drive_status(),
DriveStatus::NoDisc,
"an empty tray should report NoDisc after a real SCSI status query"
);
// Opening the selected drive through the same session path as the GUI must
// reach the drive and fail because there is no medium, not because the
// optical drive was undiscoverable. Drop the first handle before reopening.
drop(drive);
use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions};
let mut session = DiscSession::open(
DeviceTarget::Path(PathBuf::from(&empty.path)),
KeySpec::default(),
)
.unwrap_or_else(|e| panic!("failed to create empty-tray session: {e}"));
assert!(
session.scan(ScanOptions::default()).is_err(),
"scanning an empty tray must fail as no media, not report an empty drive as a disc"
);
}
/// Reads real filesystem/title metadata from a disc, without muxing or saving
/// its content. Run only after inserting a retail Blu-ray or DVD.
#[test]
#[ignore = "requires an inserted Blu-ray/DVD; scan reads disc metadata"]
fn inserted_disc_scans_filesystem_and_finds_titles() {
use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions};
let drives = list_drives();
let media_checks: Vec<_> = drives
.iter()
.map(|drive| {
(
drive.path.as_str(),
drive.vendor.as_str(),
drive.model.as_str(),
drive_has_disc(Path::new(&drive.path)),
)
})
.collect();
let drive = drives
.iter()
.find(|drive| matches!(drive_has_disc(Path::new(&drive.path)), Ok(true)))
.unwrap_or_else(|| {
panic!("no optical drive with media detected; probes: {media_checks:?}")
});
println!(
"scanning {} ({}) {}",
drive.path,
drive_name(drive),
if drive.path.starts_with("ioreg:") {
"[IOKit service selector]"
} else {
""
}
);
let mut session = DiscSession::open(
DeviceTarget::Path(PathBuf::from(&drive.path)),
KeySpec::default(),
)
.unwrap_or_else(|e| {
panic!(
"failed to open {} ({}): {e}",
drive_name(&drive),
drive.path
)
});
let disc = session.scan(ScanOptions::default()).unwrap_or_else(|e| {
panic!(
"scan failed for {} ({}): {e}",
drive_name(&drive),
drive.path
)
});
assert!(
!disc.titles.is_empty(),
"disc scan completed but found no titles on {} ({})",
drive_name(&drive),
drive.path
);
}
/// Exercises the autodetect route used by bare `disc://` sources. Keeping this
/// separate from the explicit-path test catches discovery/open mismatches.
#[test]
#[ignore = "requires an inserted Blu-ray/DVD; opens the first detected drive"]
fn inserted_disc_autodetects_and_scans_titles() {
use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions};
let mut session = DiscSession::open(DeviceTarget::Autodetect, KeySpec::default())
.unwrap_or_else(|e| panic!("autodetect failed to open the inserted disc: {e}"));
let disc = session
.scan(ScanOptions::default())
.unwrap_or_else(|e| panic!("autodetected drive failed to scan the disc: {e}"));
assert!(
!disc.titles.is_empty(),
"autodetected drive scanned the disc but found no titles"
);
}