diff --git a/CHANGELOG.md b/CHANGELOG.md
index f61110f..c01cab9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,14 @@ adheres to [Semantic Versioning](https://semver.org) and the
pretty-printed JSON, with `iat`/`nbf`/`exp` rendered as UTC dates plus a
relative hint and a live/expired badge. The signature is shown verbatim and
explicitly marked as unverified.
+- **Drive Ejector**: a new menu bar tool that lists mounted external volumes
+ with their capacity and free space and ejects them individually or all at
+ once. Only removable, non-internal volumes are ever listed, and every volume
+ is re-checked against the mount table the moment before it is ejected, so the
+ startup disk and internal drives cannot be unmounted even if a listed row goes
+ stale. Nothing is ever force-unmounted: when a file is open or Spotlight is
+ still indexing, the disk stays mounted and macOS's own reason is shown inline.
+ The list updates live as disks are plugged in and removed.
## [0.13.0] — 2026-07-12
diff --git a/Package.swift b/Package.swift
index 0992121..d151fdb 100644
--- a/Package.swift
+++ b/Package.swift
@@ -103,6 +103,10 @@ let package = Package(
.executable(
name: "DMonteSnippets",
targets: ["DMonteSnippets"]
+ ),
+ .executable(
+ name: "DMonteDriveEjector",
+ targets: ["DMonteDriveEjector"]
)
],
dependencies: [
@@ -275,6 +279,13 @@ let package = Package(
],
path: "Sources/DMonteSnippetsApp"
),
+ .executableTarget(
+ name: "DMonteDriveEjector",
+ dependencies: [
+ "DMonteCore"
+ ],
+ path: "Sources/DMonteDriveEjectorApp"
+ ),
.testTarget(
name: "DMonteCoreTests",
dependencies: ["DMonteCore"],
diff --git a/Packaging/DriveEjectorInfo.plist b/Packaging/DriveEjectorInfo.plist
new file mode 100644
index 0000000..7260e07
--- /dev/null
+++ b/Packaging/DriveEjectorInfo.plist
@@ -0,0 +1,32 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ DMonteDriveEjector
+ CFBundleIdentifier
+ com.havokentity.mactools.driveejector
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleDisplayName
+ DMonte Drive Ejector
+ CFBundleName
+ DMonte Drive Ejector
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 0.13.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 14.0
+ LSMultipleInstancesProhibited
+
+ LSUIElement
+
+ NSHumanReadableCopyright
+ Copyright © 2026 Yahushad Monte
+
+
diff --git a/README.md b/README.md
index f8a98bb..906da49 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@ The app updates itself automatically via [Sparkle](https://sparkle-project.org);
| **Audio Router** | Virtual audio routing — install independent loopback cables for app‑to‑app audio, build mirror/aggregate devices with presets, and "listen" to any input through an output |
| **Focus Timer** | Pomodoro timer with a live menu bar countdown |
| **Calendar** | Menu bar month view with your upcoming events |
+| **Drive Ejector** | Eject external volumes one at a time or all at once — internal and startup disks are never listed |
| **Keep Awake** | Prevent sleep, optionally for a set duration |
| **Maintenance** | Handy Finder/system toggles and cache/index refreshes |
| **Dev Tools** | JSON, Base64, JWT, URL, hashing, UUID, timestamp, and case utilities |
diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh
index 7718edf..a793b3c 100755
--- a/Scripts/package_app.sh
+++ b/Scripts/package_app.sh
@@ -38,6 +38,7 @@ HELPERS=(
"DMonteWindowManager|DMonte Window Manager.app|WindowManagerInfo.plist"
"DMonteMicControl|DMonte Mic Control.app|MicControlInfo.plist"
"DMonteSnippets|DMonte Snippets.app|SnippetsInfo.plist"
+ "DMonteDriveEjector|DMonte Drive Ejector.app|DriveEjectorInfo.plist"
)
stamp_version() {
diff --git a/Sources/DMonteCore/AppPreferences.swift b/Sources/DMonteCore/AppPreferences.swift
index aa9520b..0ec32bc 100644
--- a/Sources/DMonteCore/AppPreferences.swift
+++ b/Sources/DMonteCore/AppPreferences.swift
@@ -127,7 +127,8 @@ public enum AppDefaults {
DefaultsKey.focusTimerFocusMinutes: 25,
DefaultsKey.focusTimerShortBreakMinutes: 5,
DefaultsKey.focusTimerLongBreakMinutes: 15,
- DefaultsKey.focusTimerLongBreakInterval: 4
+ DefaultsKey.focusTimerLongBreakInterval: 4,
+ DefaultsKey.driveEjectorConfirmsEjectAll: true
])
}
}
diff --git a/Sources/DMonteCore/DriveEjectorController.swift b/Sources/DMonteCore/DriveEjectorController.swift
new file mode 100644
index 0000000..1e235e1
--- /dev/null
+++ b/Sources/DMonteCore/DriveEjectorController.swift
@@ -0,0 +1,293 @@
+import AppKit
+import Foundation
+
+public extension DefaultsKey {
+ /// Whether Eject All asks for a second press before acting. Ejecting every external disk at
+ /// once is the one action here that is both irreversible-in-the-moment and easy to hit by
+ /// accident, so the integrator registers a default of `true` for this key.
+ static let driveEjectorConfirmsEjectAll = "tool.driveEjector.confirmsEjectAll"
+}
+
+/// One volume queued for ejection, flattened out of the `@MainActor` model so the work can hop
+/// to a background task without carrying main-actor state across the boundary.
+private struct EjectRequest: Sendable {
+ let id: String
+ let name: String
+ let url: URL
+}
+
+/// Owns the list of ejectable volumes and the ejection attempts against them.
+///
+/// It is an `NSObject` because it observes `NSWorkspace`'s mount notifications by selector, the
+/// same pattern `ActivationTracker` uses: workspace notifications are posted on the main thread,
+/// so a `@MainActor` selector target receives them already on the right actor.
+///
+/// The controller never decides what is safe to eject — `DriveEjectorKit` does, both when
+/// building the list and again inside every `eject` call.
+@MainActor
+public final class DriveEjectorController: NSObject, ObservableObject {
+ /// The volumes currently offered. Only ever assigned from `DriveEjectorKit.ejectableVolumes()`.
+ @Published public private(set) var volumes: [VolumeDescriptor] = []
+
+ /// The inline status line: progress while ejecting, then the honest result.
+ @Published public private(set) var statusMessage: String?
+
+ /// Mount points whose rows must read as in-progress and refuse a press: the union of the
+ /// ejects this tool started and the unmounts someone else announced. Derived — never assigned
+ /// directly — so the two sources can expire on their own schedules.
+ @Published public private(set) var busyVolumeIDs: Set = []
+
+ /// `true` once Eject All has been requested and is waiting for the confirming press.
+ @Published public private(set) var isConfirmingEjectAll = false
+
+ /// Persisted preference: require a second press for Eject All.
+ @Published public private(set) var confirmsEjectAll: Bool
+
+ /// Mount points with an eject *this tool* started. Cleared only by the completion handler,
+ /// which always runs, so a refresh mid-eject cannot re-enable a button that is still working.
+ private var ejectingVolumeIDs: Set = []
+
+ /// Mount points someone else (Finder, `diskutil`) announced an unmount for, against the moment
+ /// the announcement arrived. Expired by `DriveEjectorKit.liveUnmountAnnouncements`.
+ private var announcedUnmounts: [String: Date] = [:]
+
+ /// How long a foreign unmount announcement keeps a row busy before it is presumed denied.
+ /// Generous enough to cover a slow unmount of a large disk, short enough that a row which was
+ /// vetoed un-sticks itself without the user having to relaunch the tool.
+ private static let unmountAnnouncementGrace: TimeInterval = 15
+
+ public override init() {
+ confirmsEjectAll = AppDefaults.shared.bool(forKey: DefaultsKey.driveEjectorConfirmsEjectAll)
+ super.init()
+ refresh()
+ }
+
+ // No `deinit` cleanup: a nonisolated `deinit` may not touch the main actor under Swift 6, and
+ // removing a selector-based workspace observer requires it. The app delegate calls
+ // `stopObserving()` on termination instead.
+
+ // MARK: - Public API
+
+ /// Re-reads the mount table. Cheap enough to call on every mount notification.
+ public func refresh() {
+ let previousIDs = Set(volumes.map(\.id))
+ volumes = DriveEjectorKit.ejectableVolumes()
+ recomputeBusyVolumeIDs()
+
+ // An armed Eject All authorises the set of volumes the user was looking at when they armed
+ // it. If a disk has appeared or vanished since, the confirming press would act on a list
+ // they never saw, so the arming is dropped and has to be repeated deliberately.
+ if isConfirmingEjectAll, Set(volumes.map(\.id)) != previousIDs {
+ isConfirmingEjectAll = false
+ }
+ }
+
+ /// Rebuilds `busyVolumeIDs` from its two sources, expiring foreign unmount announcements that
+ /// have outlived their grace period.
+ private func recomputeBusyVolumeIDs(now: Date = Date()) {
+ announcedUnmounts = DriveEjectorKit.liveUnmountAnnouncements(
+ announcedUnmounts,
+ mountedIDs: Set(volumes.map(\.id)),
+ now: now,
+ grace: Self.unmountAnnouncementGrace
+ )
+
+ let combined = ejectingVolumeIDs.union(announcedUnmounts.keys)
+ if combined != busyVolumeIDs {
+ busyVolumeIDs = combined
+ }
+ }
+
+ /// Begins observing mount/unmount activity so the list stays truthful without polling.
+ public func startObserving() {
+ let center = NSWorkspace.shared.notificationCenter
+ center.addObserver(
+ self,
+ selector: #selector(volumeWillUnmount(_:)),
+ name: NSWorkspace.willUnmountNotification,
+ object: nil
+ )
+ center.addObserver(
+ self,
+ selector: #selector(volumeDidUnmount(_:)),
+ name: NSWorkspace.didUnmountNotification,
+ object: nil
+ )
+ center.addObserver(
+ self,
+ selector: #selector(volumeDidMount(_:)),
+ name: NSWorkspace.didMountNotification,
+ object: nil
+ )
+ }
+
+ /// Stops observing workspace mount activity (for app-termination cleanup).
+ public func stopObserving() {
+ NSWorkspace.shared.notificationCenter.removeObserver(self)
+ }
+
+ /// Ejects one volume. The verdict is re-derived from fresh resource values inside
+ /// `DriveEjectorKit.eject(volumeAt:)`, so a stale row cannot authorise anything.
+ public func eject(_ volume: VolumeDescriptor) {
+ guard !busyVolumeIDs.contains(volume.id) else {
+ return
+ }
+
+ ejectingVolumeIDs.insert(volume.id)
+ recomputeBusyVolumeIDs()
+ statusMessage = "Ejecting “\(volume.name)”…"
+
+ let request = EjectRequest(id: volume.id, name: volume.name, url: volume.url)
+ Task.detached(priority: .userInitiated) {
+ let outcome = DriveEjectorKit.eject(volumeAt: request.url)
+ await MainActor.run { [weak self] in
+ self?.finish(request, outcome: outcome)
+ }
+ }
+ }
+
+ /// Asks to eject every listed volume. With confirmation enabled the first call only arms the
+ /// action; the second (`confirmEjectAll()`) performs it.
+ public func requestEjectAll() {
+ guard !volumes.isEmpty else {
+ return
+ }
+
+ if confirmsEjectAll, !isConfirmingEjectAll {
+ isConfirmingEjectAll = true
+ return
+ }
+
+ confirmEjectAll()
+ }
+
+ /// Performs the batch eject. Every volume goes through the same per-volume safety re-check as
+ /// a single eject — “all” describes the selection, never a relaxation of the rule.
+ public func confirmEjectAll() {
+ isConfirmingEjectAll = false
+
+ let targets = volumes
+ .filter { !busyVolumeIDs.contains($0.id) }
+ .map { EjectRequest(id: $0.id, name: $0.name, url: $0.url) }
+ guard !targets.isEmpty else {
+ return
+ }
+
+ targets.forEach { ejectingVolumeIDs.insert($0.id) }
+ recomputeBusyVolumeIDs()
+ statusMessage = "Ejecting \(targets.count) volume\(targets.count == 1 ? "" : "s")…"
+
+ Task.detached(priority: .userInitiated) {
+ // Sequential on purpose: run in parallel, macOS stacks up its own “disk in use”
+ // panels and the user cannot tell which volume each one belongs to.
+ var results: [(EjectRequest, EjectionOutcome)] = []
+ for target in targets {
+ results.append((target, DriveEjectorKit.eject(volumeAt: target.url)))
+ }
+
+ await MainActor.run { [weak self] in
+ self?.finishBatch(results)
+ }
+ }
+ }
+
+ /// Abandons an armed Eject All.
+ public func cancelEjectAll() {
+ isConfirmingEjectAll = false
+ }
+
+ /// Updates the Eject All confirmation preference, disarming any request already in flight so
+ /// the pending state cannot outlive the setting that created it.
+ public func setConfirmsEjectAll(_ newValue: Bool) {
+ guard newValue != confirmsEjectAll else {
+ return
+ }
+
+ confirmsEjectAll = newValue
+ AppDefaults.shared.set(newValue, forKey: DefaultsKey.driveEjectorConfirmsEjectAll)
+ isConfirmingEjectAll = false
+ }
+
+ /// Clears the status line, e.g. when the popover is reopened.
+ public func clearStatus() {
+ statusMessage = nil
+ }
+
+ // MARK: - Workspace notifications
+
+ /// A volume is about to go away — mark it busy so its row reads as in-progress rather than
+ /// inviting a press that would race the unmount already under way (which may be Finder's).
+ @objc private func volumeWillUnmount(_ notification: Notification) {
+ guard let url = notification.volumeURL else {
+ return
+ }
+
+ // Through the shared identity because the row IDs come from `mountedVolumeURLs`, and a
+ // marker keyed on a differently-spelled path for the same mount point would silently match
+ // nothing — leaving the row pressable while its unmount is already under way.
+ announcedUnmounts[DriveEjectorKit.volumeIdentity(for: url)] = Date()
+ recomputeBusyVolumeIDs()
+
+ // A denied unmount posts nothing further, so this is the only thing that will release the
+ // marker. Without it a vetoed unmount disables the row until the tool is relaunched.
+ Task { [weak self] in
+ try? await Task.sleep(for: .seconds(Self.unmountAnnouncementGrace))
+ self?.refresh()
+ }
+ }
+
+ @objc private func volumeDidUnmount(_ notification: Notification) {
+ refresh()
+ }
+
+ @objc private func volumeDidMount(_ notification: Notification) {
+ refresh()
+ }
+
+ // MARK: - Private
+
+ private func finish(_ request: EjectRequest, outcome: EjectionOutcome) {
+ ejectingVolumeIDs.remove(request.id)
+ statusMessage = message(for: outcome, volumeName: request.name)
+ refresh()
+ }
+
+ private func finishBatch(_ results: [(EjectRequest, EjectionOutcome)]) {
+ results.forEach { ejectingVolumeIDs.remove($0.0.id) }
+
+ let failures = results.filter { $0.1 != .ejected }
+
+ if failures.isEmpty {
+ let count = results.count
+ statusMessage = "Ejected \(count) volume\(count == 1 ? "" : "s")"
+ } else if let (request, outcome) = failures.first, failures.count == 1 {
+ // One holdout: name it and say exactly why, which is far more actionable than a tally.
+ statusMessage = message(for: outcome, volumeName: request.name)
+ } else {
+ let ejected = results.count - failures.count
+ let names = failures.map { "“\($0.0.name)”" }.joined(separator: ", ")
+ statusMessage = "Ejected \(ejected) of \(results.count) — \(names) still mounted"
+ }
+
+ refresh()
+ }
+
+ private func message(for outcome: EjectionOutcome, volumeName: String) -> String {
+ switch outcome {
+ case .ejected:
+ "Ejected “\(volumeName)”"
+ case .blocked(let verdict):
+ // A blocked eject is a safety refusal, not a failure of the disk — word it as such.
+ "Didn’t eject “\(volumeName)” — \(verdict.blockedReason ?? "it’s no longer eligible")"
+ case .failed(let reason):
+ "Couldn’t eject “\(volumeName)” — \(reason)"
+ }
+ }
+}
+
+private extension Notification {
+ /// The mount point carried by an `NSWorkspace` mount/unmount notification.
+ var volumeURL: URL? {
+ userInfo?[NSWorkspace.volumeURLUserInfoKey] as? URL
+ }
+}
diff --git a/Sources/DMonteCore/DriveEjectorKit.swift b/Sources/DMonteCore/DriveEjectorKit.swift
new file mode 100644
index 0000000..f9565ed
--- /dev/null
+++ b/Sources/DMonteCore/DriveEjectorKit.swift
@@ -0,0 +1,337 @@
+import AppKit
+import Foundation
+
+/// A snapshot of the volume resource keys the eject-safety rule depends on.
+///
+/// Every flag is a tri-state `Bool?` on purpose. `URLResourceValues` returns `nil` for any key
+/// the filesystem declines to vend, and “the system would not tell us” is a materially different
+/// answer from “no” — collapsing the two at read time would let an unclassifiable volume inherit
+/// the permissive branch. `DriveEjectorKit.verdict(for:)` keeps them apart and refuses anything
+/// it cannot positively clear.
+public struct VolumeDescriptor: Identifiable, Sendable, Equatable {
+ /// Mount point, always in canonical spelling. Doubles as the identity: only one volume can
+ /// occupy a mount point at a time.
+ public let url: URL
+ /// Display name (`volumeNameKey`), falling back to the mount point's last path component.
+ public let name: String
+ /// `true` when this is the volume the running system booted from.
+ public let isRootFileSystem: Bool?
+ /// `true` for disks physically inside the Mac (including the sealed system and Data volumes).
+ public let isInternal: Bool?
+ /// `true` for removable *media* — an SD card or optical disc, not an unpluggable drive.
+ /// Reported `false` by USB and Thunderbolt SSDs, so it cannot gate eligibility. Kept for
+ /// display and diagnostics only.
+ public let isRemovable: Bool?
+ /// `true` when the filesystem advertises itself as ejectable. Also `false` on plenty of real
+ /// external drives, so likewise not an eligibility signal. See `isRemovable`.
+ public let isEjectable: Bool?
+ /// `false` for network mounts (SMB, NFS, autofs homes). A drive ejector deals in hardware,
+ /// so a volume that is known-not-local is refused.
+ public let isLocal: Bool?
+ /// Total capacity in bytes; `0` when unavailable.
+ public let totalBytes: UInt64
+ /// Free capacity in bytes; `0` when unavailable.
+ public let freeBytes: UInt64
+
+ public var id: String { DriveEjectorKit.volumeIdentity(for: url) }
+
+ /// Bytes in use, clamped at zero so a stale free-space reading can never produce an underflow.
+ public var usedBytes: UInt64 {
+ totalBytes > freeBytes ? totalBytes - freeBytes : 0
+ }
+
+ public init(
+ url: URL,
+ name: String,
+ isRootFileSystem: Bool?,
+ isInternal: Bool?,
+ isRemovable: Bool?,
+ isEjectable: Bool?,
+ isLocal: Bool? = nil,
+ totalBytes: UInt64,
+ freeBytes: UInt64
+ ) {
+ // Canonicalised on the way in so that the mount point handed to `unmountAndEjectDevice`,
+ // the path the safety rule compares, and the identity the busy-marker bookkeeping keys on
+ // are the same string. A row whose ID disagreed with an in-flight unmount marker would
+ // re-enable itself mid-unmount, which on this tool means offering to eject a disk twice.
+ self.url = url.standardizedFileURL
+ self.name = name
+ self.isRootFileSystem = isRootFileSystem
+ self.isInternal = isInternal
+ self.isRemovable = isRemovable
+ self.isEjectable = isEjectable
+ self.isLocal = isLocal
+ self.totalBytes = totalBytes
+ self.freeBytes = freeBytes
+ }
+}
+
+/// Why a volume may or may not be offered for ejection.
+///
+/// A separate reason (rather than a bare `Bool`) exists so the re-check performed immediately
+/// before unmounting can tell the user *why* a row that looked ejectable a moment ago is not.
+public enum VolumeEjectionVerdict: Sendable, Equatable {
+ /// External, local, and demonstrably not the startup disk.
+ case eligible
+ /// The volume the system booted from. Never ejectable, regardless of its other flags.
+ case bootVolume
+ /// A disk inside the Mac (including the sealed system and Data volumes).
+ case internalDisk
+ /// A network mount rather than a drive. Unmounting one is a different action with different
+ /// consequences, so it is not offered here.
+ case networkVolume
+ /// The filesystem did not vend the flags needed to clear it. Excluded by default.
+ case undetermined
+
+ /// Plain-language explanation for the inline status line; `nil` when the volume is eligible.
+ public var blockedReason: String? {
+ switch self {
+ case .eligible: nil
+ case .bootVolume: "it’s the startup disk"
+ case .internalDisk: "it’s an internal disk"
+ case .networkVolume: "it’s a network volume, not a drive"
+ case .undetermined: "macOS wouldn’t confirm it’s safe to eject"
+ }
+ }
+}
+
+/// What happened when an eject was attempted.
+public enum EjectionOutcome: Sendable, Equatable {
+ /// The volume was unmounted and the device ejected.
+ case ejected
+ /// The safety re-check refused. The volume is untouched and still mounted.
+ case blocked(VolumeEjectionVerdict)
+ /// macOS refused the unmount — almost always because something still holds the volume.
+ /// The volume is still mounted.
+ case failed(String)
+}
+
+/// The eject-safety layer for the Drive Ejector tool.
+///
+/// The entire point of this tool is that it can only ever act on external, removable media, so
+/// the eligibility rule lives here as a pure function over `VolumeDescriptor` and is exercised
+/// directly by the test target. The I/O entry points (`mountedVolumeDescriptors()`,
+/// `eject(volumeAt:)`) are thin: they gather resource values, then defer to the same rule.
+public enum DriveEjectorKit {
+
+ // MARK: - The safety rule
+
+ /// Resource keys the tool reads for every mounted volume. Kept in one place so the list
+ /// enumeration and the pre-eject re-check can never diverge on what they inspect.
+ public static let resourceKeys: [URLResourceKey] = [
+ .volumeNameKey,
+ .volumeIsRootFileSystemKey,
+ .volumeIsInternalKey,
+ .volumeIsRemovableKey,
+ .volumeIsEjectableKey,
+ .volumeIsLocalKey,
+ .volumeTotalCapacityKey,
+ .volumeAvailableCapacityKey
+ ]
+
+ /// The directory macOS reserves for the running system's own volume group — the Data volume,
+ /// `VM`, `Preboot`, `Update`, `xarts`, `iSCPreboot`, `Hardware`.
+ ///
+ /// The enumeration keeps these off the list via `.skipHiddenVolumes`, but that is an option on
+ /// one call site rather than a property of the rule, and `eject(volumeAt:)` re-derives its
+ /// verdict from a bare mount point without going near it. On a Mac booted from external media
+ /// every one of these reports `isInternal == false` plus removable and ejectable, and only
+ /// `/System/Volumes/Data` also carries the root-filesystem flag — so the siblings would clear
+ /// the rule outright. Stated here so both paths inherit the same guarantee.
+ private static let systemVolumeGroupRoot = "/System/Volumes"
+
+ /// The one definition of “which volume is this”, shared by the descriptors, the safety rule and
+ /// the controller's busy markers.
+ ///
+ /// Mount-point URLs reach the tool from three unrelated sources — `mountedVolumeURLs`, the
+ /// `NSWorkspace` unmount notifications, and whatever a caller passes to `eject(volumeAt:)` — and
+ /// nothing guarantees they spell the same mount point identically. Every comparison in the tool
+ /// goes through here so a `..`, a trailing slash or a stray `.` cannot make two names for one
+ /// disk look like two disks.
+ public static func volumeIdentity(for url: URL) -> String {
+ url.standardizedFileURL.path
+ }
+
+ /// Decides whether a volume may be ejected. Deliberately biased towards refusing.
+ ///
+ /// Order matters: the catastrophic case is checked first and by three independent signals, so
+ /// a filesystem that vends nothing useful still cannot get a system volume onto the list.
+ public static func verdict(for descriptor: VolumeDescriptor) -> VolumeEjectionVerdict {
+ // A mount point of "/" is a fact no missing resource key can hide, and the root-filesystem
+ // flag catches the same volume when it is reached through a symlinked or relative URL.
+ // Checked before the removable flags because a Mac booted from an external USB disk reports
+ // that disk as removable *and* ejectable — exactly the combination that would otherwise
+ // wave the startup disk straight through.
+ let path = volumeIdentity(for: descriptor.url)
+ if path == "/" || descriptor.isRootFileSystem == true {
+ return .bootVolume
+ }
+
+ if path == systemVolumeGroupRoot || path.hasPrefix(systemVolumeGroupRoot + "/") {
+ return .bootVolume
+ }
+
+ // A drive ejector deals in hardware. Network mounts are refused before the hardware
+ // flags are consulted, since a share can otherwise look external in every other respect.
+ if descriptor.isLocal == false {
+ return .networkVolume
+ }
+
+ // A volume nobody will classify is one this tool declines to touch: guessing costs the
+ // user a disk, refusing costs a click. Synthetic mounts — the simulator's disk images,
+ // autofs homes — vend no `isInternal` at all and drop out here.
+ guard let isInternal = descriptor.isInternal else {
+ return .undetermined
+ }
+
+ if isInternal {
+ return .internalDisk
+ }
+
+ // `isInternal == false` is the whole rule, because it is the only flag that actually
+ // tracks "this disk is external" on modern hardware.
+ //
+ // Do NOT gate on `isRemovable` / `isEjectable`. In DiskArbitration terms "removable"
+ // means removable *media* — an SD card or an optical disc — not a drive you can
+ // unplug, so a USB or Thunderbolt SSD reports removable=false AND ejectable=false and
+ // a rule keyed on them lists nothing at all. Measured on a Mac Studio: a USB SSD and a
+ // PCIe enclosure both report false/false, while the iOS Simulator's disk images are
+ // the volumes that report true/true. The flags are kept on the descriptor because they
+ // are honest signals for display, but they cannot decide eligibility.
+ //
+ // Safety is unaffected: an externally-booted startup disk is caught above by the
+ // root-filesystem flag and the `/System/Volumes` prefix, both of which run first.
+ return .eligible
+ }
+
+ /// Convenience predicate over `verdict(for:)` for call sites that only need yes/no.
+ public static func isEligibleForEjection(_ descriptor: VolumeDescriptor) -> Bool {
+ verdict(for: descriptor) == .eligible
+ }
+
+ /// The subset of `descriptors` this tool is willing to offer, sorted for stable display.
+ public static func eligibleVolumes(from descriptors: [VolumeDescriptor]) -> [VolumeDescriptor] {
+ descriptors
+ .filter(isEligibleForEjection)
+ .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+ }
+
+ // MARK: - Enumeration
+
+ /// Every mounted volume the tool is willing to offer, already filtered and sorted.
+ public static func ejectableVolumes() -> [VolumeDescriptor] {
+ eligibleVolumes(from: mountedVolumeDescriptors())
+ }
+
+ /// Reads a descriptor for every mounted volume. Unreadable volumes are dropped rather than
+ /// synthesised with default flags, so a read failure can never look like “external and safe”.
+ public static func mountedVolumeDescriptors() -> [VolumeDescriptor] {
+ let urls = FileManager.default.mountedVolumeURLs(
+ includingResourceValuesForKeys: resourceKeys,
+ options: [.skipHiddenVolumes]
+ ) ?? []
+
+ return urls.compactMap(descriptor(forVolumeAt:))
+ }
+
+ /// Reads the safety-relevant resource values for one mount point, or `nil` if the volume
+ /// cannot be inspected at all (it vanished, or the path is not a mount point).
+ public static func descriptor(forVolumeAt url: URL) -> VolumeDescriptor? {
+ guard let values = try? url.resourceValues(forKeys: Set(resourceKeys)) else {
+ return nil
+ }
+
+ // `volumeAvailableCapacityKey` rather than the “important usage” variant: this is a
+ // headline figure for an external disk, and the purgeable-space estimate that variant
+ // adds is a local-snapshot concept that means little on removable media.
+ let total = UInt64(max(0, values.volumeTotalCapacity ?? 0))
+ let free = UInt64(max(0, values.volumeAvailableCapacity ?? 0))
+
+ return VolumeDescriptor(
+ url: url,
+ name: values.volumeName ?? url.lastPathComponent,
+ isRootFileSystem: values.volumeIsRootFileSystem,
+ isInternal: values.volumeIsInternal,
+ isRemovable: values.volumeIsRemovable,
+ isEjectable: values.volumeIsEjectable,
+ isLocal: values.volumeIsLocal,
+ totalBytes: total,
+ freeBytes: free
+ )
+ }
+
+ // MARK: - Foreign unmount announcements
+
+ /// Filters "someone else started unmounting this" markers down to the ones still worth honouring.
+ ///
+ /// `NSWorkspace.willUnmountNotification` is an announcement, not a promise: any app holding the
+ /// volume can dissent, in which case the unmount is abandoned and no `didUnmountNotification`
+ /// ever arrives. A marker kept on that basis alone would disable its row — and exclude it from
+ /// Eject All — for the lifetime of the process, so a volume that is *still mounted* once `grace`
+ /// has elapsed is released. A marker whose volume has gone is dropped immediately: the unmount
+ /// succeeded and there is no row left to protect.
+ static func liveUnmountAnnouncements(
+ _ announcements: [String: Date],
+ mountedIDs: Set,
+ now: Date,
+ grace: TimeInterval
+ ) -> [String: Date] {
+ announcements.filter { id, announcedAt in
+ mountedIDs.contains(id) && now.timeIntervalSince(announcedAt) < grace
+ }
+ }
+
+ // MARK: - Ejection
+
+ /// Unmounts and ejects the device backing `url`, but only after re-deriving the verdict from
+ /// fresh resource values.
+ ///
+ /// The listed snapshot is treated as a hint, never as authorisation: between the user seeing
+ /// a row and pressing its button the mount table can change — a disk image detaches and its
+ /// mount point is reused, a volume is remounted, the machine is rebooted from that very disk.
+ /// This re-check is the only thing standing between a stale row and an unmounted system disk,
+ /// so it runs unconditionally on every single volume, including inside Eject All.
+ ///
+ /// Never force-unmounts and never retries: a refusal means something is still using the
+ /// volume, and the honest outcome is a mounted disk plus an explanation.
+ ///
+ /// Synchronous and blocking — an unmount can take seconds on a busy disk — so callers run it
+ /// off the main actor and report progress through their own state.
+ public static func eject(volumeAt url: URL) -> EjectionOutcome {
+ guard let descriptor = descriptor(forVolumeAt: url) else {
+ return .blocked(.undetermined)
+ }
+
+ let verdict = verdict(for: descriptor)
+ guard verdict == .eligible else {
+ return .blocked(verdict)
+ }
+
+ do {
+ // The descriptor's canonical mount point, not the caller's spelling: the volume that
+ // was cleared and the volume that gets unmounted must be named by the same string.
+ try NSWorkspace.shared.unmountAndEjectDevice(at: descriptor.url)
+ return .ejected
+ } catch {
+ return .failed(failureMessage(for: error))
+ }
+ }
+
+ /// Turns an unmount error into a sentence worth showing.
+ ///
+ /// macOS puts the useful part — often the name of the app holding the volume — in the
+ /// recovery suggestion rather than the description, so both are surfaced when they differ.
+ static func failureMessage(for error: Error) -> String {
+ let nsError = error as NSError
+ let description = nsError.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
+ let suggestion = (nsError.localizedRecoverySuggestion ?? "")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ guard !suggestion.isEmpty, suggestion != description else {
+ return description.isEmpty ? "macOS refused the eject" : description
+ }
+
+ return description.isEmpty ? suggestion : "\(description) \(suggestion)"
+ }
+}
diff --git a/Sources/DMonteCore/DriveEjectorSizing.swift b/Sources/DMonteCore/DriveEjectorSizing.swift
new file mode 100644
index 0000000..087df69
--- /dev/null
+++ b/Sources/DMonteCore/DriveEjectorSizing.swift
@@ -0,0 +1,33 @@
+import AppKit
+
+public enum DriveEjectorSizing {
+ public static func preferredSize() -> NSSize {
+ let scale = currentScale
+ return NSSize(width: (340 * scale).rounded(), height: (450 * scale).rounded())
+ }
+
+ /// The settings overlay, which is centred *over* the panel and therefore must never be wider
+ /// than it. A hard-coded size looks right only at `currentScale == 1`; on a Mac whose menu bar
+ /// is thinner than 26pt the panel shrinks and the sheet does not, so it overflows equally on
+ /// both sides and its first and last characters are clipped.
+ ///
+ /// Capped at the panel rather than scaled with it. The sheet's contents are laid out at this
+ /// size with unscaled padding, so shrinking it further than the panel demands would squeeze
+ /// them for no reason — and on the tools whose sheet is already narrower than their panel,
+ /// scaling would inset it noticeably while fixing nothing.
+ public static func settingsSize() -> NSSize {
+ let panel = preferredSize()
+ // Leave room for the 18pt padding PreferencesOverlay adds around the sheet on every
+ // side: a sheet sized to the full panel becomes panel+36 once padded and spills
+ // the panel, dragging the content behind it off both edges.
+ let overlayChrome: CGFloat = 36
+ return NSSize(width: min(340, panel.width - overlayChrome), height: min(330, panel.height - overlayChrome))
+ }
+
+ static var currentScale: CGFloat {
+ let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
+ let screenScale = visibleFrame.height / 950
+ let menuBarScale = NSStatusBar.system.thickness / 26
+ return min(1.0, max(0.82, min(screenScale, menuBarScale)))
+ }
+}
diff --git a/Sources/DMonteCore/DriveEjectorView.swift b/Sources/DMonteCore/DriveEjectorView.swift
new file mode 100644
index 0000000..c4153b9
--- /dev/null
+++ b/Sources/DMonteCore/DriveEjectorView.swift
@@ -0,0 +1,368 @@
+import AppKit
+import SwiftUI
+
+/// The floating Drive Ejector popover: a live list of external volumes with capacity and free
+/// space, a per-volume eject button, an Eject All action, and an inline status line that reports
+/// refusals verbatim. Content is scaled to match the menu-bar/display scale so it fits the scaled
+/// panel (same approach as the other tools).
+public struct DriveEjectorPopoverView: View {
+ @ObservedObject var controller: DriveEjectorController
+ var onQuit: () -> Void
+
+ @State private var isShowingSettings = false
+ private let scale = DriveEjectorSizing.currentScale
+
+ public init(controller: DriveEjectorController, onQuit: @escaping () -> Void) {
+ self.controller = controller
+ self.onQuit = onQuit
+ }
+
+ private func s(_ value: CGFloat) -> CGFloat { value * scale }
+
+ private var accent: Color { .teal }
+
+ public var body: some View {
+ ZStack {
+ VStack(spacing: 0) {
+ header
+ Divider().opacity(0.6)
+
+ if controller.volumes.isEmpty {
+ emptyState
+ } else {
+ volumeList
+ }
+
+ Spacer(minLength: 0)
+
+ if let status = controller.statusMessage {
+ statusLine(status)
+ }
+
+ ejectAllRow
+ footer
+ }
+
+ if isShowingSettings {
+ PreferencesOverlay(cornerRadius: 18) {
+ DriveEjectorSettingsView(
+ controller: controller,
+ onQuit: onQuit,
+ onClose: { isShowingSettings = false }
+ )
+ }
+ }
+ }
+ .frame(width: DriveEjectorSizing.preferredSize().width, height: DriveEjectorSizing.preferredSize().height)
+ .frostedPanel(cornerRadius: 18)
+ }
+
+ // MARK: - Header
+
+ private var header: some View {
+ HStack(spacing: s(8)) {
+ Image(systemName: "eject.fill")
+ .font(.system(size: s(15), weight: .semibold))
+ .foregroundStyle(controller.volumes.isEmpty ? Color.secondary : accent)
+
+ Text("Drive Ejector")
+ .font(.system(size: s(15), weight: .bold))
+ .foregroundStyle(.primary.opacity(0.9))
+
+ Spacer()
+
+ Button {
+ isShowingSettings = true
+ } label: {
+ Image(systemName: "gearshape.fill")
+ .font(.system(size: s(14), weight: .semibold))
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .help("Settings")
+ }
+ .padding(.horizontal, s(16))
+ .padding(.top, s(14))
+ .padding(.bottom, s(10))
+ }
+
+ // MARK: - Empty state
+
+ private var emptyState: some View {
+ VStack(spacing: s(10)) {
+ Image(systemName: "externaldrive.badge.questionmark")
+ .font(.system(size: s(34), weight: .regular))
+ .foregroundStyle(.secondary)
+ .symbolRenderingMode(.hierarchical)
+
+ Text("No ejectable volumes")
+ .font(.system(size: s(14), weight: .semibold))
+ .foregroundStyle(.secondary)
+
+ Text("Only external, removable disks appear here. Your startup disk and internal drives are never listed.")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, s(24))
+ .padding(.vertical, s(34))
+ }
+
+ // MARK: - Volume list
+
+ private var volumeList: some View {
+ ScrollView {
+ VStack(spacing: s(8)) {
+ ForEach(controller.volumes) { volume in
+ volumeRow(volume)
+ }
+ }
+ .padding(.horizontal, s(16))
+ .padding(.vertical, s(12))
+ }
+ }
+
+ private func volumeRow(_ volume: VolumeDescriptor) -> some View {
+ let isBusy = controller.busyVolumeIDs.contains(volume.id)
+
+ return HStack(spacing: s(10)) {
+ VolumeIcon(url: volume.url, size: s(30))
+
+ VStack(alignment: .leading, spacing: s(2)) {
+ Text(volume.name)
+ .font(.system(size: s(13), weight: .semibold))
+ .lineLimit(1)
+ .truncationMode(.middle)
+
+ Text(capacityLine(volume))
+ .font(.system(size: s(11), weight: .medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ Spacer(minLength: s(6))
+
+ Button {
+ controller.eject(volume)
+ } label: {
+ Image(systemName: "eject.fill")
+ .font(.system(size: s(13), weight: .semibold))
+ .foregroundStyle(isBusy ? Color.secondary : accent)
+ .frame(width: s(30), height: s(26))
+ .background(
+ RoundedRectangle(cornerRadius: s(7), style: .continuous)
+ .fill((isBusy ? Color.secondary : accent).opacity(0.16))
+ )
+ .contentShape(RoundedRectangle(cornerRadius: s(7), style: .continuous))
+ }
+ .buttonStyle(.plain)
+ .disabled(isBusy)
+ .help(isBusy ? "Ejecting…" : "Eject “\(volume.name)”")
+ }
+ .padding(.horizontal, s(10))
+ .padding(.vertical, s(8))
+ .background(
+ RoundedRectangle(cornerRadius: s(10), style: .continuous)
+ .fill(Color.secondary.opacity(isBusy ? 0.06 : 0.12))
+ )
+ .opacity(isBusy ? 0.6 : 1)
+ }
+
+ /// "128GB free of 500GB" — or just the total when the volume declines to report capacity,
+ /// which beats printing a confident "0B free".
+ private func capacityLine(_ volume: VolumeDescriptor) -> String {
+ guard volume.totalBytes > 0 else {
+ return "Capacity unavailable"
+ }
+ return "\(volume.freeBytes.compactBytesString) free of \(volume.totalBytes.compactBytesString)"
+ }
+
+ // MARK: - Status
+
+ private func statusLine(_ status: String) -> some View {
+ Text(status)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, s(16))
+ .padding(.bottom, s(8))
+ }
+
+ // MARK: - Eject All
+
+ @ViewBuilder
+ private var ejectAllRow: some View {
+ if controller.isConfirmingEjectAll {
+ HStack(spacing: s(8)) {
+ Button {
+ controller.confirmEjectAll()
+ } label: {
+ Text("Eject \(controller.volumes.count) Volume\(controller.volumes.count == 1 ? "" : "s")")
+ .font(.system(size: s(12), weight: .semibold))
+ .foregroundStyle(Color.white)
+ .frame(maxWidth: .infinity)
+ .frame(height: s(32))
+ .background(
+ RoundedRectangle(cornerRadius: s(8), style: .continuous)
+ .fill(accent)
+ )
+ .contentShape(RoundedRectangle(cornerRadius: s(8), style: .continuous))
+ }
+ .buttonStyle(.plain)
+
+ Button {
+ controller.cancelEjectAll()
+ } label: {
+ Text("Cancel")
+ .font(.system(size: s(12), weight: .semibold))
+ .foregroundStyle(.primary)
+ .frame(width: s(80))
+ .frame(height: s(32))
+ .background(
+ RoundedRectangle(cornerRadius: s(8), style: .continuous)
+ .fill(Color.secondary.opacity(0.14))
+ )
+ .contentShape(RoundedRectangle(cornerRadius: s(8), style: .continuous))
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, s(16))
+ .padding(.bottom, s(8))
+ } else if !controller.volumes.isEmpty {
+ Button {
+ controller.requestEjectAll()
+ } label: {
+ HStack(spacing: s(8)) {
+ Image(systemName: "eject.circle.fill")
+ .font(.system(size: s(13), weight: .semibold))
+ Text("Eject All")
+ .font(.system(size: s(13), weight: .semibold))
+ }
+ .foregroundStyle(.primary)
+ .frame(maxWidth: .infinity)
+ .frame(height: s(34))
+ .background(
+ RoundedRectangle(cornerRadius: s(8), style: .continuous)
+ .fill(Color.secondary.opacity(0.14))
+ )
+ .contentShape(RoundedRectangle(cornerRadius: s(8), style: .continuous))
+ }
+ .buttonStyle(.plain)
+ .padding(.horizontal, s(16))
+ .padding(.bottom, s(8))
+ }
+ }
+
+ // MARK: - Footer
+
+ private var footer: some View {
+ HStack {
+ Button {
+ controller.refresh()
+ } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ .font(.system(size: s(12), weight: .semibold))
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+
+ Spacer()
+
+ Button {
+ onQuit()
+ } label: {
+ Label("Quit", systemImage: "power")
+ .font(.system(size: s(12), weight: .semibold))
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.horizontal, s(16))
+ .padding(.top, s(4))
+ .padding(.bottom, s(14))
+ }
+}
+
+/// The Finder icon for a mount point, which is what users actually recognise a disk by — a
+/// branded external SSD, a camera card, a mounted disk image all look different here.
+private struct VolumeIcon: View {
+ var url: URL
+ var size: CGFloat
+
+ var body: some View {
+ Image(nsImage: NSWorkspace.shared.icon(forFile: url.path))
+ .resizable()
+ .frame(width: size, height: size)
+ }
+}
+
+// MARK: - Settings
+
+private struct DriveEjectorSettingsView: View {
+ @ObservedObject var controller: DriveEjectorController
+ var onQuit: () -> Void
+ var onClose: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ Text("Drive Ejector Settings")
+ .font(.system(size: 16, weight: .bold))
+ Spacer()
+ Button {
+ onClose()
+ } label: {
+ Image(systemName: "xmark")
+ .font(.system(size: 12, weight: .bold))
+ .frame(width: 24, height: 24)
+ }
+ .buttonStyle(.plain)
+ }
+
+ settingRow(title: "Confirm before Eject All") {
+ GreenSwitch(isOn: Binding(
+ get: { controller.confirmsEjectAll },
+ set: { controller.setConfirmsEjectAll($0) }
+ ))
+ }
+
+ Text("When on, Eject All asks for a second press before unmounting every listed volume.")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Divider()
+
+ Text("Only external, removable volumes are ever listed. Your startup disk and internal drives are excluded, and every volume is re-checked the moment before it is ejected. Nothing here force-unmounts: if a file is open or Spotlight is still indexing, the disk stays mounted and the reason is shown.")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Divider()
+
+ Button(role: .destructive) {
+ onClose()
+ onQuit()
+ } label: {
+ Label("Quit Drive Ejector", systemImage: "power")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ Spacer()
+ }
+ .padding(20)
+ .frame(width: DriveEjectorSizing.settingsSize().width, height: DriveEjectorSizing.settingsSize().height)
+ }
+
+ private func settingRow(title: String, @ViewBuilder trailing: () -> Trailing) -> some View {
+ HStack {
+ Text(title)
+ .font(.system(size: 13, weight: .semibold))
+ Spacer()
+ trailing()
+ }
+ }
+}
diff --git a/Sources/DMonteCore/ToolboxCatalog.swift b/Sources/DMonteCore/ToolboxCatalog.swift
index d03c5e4..96df4b8 100644
--- a/Sources/DMonteCore/ToolboxCatalog.swift
+++ b/Sources/DMonteCore/ToolboxCatalog.swift
@@ -66,7 +66,8 @@ public enum ToolboxCatalog {
ToolboxTool(id: "focusTimer", title: "Focus Timer", iconName: "timer", tint: .red, bundleID: prefix + "focustimer", appName: "DMonte Focus Timer.app", executableName: "DMonteFocusTimer", arguments: ["--open"]),
ToolboxTool(id: "windowManager", title: "Window Manager", iconName: "macwindow.on.rectangle", tint: .blue, bundleID: prefix + "windowmanager", appName: "DMonte Window Manager.app", executableName: "DMonteWindowManager", arguments: ["--open"]),
ToolboxTool(id: "micControl", title: "Mic Control", iconName: "mic.slash.fill", tint: .orange, bundleID: prefix + "miccontrol", appName: "DMonte Mic Control.app", executableName: "DMonteMicControl", arguments: ["--open"]),
- ToolboxTool(id: "snippets", title: "Snippets", iconName: "note.text", tint: .indigo, bundleID: prefix + "snippets", appName: "DMonte Snippets.app", executableName: "DMonteSnippets", arguments: ["--open"])
+ ToolboxTool(id: "snippets", title: "Snippets", iconName: "note.text", tint: .indigo, bundleID: prefix + "snippets", appName: "DMonte Snippets.app", executableName: "DMonteSnippets", arguments: ["--open"]),
+ ToolboxTool(id: "driveEjector", title: "Drive Ejector", iconName: "eject.fill", tint: .teal, bundleID: prefix + "driveejector", appName: "DMonte Drive Ejector.app", executableName: "DMonteDriveEjector", arguments: ["--open"])
]
}
diff --git a/Sources/DMonteDriveEjectorApp/DriveEjectorAppDelegate.swift b/Sources/DMonteDriveEjectorApp/DriveEjectorAppDelegate.swift
new file mode 100644
index 0000000..451e637
--- /dev/null
+++ b/Sources/DMonteDriveEjectorApp/DriveEjectorAppDelegate.swift
@@ -0,0 +1,106 @@
+import AppKit
+import Combine
+import DMonteCore
+import SwiftUI
+
+/// Distributed notification used to reveal this helper's popover when the Toolbox (or a second
+/// launch with `--open`) asks for it.
+enum DriveEjectorNotifications {
+ static let showWindow = Notification.Name("com.havokentity.mactools.driveejector.showWindow")
+}
+
+@MainActor
+final class DriveEjectorAppDelegate: NSObject, NSApplicationDelegate {
+ /// Built lazily, on the first access in `applicationDidFinishLaunching` — i.e. *after*
+ /// `AppDefaults.registerDefaults()`. The controller reads the Eject All confirmation
+ /// preference in `init`, and a registration domain only exists once registered in this
+ /// process, so an eagerly-created controller would read `false` and silently ship the one
+ /// destructive action here with its confirmation turned off.
+ private lazy var controller = DriveEjectorController()
+
+ private var statusItem: HelperStatusItem?
+ private var panelHost: HelperPanelHost?
+ private var cancellables: Set = []
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ AppDefaults.registerDefaults()
+
+ let host = HelperPanelHost(
+ configuration: HelperPanelHost.Configuration(
+ sizing: .preferred({ DriveEjectorSizing.preferredSize() })
+ ),
+ content: .viewController({ [controller, weak self] in
+ NSHostingController(
+ rootView: DriveEjectorPopoverView(controller: controller, onQuit: { self?.quit() })
+ )
+ }),
+ anchorView: { [weak self] in self?.statusItem?.button }
+ )
+ panelHost = host
+ host.configure()
+
+ // The mount table changes while the popover is closed, so the list is refreshed on
+ // every open rather than only on notifications — cheap, and it makes a stale row
+ // impossible to see even if a notification was missed.
+ host.onWillShow = { [controller] in
+ controller.clearStatus()
+ // An armed Eject All must not survive the panel closing: reopening minutes later onto a
+ // primed "Eject 3 Volumes" button is one stray click from unmounting everything.
+ controller.cancelEjectAll()
+ controller.refresh()
+ }
+
+ statusItem = HelperStatusItem(
+ image: Self.statusIcon(hasVolumes: false),
+ toolTip: "Drive Ejector",
+ primaryAction: { [weak self] in self?.panelHost?.toggle() },
+ quitAction: { [weak self] in self?.quit() }
+ )
+
+ // Reflect what is already mounted at launch, before any notification arrives.
+ updateStatusIcon()
+
+ host.observeShowNotification(named: DriveEjectorNotifications.showWindow)
+ controller.startObserving()
+ observeControllerState()
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ panelHost?.stopObservingShowNotifications()
+ panelHost?.removeOutsideClickMonitor()
+ cancellables.removeAll()
+ controller.stopObserving()
+ panelHost?.dismissForTermination()
+ statusItem?.remove()
+ }
+
+ /// The two-state tray glyph: a filled eject symbol while external volumes are mounted, an
+ /// outline when there is nothing to eject. Forced to template so AppKit tints it adaptive
+ /// white and gives it the native rollover highlight.
+ private static func statusIcon(hasVolumes: Bool) -> NSImage {
+ let name = hasVolumes ? "eject.fill" : "eject"
+ let image = NSImage(systemSymbolName: name, accessibilityDescription: "Drive Ejector") ?? NSImage()
+ image.isTemplate = true
+ return image
+ }
+
+ private func updateStatusIcon() {
+ statusItem?.button?.image = Self.statusIcon(hasVolumes: !controller.volumes.isEmpty)
+ }
+
+ /// Keep the tray glyph in sync with what is mounted, so the menu bar answers “is anything
+ /// plugged in?” without opening the popover.
+ private func observeControllerState() {
+ controller.$volumes
+ .receive(on: RunLoop.main)
+ .sink { [weak self] _ in
+ self?.updateStatusIcon()
+ }
+ .store(in: &cancellables)
+ }
+
+ private func quit() {
+ panelHost?.close()
+ NSApp.terminate(nil)
+ }
+}
diff --git a/Sources/DMonteDriveEjectorApp/main.swift b/Sources/DMonteDriveEjectorApp/main.swift
new file mode 100644
index 0000000..fb2edce
--- /dev/null
+++ b/Sources/DMonteDriveEjectorApp/main.swift
@@ -0,0 +1,24 @@
+import AppKit
+import DMonteCore
+
+let singleInstanceGuard = SingleInstanceGuard(identifier: "com.havokentity.mactools.driveejector")
+
+guard singleInstanceGuard.isPrimary else {
+ if CommandLine.arguments.contains("--open") {
+ DistributedNotificationCenter.default().postNotificationName(
+ DriveEjectorNotifications.showWindow,
+ object: nil,
+ userInfo: nil,
+ deliverImmediately: true
+ )
+ }
+
+ exit(EXIT_SUCCESS)
+}
+
+let app = NSApplication.shared
+let delegate = DriveEjectorAppDelegate()
+
+app.delegate = delegate
+app.setActivationPolicy(.accessory)
+app.run()
diff --git a/Tests/DMonteCoreTests/DriveEjectorKitTests.swift b/Tests/DMonteCoreTests/DriveEjectorKitTests.swift
new file mode 100644
index 0000000..a5f41a7
--- /dev/null
+++ b/Tests/DMonteCoreTests/DriveEjectorKitTests.swift
@@ -0,0 +1,535 @@
+import XCTest
+@testable import DMonteCore
+
+/// The eject-safety rule is the entire reason this tool exists: unmounting someone's system disk
+/// is not a bug you apologise for. These tests drive `DriveEjectorKit` with synthetic volume
+/// descriptors covering every combination of the resource keys macOS vends — including the ones
+/// it declines to vend — and assert exactly which volumes are offered.
+///
+/// Everything here is headless-safe: no hardware, no mounts, no network. The two tests that touch
+/// the real filesystem only ever ask about "/" and a nonexistent path, both of which the rule
+/// rejects *before* any unmount is attempted.
+final class DriveEjectorKitTests: XCTestCase {
+
+ // MARK: - Fixtures
+
+ /// Builds a descriptor with the flags spelled out. Defaults are the fully-unknown case so a
+ /// test that omits a flag is exercising the "macOS wouldn't say" branch on purpose.
+ private func descriptor(
+ path: String,
+ name: String,
+ isRootFileSystem: Bool? = nil,
+ isInternal: Bool? = nil,
+ isRemovable: Bool? = nil,
+ isEjectable: Bool? = nil,
+ isLocal: Bool? = true,
+ totalBytes: UInt64 = 500_000_000_000,
+ freeBytes: UInt64 = 250_000_000_000
+ ) -> VolumeDescriptor {
+ VolumeDescriptor(
+ url: URL(fileURLWithPath: path),
+ name: name,
+ isRootFileSystem: isRootFileSystem,
+ isInternal: isInternal,
+ isRemovable: isRemovable,
+ isEjectable: isEjectable,
+ isLocal: isLocal,
+ totalBytes: totalBytes,
+ freeBytes: freeBytes
+ )
+ }
+
+ /// The startup disk of a normally-configured Mac.
+ private var bootVolume: VolumeDescriptor {
+ descriptor(path: "/", name: "Macintosh HD", isRootFileSystem: true, isInternal: true, isRemovable: false, isEjectable: false)
+ }
+
+ /// The writable Data volume of an APFS system group. Internal, and catastrophic to unmount.
+ private var dataVolume: VolumeDescriptor {
+ descriptor(path: "/System/Volumes/Data", name: "Macintosh HD — Data", isRootFileSystem: false, isInternal: true, isRemovable: false, isEjectable: false)
+ }
+
+ /// A second internal SSD in a Mac Pro / Studio.
+ private var internalSecondDisk: VolumeDescriptor {
+ descriptor(path: "/Volumes/Scratch", name: "Scratch", isRootFileSystem: false, isInternal: true, isRemovable: false, isEjectable: true)
+ }
+
+ /// A bus-powered external SSD: not internal, ejectable but not removable media.
+ private var externalSSD: VolumeDescriptor {
+ descriptor(path: "/Volumes/Backup SSD", name: "Backup SSD", isRootFileSystem: false, isInternal: false, isRemovable: false, isEjectable: true)
+ }
+
+ /// A USB SSD exactly as macOS actually describes one — measured on a Mac Studio, where both
+ /// the removable and the ejectable flag come back `false`. The tool listed nothing at all
+ /// until the rule stopped requiring them; this fixture is the regression guard.
+ private var usbSSDAsMacOSReportsIt: VolumeDescriptor {
+ descriptor(path: "/Volumes/T9 MAC OUT", name: "T9 MAC OUT", isRootFileSystem: false, isInternal: false, isRemovable: false, isEjectable: false, isLocal: true)
+ }
+
+ /// A Thunderbolt/PCIe enclosure, likewise measured: false/false, and external only by
+ /// `isInternal`.
+ private var pcieEnclosureAsMacOSReportsIt: VolumeDescriptor {
+ descriptor(path: "/Volumes/XTRM 5 Media", name: "XTRM 5 Media", isRootFileSystem: false, isInternal: false, isRemovable: false, isEjectable: false, isLocal: true)
+ }
+
+ /// An iOS Simulator runtime image. Reports removable *and* ejectable — the exact combination
+ /// the old rule keyed on — while vending no `isInternal`, so it must still be refused.
+ private var simulatorRuntimeImage: VolumeDescriptor {
+ descriptor(path: "/Library/Developer/CoreSimulator/Volumes/iOS_23E254a", name: "iOS 26.4.1 Simulator", isRootFileSystem: false, isInternal: nil, isRemovable: true, isEjectable: true, isLocal: true)
+ }
+
+ /// A USB stick: removable media as well as ejectable.
+ private var usbStick: VolumeDescriptor {
+ descriptor(path: "/Volumes/USB Stick", name: "USB Stick", isRootFileSystem: false, isInternal: false, isRemovable: true, isEjectable: true)
+ }
+
+ /// A mounted disk image.
+ private var diskImage: VolumeDescriptor {
+ descriptor(path: "/Volumes/Installer", name: "Installer", isRootFileSystem: false, isInternal: false, isRemovable: false, isEjectable: true)
+ }
+
+ /// An SMB share. Identified by `isLocal == false` and nothing else: its removable and
+ /// ejectable flags are indistinguishable from a real USB SSD's, which is precisely why the
+ /// rule cannot be built on them.
+ private var networkShare: VolumeDescriptor {
+ descriptor(path: "/Volumes/Studio Share", name: "Studio Share", isRootFileSystem: false, isInternal: false, isRemovable: false, isEjectable: false, isLocal: false)
+ }
+
+ /// A filesystem that vends no classification flags at all (some FUSE mounts behave this way).
+ private var unclassifiable: VolumeDescriptor {
+ descriptor(path: "/Volumes/Mystery", name: "Mystery")
+ }
+
+ // MARK: - The safety rule
+
+ /// THE test. Given the full spread of volume kinds a real Mac can present at once, exactly
+ /// the detachable external ones are offered — by name, in display order.
+ func testOnlyExternalRemovableVolumesAreOffered() {
+ let all = [
+ bootVolume,
+ dataVolume,
+ internalSecondDisk,
+ externalSSD,
+ usbStick,
+ diskImage,
+ networkShare,
+ unclassifiable
+ ]
+
+ XCTAssertEqual(
+ DriveEjectorKit.eligibleVolumes(from: all).map(\.name),
+ ["Backup SSD", "Installer", "USB Stick"],
+ "Only detachable external volumes may be offered; anything internal, boot, fixed or unclassifiable must be filtered out"
+ )
+ }
+
+ func testBootVolumeIsBlocked() {
+ XCTAssertEqual(DriveEjectorKit.verdict(for: bootVolume), .bootVolume)
+ }
+
+ /// The scenario that breaks a naive "not internal && ejectable" rule: a Mac booted from an
+ /// external USB disk reports its startup volume as external, removable AND ejectable. The
+ /// root-filesystem flag must win over all three.
+ func testExternalStartupDiskIsStillBlocked() {
+ let bootedFromUSB = descriptor(
+ path: "/",
+ name: "Field Boot Drive",
+ isRootFileSystem: true,
+ isInternal: false,
+ isRemovable: true,
+ isEjectable: true
+ )
+
+ XCTAssertEqual(DriveEjectorKit.verdict(for: bootedFromUSB), .bootVolume)
+ XCTAssertTrue(DriveEjectorKit.eligibleVolumes(from: [bootedFromUSB]).isEmpty)
+ }
+
+ /// The mount point and the root-filesystem flag are independent signals; either one alone is
+ /// enough to block, so a missing flag cannot expose the startup disk.
+ func testRootMountPointIsBlockedEvenWhenTheRootFlagIsMissing() {
+ let flagless = descriptor(
+ path: "/",
+ name: "Macintosh HD",
+ isRootFileSystem: nil,
+ isInternal: false,
+ isRemovable: true,
+ isEjectable: true
+ )
+
+ XCTAssertEqual(DriveEjectorKit.verdict(for: flagless), .bootVolume)
+ }
+
+ func testRootFlagIsBlockedEvenWhenMountedElsewhere() {
+ let elsewhere = descriptor(
+ path: "/Volumes/Somewhere",
+ name: "Somewhere",
+ isRootFileSystem: true,
+ isInternal: false,
+ isRemovable: true,
+ isEjectable: true
+ )
+
+ XCTAssertEqual(DriveEjectorKit.verdict(for: elsewhere), .bootVolume)
+ }
+
+ /// A non-standardised mount-point URL must not slip past the "/" comparison.
+ func testUnstandardisedRootPathIsBlocked() {
+ let messyRoot = VolumeDescriptor(
+ url: URL(fileURLWithPath: "/Volumes/.."),
+ name: "Macintosh HD",
+ isRootFileSystem: nil,
+ isInternal: false,
+ isRemovable: true,
+ isEjectable: true,
+ totalBytes: 1,
+ freeBytes: 1
+ )
+
+ XCTAssertEqual(DriveEjectorKit.verdict(for: messyRoot), .bootVolume)
+ XCTAssertEqual(messyRoot.id, "/", "The startup disk must not acquire a second identity")
+ }
+
+ /// The external-boot case one level deeper than the startup volume itself. When a Mac boots
+ /// from a USB SSD, the firmlinked members of the running system's volume group sit on that same
+ /// external medium: `isInternal` is false, removable and ejectable are true, and only the Data
+ /// volume carries the root flag. `VM` and `Preboot` therefore clear every other check, and the
+ /// enumeration's `.skipHiddenVolumes` does not protect `eject(volumeAt:)`, which re-derives its
+ /// verdict from a bare mount point.
+ func testSystemVolumeGroupIsBlockedOnAnExternallyBootedMac() {
+ for path in ["/System/Volumes/Data", "/System/Volumes/VM", "/System/Volumes/Preboot", "/System/Volumes/Update"] {
+ let onExternalBootMedia = descriptor(
+ path: path,
+ name: (path as NSString).lastPathComponent,
+ isRootFileSystem: false,
+ isInternal: false,
+ isRemovable: true,
+ isEjectable: true
+ )
+
+ XCTAssertEqual(
+ DriveEjectorKit.verdict(for: onExternalBootMedia),
+ .bootVolume,
+ "\(path) belongs to the running system and must never be offered"
+ )
+ }
+ }
+
+ /// The system-volume guard is a path-prefix rule, so it must match on path *components* and not
+ /// swallow an ordinary user disk whose mount point merely starts with the same characters.
+ func testUserVolumesNamedLikeSystemPathsAreStillOffered() {
+ for path in ["/Volumes/System", "/Volumes/System Volumes Backup"] {
+ let userDisk = descriptor(
+ path: path,
+ name: "Backup",
+ isRootFileSystem: false,
+ isInternal: false,
+ isRemovable: true,
+ isEjectable: true
+ )
+ XCTAssertEqual(DriveEjectorKit.verdict(for: userDisk), .eligible, "\(path) is an ordinary external disk")
+ }
+ }
+
+ func testInternalDisksAreBlockedEvenWhenEjectable() {
+ // Internal drive bays report ejectable; "internal" must veto it.
+ XCTAssertEqual(DriveEjectorKit.verdict(for: internalSecondDisk), .internalDisk)
+
+ // The Data volume is caught earlier, by the system-volume-group rule, so it stays blocked
+ // even on a Mac where "internal" would be false. Either way it is never offered.
+ XCTAssertEqual(DriveEjectorKit.verdict(for: dataVolume), .bootVolume)
+ XCTAssertFalse(DriveEjectorKit.isEligibleForEjection(dataVolume))
+ }
+
+ func testNetworkVolumesAreBlocked() {
+ XCTAssertEqual(DriveEjectorKit.verdict(for: networkShare), .networkVolume)
+ XCTAssertFalse(DriveEjectorKit.isEligibleForEjection(networkShare))
+ }
+
+ /// The regression this tool actually shipped with: every real external drive was refused.
+ ///
+ /// `isRemovable` means removable *media* — an SD card, an optical disc — so USB and
+ /// Thunderbolt SSDs report it `false`, and report `isEjectable` `false` too. A rule that
+ /// required either flag listed nothing at all on a normal Mac. These are the values macOS
+ /// really returned for two attached drives.
+ func testRealExternalDrivesAreOfferedEvenThoughBothFlagsAreFalse() {
+ XCTAssertEqual(DriveEjectorKit.verdict(for: usbSSDAsMacOSReportsIt), .eligible)
+ XCTAssertEqual(DriveEjectorKit.verdict(for: pcieEnclosureAsMacOSReportsIt), .eligible)
+ XCTAssertTrue(DriveEjectorKit.isEligibleForEjection(usbSSDAsMacOSReportsIt))
+ XCTAssertTrue(DriveEjectorKit.isEligibleForEjection(pcieEnclosureAsMacOSReportsIt))
+ }
+
+ /// The mirror image: the volumes that *do* claim removable+ejectable on a real Mac are
+ /// simulator runtime images, which must never be offered. They vend no `isInternal`.
+ func testSimulatorImagesAreRefusedDespiteClaimingRemovableAndEjectable() {
+ XCTAssertEqual(DriveEjectorKit.verdict(for: simulatorRuntimeImage), .undetermined)
+ XCTAssertFalse(DriveEjectorKit.isEligibleForEjection(simulatorRuntimeImage))
+ }
+
+ /// "Cannot be determined" must never resolve to "safe" — but only for the flag the rule
+ /// actually depends on.
+ ///
+ /// `isInternal` is that flag: without it nothing distinguishes an external drive from the
+ /// sealed system volume, so a missing value excludes. `isRemovable` and `isEjectable` are
+ /// deliberately *not* required — real external drives report them as `false`, and requiring
+ /// them (present or true) is what made the tool list nothing at all.
+ func testUndeterminedVolumesAreExcluded() {
+ XCTAssertEqual(DriveEjectorKit.verdict(for: unclassifiable), .undetermined)
+
+ XCTAssertEqual(
+ DriveEjectorKit.verdict(for: descriptor(path: "/Volumes/A", name: "A", isRootFileSystem: false, isInternal: nil, isRemovable: true, isEjectable: true)),
+ .undetermined,
+ "An unknown internal flag must exclude the volume"
+ )
+ XCTAssertEqual(
+ DriveEjectorKit.verdict(for: descriptor(path: "/Volumes/B", name: "B", isRootFileSystem: false, isInternal: false, isRemovable: nil, isEjectable: true)),
+ .eligible,
+ "A missing removable flag is not disqualifying: the rule does not consult it"
+ )
+ XCTAssertEqual(
+ DriveEjectorKit.verdict(for: descriptor(path: "/Volumes/C", name: "C", isRootFileSystem: false, isInternal: false, isRemovable: true, isEjectable: nil)),
+ .eligible,
+ "A missing ejectable flag is not disqualifying: the rule does not consult it"
+ )
+ }
+
+ /// Removable and ejectable are alternatives, not a conjunction: card media reports one, a
+ /// disk image reports the other, and both are legitimately ejectable.
+ func testEitherRemovableOrEjectableIsEnough() {
+ XCTAssertEqual(DriveEjectorKit.verdict(for: externalSSD), .eligible)
+ XCTAssertEqual(DriveEjectorKit.verdict(for: usbStick), .eligible)
+ XCTAssertEqual(DriveEjectorKit.verdict(for: diskImage), .eligible)
+
+ let cardMedia = descriptor(path: "/Volumes/SD Card", name: "SD Card", isRootFileSystem: false, isInternal: false, isRemovable: true, isEjectable: false)
+ XCTAssertEqual(DriveEjectorKit.verdict(for: cardMedia), .eligible)
+ }
+
+ func testIsEligibleAgreesWithVerdict() {
+ for volume in [bootVolume, dataVolume, internalSecondDisk, externalSSD, usbStick, diskImage, networkShare, unclassifiable] {
+ XCTAssertEqual(
+ DriveEjectorKit.isEligibleForEjection(volume),
+ DriveEjectorKit.verdict(for: volume) == .eligible,
+ "The convenience predicate must never disagree with the verdict for \(volume.name)"
+ )
+ }
+ }
+
+ func testEligibleVolumesAreSortedCaseInsensitively() {
+ let volumes = [
+ descriptor(path: "/Volumes/zeta", name: "zeta", isRootFileSystem: false, isInternal: false, isRemovable: true, isEjectable: true),
+ descriptor(path: "/Volumes/Alpha", name: "Alpha", isRootFileSystem: false, isInternal: false, isRemovable: true, isEjectable: true),
+ descriptor(path: "/Volumes/beta", name: "beta", isRootFileSystem: false, isInternal: false, isRemovable: true, isEjectable: true)
+ ]
+
+ XCTAssertEqual(DriveEjectorKit.eligibleVolumes(from: volumes).map(\.name), ["Alpha", "beta", "zeta"])
+ }
+
+ func testEmptyInputYieldsNoVolumes() {
+ XCTAssertTrue(DriveEjectorKit.eligibleVolumes(from: []).isEmpty)
+ }
+
+ // MARK: - Pre-eject re-check
+
+ /// Safe to run: the rule rejects "/" and returns before `unmountAndEjectDevice` is reached,
+ /// which is precisely the guarantee under test.
+ func testEjectingTheStartupDiskIsRefused() {
+ XCTAssertEqual(
+ DriveEjectorKit.eject(volumeAt: URL(fileURLWithPath: "/")),
+ .blocked(.bootVolume),
+ "Ejecting the startup disk must be refused by the re-check, not merely absent from the list"
+ )
+ }
+
+ /// A path that cannot be inspected yields no descriptor, and "no descriptor" must mean
+ /// "refuse" rather than "proceed with defaults".
+ func testEjectingAnUninspectablePathIsRefused() {
+ let missing = URL(fileURLWithPath: "/Volumes/\(UUID().uuidString)")
+ XCTAssertEqual(DriveEjectorKit.eject(volumeAt: missing), .blocked(.undetermined))
+ }
+
+ // MARK: - Foreign unmount announcements
+
+ /// The bug this guards: `willUnmountNotification` fires, the unmount is then *denied* by an app
+ /// holding the volume, and no further notification ever arrives. Keeping the marker on the
+ /// grounds that the volume is still mounted would disable its Eject button — and drop it from
+ /// Eject All — until the tool was relaunched.
+ func testStillMountedAnnouncementIsReleasedAfterTheGracePeriod() {
+ let announced = Date()
+ let announcements = ["/Volumes/USB Stick": announced]
+ let mounted: Set = ["/Volumes/USB Stick"]
+
+ XCTAssertEqual(
+ DriveEjectorKit.liveUnmountAnnouncements(announcements, mountedIDs: mounted, now: announced.addingTimeInterval(5), grace: 15).keys.sorted(),
+ ["/Volumes/USB Stick"],
+ "An unmount still in progress must keep the row busy"
+ )
+
+ XCTAssertTrue(
+ DriveEjectorKit.liveUnmountAnnouncements(announcements, mountedIDs: mounted, now: announced.addingTimeInterval(15), grace: 15).isEmpty,
+ "A volume still mounted after the grace period had its unmount denied; the row must become pressable again"
+ )
+ }
+
+ /// The unmount succeeded, so there is no row left to protect and the marker must not linger to
+ /// suppress a disk later remounted at the same point.
+ func testAnnouncementForAVanishedVolumeIsDroppedImmediately() {
+ let announced = Date()
+ XCTAssertTrue(
+ DriveEjectorKit.liveUnmountAnnouncements(
+ ["/Volumes/USB Stick": announced],
+ mountedIDs: [],
+ now: announced.addingTimeInterval(1),
+ grace: 15
+ ).isEmpty
+ )
+ }
+
+ func testAnnouncementsAreFilteredIndependently() {
+ let now = Date()
+ let live = DriveEjectorKit.liveUnmountAnnouncements(
+ [
+ "/Volumes/Slow": now.addingTimeInterval(-2),
+ "/Volumes/Vetoed": now.addingTimeInterval(-99),
+ "/Volumes/Gone": now.addingTimeInterval(-1)
+ ],
+ mountedIDs: ["/Volumes/Slow", "/Volumes/Vetoed"],
+ now: now,
+ grace: 15
+ )
+
+ XCTAssertEqual(live.keys.sorted(), ["/Volumes/Slow"])
+ }
+
+ // MARK: - Descriptors and formatting
+
+ func testUsedBytesNeverUnderflows() {
+ // A stale free-space reading larger than the capacity must clamp, not wrap around.
+ let odd = descriptor(path: "/Volumes/Odd", name: "Odd", totalBytes: 100, freeBytes: 400)
+ XCTAssertEqual(odd.usedBytes, 0)
+
+ let normal = descriptor(path: "/Volumes/Normal", name: "Normal", totalBytes: 400, freeBytes: 100)
+ XCTAssertEqual(normal.usedBytes, 300)
+ }
+
+ func testDescriptorIdentityIsTheMountPoint() {
+ XCTAssertEqual(usbStick.id, "/Volumes/USB Stick")
+ }
+
+ /// Identity is canonical, so the same mount point spelled three different ways is one volume.
+ /// This is what lets a busy marker keyed on a `willUnmount` notification's URL match the row
+ /// built from `mountedVolumeURLs`; without it a row would stay pressable mid-unmount.
+ func testDescriptorIdentityIsCanonical() {
+ for spelling in ["/Volumes/USB Stick", "/Volumes/USB Stick/", "/Volumes/./USB Stick", "/Volumes/Other/../USB Stick"] {
+ XCTAssertEqual(
+ descriptor(path: spelling, name: "USB Stick").id,
+ usbStick.id,
+ "\(spelling) names the same mount point and must produce the same identity"
+ )
+ }
+ }
+
+ /// The descriptor's own mount point is canonicalised too — it is the URL handed to
+ /// `unmountAndEjectDevice`, so it must name the same volume the safety rule cleared.
+ func testDescriptorURLIsCanonicalised() {
+ XCTAssertEqual(descriptor(path: "/Volumes/Other/../USB Stick", name: "USB Stick").url.path, "/Volumes/USB Stick")
+ }
+
+ /// A busy marker and a row ID derived from different spellings of one mount point must collide,
+ /// which is the whole point of routing both through `volumeIdentity(for:)`.
+ func testBusyMarkerMatchesARowBuiltFromADifferentSpelling() {
+ let announcement = DriveEjectorKit.volumeIdentity(for: URL(fileURLWithPath: "/Volumes/Other/../USB Stick/"))
+ XCTAssertTrue(
+ DriveEjectorKit.liveUnmountAnnouncements(
+ [announcement: Date()],
+ mountedIDs: [usbStick.id],
+ now: Date(),
+ grace: 15
+ ).keys.contains(usbStick.id),
+ "An unmount announced for a non-canonical path must keep the matching row busy"
+ )
+ }
+
+ /// Canonicalising identity must not weaken the rule: a system-volume-group member reached
+ /// through a non-canonical path is still refused.
+ func testNonCanonicalSystemVolumePathsAreStillBlocked() {
+ for path in ["/System/Volumes/Data/", "/System/Volumes/./VM", "/Volumes/../System/Volumes/Preboot", "/System/Volumes/Update/.."] {
+ let onExternalBootMedia = descriptor(
+ path: path,
+ name: "Data",
+ isRootFileSystem: false,
+ isInternal: false,
+ isRemovable: true,
+ isEjectable: true
+ )
+ XCTAssertEqual(
+ DriveEjectorKit.verdict(for: onExternalBootMedia),
+ .bootVolume,
+ "\(path) resolves inside the running system's volume group and must never be offered"
+ )
+ }
+ }
+
+ func testBlockedReasonsReadAsSentencesAndEligibleHasNone() {
+ XCTAssertNil(VolumeEjectionVerdict.eligible.blockedReason)
+ for verdict: VolumeEjectionVerdict in [.bootVolume, .internalDisk, .networkVolume, .undetermined] {
+ let reason = verdict.blockedReason ?? ""
+ XCTAssertFalse(reason.isEmpty, "Every blocking verdict needs an explanation to show the user")
+ XCTAssertFalse(reason.contains("..."), "Typography: use a real ellipsis, never three dots")
+ }
+ }
+
+ func testFailureMessageCombinesDescriptionAndRecoverySuggestion() {
+ // macOS routinely puts the name of the app holding the volume in the recovery suggestion,
+ // so dropping it would throw away the only actionable part of the failure.
+ let withSuggestion = NSError(domain: NSCocoaErrorDomain, code: 1, userInfo: [
+ NSLocalizedDescriptionKey: "The disk is in use.",
+ NSLocalizedRecoverySuggestionErrorKey: "Quit Preview and try again."
+ ])
+ XCTAssertEqual(
+ DriveEjectorKit.failureMessage(for: withSuggestion),
+ "The disk is in use. Quit Preview and try again."
+ )
+ }
+
+ func testFailureMessageDoesNotRepeatItself() {
+ let duplicated = NSError(domain: NSCocoaErrorDomain, code: 1, userInfo: [
+ NSLocalizedDescriptionKey: "The disk is in use.",
+ NSLocalizedRecoverySuggestionErrorKey: "The disk is in use."
+ ])
+ XCTAssertEqual(DriveEjectorKit.failureMessage(for: duplicated), "The disk is in use.")
+ }
+
+ func testFailureMessageAlwaysSaysSomething() {
+ let blank = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: ""])
+ XCTAssertFalse(
+ DriveEjectorKit.failureMessage(for: blank).isEmpty,
+ "A silent failure would leave the status line blank after a failed eject"
+ )
+ }
+
+ // MARK: - Live enumeration (environment-tolerant)
+
+ /// Whatever this machine happens to have mounted, nothing the tool offers may be internal or
+ /// the startup disk. Asserts an invariant rather than a count, so it is stable on CI.
+ func testNothingOfferedOnThisMachineIsInternalOrBoot() {
+ for volume in DriveEjectorKit.ejectableVolumes() {
+ XCTAssertNotEqual(volume.url.standardizedFileURL.path, "/", "The startup disk was offered")
+ XCTAssertNotEqual(volume.isRootFileSystem, true, "The root filesystem was offered")
+ XCTAssertNotEqual(volume.isInternal, true, "An internal disk was offered: \(volume.name)")
+ XCTAssertNotEqual(volume.isLocal, false, "A network volume was offered: \(volume.name)")
+ // Deliberately NOT asserting isRemovable/isEjectable here. Real external drives
+ // report both as false, so requiring either is what made this tool list nothing.
+ }
+ }
+
+ func testMountedDescriptorsIncludeTheStartupDiskAndTheRuleRejectsIt() {
+ let descriptors = DriveEjectorKit.mountedVolumeDescriptors()
+ XCTAssertFalse(descriptors.isEmpty, "A running Mac always has at least the startup volume mounted")
+
+ guard let root = descriptors.first(where: { $0.url.standardizedFileURL.path == "/" }) else {
+ // Some sandboxes hide "/" from the volume enumeration; the "/" eject test above still
+ // covers the refusal path directly.
+ return
+ }
+ XCTAssertEqual(DriveEjectorKit.verdict(for: root), .bootVolume)
+ }
+}
diff --git a/Tests/DMonteCoreTests/DriveEjectorSizingTests.swift b/Tests/DMonteCoreTests/DriveEjectorSizingTests.swift
new file mode 100644
index 0000000..c71a7b2
--- /dev/null
+++ b/Tests/DMonteCoreTests/DriveEjectorSizingTests.swift
@@ -0,0 +1,44 @@
+import AppKit
+import XCTest
+@testable import DMonteCore
+
+/// The settings sheet is centred over the panel, so a sheet wider than the panel is clipped on
+/// both sides — the title loses its first and last characters and the trailing switch runs off the
+/// edge. That is invisible on a Mac where `currentScale` happens to be 1, which is why it shipped:
+/// the sheet's size was a literal while the panel's was scaled.
+final class DriveEjectorSizingTests: XCTestCase {
+
+ func testSettingsSheetNeverExceedsThePanel() {
+ let panel = DriveEjectorSizing.preferredSize()
+ let settings = DriveEjectorSizing.settingsSize()
+
+ XCTAssertLessThanOrEqual(
+ settings.width, panel.width,
+ "A settings sheet wider than the panel is clipped on both sides"
+ )
+ XCTAssertLessThanOrEqual(
+ settings.height, panel.height,
+ "A settings sheet taller than the panel cannot show its bottom row — on this sheet, Quit"
+ )
+ }
+
+ /// The sheet should be as large as it was designed to be, shrinking only as far as the panel
+ /// forces. Asserting the contract rather than restating the arithmetic: when this rule changed
+ /// from "scale with the panel" to "cap at the panel", the tests that restated the formula
+ /// failed while the ones asserting the relationship kept passing.
+ func testSettingsUsesItsDesignSizeUnlessThePanelIsSmaller() {
+ let panel = DriveEjectorSizing.preferredSize()
+ let settings = DriveEjectorSizing.settingsSize()
+
+ XCTAssertEqual(settings.width, min(340, panel.width - 36), accuracy: 1)
+ XCTAssertEqual(settings.height, min(330, panel.height - 36), accuracy: 1)
+ }
+
+ /// The scale is derived from live screen and menu-bar metrics, so the guarantee has to hold
+ /// across the whole range rather than at whatever this machine reports today.
+ func testScaleStaysWithinItsDocumentedBounds() {
+ let scale = DriveEjectorSizing.currentScale
+ XCTAssertGreaterThanOrEqual(scale, 0.82)
+ XCTAssertLessThanOrEqual(scale, 1.0)
+ }
+}