From 897ba17b50d5cd475354b5d07d7315d7c40b2cf7 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Fri, 28 Aug 2026 12:51:24 +0530 Subject: [PATCH 1/8] feat: add Finder Tools extension --- .../Settings/Views/ExtensionsPane.swift | 3 +- .../Settings/Views/FinderToolsRows.swift | 62 ++ .../EdithCore/ExtensionLifecycle.swift | 37 + .../Sources/EdithCore/ExtensionRegistry.swift | 6 + .../Core/Application/AppServices.swift | 10 + .../Services/FinderToolsService.swift | 682 ++++++++++++++++++ .../Settings/Services/SettingsBackup.swift | 6 + .../Core/Defaults/AppStorageKeys.swift | 8 + .../Core/Operations/ConfigCatalog.swift | 27 +- .../Extensions/Models/ExtensionRegistry.swift | 8 +- .../Services/ExtensionDefaultsMigration.swift | 1 + .../Services/ExtensionLifecycleProbe.swift | 2 + .../Services/ExtensionLiveAdapters.swift | 18 +- .../Services/ExtensionMutationCenter.swift | 1 + .../FinderTools/FinderToolsSupport.swift | 135 ++++ .../ExtensionLifecycleProbeTests.swift | 3 + .../ExtensionLiveAdapterTests.swift | 19 + .../ExtensionReadinessModelTests.swift | 20 + .../EdithTests/ExtensionRegistryTests.swift | 4 + .../ExtensionRuntimeStateTests.swift | 1 + .../EdithTests/FinderToolsSupportTests.swift | 98 +++ docs/cli/README.md | 1 + docs/cli/finder-tools/README.md | 33 + 23 files changed, 1177 insertions(+), 8 deletions(-) create mode 100644 Packages/Edith/Sources/Edith/Features/Settings/Views/FinderToolsRows.swift create mode 100644 Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift create mode 100644 Packages/Edith/Sources/EdithKit/Features/FinderTools/FinderToolsSupport.swift create mode 100644 Packages/Edith/Tests/EdithTests/FinderToolsSupportTests.swift create mode 100644 docs/cli/finder-tools/README.md diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift index 0b94be9ba..cc19e87da 100644 --- a/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift @@ -575,7 +575,7 @@ private struct ExtensionSettingsSheet: View { case "machines": 420 case "lidAwake": 400 case "music": 460 - case "focusDim", "colorPicker": 430 + case "focusDim", "colorPicker", "finderTools": 430 case "system": 500 case "notchShelf", "presenter": 580 default: 620 @@ -937,6 +937,7 @@ private struct ExtensionDetailRows: View { case .calendar: CalendarRows() case .notchShelf: NotchShelfRows() case .clipboard: ClipboardRows() + case .finderTools: FinderToolsRows() case .focusDim: FocusDimRows() case .presenter: PresenterRows() case .colorPicker: ColorPickerRows() diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/FinderToolsRows.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/FinderToolsRows.swift new file mode 100644 index 000000000..21d1e9d6f --- /dev/null +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/FinderToolsRows.swift @@ -0,0 +1,62 @@ +import EdithKit +import SwiftUI + +struct FinderToolsRows: View { + @AppStorage(AppStorageKeys.FinderTools.enabled, store: SharedDefaults.store) private + var enabled = + false + @AppStorage(AppStorageKeys.FinderTools.cutPaste, store: SharedDefaults.store) private + var cutPaste = true + @AppStorage(AppStorageKeys.FinderTools.rename, store: SharedDefaults.store) private + var rename = true + @AppStorage(AppStorageKeys.FinderTools.pasteImages, store: SharedDefaults.store) private + var pasteImages = true + @AppStorage(AppStorageKeys.FinderTools.diskImageInstaller, store: SharedDefaults.store) private + var diskImageInstaller = true + + var body: some View { + Group { + Section("Finder shortcuts") { + Toggle( + "Cut and paste files with ⌘X and ⌘V", + isOn: $cutPaste.configured(AppStorageKeys.FinderTools.cutPaste)) + Text( + "Edith moves the selected files into the folder open in Finder. Existing files are never replaced." + ) + .settingsCaption() + Toggle( + "Rename the selection with F2", + isOn: $rename.configured(AppStorageKeys.FinderTools.rename)) + Toggle( + "Paste copied images as PNG files", + isOn: $pasteImages.configured(AppStorageKeys.FinderTools.pasteImages)) + Text( + "Press ⌘V in Finder to save a copied image into the open folder as a PNG with a timestamped name." + ) + .settingsCaption() + } + + Section("Disk images") { + Toggle( + "Offer one-click app installation", + isOn: $diskImageInstaller.configured( + AppStorageKeys.FinderTools.diskImageInstaller)) + Text( + "When a mounted DMG contains exactly one verified app, Edith can install it in Applications, eject the image, and move the unchanged download to Trash. Existing apps are never replaced." + ) + .settingsCaption() + } + + Section("Access") { + LabeledContent("Accessibility", value: "Finder keyboard shortcuts") + LabeledContent("Automation", value: "Finder selection and destination") + Text( + "Finder Automation is requested by macOS on first use. Disk image installation does not need Full Disk Access." + ) + .settingsCaption() + } + } + .disabled(!enabled) + .opacity(enabled ? 1 : 0.5) + } +} diff --git a/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift b/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift index b4085397c..c459ec6f5 100644 --- a/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift +++ b/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift @@ -589,6 +589,43 @@ public enum ExtensionLifecycleCatalog { "list", "List recent copies", "Confirm clipboard history can be read.", "ed clipboard ls --json") ]), + descriptor( + "finderTools", "Make Finder file operations faster without replacing Finder.", + workflows: [ + instruction( + "shortcuts", "Use Finder shortcuts", + "Move files with Command-X and Command-V, rename with F2, and paste images as PNG files." + ), + instruction( + "install", "Install from disk images", + "Install the single verified app on a mounted DMG, then eject and trash the download." + ), + ], + prerequisites: [ + instruction( + "access", "Grant Accessibility", + "Accessibility lets Edith handle Finder-only keyboard shortcuts.", + "ed permissions request accessibility") + ], + examples: [ + "ed extensions enable finderTools", + "ed config ls --group findertools --json", + ], + docs: [ + documentation("guide", "Finder Tools guide", "docs/cli/finder-tools/README.md") + ], + recovery: [ + instruction( + "doctor", "Check Finder Tools readiness", + "Inspect the helper, permissions, and enabled Finder features.", + "ed extensions doctor finderTools --json") + ], + verification: [ + instruction( + "status", "Inspect Finder Tools", + "Confirm the helper and selected features are ready.", + "ed extensions status finderTools --json") + ]), descriptor( "focusDim", "Reduce visual noise by dimming everything behind the active app.", workflows: [ diff --git a/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift b/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift index ccd3be244..5fe3f7bd6 100644 --- a/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift +++ b/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift @@ -184,6 +184,12 @@ public enum ExtensionRegistry { symbolName: "doc.on.clipboard", group: .utilities, featured: true, defaultsKey: "clipboardEnabled", requiredCapabilities: [.clipboardHistory], optionalCapabilities: [.globalPaste, .globalShortcuts]), + ExtensionRegistryEntry( + id: "finderTools", title: "Finder Tools", + subtitle: "Cut and paste, F2 rename, image paste, and safe DMG installs.", + symbolName: "folder.badge.gearshape", group: .utilities, featured: false, + defaultsKey: "finderToolsEnabled", + requiredCapabilities: [.globalShortcuts, .runningApplications]), ExtensionRegistryEntry( id: "focusDim", title: "Focus Dim", subtitle: "Dims everything behind your active app.", diff --git a/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift b/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift index a399eebd9..baafd3221 100644 --- a/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift +++ b/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift @@ -12,6 +12,7 @@ final class AppServices { private(set) var notchShelf: NotchShelfController? private(set) var colorPicker: ColorPickerStore? private(set) var clipboard: ClipboardStore? + private(set) var finderTools: FinderToolsService? private(set) var focusDim: FocusDimEngine? private(set) var presenter: PresenterDetector? private(set) var micMute: MicMuteEngine? @@ -101,6 +102,7 @@ final class AppServices { startup.cancel() terminating = true if #available(macOS 14.4, *) { MixerEngine.shared.shutdown() } + finderTools?.shutdown() await lidAwake?.shutdownForTermination() await lidAwakeRestorationGate.wait() } @@ -298,6 +300,14 @@ final class AppServices { notchShelf?.attachUsage(usage) notchShelf?.attachCalendar(calendar) notchShelf?.attachColorPicker(colorPicker) + + let finderToolsOn = Self.extensionEnabled(AppStorageKeys.FinderTools.enabled) + if finderToolsOn, finderTools == nil { finderTools = FinderToolsService() } + if !finderToolsOn { + finderTools?.shutdown() + finderTools = nil + } + finderTools?.syncSettings() } private func reconcilePresentationServices() { diff --git a/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift b/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift new file mode 100644 index 000000000..4beae77e3 --- /dev/null +++ b/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift @@ -0,0 +1,682 @@ +import AppKit +import ApplicationServices +import CoreGraphics +import Darwin +import EdithKit +import Foundation + +@MainActor +final class FinderToolsService { + private var shortcuts: FinderShortcutService? + private var installer: DiskImageAppInstaller? + + init() { + syncSettings() + } + + func syncSettings() { + let defaults = SharedDefaults.store + let keyboardWanted = + FinderToolsSupport.enabled( + AppStorageKeys.FinderTools.cutPaste, defaults: defaults) + || FinderToolsSupport.enabled(AppStorageKeys.FinderTools.rename, defaults: defaults) + || FinderToolsSupport.enabled( + AppStorageKeys.FinderTools.pasteImages, defaults: defaults) + if keyboardWanted, shortcuts == nil { shortcuts = FinderShortcutService() } + if !keyboardWanted { + shortcuts?.shutdown() + shortcuts = nil + } + shortcuts?.syncSettings() + + let installerWanted = FinderToolsSupport.enabled( + AppStorageKeys.FinderTools.diskImageInstaller, defaults: defaults) + if installerWanted, installer == nil { installer = DiskImageAppInstaller() } + if !installerWanted { + installer?.shutdown() + installer = nil + } + } + + func shutdown() { + shortcuts?.shutdown() + shortcuts = nil + installer?.shutdown() + installer = nil + } +} + +private final class FinderShortcutService: @unchecked Sendable { + private enum Route { + case pass + case swallow + case rename + } + + private enum Key { + static let x: Int64 = 7 + static let c: Int64 = 8 + static let v: Int64 = 9 + static let f2: Int64 = 120 + static let enter: Int64 = 36 + } + + private let lock = NSLock() + private var tap: CFMachPort? + private var runLoop: CFRunLoop? + private var thread: Thread? + private var stopping = false + private var cutURLs: [URL] = [] + private var cutChangeCount = 0 + private var moving = false + private var savingImage = false + private var cutPasteEnabled = true + private var renameEnabled = true + private var pasteImagesEnabled = true + private static let finderID = "com.apple.finder" + private static let maxImageBytes = 64 * 1_024 * 1_024 + + init() { + syncSettings() + } + + func syncSettings() { + dispatchPrecondition(condition: .onQueue(.main)) + cutPasteEnabled = FinderToolsSupport.enabled(AppStorageKeys.FinderTools.cutPaste) + renameEnabled = FinderToolsSupport.enabled(AppStorageKeys.FinderTools.rename) + pasteImagesEnabled = FinderToolsSupport.enabled(AppStorageKeys.FinderTools.pasteImages) + if AXIsProcessTrusted(), cutPasteEnabled || renameEnabled || pasteImagesEnabled { + start() + } else { + stop() + } + if !cutPasteEnabled { clearCut() } + } + + func shutdown() { + dispatchPrecondition(condition: .onQueue(.main)) + stop() + clearCut() + } + + private func start() { + let newThread = lock.withLock { () -> Thread? in + guard thread == nil else { return nil } + stopping = false + let value = Thread { [weak self] in self?.runTap() } + value.name = "Edith Finder Tools" + value.qualityOfService = .userInteractive + thread = value + return value + } + newThread?.start() + } + + private func stop() { + let snapshot = lock.withLock { () -> (CFMachPort?, CFRunLoop?) in + stopping = true + return (tap, runLoop) + } + if let tap = snapshot.0 { CGEvent.tapEnable(tap: tap, enable: false) } + if let runLoop = snapshot.1 { + CFRunLoopPerformBlock(runLoop, CFRunLoopMode.commonModes.rawValue) { + CFRunLoopStop(runLoop) + } + CFRunLoopWakeUp(runLoop) + } + } + + private func runTap() { + autoreleasepool { + let currentRunLoop = CFRunLoopGetCurrent() + lock.withLock { runLoop = currentRunLoop } + guard !lock.withLock({ stopping }) else { + clearTapState() + return + } + let mask = CGEventMask(1 << CGEventType.keyDown.rawValue) + guard + let eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, + eventsOfInterest: mask, + callback: { _, type, event, context in + guard let context else { return Unmanaged.passUnretained(event) } + let service = Unmanaged.fromOpaque(context) + .takeUnretainedValue() + return service.route(type: type, event: event) + }, userInfo: Unmanaged.passUnretained(self).toOpaque()) + else { + clearTapState() + return + } + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) + lock.withLock { tap = eventTap } + CFRunLoopAddSource(currentRunLoop, source, .commonModes) + CGEvent.tapEnable(tap: eventTap, enable: true) + if !lock.withLock({ stopping }) { CFRunLoopRun() } + CGEvent.tapEnable(tap: eventTap, enable: false) + CFRunLoopRemoveSource(currentRunLoop, source, .commonModes) + CFMachPortInvalidate(eventTap) + clearTapState() + } + } + + private func clearTapState() { + lock.withLock { + tap = nil + runLoop = nil + thread = nil + stopping = false + } + } + + private func route(type: CGEventType, event: CGEvent) -> Unmanaged? { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let tap = lock.withLock({ stopping ? nil : tap }) { + CGEvent.tapEnable(tap: tap, enable: true) + } + return Unmanaged.passUnretained(event) + } + guard type == .keyDown else { return Unmanaged.passUnretained(event) } + let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + let flags = event.flags + let commandCandidate = + flags.contains(.maskCommand) + && !flags.contains(.maskControl) && !flags.contains(.maskAlternate) + && !flags.contains(.maskShift) + && (keyCode == Key.x || keyCode == Key.c || keyCode == Key.v) + let renameCandidate = + keyCode == Key.f2 + && !flags.contains(.maskCommand) && !flags.contains(.maskControl) + && !flags.contains(.maskAlternate) && !flags.contains(.maskShift) + guard commandCandidate || renameCandidate else { return Unmanaged.passUnretained(event) } + + var result = Route.pass + DispatchQueue.main.sync { result = handle(keyCode: keyCode) } + switch result { + case .pass: + return Unmanaged.passUnretained(event) + case .swallow: + return nil + case .rename: + guard let replacement = event.copy() else { return Unmanaged.passUnretained(event) } + replacement.setIntegerValueField(.keyboardEventKeycode, value: Key.enter) + replacement.flags = [] + return Unmanaged.passRetained(replacement) + } + } + + private func handle(keyCode: Int64) -> Route { + guard AXIsProcessTrusted(), + NSWorkspace.shared.frontmostApplication?.bundleIdentifier == Self.finderID + else { return .pass } + switch keyCode { + case Key.f2: + return renameEnabled && FinderToolsSupport.focusedRoleAllowsRename(focusedElementRole()) + ? .rename : .pass + case Key.x: + guard cutPasteEnabled, !focusedElementIsEditable() else { return .pass } + captureCut() + return .swallow + case Key.c: + guard !focusedElementIsEditable() else { return .pass } + if cutPasteEnabled { clearCut() } + return .pass + case Key.v: + guard !focusedElementIsEditable() else { return .pass } + if cutPasteEnabled, !cutURLs.isEmpty { + guard NSPasteboard.general.changeCount == cutChangeCount else { + clearCut() + return imagePasteRoute() + } + guard !moving else { return .swallow } + moveCutFiles() + return .swallow + } + return imagePasteRoute() + default: + return .pass + } + } + + private func imagePasteRoute() -> Route { + guard pasteImagesEnabled, !savingImage, + FinderToolsSupport.preferredImageType( + in: (NSPasteboard.general.types ?? []).map(\.rawValue)) != nil + else { return .pass } + saveClipboardImage() + return .swallow + } + + private func captureCut() { + FinderToolsBridge.selection { [weak self] urls in + guard let self else { return } + DispatchQueue.main.async { + guard !urls.isEmpty else { + self.clearCut() + NSSound.beep() + return + } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.writeObjects(urls as [NSURL]) + self.cutURLs = urls + self.cutChangeCount = pasteboard.changeCount + } + } + } + + private func moveCutFiles() { + moving = true + let sources = cutURLs + FinderToolsBridge.insertionLocation { [weak self] directory in + guard let self else { return } + guard let directory else { + DispatchQueue.main.async { self.finishMove(failed: true) } + return + } + DispatchQueue.global(qos: .userInitiated).async { + let fileManager = FileManager.default + let destinations = sources.compactMap { + FinderToolsSupport.moveDestination( + for: $0, in: directory, fileExists: fileManager.fileExists(atPath:)) + } + guard destinations.count == sources.count, + Set(destinations.map(\.path)).count == destinations.count + else { + DispatchQueue.main.async { self.finishMove(failed: true) } + return + } + var failed = false + for (source, destination) in zip(sources, destinations) { + if destination == source { continue } + do { + try fileManager.moveItem(at: source, to: destination) + } catch { + failed = true + } + } + DispatchQueue.main.async { self.finishMove(failed: failed) } + } + } + } + + private func finishMove(failed: Bool) { + moving = false + clearCut() + if failed { NSSound.beep() } + } + + private func saveClipboardImage() { + savingImage = true + let pasteboard = NSPasteboard.general + let identifiers = (pasteboard.types ?? []).map(\.rawValue) + guard let type = FinderToolsSupport.preferredImageType(in: identifiers), + let source = pasteboard.data(forType: NSPasteboard.PasteboardType(type.identifier)), + source.count <= Self.maxImageBytes, + let representation = NSBitmapImageRep(data: source), + let png = type == .png + ? source : representation.representation(using: .png, properties: [:]), + png.count <= Self.maxImageBytes + else { + savingImage = false + NSSound.beep() + return + } + FinderToolsBridge.insertionLocation { [weak self] directory in + guard let self else { return } + DispatchQueue.global(qos: .userInitiated).async { + guard let directory else { + DispatchQueue.main.async { self.finishImageSave(failed: true) } + return + } + let manager = FileManager.default + let name = FinderToolsSupport.imageFileName(at: Date()) + let destination = FinderToolsSupport.uniqueImageURL( + named: name, in: directory, fileExists: manager.fileExists(atPath:)) + do { + try png.write(to: destination, options: [.atomic, .withoutOverwriting]) + DispatchQueue.main.async { self.finishImageSave(failed: false) } + } catch { + DispatchQueue.main.async { self.finishImageSave(failed: true) } + } + } + } + } + + private func finishImageSave(failed: Bool) { + savingImage = false + if failed { NSSound.beep() } + } + + private func clearCut() { + cutURLs = [] + cutChangeCount = 0 + } + + private func focusedElementIsEditable() -> Bool { + guard let role = focusedElementRole() else { return false } + return !FinderToolsSupport.focusedRoleAllowsRename(role) + } + + private func focusedElementRole() -> String? { + let system = AXUIElementCreateSystemWide() + AXUIElementSetMessagingTimeout(system, 0.15) + var focused: CFTypeRef? + guard + AXUIElementCopyAttributeValue( + system, kAXFocusedUIElementAttribute as CFString, &focused) == .success, + let focused, CFGetTypeID(focused) == AXUIElementGetTypeID() + else { return nil } + let element = focused as! AXUIElement + var role: CFTypeRef? + guard + AXUIElementCopyAttributeValue(element, kAXRoleAttribute as CFString, &role) == .success, + let role = role as? String + else { return nil } + return role + } +} + +private enum FinderToolsBridge { + private static let queue = DispatchQueue(label: "com.pulkit.edith.finder-tools") + + static func selection(_ completion: @escaping @Sendable ([URL]) -> Void) { + queue.async { + let source = """ + tell application id "com.apple.finder" + set paths to {} + repeat with itemReference in (get selection) + set end of paths to POSIX path of (itemReference as alias) + end repeat + return paths + end tell + """ + guard let descriptor = execute(source) else { + completion([]) + return + } + let values = (0.. Void) { + queue.async { + let source = """ + tell application id "com.apple.finder" + return POSIX path of (insertion location as alias) + end tell + """ + let path = execute(source)?.stringValue + completion(path.map { URL(fileURLWithPath: $0, isDirectory: true) }) + } + } + + private static func execute(_ source: String) -> NSAppleEventDescriptor? { + var error: NSDictionary? + let descriptor = NSAppleScript(source: source)?.executeAndReturnError(&error) + return error == nil ? descriptor : nil + } +} + +@MainActor +private final class DiskImageAppInstaller { + private struct Candidate: Sendable { + let mount: URL + let application: URL + let image: URL + let imageIdentity: FileIdentity + let destination: URL + let name: String + } + + private struct FileIdentity: Equatable, Sendable { + let device: UInt64 + let inode: UInt64 + } + + private enum Outcome: Sendable { + case installed + case installedKeepingMount + case installedKeepingImage + case failed(String) + } + + private struct CommandResult: Sendable { + let status: Int32 + let output: Data + } + + private let queue = DispatchQueue(label: "com.pulkit.edith.disk-image-installer", qos: .utility) + private var observer: NSObjectProtocol? + private var pending: [Candidate] = [] + private var activeMounts = Set() + private var prompting = false + + init() { + observer = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didMountNotification, object: nil, queue: .main + ) { [weak self] notification in + guard let mount = notification.userInfo?[NSWorkspace.volumeURLUserInfoKey] as? URL + else { + return + } + MainActor.assumeIsolated { self?.inspect(mount) } + } + } + + func shutdown() { + if let observer { NSWorkspace.shared.notificationCenter.removeObserver(observer) } + observer = nil + pending = [] + activeMounts = [] + } + + private func inspect(_ mount: URL) { + guard observer != nil else { return } + let path = mount.standardizedFileURL.resolvingSymlinksInPath().path + guard activeMounts.insert(path).inserted else { return } + queue.async { [weak self] in + let candidate = Self.candidate(mountedAt: mount) + DispatchQueue.main.async { + guard let self else { return } + self.activeMounts.remove(path) + guard self.observer != nil, let candidate else { return } + self.pending.append(candidate) + self.presentNext() + } + } + } + + private func presentNext() { + guard !prompting, observer != nil, let candidate = pending.first else { return } + pending.removeFirst() + prompting = true + let alert = NSAlert() + alert.messageText = "Install \(candidate.name)?" + alert.informativeText = + "Edith found one verified app on this disk image. Install it in Applications, eject the image, and move the DMG to Trash?" + alert.icon = NSWorkspace.shared.icon(forFile: candidate.application.path) + alert.addButton(withTitle: "Install") + alert.addButton(withTitle: "Not Now") + NSApp.activate(ignoringOtherApps: true) + guard alert.runModal() == .alertFirstButtonReturn else { + prompting = false + presentNext() + return + } + queue.async { [weak self] in + let outcome = Self.install(candidate) + DispatchQueue.main.async { + self?.present(outcome, candidate: candidate) + self?.prompting = false + self?.presentNext() + } + } + } + + private func present(_ outcome: Outcome, candidate: Candidate) { + let alert = NSAlert() + alert.icon = NSWorkspace.shared.icon(forFile: candidate.destination.path) + switch outcome { + case .installed: + alert.messageText = "\(candidate.name) Installed" + alert.informativeText = + "The app is in Applications. The disk image was ejected and its DMG moved to Trash." + case .installedKeepingMount: + alert.alertStyle = .warning + alert.messageText = "\(candidate.name) Installed" + alert.informativeText = + "The app is in Applications, but macOS could not eject the disk image. The DMG was left in place." + case .installedKeepingImage: + alert.alertStyle = .warning + alert.messageText = "\(candidate.name) Installed" + alert.informativeText = + "The app is in Applications and the disk image was ejected, but the DMG could not be moved to Trash." + case let .failed(message): + alert.alertStyle = .warning + alert.messageText = "Could Not Install \(candidate.name)" + alert.informativeText = message + } + NSApp.activate(ignoringOtherApps: true) + alert.runModal() + } + + nonisolated private static func candidate(mountedAt mount: URL) -> Candidate? { + let manager = FileManager.default + let info = run("/usr/bin/hdiutil", ["info", "-plist"], timeout: 10) + guard info.status == 0, + let image = FinderToolsSupport.diskImageURL( + mountedAt: mount, hdiutilInfo: info.output), + let identity = fileIdentity(image), + let entries = try? manager.contentsOfDirectory( + at: mount, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles]) + else { return nil } + let applications = entries.filter { url in + guard url.pathExtension.caseInsensitiveCompare("app") == .orderedSame, + let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey] + ), + values.isDirectory == true, values.isSymbolicLink != true + else { return false } + return validApplication(url) + } + guard applications.count == 1, let application = applications.first, + let destination = FinderToolsSupport.applicationDestination( + for: application, + applicationsDirectory: URL(fileURLWithPath: "/Applications", isDirectory: true)), + !manager.fileExists(atPath: destination.path) + else { return nil } + let preferred = + Bundle(url: application)?.object( + forInfoDictionaryKey: "CFBundleDisplayName") as? String + return Candidate( + mount: mount, application: application, image: image, imageIdentity: identity, + destination: destination, + name: FinderToolsSupport.displayName(preferred: preferred, application: application)) + } + + nonisolated private static func install(_ candidate: Candidate) -> Outcome { + let manager = FileManager.default + guard !manager.fileExists(atPath: candidate.destination.path) else { + return .failed( + "An app with this name already exists in Applications. Edith did not replace it.") + } + let staging: URL + do { + staging = try manager.url( + for: .itemReplacementDirectory, in: .userDomainMask, + appropriateFor: candidate.destination.deletingLastPathComponent(), create: true) + } catch { + return .failed("A secure staging folder could not be created.") + } + defer { try? manager.removeItem(at: staging) } + let staged = staging.appendingPathComponent(candidate.application.lastPathComponent) + let copied = run( + "/usr/bin/ditto", + ["--rsrc", "--extattr", "--acl", "--qtn", candidate.application.path, staged.path], + timeout: 600) + guard copied.status == 0, validApplication(staged) else { + return .failed("The app could not be copied from the disk image.") + } + guard gatekeeperAccepts(staged) else { + return .failed("macOS could not verify this app, so Edith left it on the disk image.") + } + do { + guard !manager.fileExists(atPath: candidate.destination.path) else { + return .failed("An app with this name appeared in Applications during the install.") + } + try manager.moveItem(at: staged, to: candidate.destination) + } catch { + return .failed("The verified app could not be placed in Applications.") + } + do { + try NSWorkspace.shared.unmountAndEjectDevice(at: candidate.mount) + } catch { + return .installedKeepingMount + } + guard fileIdentity(candidate.image) == candidate.imageIdentity else { + return .installedKeepingImage + } + do { + try manager.trashItem(at: candidate.image, resultingItemURL: nil) + return .installed + } catch { + return .installedKeepingImage + } + } + + nonisolated private static func validApplication(_ url: URL) -> Bool { + guard let bundle = Bundle(url: url), let executable = bundle.executableURL, + FileManager.default.isExecutableFile(atPath: executable.path) + else { return false } + let root = url.standardizedFileURL.resolvingSymlinksInPath().path + "/" + return executable.standardizedFileURL.resolvingSymlinksInPath().path.hasPrefix(root) + } + + nonisolated private static func gatekeeperAccepts(_ url: URL) -> Bool { + guard + run( + "/usr/bin/codesign", ["--verify", "--deep", "--strict", url.path], timeout: 120 + ).status == 0 + else { return false } + let status = run("/usr/sbin/spctl", ["--status"], timeout: 10) + if String(data: status.output, encoding: .utf8)?.localizedCaseInsensitiveContains( + "disabled") == true + { + return true + } + return run("/usr/sbin/spctl", ["-a", "-t", "exec", url.path], timeout: 120).status + == 0 + } + + nonisolated private static func fileIdentity(_ url: URL) -> FileIdentity? { + var value = stat() + guard url.path.withCString({ lstat($0, &value) }) == 0, + value.st_mode & S_IFMT == S_IFREG + else { return nil } + return FileIdentity(device: UInt64(value.st_dev), inode: UInt64(value.st_ino)) + } + + nonisolated private static func run( + _ executable: String, _ arguments: [String], timeout: TimeInterval + ) + -> CommandResult + { + do { + let result = try LidAwakeCommandProcess.run( + executableURL: URL(fileURLWithPath: executable), arguments: arguments, + timeout: timeout) + guard !result.timedOut, !result.cancelled else { + return CommandResult(status: -1, output: Data()) + } + return CommandResult( + status: result.terminationStatus, output: Data(result.standardOutput.utf8)) + } catch { + return CommandResult(status: -1, output: Data()) + } + } +} diff --git a/Packages/Edith/Sources/EdithHelper/Features/Settings/Services/SettingsBackup.swift b/Packages/Edith/Sources/EdithHelper/Features/Settings/Services/SettingsBackup.swift index 84700470d..a48e1ece9 100644 --- a/Packages/Edith/Sources/EdithHelper/Features/Settings/Services/SettingsBackup.swift +++ b/Packages/Edith/Sources/EdithHelper/Features/Settings/Services/SettingsBackup.swift @@ -626,6 +626,9 @@ final class SettingsBackup { AppStorageKeys.Machines.notifyDiskFull, AppStorageKeys.Machines.diskThreshold, AppStorageKeys.Machines.autoConnect, AppStorageKeys.Tabs.companionEnabled, AppStorageKeys.Companion.endpoint, + AppStorageKeys.FinderTools.enabled, AppStorageKeys.FinderTools.cutPaste, + AppStorageKeys.FinderTools.rename, AppStorageKeys.FinderTools.pasteImages, + AppStorageKeys.FinderTools.diskImageInstaller, "finderViewMode", "finderSortKey", "finderSortAscending", "finderShowHidden", "finderIconSize", "dockerLogWrap", "dockerLogTimestamps", "dockerLogFontSize", AppStorageKeys.Backup.settings, AppStorageKeys.Backup.usage, AppStorageKeys.Backup.limits, @@ -724,6 +727,9 @@ final class SettingsBackup { AppStorageKeys.Machines.notifyDiskFull, AppStorageKeys.Machines.diskThreshold, AppStorageKeys.Machines.autoConnect, AppStorageKeys.Tabs.companionEnabled, AppStorageKeys.Companion.endpoint, + AppStorageKeys.FinderTools.enabled, AppStorageKeys.FinderTools.cutPaste, + AppStorageKeys.FinderTools.rename, AppStorageKeys.FinderTools.pasteImages, + AppStorageKeys.FinderTools.diskImageInstaller, "finderViewMode", "finderSortKey", "finderSortAscending", "finderShowHidden", "finderIconSize", "dockerLogWrap", "dockerLogTimestamps", "dockerLogFontSize", AppStorageKeys.Backup.icloud, AppStorageKeys.Backup.lastBackupAt, diff --git a/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift b/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift index 5b3be7e16..d01ac64e0 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift @@ -86,6 +86,14 @@ public enum AppStorageKeys { public static let otherDisplaysMode = "focusDimOtherDisplaysMode" } + public enum FinderTools { + public static let cutPaste = "finderToolsCutPaste" + public static let diskImageInstaller = "finderToolsDiskImageInstaller" + public static let enabled = "finderToolsEnabled" + public static let pasteImages = "finderToolsPasteImages" + public static let rename = "finderToolsRename" + } + public enum Herdr { public static let agentViews = "herdrAgentViews" public static let ghosttyTerminal = "herdrGhosttyTerminal" diff --git a/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift b/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift index b1a448721..901143024 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift @@ -49,7 +49,7 @@ public enum ConfigCatalog { "dashboard", "machines", "herdr", "quinjet", "companion", "finder", "system", "cleaner", "music", "calendar", - "clipboard", + "clipboard", "findertools", "notch", "focusdim", "presenter", "colorpicker", "micmute", "backup", "permissions", "terminal", ] @@ -58,7 +58,8 @@ public enum ConfigCatalog { appearance + panel + attention + usageAndLimits + menuBar + alerts + budget + dashboard + machines + herdr + quinjet + companion + finder + system + cleaner - + music + calendar + clipboard + notch + focusDim + presenter + colorPicker + micMute + + music + calendar + clipboard + finderTools + notch + focusDim + presenter + + colorPicker + micMute + backup + permissions + terminal public static var keys: [String] { settings.map(\.key) } @@ -635,6 +636,28 @@ public enum ConfigCatalog { summary: "Last clipboard panel y position."), ] + private static let finderTools: [SettingDefinition] = [ + SettingDefinition( + AppStorageKeys.FinderTools.enabled, .bool, group: "findertools", + summary: "Finder Tools extension: file shortcuts and disk image installs.", + fallback: .bool(false)), + SettingDefinition( + AppStorageKeys.FinderTools.cutPaste, .bool, group: "findertools", + summary: "Move Finder selections with Command-X and Command-V.", + fallback: .bool(true)), + SettingDefinition( + AppStorageKeys.FinderTools.rename, .bool, group: "findertools", + summary: "Rename the Finder selection with F2.", fallback: .bool(true)), + SettingDefinition( + AppStorageKeys.FinderTools.pasteImages, .bool, group: "findertools", + summary: "Save copied images as PNG files with Command-V in Finder.", + fallback: .bool(true)), + SettingDefinition( + AppStorageKeys.FinderTools.diskImageInstaller, .bool, group: "findertools", + summary: "Offer to install the single app found on a mounted disk image.", + fallback: .bool(true)), + ] + private static let notch: [SettingDefinition] = [ SettingDefinition( AppStorageKeys.Notch.shelfEnabled, .bool, group: "notch", diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift index 4850072a1..c791c92ad 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift @@ -51,7 +51,7 @@ public enum ExtensionPermission: String, CaseIterable, Hashable, Sendable { case .calendar: "Required to read and show your schedule in Calendar." case .notifications: "Asked when you enable usage limit, pacing, or reset alerts." case .accessibility: - "Asked when you first use Clean keys or clipboard instant paste." + "Required for global keyboard tools that act outside Edith." case .inputMonitoring: "Asked when you first use Clean keys to block key presses during cleaning." case .fullDisk: "Asked when a feature needs local service credentials or usage data." @@ -61,7 +61,7 @@ public enum ExtensionPermission: String, CaseIterable, Hashable, Sendable { "Asked when you first use the Notch Shelf per-app volume mixer." case .camera: "Asked when you first open the Notch Shelf camera preview." case .bluetooth: "Asked when Notch Shelf first checks for device connections." - case .automation: "Asked when Notch Shelf first controls external playback." + case .automation: "Asked when Edith first controls Finder or external playback." } } @@ -111,7 +111,7 @@ public enum ExtensionPermission: String, CaseIterable, Hashable, Sendable { case .bluetooth: "macOS will ask for Bluetooth access when connection alerts first run." case .automation: - "macOS will ask for Automation access when Notch Shelf first controls playback." + "macOS will ask for Automation access when Edith first controls Finder or playback." case .applicationAudio: "macOS will ask for application audio access when the mixer first changes an app." default: nil @@ -128,6 +128,7 @@ public extension ExtensionRegistryEntry { switch id { case "calendar": [.calendar] case "focusDim", "presenter", "colorPicker": [.screenRecording] + case "finderTools": [.accessibility] default: [] } } @@ -138,6 +139,7 @@ public extension ExtensionRegistryEntry { case "system": [.accessibility, .inputMonitoring] case "notchShelf": [.applicationAudio, .bluetooth, .camera, .automation] case "clipboard": [.accessibility] + case "finderTools": [.automation] default: [] } } diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionDefaultsMigration.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionDefaultsMigration.swift index bb356c6e5..6a3aa0526 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionDefaultsMigration.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionDefaultsMigration.swift @@ -52,6 +52,7 @@ public enum ExtensionDefaultsMigration { AppStorageKeys.Tabs.calendarEnabled: true, AppStorageKeys.Notch.shelfEnabled: false, AppStorageKeys.Clipboard.enabled: false, + AppStorageKeys.FinderTools.enabled: false, FocusDimState.enabledKey: false, AppStorageKeys.Presenter.enabled: true, AppStorageKeys.ColorPicker.enabled: false, diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift index d231964ae..6fe45ddf6 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift @@ -209,6 +209,8 @@ public struct ExtensionLifecycleProbe: Sendable { requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), "clipboard": Policy( requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), + "finderTools": Policy( + requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), "focusDim": Policy( requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), "presenter": Policy( diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift index 17171d3e7..9da532776 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift @@ -65,8 +65,8 @@ private final class ExtensionAdapterDefaults: @unchecked Sendable { public enum ExtensionLiveAdapters { public static let extensionIDs = [ "attention", "usage", "quinjet", "system", "machines", "systemStats", "micMute", - "lidAwake", "music", "calendar", "notchShelf", "clipboard", "focusDim", "presenter", - "colorPicker", + "lidAwake", "music", "calendar", "notchShelf", "clipboard", "finderTools", "focusDim", + "presenter", "colorPicker", ] public static func provider( @@ -103,6 +103,7 @@ public enum ExtensionLiveAdapters { case "calendar": calendarReadiness() case "notchShelf": shelfReadiness() case "clipboard": clipboardReadiness() + case "finderTools": finderToolsReadiness(defaults: defaults) case "focusDim": await focusDimReadiness(defaults: defaults) case "presenter": presenterReadiness(defaults: defaults) case "colorPicker": await colorPickerReadiness(defaults: defaults) @@ -110,6 +111,19 @@ public enum ExtensionLiveAdapters { } } + static func finderToolsReadiness(defaults: UserDefaults) -> ExtensionAdapterReadiness { + let keys = [ + AppStorageKeys.FinderTools.cutPaste, AppStorageKeys.FinderTools.rename, + AppStorageKeys.FinderTools.pasteImages, AppStorageKeys.FinderTools.diskImageInstaller, + ] + let enabled = keys.filter { defaults.object(forKey: $0) as? Bool ?? true } + return ExtensionAdapterFacts( + configured: !enabled.isEmpty, + readyDetail: "Finder Tools features enabled: \(enabled.count).", + setupDetail: "Turn on at least one Finder Tools feature." + ).readiness + } + static func attentionReadiness( settings: AttentionSettings = AttentionRepository().loadSettings() ) -> ExtensionAdapterReadiness { diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift index 28a9dd4a3..1c0746660 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift @@ -85,6 +85,7 @@ public enum ExtensionDetailRoute: String, CaseIterable, Sendable { case calendar case notchShelf case clipboard + case finderTools case focusDim case presenter case colorPicker diff --git a/Packages/Edith/Sources/EdithKit/Features/FinderTools/FinderToolsSupport.swift b/Packages/Edith/Sources/EdithKit/Features/FinderTools/FinderToolsSupport.swift new file mode 100644 index 000000000..1da41a64f --- /dev/null +++ b/Packages/Edith/Sources/EdithKit/Features/FinderTools/FinderToolsSupport.swift @@ -0,0 +1,135 @@ +import Foundation +import UniformTypeIdentifiers + +public enum FinderToolsImageType: Equatable, Sendable { + case png + case image(String) + + public var identifier: String { + switch self { + case .png: "public.png" + case let .image(identifier): identifier + } + } +} + +public enum FinderToolsSupport { + public static func enabled( + _ key: String, defaults: UserDefaults = SharedDefaults.store, fallback: Bool = true + ) -> Bool { + defaults.object(forKey: key) as? Bool ?? fallback + } + + public static func preferredImageType(in identifiers: [String]) -> FinderToolsImageType? { + guard !identifiers.contains(UTType.fileURL.identifier) else { return nil } + if identifiers.contains(UTType.png.identifier) { return .png } + guard + let identifier = identifiers.first(where: { + UTType($0)?.conforms(to: .image) == true + }) + else { return nil } + return .image(identifier) + } + + public static func focusedRoleAllowsRename(_ role: String?) -> Bool { + guard let role else { return false } + return !["AXTextField", "AXTextArea", "AXComboBox", "AXSecureTextField"].contains(role) + } + + public static func imageFileName(at date: Date, calendar: Calendar = .current) -> String { + let parts = calendar.dateComponents( + [.year, .month, .day, .hour, .minute, .second], from: date) + return String( + format: "Pasted Image %04d-%02d-%02d at %02d.%02d.%02d.png", + parts.year ?? 0, parts.month ?? 0, parts.day ?? 0, parts.hour ?? 0, + parts.minute ?? 0, parts.second ?? 0) + } + + public static func uniqueImageURL( + named name: String, in directory: URL, fileExists: (String) -> Bool + ) -> URL { + let base = URL(fileURLWithPath: name).deletingPathExtension().lastPathComponent + let ext = URL(fileURLWithPath: name).pathExtension + var candidate = directory.appendingPathComponent(name) + var index = 2 + while fileExists(candidate.path) { + candidate = directory.appendingPathComponent("\(base) \(index).\(ext)") + index += 1 + } + return candidate + } + + public static func moveDestination( + for source: URL, in directory: URL, fileExists: (String) -> Bool + ) -> URL? { + let source = source.standardizedFileURL + let directory = directory.standardizedFileURL + guard source.isFileURL, directory.isFileURL, source != directory else { return nil } + let destination = directory.appendingPathComponent(source.lastPathComponent) + .standardizedFileURL + if destination == source { return source } + guard !fileExists(destination.path), !contains(directory, inside: source) else { + return nil + } + return destination + } + + public static func diskImageURL(mountedAt mountURL: URL, hdiutilInfo: Data) -> URL? { + guard + let root = try? PropertyListSerialization.propertyList( + from: hdiutilInfo, options: [], format: nil) as? [String: Any], + let images = root["images"] as? [[String: Any]] + else { return nil } + let mountPath = normalizedPath(mountURL.path) + let matches = images.compactMap { image -> String? in + guard let path = image["image-path"] as? String, path.hasPrefix("/"), + let entities = image["system-entities"] as? [[String: Any]], + entities.contains(where: { + guard let value = $0["mount-point"] as? String else { return false } + return normalizedPath(value) == mountPath + }) + else { return nil } + return path + } + guard Set(matches).count == 1, let path = matches.first else { return nil } + let url = URL(fileURLWithPath: path).standardizedFileURL + return url.pathExtension.caseInsensitiveCompare("dmg") == .orderedSame ? url : nil + } + + public static func applicationDestination( + for application: URL, applicationsDirectory: URL + ) -> URL? { + let name = application.lastPathComponent + guard application.pathExtension.caseInsensitiveCompare("app") == .orderedSame, + !name.hasPrefix("."), + !name.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + else { return nil } + let root = applicationsDirectory.standardizedFileURL + let destination = root.appendingPathComponent(name, isDirectory: true).standardizedFileURL + return destination.deletingLastPathComponent() == root ? destination : nil + } + + public static func displayName(preferred: String?, application: URL) -> String { + let fallback = application.deletingPathExtension().lastPathComponent + let trimmed = preferred?.trimmingCharacters(in: .whitespacesAndNewlines) + let source = trimmed?.isEmpty == false ? trimmed! : fallback + let words = source.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } + let scalars = words.joined(separator: " ").unicodeScalars.filter { + !CharacterSet.controlCharacters.contains($0) + && !CharacterSet.illegalCharacters.contains($0) + } + let result = String(String.UnicodeScalarView(scalars).prefix(80)) + .trimmingCharacters(in: .whitespacesAndNewlines) + return result.isEmpty ? fallback : result + } + + private static func contains(_ directory: URL, inside source: URL) -> Bool { + let sourcePath = source.resolvingSymlinksInPath().path + let directoryPath = directory.resolvingSymlinksInPath().path + return directoryPath == sourcePath || directoryPath.hasPrefix(sourcePath + "/") + } + + private static func normalizedPath(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath().path + } +} diff --git a/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift index 16bec2aa2..dd2fa98b1 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift @@ -58,6 +58,9 @@ import EdithCore MatrixRow( id: "clipboard", helper: true, machine: false, toolRule: .all, adapter: true, requiredTools: [], optionalTools: []), + MatrixRow( + id: "finderTools", helper: true, machine: false, toolRule: .all, adapter: true, + requiredTools: [], optionalTools: []), MatrixRow( id: "focusDim", helper: true, machine: false, toolRule: .all, adapter: true, requiredTools: [], optionalTools: []), diff --git a/Packages/Edith/Tests/EdithTests/ExtensionLiveAdapterTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionLiveAdapterTests.swift index 8d98216c9..e1bb80b59 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionLiveAdapterTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionLiveAdapterTests.swift @@ -51,6 +51,25 @@ import EdithCore == .ready("Attention tracking is configured for the selected sources.")) } + @Test func finderToolsRequiresAtLeastOneSelectedFeature() { + let suite = "test.extension-adapter.finder-tools.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + + #expect( + ExtensionLiveAdapters.finderToolsReadiness(defaults: defaults) + == .ready("Finder Tools features enabled: 4.")) + for key in [ + AppStorageKeys.FinderTools.cutPaste, AppStorageKeys.FinderTools.rename, + AppStorageKeys.FinderTools.pasteImages, AppStorageKeys.FinderTools.diskImageInstaller, + ] { + defaults.set(false, forKey: key) + } + #expect( + ExtensionLiveAdapters.finderToolsReadiness(defaults: defaults) + == .needsSetup("Turn on at least one Finder Tools feature.")) + } + @Test func usageDetectsMissingLoadingEmptyReadyAndCorruptData() throws { let root = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: root) } diff --git a/Packages/Edith/Tests/EdithTests/ExtensionReadinessModelTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionReadinessModelTests.swift index 135816f8c..77f433491 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionReadinessModelTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionReadinessModelTests.swift @@ -81,6 +81,26 @@ import Testing #expect(model.report == nil) } + @Test func refreshKeepsTheLastReportUntilItsReplacementIsReady() async { + let fixture = ExtensionReadinessFixture() + let model = ExtensionReadinessModel { await fixture.load($0) } + + let initial = model.refresh() + await fixture.waitUntilStarted(1) + await fixture.release(0, report: report("stable", phase: .ready)) + await initial.value + + let refresh = model.refresh(.verify) + await fixture.waitUntilStarted(2) + #expect(model.report?.state.extensionID == "stable") + #expect(model.isRefreshing) + + await fixture.release(1, report: report("updated", phase: .degraded)) + await refresh.value + #expect(model.report?.state.extensionID == "updated") + #expect(!model.isRefreshing) + } + private func report( _ id: String, phase: ExtensionLifecyclePhase ) -> ExtensionLifecycleReport { diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift index 96c2cd884..7af4a13fe 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift @@ -21,6 +21,7 @@ import Testing "tabCalendarEnabled", "notchShelfEnabled", "clipboardEnabled", + "finderToolsEnabled", "focusDimEnabled", "presenterEnabled", "colorPickerEnabled", @@ -240,6 +241,7 @@ import Testing "calendar": [.calendar], "notchShelf": [], "clipboard": [], + "finderTools": [.accessibility], "focusDim": [.screenRecording], "presenter": [.screenRecording], "colorPicker": [.screenRecording], @@ -259,6 +261,7 @@ import Testing "calendar": [], "notchShelf": [.applicationAudio, .bluetooth, .camera, .automation], "clipboard": [.accessibility], + "finderTools": [.automation], "focusDim": [], "presenter": [], "colorPicker": [], @@ -390,6 +393,7 @@ import Testing "tabCalendarEnabled": true, "notchShelfEnabled": false, "clipboardEnabled": true, + "finderToolsEnabled": false, "focusDimEnabled": false, "presenterEnabled": true, "colorPickerEnabled": false, diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift index e4b7520f7..3fa017144 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift @@ -261,6 +261,7 @@ import Testing ("calendar", "CalendarRows", "enabled", "ExtensionsPane.swift"), ("notchShelf", "NotchShelfRows", "enabled", "NotchShelfRows.swift"), ("clipboard", "ClipboardRows", "enabled", "ClipboardRows.swift"), + ("finderTools", "FinderToolsRows", "enabled", "FinderToolsRows.swift"), ("focusDim", "FocusDimRows", "enabled", "FocusDimRows.swift"), ("presenter", "PresenterRows", "presenterEnabled", "PresenterRows.swift"), ("colorPicker", "ColorPickerRows", "colorPickerEnabled", "ColorPickerRows.swift"), diff --git a/Packages/Edith/Tests/EdithTests/FinderToolsSupportTests.swift b/Packages/Edith/Tests/EdithTests/FinderToolsSupportTests.swift new file mode 100644 index 000000000..2c3617a32 --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/FinderToolsSupportTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing + +@testable import EdithKit + +@Suite struct FinderToolsSupportTests { + @Test func choosesLosslessPasteboardImages() { + #expect( + FinderToolsSupport.preferredImageType( + in: ["public.tiff", "public.png", "public.utf8-plain-text"]) + == .png) + #expect( + FinderToolsSupport.preferredImageType(in: ["public.tiff"]) + == .image("public.tiff")) + #expect( + FinderToolsSupport.preferredImageType(in: ["public.jpeg"]) + == .image("public.jpeg")) + #expect( + FinderToolsSupport.preferredImageType(in: ["public.file-url", "public.png"]) + == nil) + } + + @Test func renameRequiresKnownNoneditableFocus() { + #expect(FinderToolsSupport.focusedRoleAllowsRename("AXOutline")) + #expect(!FinderToolsSupport.focusedRoleAllowsRename("AXTextField")) + #expect(!FinderToolsSupport.focusedRoleAllowsRename("AXTextArea")) + #expect(!FinderToolsSupport.focusedRoleAllowsRename(nil)) + } + + @Test func createsStableUniqueImageNames() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let date = Date(timeIntervalSince1970: 1_704_067_200) + let name = FinderToolsSupport.imageFileName(at: date, calendar: calendar) + #expect(name == "Pasted Image 2024-01-01 at 00.00.00.png") + let directory = URL(fileURLWithPath: "/tmp", isDirectory: true) + let existing = Set([ + "/tmp/Pasted Image 2024-01-01 at 00.00.00.png", + "/tmp/Pasted Image 2024-01-01 at 00.00.00 2.png", + ]) + let destination = FinderToolsSupport.uniqueImageURL(named: name, in: directory) { + existing.contains($0) + } + #expect(destination.lastPathComponent == "Pasted Image 2024-01-01 at 00.00.00 3.png") + } + + @Test func moveDestinationsRejectOverwriteAndRecursion() { + let source = URL(fileURLWithPath: "/tmp/source", isDirectory: true) + let target = URL(fileURLWithPath: "/tmp/target", isDirectory: true) + #expect( + FinderToolsSupport.moveDestination(for: source, in: target, fileExists: { _ in false }) + == URL(fileURLWithPath: "/tmp/target/source")) + #expect( + FinderToolsSupport.moveDestination(for: source, in: target, fileExists: { _ in true }) + == nil) + #expect( + FinderToolsSupport.moveDestination( + for: source, + in: URL(fileURLWithPath: "/tmp/source/child", isDirectory: true), + fileExists: { _ in false }) == nil) + } + + @Test func mapsOnlyOneRealDiskImageToTheMount() throws { + let root: [String: Any] = [ + "images": [ + [ + "image-path": "/Users/me/Downloads/App.dmg", + "system-entities": [["mount-point": "/Volumes/App"]], + ] + ] + ] + let data = try PropertyListSerialization.data( + fromPropertyList: root, format: .xml, options: 0) + #expect( + FinderToolsSupport.diskImageURL( + mountedAt: URL(fileURLWithPath: "/Volumes/App"), hdiutilInfo: data)?.path + == "/Users/me/Downloads/App.dmg") + #expect( + FinderToolsSupport.diskImageURL( + mountedAt: URL(fileURLWithPath: "/Volumes/Other"), hdiutilInfo: data) == nil) + } + + @Test func applicationDestinationsStayInsideApplications() { + let applications = URL(fileURLWithPath: "/Applications", isDirectory: true) + #expect( + FinderToolsSupport.applicationDestination( + for: URL(fileURLWithPath: "/Volumes/App/Example.app"), + applicationsDirectory: applications)?.path == "/Applications/Example.app") + #expect( + FinderToolsSupport.applicationDestination( + for: URL(fileURLWithPath: "/Volumes/App/.Hidden.app"), + applicationsDirectory: applications) == nil) + #expect( + FinderToolsSupport.displayName( + preferred: " Example\n App ", + application: URL(fileURLWithPath: "/Volumes/App/Fallback.app")) == "Example App") + } +} diff --git a/docs/cli/README.md b/docs/cli/README.md index f37849706..25bf8af66 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -48,6 +48,7 @@ report still exits 0, so read `verified`, `state.phase`, `state.runtimePhase`, | [`ed config`](./config/README.md) | Every setting the UI exposes, and the full setting catalogue | | [`ed app`](./app/README.md) | App identity, diagnostics, paths, external links, and one-shot actions | | [`ed extensions`](./extensions/README.md) | Enabling features, checking readiness, planning setup, verifying live adapters, and recovering failures | +| [Finder Tools](./finder-tools/README.md) | Finder cut and paste, F2 rename, copied-image PNG files and disk image app installation | | [`ed lid-awake`](./lid-awake/README.md) | Closed-lid sessions, battery auto-pause and live state | | [`ed permissions`](./permissions/README.md) | Inspecting and requesting Edith's macOS permissions | | [`ed usage`](./usage/README.md) | Agent usage: limits, cost, tokens, projects, sources, and machine attribution | diff --git a/docs/cli/finder-tools/README.md b/docs/cli/finder-tools/README.md new file mode 100644 index 000000000..93d12549e --- /dev/null +++ b/docs/cli/finder-tools/README.md @@ -0,0 +1,33 @@ +# Finder Tools + +Finder Tools adds focused shortcuts and installation assistance to macOS Finder. It does not add another file browser. + +Enable the extension and inspect its readiness: + +```bash +ed extensions setup finderTools +ed extensions status finderTools --json +``` + +The extension can move Finder selections with Command-X and Command-V, rename the current selection with F2, save a copied image into the open Finder folder as a PNG file, and offer to install the single app found on a mounted disk image. Copying an image file still uses Finder's normal file paste instead of creating a duplicate PNG. + +Accessibility is required for the Finder keyboard shortcuts. macOS asks for Finder Automation access on first use when Edith reads the current selection or destination. The disk image installer only accepts a real DMG mount containing exactly one executable app bundle. It verifies the copied app before placing it in `/Applications`, never replaces an existing app, ejects the disk image after a successful install, and only then moves the unchanged DMG to Trash. + +Each behavior can be changed from Settings, Extensions, Finder Tools, or from the CLI: + +```bash +ed config ls --group findertools +ed config set finderToolsCutPaste false +ed config set finderToolsRename true +ed config set finderToolsPasteImages true +ed config set finderToolsDiskImageInstaller true +``` + +If shortcuts stop responding, refresh Accessibility after changing System Settings: + +```bash +ed permissions refresh +ed extensions doctor finderTools +``` + +[Back to the CLI reference](../README.md) From 550a1ec57ce9b252df7d299bff9e97b59ff974a6 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Sat, 29 Aug 2026 02:40:53 +0530 Subject: [PATCH 2/8] fix: align Finder Tools runtime integration --- .../EdithHelper/Core/Application/EdithHelperApp.swift | 3 +++ .../Features/Extensions/Models/ExtensionRegistry.swift | 3 +-- .../Tests/EdithCoreTests/ExtensionRegistryTests.swift | 4 ++-- .../Edith/Tests/EdithTests/ExtensionRegistryTests.swift | 8 ++++---- Resources/HelperInfo.plist | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift b/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift index 5dee0c82f..16797086d 100644 --- a/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift +++ b/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift @@ -170,6 +170,9 @@ struct EdithApp: App { SharedDefaults.store.string(forKey: AppStorageKeys.General.appearance) ?? "system") services.sync() } + _ = IPC.observe(IPC.Name.permissionsRefreshed) { + services.finderTools?.syncSettings() + } _ = IPC.observe(IPC.Name.presenterAutoActiveChanged) { services.usage?.refreshMenuBarItem() } diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift index c791c92ad..fec187b1e 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift @@ -128,7 +128,6 @@ public extension ExtensionRegistryEntry { switch id { case "calendar": [.calendar] case "focusDim", "presenter", "colorPicker": [.screenRecording] - case "finderTools": [.accessibility] default: [] } } @@ -139,7 +138,7 @@ public extension ExtensionRegistryEntry { case "system": [.accessibility, .inputMonitoring] case "notchShelf": [.applicationAudio, .bluetooth, .camera, .automation] case "clipboard": [.accessibility] - case "finderTools": [.automation] + case "finderTools": [.accessibility, .automation] default: [] } } diff --git a/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift index e4dc45d68..baf886ac3 100644 --- a/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift @@ -10,12 +10,12 @@ import Testing #expect(Set(entries.map(\.defaultsKey)).count == entries.count) } - @Test func identifiersMatchPreUtilityBaseline() { + @Test func identifiersMatchCurrentCatalog() { #expect( ExtensionRegistry.entries.map(\.id) == [ "attention", "usage", "herdr", "quinjet", "system", "machines", "companion", "systemStats", "micMute", "lidAwake", "music", "calendar", "notchShelf", - "clipboard", "focusDim", "presenter", "colorPicker", + "clipboard", "finderTools", "focusDim", "presenter", "colorPicker", ]) } diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift index 7af4a13fe..a5d95adff 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift @@ -32,12 +32,12 @@ import Testing #expect(Set(identifiers).count == identifiers.count) } - @Test func registryMatchesPreUtilityBaseline() { + @Test func registryMatchesCurrentCatalog() { #expect( ExtensionRegistry.entries.map(\.id) == [ "attention", "usage", "herdr", "quinjet", "system", "machines", "companion", "systemStats", "micMute", "lidAwake", "music", "calendar", "notchShelf", - "clipboard", "focusDim", "presenter", "colorPicker", + "clipboard", "finderTools", "focusDim", "presenter", "colorPicker", ]) } @@ -241,7 +241,7 @@ import Testing "calendar": [.calendar], "notchShelf": [], "clipboard": [], - "finderTools": [.accessibility], + "finderTools": [], "focusDim": [.screenRecording], "presenter": [.screenRecording], "colorPicker": [.screenRecording], @@ -261,7 +261,7 @@ import Testing "calendar": [], "notchShelf": [.applicationAudio, .bluetooth, .camera, .automation], "clipboard": [.accessibility], - "finderTools": [.automation], + "finderTools": [.accessibility, .automation], "focusDim": [], "presenter": [], "colorPicker": [], diff --git a/Resources/HelperInfo.plist b/Resources/HelperInfo.plist index cd4c3f4ac..c1c6d9ac2 100644 --- a/Resources/HelperInfo.plist +++ b/Resources/HelperInfo.plist @@ -23,7 +23,7 @@ LSUIElement NSAppleEventsUsageDescription - Edith controls playback in Spotify and Apple Music from the notch. + Edith reads Finder selections and destinations for Finder Tools and controls playback in Spotify and Apple Music from the notch. NSAudioCaptureUsageDescription Edith taps an app's audio only to adjust its volume in the per-app mixer. NSBluetoothAlwaysUsageDescription From 86b27ff2ce97ac364c2c07a1e911e733482b6852 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Sat, 29 Aug 2026 02:49:08 +0530 Subject: [PATCH 3/8] fix: harden Finder Tools operations --- .../Services/FinderToolsService.swift | 175 +++++++++++++----- .../Services/ExtensionLiveAdapters.swift | 13 +- .../FinderTools/FinderToolsSupport.swift | 59 +++++- .../Tests/EdithTests/AppServicesTests.swift | 1 + .../ExtensionLiveAdapterTests.swift | 12 ++ .../ExtensionReadinessModelTests.swift | 20 -- .../ExtensionRuntimeStateTests.swift | 12 ++ .../EdithTests/FinderToolsSupportTests.swift | 103 ++++++++++- 8 files changed, 322 insertions(+), 73 deletions(-) diff --git a/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift b/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift index 4beae77e3..24e4dc0cf 100644 --- a/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift +++ b/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift @@ -4,6 +4,7 @@ import CoreGraphics import Darwin import EdithKit import Foundation +import ImageIO @MainActor final class FinderToolsService { @@ -68,6 +69,7 @@ private final class FinderShortcutService: @unchecked Sendable { private var stopping = false private var cutURLs: [URL] = [] private var cutChangeCount = 0 + private var cutRequestGeneration = 0 private var moving = false private var savingImage = false private var cutPasteEnabled = true @@ -75,6 +77,8 @@ private final class FinderShortcutService: @unchecked Sendable { private var pasteImagesEnabled = true private static let finderID = "com.apple.finder" private static let maxImageBytes = 64 * 1_024 * 1_024 + private static let imageQueue = DispatchQueue( + label: "com.pulkit.edith.finder-tools.images", qos: .userInitiated) init() { syncSettings() @@ -190,6 +194,7 @@ private final class FinderShortcutService: @unchecked Sendable { && !flags.contains(.maskCommand) && !flags.contains(.maskControl) && !flags.contains(.maskAlternate) && !flags.contains(.maskShift) guard commandCandidate || renameCandidate else { return Unmanaged.passUnretained(event) } + guard event.getIntegerValueField(.keyboardEventAutorepeat) == 0 else { return nil } var result = Route.pass DispatchQueue.main.sync { result = handle(keyCode: keyCode) } @@ -249,9 +254,21 @@ private final class FinderShortcutService: @unchecked Sendable { } private func captureCut() { + cutRequestGeneration &+= 1 + let generation = cutRequestGeneration + let invocationChangeCount = NSPasteboard.general.changeCount + let finderProcessID = NSWorkspace.shared.frontmostApplication?.processIdentifier + let startedAt = DispatchTime.now().uptimeNanoseconds FinderToolsBridge.selection { [weak self] urls in guard let self else { return } DispatchQueue.main.async { + let elapsed = DispatchTime.now().uptimeNanoseconds - startedAt + guard self.cutRequestGeneration == generation, + NSPasteboard.general.changeCount == invocationChangeCount, + NSWorkspace.shared.frontmostApplication?.bundleIdentifier == Self.finderID, + NSWorkspace.shared.frontmostApplication?.processIdentifier == finderProcessID, + elapsed <= 1_000_000_000 + else { return } guard !urls.isEmpty else { self.clearCut() NSSound.beep() @@ -272,7 +289,7 @@ private final class FinderShortcutService: @unchecked Sendable { FinderToolsBridge.insertionLocation { [weak self] directory in guard let self else { return } guard let directory else { - DispatchQueue.main.async { self.finishMove(failed: true) } + DispatchQueue.main.async { self.finishMove(.reverted) } return } DispatchQueue.global(qos: .userInitiated).async { @@ -284,27 +301,28 @@ private final class FinderShortcutService: @unchecked Sendable { guard destinations.count == sources.count, Set(destinations.map(\.path)).count == destinations.count else { - DispatchQueue.main.async { self.finishMove(failed: true) } + DispatchQueue.main.async { self.finishMove(.reverted) } return } - var failed = false - for (source, destination) in zip(sources, destinations) { - if destination == source { continue } - do { - try fileManager.moveItem(at: source, to: destination) - } catch { - failed = true - } + let outcome = FinderToolsSupport.move(Array(zip(sources, destinations))) { + try fileManager.moveItem(at: $0, to: $1) } - DispatchQueue.main.async { self.finishMove(failed: failed) } + DispatchQueue.main.async { self.finishMove(outcome) } } } } - private func finishMove(failed: Bool) { + private func finishMove(_ outcome: FinderToolsMoveOutcome) { moving = false - clearCut() - if failed { NSSound.beep() } + switch outcome { + case .completed: + clearCut() + case .reverted: + NSSound.beep() + case .incomplete: + clearCut() + NSSound.beep() + } } private func saveClipboardImage() { @@ -313,50 +331,71 @@ private final class FinderShortcutService: @unchecked Sendable { let identifiers = (pasteboard.types ?? []).map(\.rawValue) guard let type = FinderToolsSupport.preferredImageType(in: identifiers), let source = pasteboard.data(forType: NSPasteboard.PasteboardType(type.identifier)), - source.count <= Self.maxImageBytes, - let representation = NSBitmapImageRep(data: source), - let png = type == .png - ? source : representation.representation(using: .png, properties: [:]), - png.count <= Self.maxImageBytes + source.count <= Self.maxImageBytes else { savingImage = false NSSound.beep() return } - FinderToolsBridge.insertionLocation { [weak self] directory in + Self.imageQueue.async { [weak self] in guard let self else { return } - DispatchQueue.global(qos: .userInitiated).async { - guard let directory else { - DispatchQueue.main.async { self.finishImageSave(failed: true) } - return - } - let manager = FileManager.default - let name = FinderToolsSupport.imageFileName(at: Date()) - let destination = FinderToolsSupport.uniqueImageURL( - named: name, in: directory, fileExists: manager.fileExists(atPath:)) - do { - try png.write(to: destination, options: [.atomic, .withoutOverwriting]) - DispatchQueue.main.async { self.finishImageSave(failed: false) } - } catch { - DispatchQueue.main.async { self.finishImageSave(failed: true) } + guard let png = Self.preparedPNGData(source, type: type) else { + DispatchQueue.main.async { self.finishImageSave(failed: true) } + return + } + FinderToolsBridge.insertionLocation { [weak self] directory in + guard let self else { return } + Self.imageQueue.async { + guard let directory else { + DispatchQueue.main.async { self.finishImageSave(failed: true) } + return + } + let manager = FileManager.default + let name = FinderToolsSupport.imageFileName(at: Date()) + let destination = FinderToolsSupport.uniqueImageURL( + named: name, in: directory, fileExists: manager.fileExists(atPath:)) + do { + try png.write(to: destination, options: [.atomic, .withoutOverwriting]) + DispatchQueue.main.async { self.finishImageSave(failed: false) } + } catch { + DispatchQueue.main.async { self.finishImageSave(failed: true) } + } } } } } + private static func preparedPNGData( + _ source: Data, type: FinderToolsImageType + ) -> Data? { + guard let imageSource = CGImageSourceCreateWithData(source as CFData, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) + as? [CFString: Any], + let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue, + let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue, + FinderToolsSupport.imageDimensionsAreSafe(width: width, height: height) + else { return nil } + if type == .png { return source } + guard let representation = NSBitmapImageRep(data: source), + let png = representation.representation(using: .png, properties: [:]), + png.count <= Self.maxImageBytes + else { return nil } + return png + } + private func finishImageSave(failed: Bool) { savingImage = false if failed { NSSound.beep() } } private func clearCut() { + cutRequestGeneration &+= 1 cutURLs = [] cutChangeCount = 0 } private func focusedElementIsEditable() -> Bool { - guard let role = focusedElementRole() else { return false } - return !FinderToolsSupport.focusedRoleAllowsRename(role) + FinderToolsSupport.focusedRoleIsEditable(focusedElementRole()) } private func focusedElementRole() -> String? { @@ -427,8 +466,10 @@ private final class DiskImageAppInstaller { private struct Candidate: Sendable { let mount: URL let application: URL + let applicationIdentity: FileIdentity let image: URL let imageIdentity: FileIdentity + let signature: String let destination: URL let name: String } @@ -448,6 +489,7 @@ private final class DiskImageAppInstaller { private struct CommandResult: Sendable { let status: Int32 let output: Data + let error: Data } private let queue = DispatchQueue(label: "com.pulkit.edith.disk-image-installer", qos: .utility) @@ -565,6 +607,8 @@ private final class DiskImageAppInstaller { return validApplication(url) } guard applications.count == 1, let application = applications.first, + let applicationIdentity = fileIdentity(application, expectedType: S_IFDIR), + let signature = verifiedSignature(application), let destination = FinderToolsSupport.applicationDestination( for: application, applicationsDirectory: URL(fileURLWithPath: "/Applications", isDirectory: true)), @@ -574,13 +618,18 @@ private final class DiskImageAppInstaller { Bundle(url: application)?.object( forInfoDictionaryKey: "CFBundleDisplayName") as? String return Candidate( - mount: mount, application: application, image: image, imageIdentity: identity, - destination: destination, + mount: mount, application: application, applicationIdentity: applicationIdentity, + image: image, imageIdentity: identity, signature: signature, destination: destination, name: FinderToolsSupport.displayName(preferred: preferred, application: application)) } nonisolated private static func install(_ candidate: Candidate) -> Outcome { let manager = FileManager.default + guard candidateIsCurrent(candidate) else { + return .failed( + "The mounted disk image or its app changed while Edith was waiting. Nothing was installed." + ) + } guard !manager.fileExists(atPath: candidate.destination.path) else { return .failed( "An app with this name already exists in Applications. Edith did not replace it.") @@ -602,7 +651,7 @@ private final class DiskImageAppInstaller { guard copied.status == 0, validApplication(staged) else { return .failed("The app could not be copied from the disk image.") } - guard gatekeeperAccepts(staged) else { + guard verifiedSignature(staged) == candidate.signature else { return .failed("macOS could not verify this app, so Edith left it on the disk image.") } do { @@ -613,6 +662,7 @@ private final class DiskImageAppInstaller { } catch { return .failed("The verified app could not be placed in Applications.") } + guard mountedImageMatches(candidate) else { return .installedKeepingMount } do { try NSWorkspace.shared.unmountAndEjectDevice(at: candidate.mount) } catch { @@ -644,19 +694,49 @@ private final class DiskImageAppInstaller { ).status == 0 else { return false } let status = run("/usr/sbin/spctl", ["--status"], timeout: 10) - if String(data: status.output, encoding: .utf8)?.localizedCaseInsensitiveContains( - "disabled") == true - { + let statusText = String(decoding: status.output + status.error, as: UTF8.self) + if statusText.localizedCaseInsensitiveContains("disabled") { return true } return run("/usr/sbin/spctl", ["-a", "-t", "exec", url.path], timeout: 120).status == 0 } - nonisolated private static func fileIdentity(_ url: URL) -> FileIdentity? { + nonisolated private static func verifiedSignature(_ url: URL) -> String? { + guard gatekeeperAccepts(url) else { return nil } + let result = run("/usr/bin/codesign", ["-d", "--verbose=4", url.path], timeout: 120) + guard result.status == 0 else { return nil } + return FinderToolsSupport.codeSignatureFingerprint( + in: String(decoding: result.output + result.error, as: UTF8.self)) + } + + nonisolated private static func candidateIsCurrent(_ expected: Candidate) -> Bool { + guard let current = candidate(mountedAt: expected.mount) else { return false } + return current.mount.standardizedFileURL == expected.mount.standardizedFileURL + && current.application.standardizedFileURL == expected.application.standardizedFileURL + && current.applicationIdentity == expected.applicationIdentity + && current.image.standardizedFileURL == expected.image.standardizedFileURL + && current.imageIdentity == expected.imageIdentity + && current.signature == expected.signature + && current.destination.standardizedFileURL == expected.destination.standardizedFileURL + } + + nonisolated private static func mountedImageMatches(_ candidate: Candidate) -> Bool { + let info = run("/usr/bin/hdiutil", ["info", "-plist"], timeout: 10) + guard info.status == 0, + let image = FinderToolsSupport.diskImageURL( + mountedAt: candidate.mount, hdiutilInfo: info.output) + else { return false } + return image.standardizedFileURL == candidate.image.standardizedFileURL + && fileIdentity(image) == candidate.imageIdentity + } + + nonisolated private static func fileIdentity( + _ url: URL, expectedType: mode_t = S_IFREG + ) -> FileIdentity? { var value = stat() guard url.path.withCString({ lstat($0, &value) }) == 0, - value.st_mode & S_IFMT == S_IFREG + value.st_mode & S_IFMT == expectedType else { return nil } return FileIdentity(device: UInt64(value.st_dev), inode: UInt64(value.st_ino)) } @@ -671,12 +751,13 @@ private final class DiskImageAppInstaller { executableURL: URL(fileURLWithPath: executable), arguments: arguments, timeout: timeout) guard !result.timedOut, !result.cancelled else { - return CommandResult(status: -1, output: Data()) + return CommandResult(status: -1, output: Data(), error: Data()) } return CommandResult( - status: result.terminationStatus, output: Data(result.standardOutput.utf8)) + status: result.terminationStatus, output: Data(result.standardOutput.utf8), + error: Data(result.standardError.utf8)) } catch { - return CommandResult(status: -1, output: Data()) + return CommandResult(status: -1, output: Data(), error: Data()) } } } diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift index 9da532776..430163302 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift @@ -112,11 +112,20 @@ public enum ExtensionLiveAdapters { } static func finderToolsReadiness(defaults: UserDefaults) -> ExtensionAdapterReadiness { - let keys = [ + let shortcutKeys = [ AppStorageKeys.FinderTools.cutPaste, AppStorageKeys.FinderTools.rename, - AppStorageKeys.FinderTools.pasteImages, AppStorageKeys.FinderTools.diskImageInstaller, + AppStorageKeys.FinderTools.pasteImages, ] + let keys = shortcutKeys + [AppStorageKeys.FinderTools.diskImageInstaller] let enabled = keys.filter { defaults.object(forKey: $0) as? Bool ?? true } + let shortcutsEnabled = shortcutKeys.contains { + defaults.object(forKey: $0) as? Bool ?? true + } + if shortcutsEnabled, + !defaults.bool(forKey: AppStorageKeys.Permissions.accessibilityGranted) + { + return .needsSetup("Grant Accessibility to use Finder keyboard shortcuts.") + } return ExtensionAdapterFacts( configured: !enabled.isEmpty, readyDetail: "Finder Tools features enabled: \(enabled.count).", diff --git a/Packages/Edith/Sources/EdithKit/Features/FinderTools/FinderToolsSupport.swift b/Packages/Edith/Sources/EdithKit/Features/FinderTools/FinderToolsSupport.swift index 1da41a64f..e9f61a645 100644 --- a/Packages/Edith/Sources/EdithKit/Features/FinderTools/FinderToolsSupport.swift +++ b/Packages/Edith/Sources/EdithKit/Features/FinderTools/FinderToolsSupport.swift @@ -13,6 +13,12 @@ public enum FinderToolsImageType: Equatable, Sendable { } } +public enum FinderToolsMoveOutcome: Equatable, Sendable { + case completed + case reverted + case incomplete +} + public enum FinderToolsSupport { public static func enabled( _ key: String, defaults: UserDefaults = SharedDefaults.store, fallback: Bool = true @@ -33,7 +39,58 @@ public enum FinderToolsSupport { public static func focusedRoleAllowsRename(_ role: String?) -> Bool { guard let role else { return false } - return !["AXTextField", "AXTextArea", "AXComboBox", "AXSecureTextField"].contains(role) + return ["AXBrowser", "AXGrid", "AXList", "AXOutline", "AXTable"].contains(role) + } + + public static func focusedRoleIsEditable(_ role: String?) -> Bool { + guard let role else { return false } + return ["AXTextField", "AXTextArea", "AXComboBox", "AXSecureTextField"].contains(role) + } + + public static func imageDimensionsAreSafe( + width: Int, height: Int, maxDimension: Int = 16_384, maxPixels: Int = 40_000_000 + ) -> Bool { + guard width > 0, height > 0, width <= maxDimension, height <= maxDimension, + maxPixels > 0 + else { return false } + return width <= maxPixels / height + } + + public static func codeSignatureFingerprint(in output: String) -> String? { + let value = output.split(whereSeparator: \Character.isNewline).first { line in + line.hasPrefix("CDHash=") + }?.dropFirst("CDHash=".count) + guard let value else { return nil } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let hexadecimal = CharacterSet(charactersIn: "0123456789abcdef") + guard normalized.count >= 40, + normalized.unicodeScalars.allSatisfy({ hexadecimal.contains($0) }) + else { return nil } + return normalized + } + + public static func move( + _ pairs: [(source: URL, destination: URL)], + using moveItem: (URL, URL) throws -> Void + ) -> FinderToolsMoveOutcome { + var moved: [(source: URL, destination: URL)] = [] + do { + for pair in pairs where pair.source != pair.destination { + try moveItem(pair.source, pair.destination) + moved.append(pair) + } + return .completed + } catch { + var rollbackFailed = false + for pair in moved.reversed() { + do { + try moveItem(pair.destination, pair.source) + } catch { + rollbackFailed = true + } + } + return rollbackFailed ? .incomplete : .reverted + } } public static func imageFileName(at date: Date, calendar: Calendar = .current) -> String { diff --git a/Packages/Edith/Tests/EdithTests/AppServicesTests.swift b/Packages/Edith/Tests/EdithTests/AppServicesTests.swift index 0ca4210ad..37f200d09 100644 --- a/Packages/Edith/Tests/EdithTests/AppServicesTests.swift +++ b/Packages/Edith/Tests/EdithTests/AppServicesTests.swift @@ -41,6 +41,7 @@ private actor AppServicesCallProbe { #expect(services.notchShelf == nil) #expect(services.colorPicker == nil) #expect(services.clipboard == nil) + #expect(services.finderTools == nil) #expect(services.focusDim == nil) #expect(services.presenter == nil) #expect(services.micMute == nil) diff --git a/Packages/Edith/Tests/EdithTests/ExtensionLiveAdapterTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionLiveAdapterTests.swift index e1bb80b59..2943d2ae5 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionLiveAdapterTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionLiveAdapterTests.swift @@ -56,6 +56,7 @@ import EdithCore let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(true, forKey: AppStorageKeys.Permissions.accessibilityGranted) #expect( ExtensionLiveAdapters.finderToolsReadiness(defaults: defaults) == .ready("Finder Tools features enabled: 4.")) @@ -68,6 +69,17 @@ import EdithCore #expect( ExtensionLiveAdapters.finderToolsReadiness(defaults: defaults) == .needsSetup("Turn on at least one Finder Tools feature.")) + + defaults.set(true, forKey: AppStorageKeys.FinderTools.diskImageInstaller) + defaults.set(false, forKey: AppStorageKeys.Permissions.accessibilityGranted) + #expect( + ExtensionLiveAdapters.finderToolsReadiness(defaults: defaults) + == .ready("Finder Tools features enabled: 1.")) + + defaults.set(true, forKey: AppStorageKeys.FinderTools.rename) + #expect( + ExtensionLiveAdapters.finderToolsReadiness(defaults: defaults) + == .needsSetup("Grant Accessibility to use Finder keyboard shortcuts.")) } @Test func usageDetectsMissingLoadingEmptyReadyAndCorruptData() throws { diff --git a/Packages/Edith/Tests/EdithTests/ExtensionReadinessModelTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionReadinessModelTests.swift index 77f433491..135816f8c 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionReadinessModelTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionReadinessModelTests.swift @@ -81,26 +81,6 @@ import Testing #expect(model.report == nil) } - @Test func refreshKeepsTheLastReportUntilItsReplacementIsReady() async { - let fixture = ExtensionReadinessFixture() - let model = ExtensionReadinessModel { await fixture.load($0) } - - let initial = model.refresh() - await fixture.waitUntilStarted(1) - await fixture.release(0, report: report("stable", phase: .ready)) - await initial.value - - let refresh = model.refresh(.verify) - await fixture.waitUntilStarted(2) - #expect(model.report?.state.extensionID == "stable") - #expect(model.isRefreshing) - - await fixture.release(1, report: report("updated", phase: .degraded)) - await refresh.value - #expect(model.report?.state.extensionID == "updated") - #expect(!model.isRefreshing) - } - private func report( _ id: String, phase: ExtensionLifecyclePhase ) -> ExtensionLifecycleReport { diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift index 3fa017144..f7756cc36 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift @@ -62,6 +62,18 @@ import Testing #expect(!source.contains("availableEntries")) } + @Test func finderToolsResynchronizesAfterPermissionChanges() throws { + let sourceURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/EdithHelper/Core/Application/EdithHelperApp.swift") + let source = try String(contentsOf: sourceURL, encoding: .utf8) + + #expect(source.contains("IPC.observe(IPC.Name.permissionsRefreshed)")) + #expect(source.contains("services.finderTools?.syncSettings()")) + } + @Test func quinjetMarketplaceBindingAndSettingsAreReachable() throws { let sourceURL = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() diff --git a/Packages/Edith/Tests/EdithTests/FinderToolsSupportTests.swift b/Packages/Edith/Tests/EdithTests/FinderToolsSupportTests.swift index 2c3617a32..6ead40c7a 100644 --- a/Packages/Edith/Tests/EdithTests/FinderToolsSupportTests.swift +++ b/Packages/Edith/Tests/EdithTests/FinderToolsSupportTests.swift @@ -20,11 +20,46 @@ import Testing == nil) } - @Test func renameRequiresKnownNoneditableFocus() { - #expect(FinderToolsSupport.focusedRoleAllowsRename("AXOutline")) + @Test func renameRequiresAFileCollectionAndEditingDetectionIsIndependent() { + for role in ["AXBrowser", "AXGrid", "AXList", "AXOutline", "AXTable"] { + #expect(FinderToolsSupport.focusedRoleAllowsRename(role)) + } + for role in ["AXButton", "AXDialog", "AXGroup", "AXScrollArea", "AXTextField"] { + #expect(!FinderToolsSupport.focusedRoleAllowsRename(role)) + } #expect(!FinderToolsSupport.focusedRoleAllowsRename("AXTextField")) - #expect(!FinderToolsSupport.focusedRoleAllowsRename("AXTextArea")) #expect(!FinderToolsSupport.focusedRoleAllowsRename(nil)) + #expect(FinderToolsSupport.focusedRoleIsEditable("AXTextField")) + #expect(FinderToolsSupport.focusedRoleIsEditable("AXTextArea")) + #expect(FinderToolsSupport.focusedRoleIsEditable("AXComboBox")) + #expect(FinderToolsSupport.focusedRoleIsEditable("AXSecureTextField")) + #expect(!FinderToolsSupport.focusedRoleIsEditable("AXOutline")) + #expect(!FinderToolsSupport.focusedRoleIsEditable(nil)) + } + + @Test func imageDimensionsBoundDecodedMemory() { + #expect(FinderToolsSupport.imageDimensionsAreSafe(width: 7_680, height: 4_320)) + #expect(!FinderToolsSupport.imageDimensionsAreSafe(width: 16_385, height: 1)) + #expect(!FinderToolsSupport.imageDimensionsAreSafe(width: 10_000, height: 10_000)) + #expect(!FinderToolsSupport.imageDimensionsAreSafe(width: 0, height: 100)) + #expect(!FinderToolsSupport.imageDimensionsAreSafe(width: 100, height: -1)) + #expect( + FinderToolsSupport.imageDimensionsAreSafe( + width: 20, height: 20, maxDimension: 20, maxPixels: 400)) + } + + @Test func parsesOnlyValidCodeSignatureFingerprints() { + let hash = "0123456789abcdef0123456789abcdef01234567" + #expect( + FinderToolsSupport.codeSignatureFingerprint( + in: "Identifier=com.example.App\nCDHash=\(hash.uppercased())\nTeamIdentifier=ABCDE") + == hash) + #expect( + FinderToolsSupport.codeSignatureFingerprint(in: "Identifier=com.example.App") == nil) + #expect(FinderToolsSupport.codeSignatureFingerprint(in: "CDHash=1234") == nil) + #expect( + FinderToolsSupport.codeSignatureFingerprint( + in: "CDHash=0123456789abcdef0123456789abcdef0123456z") == nil) } @Test func createsStableUniqueImageNames() { @@ -60,6 +95,51 @@ import Testing fileExists: { _ in false }) == nil) } + @Test func fileMovesCompleteOrRollBackInReverseOrder() { + struct MoveFailure: Error {} + let first = ( + source: URL(fileURLWithPath: "/source/a"), + destination: URL(fileURLWithPath: "/target/a") + ) + let second = ( + source: URL(fileURLWithPath: "/source/b"), + destination: URL(fileURLWithPath: "/target/b") + ) + var operations: [String] = [] + let outcome = FinderToolsSupport.move([first, second]) { source, destination in + operations.append("\(source.path)->\(destination.path)") + if source == second.source { throw MoveFailure() } + } + #expect(outcome == .reverted) + #expect( + operations == [ + "/source/a->/target/a", "/source/b->/target/b", "/target/a->/source/a", + ]) + + operations = [] + let completed = FinderToolsSupport.move([first, second]) { source, destination in + operations.append("\(source.path)->\(destination.path)") + } + #expect(completed == .completed) + #expect(operations.count == 2) + } + + @Test func fileMovesReportAnIncompleteRollback() { + struct MoveFailure: Error {} + let first = ( + source: URL(fileURLWithPath: "/source/a"), + destination: URL(fileURLWithPath: "/target/a") + ) + let second = ( + source: URL(fileURLWithPath: "/source/b"), + destination: URL(fileURLWithPath: "/target/b") + ) + let outcome = FinderToolsSupport.move([first, second]) { source, _ in + if source == second.source || source == first.destination { throw MoveFailure() } + } + #expect(outcome == .incomplete) + } + @Test func mapsOnlyOneRealDiskImageToTheMount() throws { let root: [String: Any] = [ "images": [ @@ -78,6 +158,23 @@ import Testing #expect( FinderToolsSupport.diskImageURL( mountedAt: URL(fileURLWithPath: "/Volumes/Other"), hdiutilInfo: data) == nil) + + let ambiguous = try PropertyListSerialization.data( + fromPropertyList: [ + "images": [ + [ + "image-path": "/Users/me/Downloads/App.dmg", + "system-entities": [["mount-point": "/Volumes/App"]], + ], + [ + "image-path": "/Users/me/Downloads/Other.dmg", + "system-entities": [["mount-point": "/Volumes/App"]], + ], + ] + ], format: .xml, options: 0) + #expect( + FinderToolsSupport.diskImageURL( + mountedAt: URL(fileURLWithPath: "/Volumes/App"), hdiutilInfo: ambiguous) == nil) } @Test func applicationDestinationsStayInsideApplications() { From 2babd16d78166b235d166b9ba655c8ef230abee1 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Sat, 29 Aug 2026 02:51:03 +0530 Subject: [PATCH 4/8] fix: require Finder selections for rename --- .../Services/FinderToolsService.swift | 38 ++++++++++++++++--- .../FinderTools/FinderToolsSupport.swift | 7 ++++ .../EdithTests/FinderToolsSupportTests.swift | 12 ++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift b/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift index 24e4dc0cf..f542e8652 100644 --- a/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift +++ b/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift @@ -217,7 +217,7 @@ private final class FinderShortcutService: @unchecked Sendable { else { return .pass } switch keyCode { case Key.f2: - return renameEnabled && FinderToolsSupport.focusedRoleAllowsRename(focusedElementRole()) + return renameEnabled && focusedElementAllowsRename() ? .rename : .pass case Key.x: guard cutPasteEnabled, !focusedElementIsEditable() else { return .pass } @@ -399,6 +399,21 @@ private final class FinderShortcutService: @unchecked Sendable { } private func focusedElementRole() -> String? { + guard let element = focusedElement() else { return nil } + return stringAttribute(kAXRoleAttribute as CFString, of: element) + } + + private func focusedElementAllowsRename() -> Bool { + guard let element = focusedElement() else { return false } + return FinderToolsSupport.focusedElementAllowsRename( + role: stringAttribute(kAXRoleAttribute as CFString, of: element), + selectedRowCount: arrayAttributeCount( + kAXSelectedRowsAttribute as CFString, of: element), + selectedChildCount: arrayAttributeCount( + kAXSelectedChildrenAttribute as CFString, of: element)) + } + + private func focusedElement() -> AXUIElement? { let system = AXUIElementCreateSystemWide() AXUIElementSetMessagingTimeout(system, 0.15) var focused: CFTypeRef? @@ -407,13 +422,24 @@ private final class FinderShortcutService: @unchecked Sendable { system, kAXFocusedUIElementAttribute as CFString, &focused) == .success, let focused, CFGetTypeID(focused) == AXUIElementGetTypeID() else { return nil } - let element = focused as! AXUIElement - var role: CFTypeRef? + return (focused as! AXUIElement) + } + + private func stringAttribute(_ name: CFString, of element: AXUIElement) -> String? { + var value: CFTypeRef? guard - AXUIElementCopyAttributeValue(element, kAXRoleAttribute as CFString, &role) == .success, - let role = role as? String + AXUIElementCopyAttributeValue(element, name, &value) == .success, + let value = value as? String + else { return nil } + return value + } + + private func arrayAttributeCount(_ name: CFString, of element: AXUIElement) -> Int? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, name, &value) == .success, + let value, CFGetTypeID(value) == CFArrayGetTypeID() else { return nil } - return role + return CFArrayGetCount((value as! CFArray)) } } diff --git a/Packages/Edith/Sources/EdithKit/Features/FinderTools/FinderToolsSupport.swift b/Packages/Edith/Sources/EdithKit/Features/FinderTools/FinderToolsSupport.swift index e9f61a645..b111e62f9 100644 --- a/Packages/Edith/Sources/EdithKit/Features/FinderTools/FinderToolsSupport.swift +++ b/Packages/Edith/Sources/EdithKit/Features/FinderTools/FinderToolsSupport.swift @@ -42,6 +42,13 @@ public enum FinderToolsSupport { return ["AXBrowser", "AXGrid", "AXList", "AXOutline", "AXTable"].contains(role) } + public static func focusedElementAllowsRename( + role: String?, selectedRowCount: Int?, selectedChildCount: Int? + ) -> Bool { + focusedRoleAllowsRename(role) + && max(selectedRowCount ?? 0, selectedChildCount ?? 0) > 0 + } + public static func focusedRoleIsEditable(_ role: String?) -> Bool { guard let role else { return false } return ["AXTextField", "AXTextArea", "AXComboBox", "AXSecureTextField"].contains(role) diff --git a/Packages/Edith/Tests/EdithTests/FinderToolsSupportTests.swift b/Packages/Edith/Tests/EdithTests/FinderToolsSupportTests.swift index 6ead40c7a..a48563766 100644 --- a/Packages/Edith/Tests/EdithTests/FinderToolsSupportTests.swift +++ b/Packages/Edith/Tests/EdithTests/FinderToolsSupportTests.swift @@ -29,6 +29,18 @@ import Testing } #expect(!FinderToolsSupport.focusedRoleAllowsRename("AXTextField")) #expect(!FinderToolsSupport.focusedRoleAllowsRename(nil)) + #expect( + FinderToolsSupport.focusedElementAllowsRename( + role: "AXOutline", selectedRowCount: 1, selectedChildCount: nil)) + #expect( + FinderToolsSupport.focusedElementAllowsRename( + role: "AXGrid", selectedRowCount: nil, selectedChildCount: 2)) + #expect( + !FinderToolsSupport.focusedElementAllowsRename( + role: "AXOutline", selectedRowCount: 0, selectedChildCount: 0)) + #expect( + !FinderToolsSupport.focusedElementAllowsRename( + role: "AXButton", selectedRowCount: 1, selectedChildCount: 1)) #expect(FinderToolsSupport.focusedRoleIsEditable("AXTextField")) #expect(FinderToolsSupport.focusedRoleIsEditable("AXTextArea")) #expect(FinderToolsSupport.focusedRoleIsEditable("AXComboBox")) From 0bbe0ea862ffb2b78a8a49e8ca89ba9ed763dc3d Mon Sep 17 00:00:00 2001 From: Pulkit Date: Sat, 29 Aug 2026 02:52:09 +0530 Subject: [PATCH 5/8] docs: explain Finder Tools safety guarantees --- .../Edith/Features/Settings/Views/FinderToolsRows.swift | 8 ++++---- docs/cli/finder-tools/README.md | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/FinderToolsRows.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/FinderToolsRows.swift index 21d1e9d6f..ff36901ae 100644 --- a/Packages/Edith/Sources/Edith/Features/Settings/Views/FinderToolsRows.swift +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/FinderToolsRows.swift @@ -21,7 +21,7 @@ struct FinderToolsRows: View { "Cut and paste files with ⌘X and ⌘V", isOn: $cutPaste.configured(AppStorageKeys.FinderTools.cutPaste)) Text( - "Edith moves the selected files into the folder open in Finder. Existing files are never replaced." + "Edith moves the selected files into the folder open in Finder. Existing files are never replaced, and a failed batch is rolled back when possible." ) .settingsCaption() Toggle( @@ -31,7 +31,7 @@ struct FinderToolsRows: View { "Paste copied images as PNG files", isOn: $pasteImages.configured(AppStorageKeys.FinderTools.pasteImages)) Text( - "Press ⌘V in Finder to save a copied image into the open folder as a PNG with a timestamped name." + "Press ⌘V in Finder to save a copied image into the open folder as a PNG with a timestamped name. Large or invalid images are rejected before decoding." ) .settingsCaption() } @@ -42,7 +42,7 @@ struct FinderToolsRows: View { isOn: $diskImageInstaller.configured( AppStorageKeys.FinderTools.diskImageInstaller)) Text( - "When a mounted DMG contains exactly one verified app, Edith can install it in Applications, eject the image, and move the unchanged download to Trash. Existing apps are never replaced." + "When a mounted DMG contains exactly one verified app, Edith can install it in Applications, eject the image, and move the unchanged download to Trash. Edith rechecks the app before copying and never replaces an existing app." ) .settingsCaption() } @@ -51,7 +51,7 @@ struct FinderToolsRows: View { LabeledContent("Accessibility", value: "Finder keyboard shortcuts") LabeledContent("Automation", value: "Finder selection and destination") Text( - "Finder Automation is requested by macOS on first use. Disk image installation does not need Full Disk Access." + "Accessibility is only needed for keyboard features. Finder Automation is requested on first use of selection or destination access. Disk image installation needs neither permission nor Full Disk Access." ) .settingsCaption() } diff --git a/docs/cli/finder-tools/README.md b/docs/cli/finder-tools/README.md index 93d12549e..6c07d65ac 100644 --- a/docs/cli/finder-tools/README.md +++ b/docs/cli/finder-tools/README.md @@ -9,9 +9,13 @@ ed extensions setup finderTools ed extensions status finderTools --json ``` -The extension can move Finder selections with Command-X and Command-V, rename the current selection with F2, save a copied image into the open Finder folder as a PNG file, and offer to install the single app found on a mounted disk image. Copying an image file still uses Finder's normal file paste instead of creating a duplicate PNG. +The extension can move Finder selections with Command-X and Command-V, rename the current file selection with F2, save a copied image into the open Finder folder as a PNG file, and offer to install the single app found on a mounted disk image. Copying an image file still uses Finder's normal file paste instead of creating a duplicate PNG. Existing destination files are never replaced. If one item in a move batch fails, Edith attempts to restore items it already moved and keeps the cut selection available when the rollback succeeds. -Accessibility is required for the Finder keyboard shortcuts. macOS asks for Finder Automation access on first use when Edith reads the current selection or destination. The disk image installer only accepts a real DMG mount containing exactly one executable app bundle. It verifies the copied app before placing it in `/Applications`, never replaces an existing app, ejects the disk image after a successful install, and only then moves the unchanged DMG to Trash. +Accessibility is required only when at least one Finder keyboard feature is enabled. macOS asks for Finder Automation access on first use when Edith reads the current selection or destination. Permission refreshes take effect without an unrelated settings change. An installer-only configuration remains available without Accessibility or Automation. + +Copied images are inspected off the main app thread. Edith rejects invalid images, encoded payloads larger than 64 MiB, dimensions above 16,384 pixels, or images above 40 million pixels. PNG clipboard data is preserved without a decode and re-encode pass. Other supported image formats are converted to PNG. + +The disk image installer only accepts a real DMG mount containing exactly one executable app bundle. It verifies the app with macOS before presenting the install offer, records the app's signing fingerprint and disk-image identity, revalidates both after the prompt, and verifies the staged copy before placing it in `/Applications`. It never replaces an existing app, ejects only the image that was inspected, and only then moves the unchanged DMG to Trash. Each behavior can be changed from Settings, Extensions, Finder Tools, or from the CLI: From 9ad8b7e33bd5a972063a6a87d29c06549df13e3f Mon Sep 17 00:00:00 2001 From: Pulkit Date: Tue, 8 Sep 2026 02:20:13 +0530 Subject: [PATCH 6/8] Render Finder Tools settings with isolated preferences --- .../EdithTests/FinderToolsRenderTests.swift | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 Packages/Edith/Tests/EdithTests/FinderToolsRenderTests.swift diff --git a/Packages/Edith/Tests/EdithTests/FinderToolsRenderTests.swift b/Packages/Edith/Tests/EdithTests/FinderToolsRenderTests.swift new file mode 100644 index 000000000..1f64db65e --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/FinderToolsRenderTests.swift @@ -0,0 +1,29 @@ +import AppKit +import SwiftUI +import Testing + +@testable import Edith +@testable import EdithKit + +@Suite @MainActor struct FinderToolsRenderTests { + @Test func settingsRenderWithEnabledActions() throws { + let defaults = SharedDefaults.store + let key = AppStorageKeys.FinderTools.enabled + let previous = defaults.object(forKey: key) + defer { defaults.set(previous, forKey: key) } + defaults.set(true, forKey: key) + let hosting = NSHostingView(rootView: + Form { FinderToolsRows() } + .formStyle(.grouped) + .frame(width: 580, height: 670)) + hosting.frame = NSRect(x: 0, y: 0, width: 580, height: 670) + hosting.layoutSubtreeIfNeeded() + let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + #expect(bitmap.pixelsWide >= 580) + if let directory = ProcessInfo.processInfo.environment["EDITH_RENDER_DUMP"] { + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("finder-tools.png")) + } + } +} From 27cccd8af31197c20fa72277e15d2e7bd0f4dd80 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Tue, 8 Sep 2026 02:26:08 +0530 Subject: [PATCH 7/8] Format integration for strict Swift checks --- .../EdithHelper/Core/Application/AppServices.swift | 3 ++- .../Tests/EdithCoreTests/ExtensionRegistryTests.swift | 3 ++- .../Edith/Tests/EdithTests/ExtensionRegistryTests.swift | 3 ++- .../Edith/Tests/EdithTests/FinderToolsRenderTests.swift | 8 +++++--- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift b/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift index cf0859645..a549174d9 100644 --- a/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift +++ b/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift @@ -318,7 +318,8 @@ final class AppServices { notchShelf?.attachCalendar(calendar) notchShelf?.attachColorPicker(colorPicker) - let finderToolsOn = ExtensionRegistry.entry("finderTools")?.isEnabled(in: SharedDefaults.store) == true + let finderToolsOn = + ExtensionRegistry.entry("finderTools")?.isEnabled(in: SharedDefaults.store) == true if finderToolsOn, finderTools == nil { finderTools = FinderToolsService() } if !finderToolsOn { finderTools?.shutdown() diff --git a/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift index 05b0aa067..dc90632b3 100644 --- a/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift @@ -16,7 +16,8 @@ import Testing "usage", "herdr", "quinjet", "companion", "plugins", "appMaintenance", "homebrew", "cleaner", "system", "keepAwake", "lidAwake", "systemStats", "micMute", - "clipboard", "finderTools", "emoji", "colorPicker", "keystrokeHighlight", "focusDim", "presenter", + "clipboard", "finderTools", "emoji", "colorPicker", "keystrokeHighlight", + "focusDim", "presenter", "music", "downloads", "notchShelf", "audioMixer", "calendar", "database", "attention", "seoAudit", ]) diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift index 1ed8a14e2..4a3c20c48 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift @@ -48,7 +48,8 @@ import Testing "usage", "herdr", "quinjet", "companion", "plugins", "appMaintenance", "homebrew", "cleaner", "system", "keepAwake", "lidAwake", "systemStats", "micMute", - "clipboard", "finderTools", "emoji", "colorPicker", "keystrokeHighlight", "focusDim", "presenter", + "clipboard", "finderTools", "emoji", "colorPicker", "keystrokeHighlight", + "focusDim", "presenter", "music", "downloads", "notchShelf", "audioMixer", "calendar", "database", "attention", "seoAudit", ]) diff --git a/Packages/Edith/Tests/EdithTests/FinderToolsRenderTests.swift b/Packages/Edith/Tests/EdithTests/FinderToolsRenderTests.swift index 1f64db65e..4c35beb9a 100644 --- a/Packages/Edith/Tests/EdithTests/FinderToolsRenderTests.swift +++ b/Packages/Edith/Tests/EdithTests/FinderToolsRenderTests.swift @@ -12,8 +12,9 @@ import Testing let previous = defaults.object(forKey: key) defer { defaults.set(previous, forKey: key) } defaults.set(true, forKey: key) - let hosting = NSHostingView(rootView: - Form { FinderToolsRows() } + let hosting = NSHostingView( + rootView: + Form { FinderToolsRows() } .formStyle(.grouped) .frame(width: 580, height: 670)) hosting.frame = NSRect(x: 0, y: 0, width: 580, height: 670) @@ -23,7 +24,8 @@ import Testing #expect(bitmap.pixelsWide >= 580) if let directory = ProcessInfo.processInfo.environment["EDITH_RENDER_DUMP"] { let data = try #require(bitmap.representation(using: .png, properties: [:])) - try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("finder-tools.png")) + try data.write( + to: URL(fileURLWithPath: directory).appendingPathComponent("finder-tools.png")) } } } From 80595091ef9a49c073c72ee36d12019ffcc212e4 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Tue, 8 Sep 2026 02:40:41 +0530 Subject: [PATCH 8/8] Import the shared bounded process runner for Finder installs --- .../Features/FinderTools/Services/FinderToolsService.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift b/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift index f542e8652..028d3e10f 100644 --- a/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift +++ b/Packages/Edith/Sources/EdithHelper/Features/FinderTools/Services/FinderToolsService.swift @@ -3,6 +3,7 @@ import ApplicationServices import CoreGraphics import Darwin import EdithKit +import EdithLidAwakeSupport import Foundation import ImageIO