diff --git a/FinderSyncExt/FinderSyncExt.swift b/FinderSyncExt/FinderSyncExt.swift index ba3d482..5fe5166 100644 --- a/FinderSyncExt/FinderSyncExt.swift +++ b/FinderSyncExt/FinderSyncExt.swift @@ -269,8 +269,11 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { if let appURL = app.appURL { let cacheKey = "app:\(appURL)" if let cached = iconCache[cacheKey] { return cached } - let icon: NSImage = DispatchQueue.main.sync { - NSWorkspace.shared.icon(forFile: appURL) + let icon: NSImage + if Thread.isMainThread { + icon = MainActor.assumeIsolated { NSWorkspace.shared.icon(forFile: appURL) } + } else { + icon = DispatchQueue.main.sync { NSWorkspace.shared.icon(forFile: appURL) } } if icon.size.width > 0 { iconCache[cacheKey] = icon @@ -342,6 +345,18 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { return menu } + if let nodes = config.customMenu { + CustomMenu.render(nodes, into: menu, makeItem: { type, id in + switch type { + case .action: return config.actions.first { $0.id == id }.map(self.makeActionItem) + case .app: return config.apps.first { $0.id == id }.map(self.makeAppItem) + case .newFile: return config.newFiles.first { $0.id == id }.map(self.makeNewFileItem) + case .commonDir: return config.commonDirs.first { $0.id == id }.map(self.makeCommonDirItem) + } + }, loadIcon: { self.loadIcon(named: $0, accessibilityDescription: $0) }) + return menu + } + // 构建动作菜单 if !config.actions.isEmpty { if config.actionsCollapsed { @@ -349,12 +364,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { let actionsTitle = AppLocalization.localized("Actions") let actionsSubMenu = NSMenu(title: actionsTitle) for action in config.actions { - let item = NSMenuItem(title: action.name, action: #selector(handleActionClick(_:)), keyEquivalent: "") - item.tag = hashForAction(action) - item.target = self - if let icon = templateSymbol(action.icon) { - item.image = icon - } + let item = makeActionItem(action) actionsSubMenu.addItem(item) } let actionsItem = NSMenuItem(title: actionsTitle, action: nil, keyEquivalent: "") @@ -364,12 +374,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { } else { // 不折叠:直接显示菜单项 for action in config.actions { - let item = NSMenuItem(title: action.name, action: #selector(handleActionClick(_:)), keyEquivalent: "") - item.tag = hashForAction(action) - item.target = self - if let icon = templateSymbol(action.icon) { - item.image = icon - } + let item = makeActionItem(action) menu.addItem(item) } } @@ -382,10 +387,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { let appsTitle = AppLocalization.localized("Open With") let appsSubMenu = NSMenu(title: appsTitle) for app in config.apps { - let item = NSMenuItem(title: app.name, action: #selector(handleAppClick(_:)), keyEquivalent: "") - item.tag = hashForApp(app) - item.target = self - item.image = cachedAppIcon(app: app) + let item = makeAppItem(app) appsSubMenu.addItem(item) } let appsItem = NSMenuItem(title: appsTitle, action: nil, keyEquivalent: "") @@ -395,10 +397,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { } else { // 不折叠:直接显示菜单项 for app in config.apps { - let item = NSMenuItem(title: app.name, action: #selector(handleAppClick(_:)), keyEquivalent: "") - item.tag = hashForApp(app) - item.target = self - item.image = cachedAppIcon(app: app) + let item = makeAppItem(app) menu.addItem(item) } } @@ -411,11 +410,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { let newFilesTitle = AppLocalization.localized("New File") let newFilesSubMenu = NSMenu(title: newFilesTitle) for newFile in config.newFiles { - let item = NSMenuItem(title: newFile.name, action: #selector(handleNewFileClick(_:)), keyEquivalent: "") - item.tag = hashForNewFile(newFile) - item.target = self - item.image = iconProvider.icon(for: newFile.ext, fallbackSymbol: newFile.icon) - item.image?.accessibilityDescription = newFile.name + let item = makeNewFileItem(newFile) newFilesSubMenu.addItem(item) } let newFilesItem = NSMenuItem(title: newFilesTitle, action: nil, keyEquivalent: "") @@ -425,11 +420,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { } else { // 不折叠:直接显示菜单项 for newFile in config.newFiles { - let item = NSMenuItem(title: newFile.name, action: #selector(handleNewFileClick(_:)), keyEquivalent: "") - item.tag = hashForNewFile(newFile) - item.target = self - item.image = iconProvider.icon(for: newFile.ext, fallbackSymbol: newFile.icon) - item.image?.accessibilityDescription = newFile.name + let item = makeNewFileItem(newFile) menu.addItem(item) } } @@ -442,10 +433,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { let commonDirsTitle = AppLocalization.localized("Common Dirs") let commonDirsSubMenu = NSMenu(title: commonDirsTitle) for commonDir in config.commonDirs { - let item = NSMenuItem(title: commonDir.name, action: #selector(handleCommonDirClick(_:)), keyEquivalent: "") - item.tag = hashForCommonDir(commonDir) - item.target = self - item.image = loadIcon(named: commonDir.icon, accessibilityDescription: commonDir.name) + let item = makeCommonDirItem(commonDir) commonDirsSubMenu.addItem(item) } let commonDirsItem = NSMenuItem(title: commonDirsTitle, action: nil, keyEquivalent: "") @@ -455,10 +443,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { } else { // 不折叠:直接显示菜单项 for commonDir in config.commonDirs { - let item = NSMenuItem(title: commonDir.name, action: #selector(handleCommonDirClick(_:)), keyEquivalent: "") - item.tag = hashForCommonDir(commonDir) - item.target = self - item.image = loadIcon(named: commonDir.icon, accessibilityDescription: commonDir.name) + let item = makeCommonDirItem(commonDir) menu.addItem(item) } } @@ -467,6 +452,41 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { return menu } + private func makeActionItem(_ action: ActionMenuItem) -> NSMenuItem { + let item = NSMenuItem(title: action.name, action: #selector(handleActionClick(_:)), keyEquivalent: "") + item.tag = hashForAction(action) + item.target = self + if let icon = templateSymbol(action.icon) { + item.image = icon + } + return item + } + + private func makeAppItem(_ app: AppMenuItem) -> NSMenuItem { + let item = NSMenuItem(title: app.name, action: #selector(handleAppClick(_:)), keyEquivalent: "") + item.tag = hashForApp(app) + item.target = self + item.image = cachedAppIcon(app: app) + return item + } + + private func makeNewFileItem(_ newFile: NewFileMenuItem) -> NSMenuItem { + let item = NSMenuItem(title: newFile.name, action: #selector(handleNewFileClick(_:)), keyEquivalent: "") + item.tag = hashForNewFile(newFile) + item.target = self + item.image = iconProvider.icon(for: newFile.ext, fallbackSymbol: newFile.icon) + item.image?.accessibilityDescription = newFile.name + return item + } + + private func makeCommonDirItem(_ commonDir: CommonDirMenuItem) -> NSMenuItem { + let item = NSMenuItem(title: commonDir.name, action: #selector(handleCommonDirClick(_:)), keyEquivalent: "") + item.tag = hashForCommonDir(commonDir) + item.target = self + item.image = loadIcon(named: commonDir.icon, accessibilityDescription: commonDir.name) + return item + } + // MARK: - Menu Item Hash Functions private func hashForAction(_ action: ActionMenuItem) -> Int { diff --git a/RClick/AppState.swift b/RClick/AppState.swift index ad3ad48..2e3e43b 100644 --- a/RClick/AppState.swift +++ b/RClick/AppState.swift @@ -92,7 +92,7 @@ class AppState: ObservableObject, ActionStateProviding { } func getAppItem(rid: String) -> OpenWithApp? { - return apps.first { rid.contains($0.id) } + return apps.first { rid == $0.id } } func getFileType(rid: String) -> NewFile? { diff --git a/RClick/Localizable.xcstrings b/RClick/Localizable.xcstrings index 663fd6e..e41417d 100644 --- a/RClick/Localizable.xcstrings +++ b/RClick/Localizable.xcstrings @@ -1,6 +1,321 @@ { "sourceLanguage": "en", "strings": { + "Advanced Menu Layout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Advanced Menu Layout" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Diseño avanzado del menú" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition avancée du menu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "詳細なメニューレイアウト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高级菜单布局" + } + } + } + }, + "Open Config": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Config" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir configuración" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir la configuration" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定ファイルを開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开配置" + } + } + } + }, + "Reveal in Finder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reveal in Finder" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar en Finder" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher dans le Finder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Finderに表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在访达中显示" + } + } + } + }, + "Customize top-level items and nested submenus with custom_menu.json. 💡 Tip: Give this file to an AI assistant (such as ChatGPT / Claude) to help arrange your menu.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Customize top-level items and nested submenus with custom_menu.json. 💡 Tip: Give this file to an AI assistant (such as ChatGPT / Claude) to help arrange your menu." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Personaliza los elementos del menú principal y los submenús anidados con custom_menu.json. 💡 Consejo: Comparte este archivo con un asistente de IA (como ChatGPT / Claude) para que te ayude a organizar el menú." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Personnalisez les éléments du menu principal et les sous-menus imbriqués avec custom_menu.json. 💡 Astuce : Confiez ce fichier à un assistant IA (comme ChatGPT / Claude) pour vous aider à organiser votre menu." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "custom_menu.jsonで最上位の項目や多階層のサブメニューをカスタマイズできます。💡 ヒント:このファイルをAIアシスタント(ChatGPT / Claudeなど)に渡すと、メニュー構成の作成を手伝ってもらえます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通过 custom_menu.json 自定义一级平铺与多层子菜单。💡 提示:可直接将此文件提供给 AI 助手(如 ChatGPT / Claude)帮你一键编排结构。" + } + } + } + }, + "Changes appear after reopening the menu within 10 seconds. Remove the file to restore the default layout.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Changes appear after reopening the menu within 10 seconds. Remove the file to restore the default layout." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los cambios aparecerán al volver a abrir el menú, en un plazo de 10 segundos. Elimina el archivo para restaurar el diseño predeterminado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les modifications apparaissent à la réouverture du menu, dans un délai de 10 secondes. Supprimez le fichier pour rétablir la disposition par défaut." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "変更は10秒以内に反映されます。メニューを開き直してください。ファイルを削除すると標準のレイアウトに戻ります。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更改会在 10 秒内重新打开菜单时生效。删除此文件即可恢复默认布局。" + } + } + } + }, + "Unable to Open Menu Config": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to Open Menu Config" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede abrir la configuración del menú" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d’ouvrir la configuration du menu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メニュー設定ファイルを開けません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法打开菜单配置" + } + } + } + }, + "The shared configuration folder is unavailable.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The shared configuration folder is unavailable." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La carpeta de configuración compartida no está disponible." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le dossier de configuration partagé est indisponible." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "共有設定フォルダを利用できません。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "共享配置文件夹不可用。" + } + } + } + }, + "No application could open the configuration file. Try Reveal in Finder and choose a text editor.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No application could open the configuration file. Try Reveal in Finder and choose a text editor." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ninguna aplicación pudo abrir el archivo de configuración. Usa «Mostrar en Finder» y elige un editor de texto." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune application n’a pu ouvrir le fichier de configuration. Utilisez « Afficher dans le Finder » et choisissez un éditeur de texte." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定ファイルを開けるアプリケーションがありません。「Finderに表示」を使い、テキストエディタを選択してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有应用能够打开配置文件。请使用“在访达中显示”,然后选择文本编辑器打开。" + } + } + } + }, + "More": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "More" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Más" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Plus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更多" + } + } + } + }, "": { "extractionState": "stale", "localizations": { diff --git a/RClick/Model/NewFileTypeEntity.swift b/RClick/Model/NewFileTypeEntity.swift index dc37b29..265ea7e 100644 --- a/RClick/Model/NewFileTypeEntity.swift +++ b/RClick/Model/NewFileTypeEntity.swift @@ -50,7 +50,9 @@ final class NewFileTypeEntity { name: newFile.name, icon: newFile.icon, isEnabled: newFile.enabled, - sortOrder: newFile.idx + sortOrder: newFile.idx, + templatePath: newFile.template?.path, + openAppPath: newFile.openApp?.path ) } diff --git a/RClick/Runtime/ConfigService.swift b/RClick/Runtime/ConfigService.swift index e8ca654..8c60a81 100644 --- a/RClick/Runtime/ConfigService.swift +++ b/RClick/Runtime/ConfigService.swift @@ -23,7 +23,11 @@ final class ConfigService { @AppLog(category: "ConfigService") private var logger - let modelContext = ModelContext(SharedDataManager.sharedModelContainer) + let modelContext: ModelContext + + init(modelContext: ModelContext? = nil) { + self.modelContext = modelContext ?? ModelContext(SharedDataManager.sharedModelContainer) + } /// 从 SwiftData 读取全部配置 func load() -> AppConfigData { @@ -57,13 +61,17 @@ final class ConfigService { // NewFiles let newFileDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]) data.newFiles = (try? modelContext.fetch(newFileDescriptor))?.map { entity in - NewFile( + var file = NewFile( ext: entity.fileExtension, name: entity.name, enabled: entity.isEnabled, idx: entity.sortOrder, - icon: entity.icon + icon: entity.icon, + id: entity.id ) + file.template = entity.templatePath.map { URL(fileURLWithPath: $0) } + file.openApp = entity.openAppPath.map { URL(fileURLWithPath: $0) } + return file } ?? [] // CommonDirs(含旧图标自动修复) diff --git a/RClick/Runtime/MenuService.swift b/RClick/Runtime/MenuService.swift index 656b257..b5be5f5 100644 --- a/RClick/Runtime/MenuService.swift +++ b/RClick/Runtime/MenuService.swift @@ -7,6 +7,7 @@ // import Foundation +import OSLog @MainActor final class MenuService { @@ -17,6 +18,43 @@ final class MenuService { var lastSnapshot: Data? { lastMenuSnapshot } + static var customMenuURL: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.suitName)? + .appendingPathComponent("custom_menu.json") + } + + /// Seed only once, using configured IDs so the example works on this installation. + static func prepareCustomMenu(at url: URL, config: MenuConfigPayload) throws { + if FileManager.default.fileExists(atPath: url.path) { return } + var nodes = config.apps.map { MenuNode(type: .item, itemType: .app, id: $0.id) } + nodes += config.actions.map { MenuNode(type: .item, itemType: .action, id: $0.id) } + var groups: [MenuNode] = [] + if !config.newFiles.isEmpty { + groups.append(MenuNode(type: .submenu, title: AppLocalization.localized("New File"), + icon: "doc.badge.plus", children: config.newFiles.map { + MenuNode(type: .item, itemType: .newFile, id: $0.id) + })) + } + if !config.commonDirs.isEmpty { + groups.append(MenuNode(type: .submenu, title: AppLocalization.localized("Common Dirs"), + icon: "folder", children: config.commonDirs.map { + MenuNode(type: .item, itemType: .commonDir, id: $0.id) + })) + } + if !nodes.isEmpty { nodes.append(MenuNode(type: .separator)) } + nodes.append(MenuNode(type: .submenu, title: AppLocalization.localized("More"), + icon: "ellipsis.circle", children: groups)) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(nodes) + do { + // Exclusive creation also protects edits if another writer wins the race. + try data.write(to: url, options: .withoutOverwriting) + } catch CocoaError.fileWriteFileExists { + return + } + } + /// 从 AppState 实时构建菜单配置 func buildConfig(from state: AppState) -> MenuConfigPayload { let actionMenuItems = state.actions.filter(\.enabled).map { $0.toActionMenuItem() } @@ -26,7 +64,7 @@ final class MenuService { menuVersion += 1 - let config = MenuConfigPayload( + var config = MenuConfigPayload( version: menuVersion, actions: actionMenuItems, apps: appMenuItems, @@ -38,6 +76,16 @@ final class MenuService { commonDirsCollapsed: state.foldCommonDirMenu ) + if let url = Self.customMenuURL { + do { + config.customMenu = try CustomMenu.load( + from: url, config: config + ) + } catch { + Logger(subsystem: "RClick", category: "MenuService").error("Invalid custom_menu.json; using default menu: \(String(describing: error), privacy: .public)") + } + } + lastMenuSnapshot = try? JSONEncoder().encode(config) return config } diff --git a/RClick/Settings/GeneralSettingsTabView.swift b/RClick/Settings/GeneralSettingsTabView.swift index a345a61..31d40c8 100644 --- a/RClick/Settings/GeneralSettingsTabView.swift +++ b/RClick/Settings/GeneralSettingsTabView.swift @@ -24,6 +24,8 @@ struct GeneralSettingsTabView: View { @State private var finderSyncStatus: PermissionStatus = .unknown @State private var accessibilityStatus: PermissionStatus = .unknown @State private var showFolderPermissionsSheet = false + @State private var menuConfigError: String? + @State private var showMenuConfigError = false @State private var showDirImporter = false @State private var wrongFold = false @@ -92,6 +94,26 @@ struct GeneralSettingsTabView: View { .fixedSize(horizontal: false, vertical: true) } + Section { + HStack { + Button(AppLocalization.localized("Open Config")) { + openMenuConfig(reveal: false) + } + Button(AppLocalization.localized("Reveal in Finder")) { + openMenuConfig(reveal: true) + } + } + } header: { + Text(appLocalized: "Advanced Menu Layout") + } footer: { + VStack(alignment: .leading, spacing: 6) { + Text(appLocalized: "Customize top-level items and nested submenus with custom_menu.json. 💡 Tip: Give this file to an AI assistant (such as ChatGPT / Claude) to help arrange your menu.") + Text(appLocalized: "Changes appear after reopening the menu within 10 seconds. Remove the file to restore the default layout.") + } + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + // MARK: - 第三组:设置管理 Section { // 备份 @@ -159,11 +181,37 @@ struct GeneralSettingsTabView: View { } message: { Text(appLocalized: "Folder access permission is required to use this feature.") } + .alert(AppLocalization.localized("Unable to Open Menu Config"), isPresented: $showMenuConfigError) { + Button(AppLocalization.localized("OK"), role: .cancel) {} + } message: { + Text(menuConfigError ?? "") + } .sheet(isPresented: $showFolderPermissionsSheet) { FolderPermissionsSheetView(bookmarkManager: bookmarkManager) } } + private func openMenuConfig(reveal: Bool) { + do { + guard let url = MenuService.customMenuURL else { + menuConfigError = AppLocalization.localized("The shared configuration folder is unavailable.") + showMenuConfigError = true + return + } + try MenuService.prepareCustomMenu(at: url, config: RCRuntime.shared.menuService.buildConfig(from: store)) + NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) + if reveal { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } else if !NSWorkspace.shared.open(url) { + menuConfigError = AppLocalization.localized("No application could open the configuration file. Try Reveal in Finder and choose a text editor.") + showMenuConfigError = true + } + } catch { + menuConfigError = error.localizedDescription + showMenuConfigError = true + } + } + // MARK: - 权限状态检测 private func updatePermissionStatus() { diff --git a/RClick/Shared/PermissionChecker.swift b/RClick/Shared/PermissionChecker.swift index 0a56072..1694b67 100644 --- a/RClick/Shared/PermissionChecker.swift +++ b/RClick/Shared/PermissionChecker.swift @@ -26,8 +26,17 @@ public class PermissionChecker { /// 打开辅助功能权限设置 @MainActor public static func openAccessibilitySettings() { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { - NSWorkspace.shared.open(url) + let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + "x-apple.systemsettings:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility" + ] + for path in candidates { + if let url = URL(string: path), NSWorkspace.shared.open(url) { + return + } + } + if let settingsUrl = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.systemsettings") { + NSWorkspace.shared.open(settingsUrl) } } diff --git a/RClickTests/ActionServiceTests.swift b/RClickTests/ActionServiceTests.swift index aeff1ac..f9b0027 100644 --- a/RClickTests/ActionServiceTests.swift +++ b/RClickTests/ActionServiceTests.swift @@ -94,4 +94,24 @@ final class ActionServiceTests { let fetched = try context.fetch(FetchDescriptor()) #expect(fetched.first?.opensNewInstance == true) } + @Test func fileTemplateSurvivesSaveAndReload() throws { + let container = try ModelContainer( + for: AppEntity.self, ActionEntity.self, NewFileTypeEntity.self, CommonDirEntity.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + let service = ConfigService(modelContext: container.mainContext) + var file = NewFile(ext: ".txt", name: "Template", idx: 0, id: "stable-template-id") + file.template = URL(fileURLWithPath: "/tmp/template with spaces.txt") + file.openApp = URL(fileURLWithPath: "/Applications/TextEdit.app") + try service.save(AppConfigData(newFiles: [file])) + + for _ in 0..<2 { + let loaded = try #require(service.load().newFiles.first) + #expect(loaded.id == file.id) + #expect(loaded.template == file.template) + #expect(loaded.openApp == file.openApp) + try service.save(AppConfigData(newFiles: [loaded])) + } + } + } diff --git a/RClickTests/CustomMenuTests.swift b/RClickTests/CustomMenuTests.swift new file mode 100644 index 0000000..e6a83b1 --- /dev/null +++ b/RClickTests/CustomMenuTests.swift @@ -0,0 +1,199 @@ +import AppKit +import Testing +@testable import RClick + +@MainActor +struct CustomMenuTests { + private var catalog: MenuConfigPayload { + MenuConfigPayload( + actions: [ActionMenuItem(id: "copy-path", name: "Copy Path", icon: "doc", tag: 1)], + apps: [ + AppMenuItem(id: "vscode-id", name: "VS Code", icon: "app", tag: 2, + appURL: "/Applications/Visual Studio Code.app"), + AppMenuItem(id: "warp-id", name: "Warp", icon: "app", tag: 3, + appURL: "/Applications/Warp.app") + ], + newFiles: [ + NewFileMenuItem(id: "txt-id", name: "TXT", ext: ".txt", icon: "doc"), + NewFileMenuItem(id: "md-id", name: "Markdown", ext: ".md", icon: "doc") + ] + ) + } + + private func fixture() throws -> [MenuNode] { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("examples/custom_menu.json") + return try #require(try CustomMenu.load(from: url, config: catalog)) + } + + private func load(_ json: String, config: MenuConfigPayload? = nil) throws -> [MenuNode]? { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("rclick-menu-\(UUID()).json") + try Data(json.utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + return try CustomMenu.load(from: url, config: config ?? catalog) + } + + @Test func exampleResolvesHumanReadableReferencesToStableIDs() throws { + let nodes = try fixture() + #expect(nodes.count == 4) + #expect(nodes[0].id == "vscode-id") + #expect(nodes[0].appPath == nil) + #expect(nodes[1].id == "warp-id") + let children = try #require(nodes[3].children) + #expect(children[0].id == "copy-path") + let files = try #require(children[2].children) + #expect(files.map(\.id) == ["txt-id", "md-id"]) + #expect(files.allSatisfy { $0.fileExtension == nil }) + } + + @Test func rendersThreeLevelsAndPreservesLeafDispatch() throws { + let nodes = try fixture() + let menu = NSMenu(title: "RClick") + let target = NSObject() + let action = #selector(NSObject.isEqual(_:)) + let leafIcon = NSImage(size: NSSize(width: 16, height: 16)) + let groupIcon = NSImage(size: NSSize(width: 16, height: 16)) + var leaves: [NSMenuItem] = [] + var references: [String] = [] + var symbols: [String] = [] + CustomMenu.render(nodes, into: menu, makeItem: { type, id in + references.append("\(type.rawValue):\(id)") + let item = NSMenuItem(title: id, action: action, keyEquivalent: "") + item.target = target + item.tag = leaves.count + 10 + item.image = leafIcon + leaves.append(item) + return item + }, loadIcon: { symbol in + symbols.append(symbol) + return groupIcon + }) + #expect(menu.items.map(\.title) == ["用 VS Code 打开", "warp-id", "", "更多"]) + #expect(menu.items[2].isSeparatorItem) + #expect(menu.items[0] === leaves[0]) + #expect(menu.items[0].image === leafIcon) + #expect(menu.items[3].image === groupIcon) + let more = try #require(menu.items[3].submenu) + #expect(more.items.count == 3) + #expect(more.items[0] === leaves[2]) + #expect(more.items[1].isSeparatorItem) + #expect(more.items[2].title == "新建文件") + let files = try #require(more.items[2].submenu) + #expect(files.items.count == 2) + #expect(files.items[0] === leaves[3]) + #expect(files.items[1] === leaves[4]) + #expect(references == ["app:vscode-id", "app:warp-id", "action:copy-path", "new-file:txt-id", "new-file:md-id"]) + #expect(symbols == ["doc.badge.plus", "ellipsis.circle"]) + for (index, leaf) in leaves.enumerated() { + #expect(leaf.action == action) + #expect(leaf.target === target) + #expect(leaf.tag == index + 10) + } + } + + @Test func leafPresentationOverridesPreserveDispatch() throws { + let nodes = try #require(try load(#"[{"type":"item","itemType":"action","id":"copy-path","title":"复制路径","icon":"link"}]"#)) + let menu = NSMenu() + let target = NSObject() + let leaf = NSMenuItem(title: "Original", action: #selector(NSObject.isEqual(_:)), keyEquivalent: "") + leaf.target = target + leaf.tag = 42 + let icon = NSImage(size: NSSize(width: 16, height: 16)) + CustomMenu.render(nodes, into: menu, makeItem: { _, _ in leaf }, loadIcon: { symbol in + #expect(symbol == "link") + return icon + }) + #expect(menu.items.first === leaf) + #expect(leaf.title == "复制路径") + #expect(leaf.image === icon) + #expect(leaf.target === target) + #expect(leaf.tag == 42) + #expect(leaf.action == #selector(NSObject.isEqual(_:))) + } + + @Test func missingFileAndEmptyArrayRemainDistinct() throws { + let missing = FileManager.default.temporaryDirectory.appendingPathComponent("absent-\(UUID()).json") + #expect(try CustomMenu.load(from: missing, config: catalog) == nil) + let empty = try #require(try load("[]")) + #expect(empty.isEmpty) + let menu = NSMenu() + CustomMenu.render(empty, into: menu, makeItem: { _, _ in + Issue.record("An empty layout must not construct leaves") + return nil + }, loadIcon: { _ in nil }) + #expect(menu.items.isEmpty) + } + + @Test(arguments: [ + "not JSON", + #"[{"type":"unknown"}]"#, + #"[{"type":"submenu","title":" " ,"children":[]}]"#, + #"[{"type":"submenu","title":"Missing children"}]"#, + #"[{"type":"separator","title":"Unexpected"}]"#, + #"[{"type":"item","itemType":"app"}]"#, + #"[{"type":"item","itemType":"app","id":"vscode-id","appPath":"/Applications/Visual Studio Code.app"}]"#, + #"[{"type":"item","itemType":"action","fileExtension":".txt"}]"#, + #"[{"type":"item","itemType":"action","id":"unknown"}]"#, + #"[{"type":"item","itemType":"action","id":"copy-path","title":" "}]"# + ]) + func invalidLayoutsThrow(json: String) { + #expect(throws: (any Error).self) { try load(json) } + } + + @Test func ambiguousReferenceThrows() { + let config = MenuConfigPayload(newFiles: [ + NewFileMenuItem(id: "one", name: "One", ext: ".txt", icon: "doc"), + NewFileMenuItem(id: "two", name: "Two", ext: ".txt", icon: "doc") + ]) + #expect(throws: (any Error).self) { + try load(#"[{"type":"item","itemType":"new-file","fileExtension":".txt"}]"#, config: config) + } + } + + @Test func oldPayloadDecodesAndCustomTreeRoundTrips() throws { + let oldJSON = #"{"version":1,"actions":[],"apps":[],"newFiles":[],"commonDirs":[],"actionsCollapsed":false,"appsCollapsed":false,"newFilesCollapsed":true,"commonDirsCollapsed":true}"# + let old = try JSONDecoder().decode(MenuConfigPayload.self, from: Data(oldJSON.utf8)) + #expect(old.customMenu == nil) + var config = catalog + config.customMenu = try fixture() + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(config) + let decoded = try JSONDecoder().decode(MenuConfigPayload.self, from: data) + #expect(try encoder.encode(decoded) == data) + #expect(decoded.customMenu?[0].id == "vscode-id") + } + @Test func preparesUsableExampleWithoutOverwritingExistingConfig() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("rclick-seed-\(UUID()).json") + defer { try? FileManager.default.removeItem(at: url) } + try MenuService.prepareCustomMenu(at: url, config: catalog) + let nodes = try #require(try CustomMenu.load(from: url, config: catalog)) + #expect(nodes.prefix(3).map(\.id) == ["vscode-id", "warp-id", "copy-path"]) + #expect(nodes[3].type == .separator) + let more = try #require(nodes.last?.children) + #expect(more.first?.children?.map(\.id) == ["txt-id", "md-id"]) + + // Even malformed user content is preserved for editing, never replaced by a seed. + let edited = Data("unfinished user edit".utf8) + try edited.write(to: url) + try MenuService.prepareCustomMenu(at: url, config: catalog) + #expect(try Data(contentsOf: url) == edited) + } + + @Test func emptyCatalogProducesValidExampleAndWriteErrorsPropagate() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("rclick-empty-\(UUID()).json") + defer { try? FileManager.default.removeItem(at: url) } + try MenuService.prepareCustomMenu(at: url, config: MenuConfigPayload()) + let nodes = try #require(try CustomMenu.load(from: url, config: MenuConfigPayload())) + #expect(nodes.count == 1) + #expect(nodes.first?.type == .submenu) + #expect(nodes.first?.children?.isEmpty == true) + let missingParent = FileManager.default.temporaryDirectory + .appendingPathComponent("absent-\(UUID())/custom_menu.json") + #expect(throws: (any Error).self) { + try MenuService.prepareCustomMenu(at: missingParent, config: catalog) + } + } + +} diff --git a/README.md b/README.md index 6079d8d..d48c688 100644 --- a/README.md +++ b/README.md @@ -133,3 +133,9 @@ We welcome contributions! Here's how you can help: - **[Security Policy](SECURITY.md)** — How to responsibly report vulnerabilities. Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before participating. + +## 自定义菜单层级 + +支持一级常用项与任意嵌套子菜单混排、分隔线及自定义标题/图标。 +将声明式 `custom_menu.json` 放入 App Group 容器即可生效;缺失或无效时保留原有菜单。 +参见 [配置示例与使用说明](examples/README.md)。 diff --git a/Shared/CustomMenu.swift b/Shared/CustomMenu.swift new file mode 100644 index 0000000..fc579ef --- /dev/null +++ b/Shared/CustomMenu.swift @@ -0,0 +1,108 @@ +import AppKit + +/// Layout only: leaves reference existing configured items, never executable commands. +struct MenuNode: Codable { + enum Kind: String, Codable { case item, submenu, separator } + + var type: Kind + var title: String? + var icon: String? + var itemType: MenuItemType? + var id: String? + var appPath: String? + var fileExtension: String? + var children: [MenuNode]? + + /// Resolve human-friendly selectors to the same IDs used by existing click handlers. + func resolved(using config: MenuConfigPayload) throws -> MenuNode { + var node = self + let selectors = [id, appPath, fileExtension].compactMap { $0 } + switch type { + case .separator: + guard title == nil, icon == nil, itemType == nil, selectors.isEmpty, children == nil else { + throw CustomMenuError.invalid("separator cannot contain other fields") + } + case .submenu: + guard let title, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let children, itemType == nil, selectors.isEmpty else { + throw CustomMenuError.invalid("submenu requires a title and children, without an item reference") + } + node.children = try children.map { try $0.resolved(using: config) } + case .item: + guard let itemType, children == nil, selectors.count == 1, + !selectors[0].isEmpty else { + throw CustomMenuError.invalid("item requires itemType and exactly one of id, appPath, fileExtension") + } + let matches: [String] + switch itemType { + case .action: + guard appPath == nil, fileExtension == nil else { throw CustomMenuError.invalid("action requires id") } + matches = config.actions.filter { $0.id == id }.map(\.id) + case .app: + guard fileExtension == nil else { throw CustomMenuError.invalid("app requires id or appPath") } + matches = config.apps.filter { id != nil ? $0.id == id : $0.appURL == appPath }.map(\.id) + case .newFile: + guard appPath == nil else { throw CustomMenuError.invalid("new-file requires id or fileExtension") } + matches = config.newFiles.filter { id != nil ? $0.id == id : $0.ext == fileExtension }.map(\.id) + case .commonDir: + guard appPath == nil, fileExtension == nil else { throw CustomMenuError.invalid("common-dir requires id") } + matches = config.commonDirs.filter { $0.id == id }.map(\.id) + } + guard matches.count == 1 else { + throw CustomMenuError.invalid("\(itemType.rawValue) reference '\(selectors[0])' matched \(matches.count) enabled items") + } + if let title, title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw CustomMenuError.invalid("item title cannot be blank") + } + node.id = matches[0] + node.appPath = nil + node.fileExtension = nil + } + return node + } +} + +enum CustomMenuError: Error, CustomStringConvertible { + case invalid(String) + var description: String { + switch self { case .invalid(let message): return message } + } +} + +enum CustomMenu { + /// Missing files mean legacy layout. Invalid files throw so callers can log and fall back. + static func load(from url: URL, config: MenuConfigPayload) throws -> [MenuNode]? { + let data: Data + do { + data = try Data(contentsOf: url) + } catch CocoaError.fileReadNoSuchFile { + return nil + } + let nodes = try JSONDecoder().decode([MenuNode].self, from: data) + return try nodes.map { try $0.resolved(using: config) } + } + + /// Both nested and top-level leaves use the extension's existing item factories. + static func render(_ nodes: [MenuNode], into menu: NSMenu, + makeItem: (MenuItemType, String) -> NSMenuItem?, + loadIcon: (String) -> NSImage?) { + for node in nodes { + let item: NSMenuItem + switch node.type { + case .separator: + item = .separator() + case .submenu: + item = NSMenuItem(title: node.title ?? "", action: nil, keyEquivalent: "") + let submenu = NSMenu(title: item.title) + render(node.children ?? [], into: submenu, makeItem: makeItem, loadIcon: loadIcon) + item.submenu = submenu + case .item: + guard let type = node.itemType, let id = node.id, let leaf = makeItem(type, id) else { continue } + item = leaf + if let title = node.title { item.title = title } + } + if let icon = node.icon { item.image = loadIcon(icon) } + menu.addItem(item) + } + } +} diff --git a/Shared/Messager.swift b/Shared/Messager.swift index ce9e617..3b5b93f 100644 --- a/Shared/Messager.swift +++ b/Shared/Messager.swift @@ -57,6 +57,8 @@ struct MenuConfigPayload: Codable { let newFilesCollapsed: Bool /// 是否折叠常用目录菜单(默认 true) let commonDirsCollapsed: Bool + /// nil preserves the legacy layout; [] intentionally renders no items. + var customMenu: [MenuNode]? init( version: Int = 1, diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..32ee307 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,56 @@ +# 自定义 Finder 菜单 + +`custom_menu.json` 是菜单根节点数组;顺序就是显示顺序。示例将 VS Code、Warp 平铺,次要动作放在“更多”,TXT / Markdown 放在第三级“新建文件”。 + +## 使用 + +推荐从 **设置 → 通用 → 高级菜单布局** 点击“打开配置”,系统会用默认关联应用打开 JSON;“在访达中显示”会选中文件。两个入口在文件不存在时都会基于当前已配置项目生成可用示例,保留已有文件(包括尚未编辑完成的 JSON)。首次生成的布局会立即推送;之后保存修改,等待最多一次 10 秒心跳再重新打开菜单。 + +示例将当前应用和动作平铺,文件模板与常用目录放入“更多”下的子菜单。可直接把 JSON 交给 AI 助手调整结构。若尚未配置任何项目,会生成空的“更多”子菜单。 + +以下是手动安装仓库示例的方法: + +1. 先在 RClick 设置中添加 VS Code、Warp,启用 Copy Path、TXT 和 Markdown。应用路径要与配置完全一致;不存在、禁用或有歧义的引用会导致整份配置回退,控制台会记录原因。 +2. 将本目录的 `custom_menu.json` 复制到 App Group 根目录: + + ```sh + cp examples/custom_menu.json "$HOME/Library/Group Containers/group.cn.wflixu.RClick/custom_menu.json" + ``` + + 目录应由正常签名并运行过的 RClick 创建。实际读取使用 `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)`;若改变 App Group 标识,使用对应容器。 +3. 保持主程序运行。扩展每 10 秒心跳会触发主程序重新读取;等待一次心跳后重新打开右键菜单,或重启 RClick。已展开的菜单不会即时重绘。 +4. 删除或移走该文件即可恢复原来的分类顺序与折叠设置。JSON 错误、结构错误、无法读取或引用不唯一也会回退;`[]` 则有意显示空菜单。 + +## 节点 + +| type | 字段 | 含义 | +| --- | --- | --- | +| `item` | `itemType` + 一个引用字段,可选 `title` / `icon` | 引用已配置项目,保留原来的点击逻辑 | +| `submenu` | `title`、`children`,可选 `icon` | 自定义子菜单,可递归嵌套 | +| `separator` | 无其他字段 | 分隔线 | + +`itemType` 与引用字段: + +- `app`:`appPath`(精确绝对路径,例如 `/Applications/Warp.app`)或持久化 `id`。 +- `action`:`id`,例如 `copy-path`、`delete-direct`、`hide`、`unhide`、`airdrop`。必须在设置中启用。 +- `new-file`:`fileExtension`(包含点,例如 `.txt`)或持久化 `id`。同扩展名有多个模板时必须用 `id`。 +- `common-dir`:`id`,内置值包括 `home`、`desktop`、`documents`、`downloads`、`applications`;须先打开常用目录开关。 + +一个叶子只能使用一种引用方式。自定义项目 ID 来自现有 SwiftData 配置;没有新增 ID 编辑界面。标题原样显示,图标使用扩展内的 Asset 名称或 SF Symbol(例如 `folder`);省略时叶子沿用原图标,无效图标不会阻止菜单构建。 + +配置只控制布局:未列出的项目不显示,原有折叠开关在自定义布局生效时不参与排列。应用参数、文件模板、权限处理继续由主程序现有配置与执行链负责,JSON 不定义命令或修改参数执行语义。 + +## 实现与验证 + +`Shared/CustomMenu.swift` 定义共享 `Codable` 模型、引用校验与递归 NSMenu 构建;`MenuService` 在原有配置推送时读取文件,解析为稳定 ID 后放入可选 `MenuConfigPayload.customMenu`。扩展复用旧菜单的四类叶子工厂和点击处理器,保留 ID、类型、Finder 目标路径及触发来源。此模型可作为以后树形编辑 UI 的存储格式,无需迁移现有业务实体。 + +`RClickTests/CustomMenuTests.swift` 使用同一示例验证解析、三级 NSMenu 结构与叶子绑定;`ActionServiceTests` 验证模板 ID / 路径经过保存与重载仍保持一致。测试不等同于签名安装后的 Finder 实机验收。 + +本地验证命令: + +```sh +xcodebuild -project RClick.xcodeproj -scheme RClick -configuration Release +xcodebuild -project RClick.xcodeproj -scheme RClick -configuration Debug -destination 'platform=macOS' test +``` + +若 Fork 后尚未配置自己的签名团队 / provisioning profile,可在命令末尾加 `CODE_SIGNING_ALLOWED=NO` 验证编译与单元测试。关闭签名不能替代 Finder 扩展的正常签名、安装和实机验证;仓库仍保留原来的签名设置。 diff --git a/examples/custom_menu.json b/examples/custom_menu.json new file mode 100644 index 0000000..788ed8c --- /dev/null +++ b/examples/custom_menu.json @@ -0,0 +1,32 @@ +[ + { + "type": "item", + "itemType": "app", + "appPath": "/Applications/Visual Studio Code.app", + "title": "用 VS Code 打开" + }, + { + "type": "item", + "itemType": "app", + "appPath": "/Applications/Warp.app" + }, + { "type": "separator" }, + { + "type": "submenu", + "title": "更多", + "icon": "ellipsis.circle", + "children": [ + { "type": "item", "itemType": "action", "id": "copy-path" }, + { "type": "separator" }, + { + "type": "submenu", + "title": "新建文件", + "icon": "doc.badge.plus", + "children": [ + { "type": "item", "itemType": "new-file", "fileExtension": ".txt" }, + { "type": "item", "itemType": "new-file", "fileExtension": ".md" } + ] + } + ] + } +] diff --git a/examples/screenshots/menu-preview-1.png b/examples/screenshots/menu-preview-1.png new file mode 100644 index 0000000..5b78fdd Binary files /dev/null and b/examples/screenshots/menu-preview-1.png differ diff --git a/examples/screenshots/menu-preview-2.png b/examples/screenshots/menu-preview-2.png new file mode 100644 index 0000000..1a9ed21 Binary files /dev/null and b/examples/screenshots/menu-preview-2.png differ diff --git a/examples/screenshots/menu-preview-3.png b/examples/screenshots/menu-preview-3.png new file mode 100644 index 0000000..863a6ea Binary files /dev/null and b/examples/screenshots/menu-preview-3.png differ