From 1123e2e84724a7a0d4b477ce9b8761263ce9009b Mon Sep 17 00:00:00 2001 From: Juns <92599167+Juns-g@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:38:30 +0800 Subject: [PATCH] fix: observe mounted volumes and their lifecycle in Finder Sync Register each mounted volume explicitly and apply workspace mount, unmount and rename paths without trusting stale unmount enumeration. Add lifecycle regression coverage and document the real DMG probe and remaining signed Finder verification. Fixes #148 Fixes #150 --- FinderSyncExt/FinderSyncExt.swift | 24 ++--- RClickTests/MountedVolumeObserverTests.swift | 93 ++++++++++++++++++++ Shared/MountedVolumeObserver.swift | 65 ++++++++++++++ specs/external-volumes-observation.md | 51 +++++++++++ 4 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 RClickTests/MountedVolumeObserverTests.swift create mode 100644 Shared/MountedVolumeObserver.swift create mode 100644 specs/external-volumes-observation.md diff --git a/FinderSyncExt/FinderSyncExt.swift b/FinderSyncExt/FinderSyncExt.swift index ba3d482..657936a 100644 --- a/FinderSyncExt/FinderSyncExt.swift +++ b/FinderSyncExt/FinderSyncExt.swift @@ -23,6 +23,8 @@ private let logger = Logger( /// 只负责菜单渲染和事件转发,不读取 SwiftData class FinderSyncExt: FIFinderSync, @unchecked Sendable { + private var volumeObserver: MountedVolumeObserver? + // MARK: - Properties /// 菜单配置缓存(内存缓存,从 Main App 推送) @@ -84,19 +86,17 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { // MARK: - Directory Observing - /// 设置监听目录(全盘监听) - /// - /// Observing "/" covers every reachable folder — /Users, /Applications, - /// /opt, /tmp, external and network volumes (mounted under /Volumes) — - /// which is what the original per-path list intended but missed for - /// anything on the system volume outside /Users. FileProvider-backed - /// locations (iCloud Drive, synced Desktop & Documents) still get no - /// FinderSync menus; that is a macOS restriction on all FinderSync - /// extensions, not something an observation URL can change. + /// Register each mounted volume explicitly; filesystem ancestry alone is not + /// a reliable observation boundary for Finder Sync across mount points. private func setupObservingDirectories() { - let directories: Set = [URL(fileURLWithPath: "/")] - FIFinderSyncController.default().directoryURLs = directories - logger.info("Observing directories: \(directories.map { $0.path })") + // Finder Sync requires an initial set during extension startup. + FIFinderSyncController.default().directoryURLs = [URL(fileURLWithPath: "/")] + Task { @MainActor [weak self] in + self?.volumeObserver = MountedVolumeObserver { directories in + FIFinderSyncController.default().directoryURLs = directories + logger.info("Observing \(directories.count) filesystem roots") + } + } } // MARK: - Message Handling diff --git a/RClickTests/MountedVolumeObserverTests.swift b/RClickTests/MountedVolumeObserverTests.swift new file mode 100644 index 0000000..76e54ae --- /dev/null +++ b/RClickTests/MountedVolumeObserverTests.swift @@ -0,0 +1,93 @@ +import AppKit +import Testing +@testable import RClick + +@MainActor +struct MountedVolumeObserverTests { + @Test func tracksStartupMountRenameAndUnmountWithoutRestart() { + let center = NotificationCenter() + let root = URL(fileURLWithPath: "/") + let disk = URL(fileURLWithPath: "/Volumes/External Disk") + let nas = URL(fileURLWithPath: "/Volumes/NAS") + let dmg = URL(fileURLWithPath: "/Volumes/Installer") + let renamed = URL(fileURLWithPath: "/Volumes/Renamed Disk") + let custom = URL(fileURLWithPath: "/mnt/nfs") + var volumes: [URL]? = [root, disk, nas, custom] + var updates: [Set] = [] + let observer = MountedVolumeObserver(center: center, mountedVolumes: { volumes }) { + updates.append($0) + } + withExtendedLifetime(observer) { + #expect(updates.last == [root, disk, nas, custom]) + volumes?.append(dmg) + center.post(name: NSWorkspace.didMountNotification, object: nil) + #expect(updates.last == [root, disk, nas, custom, dmg]) + volumes = [root, renamed, nas, custom, dmg] + center.post(name: NSWorkspace.didRenameVolumeNotification, object: nil) + #expect(updates.last == [root, renamed, nas, custom, dmg]) + volumes = [root, nas, custom] + center.post(name: NSWorkspace.didUnmountNotification, object: nil) + #expect(updates.last == [root, nas, custom]) + #expect(updates.count == 4) + center.post(name: NSWorkspace.didMountNotification, object: nil) + #expect(updates.count == 4) + volumes = nil + center.post(name: NSWorkspace.didUnmountNotification, object: nil) + #expect(updates.last == [root, nas, custom]) + #expect(updates.count == 4) + volumes = [] + center.post(name: NSWorkspace.didUnmountNotification, object: nil) + #expect(updates.last == [root]) + } + } + + @Test func failedInitialEnumerationRecoversAndObserversAreReleased() { + let center = NotificationCenter() + var volumes: [URL]? + var reads = 0 + var updates: [Set] = [] + var observer: MountedVolumeObserver? = MountedVolumeObserver(center: center, mountedVolumes: { + reads += 1 + return volumes + }, update: { updates.append($0) }) + weak var weakObserver = observer + #expect(reads == 1) + #expect(updates.last == [URL(fileURLWithPath: "/")]) + let nas = URL(fileURLWithPath: "/Volumes/Reconnected NAS") + let existing = URL(fileURLWithPath: "/Volumes/Already Mounted") + volumes = [nas, existing] + center.post(name: NSWorkspace.didMountNotification, object: nil, + userInfo: [NSWorkspace.volumeURLUserInfoKey: nas]) + #expect(reads == 2) + #expect(updates.last == [URL(fileURLWithPath: "/"), nas, existing]) + observer = nil + #expect(weakObserver == nil) + center.post(name: NSWorkspace.didMountNotification, object: nil) + #expect(reads == 2) + } + @Test func notificationPathsOverrideStaleVolumeEnumeration() { + let center = NotificationCenter() + let root = URL(fileURLWithPath: "/") + let old = URL(fileURLWithPath: "/Volumes/Old") + let new = URL(fileURLWithPath: "/Volumes/New") + var snapshot = [old] + var latest: Set = [] + let observer = MountedVolumeObserver(center: center, mountedVolumes: { snapshot }) { latest = $0 } + withExtendedLifetime(observer) { + // Real NSWorkspace unmount notifications can precede the enumeration update. + center.post(name: NSWorkspace.didUnmountNotification, object: nil, + userInfo: [NSWorkspace.volumeURLUserInfoKey: old]) + #expect(latest == [root]) + snapshot = [] + center.post(name: NSWorkspace.didMountNotification, object: nil, + userInfo: [NSWorkspace.volumeURLUserInfoKey: old]) + #expect(latest == [root, old]) + snapshot = [old] + center.post(name: NSWorkspace.didRenameVolumeNotification, object: nil, + userInfo: [NSWorkspace.volumeURLUserInfoKey: new, + NSWorkspace.oldVolumeURLUserInfoKey: old]) + #expect(latest == [root, new]) + } + } + +} diff --git a/Shared/MountedVolumeObserver.swift b/Shared/MountedVolumeObserver.swift new file mode 100644 index 0000000..5d31d4a --- /dev/null +++ b/Shared/MountedVolumeObserver.swift @@ -0,0 +1,65 @@ +import AppKit + +/// Keeps Finder's observation roots in sync without traversing mounted filesystems. +@MainActor +final class MountedVolumeObserver { + private let center: NotificationCenter + private var observers: [NSObjectProtocol] = [] + private let mountedVolumes: () -> [URL]? + private let update: (Set) -> Void + private var directories: Set = [] + private var hasVolumeSnapshot = false + + init(center: NotificationCenter = NSWorkspace.shared.notificationCenter, + mountedVolumes: @escaping () -> [URL]? = { + FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: nil, options: []) + }, + update: @escaping (Set) -> Void) { + self.center = center + self.mountedVolumes = mountedVolumes + self.update = update + // Subscribe before the initial snapshot so a mount during startup is not missed. + for name in [NSWorkspace.didMountNotification, NSWorkspace.didUnmountNotification, + NSWorkspace.didRenameVolumeNotification] { + observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] notification in + let name = notification.name + let volume = notification.userInfo?[NSWorkspace.volumeURLUserInfoKey] as? URL + let oldVolume = notification.userInfo?[NSWorkspace.oldVolumeURLUserInfoKey] as? URL + MainActor.assumeIsolated { self?.refresh(name: name, volume: volume, oldVolume: oldVolume) } + }) + } + refresh() + } + + private func refresh(name: Notification.Name? = nil, volume: URL? = nil, oldVolume: URL? = nil) { + var next: Set = [URL(fileURLWithPath: "/")] + if volume != nil && hasVolumeSnapshot { + next.formUnion(directories) + } else if let volumes = mountedVolumes() { + // Include network, hidden and custom mount locations. Retry a failed + // initial snapshot on the next event, even if that event carries a URL. + next.formUnion(volumes) + hasVolumeSnapshot = true + } else { + // A failed enumeration is not evidence that all volumes were unmounted. + next.formUnion(directories) + } + if let volume { + // Enumeration can still contain an ejected volume when didUnmount fires. + // Apply the event last so stale enumeration cannot override its paths. + if name == NSWorkspace.didUnmountNotification { + next.remove(volume) + } else { + if let oldVolume { next.remove(oldVolume) } + next.insert(volume) + } + } + guard next != directories else { return } + directories = next + update(next) + } + + isolated deinit { + for observer in observers { center.removeObserver(observer) } + } +} diff --git a/specs/external-volumes-observation.md b/specs/external-volumes-observation.md new file mode 100644 index 0000000..6cef98b --- /dev/null +++ b/specs/external-volumes-observation.md @@ -0,0 +1,51 @@ +# External and network volume observation (#148, #150) + +Finder observation and file-operation authorization are separate. Registering a +security-scoped bookmark in the app does not add a Finder Sync observation root. +The old extension registered only `/`, once at startup. It neither enumerated +mounted volumes nor handled volume lifecycle changes. + +Apple documents recursive subdirectory observation, but does not explicitly +promise traversal across volume boundaries. Adding `/Volumes` alone still relies +on that assumption and misses volumes mounted elsewhere. Register `/` plus all +URLs from `FileManager.mountedVolumeURLs(includingResourceValuesForKeys:nil, +options:[])`, without filtering out network, hidden, or non-removable volumes. +No directory traversal or volume resource-value queries are needed. + +Subscribe to `NSWorkspace.shared.notificationCenter` before taking the initial +snapshot. Apply mount/unmount/rename notification URLs directly to the current +set. A real temporary HFS+ DMG probe demonstrated that, during didUnmount, volume +enumeration can still return the ejected volume. Blindly rescanning on every +notification therefore leaves stale roots. Re-enumerate only when notification +URLs are unavailable or the initial snapshot has not succeeded; preserve the +previous set on enumeration failure and apply event paths after any retry. Assign +`directoryURLs` only when the set changes. Observer lifetime follows the extension; +UI-framework interaction and observer cleanup use the main actor. + +## Validation + +- Release build and the complete existing test suite passed with + `CODE_SIGNING_ALLOWED=NO` under Swift 6. +- Regression test `notificationPathsOverrideStaleVolumeEnumeration` failed with + rescan-only handling and passed after applying notification URLs. +- Tests cover startup volumes, simulated external/NAS/custom mount paths, + mount/unmount/rename, duplicate events, failed enumeration and cleanup. +- A temporary DMG using the production observer and real NSWorkspace notifications + produced `probe-volume-present=false → true → false` without restarting the + observer. The DMG was ejected after the probe. +- Normal signing remains blocked by missing upstream provisioning profiles. + This verifies observation-root management, not a signed Finder installation or + physical USB / SMB / NFS / AFP menu appearance and action execution. + +After signing and enabling the extension, check a volume mounted before launch, +a USB drive and NAS mounted while running, a mounted DMG, rename/eject/remount, +and a normal local folder. Check menus inside each volume as well as on its root; +then verify an innocuous action such as Copy Path. File Provider-managed locations +and sandbox permissions are separate concerns; this change does not bypass them. + +Sources: +- https://developer.apple.com/documentation/findersync/fifindersynccontroller/directoryurls +- https://developer.apple.com/documentation/foundation/filemanager/mountedvolumeurls(includingresourcevaluesforkeys:options:) +- https://developer.apple.com/documentation/appkit/nsworkspace/didmountnotification +- https://developer.apple.com/documentation/appkit/nsworkspace/didunmountnotification +- https://developer.apple.com/documentation/appkit/nsworkspace/didrenamevolumenotification