import AppKit import FinderSync import Foundation import OSLog final class RightClickFinderSync: FIFinderSync { private enum ActionID { static let newFolder = "new-folder" static let newFile = "new-file" static let openInTerminal = "open-terminal" static let openWith = "open-with" static let copyFileName = "copy-file-name" static let copyAbsolutePath = "copy-absolute-path" static let copyRelativePath = "copy-relative-path" static let copyShellEscapedPath = "copy-shell-escaped-path" static let copyFileURL = "copy-file-url" } private final class MenuActionContext: NSObject { let actionID: String let directory: URL? let fileExtension: String? let appPath: String? init(actionID: String, directory: URL? = nil, fileExtension: String? = nil, appPath: String? = nil) { self.actionID = actionID self.directory = directory self.fileExtension = fileExtension self.appPath = appPath } } private let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools.right-click.finder-sync", category: "RightClickFinderSync" ) private var titleToActionContext: [String: MenuActionContext] = [:] private var lastSelectedURLs: [URL] = [] private var lastTargetedURL: URL? private let hostURLScheme = Bundle.main.object(forInfoDictionaryKey: "MTRightClickHostURLScheme") as? String ?? "mactools" override init() { super.init() FIFinderSyncController.default().directoryURLs = Self.monitoredDirectories() let monitoredPaths = FIFinderSyncController.default().directoryURLs.map(\.path).joined(separator: ", ") logger.info("RightClick Finder Sync loaded. Monitoring: \(monitoredPaths, privacy: .public)") } override var toolbarItemName: String { "MacTools" } override var toolbarItemToolTip: String { let configuration = RightClickConfigurationStore.load() return RightClickLocalization.string( "finder.toolbarToolTip", defaultValue: "MacTools 右键工具", preferredLanguages: configuration.preferredLanguages ) } override var toolbarItemImage: NSImage { let image = NSImage(systemSymbolName: "contextualmenu.and.cursorarrow", accessibilityDescription: "MacTools") ?? NSImage() image.isTemplate = true return image } override func menu(for menuKind: FIMenuKind) -> NSMenu? { let controller = FIFinderSyncController.default() let selectedURLs = controller.selectedItemURLs() ?? [] let targetedURL = controller.targetedURL() lastSelectedURLs = selectedURLs lastTargetedURL = targetedURL titleToActionContext.removeAll() let configuration = RightClickConfigurationStore.load() guard configuration.menuEnabled else { return nil } let menu = NSMenu(title: "MacTools") switch menuKind { case .contextualMenuForContainer: if let directory = RightClickTargetResolver.targetDirectory( selectedURLs: [], targetedURL: targetedURL ) { addDirectoryItems(to: menu, directory: directory, configuration: configuration) addOpenWithMenu(to: menu, configuration: configuration, targets: [directory]) } case .contextualMenuForItems, .contextualMenuForSidebar, .toolbarItemMenu: addMenuItemsForSelection(selectedURLs, targetedURL: targetedURL, to: menu, configuration: configuration) @unknown default: break } return menu } override func beginObservingDirectory(at url: URL) { logger.debug("Begin observing directory: \(url.path, privacy: .public)") } override func endObservingDirectory(at url: URL) { logger.debug("End observing directory: \(url.path, privacy: .public)") } private func addMenuItemsForSelection( _ selectedURLs: [URL], targetedURL: URL?, to menu: NSMenu, configuration: RightClickConfiguration ) { if let directory = RightClickTargetResolver.targetDirectory( selectedURLs: selectedURLs, targetedURL: targetedURL ) { addDirectoryItems(to: menu, directory: directory, configuration: configuration) } guard !selectedURLs.isEmpty else { return } if configuration.copyFileName { addCopyItem( title: localized("finder.copyFileName", defaultValue: "复制文件名", configuration: configuration), actionID: ActionID.copyFileName, to: menu ) } if configuration.copyAbsolutePath { addCopyItem( title: localized("finder.copyAbsolutePath", defaultValue: "复制绝对路径", configuration: configuration), actionID: ActionID.copyAbsolutePath, to: menu ) } if configuration.copyRelativePath { addCopyItem( title: localized("finder.copyRelativePath", defaultValue: "复制相对路径", configuration: configuration), actionID: ActionID.copyRelativePath, to: menu ) } if configuration.copyShellEscapedPath { addCopyItem( title: localized("finder.copyShellEscapedPath", defaultValue: "复制转义路径", configuration: configuration), actionID: ActionID.copyShellEscapedPath, to: menu ) } if configuration.copyFileURL { addCopyItem( title: localized("finder.copyFileURL", defaultValue: "复制 file:// 链接", configuration: configuration), actionID: ActionID.copyFileURL, to: menu ) } addOpenWithMenu(to: menu, configuration: configuration, targets: selectedURLs) } /// Directory-level items shared by container and item right-clicks. private func addDirectoryItems(to menu: NSMenu, directory: URL, configuration: RightClickConfiguration) { if configuration.newFolder { addNewFolderItem(to: menu, directory: directory, configuration: configuration) } if configuration.newFile { addNewFileMenu(to: menu, directory: directory, configuration: configuration) } if configuration.openInTerminal { addOpenInTerminalItem(to: menu, directory: directory, configuration: configuration) } } private func addNewFolderItem(to menu: NSMenu, directory: URL, configuration: RightClickConfiguration) { let item = actionItem(title: localized("finder.newFolder", defaultValue: "新建文件夹", configuration: configuration), context: MenuActionContext( actionID: ActionID.newFolder, directory: directory )) menu.addItem(item) } private func addNewFileMenu(to menu: NSMenu, directory: URL, configuration: RightClickConfiguration) { let title = localized("finder.newFile", defaultValue: "新建文件", configuration: configuration) let parent = NSMenuItem(title: title, action: nil, keyEquivalent: "") let submenu = NSMenu(title: title) for ext in RightClickNewFile.supportedExtensions { let item = actionItem( title: Self.newFileItemTitle(for: ext, preferredLanguages: configuration.preferredLanguages), context: MenuActionContext(actionID: ActionID.newFile, directory: directory, fileExtension: ext) ) submenu.addItem(item) } parent.submenu = submenu menu.addItem(parent) } private static func newFileItemTitle(for ext: String, preferredLanguages: [String]?) -> String { switch ext { case "txt": RightClickLocalization.string( "finder.newFile.text", defaultValue: "文本文件 (.txt)", preferredLanguages: preferredLanguages ) case "md": "Markdown (.md)" case "json": "JSON (.json)" default: ".\(ext)" } } private func addOpenInTerminalItem(to menu: NSMenu, directory: URL, configuration: RightClickConfiguration) { let item = actionItem(title: localized("finder.openInTerminal", defaultValue: "在终端打开", configuration: configuration), context: MenuActionContext( actionID: ActionID.openInTerminal, directory: directory )) menu.addItem(item) } /// Adds an "open with" submenu for the configured apps that match at least one /// target's extension (an entry with no extension filter matches everything). private func addOpenWithMenu(to menu: NSMenu, configuration: RightClickConfiguration, targets: [URL]) { guard !configuration.openWithApps.isEmpty, !targets.isEmpty else { return } let matching = configuration.openWithApps.filter { app in targets.contains { app.matches(fileExtension: $0.pathExtension) } } guard !matching.isEmpty else { return } let title = localized("finder.openWith", defaultValue: "用应用打开", configuration: configuration) let parent = NSMenuItem(title: title, action: nil, keyEquivalent: "") let submenu = NSMenu(title: title) var usedTitles: Set = [] for app in matching { // Stable identity is appPath (carried in representedObject). Make the // displayed title unique too, so the title fallback (used when // FinderSync strips representedObject) still resolves to one app. var title = app.name if !usedTitles.insert(title).inserted { let folder = URL(fileURLWithPath: app.appPath).deletingLastPathComponent().lastPathComponent title = "\(app.name) — \(folder)" while !usedTitles.insert(title).inserted { title += " ·" } } let item = actionItem( title: title, context: MenuActionContext(actionID: ActionID.openWith, appPath: app.appPath) ) submenu.addItem(item) } parent.submenu = submenu menu.addItem(parent) } private func localized( _ key: String, defaultValue: String, configuration: RightClickConfiguration ) -> String { RightClickLocalization.string( key, defaultValue: defaultValue, preferredLanguages: configuration.preferredLanguages ) } private func addCopyItem( title: String, actionID: String, to menu: NSMenu ) { menu.addItem(actionItem(title: title, actionID: actionID)) } private func actionItem(title: String, actionID: String) -> NSMenuItem { actionItem(title: title, context: MenuActionContext(actionID: actionID)) } private func actionItem(title: String, context: MenuActionContext) -> NSMenuItem { let item = NSMenuItem( title: title, action: #selector(handleMenuAction(_:)), keyEquivalent: "" ) item.target = self item.representedObject = context titleToActionContext[title] = context return item } @objc private func handleMenuAction(_ sender: NSMenuItem) { guard let context = menuActionContext(for: sender) else { logger.error("Missing action context for menu item: \(sender.title, privacy: .public)") return } switch context.actionID { case ActionID.newFolder: guard let directory = context.directory else { return } openHostURL(path: "/new-folder", queryItems: [ URLQueryItem(name: "directory", value: directory.path) ]) case ActionID.newFile: guard let directory = context.directory, let ext = context.fileExtension else { return } openHostURL(path: "/new-file", queryItems: [ URLQueryItem(name: "directory", value: directory.path), URLQueryItem(name: "ext", value: ext) ]) case ActionID.openInTerminal: guard let directory = context.directory else { return } openHostURL(path: "/open-terminal", queryItems: [ URLQueryItem(name: "directory", value: directory.path) ]) case ActionID.openWith: handleOpenWith(appPath: context.appPath) case ActionID.copyFileName: copyToPasteboard(RightClickPathFormatter.joinedFileNames(currentSelectedURLs())) case ActionID.copyAbsolutePath: copyToPasteboard(RightClickPathFormatter.joinedPaths(currentSelectedURLs())) case ActionID.copyRelativePath: copyToPasteboard( RightClickPathFormatter.joinedRelativePaths( currentSelectedURLs(), base: currentTargetedURL() ) ) case ActionID.copyShellEscapedPath: copyToPasteboard(RightClickPathFormatter.joinedShellEscapedPaths(currentSelectedURLs())) case ActionID.copyFileURL: copyToPasteboard(RightClickPathFormatter.joinedFileURLs(currentSelectedURLs())) default: logger.error("Unknown action: \(context.actionID, privacy: .public)") } } /// Forward an open-with request to the host: resolve the app by its name from /// the current config (the menu item only carries the title), then send the /// app path and the target file paths. private func handleOpenWith(appPath: String?) { guard let appPath else { return } let configuration = RightClickConfigurationStore.load() guard let app = configuration.openWithApps.first(where: { $0.appPath == appPath }) else { logger.error("open-with: no configured app at \(appPath, privacy: .public)") return } let selected = currentSelectedURLs() let resolved = selected.isEmpty ? [currentTargetedURL()].compactMap { $0 } : selected // Only forward files this app actually applies to — a mixed selection may // include types its extension filter excludes. let targets = resolved.filter { app.matches(fileExtension: $0.pathExtension) } guard !targets.isEmpty else { logger.error("open-with: no matching target items for \(appPath, privacy: .public)") return } var queryItems = [URLQueryItem(name: "app", value: app.appPath)] queryItems += targets.map { URLQueryItem(name: "file", value: $0.path) } openHostURL(path: "/open-with", queryItems: queryItems) } private func menuActionContext(for item: NSMenuItem) -> MenuActionContext? { if let context = item.representedObject as? MenuActionContext { return context } // macOS can strip representedObject from Finder Sync menu items. Keep a // title fallback, mirroring SuperRClick's approach. return titleToActionContext[item.title] } private func currentSelectedURLs() -> [URL] { let selectedURLs = FIFinderSyncController.default().selectedItemURLs() ?? [] return selectedURLs.isEmpty ? lastSelectedURLs : selectedURLs } private func currentTargetedURL() -> URL? { FIFinderSyncController.default().targetedURL() ?? lastTargetedURL } private func copyToPasteboard(_ value: String) { guard !value.isEmpty else { logger.error("Copy action skipped because selected URLs are empty") return } let pasteboard = NSPasteboard.general pasteboard.declareTypes([.string], owner: nil) guard pasteboard.setString(value, forType: .string) else { logger.error("Failed to write right-click value to pasteboard") return } } private func openHostURL(path: String, queryItems: [URLQueryItem]) { var components = URLComponents() components.scheme = hostURLScheme components.host = "right-click" components.path = path components.queryItems = queryItems guard let url = components.url else { logger.error("Failed to build host URL for \(path, privacy: .public)") return } NSWorkspace.shared.open(url) } private static func monitoredDirectories() -> Set { // Finder Sync only asks for menus inside monitored directories. A right-click // utility is expected to work from arbitrary Finder locations, so monitor the // filesystem root and let the menu builder stay lightweight. [URL(fileURLWithPath: "/", isDirectory: true)] } }