diff --git a/Config/products/macpacker.json b/Config/products/macpacker.json index a81e610..0c0dd13 100644 --- a/Config/products/macpacker.json +++ b/Config/products/macpacker.json @@ -38,6 +38,30 @@ { "version": "1.0.1", "items": [ + { + "type": "fix", + "title": { + "en": "Finder actions on external drives", + "zh-Hans": "外置驱动器上的 Finder 操作", + "fr": "Actions Finder sur les disques externes", + "de": "Finder-Aktionen auf externen Laufwerken", + "it": "Azioni Finder sui dischi esterni", + "ja": "外付けドライブでの Finder アクション", + "fa": "عملیات Finder در درایوهای خارجی", + "pl": "Akcje Finder na dyskach zewnętrznych", + "pt-BR": "Ações do Finder em unidades externas", + "ru": "Действия Finder на внешних дисках", + "uk": "Дії Finder на зовнішніх дисках", + "es-MX": "Acciones de Finder en discos externos", + "ko": "외장 드라이브의 Finder 동작", + "nl": "Finder-acties op externe schijven", + "vi": "Thao tác Finder trên ổ đĩa ngoài", + "tr": "Harici sürücülerde Finder işlemleri" + }, + "issues": [ + "275" + ] + }, { "type": "fix", "highlight": true, diff --git a/FinderExtension/FinderSync.swift b/FinderExtension/FinderSync.swift index 64ad45d..fbbd430 100644 --- a/FinderExtension/FinderSync.swift +++ b/FinderExtension/FinderSync.swift @@ -53,18 +53,36 @@ class FinderSync: FIFinderSync { tb.start() log.debug("FinderSync() launched from \(Bundle.main.bundlePath as NSString)") - // Set up the directory we are syncing. - let syncUrls: Set = [ - self.baseFolderUrl, - URL(fileURLWithPath: "/Users/\(ProcessInfo.processInfo.userName)") - ] - FIFinderSyncController.default().directoryURLs = syncUrls - log.debug("Initializing on...") - for syncUrl in syncUrls { - log.debug("\t\(syncUrl.path)") + let notifications = NSWorkspace.shared.notificationCenter + for name in [NSWorkspace.didMountNotification, NSWorkspace.didUnmountNotification, + NSWorkspace.didRenameVolumeNotification] { + notifications.addObserver(self, selector: #selector(volumesDidChange(_:)), name: name, object: nil) } + refreshObservedDirectories() } - + + deinit { + NSWorkspace.shared.notificationCenter.removeObserver(self) + } + + @objc private func volumesDidChange(_ notification: Notification) { + refreshObservedDirectories() + } + + private func refreshObservedDirectories() { + // Finder needs each mounted volume's root, not just its /Volumes parent. + // This only controls menu availability; file access still goes through + // the main app's existing folder permission flow. + let volumes = FileManager.default.mountedVolumeURLs( + includingResourceValuesForKeys: nil, options: [.skipHiddenVolumes]) ?? [] + let roots = FinderObservedDirectories.urls( + homeDirectory: baseFolderUrl, + userName: ProcessInfo.processInfo.userName, + mountedVolumes: volumes) + FIFinderSyncController.default().directoryURLs = roots + log.debug("Observing Finder directories: \(roots.map(\.path).sorted())") + } + // MARK: - Primary Finder Sync protocol methods override func beginObservingDirectory(at url: URL) { diff --git a/Modules/Sources/FinderMenu/FinderObservedDirectories.swift b/Modules/Sources/FinderMenu/FinderObservedDirectories.swift new file mode 100644 index 0000000..bed6f6e --- /dev/null +++ b/Modules/Sources/FinderMenu/FinderObservedDirectories.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Finder asks for context menus only underneath these roots. Watching a root +/// does not grant the app permission to read or write its files. +public enum FinderObservedDirectories { + public static func urls(homeDirectory: URL, userName: String, mountedVolumes: [URL]) -> Set { + var roots: Set = [ + URL(fileURLWithPath: homeDirectory.path, isDirectory: true), + URL(fileURLWithPath: "/Users/\(userName)", isDirectory: true) + ] + for volume in mountedVolumes where volume.isFileURL { + let root = URL(fileURLWithPath: volume.standardizedFileURL.path, isDirectory: true) + // Registering / would include system directories and every user's + // home. Retain the existing home scope on the startup volume. + if root.path != "/" { roots.insert(root) } + } + return roots + } +} diff --git a/Modules/Tests/CoreTests/FinderObservedDirectoriesTests.swift b/Modules/Tests/CoreTests/FinderObservedDirectoriesTests.swift new file mode 100644 index 0000000..f0bb3a0 --- /dev/null +++ b/Modules/Tests/CoreTests/FinderObservedDirectoriesTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +import FinderMenu + +extension AllCoreTests { + struct FinderObservedDirectoriesTests { + private let home = URL(fileURLWithPath: "/Users/tester", isDirectory: true) + + @Test("Finder watches mounted volumes as well as the home folder") + func externalVolumes() { + let external = URL(fileURLWithPath: "/Volumes/External Drive", isDirectory: true) + let roots = FinderObservedDirectories.urls(homeDirectory: home, userName: "tester", mountedVolumes: [external]) + #expect(roots.contains(external)) + #expect(roots.contains(home)) + } + + @Test("Sandbox home keeps the real home fallback") + func sandboxHome() { + let sandbox = URL(fileURLWithPath: "/Users/tester/Library/Containers/app/Data", isDirectory: true) + let roots = FinderObservedDirectories.urls(homeDirectory: sandbox, userName: "tester", mountedVolumes: []) + #expect(roots == [sandbox, home]) + } + + @Test("Mounted roots are normalized and non-file URLs are ignored") + func normalizedRoots() { + let volume = URL(fileURLWithPath: "/Volumes/External Drive", isDirectory: true) + let roots = FinderObservedDirectories.urls(homeDirectory: home, userName: "tester", mountedVolumes: [ + URL(fileURLWithPath: "/Volumes/External Drive/./"), volume, + URL(string: "https://example.com/volume")! + ]) + #expect(roots == [home, volume]) + } + + @Test("Finder does not watch the entire startup volume") + func excludesStartupRoot() { + let roots = FinderObservedDirectories.urls(homeDirectory: home, userName: "tester", mountedVolumes: [URL(fileURLWithPath: "/")]) + #expect(!roots.contains(URL(fileURLWithPath: "/"))) + } + + @Test("Volume refresh removes stale roots after unmount or rename") + func refresh() { + let old = URL(fileURLWithPath: "/Volumes/Old", isDirectory: true) + let new = URL(fileURLWithPath: "/Volumes/New", isDirectory: true) + let before = FinderObservedDirectories.urls(homeDirectory: home, userName: "tester", mountedVolumes: [old]) + let after = FinderObservedDirectories.urls(homeDirectory: home, userName: "tester", mountedVolumes: [new]) + #expect(before.contains(old)) + #expect(after.contains(new)) + #expect(!after.contains(old)) + #expect(FinderObservedDirectories.urls(homeDirectory: home, userName: "tester", mountedVolumes: []).contains(home)) + } + } +} diff --git a/docs/verification/issue-275/README.md b/docs/verification/issue-275/README.md new file mode 100644 index 0000000..0be2197 --- /dev/null +++ b/docs/verification/issue-275/README.md @@ -0,0 +1,40 @@ +# Issue #275 verification + +## Finder on a physical USB drive + +Tested on macOS 27 using a mounted FAT32 USB drive. All operations used a disposable test directory; no existing drive files were changed. + +An isolated ad-hoc-signed review Finder extension contained the patched binary. The installed MacPacker app remained unchanged and handled the existing extraction/compression URL actions. The review extension was disabled for the before image and enabled for the after image. + +| Before: no MacPacker submenu | After: MacPacker submenu available | +| --- | --- | +| ![Before](before.png) | ![After](after.png) | + +Screenshots are genuine Finder context-menu crops. The cursor and unrelated windows are outside the crop. + +- Right-clicking the existing ZIP fixture on the USB drive showed no MacPacker submenu before the fix and showed extraction/compression actions with the review extension enabled. +- Invoked **Extract Here** through the patched Finder extension. All 3 extracted files matched the fixture ZIP byte-for-byte. +- Invoked MacPacker's **Compress to "test-file.zip"** on a disposable text file. The resulting ZIP contained the original file byte-for-byte. +- Removed the review app, extension registration, and disposable USB directory after verification. Confirmed the original installed Finder extension remained enabled. + +## Automated verification + +The initial home-only implementation failed 3 assertions across the mounted-volume and refresh regression tests. After the fix: + +```text +Test run with 5 tests in 2 suites passed. +``` + +The full suite passed before adding the two extra sandbox-home/normalization cases; those extra cases passed in the focused run above: + +```text +Test run with 580 tests in 138 suites passed after 108.639 seconds. +``` + +Both MacPacker and MacPacker Store Release builds succeeded with ONLY_ACTIVE_ARCH=NO. The architecture guard checked the resulting bundles: + +```text +15 Mach-O files checked, every one has an arm64 and an x86_64 slice. +``` + +Mount/unmount/rename root replacement is covered by the pure regression tests. Physical hot-plug and rename notifications were not manually exercised; the user's external drive was left mounted throughout testing. diff --git a/docs/verification/issue-275/after.png b/docs/verification/issue-275/after.png new file mode 100644 index 0000000..f08e625 Binary files /dev/null and b/docs/verification/issue-275/after.png differ diff --git a/docs/verification/issue-275/before.png b/docs/verification/issue-275/before.png new file mode 100644 index 0000000..c40cdb0 Binary files /dev/null and b/docs/verification/issue-275/before.png differ