Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

423 lines
17 KiB
Swift

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<String> = []
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<URL> {
// 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)]
}
}