Skip to content
Open
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: 24 additions & 0 deletions Config/products/macpacker.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 28 additions & 10 deletions FinderExtension/FinderSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<URL> = [
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) {
Expand Down
19 changes: 19 additions & 0 deletions Modules/Sources/FinderMenu/FinderObservedDirectories.swift
Original file line number Diff line number Diff line change
@@ -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<URL> {
var roots: Set<URL> = [
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
}
}
52 changes: 52 additions & 0 deletions Modules/Tests/CoreTests/FinderObservedDirectoriesTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
40 changes: 40 additions & 0 deletions docs/verification/issue-275/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added docs/verification/issue-275/after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/verification/issue-275/before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.