diff --git a/FinderSyncExt/FinderSyncExt.swift b/FinderSyncExt/FinderSyncExt.swift index da4e327..d35fa45 100644 --- a/FinderSyncExt/FinderSyncExt.swift +++ b/FinderSyncExt/FinderSyncExt.swift @@ -358,7 +358,32 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { // 构建常用目录菜单 if !config.commonDirs.isEmpty { - if config.commonDirsCollapsed { + if currentMenuKind == .contextualMenuForItems, + !(FIFinderSyncController.default().selectedItemURLs() ?? []).isEmpty { + if config.shouldShowCopyToCommonDirs { + addCommonDirTransferMenu( + to: menu, + title: AppLocalization.localized("Copy To"), + icon: "doc.on.doc", + commonDirs: config.commonDirs, + action: #selector(handleCopyToCommonDirClick(_:)), + operation: "copy" + ) + } + + if config.shouldShowMoveToCommonDirs { + addCommonDirTransferMenu( + to: menu, + title: AppLocalization.localized("Move To"), + icon: "folder", + commonDirs: config.commonDirs, + action: #selector(handleMoveToCommonDirClick(_:)), + operation: "move" + ) + } + } + + if config.shouldShowCommonDirs, config.commonDirsCollapsed { // 折叠:使用子菜单 let commonDirsTitle = AppLocalization.localized("Common Dirs") let commonDirsSubMenu = NSMenu(title: commonDirsTitle) @@ -373,7 +398,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { commonDirsItem.submenu = commonDirsSubMenu commonDirsItem.image = templateSymbol("folder") menu.addItem(commonDirsItem) - } else { + } else if config.shouldShowCommonDirs { // 不折叠:直接显示菜单项 for commonDir in config.commonDirs { let item = NSMenuItem(title: commonDir.name, action: #selector(handleCommonDirClick(_:)), keyEquivalent: "") @@ -406,6 +431,33 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { return "commondir_\(commonDir.id)".hash } + private func hashForCommonDirTransfer(_ commonDir: CommonDirMenuItem, operation: String) -> Int { + return "\(operation)_commondir_\(commonDir.id)".hash + } + + private func addCommonDirTransferMenu( + to menu: NSMenu, + title: String, + icon: String, + commonDirs: [CommonDirMenuItem], + action: Selector, + operation: String + ) { + let subMenu = NSMenu(title: title) + for commonDir in commonDirs { + let item = NSMenuItem(title: commonDir.name, action: action, keyEquivalent: "") + item.tag = hashForCommonDirTransfer(commonDir, operation: operation) + item.target = self + item.image = loadIcon(named: commonDir.icon, accessibilityDescription: commonDir.name) + subMenu.addItem(item) + } + + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.submenu = subMenu + item.image = templateSymbol(icon) + menu.addItem(item) + } + // MARK: - Menu Action Handlers @objc private func handleActionClick(_ sender: NSMenuItem) { @@ -496,6 +548,36 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { messager.sendClickEvent(event) } + @objc private func handleCopyToCommonDirClick(_ sender: NSMenuItem) { + handleCommonDirTransferClick(sender, itemType: .copyToCommonDir, operation: "copy") + } + + @objc private func handleMoveToCommonDirClick(_ sender: NSMenuItem) { + handleCommonDirTransferClick(sender, itemType: .moveToCommonDir, operation: "move") + } + + private func handleCommonDirTransferClick(_ sender: NSMenuItem, itemType: MenuItemType, operation: String) { + guard let config = cachedMenuConfig, + let commonDir = config.commonDirs.first(where: { hashForCommonDirTransfer($0, operation: operation) == sender.tag }) else { + logger.warning("CommonDir transfer target not found for tag: \(sender.tag)") + return + } + + let itemPaths = selectedActionTargetPaths() + guard !itemPaths.isEmpty else { + logger.warning("No selected items for common dir transfer") + return + } + + let event = ClickEventPayload( + itemId: commonDir.id, + itemType: itemType, + target: itemPaths, + trigger: getTriggerForMenuKind() + ) + messager.sendClickEvent(event) + } + // MARK: - Helper Methods /// 获取触发来源 @@ -531,6 +613,20 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { return [] } + + private func selectedActionTargetPaths() -> [String] { + let selectedItems = FIFinderSyncController.default().selectedItemURLs() ?? [] + if !selectedItems.isEmpty { + return selectedItems.map { $0.path } + } + + if currentMenuKind == .contextualMenuForContainer, + let targetURL = FIFinderSyncController.default().targetedURL() { + return [targetURL.path] + } + + return [] + } } // MARK: - NSMenu Extension diff --git a/FinderSyncExt/Localizable.xcstrings b/FinderSyncExt/Localizable.xcstrings index 8b94d4b..58e79f1 100644 --- a/FinderSyncExt/Localizable.xcstrings +++ b/FinderSyncExt/Localizable.xcstrings @@ -783,6 +783,236 @@ } } }, + "Copy File Name" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy File Name" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイル名をコピー" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制文件名" + } + } + } + }, + "Copy Parent Path" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy Parent Path" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "親フォルダのパスをコピー" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制父目录路径" + } + } + } + }, + "Copy To" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy To" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コピー先" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制到" + } + } + } + }, + "Destination folder is not available." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Destination folder is not available." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コピー先または移動先のフォルダを利用できません。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标文件夹不可用。" + } + } + } + }, + "Move To" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move To" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "移動先" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移动到" + } + } + } + }, + "New Folder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Folder" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "新規フォルダ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建文件夹" + } + } + } + }, + "Please select files or folders first." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please select files or folders first." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "先にファイルまたはフォルダを選択してください。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请先选择文件或文件夹。" + } + } + } + }, + "Protected system folders cannot be moved: %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protected system folders cannot be moved: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "保護されたシステムフォルダは移動できません: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "受保护的系统文件夹不能移动:%@" + } + } + } + }, + "Put Into New Folder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Put Into New Folder" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "新規フォルダに入れる" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "放入新文件夹" + } + } + } + }, + "Selected items must be in the same folder." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selected items must be in the same folder." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "選択した項目は同じフォルダ内にある必要があります。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所选项目必须位于同一个文件夹。" + } + } + } + }, "Default Open App" : { "extractionState" : "manual", "localizations" : { @@ -3263,4 +3493,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/RClick/AppState.swift b/RClick/AppState.swift index 3ac52bf..4b2ff03 100644 --- a/RClick/AppState.swift +++ b/RClick/AppState.swift @@ -34,6 +34,8 @@ class AppState: ObservableObject { @AppStorage("foldCommonDirMenu") var foldCommonDirMenu: Bool = true // 常用文件夹总开关(默认关闭) @AppStorage("showCommonDirs") var showCommonDirs: Bool = false + @AppStorage("showCopyToCommonDirs") var showCopyToCommonDirs: Bool = true + @AppStorage("showMoveToCommonDirs") var showMoveToCommonDirs: Bool = true // 菜单栏显示 @AppStorage(Key.showMenuBarExtra) var showMenuBar: Bool = true @@ -148,6 +150,12 @@ class AppState: ObservableObject { rcAtion.id == rid }) } + + func getCommonDirItem(rid: String) -> CommonDir? { + cdirs.first(where: { commonDir in + commonDir.id == rid + }) + } // Action @MainActor func toggleActionItem() { @@ -228,8 +236,8 @@ class AppState: ObservableObject { // 保存 CommonDirs try context.delete(model: CommonDirEntity.self) - for commonDir in cdirs { - context.insert(CommonDirEntity(from: commonDir)) + for (index, commonDir) in cdirs.enumerated() { + context.insert(CommonDirEntity(from: commonDir, sortOrder: index)) } try context.save() @@ -266,12 +274,21 @@ class AppState: ObservableObject { // 加载 NewFiles let newFileDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]) newFiles = (try? context.fetch(newFileDescriptor))?.map { entity in - NewFile( + var newFile = NewFile( ext: entity.fileExtension, name: entity.name, + enabled: entity.isEnabled, idx: entity.sortOrder, - icon: entity.icon + icon: entity.icon, + id: entity.id ) + if let templatePath = entity.templatePath, !templatePath.isEmpty { + newFile.template = URL(fileURLWithPath: templatePath) + } + if let openAppPath = entity.openAppPath, !openAppPath.isEmpty { + newFile.openApp = URL(fileURLWithPath: openAppPath) + } + return newFile } ?? [] // 加载 CommonDirs @@ -299,6 +316,42 @@ class AppState: ObservableObject { try? context.save() } + appendMissingDefaultActionsIfNeeded() logger.debug("Load from SwiftData: \(self.apps.count) apps, \(self.actions.count) actions, \(self.newFiles.count) newFiles, \(self.cdirs.count) commonDirs") } + + @MainActor + func replaceAllSettings( + apps: [OpenWithApp], + actions: [RCAction], + newFiles: [NewFile], + commonDirs: [CommonDir] + ) throws { + self.apps = apps + self.actions = actions + self.newFiles = newFiles + self.cdirs = commonDirs + + try save() + appendMissingDefaultActionsIfNeeded() + NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) + } + + @MainActor + private func appendMissingDefaultActionsIfNeeded() { + let existingIds = Set(actions.map(\.id)) + let missingActions = RCAction.all.filter { !existingIds.contains($0.id) } + guard !missingActions.isEmpty else { return } + + var nextIndex = (actions.map(\.idx).max() ?? -1) + 1 + let patchedActions = missingActions.map { action in + var action = action + action.idx = nextIndex + nextIndex += 1 + return action + } + actions.append(contentsOf: patchedActions) + try? save() + logger.debug("Appended missing default actions: \(patchedActions.map(\.id).joined(separator: ","))") + } } diff --git a/RClick/Localizable.xcstrings b/RClick/Localizable.xcstrings index 5426302..04e6cc9 100644 --- a/RClick/Localizable.xcstrings +++ b/RClick/Localizable.xcstrings @@ -467,6 +467,29 @@ } } }, + "Cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, "Change App": { "extractionState": "manual", "localizations": { @@ -674,6 +697,29 @@ } } }, + "Collapse Open Folder menu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse Open Folder menu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「フォルダを開く」メニューを折りたたむ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "折叠打开文件夹菜单" + } + } + } + }, "Collapse new file menu": { "extractionState": "manual", "localizations": { @@ -720,6 +766,29 @@ } } }, + "Create Blank File": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create Blank File" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空のファイルを作成" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建空白文件" + } + } + } + }, "Copy Path": { "extractionState": "manual", "localizations": { @@ -743,6 +812,259 @@ } } }, + "Copy File Name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy File Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル名をコピー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制文件名" + } + } + } + }, + "Copy Parent Path": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Parent Path" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "親フォルダのパスをコピー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制父目录路径" + } + } + } + }, + "Copy To": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy To" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピー先" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制到" + } + } + } + }, + "Copy and Move Menus": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy and Move Menus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピーと移動メニュー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制和移动菜单" + } + } + } + }, + "Destination folder is not available.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Destination folder is not available." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピー先または移動先のフォルダを利用できません。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目标文件夹不可用。" + } + } + } + }, + "Move To": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move To" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "移動先" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移动到" + } + } + } + }, + "New Folder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Folder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規フォルダ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建文件夹" + } + } + } + }, + "Please select files or folders first.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Please select files or folders first." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "先にファイルまたはフォルダを選択してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请先选择文件或文件夹。" + } + } + } + }, + "Protected system folders cannot be moved: %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Protected system folders cannot be moved: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保護されたシステムフォルダは移動できません: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "受保护的系统文件夹不能移动:%@" + } + } + } + }, + "Put Into New Folder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Put Into New Folder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規フォルダに入れる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "放入新文件夹" + } + } + } + }, + "Selected items must be in the same folder.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Selected items must be in the same folder." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択した項目は同じフォルダ内にある必要があります。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所选项目必须位于同一个文件夹。" + } + } + } + }, "Delete App": { "extractionState": "manual", "localizations": { @@ -996,6 +1318,190 @@ } } }, + "Enable Copy To menu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable Copy To menu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「コピー先」メニューを有効化" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用复制到菜单" + } + } + } + }, + "Enable Move To menu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable Move To menu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「移動先」メニューを有効化" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用移动到菜单" + } + } + } + }, + "Open Folder Menu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Folder Menu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを開くメニュー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开文件夹菜单" + } + } + } + }, + "Show Copy To menu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Copy To menu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「コピー先」メニューを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示复制到菜单" + } + } + } + }, + "Show Move To menu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Move To menu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「移動先」メニューを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示移动到菜单" + } + } + } + }, + "Show Open Folder menu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Open Folder menu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「フォルダを開く」メニューを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示打开文件夹菜单" + } + } + } + }, + "Shows saved folders as shortcuts that open those folders from the Finder context menu.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows saved folders as shortcuts that open those folders from the Finder context menu." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存したフォルダを、Finderのコンテキストメニューから開けるショートカットとして表示します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "把已保存的文件夹显示为 Finder 右键菜单中的打开快捷方式。" + } + } + } + }, + "These menus use the saved folders below as destinations and only appear when Finder items are selected.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "These menus use the saved folders below as destinations and only appear when Finder items are selected." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下に保存したフォルダをコピー先または移動先として使います。Finderで項目を選択したときだけ表示されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这些菜单会使用下方保存的文件夹作为目标位置,并且只在 Finder 中选中文件或文件夹时显示。" + } + } + } + }, "Enable Full Disk Access": { "extractionState": "manual", "localizations": { @@ -1134,94 +1640,140 @@ } } }, - "Environment Variables": { - "extractionState": "stale", + "Environment Variables": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Environment Variables" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "環境変数" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "环境变量" + } + } + } + }, + "Environment variables: %lld": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Environment variables: %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "環境変数: %lld 個" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "环境变量:%lld 个" + } + } + } + }, + "Export Logs…": { + "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Environment Variables" + "value": "Export Logs…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "環境変数" + "value": "ログを書き出し…" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "环境变量" + "value": "导出日志…" } } } }, - "Environment variables: %lld": { + "Export…": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Environment variables: %lld" + "value": "Export…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "環境変数: %lld 個" + "value": "書き出し…" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "环境变量:%lld 个" + "value": "导出…" } } } }, - "Export Logs…": { + "Export Failed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Export Logs…" + "value": "Export Failed" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ログを書き出し…" + "value": "書き出しに失敗しました" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "导出日志…" + "value": "导出失败" } } } }, - "Export…": { + "Export failed: %@": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Export…" + "value": "Export failed: %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "書き出し…" + "value": "書き出しに失敗しました: %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "导出…" + "value": "导出失败:%@" } } } @@ -1664,6 +2216,121 @@ } } }, + "Replace Settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Replace Settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を置き換える" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "替换设置" + } + } + } + }, + "Import Failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import Failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込みに失敗しました" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入失败" + } + } + } + }, + "Import Settings?": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import Settings?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を読み込みますか?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入设置?" + } + } + } + }, + "Import failed: %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import failed: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込みに失敗しました: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入失败:%@" + } + } + } + }, + "Importing will replace your current RClick settings. This action cannot be undone.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Importing will replace your current RClick settings. This action cannot be undone." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込むと現在の RClick 設定が置き換えられます。この操作は元に戻せません。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入会替换当前 RClick 设置,此操作无法撤销。" + } + } + } + }, "Installation failed: %@": { "extractionState": "manual", "localizations": { @@ -1986,6 +2653,29 @@ } } }, + "OK": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确定" + } + } + } + }, "Open Settings": { "extractionState": "manual", "localizations": { @@ -2576,6 +3266,52 @@ } } }, + "Settings Exported": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings Exported" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を書き出しました" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置已导出" + } + } + } + }, + "Settings Imported": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings Imported" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を読み込みました" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置已导入" + } + } + } + }, "Settings Management": { "extractionState": "manual", "localizations": { @@ -2599,6 +3335,52 @@ } } }, + "Settings exported successfully.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings exported successfully." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を書き出しました。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置已成功导出。" + } + } + } + }, + "Settings imported successfully. Some changes may require restarting RClick.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings imported successfully. Some changes may require restarting RClick." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を読み込みました。一部の変更は RClick の再起動後に反映される場合があります。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置已成功导入。部分更改可能需要重启 RClick 后生效。" + } + } + } + }, "Settings…": { "extractionState": "stale", "localizations": { @@ -3122,6 +3904,86 @@ } } }, + "All settings were restored to their defaults.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "All settings were restored to their defaults." } }, + "ja": { "stringUnit": { "state": "translated", "value": "すべての設定を初期値に戻しました。" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "所有设置已恢复为默认值。" } } + } + }, + "Log Export Failed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Log Export Failed" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ログの書き出しに失敗しました" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "日志导出失败" } } + } + }, + "Log export failed: %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Log export failed: %@" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ログの書き出しに失敗しました: %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "日志导出失败:%@" } } + } + }, + "Logs Exported": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Logs Exported" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ログを書き出しました" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "日志已导出" } } + } + }, + "Logs exported successfully.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Logs exported successfully." } }, + "ja": { "stringUnit": { "state": "translated", "value": "ログを書き出しました。" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "日志已成功导出。" } } + } + }, + "Reset Failed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Reset Failed" } }, + "ja": { "stringUnit": { "state": "translated", "value": "リセットに失敗しました" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "重置失败" } } + } + }, + "Reset failed: %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Reset failed: %@" } }, + "ja": { "stringUnit": { "state": "translated", "value": "リセットに失敗しました: %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "重置失败:%@" } } + } + }, + "Settings Reset": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Settings Reset" } }, + "ja": { "stringUnit": { "state": "translated", "value": "設定をリセットしました" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "设置已重置" } } + } + }, + "The template storage folder is unavailable.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The template storage folder is unavailable." } }, + "ja": { "stringUnit": { "state": "translated", "value": "テンプレート保存フォルダを利用できません。" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "模板存储文件夹不可用。" } } + } + }, + "Unable to Save File Type": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Unable to Save File Type" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイル種類を保存できません" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法保存文件类型" } } + } + }, "Warning": { "extractionState": "manual", "localizations": { diff --git a/RClick/Model/ActionEntity.swift b/RClick/Model/ActionEntity.swift index 4d77802..a3b370f 100644 --- a/RClick/Model/ActionEntity.swift +++ b/RClick/Model/ActionEntity.swift @@ -48,10 +48,13 @@ final class ActionEntity { static func createDefaultActions() -> [ActionEntity] { return [ ActionEntity(id: "copy-path", name: "复制路径", icon: "doc.on.doc", isEnabled: true, sortOrder: 0), - ActionEntity(id: "delete-direct", name: "直接删除", icon: "trash", isEnabled: true, sortOrder: 1), - ActionEntity(id: "hide", name: "隐藏", icon: "eye.slash", isEnabled: false, sortOrder: 2), - ActionEntity(id: "unhide", name: "显示", icon: "eye", isEnabled: false, sortOrder: 3), - ActionEntity(id: "airdrop", name: "AirDrop", icon: "paperplane", isEnabled: false, sortOrder: 4), + ActionEntity(id: "copy-file-name", name: "复制文件名", icon: "doc.text", isEnabled: true, sortOrder: 1), + ActionEntity(id: "copy-parent-path", name: "复制父目录路径", icon: "folder", isEnabled: true, sortOrder: 2), + ActionEntity(id: "put-into-new-folder", name: "放入新文件夹", icon: "folder.badge.plus", isEnabled: true, sortOrder: 3), + ActionEntity(id: "delete-direct", name: "直接删除", icon: "trash", isEnabled: true, sortOrder: 4), + ActionEntity(id: "hide", name: "隐藏", icon: "eye.slash", isEnabled: false, sortOrder: 5), + ActionEntity(id: "unhide", name: "显示", icon: "eye", isEnabled: false, sortOrder: 6), + ActionEntity(id: "airdrop", name: "AirDrop", icon: "paperplane", isEnabled: false, sortOrder: 7), ] } } diff --git a/RClick/Model/CommonDirEntity.swift b/RClick/Model/CommonDirEntity.swift index e101db0..0b2020d 100644 --- a/RClick/Model/CommonDirEntity.swift +++ b/RClick/Model/CommonDirEntity.swift @@ -46,12 +46,13 @@ final class CommonDirEntity { } /// 从 CommonDir 转换 - convenience init(from commonDir: CommonDir) { + convenience init(from commonDir: CommonDir, sortOrder: Int = 0) { self.init( id: commonDir.id, name: commonDir.name, path: commonDir.url, - icon: commonDir.icon + icon: commonDir.icon, + sortOrder: sortOrder ) } diff --git a/RClick/Model/NewFileTypeEntity.swift b/RClick/Model/NewFileTypeEntity.swift index d099275..886475b 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(percentEncoded: false), + openAppPath: newFile.openApp?.path(percentEncoded: false) ) } diff --git a/RClick/RClickApp.swift b/RClick/RClickApp.swift index b383a38..77ef16a 100644 --- a/RClick/RClickApp.swift +++ b/RClick/RClickApp.swift @@ -194,8 +194,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Build menu config from AppState let actionMenuItems = appState.actions.filter(\.enabled).map { $0.toActionMenuItem() } let appMenuItems = appState.apps.map { $0.toAppMenuItem() } - let newFileMenuItems = appState.newFiles.map { NewFileMenuItem(id: $0.id, name: $0.name, ext: $0.ext, icon: $0.icon) } - let commonDirMenuItems = appState.showCommonDirs ? appState.cdirs.map { CommonDirMenuItem(id: $0.id, name: $0.displayName, icon: $0.icon, url: $0.url.path) } : [] + let newFileMenuItems = NewFileMenuItem.configuredItems(from: appState.newFiles) + let commonDirMenuItems = appState.cdirs.map { CommonDirMenuItem(id: $0.id, name: $0.displayName, icon: $0.icon, url: $0.url.path) } // 更新版本号 menuVersion += 1 @@ -206,6 +206,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { apps: appMenuItems, newFiles: newFileMenuItems, commonDirs: commonDirMenuItems, + showCommonDirs: appState.showCommonDirs, + showCopyToCommonDirs: appState.showCopyToCommonDirs, + showMoveToCommonDirs: appState.showMoveToCommonDirs, actionsCollapsed: appState.foldActionsMenu, appsCollapsed: appState.foldAppsMenu, newFilesCollapsed: appState.foldNewFileMenu, @@ -233,6 +236,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { self.createFile(rid: event.itemId, target: event.target) case .commonDir: self.openCommonDirs(target: event.target) + case .copyToCommonDir: + self.copyItemsToCommonDir(rid: event.itemId, target: event.target) + case .moveToCommonDir: + self.moveItemsToCommonDir(rid: event.itemId, target: event.target) } } @@ -441,6 +448,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { switch rcitem.id { case "copy-path": copyPath(target) + case "copy-file-name": + copyFileName(target) + case "copy-parent-path": + copyParentPath(target) + case "put-into-new-folder": + putItemsIntoNewFolder(target, trigger) case "delete-direct": deleteFoldorFile(target, trigger) case "unhide": @@ -599,11 +612,30 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func copyPath(_ target: [String]) { - if let dirPath = target.first { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(dirPath.removingPercentEncoding ?? dirPath, forType: .string) + copyStringsToPasteboard(target.map { $0.removingPercentEncoding ?? $0 }) + } + + func copyFileName(_ target: [String]) { + let fileNames = target.map { rawPath in + URL(fileURLWithPath: rawPath.removingPercentEncoding ?? rawPath).lastPathComponent + } + copyStringsToPasteboard(fileNames) + } + + func copyParentPath(_ target: [String]) { + let parentPaths = target.map { rawPath in + URL(fileURLWithPath: rawPath.removingPercentEncoding ?? rawPath).deletingLastPathComponent().path } + copyStringsToPasteboard(parentPaths) + } + + private func copyStringsToPasteboard(_ values: [String]) { + let cleanedValues = values.filter { !$0.isEmpty } + guard !cleanedValues.isEmpty else { return } + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(cleanedValues.joined(separator: UserDefaults.group.copySeparator), forType: .string) } func deleteFoldorFile(_ target: [String], _ trigger: String) { @@ -643,6 +675,132 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + func copyItemsToCommonDir(rid: String, target: [String]) { + transferItemsToCommonDir(rid: rid, target: target, operation: .copy) + } + + func moveItemsToCommonDir(rid: String, target: [String]) { + transferItemsToCommonDir(rid: rid, target: target, operation: .move) + } + + private enum FileTransferOperation: Equatable { + case copy + case move + } + + private func transferItemsToCommonDir(rid: String, target: [String], operation: FileTransferOperation) { + guard let commonDir = appState.getCommonDirItem(rid: rid) else { + logger.warning("Common dir not found for transfer: \(rid)") + return + } + + let destinationDir = commonDir.url + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: destinationDir.path, isDirectory: &isDirectory), isDirectory.boolValue else { + showWarning(message: AppLocalization.localized("Destination folder is not available.")) + logger.warning("Destination folder is not available: \(destinationDir.path)") + return + } + + for rawPath in target { + let sourceURL = URL(fileURLWithPath: rawPath.removingPercentEncoding ?? rawPath) + guard FileManager.default.fileExists(atPath: sourceURL.path) else { + logger.warning("Transfer source does not exist: \(sourceURL.path)") + continue + } + + if operation == .move, Utils.isProtectedFolder(sourceURL.path) { + showWarning(message: String(format: AppLocalization.localized("Protected system folders cannot be moved: %@"), sourceURL.path)) + logger.warning("Skip moving protected path: \(sourceURL.path)") + continue + } + + do { + let destinationURL = uniqueDestinationURL(for: sourceURL.lastPathComponent, in: destinationDir) + switch operation { + case .copy: + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + logger.info("Copied \(sourceURL.path) to \(destinationURL.path)") + case .move: + try FileManager.default.moveItem(at: sourceURL, to: destinationURL) + logger.info("Moved \(sourceURL.path) to \(destinationURL.path)") + } + } catch { + logger.error("Transfer failed for \(sourceURL.path): \(error.localizedDescription)") + } + } + } + + func putItemsIntoNewFolder(_ target: [String], _ trigger: String) { + guard trigger != "ctx-container" else { + showWarning(message: AppLocalization.localized("Please select files or folders first.")) + return + } + + let itemURLs = target.map { URL(fileURLWithPath: $0.removingPercentEncoding ?? $0) } + .filter { FileManager.default.fileExists(atPath: $0.path) } + guard !itemURLs.isEmpty else { return } + + let parentURL = itemURLs[0].deletingLastPathComponent() + guard itemURLs.allSatisfy({ $0.deletingLastPathComponent().standardizedFileURL == parentURL.standardizedFileURL }) else { + showWarning(message: AppLocalization.localized("Selected items must be in the same folder.")) + return + } + + do { + let folderURL = uniqueDestinationURL(for: AppLocalization.localized("New Folder"), in: parentURL) + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: false) + + for itemURL in itemURLs { + if Utils.isProtectedFolder(itemURL.path) { + logger.warning("Skip moving protected path into new folder: \(itemURL.path)") + continue + } + + let destinationURL = uniqueDestinationURL(for: itemURL.lastPathComponent, in: folderURL) + try FileManager.default.moveItem(at: itemURL, to: destinationURL) + } + + revealInFinderAndRename(folderURL) + } catch { + logger.error("Put into new folder failed: \(error.localizedDescription)") + } + } + + private func uniqueDestinationURL(for fileName: String, in directory: URL) -> URL { + let fileManager = FileManager.default + let originalURL = directory.appendingPathComponent(fileName) + guard fileManager.fileExists(atPath: originalURL.path) else { + return originalURL + } + + let sourceURL = URL(fileURLWithPath: fileName) + let baseName = sourceURL.deletingPathExtension().lastPathComponent + let pathExtension = sourceURL.pathExtension + var counter = 1 + + while true { + let suffix = counter == 1 ? " copy" : " copy \(counter)" + let candidateName = pathExtension.isEmpty + ? "\(baseName)\(suffix)" + : "\(baseName)\(suffix).\(pathExtension)" + let candidateURL = directory.appendingPathComponent(candidateName) + if !fileManager.fileExists(atPath: candidateURL.path) { + return candidateURL + } + counter += 1 + } + } + + private func showWarning(message: String) { + let alert = NSAlert() + alert.messageText = AppLocalization.localized("Warning") + alert.informativeText = message + alert.alertStyle = .warning + alert.addButton(withTitle: AppLocalization.localized("OK")) + alert.runModal() + } + func createFile(rid: String, target: [String]) { guard let dirPath = targetDirectoryForNewFile(target) else { logger.warning("when createFile, but not have target directory") diff --git a/RClick/Settings/CommonDirsSettingTabView.swift b/RClick/Settings/CommonDirsSettingTabView.swift index 1372de9..37d0da8 100644 --- a/RClick/Settings/CommonDirsSettingTabView.swift +++ b/RClick/Settings/CommonDirsSettingTabView.swift @@ -25,18 +25,41 @@ struct CommonDirsSettingTabView: View { Form { Section { Toggle(isOn: $store.showCommonDirs) { - Text(appLocalized: "Enable common folders") + Text(appLocalized: "Show Open Folder menu") } .onChange(of: store.showCommonDirs) { NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) } Toggle(isOn: $store.foldCommonDirMenu) { - Text(appLocalized: "Collapse menu") + Text(appLocalized: "Collapse Open Folder menu") } .disabled(!store.showCommonDirs) .onChange(of: store.foldCommonDirMenu) { NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) } + } header: { + Text(appLocalized: "Open Folder Menu") + } footer: { + Text(appLocalized: "Shows saved folders as shortcuts that open those folders from the Finder context menu.") + } + + Section { + Toggle(isOn: $store.showCopyToCommonDirs) { + Text(appLocalized: "Show Copy To menu") + } + .onChange(of: store.showCopyToCommonDirs) { + NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) + } + Toggle(isOn: $store.showMoveToCommonDirs) { + Text(appLocalized: "Show Move To menu") + } + .onChange(of: store.showMoveToCommonDirs) { + NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) + } + } header: { + Text(appLocalized: "Copy and Move Menus") + } footer: { + Text(appLocalized: "These menus use the saved folders below as destinations and only appear when Finder items are selected.") } Section { diff --git a/RClick/Settings/EditFileTypeSheetView.swift b/RClick/Settings/EditFileTypeSheetView.swift index 36473dc..9444a10 100644 --- a/RClick/Settings/EditFileTypeSheetView.swift +++ b/RClick/Settings/EditFileTypeSheetView.swift @@ -25,6 +25,7 @@ struct EditFileTypeSheetView: View { @State private var showSelectApp = false @State private var showSelectTemplate = false + @State private var saveErrorMessage: String? let messager = Messager.shared @@ -186,8 +187,16 @@ struct EditFileTypeSheetView: View { .keyboardShortcut(.escape) Button(AppLocalization.localized(isAdding ? "Add" : "Save")) { - saveChanges() - dismiss() + do { + try saveChanges() + dismiss() + } catch { + if error is TemplateStorageError { + saveErrorMessage = AppLocalization.localized("The template storage folder is unavailable.") + } else { + saveErrorMessage = error.localizedDescription + } + } } .keyboardShortcut(.return) .disabled(name.isEmpty || ext.isEmpty) @@ -195,9 +204,22 @@ struct EditFileTypeSheetView: View { .padding(.bottom, 20) } .frame(width: 440, height: 580) + .alert( + Text(appLocalized: "Unable to Save File Type"), + isPresented: Binding( + get: { saveErrorMessage != nil }, + set: { if !$0 { saveErrorMessage = nil } } + ) + ) { + Button(AppLocalization.localized("OK")) { + saveErrorMessage = nil + } + } message: { + Text(saveErrorMessage ?? "") + } } - private func saveChanges() { + private func saveChanges() throws { if isAdding { var newFile = NewFile( ext: ext, @@ -208,14 +230,7 @@ struct EditFileTypeSheetView: View { if let app = openApp { newFile.openApp = app } - if let templateUrl = template { - if let templateDir = templatesDir { - try? FileManager.default.createDirectory(at: templateDir, withIntermediateDirectories: true) - let destUrl = templateDir.appendingPathComponent(templateUrl.lastPathComponent) - try? FileManager.default.copyItem(at: templateUrl, to: destUrl) - newFile.template = destUrl - } - } + newFile.template = try storedTemplate(from: template, id: newFile.id, replacing: nil) appState.addNewFile(newFile) } else { if let index = appState.newFiles.firstIndex(where: { $0.id == file.id }) { @@ -224,20 +239,59 @@ struct EditFileTypeSheetView: View { updatedFile.ext = ext updatedFile.icon = icon updatedFile.openApp = openApp - if let templateUrl = template { - if let templateDir = templatesDir { - try? FileManager.default.createDirectory(at: templateDir, withIntermediateDirectories: true) - let destUrl = templateDir.appendingPathComponent(templateUrl.lastPathComponent) - try? FileManager.default.copyItem(at: templateUrl, to: destUrl) - updatedFile.template = destUrl - } - } + updatedFile.template = try storedTemplate(from: template, id: file.id, replacing: file.template) appState.newFiles[index] = updatedFile } } - Task { @MainActor in - appState.sync() - } + appState.sync() messager.sendRunningNotification() } + + private func storedTemplate(from sourceURL: URL?, id: String, replacing previousURL: URL?) throws -> URL? { + guard let templatesDir else { + throw TemplateStorageError.applicationSupportUnavailable + } + guard let sourceURL else { + try removeManagedTemplate(at: previousURL, templatesDir: templatesDir) + return nil + } + + try FileManager.default.createDirectory(at: templatesDir, withIntermediateDirectories: true) + let fileName = sourceURL.pathExtension.isEmpty ? id : "\(id).\(sourceURL.pathExtension)" + let destinationURL = templatesDir.appendingPathComponent(fileName) + guard sourceURL.standardizedFileURL != destinationURL.standardizedFileURL else { + return destinationURL + } + + let didStartAccessing = sourceURL.startAccessingSecurityScopedResource() + defer { + if didStartAccessing { + sourceURL.stopAccessingSecurityScopedResource() + } + } + + let stagingURL = templatesDir.appendingPathComponent(".\(UUID().uuidString).tmp") + defer { try? FileManager.default.removeItem(at: stagingURL) } + try FileManager.default.copyItem(at: sourceURL, to: stagingURL) + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + try FileManager.default.moveItem(at: stagingURL, to: destinationURL) + + if previousURL?.standardizedFileURL != destinationURL.standardizedFileURL { + try removeManagedTemplate(at: previousURL, templatesDir: templatesDir) + } + return destinationURL + } + + private func removeManagedTemplate(at url: URL?, templatesDir: URL) throws { + guard let url, + url.deletingLastPathComponent().standardizedFileURL == templatesDir.standardizedFileURL, + FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } +} + +private enum TemplateStorageError: Error { + case applicationSupportUnavailable } diff --git a/RClick/Settings/GeneralSettingsTabView.swift b/RClick/Settings/GeneralSettingsTabView.swift index a209980..1dc1138 100644 --- a/RClick/Settings/GeneralSettingsTabView.swift +++ b/RClick/Settings/GeneralSettingsTabView.swift @@ -17,7 +17,6 @@ struct GeneralSettingsTabView: View { @AppLog(category: "settings-general") private var logger - @AppStorage("launchAtLogin") private var launchAtLogin = false @AppStorage(Key.showMenuBarExtra, store: .group) private var showMenuBarExtra = true @EnvironmentObject var store: AppState @@ -57,7 +56,7 @@ struct GeneralSettingsTabView: View { Text(appLocalized: "Show icon in menu bar") } - Toggle(isOn: $launchAtLogin) { + LaunchAtLogin.Toggle { Text(appLocalized: "Launch at login") } @@ -270,36 +269,96 @@ struct GeneralSettingsTabView: View { private func exportSettings() { let savePanel = NSSavePanel() - savePanel.allowedContentTypes = [.propertyList] - savePanel.nameFieldStringValue = "RClick_Settings.plist" + savePanel.allowedContentTypes = [.json] + savePanel.nameFieldStringValue = "RClick_Settings.json" savePanel.begin { response in guard response == .OK, let url = savePanel.url else { return } - // TODO: 实现设置导出逻辑 - logger.info("导出设置到:\(url.path)") + do { + try DataMigrationManager.shared.exportSettings(to: url, appState: store) + logger.info("导出设置到:\(url.path)") + showSettingsManagementAlert( + title: AppLocalization.localized("Settings Exported"), + message: AppLocalization.localized("Settings exported successfully.") + ) + } catch { + logger.error("导出设置失败:\(error.localizedDescription)") + showSettingsManagementAlert( + title: AppLocalization.localized("Export Failed"), + message: String(format: AppLocalization.localized("Export failed: %@"), error.localizedDescription), + style: .warning + ) + } } } private func importSettings() { let openPanel = NSOpenPanel() - openPanel.allowedContentTypes = [.propertyList] + openPanel.allowedContentTypes = [.json] openPanel.canChooseFiles = true openPanel.canChooseDirectories = false openPanel.allowsMultipleSelection = false openPanel.begin { response in guard response == .OK, let url = openPanel.url else { return } - // TODO: 实现设置导入逻辑 - logger.info("从以下路径导入设置:\(url.path)") + guard confirmSettingsImport() else { return } + + do { + try DataMigrationManager.shared.importSettings(from: url, appState: store) + logger.info("从以下路径导入设置:\(url.path)") + showSettingsManagementAlert( + title: AppLocalization.localized("Settings Imported"), + message: AppLocalization.localized("Settings imported successfully. Some changes may require restarting RClick.") + ) + } catch { + logger.error("导入设置失败:\(error.localizedDescription)") + showSettingsManagementAlert( + title: AppLocalization.localized("Import Failed"), + message: String(format: AppLocalization.localized("Import failed: %@"), error.localizedDescription), + style: .warning + ) + } } } + private func confirmSettingsImport() -> Bool { + let alert = NSAlert() + alert.messageText = AppLocalization.localized("Import Settings?") + alert.informativeText = AppLocalization.localized("Importing will replace your current RClick settings. This action cannot be undone.") + alert.alertStyle = .warning + alert.addButton(withTitle: AppLocalization.localized("Replace Settings")) + alert.addButton(withTitle: AppLocalization.localized("Cancel")) + return alert.runModal() == .alertFirstButtonReturn + } + + private func showSettingsManagementAlert(title: String, message: String, style: NSAlert.Style = .informational) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = style + alert.addButton(withTitle: AppLocalization.localized("OK")) + alert.runModal() + } + private func exportLogs() { let savePanel = NSSavePanel() savePanel.allowedContentTypes = [.plainText] savePanel.nameFieldStringValue = "RClick_Log.txt" savePanel.begin { response in guard response == .OK, let url = savePanel.url else { return } - // TODO: 实现日志导出逻辑 - logger.info("导出日志到:\(url.path)") + do { + try AppLogExporter.exportCurrentProcess(to: url) + logger.info("导出日志到:\(url.path)") + showSettingsManagementAlert( + title: AppLocalization.localized("Logs Exported"), + message: AppLocalization.localized("Logs exported successfully.") + ) + } catch { + logger.error("导出日志失败:\(error.localizedDescription)") + showSettingsManagementAlert( + title: AppLocalization.localized("Log Export Failed"), + message: String(format: AppLocalization.localized("Log export failed: %@"), error.localizedDescription), + style: .warning + ) + } } } @@ -314,8 +373,22 @@ struct GeneralSettingsTabView: View { let response = alert.runModal() if response == .alertFirstButtonReturn { - // TODO: 实现重置逻辑 - logger.info("重置所有设置") + do { + try DataMigrationManager.shared.resetSettings(appState: store) + showMenuBarExtra = true + logger.info("重置所有设置") + showSettingsManagementAlert( + title: AppLocalization.localized("Settings Reset"), + message: AppLocalization.localized("All settings were restored to their defaults.") + ) + } catch { + logger.error("重置设置失败:\(error.localizedDescription)") + showSettingsManagementAlert( + title: AppLocalization.localized("Reset Failed"), + message: String(format: AppLocalization.localized("Reset failed: %@"), error.localizedDescription), + style: .warning + ) + } } } } diff --git a/RClick/Shared/AppLogger.swift b/RClick/Shared/AppLogger.swift index b104081..185a81b 100644 --- a/RClick/Shared/AppLogger.swift +++ b/RClick/Shared/AppLogger.swift @@ -5,6 +5,7 @@ // Created by 李旭 on 2024/4/25. // +import Foundation import OSLog @@ -21,3 +22,23 @@ struct AppLog { return logger } } + +enum AppLogExporter { + static func exportCurrentProcess(to url: URL, since startDate: Date = Date().addingTimeInterval(-24 * 60 * 60)) throws { + let store = try OSLogStore(scope: .currentProcessIdentifier) + let position = store.position(date: startDate) + let subsystem = Bundle.main.bundleIdentifier ?? "RClick" + let formatter = ISO8601DateFormatter() + + let lines = try store.getEntries(at: position).compactMap { entry -> String? in + guard let logEntry = entry as? OSLogEntryLog, + logEntry.subsystem == subsystem else { return nil } + return "\(formatter.string(from: logEntry.date)) [\(logEntry.level)] [\(logEntry.category)] \(logEntry.composedMessage)" + } + + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "Unknown" + let header = "RClick \(version) (\(build))\nExported: \(formatter.string(from: Date()))\n\n" + try (header + lines.joined(separator: "\n") + "\n").write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/RClick/Shared/DataMigrationManager.swift b/RClick/Shared/DataMigrationManager.swift index fb4262e..f1f2b86 100644 --- a/RClick/Shared/DataMigrationManager.swift +++ b/RClick/Shared/DataMigrationManager.swift @@ -9,6 +9,264 @@ import Foundation import SwiftData import OSLog +enum SettingsBackupError: LocalizedError { + case unsupportedVersion(Int) + + var errorDescription: String? { + switch self { + case .unsupportedVersion(let version): + return "Unsupported settings backup version: \(version)" + } + } +} + +struct SettingsBackup: Codable { + static let currentVersion = 1 + + var version: Int + var exportedAt: Date + var appVersion: String + var apps: [SettingsBackupApp] + var actions: [SettingsBackupAction] + var newFiles: [SettingsBackupNewFile] + var commonDirs: [SettingsBackupCommonDir] + var preferences: SettingsBackupPreferences + + @MainActor + init(appState: AppState) { + self.version = Self.currentVersion + self.exportedAt = Date() + self.appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" + self.apps = appState.apps.map { SettingsBackupApp(from: $0) } + self.actions = appState.actions.map { SettingsBackupAction(from: $0) } + self.newFiles = appState.newFiles.map { SettingsBackupNewFile(from: $0) } + self.commonDirs = appState.cdirs.map { SettingsBackupCommonDir(from: $0) } + self.preferences = SettingsBackupPreferences(appState: appState) + } +} + +struct SettingsBackupApp: Codable { + var id: String + var path: String + var itemName: String + var inheritFromGlobalArguments: Bool + var inheritFromGlobalEnvironment: Bool + var arguments: [String] + var environment: [String: String] + + @MainActor + init(from app: OpenWithApp) { + self.id = app.id + self.path = app.url.path(percentEncoded: false) + self.itemName = app.itemName + self.inheritFromGlobalArguments = app.inheritFromGlobalArguments + self.inheritFromGlobalEnvironment = app.inheritFromGlobalEnvironment + self.arguments = app.arguments + self.environment = app.environment + } + + @MainActor + func toModel() -> OpenWithApp { + var app = OpenWithApp(id: id, appURL: URL(fileURLWithPath: path)) + app.itemName = itemName + app.inheritFromGlobalArguments = inheritFromGlobalArguments + app.inheritFromGlobalEnvironment = inheritFromGlobalEnvironment + app.arguments = arguments + app.environment = environment + return app + } +} + +struct SettingsBackupAction: Codable { + var id: String + var name: String + var enabled: Bool + var idx: Int + var icon: String + + @MainActor + init(from action: RCAction) { + self.id = action.id + self.name = action.name + self.enabled = action.enabled + self.idx = action.idx + self.icon = action.icon + } + + @MainActor + func toModel() -> RCAction { + RCAction(id: id, name: name, enabled: enabled, idx: idx, icon: icon) + } +} + +struct SettingsBackupNewFile: Codable { + var id: String + var ext: String + var name: String + var enabled: Bool + var idx: Int + var icon: String + var openAppPath: String? + var templatePath: String? + + @MainActor + init(from newFile: NewFile) { + self.id = newFile.id + self.ext = newFile.ext + self.name = newFile.name + self.enabled = newFile.enabled + self.idx = newFile.idx + self.icon = newFile.icon + self.openAppPath = newFile.openApp?.path(percentEncoded: false) + self.templatePath = newFile.template?.path(percentEncoded: false) + } + + @MainActor + func toModel() -> NewFile { + var newFile = NewFile(ext: ext, name: name, enabled: enabled, idx: idx, icon: icon, id: id) + if let openAppPath, !openAppPath.isEmpty { + newFile.openApp = URL(fileURLWithPath: openAppPath) + } + if let templatePath, !templatePath.isEmpty { + newFile.template = URL(fileURLWithPath: templatePath) + } + return newFile + } +} + +struct SettingsBackupCommonDir: Codable { + var id: String + var name: String + var path: String + var icon: String + + @MainActor + init(from commonDir: CommonDir) { + self.id = commonDir.id + self.name = commonDir.name + self.path = commonDir.url.path(percentEncoded: false) + self.icon = commonDir.icon + } + + @MainActor + func toModel() -> CommonDir { + CommonDir(id: id, name: name, url: URL(fileURLWithPath: path), icon: icon) + } +} + +struct SettingsBackupPreferences: Codable { + var launchAtLogin: Bool + var foldAppsMenu: Bool + var foldActionsMenu: Bool + var foldNewFileMenu: Bool + var foldCommonDirMenu: Bool + var showCommonDirs: Bool + var showCopyToCommonDirs: Bool + var showMoveToCommonDirs: Bool + var showMenuBarExtra: Bool + var showInDock: Bool + var showDockIcon: Bool + var showContextualMenuForItem: Bool + var showContextualMenuForContainer: Bool + var showContextualMenuForSidebar: Bool + var showToolbarItemMenu: Bool + var globalApplicationArgumentsString: String + var globalApplicationEnvironmentString: String + var copySeparator: String + var newFileName: String + var newFileExtension: String + var showSubMenuForApplication: Bool + var showSubMenuForAction: Bool + var hasSeenFullDiskAccessGuide: Bool + var selectedLanguage: String + var ignoredVersionDataBase64: String? + + @MainActor + init(appState: AppState) { + let group = UserDefaults.group + let standard = UserDefaults.standard + + self.launchAtLogin = LaunchAtLogin.isEnabled + self.foldAppsMenu = appState.foldAppsMenu + self.foldActionsMenu = appState.foldActionsMenu + self.foldNewFileMenu = appState.foldNewFileMenu + self.foldCommonDirMenu = appState.foldCommonDirMenu + self.showCommonDirs = appState.showCommonDirs + self.showCopyToCommonDirs = appState.showCopyToCommonDirs + self.showMoveToCommonDirs = appState.showMoveToCommonDirs + self.showMenuBarExtra = group.backupBool(forKey: Key.showMenuBarExtra, default: true) + self.showInDock = group.backupBool(forKey: Key.showInDock, default: false) + self.showDockIcon = group.backupBool(forKey: Key.showDockIcon, default: false) + self.showContextualMenuForItem = group.backupBool(forKey: Key.showContextualMenuForItem, default: true) + self.showContextualMenuForContainer = group.backupBool(forKey: Key.showContextualMenuForContainer, default: true) + self.showContextualMenuForSidebar = group.backupBool(forKey: Key.showContextualMenuForSidebar, default: true) + self.showToolbarItemMenu = group.backupBool(forKey: Key.showToolbarItemMenu, default: true) + self.globalApplicationArgumentsString = group.backupString(forKey: Key.globalApplicationArgumentsString, default: "") + self.globalApplicationEnvironmentString = group.backupString(forKey: Key.globalApplicationEnvironmentString, default: "") + self.copySeparator = group.backupString(forKey: Key.copySeparator, default: "") + self.newFileName = group.backupString(forKey: Key.newFileName, default: "Untitled") + self.newFileExtension = group.backupString(forKey: Key.newFileExtension, default: "") + self.showSubMenuForApplication = group.backupBool(forKey: Key.showSubMenuForApplication, default: false) + self.showSubMenuForAction = group.backupBool(forKey: Key.showSubMenuForAction, default: false) + self.hasSeenFullDiskAccessGuide = group.backupBool(forKey: Key.hasSeenFDAGuide, default: false) + self.selectedLanguage = appState.selectedLanguage.rawValue + + if let ignoredVersionData = standard.data(forKey: "ignoredVersion"), !ignoredVersionData.isEmpty { + self.ignoredVersionDataBase64 = ignoredVersionData.base64EncodedString() + } + } + + @MainActor + func apply(to appState: AppState) { + let group = UserDefaults.group + let standard = UserDefaults.standard + + standard.set(launchAtLogin, forKey: "launchAtLogin") + LaunchAtLogin.isEnabled = launchAtLogin + + appState.foldAppsMenu = foldAppsMenu + appState.foldActionsMenu = foldActionsMenu + appState.foldNewFileMenu = foldNewFileMenu + appState.foldCommonDirMenu = foldCommonDirMenu + appState.showCommonDirs = showCommonDirs + appState.showCopyToCommonDirs = showCopyToCommonDirs + appState.showMoveToCommonDirs = showMoveToCommonDirs + appState.showMenuBar = showMenuBarExtra + + group.set(showMenuBarExtra, forKey: Key.showMenuBarExtra) + group.set(showInDock, forKey: Key.showInDock) + group.set(showDockIcon, forKey: Key.showDockIcon) + group.set(showContextualMenuForItem, forKey: Key.showContextualMenuForItem) + group.set(showContextualMenuForContainer, forKey: Key.showContextualMenuForContainer) + group.set(showContextualMenuForSidebar, forKey: Key.showContextualMenuForSidebar) + group.set(showToolbarItemMenu, forKey: Key.showToolbarItemMenu) + group.set(globalApplicationArgumentsString, forKey: Key.globalApplicationArgumentsString) + group.set(globalApplicationEnvironmentString, forKey: Key.globalApplicationEnvironmentString) + group.set(copySeparator, forKey: Key.copySeparator) + group.set(newFileName, forKey: Key.newFileName) + group.set(newFileExtension, forKey: Key.newFileExtension) + group.set(showSubMenuForApplication, forKey: Key.showSubMenuForApplication) + group.set(showSubMenuForAction, forKey: Key.showSubMenuForAction) + group.set(hasSeenFullDiskAccessGuide, forKey: Key.hasSeenFDAGuide) + + if let language = AppLanguage(rawValue: selectedLanguage) { + appState.selectedLanguage = language + } else { + group.set(selectedLanguage, forKey: Key.selectedLanguage) + } + + if let ignoredVersionDataBase64, + let ignoredVersionData = Data(base64Encoded: ignoredVersionDataBase64) { + standard.set(ignoredVersionData, forKey: "ignoredVersion") + } else { + standard.removeObject(forKey: "ignoredVersion") + } + + standard.synchronize() + group.synchronize() + } +} + /// 数据迁移管理器 - 负责将UserDefaults中的数据迁移到SwiftData @MainActor class DataMigrationManager { @@ -162,6 +420,112 @@ class DataMigrationManager { logger.info("旧数据已清理") } + func exportSettings(to url: URL, appState: AppState = .shared) throws { + var targetURL = url + if targetURL.pathExtension.isEmpty { + targetURL.appendPathExtension("json") + } + + let backup = SettingsBackup(appState: appState) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(backup) + try data.write(to: targetURL, options: .atomic) + logger.info("Settings exported to: \(targetURL.path)") + } + + @discardableResult + func importSettings(from url: URL, appState: AppState = .shared) throws -> SettingsBackup { + let didStartAccessing = url.startAccessingSecurityScopedResource() + defer { + if didStartAccessing { + url.stopAccessingSecurityScopedResource() + } + } + + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let backup = try decoder.decode(SettingsBackup.self, from: data) + + guard backup.version <= SettingsBackup.currentVersion else { + throw SettingsBackupError.unsupportedVersion(backup.version) + } + + try appState.replaceAllSettings( + apps: backup.apps.map { $0.toModel() }, + actions: backup.actions.map { $0.toModel() }, + newFiles: backup.newFiles.map { $0.toModel() }, + commonDirs: backup.commonDirs.map { $0.toModel() } + ) + backup.preferences.apply(to: appState) + NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) + logger.info("Settings imported from: \(url.path)") + + return backup + } + + func resetSettings(appState: AppState = .shared) throws { + let defaultCommonDirs = CommonDirEntity.createDefaultCommonDirs().map { + CommonDir(id: $0.id, name: $0.name, url: $0.path, icon: $0.icon) + } + try appState.replaceAllSettings( + apps: OpenWithApp.defaultApps, + actions: RCAction.all, + newFiles: NewFile.all, + commonDirs: defaultCommonDirs + ) + + appState.foldAppsMenu = false + appState.foldActionsMenu = false + appState.foldNewFileMenu = true + appState.foldCommonDirMenu = true + appState.showCommonDirs = false + appState.showCopyToCommonDirs = true + appState.showMoveToCommonDirs = true + appState.showMenuBar = true + appState.selectedLanguage = .automatic + + let standard = UserDefaults.standard + standard.removeObject(forKey: "launchAtLogin") + standard.removeObject(forKey: "ignoredVersion") + LaunchAtLogin.isEnabled = false + + let group = UserDefaults.group + let keysToRemove = [ + Key.showContextualMenuForItem, + Key.showContextualMenuForContainer, + Key.showContextualMenuForSidebar, + Key.showToolbarItemMenu, + Key.showDockIcon, + Key.globalApplicationArgumentsString, + Key.globalApplicationEnvironmentString, + Key.copySeparator, + Key.newFileName, + Key.newFileExtension, + Key.showSubMenuForApplication, + Key.showSubMenuForAction, + Key.showInDock, + Key.hasSeenFDAGuide, + Key.actionMenuItems, + Key.appMenuItems, + ] + keysToRemove.forEach { group.removeObject(forKey: $0) } + group.set(true, forKey: Key.showMenuBarExtra) + + if let applicationSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + let templatesURL = applicationSupport.appendingPathComponent("RClick/Templates") + if FileManager.default.fileExists(atPath: templatesURL.path) { + try FileManager.default.removeItem(at: templatesURL) + } + } + + standard.synchronize() + group.synchronize() + NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) + } + /// 备份UserDefaults数据到文件 func backupUserDefaults() -> URL? { let dateFormatter = ISO8601DateFormatter() @@ -199,3 +563,13 @@ class DataMigrationManager { } } } + +private extension UserDefaults { + func backupBool(forKey key: String, default defaultValue: Bool) -> Bool { + object(forKey: key) as? Bool ?? defaultValue + } + + func backupString(forKey key: String, default defaultValue: String) -> String { + object(forKey: key) as? String ?? defaultValue + } +} diff --git a/RClick/Shared/LaunchAtLogin.swift b/RClick/Shared/LaunchAtLogin.swift index 861fe4e..7635406 100644 --- a/RClick/Shared/LaunchAtLogin.swift +++ b/RClick/Shared/LaunchAtLogin.swift @@ -25,13 +25,9 @@ public enum LaunchAtLogin { observable.objectWillChange.send() do { - if newValue { - if SMAppService.mainApp.status == .enabled { - try? SMAppService.mainApp.unregister() - } - + if newValue, SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() - } else { + } else if !newValue, SMAppService.mainApp.status == .enabled { try SMAppService.mainApp.unregister() } } catch { @@ -139,4 +135,3 @@ extension LaunchAtLogin.Toggle { self.init("Launch at login") } } - diff --git a/RClick/Shared/Updater.swift b/RClick/Shared/Updater.swift index 68c4609..0c7c4ae 100644 --- a/RClick/Shared/Updater.swift +++ b/RClick/Shared/Updater.swift @@ -318,8 +318,10 @@ class UpdateManager: ObservableObject { private func extractAppZip(zipURL: URL) async throws -> URL { let tempDir = FileManager.default.temporaryDirectory let extractionDir = tempDir.appendingPathComponent("app_extraction") - - // 创建解压目录 + + if FileManager.default.fileExists(atPath: extractionDir.path) { + try FileManager.default.removeItem(at: extractionDir) + } try FileManager.default.createDirectory(at: extractionDir, withIntermediateDirectories: true) // 使用系统命令解压 @@ -403,9 +405,8 @@ class UpdateManager: ObservableObject { throw InstallationError.invalidAppBundle(AppLocalization.localized("The application bundle is invalid or damaged.")) } } catch { - print("❌ 安装失败: \(error)") + throw error } - } // MARK: - 显示安装完成提示 diff --git a/RClickTests/RClickTests.swift b/RClickTests/RClickTests.swift index 0b92953..5e6a447 100644 --- a/RClickTests/RClickTests.swift +++ b/RClickTests/RClickTests.swift @@ -9,11 +9,31 @@ import Testing @testable import RClick struct RClickTests { + private struct TestPayload: Codable { + let values: [String: Int] + } + + @Test @MainActor func signedPayloadAcceptsUntamperedContent() throws { + let signed = try MessageSecurity.sign(TestPayload(values: ["alpha": 1, "beta": 2])) + #expect(MessageSecurity.verify(signed)) + } - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - // Swift Testing Documentation - // https://developer.apple.com/documentation/testing + @Test @MainActor func signedPayloadRejectsReplacedContent() throws { + let signed = try MessageSecurity.sign(TestPayload(values: ["safe": 1])) + let tampered = SignedPayload( + payload: TestPayload(values: ["delete": 1]), + signature: signed.signature, + jsonData: signed.jsonData + ) + #expect(!MessageSecurity.verify(tampered)) } + @Test @MainActor func newFileMenuIncludesBlankFileAndEnabledTypesOnly() { + let enabled = NewFile(ext: ".txt", name: "Text", enabled: true, idx: 0, id: "enabled") + let disabled = NewFile(ext: ".md", name: "Markdown", enabled: false, idx: 1, id: "disabled") + + let items = NewFileMenuItem.configuredItems(from: [enabled, disabled]) + + #expect(items.map(\.id) == [NewFileMenuItem.customFileId, "enabled"]) + } } diff --git a/Shared/MessageSecurity.swift b/Shared/MessageSecurity.swift index 89e71e2..277a2fe 100644 --- a/Shared/MessageSecurity.swift +++ b/Shared/MessageSecurity.swift @@ -25,12 +25,18 @@ public class MessageSecurity { // 共享密钥 - 应存储在 Keychain 中,这里使用常量简化实现 private static let sharedKey = "RClick_IPC_SharedKey_2026_v1" + private static func encode(_ payload: T) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(payload) + } + /// 为消息载荷添加 HMAC 签名 /// - Parameter payload: 需要签名的消息载荷 /// - Returns: 带签名的消息结构 /// - Throws: 如果编码失败则抛出错误 public static func sign(_ payload: T) throws -> SignedPayload { - let data = try JSONEncoder().encode(payload) + let data = try encode(payload) let key = SymmetricKey(data: sharedKey.data(using: .utf8)!) let hmac = HMAC.authenticationCode(for: data, using: key) @@ -50,9 +56,10 @@ public class MessageSecurity { return false } - // 从保存的 jsonData 恢复原始数据 - guard let payloadData = Data(base64Encoded: signed.jsonData) else { - logger.error("Failed to decode jsonData from base64") + guard let storedPayloadData = Data(base64Encoded: signed.jsonData), + let payloadData = try? encode(signed.payload), + storedPayloadData == payloadData else { + logger.error("Signed payload does not match its authenticated data") return false } diff --git a/Shared/Messager.swift b/Shared/Messager.swift index 44fec28..dc6e426 100644 --- a/Shared/Messager.swift +++ b/Shared/Messager.swift @@ -49,6 +49,12 @@ struct MenuConfigPayload: Codable { let newFiles: [NewFileMenuItem] /// 常用目录菜单项列表 let commonDirs: [CommonDirMenuItem] + /// 是否显示常用目录打开菜单(nil 表示旧版协议:有常用目录就显示) + let showCommonDirs: Bool? + /// 是否显示复制到常用目录菜单(nil 表示旧版协议:有常用目录就显示) + let showCopyToCommonDirs: Bool? + /// 是否显示移动到常用目录菜单(nil 表示旧版协议:有常用目录就显示) + let showMoveToCommonDirs: Bool? /// 是否折叠动作菜单(默认 false) let actionsCollapsed: Bool /// 是否折叠应用菜单(默认 false) @@ -64,6 +70,9 @@ struct MenuConfigPayload: Codable { apps: [AppMenuItem] = [], newFiles: [NewFileMenuItem] = [], commonDirs: [CommonDirMenuItem] = [], + showCommonDirs: Bool? = nil, + showCopyToCommonDirs: Bool? = nil, + showMoveToCommonDirs: Bool? = nil, actionsCollapsed: Bool = false, appsCollapsed: Bool = false, newFilesCollapsed: Bool = true, @@ -74,11 +83,26 @@ struct MenuConfigPayload: Codable { self.apps = apps self.newFiles = newFiles self.commonDirs = commonDirs + self.showCommonDirs = showCommonDirs + self.showCopyToCommonDirs = showCopyToCommonDirs + self.showMoveToCommonDirs = showMoveToCommonDirs self.actionsCollapsed = actionsCollapsed self.appsCollapsed = appsCollapsed self.newFilesCollapsed = newFilesCollapsed self.commonDirsCollapsed = commonDirsCollapsed } + + var shouldShowCommonDirs: Bool { + showCommonDirs ?? !commonDirs.isEmpty + } + + var shouldShowCopyToCommonDirs: Bool { + showCopyToCommonDirs ?? !commonDirs.isEmpty + } + + var shouldShowMoveToCommonDirs: Bool { + showMoveToCommonDirs ?? !commonDirs.isEmpty + } } /// 点击事件消息载荷 @@ -123,6 +147,8 @@ enum MenuItemType: String, Codable { case app = "app" // 应用菜单 case newFile = "new-file" // 新建文件 case commonDir = "common-dir" // 常用目录 + case copyToCommonDir = "copy-to-common-dir" // 复制到常用目录 + case moveToCommonDir = "move-to-common-dir" // 移动到常用目录 } /// 触发来源 diff --git a/Shared/RCBase.swift b/Shared/RCBase.swift index f8eda7a..b330ad2 100644 --- a/Shared/RCBase.swift +++ b/Shared/RCBase.swift @@ -202,12 +202,24 @@ struct RCAction: @MainActor RCBase { extension RCAction { static let copyPath = RCAction(id: "copy-path", name: "Copy Path", enabled: true, idx: 0, icon: "doc.on.doc") - static let deleteDirect = RCAction(id: "delete-direct", name: "Delete Direct", enabled: true, idx: 1, icon: "trash") - static let hideFileDir = RCAction(id: "hide", name: "Hide", enabled: false, idx: 2, icon: "eye.slash") - static let unhideFileDir = RCAction(id: "unhide", name: "Unhide", enabled: false, idx: 3, icon: "eye") - static let airdrop = RCAction(id: "airdrop", name: "AirDrop", enabled: false, idx: 4, icon: "paperplane") - - static let all: [RCAction] = [.copyPath, .deleteDirect, .airdrop, .hideFileDir, .unhideFileDir] + static let copyFileName = RCAction(id: "copy-file-name", name: "Copy File Name", enabled: true, idx: 1, icon: "doc.text") + static let copyParentPath = RCAction(id: "copy-parent-path", name: "Copy Parent Path", enabled: true, idx: 2, icon: "folder") + static let putIntoNewFolder = RCAction(id: "put-into-new-folder", name: "Put Into New Folder", enabled: true, idx: 3, icon: "folder.badge.plus") + static let deleteDirect = RCAction(id: "delete-direct", name: "Delete Direct", enabled: true, idx: 4, icon: "trash") + static let hideFileDir = RCAction(id: "hide", name: "Hide", enabled: false, idx: 5, icon: "eye.slash") + static let unhideFileDir = RCAction(id: "unhide", name: "Unhide", enabled: false, idx: 6, icon: "eye") + static let airdrop = RCAction(id: "airdrop", name: "AirDrop", enabled: false, idx: 7, icon: "paperplane") + + static let all: [RCAction] = [ + .copyPath, + .copyFileName, + .copyParentPath, + .putIntoNewFolder, + .deleteDirect, + .airdrop, + .hideFileDir, + .unhideFileDir + ] } // New File Type @@ -302,6 +314,22 @@ struct NewFileMenuItem: Codable { } } +extension NewFileMenuItem { + @MainActor + static func configuredItems(from newFiles: [NewFile]) -> [NewFileMenuItem] { + let blankFile = NewFileMenuItem( + id: customFileId, + name: AppLocalization.localized("Create Blank File"), + ext: "", + icon: "doc.badge.plus" + ) + let enabledFileTypes = newFiles.filter(\.enabled).map { + NewFileMenuItem(id: $0.id, name: $0.name, ext: $0.ext, icon: $0.icon) + } + return [blankFile] + enabledFileTypes + } +} + /// Menu item for common directories struct CommonDirMenuItem: Codable { let id: String @@ -324,6 +352,9 @@ extension RCAction { var displayName: String { switch id { case "copy-path": return AppLocalization.localized("Copy Path") + case "copy-file-name": return AppLocalization.localized("Copy File Name") + case "copy-parent-path": return AppLocalization.localized("Copy Parent Path") + case "put-into-new-folder": return AppLocalization.localized("Put Into New Folder") case "delete-direct": return AppLocalization.localized("Delete Direct") case "hide": return AppLocalization.localized("Hide") case "unhide": return AppLocalization.localized("Unhide")