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