Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions FinderSyncExt/FinderSyncExt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ private let logger = Logger(
/// 只负责菜单渲染和事件转发,不读取 SwiftData
class FinderSyncExt: FIFinderSync, @unchecked Sendable {

private var volumeObserver: MountedVolumeObserver?

// MARK: - Properties

/// 菜单配置缓存(内存缓存,从 Main App 推送)
Expand Down Expand Up @@ -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> = [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
Expand Down
93 changes: 93 additions & 0 deletions RClickTests/MountedVolumeObserverTests.swift
Original file line number Diff line number Diff line change
@@ -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<URL>] = []
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<URL>] = []
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<URL> = []
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])
}
}

}
65 changes: 65 additions & 0 deletions Shared/MountedVolumeObserver.swift
Original file line number Diff line number Diff line change
@@ -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<URL>) -> Void
private var directories: Set<URL> = []
private var hasVolumeSnapshot = false

init(center: NotificationCenter = NSWorkspace.shared.notificationCenter,
mountedVolumes: @escaping () -> [URL]? = {
FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: nil, options: [])
},
update: @escaping (Set<URL>) -> 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> = [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) }
}
}
51 changes: 51 additions & 0 deletions specs/external-volumes-observation.md
Original file line number Diff line number Diff line change
@@ -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